import Foundation
import Testing
@testable import Kanban

/// `TransientBoardState` is one claim stated four ways: **transient state may only reference items
/// the current snapshot has**. So these tests are about what survives a reload and what doesn't —
/// per set, independently, and with the placeholder's lane-anchored variant of the same idea.
///
/// They drive a **real `BoardStore` over a real temp board**, exactly as `BoardStoreTests` does,
/// rather than calling `resolve(against:)` on a hand-built model: the container's contract includes
/// being called by the store's reload path, and a suite that never went through `land(_:...)` could
/// pass with that wire cut. The one exception is the same-rule test, which compares two *pure value*
/// functions and has to call one of them directly to have anything to compare.

// MARK: - Fixtures

/// `WriterFixture`, `Ident` and `Item` live in `WriterTestSupport.swift`.

/// A lane or card whose `deleted:` key is present — the tombstone an agent or a hand-edit adds to a
/// file the user currently has selected, cut, or is creating a card under.
private func tombstoned(order: String, title: String) -> String {
    """
    ---
    schema: 1
    title: \(title)
    order: \(order)
    deleted: 2026-03-03T09:00:00Z
    ---
    \(title) body.

    """
}

/// Two lanes and three cards — enough that "overlapping but different" member sets are expressible
/// and that a set can survive the reload that empties another.
@MainActor
private func makeBoard() 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"))
    return fixture
}

private let lane1 = ItemID(rawValue: Ident.lane1)
private let lane2 = ItemID(rawValue: Ident.lane2)
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)

/// One foreign reload, start to settled — the only way anything in this suite gets re-resolved.
@MainActor
private func reload(_ store: BoardStore) async {
    store.handleWatcherEvent(.treeChanged(.foreign))
    await store.awaitQuiescence()
}

// MARK: - Tests

@MainActor
@Suite("TransientBoardState")
struct TransientBoardStateTests {

    // MARK: The sets

    @Test("A vanished item leaves only the sets that held it — each set resolves independently")
    func setsResolveIndependently() async throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)

        // Overlapping but different: card1 is selected and cut, card2 is selected and dragged,
        // card3 is dragged and cut. Whatever happens to one member, two of the three sets are
        // always the control.
        store.transient.select([card1, card2], in: .board)
        store.transient.dragMembers = ItemReferenceSet(ids: [card2, card3], container: .board)
        store.transient.pendingCut = ItemReferenceSet(ids: [card1, card3], container: .board)

        try FileManager.default.removeItem(at: fixture.url("\(Ident.lane1)/\(Ident.card1)"))
        await reload(store)

        #expect(store.transient.selection.ids == [card2], "the selection loses the member it held")
        #expect(store.transient.pendingCut.ids == [card3], "so does the cut, on its own")
        #expect(
            store.transient.dragMembers.ids == [card2, card3],
            "the drag never held card1 and must come through untouched — a member leaving one set may not disturb another"
        )
        #expect(store.selection == store.transient.selection, "the store's convenience is the same value")
    }

    @Test("The filter's universe and a reload's universe are the same rule, expressed once")
    func constrainedAndResolvedAgree() async throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)

        let seeded = ItemReferenceSet(ids: [card1, card2], container: .board)

        // Direction one — the live search filter (04-interactions.md § Search): card1's title and
        // body both miss the query, so it is not in the visible set the predicate produced, and
        // "hidden cards leave the selection" is just this intersection.
        let visible: Set<ItemID> = [lane1, lane2, card2, card3]
        let filtered = seeded.constrained(to: visible)

        // Direction two — reload survival (02-architecture.md § Live-reload resilience): the same
        // member, gone from the tree instead of hidden by a predicate.
        try FileManager.default.removeItem(at: fixture.url("\(Ident.lane1)/\(Ident.card1)"))
        await reload(store)
        let resolved = seeded.resolved(against: store.snapshot)

        #expect(filtered.ids == [card2])
        #expect(filtered == resolved, "one primitive, two universes — the two rules are one rule")
    }

    @Test("A container crossing is a vanish for every item-referencing set")
    func aContainerCrossingEjectsFromEverySet() async throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)

        store.transient.select([card1, card2], in: .board)
        store.transient.pendingCut = ItemReferenceSet(ids: [card2, card3], container: .board)

        // A foreign writer moves card1 and card2 into the board's trash — the move a delete is.
        try fixture.move("\(Ident.lane1)/\(Ident.card1)", toTrash: Ident.card1)
        try fixture.move("\(Ident.lane1)/\(Ident.card2)", toTrash: Ident.card2)
        await reload(store)

        // 02-architecture.md § Live-reload resilience, resettled 2026-07-28: "re-resolution matches
        // UUID *and* container side … a foreign move that trashes a selected board card ejects it
        // from the selection (and from the pending cut)".
        let trashedIDs = Set(store.snapshot.trash.map(\.id))
        #expect(trashedIDs == Set([card1, card2]))
        #expect(store.transient.selection.ids.isEmpty)
        #expect(store.transient.pendingCut.ids == [card3], "card3 never moved, so card3 stays cut")
    }

    @Test("A restore ejects a trash-side set the same way, in the other direction")
    func restoringEjectsATrashSideSet() async throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        try fixture.move("\(Ident.lane2)/\(Ident.card3)", toTrash: Ident.card3)
        let store = try BoardStore(rootURL: fixture.root)

        // Everything that can point at an item, all aimed at the trashed card.
        store.transient.select([card3], in: .trash, anchor: card3, head: card3)
        store.transient.dragMembers = ItemReferenceSet(ids: [card3], container: .trash)
        store.transient.pendingCut = ItemReferenceSet(ids: [card3], container: .trash)
        store.transient.beginRename(of: card3, currentTitle: "Third")
        #expect(store.snapshot.trash.map(\.id) == [card3])

        // An agent moves it back out — a container crossing the other way.
        try fixture.move(".trash/\(Ident.card3)", toLane: Ident.lane2, card: Ident.card3)
        await reload(store)

        #expect(store.snapshot.trash.isEmpty)
        #expect(!ItemContainer.trash.ids(in: store.snapshot).contains(card3))
        #expect(ItemContainer.board.ids(in: store.snapshot).contains(card3),
                "presence is the whole test — the card is simply on the other side now")

        #expect(store.transient.selection.ids.isEmpty)
        #expect(store.transient.dragMembers.ids.isEmpty)
        #expect(store.transient.pendingCut.ids.isEmpty)

        // And no cursor survives on it: an anchor that ranges from a container the selection has left
        // would be a range the user cannot see the origin of.
        #expect(store.transient.selectionAnchor == nil)
        #expect(store.transient.selectionHead == nil)

        // The rename editor tracks the *board* container, so a card arriving back on the board keeps
        // its editor — "a foreign move mid-rename is invisible" (04 ▸ Grammar). It is the departure
        // into the trash that discards it, which the delete tests cover.
        #expect(store.transient.renameEditor?.targetID == card3)

        // Menu validation agrees with the sets, which is the point of both reading one rule.
        let stale = ItemReferenceSet(ids: [card3], container: .trash)
        #expect(!TrashModel.canDelete(selection: stale, in: store.snapshot))
        #expect(TrashModel.canDelete(selection: ItemReferenceSet(ids: [card3], container: .board),
                                     in: store.snapshot))
    }

    @Test("Every set resolves to empty against a board whose lanes all vanished")
    func everythingResolvesToNothingOnAnEmptyBoard() async throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)

        store.transient.select([card1, lane1], in: .board)
        store.transient.dragMembers = ItemReferenceSet(ids: [card2], container: .board)
        store.transient.pendingCut = ItemReferenceSet(ids: [card3], container: .board)
        store.transient.beginPlaceholder(inLane: lane2)

        try FileManager.default.removeItem(at: fixture.url(Ident.lane1))
        try FileManager.default.removeItem(at: fixture.url(Ident.lane2))
        await reload(store)

        #expect(store.snapshot.lanes.isEmpty)
        #expect(store.transient.selection == .empty)
        #expect(store.transient.dragMembers == .empty)
        #expect(store.transient.pendingCut == .empty)
        #expect(store.transient.newCardPlaceholder == nil)
    }

    // MARK: The placeholder

    @Test("A placeholder whose lane is deleted from the tree is discarded")
    func placeholderDiscardedWhenItsLaneIsRemoved() async throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)

        store.transient.beginPlaceholder(inLane: lane2)
        store.transient.updateDraft("Half a title")

        // Not a tombstone — the folder is gone, the way an agent's `rm -rf` or a Finder delete
        // leaves it. Nothing was ever on disk for the placeholder, so there is nothing to clean up.
        try FileManager.default.removeItem(at: fixture.url(Ident.lane2))
        await reload(store)

        #expect(store.snapshot.lanes.count == 1)
        #expect(store.transient.newCardPlaceholder == nil, "the placeholder's lane vanished, so it goes with it")
    }

    @Test("A placeholder whose lane is tombstoned is discarded — a tombstoned lane renders nowhere")
    func placeholderDiscardedWhenItsLaneIsTombstoned() async throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)

        store.transient.beginPlaceholder(inLane: lane2)
        store.transient.updateDraft("Half a title")

        try FileManager.default.removeItem(at: fixture.url(Ident.lane2))
        await reload(store)

        // A lane delete is physical (03-board-ui.md § Trash), so "the placeholder's lane vanished in
        // the reload" is literally the whole test.
        #expect(store.snapshot.lanes.first { $0.id == lane2 } == nil)
        #expect(store.transient.newCardPlaceholder == nil)
    }

    @Test("The placeholder hands off by discarding itself the moment its real card arrives")
    func placeholderHandsOffWhenTheCardArrives() async throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)

        store.transient.beginPlaceholder(inLane: lane2)
        store.transient.updateDraft("Filed at last")
        store.transient.commitPlaceholder(expecting: card4)
        #expect(store.transient.newCardPlaceholder?.phase == .awaitingArrival(card4))

        // What the Writer's create leaves behind, and what the watcher then round-trips: a real
        // card folder under the anchor lane, carrying the minted id.
        try fixture.item("\(Ident.lane2)/\(Ident.card4)", Item.rich(order: "2048", title: "Filed at last"))
        await reload(store)

        #expect(store.snapshot.lanes.first { $0.id == lane2 }?.cards.count == 2)
        #expect(store.transient.newCardPlaceholder == nil, "the overlay's job ended when the card it stood in for landed")
    }

    @Test("The placeholder stands until its card actually arrives — a reload without it changes nothing")
    func placeholderStandsUntilItsCardArrives() async throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)

        store.transient.beginPlaceholder(inLane: lane2)
        store.transient.updateDraft("Filed at last")
        store.transient.commitPlaceholder(expecting: card4)

        // A reload lands in the gap between the Writer's create and the watcher noticing it — the
        // exact window the `.awaitingArrival` phase exists to cover. The lane is intact and the card
        // is not there yet, so neither discard rule fires.
        try fixture.item(Ident.lane3, Item.rich(order: "3072", title: "Done"))
        await reload(store)

        #expect(store.snapshot.lanes.count == 3)
        #expect(
            store.transient.newCardPlaceholder
                == NewCardPlaceholder(laneID: lane2, draftTitle: "Filed at last", phase: .awaitingArrival(card4))
        )
    }

    @Test("An unrelated reload swaps the snapshot underneath the placeholder, draft intact")
    func placeholderSurvivesAnUnrelatedReload() async throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)

        store.transient.beginPlaceholder(inLane: lane1)
        store.transient.updateDraft("Half a ti")

        // An agent files a card in the other lane mid-typing. The overlay is not a card and is not
        // in the snapshot, so it has nothing to lose here — including the keystrokes, which live
        // nowhere else.
        try fixture.item("\(Ident.lane2)/\(Ident.card4)", Item.rich(order: "2048", title: "Filed by an agent"))
        await reload(store)

        #expect(
            store.transient.newCardPlaceholder
                == NewCardPlaceholder(laneID: lane1, draftTitle: "Half a ti", phase: .editing)
        )
    }

    // MARK: The rename editor

    @Test("The rename editor seeds from the current title and records what is typed")
    func renameEditorSeedsAndTracks() throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)

        store.transient.beginRename(of: card1, currentTitle: "First")
        #expect(store.transient.renameEditor == RenameEditor(targetID: card1, draftTitle: "First"))
        #expect(store.transient.isEditingInline)

        store.transient.updateRenameDraft("First, renamed")
        #expect(store.transient.renameEditor?.draftTitle == "First, renamed")

        // An untitled item seeds *empty*, never with the word the face renders: "Untitled" is a
        // rendering, not a value (03-board-ui.md § Card face), and typing it into the file would
        // turn a missing key into a real title.
        store.transient.beginRename(of: lane2, currentTitle: nil)
        #expect(store.transient.renameEditor?.draftTitle.isEmpty == true)

        store.transient.discardRename()
        #expect(store.transient.renameEditor == nil)
        #expect(!store.transient.isEditingInline)
    }

    @Test("One focus, one editor — beginning either kind ends the other")
    func theTwoEditorsAreMutuallyExclusive() throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)

        store.transient.beginPlaceholder(inLane: lane1)
        store.transient.updateDraft("half typed")
        store.transient.beginRename(of: card1, currentTitle: "First")

        // The draft is discarded per the placeholder's own click-away rule — 02-architecture.md's
        // "starting a new creation while a placeholder is open is a click-away for the draft",
        // read in the other direction.
        #expect(store.transient.newCardPlaceholder == nil)
        #expect(store.transient.renameEditor?.targetID == card1)

        store.transient.beginPlaceholder(inLane: lane2)
        #expect(store.transient.renameEditor == nil)
        #expect(store.transient.newCardPlaceholder?.laneID == lane2)
    }

    @Test("A rename whose target is deleted from the tree is discarded")
    func renameDiscardedWhenItsTargetIsRemoved() async throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)

        store.transient.beginRename(of: card1, currentTitle: "First")
        store.transient.updateRenameDraft("Never lands")

        try FileManager.default.removeItem(at: fixture.url("\(Ident.lane1)/\(Ident.card1)"))
        await reload(store)

        // "A target that is tombstoned, deleted, or gone at commit time discards the editor and its
        // keystrokes silently" (04-interactions.md ▸ Grammar).
        #expect(store.transient.renameEditor == nil)
    }

    @Test("A rename whose target enters the trash is discarded — a container crossing is a vanish")
    func renameDiscardedWhenItsTargetIsTrashed() async throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)

        store.transient.beginRename(of: card1, currentTitle: "First")

        try fixture.move("\(Ident.lane1)/\(Ident.card1)", toTrash: Ident.card1)
        await reload(store)

        // 04 ▸ Grammar: "a target that is trashed, deleted, or gone at commit time discards the
        // editor and its keystrokes silently — entering the trash is a vanish from the board".
        #expect(store.snapshot.trash.map(\.id) == [card1])
        #expect(store.transient.renameEditor == nil)
    }

    @Test("A rename whose lane is deleted is discarded too — the card went with it")
    func renameDiscardedWhenItsLaneIsDeleted() async throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)

        store.transient.beginRename(of: card1, currentTitle: "First")

        try FileManager.default.removeItem(at: fixture.url(Ident.lane1))
        await reload(store)

        // A lane delete is physical, so the card is simply not in the snapshot — no ancestor walk.
        #expect(store.snapshot.lanes.first { $0.id == lane1 } == nil)
        #expect(store.transient.renameEditor == nil)
    }

    @Test("A rename survives a foreign move — the editor follows the UUID, not the position")
    func renameSurvivesAForeignMove() async throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)

        store.transient.beginRename(of: card1, currentTitle: "First")
        store.transient.updateRenameDraft("Still mine")

        // An agent files the card into the other lane mid-typing. "A foreign *move* mid-rename is
        // invisible — the editor follows the UUID and the commit writes the title wherever the card
        // now lives."
        try FileManager.default.moveItem(
            at: fixture.url("\(Ident.lane1)/\(Ident.card1)"),
            to: fixture.url("\(Ident.lane2)/\(Ident.card1)")
        )
        await reload(store)

        #expect(store.snapshot.lanes.first { $0.id == lane2 }?.cards.contains { $0.id == card1 } == true)
        #expect(store.transient.renameEditor == RenameEditor(targetID: card1, draftTitle: "Still mine"))
    }

    @Test("A rename of a lane survives an unrelated reload, draft intact")
    func laneRenameSurvivesAnUnrelatedReload() async throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)

        store.transient.beginRename(of: lane1, currentTitle: "Todo")
        store.transient.updateRenameDraft("To d")

        try fixture.item("\(Ident.lane2)/\(Ident.card4)", Item.rich(order: "2048", title: "Filed by an agent"))
        await reload(store)

        #expect(store.transient.renameEditor == RenameEditor(targetID: lane1, draftTitle: "To d"))
    }

    // MARK: The cursors' sole-member default

    @Test("The sole-member default is opt-out — a band that sweeps one card acquires no cursors")
    func theSoleMemberDefaultIsOptOut() throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)

        // **The default, unchanged**: a one-item selection made by any ordinary route is a legitimate
        // range origin and a legitimate place to arrow from, so `nil` takes the sole member.
        store.transient.select([card1], in: .board)
        #expect(store.transient.selectionAnchor == card1)
        #expect(store.transient.selectionHead == card1)

        // **The opt-out**: a rubber band names no click to range from and no item to arrow from
        // whatever it happens to enclose, and passing `nil` cannot say so — `nil` is what *asks* for
        // the default. `MarqueeControl.gesture` passes the flag, and this is what it buys: a band
        // narrowed onto exactly one card leaves both cursors empty, so a ⇧-click after it acts plain
        // and `LaneView.cardStack`'s scroll-to has no head to fire on mid-drag.
        store.transient.select([card2], in: .board, anchor: nil, head: nil, defaultsSoleMember: false)
        #expect(store.transient.selection.ids == [card2], "the selection itself still lands")
        #expect(store.transient.selectionAnchor == nil)
        #expect(store.transient.selectionHead == nil)

        // The flag defaults nothing away: an explicit cursor is still taken verbatim.
        store.transient.select([card2], in: .board, anchor: card2, head: card2, defaultsSoleMember: false)
        #expect(store.transient.selectionAnchor == card2)
        #expect(store.transient.selectionHead == card2)

        // And the store's funnel forwards it — the marquee calls `BoardStore.select`, not the
        // transient's directly.
        store.select([card1], in: .board, anchor: nil, head: nil, defaultsSoleMember: false)
        #expect(store.transient.selectionAnchor == nil)
        #expect(store.transient.selectionHead == nil)
    }

    // MARK: The last-active lane

    @Test("Selecting a lane or one of its cards marks it active; clearing the selection does not forget it")
    func selectionMarksTheActiveLane() throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)

        #expect(store.transient.lastActiveLaneID == nil, "a fresh board has no history to remember")

        store.select([lane2], in: .board)
        #expect(store.transient.lastActiveLaneID == lane2)

        // A *card* selection is its lane holding selection too — 04's "the lane that most recently
        // held selection or a creation".
        store.select([card1], in: .board)
        #expect(store.transient.lastActiveLaneID == lane1)

        // A cross-lane selection names no single lane, so it leaves the memory alone rather than
        // guessing at one of the two.
        store.select([card1, card3], in: .board)
        #expect(store.transient.lastActiveLaneID == lane1)

        // Deselecting does not un-happen where the user was working: ⌘N with nothing selected is
        // exactly the case the memory exists to answer.
        store.clearSelection()
        #expect(store.transient.lastActiveLaneID == lane1)
    }

    @Test("Creating into a lane marks it active, and a vanished lane is forgotten on reload")
    func creationMarksTheActiveLaneAndAVanishClearsIt() async throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)

        store.transient.beginPlaceholder(inLane: lane2)
        #expect(store.transient.lastActiveLaneID == lane2)

        try FileManager.default.removeItem(at: fixture.url(Ident.lane2))
        await reload(store)

        // A lane that is gone is no target at all; `NewCardTarget` then falls through to the first
        // lane rather than proposing the trash.
        #expect(store.transient.lastActiveLaneID == nil)
    }

    // MARK: Per-open values

    @Test("Trash visibility and the search query default per-open and pass through a reload untouched")
    func perOpenValuesDefaultAndSurviveResolve() async throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)

        // Fresh container, fresh store — the trash is hidden on every open and there is no query.
        // Nothing resets these; being built with the store is the reset.
        #expect(store.transient.isTrashVisible == false)
        #expect(store.transient.searchQuery.isEmpty)

        store.transient.isTrashVisible = true
        store.transient.searchQuery = "log"

        try fixture.item("\(Ident.lane2)/\(Ident.card4)", Item.rich(order: "2048", title: "Fix login"))
        await reload(store)

        // Neither references an item, so no snapshot can invalidate either — and the query's
        // *results* are recomputed rather than stored, which is why there is nothing else to check.
        #expect(store.transient.isTrashVisible)
        #expect(store.transient.searchQuery == "log")
    }
}
