import Foundation
import Testing
@testable import Kanban

/// What is left of the trash as a *model* once the trash became a folder — 03-board-ui.md § Trash,
/// resettled 2026-07-28 (the materialized trash).
///
/// **The suite is much smaller than the tombstone model's was, and that is the finding.** The rows
/// no longer need deriving — the trash's contents *are* `snapshot.trash`, parsed by the same card
/// parse the lanes use and already in `order` display order — so the deterministic timestamp sort,
/// the absolute ancestor walk, and the returning-card count all went with the entries they described.
/// What remains is what the *commands* need: the two purge confirmations' phrasing, and the menu
/// validation that stages Delete by place. Plus `ItemPath`, the location vocabulary that replaced
/// `TrashModel.paths`.
///
/// The snapshots are **loaded from real temp boards** rather than hand-built, for the reason every
/// other model suite here does it: only the loader produces `.trash` the way production does.
/// `WriterFixture`, `Ident` and `Item` come from `WriterTestSupport.swift`.

// MARK: - Fixtures

/// A few more literal identities than `Ident` offers — folder name is the display tie-break, so the
/// names matter.
private enum More {
    static let laneA = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"
    static let cardD = "dddddddd-dddd-4ddd-8ddd-dddddddddddd"
    static let cardE = "eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee"
    static let cardF = "ffffffff-ffff-4fff-8fff-ffffffffffff"
}

private func card(order: String, title: String, modified: String? = nil) -> String {
    // The trash sorts by `modified` descending (01-storage-format.md § Deletion, re-ruled
    // 2026-07-31), so a trash fixture states its column position here rather than in `order`.
    let stamp = modified.map { "modified: \($0)\n" } ?? ""
    return "---\nschema: 1\ntitle: \(title)\norder: \(order)\n\(stamp)---\n\(title) body.\n"
}

private func untitled(order: String) -> String {
    "---\nschema: 1\norder: \(order)\n---\nNo title.\n"
}

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

/// One lane with two cards, and three cards in the board's trash.
@MainActor
private func makeBoard() throws -> WriterFixture {
    let fixture = try WriterFixture()
    try fixture.item("", Item.board)
    try fixture.item(More.laneA, card(order: "1024", title: "Todo"))
    try fixture.item("\(More.laneA)/\(Ident.card1)", card(order: "1024", title: "First"))
    try fixture.item("\(More.laneA)/\(Ident.card2)", card(order: "2048", title: "Second"))
    // Newest-first by `modified`; the ranks disagree on purpose, so nothing here can pass by
    // accident of the retired arrival-rank rule.
    try fixture.item(".trash/\(More.cardD)", card(order: "256", title: "Oldest", modified: "2026-05-01T09:00:00Z"))
    try fixture.item(".trash/\(More.cardE)", card(order: "512", title: "Middle", modified: "2026-05-03T09:00:00Z"))
    try fixture.item(".trash/\(More.cardF)", card(order: "1024", title: "Newest", modified: "2026-05-05T09:00:00Z"))
    return fixture
}

private let laneA = ItemID(rawValue: More.laneA)
private let card1 = ItemID(rawValue: Ident.card1)
private let card2 = ItemID(rawValue: Ident.card2)
private let cardD = ItemID(rawValue: More.cardD)
private let cardE = ItemID(rawValue: More.cardE)
private let cardF = ItemID(rawValue: More.cardF)

// MARK: - The contents

/// **There is no derivation left to test** — so what this suite pins instead is that the container
/// *is* the list, in the order the column shows it, which is the pivot's whole claim.
@MainActor
@Suite("The trash's contents are the container")
struct TrashContentsTests {

    @Test("The trash is `snapshot.trash`, newest first by `modified`")
    func theContainerIsTheList() throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let snapshot = try load(fixture)

        #expect(snapshot.trash.compactMap(\.title.value) == ["Newest", "Middle", "Oldest"],
                "03 ▸ Trash: the trash sorts by `modified` descending, and entry is at the top")
        #expect(snapshot.trash.allSatisfy { $0.deleted.isMissing },
                "there is no `deleted:` key — the stamp is the position")
    }

    @Test("Lanes are never in it, whatever a hand-editor nests in there")
    func lanesAreNeverTrashed() throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        // A lane-shaped nesting inside `.trash/` is a stray by construction: the container holds
        // card folders directly, and the walk does not descend.
        try fixture.item(".trash/\(Ident.lane3)/\(Ident.card4)", card(order: "1024", title: "Nested"))
        let snapshot = try load(fixture)

        #expect(!snapshot.trash.map(\.id).contains(ItemID(rawValue: Ident.card4)))
        #expect(snapshot.lanes.map(\.id) == [laneA], "and nothing in there is a lane")
    }

    @Test("An absent container is an empty trash")
    func absentIsEmpty() throws {
        let fixture = try WriterFixture()
        defer { fixture.tearDown() }
        try fixture.item("", Item.board)
        try fixture.item(More.laneA, card(order: "1024", title: "Todo"))

        #expect(try load(fixture).trash.isEmpty)
    }
}

// MARK: - ItemPath

/// The location vocabulary that replaced `TrashModel.paths` — three cases because the board has
/// exactly three places an identity-bearing folder can be.
@MainActor
@Suite("ItemPath")
struct ItemPathTests {

    @Test("Each case resolves to the folder it names")
    func foldersResolve() throws {
        let root = URL(fileURLWithPath: "/Boards/Work.kanban", isDirectory: true)

        #expect(ItemPath.lane(laneA).folder(under: root).path == "/Boards/Work.kanban/\(More.laneA)")
        #expect(ItemPath.card(lane: laneA, id: card1).folder(under: root).path
            == "/Boards/Work.kanban/\(More.laneA)/\(Ident.card1)")
        #expect(ItemPath.trashCard(cardD).folder(under: root).path
            == "/Boards/Work.kanban/.trash/\(More.cardD)")
    }

    @Test("The container is the case, and only a lane is a lane")
    func containerAndKind() {
        #expect(ItemPath.lane(laneA).container == .board)
        #expect(ItemPath.card(lane: laneA, id: card1).container == .board)
        #expect(ItemPath.trashCard(cardD).container == .trash)

        #expect(ItemPath.lane(laneA).isLane)
        #expect(!ItemPath.card(lane: laneA, id: card1).isLane)
        #expect(!ItemPath.trashCard(cardD).isLane)
    }

    @Test("Resolution is per container, in display order, skipping what is not there")
    func resolutionIsPerContainer() throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let snapshot = try load(fixture)
        let everything: Set<ItemID> = [laneA, card1, card2, cardD, cardE, cardF]

        #expect(ItemPath.resolve(everything, in: .board, snapshot: snapshot)
            == [.lane(laneA), .card(lane: laneA, id: card1), .card(lane: laneA, id: card2)],
            "lanes left to right, each lane then its cards — never the caller's set order")
        #expect(ItemPath.resolve(everything, in: .trash, snapshot: snapshot)
            == [.trashCard(cardF), .trashCard(cardE), .trashCard(cardD)],
            "and the trash top to bottom")

        #expect(ItemPath.resolve([], in: .board, snapshot: snapshot).isEmpty)
        #expect(ItemPath.resolve([ItemID(rawValue: Ident.indexless)], in: .trash, snapshot: snapshot).isEmpty)
    }

    @Test("A lookup that spans containers finds an item wherever it is, and nothing where it is not")
    func lookupSpansContainers() throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let snapshot = try load(fixture)

        #expect(ItemPath.of(laneA, in: snapshot) == .lane(laneA))
        #expect(ItemPath.of(card1, in: snapshot) == .card(lane: laneA, id: card1))
        #expect(ItemPath.of(cardD, in: snapshot) == .trashCard(cardD))
        #expect(ItemPath.of(ItemID(rawValue: Ident.indexless), in: snapshot) == nil)
    }
}

// MARK: - The universes

@MainActor
@Suite("ItemContainer ▸ the universe")
struct ItemContainerUniverseTests {

    @Test("The board's universe is its lanes and their cards; the trash's is its cards")
    func universesArePresenceOnly() throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let snapshot = try load(fixture)

        #expect(ItemContainer.board.ids(in: snapshot) == [laneA, card1, card2])
        #expect(ItemContainer.trash.ids(in: snapshot) == [cardD, cardE, cardF])
    }

    @Test("The two universes partition the board — nothing is in both, nothing is in neither")
    func theyPartition() throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let snapshot = try load(fixture)
        let board = ItemContainer.board.ids(in: snapshot)
        let trash = ItemContainer.trash.ids(in: snapshot)

        #expect(board.isDisjoint(with: trash))
        // The tombstone model's two sides deliberately did *not* partition — a card under a
        // tombstoned lane was in neither. Presence being the whole test is what closed that gap.
        var everything: Set<ItemID> = []
        for lane in snapshot.lanes {
            everything.insert(lane.id)
            for card in lane.cards { everything.insert(card.id) }
        }
        for card in snapshot.trash { everything.insert(card.id) }
        #expect(board.union(trash) == everything)
    }
}

// MARK: - Phrasing

@Suite("TrashModel ▸ phrasing")
struct TrashPhrasingTests {

    @Test("Plurals fold, and there is only one noun left to fold")
    func pluralFolding() {
        #expect(TrashModel.phrase(1) == "1 card")
        #expect(TrashModel.phrase(41) == "41 cards")
        #expect(TrashModel.phrase(0) == "0 cards")
    }

    @MainActor
    @Test("A sole card is named; several fold into a count")
    func purgePromptNamesOrCounts() throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let snapshot = try load(fixture)

        let sole = try #require(TrashModel.purgePrompt(
            for: [cardF], snapshot: snapshot, unrecoverable: true
        ))
        #expect(sole.title == "Permanently delete \u{201C}Newest\u{201D}?")
        #expect(sole.message == "This can\u{2019}t be undone.")
        #expect(sole.confirmTitle == "Delete")

        let several = try #require(TrashModel.purgePrompt(
            for: [cardE, cardF], snapshot: snapshot, unrecoverable: true
        ))
        #expect(several.title == "Permanently delete 2 cards?")
    }

    @MainActor
    @Test("An untitled card reads as the untitled rendering, never as an empty pair of quotes")
    func untitledReadsAsARendering() throws {
        let fixture = try WriterFixture()
        defer { fixture.tearDown() }
        try fixture.item("", Item.board)
        try fixture.item(More.laneA, card(order: "1024", title: "Todo"))
        try fixture.item(".trash/\(More.cardD)", untitled(order: "1024"))
        let snapshot = try load(fixture)

        let prompt = try #require(TrashModel.purgePrompt(
            for: [cardD], snapshot: snapshot, unrecoverable: true
        ))
        #expect(prompt.title == "Permanently delete \u{201C}Untitled\u{201D}?")
    }

    @MainActor
    @Test("A set naming nothing raises no prompt — the refusal and the action agree")
    func nothingToPurgeRaisesNothing() throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let snapshot = try load(fixture)

        #expect(TrashModel.purgePrompt(
            for: [ItemID(rawValue: Ident.indexless)], snapshot: snapshot, unrecoverable: true
        ) == nil)
        #expect(TrashModel.purgePrompt(for: [], snapshot: snapshot, unrecoverable: true) == nil)
    }

    @MainActor
    @Test("Empty Trash names the true count, and the message follows recoverability")
    func emptyTrashPrompt() throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let snapshot = try load(fixture)

        let unrecoverable = try #require(TrashModel.emptyTrashPrompt(in: snapshot, unrecoverable: true))
        #expect(unrecoverable.title == "Permanently delete 3 cards?")
        #expect(unrecoverable.message == "This can\u{2019}t be undone.")

        let recoverable = try #require(TrashModel.emptyTrashPrompt(in: snapshot, unrecoverable: false))
        #expect(recoverable.message == "The board\u{2019}s history still has them.")
    }

    @MainActor
    @Test("An empty trash raises no Empty Trash prompt")
    func emptyTrashOnAnEmptyTrash() throws {
        let fixture = try WriterFixture()
        defer { fixture.tearDown() }
        try fixture.item("", Item.board)
        try fixture.item(More.laneA, card(order: "1024", title: "Todo"))

        #expect(TrashModel.emptyTrashPrompt(in: try load(fixture), unrecoverable: true) == nil)
    }
}

// MARK: - Lane freight

/// "Confirms name the freight honestly — a trashed lane's alert counts its cards" (03 § Trash,
/// re-ruled 2026-07-29). The count is `TrashedLane.heldCards`, which the loader took at load
/// precisely because the subtree it counts is deliberately not in the snapshot.
@MainActor
@Suite("TrashModel ▸ lane freight in the confirmations")
struct TrashFreightTests {

    /// A board whose trash holds one card and one lane carrying `held` cards.
    private func makeFreightBoard(held: Int) throws -> WriterFixture {
        let fixture = try WriterFixture()
        try fixture.item("", Item.board)
        try fixture.item(More.laneA, card(order: "1024", title: "Todo"))
        try fixture.item(
            ".trash/\(More.cardD)", card(order: "1024", title: "A card", modified: "2026-05-01T09:00:00Z"))
        try fixture.item(
            ".trash/\(Ident.lane2)",
            "---\nschema: 1\ntitle: Doing\norder: 512\nmodified: 2026-05-03T09:00:00Z\nkind: lane\n---\n")
        for index in 0 ..< held {
            try fixture.item(
                ".trash/\(Ident.lane2)/\(UUID().uuidString.lowercased())",
                card(order: "\((index + 1) * 1024)", title: "Freight \(index)")
            )
        }
        return fixture
    }

    /// 03's own phrasing, verbatim: "Permanently delete lane 'Doing' and its 5 cards".
    @Test("A sole trashed lane names itself and its freight")
    func soleLaneNamesItsFreight() throws {
        let fixture = try makeFreightBoard(held: 5)
        defer { fixture.tearDown() }
        let snapshot = try load(fixture)

        let prompt = try #require(TrashModel.purgePrompt(
            for: [ItemID(rawValue: Ident.lane2)], snapshot: snapshot, unrecoverable: true
        ))
        #expect(prompt.title == "Permanently delete lane \u{201C}Doing\u{201D} and its 5 cards?")
        #expect(prompt.message == "This can\u{2019}t be undone.")
    }

    /// An empty lane has no freight clause to add — "2 lanes containing 0 cards" would say less
    /// than naming the lane.
    @Test("An empty trashed lane names only itself")
    func emptyLaneNamesOnlyItself() throws {
        let fixture = try makeFreightBoard(held: 0)
        defer { fixture.tearDown() }
        let snapshot = try load(fixture)

        let prompt = try #require(TrashModel.purgePrompt(
            for: [ItemID(rawValue: Ident.lane2)], snapshot: snapshot, unrecoverable: true
        ))
        #expect(prompt.title == "Permanently delete lane \u{201C}Doing\u{201D}?")
    }

    @Test("A sole held card folds to the singular")
    func oneHeldCardFoldsSingular() throws {
        let fixture = try makeFreightBoard(held: 1)
        defer { fixture.tearDown() }

        let prompt = try #require(TrashModel.purgePrompt(
            for: [ItemID(rawValue: Ident.lane2)], snapshot: try load(fixture), unrecoverable: true
        ))
        #expect(prompt.title == "Permanently delete lane \u{201C}Doing\u{201D} and its 1 card?")
    }

    /// 03's other example phrasing: "… 41 cards and 2 lanes containing 9 more cards".
    @Test("Empty Trash counts both kinds and the lanes' freight")
    func emptyTrashCountsFreight() throws {
        let fixture = try makeFreightBoard(held: 5)
        defer { fixture.tearDown() }

        let prompt = try #require(TrashModel.emptyTrashPrompt(in: try load(fixture), unrecoverable: true))
        #expect(prompt.title == "Permanently delete 1 card and 1 lane containing 5 more cards?")
    }

    /// The subject builder as a pure value, so the phrasing rules are pinned without a board:
    /// **"more" only where cards precede it**, and no clause for freight that is not there.
    @Test("The aggregate subject folds every shape")
    func theSubjectFolds() {
        func subject(cards: Int, lanes: Int, held: Int) -> String {
            TrashModel.subject(for: TrashModel.Freight(cards: cards, lanes: lanes, heldCards: held))
        }
        #expect(subject(cards: 41, lanes: 0, held: 0) == "41 cards")
        #expect(subject(cards: 1, lanes: 0, held: 0) == "1 card")
        #expect(subject(cards: 41, lanes: 2, held: 9) == "41 cards and 2 lanes containing 9 more cards")
        #expect(subject(cards: 0, lanes: 2, held: 9) == "2 lanes containing 9 cards",
                "nothing precedes it, so nothing is 'more'")
        #expect(subject(cards: 0, lanes: 1, held: 1) == "1 lane containing 1 card")
        #expect(subject(cards: 3, lanes: 1, held: 0) == "3 cards and 1 lane")
    }

    /// A trashed lane is deletable from the trash like any entry — the menu validation is by
    /// container, and the kind never enters it.
    @Test("Delete is enabled on a trash lane selection, and resolves to a purgeable path")
    func laneRowsValidate() throws {
        let fixture = try makeFreightBoard(held: 2)
        defer { fixture.tearDown() }
        let snapshot = try load(fixture)
        let selection = ItemReferenceSet(ids: [ItemID(rawValue: Ident.lane2)], container: .trash)

        #expect(TrashModel.canDelete(selection: selection, in: snapshot))
        #expect(ItemPath.resolve(selection.ids, in: .trash, snapshot: snapshot)
            == [.trashLane(ItemID(rawValue: Ident.lane2))])
    }

    /// The column is one list: `resolve` hands back the trash's paths interleaved by `modified`,
    /// because a batch's order is the column's order (03 § Trash).
    @Test("Resolution interleaves the container's two kinds by the column's own order")
    func resolutionInterleaves() throws {
        let fixture = try makeFreightBoard(held: 0)
        defer { fixture.tearDown() }
        let snapshot = try load(fixture)

        // The lane's stamp is the newer of the two, so the lane row comes first.
        #expect(ItemPath.resolve(
            [ItemID(rawValue: Ident.lane2), cardD], in: .trash, snapshot: snapshot
        ) == [.trashLane(ItemID(rawValue: Ident.lane2)), .trashCard(cardD)])
    }
}

// MARK: - Menu validation

@MainActor
@Suite("TrashModel ▸ validation")
struct TrashValidationTests {

    /// **One Delete, one predicate.** The tombstone model needed a mirror-image pair so two ⌘⌫ twins
    /// could enable exactly one of themselves; Put Back's retirement left one item, so the predicate
    /// is "does this selection name anything", asked in the selection's own container.
    @Test("Delete enables for either container, and for nothing that names nothing")
    func canDeleteIsStagedNotSplit() throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let snapshot = try load(fixture)

        #expect(TrashModel.canDelete(selection: ItemReferenceSet(ids: [card1], container: .board), in: snapshot))
        #expect(TrashModel.canDelete(selection: ItemReferenceSet(ids: [laneA], container: .board), in: snapshot))
        #expect(TrashModel.canDelete(selection: ItemReferenceSet(ids: [cardD], container: .trash), in: snapshot))

        #expect(!TrashModel.canDelete(selection: .empty, in: snapshot))
        // A selection the next reload will drop: the id is real, but not in the container it claims.
        #expect(!TrashModel.canDelete(
            selection: ItemReferenceSet(ids: [card1], container: .trash), in: snapshot
        ))
        #expect(!TrashModel.canDelete(
            selection: ItemReferenceSet(ids: [cardD], container: .board), in: snapshot
        ))
    }
}
