import CoreGraphics
import Foundation
import Testing
@testable import Kanban

/// 04-interactions.md § Selection's pointer grammar and § The trash's extensions to it, branch by
/// branch — plus the anchor that makes ⇧-click mean anything, and the rubber band's arithmetic.
///
/// The grammar is written as a pure function precisely so it can be tested like one: a click, a
/// selection, an anchor and a snapshot in, a selection and an anchor out — no window, no gesture, no
/// modifier flags. The boards underneath are **real loads off real temp trees**, because the rules
/// read card ordering and the trash container, and a hand-built `BoardModel` would let both drift
/// from what the loader actually produces.
///
/// **One container axis, and a kind axis that stops at it** (resettled 2026-07-28; kind-blind trash
/// re-ruled 2026-07-31): a selection never mixes trash rows with board items, and *on the board* it
/// is cards XOR lanes — but inside the trash "cards and lane rows select together", so the kind axis
/// does not reach into the second container. The guard that used to live there moved to the exits
/// (⌘C/⌘X validation and the mixed-payload drop refusal).
///
/// `WriterFixture`, `Ident` and `Item` live in `WriterTestSupport.swift`.

// MARK: - Fixtures

/// More literal identities than `Ident` offers: a three-lane range needs five cards.
private enum More {
    static let card5 = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"
    static let card6 = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"
}

/// Three lanes and five cards — enough that a flatten-order range crosses two lane boundaries.
///
/// Flatten order is `[card1, card2, card3, card4, card5]`.
@MainActor
private func makeLiveBoard() throws -> WriterFixture {
    let fixture = try WriterFixture()
    try fixture.item("", Item.board)
    try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo"))
    try fixture.item("\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "First"))
    try fixture.item("\(Ident.lane1)/\(Ident.card2)", Item.rich(order: "2048", title: "Second"))
    try fixture.item(Ident.lane2, Item.rich(order: "2048", title: "Doing"))
    try fixture.item("\(Ident.lane2)/\(Ident.card3)", Item.rich(order: "1024", title: "Third"))
    try fixture.item("\(Ident.lane2)/\(Ident.card4)", Item.rich(order: "2048", title: "Fourth"))
    try fixture.item(Ident.lane3, Item.rich(order: "3072", title: "Done"))
    try fixture.item("\(Ident.lane3)/\(More.card5)", Item.rich(order: "1024", title: "Fifth"))
    return fixture
}

/// A board with one lane and three cards in its `.trash/` — the container the trash-side grammar
/// walks, newest first by `modified` (`[card1, card2, card3]`).
@MainActor
private func makeTrashBoard() throws -> WriterFixture {
    let fixture = try WriterFixture()
    try fixture.item("", Item.board)
    try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo"))
    try fixture.item("\(Ident.lane1)/\(More.card6)", Item.rich(order: "1024", title: "Live"))
    // **The trash's order is `modified` descending** (01-storage-format.md § Deletion, re-ruled
    // 2026-07-31), so the stamps are what these fixtures state their sequence in; the `order` values
    // ride along and are deliberately in the *opposite* direction, so nothing here can pass by
    // accident of the retired rank rule.
    try fixture.item(".trash/\(Ident.card1)", trashItem(order: "1024", title: "First", modified: "2026-05-05T09:00:00Z"))
    try fixture.item(".trash/\(Ident.card2)", trashItem(order: "512", title: "Second", modified: "2026-05-03T09:00:00Z"))
    try fixture.item(".trash/\(Ident.card3)", trashItem(order: "256", title: "Third", modified: "2026-05-01T09:00:00Z"))
    return fixture
}

/// One trash entry, stated in the key the container actually sorts by.
private func trashItem(order: String, title: String, modified: String, kind: String? = nil) -> String {
    """
    ---
    schema: 1
    title: \(title)
    order: \(order)
    modified: \(modified)
    \(kind.map { "kind: \($0)\n" } ?? "")---
    \(title) body.

    """
}

/// The same trash with **two lane rows interleaved among its cards** (03-board-ui.md § Trash, lanes
/// rejoined 2026-07-29): the column order is `[card1, lane2, card2, lane3, card3]`, so every
/// kind-crossing claim below has a row of the other kind sitting inside the span it asks about.
@MainActor
private func makeMixedTrashBoard() throws -> WriterFixture {
    let fixture = try makeTrashBoard()
    try fixture.item(
        ".trash/\(Ident.lane2)",
        trashItem(order: "384", title: "Doing", modified: "2026-05-04T09:00:00Z", kind: "lane")
    )
    try fixture.item(
        ".trash/\(Ident.lane3)",
        trashItem(order: "768", title: "Done", modified: "2026-05-02T09:00:00Z", kind: "lane")
    )
    return fixture
}

private let lane1 = ItemID(rawValue: Ident.lane1)
private let lane2 = ItemID(rawValue: Ident.lane2)
private let lane3 = ItemID(rawValue: Ident.lane3)
private let card1 = ItemID(rawValue: Ident.card1)
private let card2 = ItemID(rawValue: Ident.card2)
private let card3 = ItemID(rawValue: Ident.card3)
private let card4 = ItemID(rawValue: Ident.card4)
private let card5 = ItemID(rawValue: More.card5)
private let card6 = ItemID(rawValue: More.card6)

private func load(_ fixture: WriterFixture) throws -> BoardModel {
    try BoardLoader.load(boardRoot: fixture.root).model
}

private func target(_ id: ItemID, _ kind: SelectionKind, _ container: ItemContainer = .board) -> SelectionTarget {
    SelectionTarget(id: id, kind: kind, container: container)
}

private func set(_ ids: Set<ItemID>, _ container: ItemContainer = .board) -> ItemReferenceSet {
    ItemReferenceSet(ids: ids, container: container)
}

/// One click, with the grammar's own defaults filled in.
private func click(
    _ target: SelectionTarget,
    _ modifier: ClickModifier,
    selection: ItemReferenceSet = .empty,
    anchor: ItemID? = nil,
    in snapshot: BoardModel,
    togglesOnRepeat: Bool = false
) -> SelectionGrammar.Outcome {
    SelectionGrammar.click(
        target,
        modifier: modifier,
        selection: selection,
        anchor: anchor,
        snapshot: snapshot,
        togglesOnRepeat: togglesOnRepeat
    )
}

// MARK: - The order lists

@MainActor
@Suite("SelectionGrammar ▸ order")
struct SelectionOrderTests {

    @Test("Board cards flatten lane order first, then card order")
    func flattenOrder() throws {
        let fixture = try makeLiveBoard()
        defer { fixture.tearDown() }
        let snapshot = try load(fixture)

        // "Lane `order` first, then card `order` (a cross-lane selection flattens left-to-right,
        // top-to-bottom)" — the multi-drag order (04-interactions.md ▸ Drag and drop).
        #expect(SelectionGrammar.boardCards(in: snapshot) == [card1, card2, card3, card4, card5])
        #expect(SelectionGrammar.lanes(in: snapshot) == [lane1, lane2, lane3])
    }

    @Test("The trash's list is its rows, newest first; a trash with no lane rows is all cards")
    func trashOrder() throws {
        let fixture = try makeTrashBoard()
        defer { fixture.tearDown() }
        let snapshot = try load(fixture)

        #expect(SelectionGrammar.trashCards(in: snapshot) == [card1, card2, card3])
        #expect(SelectionGrammar.order(of: .card, in: .trash, snapshot: snapshot) == [card1, card2, card3])
        // Kind-blind: with no lane rows in the container the two lists coincide, which is the point —
        // there is only ever *one* trash list to walk (04 ▸ The trash, re-ruled 2026-07-31).
        #expect(SelectionGrammar.order(of: .lane, in: .trash, snapshot: snapshot) == [card1, card2, card3])
        #expect(SelectionGrammar.trashLanes(in: snapshot).isEmpty)
    }

    /// **The column has one list, and the kind argument does not narrow it** (04-interactions.md ▸
    /// The trash, re-ruled 2026-07-31 — kind-blind trash selection): navigation and ranging read the
    /// same merged sequence, so a ⇧-range sweeps the rows of the other kind rather than skipping
    /// them. The kind-scoped slices survive as `trashCards`/`trashLanes` for the consumers that
    /// genuinely mean one kind, and they are still slices of the same order.
    @Test("The trash's rows interleave by stamp, and `order(of:in:)` returns them whatever the kind")
    func trashRowsInterleave() throws {
        let fixture = try makeMixedTrashBoard()
        defer { fixture.tearDown() }
        let snapshot = try load(fixture)

        let rows = [card1, lane2, card2, lane3, card3]
        #expect(SelectionGrammar.trashRows(in: snapshot) == rows)
        #expect(SelectionGrammar.order(of: .card, in: .trash, snapshot: snapshot) == rows)
        #expect(SelectionGrammar.order(of: .lane, in: .trash, snapshot: snapshot) == rows)
        // The kind-scoped slices are still slices of it.
        #expect(SelectionGrammar.trashCards(in: snapshot) == [card1, card2, card3])
        #expect(SelectionGrammar.trashLanes(in: snapshot) == [lane2, lane3])
        // One merge, one order: the path resolver batches in exactly the order the column draws.
        #expect(ItemPath.resolve([card2, lane2, lane3], in: .trash, snapshot: snapshot)
            == [.trashLane(lane2), .trashCard(card2), .trashLane(lane3)])
    }

    @Test("The trash's kind is derived from the snapshot for both kinds")
    func trashKindDerivation() throws {
        let fixture = try makeMixedTrashBoard()
        defer { fixture.tearDown() }
        let snapshot = try load(fixture)

        #expect(SelectionGrammar.kind(of: set([card1, card2], .trash), in: snapshot) == .card)
        #expect(SelectionGrammar.kind(of: set([lane2, lane3], .trash), in: snapshot) == .lane)
        // A board id claimed on the trash side names nothing there — the container is the question.
        #expect(SelectionGrammar.kind(of: set([card6], .trash), in: snapshot) == nil)
        #expect(SelectionGrammar.kind(of: set([lane2]), in: snapshot) == nil, "and the reverse")
    }

    @Test("A selection's kind is derived from the snapshot, and a ghost selection has none")
    func kindDerivation() throws {
        let fixture = try makeLiveBoard()
        defer { fixture.tearDown() }
        let snapshot = try load(fixture)

        #expect(SelectionGrammar.kind(of: set([card1, card3]), in: snapshot) == .card)
        #expect(SelectionGrammar.kind(of: set([lane2]), in: snapshot) == .lane)
        #expect(SelectionGrammar.kind(of: .empty, in: snapshot) == nil)
        // A set claiming a container that does not hold its members answers nothing.
        #expect(SelectionGrammar.kind(of: set([card1], .trash), in: snapshot) == nil)
        // Members that name nothing are ignored; one that names something still answers.
        #expect(SelectionGrammar.kind(of: set([ItemID(rawValue: Ident.indexless), card1]), in: snapshot) == .card)
    }
}

// MARK: - Plain

@MainActor
@Suite("SelectionGrammar ▸ plain click")
struct PlainClickTests {

    @Test("A plain click replaces the selection and becomes the anchor")
    func replacesAndAnchors() throws {
        let fixture = try makeLiveBoard()
        defer { fixture.tearDown() }
        let snapshot = try load(fixture)

        let outcome = click(target(card3, .card), .plain, selection: set([card1, card2]), anchor: card1, in: snapshot)
        #expect(outcome.selection == set([card3]))
        #expect(outcome.anchor == card3)
    }

    @Test("Click again unselects — but only where the design gives that behaviour, and only on a sole selection")
    func toggleOffIsSoleMembershipOnly() throws {
        let fixture = try makeLiveBoard()
        defer { fixture.tearDown() }
        let snapshot = try load(fixture)

        // "Single click selects the lane (click again to unselect)", and the header "toggles like
        // empty space (settled)".
        let off = click(target(lane1, .lane), .plain, selection: set([lane1]), anchor: lane1, in: snapshot, togglesOnRepeat: true)
        #expect(off.selection.isEmpty)
        #expect(off.anchor == nil)

        // A multi-lane selection containing this lane is *narrowed*, not wiped: the toggle is about
        // the lane the user already had alone, not about a set they built with ⌘.
        let narrowed = click(target(lane1, .lane), .plain, selection: set([lane1, lane2]), anchor: lane2, in: snapshot, togglesOnRepeat: true)
        #expect(narrowed.selection == set([lane1]))
        #expect(narrowed.anchor == lane1)

        // A card face never toggles off — Finder does not deselect a file by clicking it twice.
        let card = click(target(card1, .card), .plain, selection: set([card1]), anchor: card1, in: snapshot)
        #expect(card.selection == set([card1]))
        #expect(card.anchor == card1)

        // Nor does the toggle reach across the boundary: a board click on a trash-side sole
        // selection of the same id is a replace.
        let crossed = click(target(lane1, .lane), .plain, selection: set([lane1], .trash), anchor: lane1, in: snapshot, togglesOnRepeat: true)
        #expect(crossed.selection == set([lane1]))
    }
}

// MARK: - Command

@MainActor
@Suite("SelectionGrammar ▸ ⌘-click")
struct CommandClickTests {

    @Test("⌘-click toggles within one kind, and the click is the new anchor either way")
    func togglesWithinKind() throws {
        let fixture = try makeLiveBoard()
        defer { fixture.tearDown() }
        let snapshot = try load(fixture)

        let added = click(target(card3, .card), .command, selection: set([card1]), anchor: card1, in: snapshot)
        #expect(added.selection == set([card1, card3]))
        #expect(added.anchor == card3)

        let removed = click(target(card1, .card), .command, selection: set([card1, card3]), anchor: card3, in: snapshot)
        #expect(removed.selection == set([card3]))
        #expect(removed.anchor == card1)
    }

    @Test("Toggling the last member out leaves nothing selected and nothing to range from")
    func emptyingClearsTheAnchor() throws {
        let fixture = try makeLiveBoard()
        defer { fixture.tearDown() }
        let snapshot = try load(fixture)

        let outcome = click(target(card1, .card), .command, selection: set([card1]), anchor: card1, in: snapshot)
        #expect(outcome.selection.isEmpty)
        #expect(outcome.anchor == nil)
    }

    @Test("⌘-click across the kind boundary replaces — a selection is never mixed")
    func acrossKindReplaces() throws {
        let fixture = try makeLiveBoard()
        defer { fixture.tearDown() }
        let snapshot = try load(fixture)

        // "Selection is homogeneous: cards XOR lanes" (04-interactions.md § Selection).
        let ontoLane = click(target(lane2, .lane), .command, selection: set([card1, card2]), anchor: card2, in: snapshot)
        #expect(ontoLane.selection == set([lane2]))
        #expect(ontoLane.anchor == lane2)

        let ontoCard = click(target(card1, .card), .command, selection: set([lane1, lane2]), anchor: lane2, in: snapshot)
        #expect(ontoCard.selection == set([card1]))
        #expect(ontoCard.anchor == card1)
    }

    /// **Inside the trash there is no kind boundary to cross** (04-interactions.md ▸ The trash,
    /// re-ruled 2026-07-31, superseding the kind-homogeneous trash grammar): "within the trash cards
    /// and lane rows select together — clicks, ⇧-click ranges, ⇧-arrow extension, and the rubber
    /// band all sweep every row". So a ⌘-click that used to replace now *extends*.
    @Test("⌘-click adds a lane row to a card selection inside the trash")
    func acrossKindInTheTrashExtends() throws {
        let fixture = try makeMixedTrashBoard()
        defer { fixture.tearDown() }
        let snapshot = try load(fixture)

        let ontoRow = click(target(lane2, .lane, .trash), .command, selection: set([card1], .trash), anchor: card1, in: snapshot)
        #expect(ontoRow.selection == set([card1, lane2], .trash))

        let ontoCard = click(target(card1, .card, .trash), .command, selection: set([lane2, lane3], .trash), anchor: lane3, in: snapshot)
        #expect(ontoCard.selection == set([lane2, lane3, card1], .trash))

        // Within one kind it still toggles, which is what makes the branch above a rule rather than
        // a refusal of ⌘ in the trash.
        let added = click(target(lane3, .lane, .trash), .command, selection: set([lane2], .trash), anchor: lane2, in: snapshot)
        #expect(added.selection == set([lane2, lane3], .trash))
    }

    @Test("⌘-click across the container boundary replaces too")
    func acrossContainerReplaces() throws {
        let fixture = try makeTrashBoard()
        defer { fixture.tearDown() }
        let snapshot = try load(fixture)

        // "A selection never mixes trash cards with board cards — a single container rule replacing
        // the old liveness law" (04 ▸ The trash, resettled 2026-07-28).
        let intoTrash = click(target(card1, .card, .trash), .command, selection: set([card6]), anchor: card6, in: snapshot)
        #expect(intoTrash.selection == set([card1], .trash))

        let backOut = click(target(card6, .card), .command, selection: set([card1, card2], .trash), anchor: card2, in: snapshot)
        #expect(backOut.selection == set([card6]))
    }

    @Test("⌘-click with nothing — or nothing real — selected replaces")
    func emptyOrGhostSelectionReplaces() throws {
        let fixture = try makeLiveBoard()
        defer { fixture.tearDown() }
        let snapshot = try load(fixture)

        let fromEmpty = click(target(card1, .card), .command, in: snapshot)
        #expect(fromEmpty.selection == set([card1]))
        #expect(fromEmpty.anchor == card1)

        // A selection whose members all name nothing the board renders counts as empty: a ⌘-click
        // after a foreign delete starts a fresh set rather than extending a ghost.
        let ghost = ItemID(rawValue: Ident.indexless)
        let fromGhost = click(target(card1, .card), .command, selection: set([ghost]), anchor: ghost, in: snapshot)
        #expect(fromGhost.selection == set([card1]))
    }
}

// MARK: - Shift

@MainActor
@Suite("SelectionGrammar ▸ ⇧-click")
struct ShiftClickTests {

    @Test("A ⇧-range spans the flatten order across lanes and leaves the anchor put")
    func rangeAcrossLanes() throws {
        let fixture = try makeLiveBoard()
        defer { fixture.tearDown() }
        let snapshot = try load(fixture)

        let outcome = click(target(card5, .card), .shift, selection: set([card1]), anchor: card1, in: snapshot)
        #expect(outcome.selection == set([card1, card2, card3, card4, card5]))
        // Finder-list style: the anchor is unchanged, so successive ⇧-clicks sweep from one origin.
        #expect(outcome.anchor == card1)
    }

    @Test("Direction does not matter — the range is the span between anchor and target")
    func rangeIsDirectionless() throws {
        let fixture = try makeLiveBoard()
        defer { fixture.tearDown() }
        let snapshot = try load(fixture)

        let backwards = click(target(card1, .card), .shift, selection: set([card5]), anchor: card5, in: snapshot)
        #expect(backwards.selection == set([card1, card2, card3, card4, card5]))
        #expect(backwards.anchor == card5)
    }

    @Test("Lanes range in their own order list")
    func laneRange() throws {
        let fixture = try makeLiveBoard()
        defer { fixture.tearDown() }
        let snapshot = try load(fixture)

        let outcome = click(target(lane3, .lane), .shift, selection: set([lane1]), anchor: lane1, in: snapshot)
        #expect(outcome.selection == set([lane1, lane2, lane3]))
        #expect(outcome.anchor == lane1)
    }

    @Test("An invalid anchor makes ⇧ a plain click — nil, vanished, or across a boundary")
    func invalidAnchorActsPlain() throws {
        let fixture = try makeLiveBoard()
        defer { fixture.tearDown() }
        let snapshot = try load(fixture)

        // Nothing to range from.
        let noAnchor = click(target(card3, .card), .shift, selection: set([card1]), in: snapshot)
        #expect(noAnchor.selection == set([card3]))
        #expect(noAnchor.anchor == card3)

        // An anchor that names nothing the board renders.
        let ghost = ItemID(rawValue: Ident.indexless)
        let vanished = click(target(card3, .card), .shift, selection: set([card1]), anchor: ghost, in: snapshot)
        #expect(vanished.selection == set([card3]))
        #expect(vanished.anchor == card3)

        // An anchor in the *other* kind's list: the two lists are disjoint, so the anchor is not
        // findable and the click degrades — never a mixed range.
        let acrossKind = click(target(card3, .card), .shift, selection: set([lane1]), anchor: lane1, in: snapshot)
        #expect(acrossKind.selection == set([card3]))
        #expect(acrossKind.anchor == card3)

        // Same for a container crossing: the board's card list holds no trash card.
        let acrossContainer = click(target(card3, .card, .trash), .shift, selection: set([card1]), anchor: card1, in: snapshot)
        #expect(acrossContainer.selection == set([card3], .trash))
    }

    @Test("A trash range walks the column's own order")
    func trashRangeWalksTheColumn() throws {
        let fixture = try makeTrashBoard()
        defer { fixture.tearDown() }
        let snapshot = try load(fixture)

        // The column's order is `[card1, card2, card3]`; a range between the ends takes all three,
        // with no kind to step over — lanes are never trashed.
        let outcome = click(target(card3, .card, .trash), .shift, selection: set([card1], .trash), anchor: card1, in: snapshot)
        #expect(outcome.selection == set([card1, card2, card3], .trash))
        #expect(outcome.anchor == card1)
    }

    /// **A trash range sweeps every row** (04-interactions.md ▸ The trash, re-ruled 2026-07-31 —
    /// superseding the skip-by-kind ruling this suite used to pin): "⇧-click ranges … all sweep every
    /// row". The kinds are not a boundary inside the container any more; the container still is.
    @Test("A trash range sweeps every row between its endpoints, both kinds")
    func trashRangeSweepsEveryRow() throws {
        let fixture = try makeMixedTrashBoard()
        defer { fixture.tearDown() }
        let snapshot = try load(fixture)

        // The column reads [card1, lane2, card2, lane3, card3]: a range from the top to the bottom
        // now takes all five rows rather than stepping over the two lane rows.
        let cards = click(target(card3, .card, .trash), .shift, selection: set([card1], .trash), anchor: card1, in: snapshot)
        #expect(cards.selection == set([card1, lane2, card2, lane3, card3], .trash))

        // And a range anchored on a lane row picks up the card between them.
        let rows = click(target(lane3, .lane, .trash), .shift, selection: set([lane2], .trash), anchor: lane2, in: snapshot)
        #expect(rows.selection == set([lane2, card2, lane3], .trash))

        // And a range aimed from a card to a lane row is now an ordinary range rather than a
        // degraded plain click: both endpoints sit in the one list, so the anchor stays put.
        let crossed = click(target(lane3, .lane, .trash), .shift, selection: set([card1], .trash), anchor: card1, in: snapshot)
        #expect(crossed.selection == set([card1, lane2, card2, lane3], .trash))
        #expect(crossed.anchor == card1)
    }

    @Test("A range never crosses the container boundary")
    func trashRangeStopsAtTheBoundary() throws {
        let fixture = try makeTrashBoard()
        defer { fixture.tearDown() }
        let snapshot = try load(fixture)

        // `card6` is on the board and `card1` is in the trash: no single order list holds both, so
        // the anchor is not findable and the ⇧-click degrades to a plain one.
        let outcome = click(target(card1, .card, .trash), .shift, selection: set([card6]), anchor: card6, in: snapshot)
        #expect(outcome.selection == set([card1], .trash))
        #expect(outcome.anchor == card1)
    }
}

// MARK: - The rubber band

@Suite("MarqueeMath")
struct MarqueeMathTests {

    private static func card(_ id: ItemID, _ y: CGFloat, container: ItemContainer = .board) -> MarqueeTarget {
        MarqueeTarget(id: id, kind: .card, container: container, frame: CGRect(x: 0, y: y, width: 100, height: 40))
    }

    @Test("On the board side the band takes intersecting cards, and only cards")
    func boardSideTakesCards() {
        let targets = [
            Self.card(card1, 0),
            Self.card(card2, 100),
            // A lane registered by mistake is still never swept: "click-drag rubber-bands across
            // lanes" (04-interactions.md § Selection) — across, not over.
            MarqueeTarget(id: lane1, kind: .lane, container: .board, frame: CGRect(x: 0, y: 0, width: 200, height: 400)),
            // A trash row cannot be reached by a band that began on the board.
            Self.card(card3, 10, container: .trash)
        ]
        let ids = MarqueeMath.selection(rect: CGRect(x: 0, y: 0, width: 50, height: 120), targets: targets, in: .board)
        #expect(ids == [card1, card2])
    }

    /// **In the trash the band sweeps every row** (04-interactions.md ▸ The trash, re-ruled
    /// 2026-07-31, superseding the card-only band): "the rubber band [sweeps] every row (the band's
    /// full-height backdrop covers both kinds)". A trashed lane row registers its frame like a card
    /// does, so this needed only the kind filter to come off on that side — the *container* filter
    /// stays, and the band still never leaves the side it began on.
    @Test("On the trash side the band takes every row, and stays on its own side")
    func trashSideTakesEveryRow() {
        let targets = [
            Self.card(card1, 0, container: .trash),
            MarqueeTarget(id: lane2, kind: .lane, container: .trash,
                          frame: CGRect(x: 0, y: 50, width: 100, height: 30)),
            Self.card(card2, 100, container: .trash),
            Self.card(card3, 50)
        ]
        let all = CGRect(x: 0, y: 0, width: 50, height: 200)

        #expect(MarqueeMath.selection(rect: all, targets: targets, in: .trash) == [card1, lane2, card2])
        #expect(MarqueeMath.selection(rect: all, targets: targets, in: .board) == [card3])
    }

    @Test("A band touching nothing selects nothing")
    func emptyBand() {
        let targets = [Self.card(card1, 0), Self.card(card2, 100)]
        #expect(MarqueeMath.selection(rect: CGRect(x: 500, y: 500, width: 10, height: 10), targets: targets, in: .board).isEmpty)
        #expect(MarqueeMath.selection(rect: .zero, targets: [], in: .trash).isEmpty)
    }
}

// MARK: - The anchor's storage and its reload rule

/// One foreign reload, start to settled — `TransientBoardStateTests`' helper, borrowed for the one
/// piece of transient state this card adds.
@MainActor
private func reload(_ store: BoardStore) async {
    store.handleWatcherEvent(.treeChanged(.foreign))
    await store.awaitQuiescence()
}

@MainActor
@Suite("TransientBoardState ▸ the selection anchor")
struct SelectionAnchorTests {

    @Test("A sole selection anchors itself; a wholesale one does not; clearing drops it")
    func anchorDefaults() {
        let state = TransientBoardState()

        state.select([card1], in: .board)
        #expect(state.selectionAnchor == card1)

        // "A marquee and wholesale selections pass no anchor deliberately."
        state.select([card1, card2], in: .board)
        #expect(state.selectionAnchor == nil)

        // An explicit anchor wins over the default in both directions.
        state.select([card1, card2, card3], in: .board, anchor: card2)
        #expect(state.selectionAnchor == card2)

        state.clearSelection()
        #expect(state.selectionAnchor == nil)
    }

    @Test("A vanished anchor is dropped by the reload, and a surviving one is kept")
    func vanishedAnchorIsDropped() async throws {
        let fixture = try makeLiveBoard()
        defer { fixture.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)

        store.select([card1, card2], in: .board, anchor: card1)
        // A survivor of the same reload proves the rule is about the anchor, not about reloading.
        try FileManager.default.removeItem(at: fixture.url("\(Ident.lane1)/\(Ident.card2)"))
        await reload(store)
        #expect(store.selection.ids == [card1])
        #expect(store.transient.selectionAnchor == card1)

        try FileManager.default.removeItem(at: fixture.url("\(Ident.lane1)/\(Ident.card1)"))
        await reload(store)
        // The anchor's card is gone — the rule this test exists for. The selection is not empty
        // afterwards because the reload emptied it *and* took the cursor with it, which is
        // 10-accessibility.md's vanishing-focus case: focus recovers to the card's lane, and a
        // one-item selection made by any route is its own anchor (`BoardAnnouncerStoreTests`).
        #expect(store.selection.ids == [lane1])
        #expect(store.transient.selectionAnchor == lane1)
    }

    @Test("A container crossing is a vanish for the anchor too")
    func crossedAnchorIsDropped() async throws {
        let fixture = try makeLiveBoard()
        defer { fixture.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)

        store.select([card1, card2], in: .board, anchor: card1)
        try fixture.move("\(Ident.lane1)/\(Ident.card1)", toTrash: Ident.card1)
        await reload(store)

        // "A container crossing is a vanish for this purpose" (02-architecture.md's reload rule,
        // resettled 2026-07-28).
        #expect(store.selection.ids == [card2])
        #expect(store.transient.selectionAnchor == nil)
    }

    @Test("An anchor no longer in the selection still ranges — membership is not the rule")
    func anchorNeedNotBeSelected() throws {
        let fixture = try makeLiveBoard()
        defer { fixture.tearDown() }
        let snapshot = try load(fixture)
        let state = TransientBoardState()

        // A ⌘-click that toggled the anchor's own row out leaves the anchor standing.
        state.select([card2, card3], in: .board, anchor: card1)
        #expect(state.selectionAnchor == card1)
        let outcome = click(target(card3, .card), .shift, selection: state.selection, anchor: state.selectionAnchor, in: snapshot)
        #expect(outcome.selection == set([card1, card2, card3]))
    }
}

// MARK: - Select All

@MainActor
@Suite("BoardStore ▸ Select All")
struct SelectAllTests {

    @Test("Select All takes every rendered card, and never a lane")
    func liveBranch() throws {
        let fixture = try makeLiveBoard()
        defer { fixture.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)

        store.select([lane2], in: .board)
        store.selectAll()

        // "All visible cards on the board" (04-interactions.md ▸ The map).
        #expect(store.selection == set([card1, card2, card3, card4, card5]))
        // The lane the anchor named is not in the new set, so the anchor goes with it.
        #expect(store.transient.selectionAnchor == nil)
    }

    @Test("An anchor inside the new set survives Select All")
    func anchorSurvivesWhenStillInside() throws {
        let fixture = try makeLiveBoard()
        defer { fixture.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)

        store.select([card3], in: .board)
        store.selectAll()
        #expect(store.transient.selectionAnchor == card3)
    }

    @Test("On the trash side Select All takes every visible trash card")
    func trashBranchTakesTheColumn() throws {
        let fixture = try makeTrashBoard()
        defer { fixture.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)
        store.transient.isTrashVisible = true

        // "With the trash visible and a non-empty trash selection, Select All selects all visible
        // trash rows" (04 ▸ The map; kind-blind since 2026-07-31). This container holds only cards,
        // so rows and cards coincide — the mixed case is the test below.
        store.select([card2], in: .trash)
        store.selectAll()
        #expect(store.selection == set([card1, card2, card3], .trash))
    }

    /// **In the trash, "all" is all rows** (04-interactions.md ▸ The trash and 11-command-nexus.md ▸
    /// Select All, re-ruled 2026-07-31 with kind-blind trash selection: "Select All with a non-empty
    /// trash selection selects **all visible trash rows**"). The live board's own Select All stays
    /// card-scoped, which the board branch above pins.
    @Test("Select All in the trash takes every row, lane rows included")
    func trashBranchTakesEveryRow() throws {
        let fixture = try makeMixedTrashBoard()
        defer { fixture.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)
        store.transient.isTrashVisible = true

        store.select([lane2], in: .trash)
        store.selectAll()

        #expect(store.selection == set([card1, lane2, card2, lane3, card3], .trash))
    }

    @Test("The trash branch is narrow: hidden, live, empty, or ghost selections take the board")
    func trashBranchFallsThrough() throws {
        let fixture = try makeTrashBoard()
        defer { fixture.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)

        // Hidden — the column is invisible to every gesture (04 ▸ The trash).
        store.select([card1], in: .trash)
        store.selectAll()
        #expect(store.selection.container == .board)

        // Shown, but nothing in the trash is selected.
        store.transient.isTrashVisible = true
        store.clearSelection()
        store.selectAll()
        #expect(store.selection.container == .board)

        // Shown, trash side, but the ids name no card there: a guess would be worse than the board.
        store.select([card5], in: .trash)
        store.selectAll()
        #expect(store.selection.container == .board)
    }

    @Test("Select All on a board with no rendered cards clears rather than selecting an empty set")
    func emptyBoardClears() throws {
        let fixture = try WriterFixture()
        defer { fixture.tearDown() }
        try fixture.item("", Item.board)
        try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo"))
        let store = try BoardStore(rootURL: fixture.root)

        store.select([lane1], in: .board)
        store.selectAll()
        #expect(store.selection.isEmpty)
        #expect(store.transient.selectionAnchor == nil)
    }
}
