import CoreGraphics

// MARK: - Spatial navigation

/// The arrows' geometry — "nearest card in the direction, across interior grid columns and lanes"
/// (04-interactions.md ▸ Grammar), as a pure function of the drawn frames (`NavigationMathTests`).
///
/// **It reads the marquee's registry, deliberately.** The frames come from `MarqueeTargetRegistry`,
/// which the views populate with what they actually drew — so the keyboard and the rubber band
/// answer "where is that card" from one set of rectangles, and geometry can never disagree with
/// hit-testing. A second derivation off the masonry's arithmetic would be a second answer, and one
/// that a lane resize, a reorder in flight or a foreign reload could falsify.
///
/// **It is also how the hidden trash stays invisible for free**: a hidden column registers nothing,
/// so there is nothing here to filter out — 04's "hidden, it is invisible to every gesture" needs no
/// code of its own. **A collapsed lane's cards ride the same mechanism** (03-board-ui.md § Lane ▸
/// Collapsed lanes: "cards inside are not rendered"): a lane drawn as a slim strip lays out no card
/// faces, so it registers no frames and `nearest` cannot step into one. The `⌥`-jumps are the half
/// that *does* need code, because they name an absolute destination rather than a neighbour — see
/// `firstCard(scanning:filter:)`.
///
/// Pure and `CoreGraphics`-only, for `SelectionGrammar`'s reason: the branches become lines of test
/// rather than gestures to drive, and the four arrow handlers stay thin over it.
public enum NavigationMath {

    public enum Direction: Sendable, Equatable {
        case up
        case down
        case left
        case right
    }

    /// The nearest target in `direction` from `origin`, or `nil` when the direction has no candidate.
    ///
    /// The rule, in three parts:
    ///
    /// - **Strictly beyond, along the primary axis.** A candidate's centre must sit at least 1pt
    ///   past the origin's centre in the direction travelled. The tolerance is what excludes the
    ///   origin itself and what keeps a card sharing a row (or a column) with the origin from
    ///   counting as "above" it because of a sub-pixel layout difference.
    /// - **Orthogonal drift costs double.** The score is the primary-axis centre distance plus twice
    ///   the orthogonal one, so a card straight ahead beats a nearer one off to the side — which is
    ///   what makes ↓ walk down a masonry column rather than wandering across it, and ← / → cross to
    ///   the neighbouring lane at the same height.
    /// - **Ties are broken by position, then identity** (`MarqueeMath.isAbove`), so identical input
    ///   picks identically twice.
    ///
    /// - Parameter predicate: which targets are eligible — the ⇧-arrow's same-side restriction, and
    ///   nothing else so far. A plain arrow passes everything, because "plain arrows still walk
    ///   across" the live/trash boundary (04 ▸ The trash).
    public static func nearest(
        from origin: CGRect,
        direction: Direction,
        among targets: [MarqueeTarget],
        where predicate: (MarqueeTarget) -> Bool = { _ in true }
    ) -> ItemID? {
        /// Below this, a candidate is level with the origin rather than beyond it.
        let threshold: CGFloat = 1

        var best: MarqueeTarget?
        var bestScore = CGFloat.infinity

        for candidate in targets where predicate(candidate) {
            let primary: CGFloat
            let orthogonal: CGFloat
            switch direction {
            case .up:
                primary = origin.midY - candidate.frame.midY
                orthogonal = abs(candidate.frame.midX - origin.midX)
            case .down:
                primary = candidate.frame.midY - origin.midY
                orthogonal = abs(candidate.frame.midX - origin.midX)
            case .left:
                primary = origin.midX - candidate.frame.midX
                orthogonal = abs(candidate.frame.midY - origin.midY)
            case .right:
                primary = candidate.frame.midX - origin.midX
                orthogonal = abs(candidate.frame.midY - origin.midY)
            }
            guard primary >= threshold else { continue }

            let score = primary + 2 * orthogonal
            if score < bestScore {
                best = candidate
                bestScore = score
            } else if score == bestScore, let current = best, MarqueeMath.isAbove(candidate, current) {
                best = candidate
            }
        }
        return best?.id
    }

    /// **The first card the board is actually showing**, scanning `lanes` in the order given — the
    /// landing every absolute keyboard destination shares: ⌥←/⌥→'s first/last lane, and the seed an
    /// arrow from an empty selection takes (04-interactions.md ▸ Grammar).
    ///
    /// Three ways a lane is scanned past, and they are one rule — *the user cannot see into it*:
    ///
    /// - it holds no cards;
    /// - the search query hid all of them (04 § Search — "a jump that landed nowhere because the end
    ///   lane happens to be empty would be a dead key", and a lane the query emptied is empty to the
    ///   eye);
    /// - it is **collapsed** (03-board-ui.md § Lane ▸ Collapsed lanes): the slim strip draws no card
    ///   faces at all, so a jump that landed on one would select something rendered nowhere and leave
    ///   the arrows with no frame to step from. The strip itself stays selectable **as a lane**, which
    ///   is the lane domain's business and not this scan's.
    ///
    /// Pure, and here rather than in the view for `SortMath`'s reason: the three skips are lines of
    /// test instead of a board to drive.
    public static func firstCard(
        scanning lanes: some Sequence<Lane>,
        filter: SearchFilter = .inactive
    ) -> ItemID? {
        for lane in lanes where !LaneLayoutMath.isCollapsed(lane) {
            if let card = lane.cards.first(where: { filter.matches($0) }) { return card.id }
        }
        return nil
    }

    // MARK: The sticky ordinal

    /// Where a lateral hop lands, and the ordinal it carries onward — `lateralHop`'s answer.
    public struct LateralHop: Sendable, Equatable {

        /// The card to select.
        public let target: ItemID

        /// The ordinal the *sequence* is holding — the origin's, **unclamped**, so a walk through a
        /// short lane and out the other side remembers where it started rather than where it was
        /// squeezed to. `TransientBoardState.lateralOrdinal` stores exactly this.
        public let ordinal: Int

        public init(target: ItemID, ordinal: Int) {
            self.target = target
            self.ordinal = ordinal
        }
    }

    /// **←/→ preserve the origin's position in its lane** (ruled 2026-08-09): a lateral hop lands on
    /// the `min(sticky, count)`-th card of the adjacent lane, counting the cards the board is
    /// *showing*, so the 10-3-10 walk in the card's title round-trips — 8th → 3rd (clamped) → 8th.
    ///
    /// Geometry cannot do this and the failure is not a bug in the score: `nearest` picks the frame
    /// closest to the origin's height, and after a clamp into a short lane the origin's height *is*
    /// the short lane's 3rd card. Every candidate rule over drawn rectangles loses the same
    /// information, because the information — "the user was 8 cards down" — stopped being on screen.
    /// So the ordinal is remembered instead (`TransientBoardState.lateralOrdinal`), and this function
    /// is the only thing that reads it.
    ///
    /// **The lanes it walks are the showing ones**, which is `firstCard`'s rule with one addition,
    /// and the three skips are the same three: a collapsed lane draws no card faces, a lane the query
    /// emptied shows none either, and an empty lane has none to show. A hop is *one* showing lane
    /// over — never "the next lane, unless it is folded, in which case two" spelled at a call site.
    ///
    /// **Ordinals are lane-granular and read the logical order** (10-accessibility.md's rule, and the
    /// one `SortMath` moves along): a wide lane's masonry columns are a rendering, so the 8th card is
    /// the 8th in `order` whichever interior column it was laid into. The *interior* step keeps its
    /// geometry — `BoardView.step` takes a same-lane neighbour before it ever asks this — because a
    /// lane hop and a column hop are different gestures wearing one key.
    ///
    /// - Parameters:
    ///   - origin: the card the cursor is on. Not a frame: this rule is about lanes and lists, and
    ///     taking a rectangle would invite the geometry back in.
    ///   - sticky: the ordinal the sequence is already carrying, or `nil` to **start** one here —
    ///     which captures the origin's own ordinal, and is what makes "the first hop of a sequence"
    ///     need no flag of its own.
    /// - Returns: `nil` when the direction is vertical, when `origin` is not in a showing lane, or
    ///   when there is no showing lane that way — the last of which is `BoardView.step`'s cue to fall
    ///   back to geometry, so `→` still reaches the shown trash off the last lane.
    public static func lateralHop(
        from origin: ItemID,
        _ direction: Direction,
        scanning lanes: some Sequence<Lane>,
        filter: SearchFilter = .inactive,
        sticky: Int? = nil
    ) -> LateralHop? {
        guard direction == .left || direction == .right else { return nil }

        let showing: [[ItemID]] = lanes.compactMap { lane in
            guard !LaneLayoutMath.isCollapsed(lane) else { return nil }
            let ids = lane.cards.filter { filter.matches($0) }.map(\.id)
            return ids.isEmpty ? nil : ids
        }

        guard let home = showing.firstIndex(where: { $0.contains(origin) }),
              let position = showing[home].firstIndex(of: origin)
        else { return nil }

        let carried = sticky ?? (position + 1)
        let next = home + (direction == .left ? -1 : 1)
        guard showing.indices.contains(next) else { return nil }

        let landing = showing[next]
        return LateralHop(target: landing[min(carried, landing.count) - 1], ordinal: carried)
    }
}

// MARK: - Within-lane sort

/// ⌥⌘↑/⌥⌘↓'s arithmetic — "the selected card(s) move one position within the lane — logical
/// `order`, across interior masonry columns" (04-interactions.md ▸ The map), as a pure permutation
/// of the lane's rendered card ids (`NavigationMathTests`).
///
/// **Logical order, never geometry.** The masonry's columns are a rendering; the thing being moved
/// is the `order` ladder, which is also 10-accessibility.md's logical-order rule. So this function
/// never sees a frame — it is given the lane's ids top-to-bottom and hands back the same ids in a
/// new order, and `BoardStore.sortSelection` turns that into the minimum set of `order` rewrites.
public enum SortMath {

    public enum Direction: Sendable, Equatable {
        case up
        case down
    }

    /// The lane's ids after one press, or `nil` for a no-op.
    ///
    /// Two behaviours, and which one fires depends only on whether the selection is already
    /// contiguous:
    ///
    /// - **Non-contiguous gathers, and only gathers.** "A non-contiguous multi-selection gathers on
    ///   the first press: the cards collect into a contiguous block anchored at the first selected
    ///   card (first = lowest logical order; the rest follow in preserved relative order), and
    ///   subsequent presses move the block one position." The gather is therefore direction-blind —
    ///   the press that gathers does not also step, which is what makes the second press's meaning
    ///   unambiguous.
    /// - **Contiguous steps one position**, hopping the single unselected sibling above (or below)
    ///   the block, so the block travels as a unit. At the ladder's end there is nothing to hop, and
    ///   the answer is `nil`.
    ///
    /// `nil` rather than "the input unchanged" so the menu item's `disabled` state and the store's
    /// write path read the *same* answer — `LaneWidthCommands`' rule, and for its reason.
    ///
    /// Ids in `selected` that are not in `ordered` are ignored: a selection the next reload will
    /// drop must not decide what a press does now.
    public static func reordered(
        _ ordered: [ItemID],
        moving selected: Set<ItemID>,
        _ direction: Direction
    ) -> [ItemID]? {
        let doomed = ordered.indices.filter { selected.contains(ordered[$0]) }
        guard let first = doomed.first, let last = doomed.last else { return nil }

        let block = doomed.map { ordered[$0] }
        // Contiguity is a property of the positions, not of the count: N members spanning exactly N
        // slots is the block that steps; anything wider gathers first.
        guard doomed.count == last - first + 1 else {
            var others = ordered.filter { !selected.contains($0) }
            // Everything before the first selected card is unselected by definition, so the block's
            // landing index among the survivors *is* that first index — "anchored at the first
            // selected card".
            others.insert(contentsOf: block, at: first)
            return others
        }

        switch direction {
        case .up:
            guard first > 0 else { return nil }
            return Array(ordered[..<(first - 1)]) + block + [ordered[first - 1]] + Array(ordered[(last + 1)...])
        case .down:
            guard last + 1 < ordered.count else { return nil }
            return Array(ordered[..<first]) + [ordered[last + 1]] + block + Array(ordered[(last + 2)...])
        }
    }
}
