import Foundation
import Testing
@testable import Kanban

/// 04-interactions.md's **⌘N target rule**, branch by branch.
///
/// The rule is written as a pure function precisely so it can be tested like one: every branch is a
/// selection plus a snapshot in, a lane-and-anchor (or nothing) out — no menu, no window, no
/// gesture. The board underneath is a real load off a real temp tree, because the rule reads
/// `isDeleted` and card ordering and a hand-built `BoardModel` would let those drift from what the
/// loader actually produces.

// MARK: - Fixtures

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

private func tombstoned(order: String, title: String) -> String {
    """
    ---
    schema: 1
    title: \(title)
    order: \(order)
    deleted: 2026-03-03T09:00:00Z
    ---
    \(title) body.

    """
}

/// Two live lanes (three cards between them) and one tombstoned lane, so every branch has something
/// to point at and the trash side has a member of its own.
@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 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 func resolve(
    _ snapshot: BoardModel,
    selection: ItemReferenceSet = .empty,
    lastActive: ItemID? = nil
) -> NewCardTarget.Resolution? {
    NewCardTarget.resolve(selection: selection, lastActiveLaneID: lastActive, snapshot: snapshot)
}

// MARK: - Tests

@MainActor
@Suite("NewCardTarget ▸ the ⌘N target rule")
struct NewCardTargetTests {

    @Test("A sole selected card targets its own lane, immediately after it")
    func aSelectedCardAnchorsInItsLane() throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let snapshot = try BoardLoader.load(boardRoot: fixture.root).model

        // "With a card selected, the new card is created in that card's lane, immediately after it
        // (paste-anchor consistency)."
        #expect(resolve(snapshot, selection: ItemReferenceSet(ids: [card1], container: .board))
            == NewCardTarget.Resolution(laneID: lane1, anchorCardID: card1))
        #expect(resolve(snapshot, selection: ItemReferenceSet(ids: [card3], container: .board))
            == NewCardTarget.Resolution(laneID: lane2, anchorCardID: card3))

        // The last card in a lane is still an anchor here — "after the last card" and "at the
        // bottom" coincide, and it is the commit that notices (`BoardStore.insertionIndex`).
        #expect(resolve(snapshot, selection: ItemReferenceSet(ids: [card2], container: .board))
            == NewCardTarget.Resolution(laneID: lane1, anchorCardID: card2))
    }

    @Test("A sole selected lane targets its bottom, with no anchor")
    func aSelectedLaneTargetsItsBottom() throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let snapshot = try BoardLoader.load(boardRoot: fixture.root).model

        // "With a lane selected, appended at its bottom (Return consistency)."
        #expect(resolve(snapshot, selection: ItemReferenceSet(ids: [lane2], container: .board))
            == NewCardTarget.Resolution(laneID: lane2, anchorCardID: nil))
    }

    @Test("Nothing selected falls to the last-active lane")
    func nothingSelectedUsesTheLastActiveLane() throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let snapshot = try BoardLoader.load(boardRoot: fixture.root).model

        #expect(resolve(snapshot, lastActive: lane2)
            == NewCardTarget.Resolution(laneID: lane2, anchorCardID: nil))

        // And the last resort when there is no memory to consult, or the lane it names is gone:
        // "falling back to the first lane".
        #expect(resolve(snapshot) == NewCardTarget.Resolution(laneID: lane1, anchorCardID: nil))
        #expect(resolve(snapshot, lastActive: ItemID(rawValue: Ident.indexless))
            == NewCardTarget.Resolution(laneID: lane1, anchorCardID: nil))
        // A lane that has been deleted is not a target either — a lane delete is physical, so the
        // memory simply names nothing.
        #expect(resolve(snapshot, lastActive: lane3)
            == NewCardTarget.Resolution(laneID: lane1, anchorCardID: nil))
    }

    @Test("A trash selection never anchors creation — it behaves as nothing selected")
    func aTrashSelectionNeverAnchors() throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let snapshot = try BoardLoader.load(boardRoot: fixture.root).model

        // 04 ▸ The map, on the ⌘N rule's own wording: "with nothing selected — or a **trash**
        // selection, which never anchors creation". A trashed card's board-side lane must not leak
        // in as a target.
        let inTrash = ItemReferenceSet(ids: [card1], container: .trash)
        #expect(resolve(snapshot, selection: inTrash, lastActive: lane2)
            == NewCardTarget.Resolution(laneID: lane2, anchorCardID: nil))
        #expect(resolve(snapshot, selection: inTrash)
            == NewCardTarget.Resolution(laneID: lane1, anchorCardID: nil))
    }

    @Test("A zero-lane board has no target at all — the menu item's disabled condition")
    func aZeroLaneBoardHasNoTarget() throws {
        let fixture = try WriterFixture()
        defer { fixture.tearDown() }
        try fixture.item("", Item.board)
        let empty = try BoardLoader.load(boardRoot: fixture.root).model

        // "Zero-lane board (hand-made, or every lane deleted): card creation and card paste have no
        // target — New Card, Return-creation, and Paste with a card payload disable via menu
        // validation until a lane exists."
        #expect(resolve(empty) == nil)
        #expect(resolve(empty, selection: ItemReferenceSet(ids: [card1], container: .board)) == nil)
        #expect(resolve(empty, lastActive: lane1) == nil)
    }

    @Test("A board whose every lane has been deleted is a zero-lane board")
    func everyLaneDeletedIsAlsoZeroLane() throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        for lane in [Ident.lane1, Ident.lane2, Ident.lane3] where fixture.exists(lane) {
            try FileManager.default.removeItem(at: fixture.url(lane))
        }
        let snapshot = try BoardLoader.load(boardRoot: fixture.root).model

        // "Every lane deleted" is the design's own second reading of the zero-lane case — and a lane
        // delete is physical now, so it is literally the same board as the hand-made one above.
        #expect(snapshot.lanes.isEmpty)
        #expect(resolve(snapshot) == nil)
    }

    @Test("A multi-selection anchors at its last member in flatten order")
    func pluralSelectionsAnchorAtTheirLastMember() throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let snapshot = try BoardLoader.load(boardRoot: fixture.root).model

        // "A multi-selection anchors at its last member in flatten order (lane `order`, then card
        // `order`, the multi-drag order)": card3 lives in lane2, which sorts after lane1's pair, so
        // creation follows card3 — the last-active lane never enters into it.
        #expect(resolve(snapshot, selection: ItemReferenceSet(ids: [card1, card3], container: .board), lastActive: lane1)
            == NewCardTarget.Resolution(laneID: lane2, anchorCardID: card3))

        // Within one lane the flatten order is card order: card2 sorts after card1.
        #expect(resolve(snapshot, selection: ItemReferenceSet(ids: [card1, card2], container: .board), lastActive: lane2)
            == NewCardTarget.Resolution(laneID: lane1, anchorCardID: card2))

        // A multi-LANE selection appends to the last selected lane's bottom.
        #expect(resolve(snapshot, selection: ItemReferenceSet(ids: [lane1, lane2], container: .board))
            == NewCardTarget.Resolution(laneID: lane2, anchorCardID: nil))
    }

    @Test("A stale selection falls through rather than guessing or refusing")
    func staleSelectionsFallThrough() throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let snapshot = try BoardLoader.load(boardRoot: fixture.root).model

        // A selection naming something the board does not render — the reload that drops it has not
        // landed yet — must not refuse the creation the user just asked for.
        #expect(resolve(snapshot, selection: ItemReferenceSet(ids: [ItemID(rawValue: Ident.indexless)], container: .board))
            == NewCardTarget.Resolution(laneID: lane1, anchorCardID: nil))
    }

    /// **⌘N skips collapsed lanes** (03-board-ui.md § Lane ▸ Collapsed lanes): the placeholder is a
    /// pseudo-card drawn in the lane's masonry, and a folded lane draws none — so a creation there would
    /// be a focused text field rendered nowhere.
    @Test("A folded lane is never a creation target, in any branch")
    func foldedLanesAreSkipped() throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        try fixture.item(Ident.lane1, "---\nschema: 1\ntitle: Todo\norder: 1024\ncollapsed: true\n---\n\n")
        let snapshot = try BoardLoader.load(boardRoot: fixture.root).model

        // The default fall-through skips it: the first *open* lane is the target.
        #expect(resolve(snapshot) == NewCardTarget.Resolution(laneID: lane2, anchorCardID: nil))
        // And so does the last-active lane, which may well be the lane the user just folded.
        #expect(resolve(snapshot, lastActive: lane1) == NewCardTarget.Resolution(laneID: lane2, anchorCardID: nil))

        // A selection inside it **falls through** rather than refusing — the stale selection's rule,
        // for its reason: the user pressed ⌘N and the board has lanes it can create into.
        #expect(resolve(snapshot, selection: ItemReferenceSet(ids: [card1], container: .board))
            == NewCardTarget.Resolution(laneID: lane2, anchorCardID: nil))
        #expect(resolve(snapshot, selection: ItemReferenceSet(ids: [lane1], container: .board))
            == NewCardTarget.Resolution(laneID: lane2, anchorCardID: nil))

        // A selection in the open lane is untouched by any of it.
        #expect(resolve(snapshot, selection: ItemReferenceSet(ids: [card3], container: .board))
            == NewCardTarget.Resolution(laneID: lane2, anchorCardID: card3))
    }

    @Test("A board whose every lane is folded has no target at all — the zero-lane answer")
    func everyLaneFoldedRefuses() throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        try fixture.item(Ident.lane1, "---\nschema: 1\ntitle: Todo\norder: 1024\ncollapsed: true\n---\n\n")
        try fixture.item(Ident.lane2, "---\nschema: 1\ntitle: Doing\norder: 2048\ncollapsed: true\n---\n\n")
        let snapshot = try BoardLoader.load(boardRoot: fixture.root).model

        // `nil` is New Card's `disabled` condition as well as its refusal, so the item greys out
        // rather than doing nothing when pressed.
        #expect(resolve(snapshot) == nil)
        #expect(resolve(snapshot, lastActive: lane1) == nil)
        #expect(resolve(snapshot, selection: ItemReferenceSet(ids: [card1], container: .board)) == nil)
    }
}
