import CoreGraphics

/// Where a drag would land, as pure arithmetic — no views, no session, no snapshot
/// (`DropSlotMathTests`). The full model this implements is **DRAG-REORDER.md** at the repository
/// root; the reasoning is reproduced here only where a signature would otherwise be a puzzle.
///
/// Three ideas run through everything below:
///
/// - **Resting-layout zones.** The proposal is an insertion index into the *resting* layout — the
///   visible siblings laid out with the dragged run removed and no placeholder inserted. Slot `i`'s
///   zone is item `i`'s whole extent plus half the inter-item gap on each side; zones tile the
///   container, so a zone is entered exactly at its border and left only by entering another. The
///   zones are computed **analytically** — from unit counts and frozen heights, never from measured
///   frames — because measured frames are garbage precisely during the ~0.18s reflow a proposal
///   change triggers (03-board-ui.md § Motion, "motion never feeds back into logic").
/// - **Span-capped triggers.** Slot `i` triggers only while the cursor is over the span the dragged
///   run would *actually occupy* if dropped there — the shadow run's future footprint. The far side
///   of a wider item's zone is a **dead region** (04-interactions.md ▸ Drag and drop: "no reflow
///   until the cursor reaches where the dragged lane would actually land").
/// - **Hysteresis, spelled `nil`.** A dead region returns `nil`, which means *hold the current
///   proposal* — not "propose nothing" and not the caller's own value echoed back. The one
///   exception is a dead region hovered with no valid prior proposal (a fresh cross-board entry):
///   a drag in flight over a live target must always have *some* landing spot, so the containing
///   slot is proposed anyway.
///
/// Multi-drag is a single index for the whole run: the dragged items insert contiguously there, in
/// preserved flatten order (`SelectionGrammar.liveCards`). Nothing here knows how many shadows get
/// drawn — only how wide the run is (`draggedSpan`), which is what the cap is measured in.
enum DropSlotMath {

    // MARK: - Zones (one axis, shared by both layouts)

    /// The boundaries separating consecutive slot zones, ascending, computed from the items'
    /// extents in the resting layout.
    ///
    /// `boundaries[i]` separates slot `i` from slot `i + 1`: for interior neighbours it is the
    /// midpoint of the gap between item `i` and item `i + 1` ("half the gap on each side"); the
    /// final boundary is the last item's trailing edge plus half a `gap`, beyond which lies the end
    /// slot.
    ///
    /// - Parameters:
    ///   - extents: each visible item's span along the layout axis, in resting positions with the
    ///     dragged run already removed, ascending.
    ///   - gap: the layout's inter-item spacing.
    static func zoneBoundaries(extents: [ClosedRange<CGFloat>], gap: CGFloat) -> [CGFloat] {
        guard !extents.isEmpty else { return [] }
        var boundaries: [CGFloat] = []
        for index in 0..<(extents.count - 1) {
            boundaries.append((extents[index].upperBound + extents[index + 1].lowerBound) / 2)
        }
        boundaries.append(extents[extents.count - 1].upperBound + gap / 2)
        return boundaries
    }

    /// The slot (`0...boundaries.count`) whose zone contains `cursor` — the uncapped reading,
    /// before any span cap applies.
    ///
    /// - Parameters:
    ///   - cursor: pointer position along the layout axis, in `boundaries`' coordinate space.
    ///   - boundaries: `zoneBoundaries(extents:gap:)`, ascending.
    ///   - current: the currently proposed slot (`nil` when there is none). Consulted **only** to
    ///     break the tie when `cursor` sits on an exact boundary value: if `current` is one of the
    ///     two zones meeting there it is kept, so the shadow can never oscillate on a boundary
    ///     pixel.
    static func containingSlot(cursor: CGFloat, boundaries: [CGFloat], current: Int?) -> Int {
        let count = boundaries.count
        guard count > 0 else { return 0 }

        // Exact-boundary tie: the zones meeting at `cursor` are `b` and `b + 1`; keep the current
        // proposal if it is one of them.
        if let current,
           let boundaryIndex = boundaries.firstIndex(of: cursor),
           current == boundaryIndex || current == boundaryIndex + 1 {
            return current
        }

        // Otherwise the containing zone: how many boundaries sit at or below the cursor (a zone is
        // entered exactly at its border).
        var index = 0
        while index < count, cursor >= boundaries[index] { index += 1 }
        return index
    }

    /// The span-capped slot for `cursor`, or `nil` to **hold** the current proposal.
    ///
    /// Slot `i` triggers only while the cursor is over `[leading(i) − gap/2, leading(i) +
    /// draggedSpan + gap/2]` — where the dragged run would sit after a drop there. Past that the
    /// zone is dead and this answers `nil`, so dragging a 1× lane across a 3× lane does not reflow
    /// while the cursor is over the 3× lane's far side; the shadow stays where it was until the
    /// cursor reaches a spot the run could really land.
    ///
    /// Two slots are never capped: the **end slot** (past the last item — appending is the only
    /// reading) and, by construction rather than by a special case, the region **before the first
    /// item** (the cap only ever truncates a zone's far side, and slot 0's far side is inside the
    /// container).
    ///
    /// - Parameters:
    ///   - cursor: pointer position along the layout axis.
    ///   - extents: the visible items' resting spans with the dragged run removed, ascending.
    ///   - gap: the layout's inter-item spacing.
    ///   - draggedSpan: the dragged run's total extent when laid out — the sum of its items' spans
    ///     plus the gaps between them.
    ///   - current: the currently proposed slot, or `nil`. A dead region with no valid `current`
    ///     proposes the containing slot (the fresh-entry rule); with one, it answers `nil`.
    /// - Returns: a slot in `0...extents.count`, or `nil` meaning "no change".
    static func slot(
        cursor: CGFloat,
        extents: [ClosedRange<CGFloat>],
        gap: CGFloat,
        draggedSpan: CGFloat,
        current: Int?
    ) -> Int? {
        guard !extents.isEmpty else { return 0 }
        let boundaries = zoneBoundaries(extents: extents, gap: gap)
        let index = containingSlot(cursor: cursor, boundaries: boundaries, current: current)
        guard index < extents.count else { return index }   // end slot: uncapped

        let triggerStart = extents[index].lowerBound - gap / 2
        if cursor <= triggerStart + draggedSpan + gap { return index }

        // Dead region. Hold — unless there is nothing to hold, in which case the containing zone
        // is the answer: a drag in flight must always have some landing spot.
        guard let current, (0...extents.count).contains(current) else { return index }
        return nil
    }

    // MARK: - The lane strip

    /// The lanes' resting extents along the strip, in strip coordinates (0 at the strip's leading
    /// edge, the outer margin included) — the layout `unitCounts` would have if it were the whole
    /// strip.
    ///
    /// The strip's outer margin is one `gap`, so the first slot starts at `gap`; each lane is
    /// `LaneLayoutMath.slotWidth(units:standard:gap:)` wide and one `gap` follows it. Same
    /// arithmetic `LaneLayoutMath.laneIndex` walks, in range form.
    ///
    /// `unitCounts` is the **visible lanes minus the dragged run**. `standard` is *not* recomputed
    /// for that shorter list: it is a function of the board's unit total, and a lane in flight is
    /// still a lane on the board (DRAG-REORDER.md § The lane strip's resting layout is arithmetic).
    static func laneExtents(unitCounts: [Int], standard: CGFloat, gap: CGFloat) -> [ClosedRange<CGFloat>] {
        laneExtents(
            widths: unitCounts.map { LaneLayoutMath.slotWidth(units: $0, standard: standard, gap: gap) },
            gap: gap)
    }

    /// The same extents over **drawn** widths, which is what a strip holding collapsed lanes has: a
    /// slim strip's width is a fixed figure and not a multiple of the standard (03-board-ui.md § Lane ▸
    /// Collapsed lanes; `LaneLayoutMath.drawnWidths`).
    ///
    /// The primitive, with the unit-count version above as its wrapper — one walk, so a folded lane can
    /// only ever move a zone, never change how zones are built.
    static func laneExtents(widths: [CGFloat], gap: CGFloat) -> [ClosedRange<CGFloat>] {
        var extents: [ClosedRange<CGFloat>] = []
        var left = gap
        for width in widths {
            extents.append(left...(left + width))
            left += width + gap
        }
        return extents
    }

    /// The total extent a run of dragged lanes occupies when laid out — the sum of their slot
    /// widths plus the `n − 1` gaps between them. This is the span the trigger regions are capped
    /// at, and it is exactly the shadow run's future footprint.
    static func laneRunSpan(unitCounts: [Int], standard: CGFloat, gap: CGFloat) -> CGFloat {
        laneRunSpan(
            widths: unitCounts.map { LaneLayoutMath.slotWidth(units: $0, standard: standard, gap: gap) },
            gap: gap)
    }

    /// The same span over **drawn** widths — a dragged run that includes a collapsed lane occupies that
    /// lane's slim strip, not the slot its `width` key would have bought (see `laneExtents(widths:)`).
    static func laneRunSpan(widths: [CGFloat], gap: CGFloat) -> CGFloat {
        guard !widths.isEmpty else { return 0 }
        return widths.reduce(0, +) + gap * CGFloat(widths.count - 1)
    }

    /// Where a lane drag would land: an index into the ordered live lanes **with the dragged run
    /// removed**, or `nil` to hold the current proposal.
    ///
    /// - Parameters:
    ///   - cursorX: the pointer in strip coordinates. The *pointer*, not a measured replica frame
    ///     — 03-board-ui.md § Motion.
    ///   - restingUnits: the remaining lanes' display units (`LaneLayoutMath.displayUnits`), in
    ///     board order, recomputed against each snapshot rather than frozen at drag start so a
    ///     foreign lane add mid-drag just moves the zones (04-interactions.md ▸ Drag and drop,
    ///     rule 1).
    ///   - draggedUnits: the dragged lanes' display units, in the order they will land.
    ///   - standard: the 1× lane width (`LaneLayoutMath.standardWidth`) of the board being dropped
    ///     **into** — a cross-board arrival is measured in the destination's units.
    ///   - gap: the inter-lane gap, which is also the strip's outer margin.
    ///   - current: the currently proposed index, or `nil`.
    static func laneSlot(
        cursorX: CGFloat,
        restingUnits: [Int],
        draggedUnits: [Int],
        standard: CGFloat,
        gap: CGFloat,
        current: Int?
    ) -> Int? {
        laneSlot(
            cursorX: cursorX,
            restingWidths: restingUnits.map { LaneLayoutMath.slotWidth(units: $0, standard: standard, gap: gap) },
            draggedWidths: draggedUnits.map { LaneLayoutMath.slotWidth(units: $0, standard: standard, gap: gap) },
            gap: gap,
            current: current
        )
    }

    /// The same answer from **drawn** widths — what the board actually asks, since a collapsed lane's
    /// slim strip is neither a unit count nor a multiple of the standard (03-board-ui.md § Lane ▸
    /// Collapsed lanes).
    ///
    /// A folded lane is an ordinary member of both lists: it occupies one zone in the resting strip like
    /// any other lane (so a reorder drags *across* it in one narrow zone rather than several), and a
    /// folded lane being dragged contributes its strip's width to the run's span cap, so the trigger
    /// region matches the footprint the drop will actually produce.
    static func laneSlot(
        cursorX: CGFloat,
        restingWidths: [CGFloat],
        draggedWidths: [CGFloat],
        gap: CGFloat,
        current: Int?
    ) -> Int? {
        slot(
            cursor: cursorX,
            extents: laneExtents(widths: restingWidths, gap: gap),
            gap: gap,
            draggedSpan: laneRunSpan(widths: draggedWidths, gap: gap),
            current: current
        )
    }

    // MARK: - The card masonry

    /// Which interior column `x` falls in — `0..<placement.columnCount`, clamped, so the lane's
    /// padding and the region above its grid target the nearest column rather than nothing.
    ///
    /// The bands tile: column `c` plus half a spacing on each side. `currentColumn` breaks an
    /// exact-boundary tie exactly as `containingSlot` does in 1D.
    static func columnIndex(atX x: CGFloat, placement: MasonryPlacement, currentColumn: Int?) -> Int {
        let count = placement.columnCount
        guard count > 1 else { return 0 }
        let boundaries = (0..<(count - 1)).map {
            placement.columnX($0) + placement.columnWidth + placement.spacing / 2
        }
        return containingSlot(cursor: x, boundaries: boundaries, current: currentColumn)
    }

    /// Where a card drag would land in a lane's masonry: a position in the lane's **logical** card
    /// order (`0...heights.count`), or `nil` to hold the current proposal.
    ///
    /// Cursor → proposal in three steps (DRAG-REORDER.md § The card masonry):
    ///
    /// 1. **Column** — the cursor's x-band picks interior column `c`, clamped inward at the edges.
    /// 2. **Row** — column `c`'s cards are the *contiguous* logical range `[start(c), start(c + 1))`
    ///    (`MasonryPlacement.columnStart(_:itemCount:)`); their vertical extents feed the *same*
    ///    span-capped 1D machinery the strip uses, with `draggedSpan` the first dragged card's
    ///    frozen height. Dead regions hold; the tail slot below the column's last card is uncapped,
    ///    as is the region above its first.
    /// 3. **Logical index** — column `c`, row `r` is position `start(c) + r`, and no clamp is
    ///    needed: `r` never exceeds the column's card count, so the answer never leaves
    ///    `0...heights.count`. A column's tail maps to `start(c + 1)` — the head of the next column,
    ///    a genuine mid-list position — so "below this column" proposes landing there rather than
    ///    appending. Only the *last* column's tail is the end slot, which is the honest reading now
    ///    that the columns are read in order.
    ///
    /// - Parameters:
    ///   - cursor: the pointer in the same space as `placement.origin`.
    ///   - placement: the lane's resting grid geometry.
    ///   - heights: the lane's rendered cards' heights **minus the dragged ones**, in logical
    ///     order, **frozen at drag start** — measured heights mid-flight are the animation-proof
    ///     rule's forbidden input.
    ///   - draggedHeight: the first dragged card's frozen height — the run's footprint at the
    ///     landing spot, which is the trigger rect the cursor is over.
    ///   - current: the currently proposed logical index, or `nil`.
    static func cardSlot(
        cursor: CGPoint,
        placement: MasonryPlacement,
        heights: [CGFloat],
        draggedHeight: CGFloat,
        current: Int?
    ) -> Int? {
        let count = heights.count
        guard count > 0 else { return 0 }

        // The proposal's own column, consulted only to settle an exact band tie. The end slot is
        // the last column's tail, so it names that column rather than no column at all.
        let currentColumn: Int? = {
            guard let current, (0...count).contains(current) else { return nil }
            return placement.column(of: current, itemCount: count)
        }()
        let column = columnIndex(atX: cursor.x, placement: placement, currentColumn: currentColumn)

        let frames = placement.frames(heights: heights)
        let start = placement.columnStart(column, itemCount: count)
        let end = placement.columnStart(column + 1, itemCount: count)
        let extents = (start..<end).map { frames[$0].minY...frames[$0].maxY }

        // The row this column would hold the current proposal at. `start...end` is exactly the set
        // of logical positions this column's rows name — its own cards' positions plus its tail —
        // so a proposal outside it belongs to another column, where a hold would be meaningless and
        // the answer is nothing.
        let currentRow: Int? = {
            guard let current, (start...end).contains(current) else { return nil }
            return current - start
        }()

        guard let row = slot(cursor: cursor.y, extents: extents, gap: placement.spacing,
                             draggedSpan: draggedHeight, current: currentRow)
        else { return nil }

        return placement.index(column: column, row: row, itemCount: count)
    }

    // MARK: - Applying a proposal

    /// `items` with the members at `moving` lifted out and re-inserted contiguously at `index`,
    /// where `index` is counted **with them already removed** — the convention every proposal and
    /// every drop commit in this app shares.
    ///
    /// The lifted members keep their given order (flatten order at the call sites), which is what
    /// "drop inserts contiguously in preserved relative order" means (04-interactions.md ▸ Drag and
    /// drop). Shared by the geometry's callers and by `BoardStore`'s no-op guard, so the shadow's
    /// arrangement and the arrangement the store refuses to rewrite can never disagree.
    static func applied<T: Equatable>(_ items: [T], moving: [T], to index: Int) -> [T] {
        var remaining = items.filter { !moving.contains($0) }
        let target = min(max(0, index), remaining.count)
        remaining.insert(contentsOf: moving, at: target)
        return remaining
    }
}

// MARK: - A Finder file drag's zones

/// Where an external **Finder file** drag resolves inside one lane, as pure arithmetic
/// (`FileDropZoneTests`) — the three answers a lane's own geometry gives it.
///
/// **Drops are positional everywhere** (04-interactions.md ▸ Drag and drop, settled 2026-07-28):
/// "created cards land at the drop position — resolved through the same card-grid zones an ordinary
/// card drag uses, shadow included", and append-at-bottom stays the creation *trio*'s rule (⌘N,
/// Return, a double click on empty space), not the drop's. So the create landing is
/// `DropSlotMath.cardSlot` and nothing else: the very function a card drag proposes through, with the
/// incoming run's **nominal** footprint standing in for the frozen height a card drag freezes at
/// pickup — the cards being proposed do not exist yet to have been measured, exactly as a cross-board
/// arrival's do not.
///
/// Kept out of `BoardDropContext` for `TrashDrop`'s reason: the ruling is then checkable without a
/// window, and the hover and the release read one answer rather than two that can drift.
enum FileDropZones {

    /// What the lane's geometry says the files would become.
    enum Landing: Equatable, Sendable {
        /// The cursor is over the card at this position in the lane's **logical** card order — the
        /// files join its `attachments/`. Attach beats create anywhere on a card's bounds.
        case attach(index: Int)
        /// One card per file, opening at this position in the logical card order.
        case create(index: Int)
        /// A dead region (`DropSlotMath.slot`'s `nil`): **hold** whatever the create slot already was.
        case hold
    }

    /// Resolves `cursor` against one lane, in three questions asked in this order.
    ///
    /// 1. **The lane header is the topmost position** (04-interactions.md ▸ Drag and drop, settled
    ///    2026-07-28: "a release on the lane header resolves to the topmost position — forgiving beats
    ///    a dead stripe: the header's chrome roles don't collide with a file payload"). The header
    ///    does not scroll, so its own edge is the honest boundary; the accent band and the plate's top
    ///    padding sit above it and are its chrome, which is why the test is *at or above* rather than
    ///    containment. It is asked **first** because a scrolled masonry can place a card's analytic
    ///    frame behind the header stripe, and the ruling admits no exception there.
    /// 2. **A card under the cursor always wins** over the lane behind it. The bounds are the resting
    ///    frames `MasonryPlacement.frames(heights:)` replays — the same reconstruction the slot zones
    ///    are built from, never a measured frame (03-board-ui.md § Motion). Closed containment, first
    ///    match wins, so the answer is deterministic however the frames abut.
    /// 3. **Otherwise the create slot**, from `DropSlotMath.cardSlot`.
    ///
    /// - Parameters:
    ///   - cursor: the pointer, in `placement.origin`'s space.
    ///   - headerBottom: the lane header's bottom edge in that same space, or `nil` for a lane whose
    ///     header has not registered a frame yet — where the masonry answers alone.
    ///   - placement: the lane's resting grid geometry.
    ///   - heights: the lane's rendered cards' heights, in logical order.
    ///   - nominalHeight: the height an incoming, unmeasured card is assumed to have — the span the
    ///     create zone's cap is measured against, and the height each shadow draws at.
    ///   - current: the create slot currently proposed for this lane, or `nil`.
    static func landing(
        cursor: CGPoint,
        headerBottom: CGFloat?,
        placement: MasonryPlacement,
        heights: [CGFloat],
        nominalHeight: CGFloat,
        current: Int?
    ) -> Landing {
        if let headerBottom, cursor.y <= headerBottom { return .create(index: 0) }

        let frames = placement.frames(heights: heights)
        if let index = frames.firstIndex(where: { frame in
            cursor.x >= frame.minX && cursor.x <= frame.maxX
                && cursor.y >= frame.minY && cursor.y <= frame.maxY
        }) {
            return .attach(index: index)
        }

        guard let slot = DropSlotMath.cardSlot(
            cursor: cursor,
            placement: placement,
            heights: heights,
            draggedHeight: nominalHeight,
            current: current
        ) else { return .hold }
        return .create(index: slot)
    }
}
