import Foundation
import Testing
@testable import Kanban

/// **Change narrator — the semantic message spec** (06-history-undo.md ▸ Commit messages) — the
/// vocabulary, the folding, the trash pair, the bookkeeping silence, the path-shaped events, and the
/// external gap the section exists to close.
///
/// **No repository anywhere in this file.** The narrator is "a pure, testable function": two board
/// snapshots and a changed-path list in, one message out. Both snapshots are written as bytes and
/// read back through the real `BoardLoader` (`WriterFixture.snapshot()`'s reason, restated: a
/// hand-assembled `BoardModel` would be a value the loader can never produce), and the changed-path
/// list is derived by comparing the two trees — which is what git would have reported, arrived at
/// without git. The engine-level half — that a real commit carries this message, by the real author —
/// lives in `AutoCommitTests`.

// MARK: - Fixtures

/// The board both snapshots start as: three lanes, two cards in the first.
private func baseBoard(_ fixture: WriterFixture) throws {
    try fixture.board(title: "Board")
    try fixture.lane(Ident.lane1, order: "1024", title: "Todo")
    try fixture.lane(Ident.lane2, order: "2048", title: "Doing")
    try fixture.lane(Ident.lane3, order: "3072", title: "Done")
    try fixture.card(Ident.card1, in: Ident.lane1, order: "1024", title: "Fix login")
    try fixture.card(Ident.card2, in: Ident.lane1, order: "2048", title: "Ship it")
}

/// The base board with its first lane already folded — the "before" an Expand needs
/// (03-board-ui.md § Lane ▸ Collapsed lanes).
private func baseBoardWithFoldedTodo(_ fixture: WriterFixture) throws {
    try baseBoard(fixture)
    try fixture.item(Ident.lane1, "---\nschema: 1\ntitle: Todo\norder: 1024\ncollapsed: true\n---\n\n")
}

/// A UUID-shaped name `Ident` does not already spend on the base board — the trash suites need one
/// more identity than the fixture offers, and reusing a live card's would be a duplicate the loader
/// would rightly withhold.
private let spareIdentity = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"

/// Every file under a root, keyed by its board-root-relative path in git's spelling.
private func files(under root: URL) -> [String: Data] {
    var found: [String: Data] = [:]
    let manager = FileManager.default
    guard let walker = manager.enumerator(atPath: root.path) else { return found }
    for case let relative as String in walker {
        let url = root.appendingPathComponent(relative)
        var isDirectory: ObjCBool = false
        guard manager.fileExists(atPath: url.path, isDirectory: &isDirectory), !isDirectory.boolValue
        else { continue }
        found[relative] = (try? Data(contentsOf: url)) ?? Data()
    }
    return found
}

/// The changed-path list a repository survey would have reported for these two trees — a plain
/// content comparison, since nothing here has a repository to ask.
private func changedPaths(from before: URL, to after: URL) -> [ChangedPath] {
    let old = files(under: before)
    let new = files(under: after)
    var paths: [ChangedPath] = []
    for (path, data) in new where old[path] != data {
        // `isArrival` is git's own `GIT_DELTA_ADDED`, which here is simply "HEAD did not have it".
        paths.append(ChangedPath(
            path: path,
            isDeletion: false,
            isRename: false,
            isArrival: old[path] == nil
        ))
    }
    for path in old.keys where new[path] == nil {
        paths.append(ChangedPath(path: path, isDeletion: true, isRename: false))
    }
    return paths.sorted { $0.path < $1.path }
}

/// Builds the same board twice, changes one copy, and composes the message for the difference.
///
/// - Parameters:
///   - board: the state both snapshots start in.
///   - change: what happened — applied to the "after" copy only.
///   - authorship: the commit's class. **Defaults to `.user`, and almost every test leaves it there**
///     because 06 rules that it must not matter: "a foreign move reads 'Move card …' exactly like an
///     app-mediated one". The one suite that varies it proves exactly that.
///   - renames: paths the survey would have paired as renames, for the shapes that turn on it.
private func compose(
    board: (WriterFixture) throws -> Void = baseBoard,
    change: (WriterFixture) throws -> Void,
    authorship: ChangeAuthorship = .user,
    renames: Set<String> = [],
    guideText: String? = nil
) throws -> String {
    let before = try WriterFixture()
    defer { before.tearDown() }
    let after = try WriterFixture()
    defer { after.tearDown() }

    try board(before)
    try board(after)
    try change(after)

    let paths = changedPaths(from: before.root, to: after.root).map { path in
        renames.contains(path.path)
            ? ChangedPath(
                path: path.path,
                isDeletion: path.isDeletion,
                isRename: true,
                isArrival: path.isArrival
            )
            : path
    }
    return ChangeNarrator.narrative(for: ChangeNarrationRequest(
        boardRoot: after.root,
        changedPaths: paths,
        authorship: authorship,
        isRootCommit: false,
        snapshot: try after.snapshot(),
        previousSnapshot: try before.snapshot(),
        agentGuideText: guideText,
        // Resolved the way a flush resolves it — off the "after" tree, through the narrator's own
        // reader — rather than hand-assembled, for the same reason both snapshots are loaded rather
        // than built: a map the flush could never produce would prove nothing about the flush.
        commentTimestamps: ChangeNarrator.commentTimestamps(for: paths, boardRoot: after.root)
    ))
}

private func subject(of message: String) -> String {
    String(message.split(separator: "\n", omittingEmptySubsequences: false).first ?? "")
}

private func body(of message: String) -> [String] {
    let lines = message.split(separator: "\n", omittingEmptySubsequences: false).map(String.init)
    guard lines.count > 2 else { return [] }
    return Array(lines.dropFirst(2))
}

// MARK: - One event, one subject

@Suite("Change narrator ▸ a single event is the subject")
struct CommitMessageSingleEventTests {

    @Test("Adding a card names the card, with its destination as the detail")
    func addingACard() throws {
        let message = try compose { fixture in
            try fixture.card(Ident.card3, in: Ident.lane2, order: "1024", title: "Write tests")
        }
        #expect(subject(of: message) == "Add card 'Write tests'")
        #expect(body(of: message) == ["to Doing"])
    }

    @Test("A cross-lane move reads as a move — never delete plus add")
    func movingACard() throws {
        // The whole-board id match: the folder left one lane and arrived in another, and the
        // composer is asked to tell that from a deletion beside an arrival.
        let message = try compose { fixture in
            try fixture.moveFolder("\(Ident.lane1)/\(Ident.card1)", to: "\(Ident.lane3)/\(Ident.card1)")
        }
        #expect(subject(of: message) == "Move card 'Fix login' to Done")
        #expect(body(of: message) == ["Todo → Done"])
    }

    @Test("A rename carries both names in the subject — 06's own form")
    func renamingALane() throws {
        let message = try compose { fixture in
            try fixture.lane(Ident.lane1, order: "1024", title: "Backlog")
        }
        #expect(message == "Rename lane 'Todo' → 'Backlog'")
    }

    @Test("An edited body is an Edit, and a restyle is a Restyle")
    func editingAndRestyling() throws {
        let edited = try compose { fixture in
            try fixture.card(Ident.card1, in: Ident.lane1, order: "1024", title: "Fix login", body: "Now with detail.")
        }
        #expect(edited == "Edit card 'Fix login'")

        let restyled = try compose { fixture in
            try fixture.item(
                "\(Ident.lane1)/\(Ident.card1)",
                "---\nschema: 1\ntitle: Fix login\norder: 1024\nbackground: {color: blue}\n---\n\n"
            )
        }
        #expect(restyled == "Restyle card 'Fix login'")
    }

    @Test("A widened lane is a Resize")
    func resizingALane() throws {
        let message = try compose { fixture in
            try fixture.lane(Ident.lane1, order: "1024", title: "Todo", width: 2)
        }
        #expect(message == "Resize lane 'Todo' to 2×")
    }

    /// **The fold has its own two words** (03-board-ui.md § Lane ▸ Collapsed lanes; 06 ▸ Commit
    /// messages' vocabulary), told apart by diff shape alone exactly as the trash pair is.
    @Test("A folded lane is a Collapse, and an unfolded one an Expand")
    func collapsingALane() throws {
        let collapsed = try compose { fixture in
            try fixture.item(Ident.lane1, "---\nschema: 1\ntitle: Todo\norder: 1024\ncollapsed: true\n---\n\n")
        }
        #expect(collapsed == "Collapse lane 'Todo'")

        let expanded = try compose(
            board: { fixture in
                try baseBoardWithFoldedTodo(fixture)
            },
            change: { fixture in
                try fixture.lane(Ident.lane1, order: "1024", title: "Todo")
            }
        )
        #expect(expanded == "Expand lane 'Todo'")
    }

    /// The narrator composes off the **reading**, not the key: `collapsed: false` beside an absent key
    /// moves no lane on screen, so it is the "sequence, not raw `order`" discipline one field over.
    @Test("A `collapsed: false` that changes no lane composes nothing")
    func anExplicitFalseIsNotAnEvent() throws {
        let message = try compose { fixture in
            try fixture.item(Ident.lane1, "---\nschema: 1\ntitle: Todo\norder: 1024\ncollapsed: false\n---\n\n")
        }
        #expect(message == ChangeNarrator.unnamedSubject)
    }

    @Test("An attachment composes Attach, named by the card it landed on")
    func attachingAFile() throws {
        let message = try compose { fixture in
            try fixture.file("\(Ident.lane1)/\(Ident.card1)/attachments/spec.pdf", Data("pdf".utf8))
        }
        #expect(message == "Attach 'spec.pdf' to card 'Fix login'")
    }

    @Test("A removed attachment composes Remove")
    func removingAFile() throws {
        let message = try compose { fixture in
            try baseBoard(fixture)
            try fixture.file("\(Ident.lane1)/\(Ident.card1)/attachments/spec.pdf", Data("pdf".utf8))
        } change: { fixture in
            try FileManager.default.removeItem(
                at: fixture.url("\(Ident.lane1)/\(Ident.card1)/attachments").appendingPathComponent("spec.pdf")
            )
        }
        #expect(message == "Remove 'spec.pdf' from card 'Fix login'")
    }

    @Test("A rewritten attachment composes Replace — never the anonymous path generic")
    func replacingAFile() throws {
        // Added 2026-07-31: "a changed file under a card's `attachments/` with an unchanged listing is
        // a content replacement, named from the path alone". The listing is unchanged here — same
        // name, new bytes — so the snapshot diff has nothing to say and the path says it instead.
        let message = try compose { fixture in
            try baseBoard(fixture)
            try fixture.file("\(Ident.lane1)/\(Ident.card1)/attachments/photo.png", Data("old".utf8))
        } change: { fixture in
            try fixture.file("\(Ident.lane1)/\(Ident.card1)/attachments/photo.png", Data("new".utf8))
        }
        #expect(message == "Replace attachment 'photo.png' — card 'Fix login'")
    }

    @Test("Two replaced attachments on one card fold plural, still naming the card")
    func replacingSeveralFiles() throws {
        let message = try compose { fixture in
            try baseBoard(fixture)
            try fixture.file("\(Ident.lane1)/\(Ident.card1)/attachments/photo.png", Data("old".utf8))
            try fixture.file("\(Ident.lane1)/\(Ident.card1)/attachments/spec.pdf", Data("old".utf8))
        } change: { fixture in
            try fixture.file("\(Ident.lane1)/\(Ident.card1)/attachments/photo.png", Data("new".utf8))
            try fixture.file("\(Ident.lane1)/\(Ident.card1)/attachments/spec.pdf", Data("new".utf8))
        }
        #expect(subject(of: message) == "Replace 2 attachments — card 'Fix login'")
        #expect(body(of: message) == [
            "- Replace attachment 'photo.png' — card 'Fix login'",
            "- Replace attachment 'spec.pdf' — card 'Fix login'",
        ])
    }

    @Test("A repositioned card composes Reorder, naming its lane")
    func reorderingCards() throws {
        // A *foreign* single-file reorder: one card's rank crosses its sibling's, nothing else
        // changes. "An order change that repositions an item among its siblings composes Reorder."
        let message = try compose { fixture in
            try fixture.card(Ident.card1, in: Ident.lane1, order: "4096", title: "Fix login")
        }
        #expect(message == "Reorder cards in Todo")
    }

    @Test("The board's own fields compose board events")
    func boardLevelEvents() throws {
        let renamed = try compose { fixture in
            try fixture.board(title: "Q3 Plan")
        }
        #expect(renamed == "Rename board 'Board' → 'Q3 Plan'")

        let described = try compose { fixture in
            try fixture.board(title: "Board", body: "What this board is for.")
        }
        #expect(described == "Edit board description")
    }
}

// MARK: - The trash pair

@Suite("Change narrator ▸ the trash pair, by diff shape alone")
struct CommitMessageTrashTests {

    @Test("A move into .trash/ is Delete")
    func intoTheTrashIsDelete() throws {
        let message = try compose { fixture in
            try fixture.move("\(Ident.lane1)/\(Ident.card1)", toTrash: Ident.card1)
        }
        #expect(message == "Delete card 'Fix login'")
    }

    @Test("A move out of .trash/ is Restore")
    func outOfTheTrashIsRestore() throws {
        let message = try compose { fixture in
            try baseBoard(fixture)
            try fixture.trashCard(Ident.card3, order: "1024", title: "Old idea")
        } change: { fixture in
            try fixture.move(".trash/\(Ident.card3)", toLane: Ident.lane2, card: Ident.card3)
        }
        #expect(message == "Restore card 'Old idea'")
    }

    @Test("Leaving the tree entirely is Permanently delete")
    func leavingTheTreeIsPermanent() throws {
        let message = try compose { fixture in
            try baseBoard(fixture)
            try fixture.trashCard(Ident.card3, order: "1024", title: "Old idea")
        } change: { fixture in
            try FileManager.default.removeItem(at: fixture.url(".trash/\(Ident.card3)"))
        }
        #expect(message == "Permanently delete card 'Old idea'")
    }

    @Test("Empty Trash folds — 'Permanently delete 3 cards', distinct from a multi-select delete")
    func emptyTrashFolds() throws {
        let purge = try compose { fixture in
            try baseBoard(fixture)
            try fixture.trashCard(Ident.card3, order: "1024", title: "One")
            try fixture.trashCard(Ident.card4, order: "2048", title: "Two")
            try fixture.trashCard(spareIdentity, order: "3072", title: "Three")
        } change: { fixture in
            try FileManager.default.removeItem(at: fixture.url(".trash"))
        }
        #expect(subject(of: purge) == "Permanently delete 3 cards")

        let delete = try compose { fixture in
            try baseBoard(fixture)
            try fixture.card(Ident.card3, in: Ident.lane1, order: "3072", title: "Third")
        } change: { fixture in
            for card in [Ident.card1, Ident.card2, Ident.card3] {
                try fixture.move("\(Ident.lane1)/\(card)", toTrash: card)
            }
        }
        #expect(subject(of: delete) == "Delete 3 cards")
    }

    @Test("A trashed lane takes its cards with it — 'Delete lane', cards as bullets")
    func aTrashedLaneKeepsTheSubject() throws {
        // "Implied events don't steal the subject: deleting a lane with five cards reads 'Delete lane
        // 'X'' with the card deletions as body bullets — not 'Update board'." And the cards are
        // *deleted*, not permanently deleted: they went into the trash inside their lane's folder.
        let message = try compose { fixture in
            try fixture.moveFolder(Ident.lane1, to: ".trash/\(Ident.lane1)")
        }
        #expect(subject(of: message) == "Delete lane 'Todo'")
        #expect(body(of: message) == [
            "- Delete lane 'Todo'",
            "- Delete card 'Fix login'",
            "- Delete card 'Ship it'",
        ])
    }
}

// MARK: - Folding

@Suite("Change narrator ▸ folding")
struct CommitMessageFoldingTests {

    @Test("Several of one kind fold, and a shared destination survives into the subject")
    func sharedDestinationsFold() throws {
        let message = try compose { fixture in
            try baseBoard(fixture)
            try fixture.card(Ident.card3, in: Ident.lane1, order: "3072", title: "Third")
        } change: { fixture in
            for card in [Ident.card1, Ident.card2, Ident.card3] {
                try fixture.moveFolder("\(Ident.lane1)/\(card)", to: "\(Ident.lane3)/\(card)")
            }
        }
        #expect(subject(of: message) == "Move 3 cards to Done")
        #expect(body(of: message).count == 3)
        #expect(body(of: message).first == "- Move card 'Fix login' from Todo to Done")
    }

    @Test("Destinations that disagree drop out of the subject rather than lying")
    func disagreeingDestinationsDropOut() throws {
        let message = try compose(change: { fixture in
            try fixture.moveFolder("\(Ident.lane1)/\(Ident.card1)", to: "\(Ident.lane2)/\(Ident.card1)")
            try fixture.moveFolder("\(Ident.lane1)/\(Ident.card2)", to: "\(Ident.lane3)/\(Ident.card2)")
        })
        #expect(subject(of: message) == "Move 2 cards")
    }

    @Test("A genuinely mixed window says so — with every event in the body")
    func mixedWindowsFallBack() throws {
        // Re-ruled 2026-07-31: the bare "Update board" is retired. Two cards, so no shared item and
        // no name to keep — "Mixed update — N changes", never a shrug dressed as one thing.
        let message = try compose { fixture in
            try fixture.card(Ident.card1, in: Ident.lane1, order: "1024", title: "Fix logout")
            try fixture.card(Ident.card3, in: Ident.lane2, order: "1024", title: "Write tests")
        }
        #expect(subject(of: message) == "Mixed update — 2 changes")
        #expect(body(of: message) == [
            "- Add card 'Write tests' to Doing",
            "- Rename card 'Fix login' → 'Fix logout'",
        ])
    }

    @Test("A deleted lane's cards are body bullets, never the subject")
    func impliedEventsDoNotStealTheSubject() throws {
        let message = try compose { fixture in
            try FileManager.default.removeItem(at: fixture.url(Ident.lane1))
        }
        #expect(subject(of: message) == "Permanently delete lane 'Todo'")
        #expect(body(of: message) == [
            "- Permanently delete lane 'Todo'",
            "- Permanently delete card 'Fix login'",
            "- Permanently delete card 'Ship it'",
        ])
    }
}

// MARK: - The external gap

@Suite("Change narrator ▸ the external gap, closed")
struct CommitMessageExternalSurfaceTests {

    @Test("Labels, assignees and due dates each compose a named subject")
    func theReservedTrioComposes() throws {
        func withKey(_ line: String) throws -> String {
            try compose { fixture in
                try fixture.item(
                    "\(Ident.lane1)/\(Ident.card1)",
                    "---\nschema: 1\ntitle: Fix login\norder: 1024\n\(line)\n---\n\n"
                )
            }
        }
        #expect(try withKey("labels: [bug, urgent]") == "Relabel card 'Fix login'")
        #expect(try withKey("assignees: [ada]") == "Assign card 'Fix login'")
        #expect(try withKey("due: 2026-08-31") == "Set due date on card 'Fix login'")
    }

    @Test("An unmodeled custom key says what it is, with its values in the body")
    func customKeysSayWhatTheyAre() throws {
        // Re-ruled 2026-07-31: the named generic ("Update card 'X'") is retired here — "first lines
        // self-describe; generics are a last resort". One key, so the singular.
        let message = try compose { fixture in
            try fixture.item(
                "\(Ident.lane1)/\(Ident.card1)",
                "---\nschema: 1\ntitle: Fix login\norder: 1024\nsprint: 42\n---\n\n"
            )
        }
        #expect(subject(of: message) == "Change custom key on card 'Fix login'")
        #expect(body(of: message) == ["sprint: (none) → 42"])
    }

    @Test("Several custom keys fold plural on one item, each named with its old → new values")
    func customKeysFoldPlural() throws {
        let message = try compose { fixture in
            try baseBoard(fixture)
            try fixture.item(
                "\(Ident.lane1)/\(Ident.card1)",
                "---\nschema: 1\ntitle: Fix login\norder: 1024\nsprint: 41\nestimate: 3\n---\n\n"
            )
        } change: { fixture in
            try fixture.item(
                "\(Ident.lane1)/\(Ident.card1)",
                "---\nschema: 1\ntitle: Fix login\norder: 1024\nsprint: 42\n---\n\n"
            )
        }
        // Two keys, one event — the item is still what the subject names — and the body carries both
        // sides of each, a removal reading as a move to absence.
        #expect(subject(of: message) == "Change 2 custom keys on card 'Fix login'")
        #expect(body(of: message) == ["estimate: 3 → (none)", "sprint: 41 → 42"])
    }

    @Test("A lane's and the board's custom keys name their item too — never a board-level shrug")
    func customKeysNameLanesAndTheBoard() throws {
        let lane = try compose { fixture in
            try fixture.item(
                Ident.lane1,
                "---\nschema: 1\ntitle: Todo\norder: 1024\nwip-limit: 5\n---\n\n"
            )
        }
        #expect(subject(of: lane) == "Change custom key on lane 'Todo'")

        // The board is the case 06 calls out by name: "never a board-level shrug when the touched item
        // is identifiable" — and the board is identifiable, by its own title.
        let board = try compose { fixture in
            try fixture.item("", "---\nschema: 1\ntitle: Board\nsprint-length: 2w\n---\nBoard description.\n")
        }
        #expect(subject(of: board) == "Change custom key on board 'Board'")
    }

    @Test("A foreign change composes identically to an app-mediated one")
    func originIsNotInTheProse() throws {
        // "Origin lives in the author field (structural attribution), not in message prose — a foreign
        // move reads 'Move card …' exactly like an app-mediated one." Same change, all three classes.
        func move(_ authorship: ChangeAuthorship) throws -> String {
            try compose(change: { fixture in
                try fixture.moveFolder("\(Ident.lane1)/\(Ident.card1)", to: "\(Ident.lane2)/\(Ident.card1)")
            }, authorship: authorship)
        }
        let app = try move(.user)
        let foreign = try move(.foreign("Lanework External"))
        let heal = try move(.heal)
        #expect(app == "Move card 'Fix login' to Doing\n\nTodo → Doing")
        #expect(foreign == app)
        #expect(heal == app)
    }
}

// MARK: - Bookkeeping

@Suite("Change narrator ▸ bookkeeping composes nothing")
struct CommitMessageBookkeepingTests {

    @Test("A bumped modified stamp is not an event")
    func stampsAreSilent() throws {
        let message = try compose { fixture in
            try fixture.card(
                Ident.card1, in: Ident.lane1, order: "1024", title: "Fix login",
                modified: "2026-07-31T12:00:00Z"
            )
        }
        #expect(message == ChangeNarrator.unnamedSubject)
    }

    @Test("A renumber's rescale preserves sequence and composes nothing")
    func rescalesAreSilent() throws {
        // Every rank multiplied, nobody repositioned — 01-storage-format.md's renumber. "Sequence is
        // what the diff compares, not raw `order` values."
        let message = try compose { fixture in
            try fixture.card(Ident.card1, in: Ident.lane1, order: "16384", title: "Fix login")
            try fixture.card(Ident.card2, in: Ident.lane1, order: "32768", title: "Ship it")
        }
        #expect(message == ChangeNarrator.unnamedSubject)
    }

    @Test("An on-touch heal's backfilled kind is not an event")
    func theBackfilledKindIsSilent() throws {
        let message = try compose { fixture in
            try fixture.item(
                "\(Ident.lane1)/\(Ident.card1)",
                "---\nschema: 1\ntitle: Fix login\norder: 1024\nkind: card\n---\n\n"
            )
        }
        #expect(message == ChangeNarrator.unnamedSubject)
    }

    @Test("A renumber batches with the insert that triggered it")
    func aRenumberRidesWithItsInsert() throws {
        // Midpoint exhaustion: the insert forces every sibling's rank to be rewritten. The commit
        // reads as the insert, because the *sequence* of the cards that were already there is
        // unchanged.
        let message = try compose { fixture in
            try fixture.card(Ident.card1, in: Ident.lane1, order: "1024", title: "Fix login")
            try fixture.card(Ident.card3, in: Ident.lane1, order: "2048", title: "Wedged in")
            try fixture.card(Ident.card2, in: Ident.lane1, order: "3072", title: "Ship it")
        }
        #expect(subject(of: message) == "Add card 'Wedged in'")
    }
}

// MARK: - Titles

@Suite("Change narrator ▸ titles")
struct CommitMessageTitleTests {

    @Test("Titles truncate in subjects only; bodies carry them whole")
    func truncationIsSubjectOnly() throws {
        let long = String(repeating: "A", count: 60)
        let message = try compose { fixture in
            try fixture.card(Ident.card3, in: Ident.lane2, order: "1024", title: long)
            try fixture.card(Ident.card4, in: Ident.lane2, order: "2048", title: long)
        }
        #expect(subject(of: message) == "Add 2 cards to Doing")
        #expect(body(of: message).allSatisfy { $0.contains(long) })

        let sole = try compose { fixture in
            try fixture.card(Ident.card3, in: Ident.lane2, order: "1024", title: long)
        }
        #expect(subject(of: sole) == "Add card '\(String(repeating: "A", count: 40))…'")
    }

    @Test("An untitled item reads (untitled), never a bare pair of quotes")
    func untitledIsRendered() throws {
        let message = try compose { fixture in
            try fixture.item("\(Ident.lane2)/\(Ident.card3)", "---\nschema: 1\norder: 1024\n---\n\n")
        }
        #expect(message == "Add card '(untitled)'\n\nto Doing")
        #expect(!message.contains("''"))
    }
}

// MARK: - Non-snapshot paths

@Suite("Change narrator ▸ non-snapshot files")
struct CommitMessagePathEventTests {

    @Test("A stray composes its own path-shaped event, and several fold")
    func straysComposePathEvents() throws {
        let one = try compose { fixture in
            try fixture.file("notes.txt", Data("hello".utf8))
        }
        #expect(one == "Update 'notes.txt'")

        let several = try compose { fixture in
            try fixture.file("notes.txt", Data("hello".utf8))
            try fixture.file(".gitignore", Data("*.tmp\n".utf8))
            try fixture.file("\(Ident.lane1)/\(Ident.card1)/scratch.md", Data("scratch".utf8))
        }
        #expect(subject(of: several) == "Update 3 files")
        #expect(body(of: several) == [
            "- Update '.gitignore'",
            "- Update '\(Ident.lane1)/\(Ident.card1)/scratch.md'",
            "- Update 'notes.txt'",
        ])
    }

    @Test("The agent guide composes its version, read from the marker line")
    func theAgentGuideComposesItsVersion() throws {
        let guide = "<!-- lanework-agent-guide v7 -->\nHow to work in this board.\n"
        let message = try compose(change: { fixture in
            try fixture.file(AgentGuide.filename, Data(guide.utf8))
        }, guideText: guide)
        #expect(message == "Update agent guide (v7)")
    }

    @Test("A markerless CLAUDE.md is somebody else's file, and composes as the path it is")
    func amarkerlessGuideIsJustAFile() throws {
        let text = "My own notes for agents.\n"
        let message = try compose(change: { fixture in
            try fixture.file(AgentGuide.filename, Data(text.utf8))
        }, guideText: text)
        #expect(message == "Update 'CLAUDE.md'")
    }

    @Test("Model events keep the subject; a stray rides along as a bullet")
    func modelEventsKeepTheSubject() throws {
        let message = try compose { fixture in
            try fixture.card(Ident.card3, in: Ident.lane2, order: "1024", title: "Write tests")
            try fixture.file("notes.txt", Data("hello".utf8))
        }
        #expect(subject(of: message) == "Add card 'Write tests'")
        #expect(body(of: message) == [
            "- Add card 'Write tests' to Doing",
            "- Update 'notes.txt'",
        ])
    }

    @Test("A path this commit does not stage composes nothing — the stage-around, in the message")
    func pathsOutsideTheCommitAreNotDescribed() throws {
        // The split window's rule, and the open-Edit-session exclusion's: the composer describes the
        // commit it is composing for, not everything that differs from HEAD.
        let before = try WriterFixture()
        defer { before.tearDown() }
        let after = try WriterFixture()
        defer { after.tearDown() }
        try baseBoard(before)
        try baseBoard(after)
        try after.card(Ident.card1, in: Ident.lane1, order: "1024", title: "Fix login", body: "Half-typed.")
        try after.card(Ident.card3, in: Ident.lane2, order: "1024", title: "Write tests")

        let staged = changedPaths(from: before.root, to: after.root)
            .filter { !$0.path.hasPrefix("\(Ident.lane1)/\(Ident.card1)/") }
        let message = ChangeNarrator.narrative(for: ChangeNarrationRequest(
            boardRoot: after.root,
            changedPaths: staged,
            authorship: .user,
            isRootCommit: false,
            snapshot: try after.snapshot(),
            previousSnapshot: try before.snapshot()
        ))
        #expect(message == "Add card 'Write tests'\n\nto Doing")
    }
}

// MARK: - The comment verb family

@Suite("Change narrator ▸ the comment verb family")
struct CommitMessageCommentTests {

    private static let commentA = "cccccccc-0000-4000-8000-000000000001"
    private static let commentB = "cccccccc-0000-4000-8000-000000000002"
    private static let commentC = "cccccccc-0000-4000-8000-000000000003"

    /// A comment's `index.md` — never parsed by the composer, which reads the family off path shape
    /// alone ("comments are window-scoped, outside the board snapshot" — 01-storage-format.md).
    private static func commentText(_ body: String) -> Data {
        Data("---\nschema: 1\nkind: comment\nauthor: Ada\n---\n\(body)\n".utf8)
    }

    private static func thread(_ card: String, _ entry: String) -> String {
        "\(Ident.lane1)/\(card)/comments/\(entry)/index.md"
    }

    @Test("Posting — the .draft rename — composes 'Comment on ⟨card⟩'")
    func postingComposesComment() throws {
        // The app's post is one rename: `comments/.draft/` → `comments/<uuid>/`, restamped in the
        // same bracket. The departing `.draft` end is silent under the rename rule; the arrival is
        // the event.
        let draft = Self.thread(Ident.card1, ".draft")
        let posted = Self.thread(Ident.card1, Self.commentA)
        let message = try compose(board: { fixture in
            try baseBoard(fixture)
            try fixture.file(draft, Self.commentText("Half a thought."))
        }, change: { fixture in
            try fixture.file(posted, Self.commentText("Half a thought."))
            try FileManager.default.removeItem(
                at: fixture.root.appendingPathComponent("\(Ident.lane1)/\(Ident.card1)/comments/.draft")
            )
        }, renames: [draft, posted])
        #expect(message == "Comment on 'Fix login'")
    }

    @Test("A foreign writer's new comment folder composes the same words")
    func foreignArrivalsComposeTheSame() throws {
        // No rename to read: an agent simply wrote a folder. Origin-agnostic one level down.
        let message = try compose { fixture in
            try fixture.file(Self.thread(Ident.card1, Self.commentA), Self.commentText("From an agent."))
        }
        #expect(message == "Comment on 'Fix login'")
    }

    @Test("Rewriting an existing comment composes 'Edit comment on ⟨card⟩'")
    func editsComposeEditComment() throws {
        let message = try compose { fixture in
            try baseBoard(fixture)
            try fixture.file(Self.thread(Ident.card1, Self.commentA), Self.commentText("First draft."))
        } change: { fixture in
            try fixture.file(Self.thread(Ident.card1, Self.commentA), Self.commentText("Second thoughts."))
        }
        #expect(message == "Edit comment on 'Fix login'")
    }

    @Test("A move into comments/.trash/ composes one Delete, not a departure plus an arrival")
    func deletionComposesDeleteComment() throws {
        let message = try compose { fixture in
            try baseBoard(fixture)
            try fixture.file(Self.thread(Ident.card1, Self.commentA), Self.commentText("Regretted."))
        } change: { fixture in
            try fixture.moveFolder(
                "\(Ident.lane1)/\(Ident.card1)/comments/\(Self.commentA)",
                to: "\(Ident.lane1)/\(Ident.card1)/comments/.trash/\(Self.commentA)"
            )
        }
        // Both halves are in the window and git paired neither of them; the arriving end speaks.
        #expect(message == "Delete comment on 'Fix login'")
    }

    @Test("The close purge composes 'Permanently delete comment on ⟨card⟩'")
    func purgeComposesPermanentDelete() throws {
        // `comments/.trash/` is undo's backing store, purged when the card window closes. Unruled in
        // DESIGN; composed by the trash pair's own symmetry rather than left as a silent shrug.
        let message = try compose { fixture in
            try baseBoard(fixture)
            try fixture.file(
                "\(Ident.lane1)/\(Ident.card1)/comments/.trash/\(Self.commentA)/index.md",
                Self.commentText("Deleted a while ago.")
            )
        } change: { fixture in
            try FileManager.default.removeItem(
                at: fixture.root.appendingPathComponent("\(Ident.lane1)/\(Ident.card1)/comments/.trash")
            )
        }
        #expect(message == "Permanently delete comment on 'Fix login'")
    }

    @Test("A draft save composes the quiet 'Draft comment on ⟨card⟩'")
    func draftSavesComposeDraftComment() throws {
        let message = try compose { fixture in
            try baseBoard(fixture)
            try fixture.file(Self.thread(Ident.card1, ".draft"), Self.commentText("Still typing"))
        } change: { fixture in
            try fixture.file(Self.thread(Ident.card1, ".draft"), Self.commentText("Still typing, more"))
        }
        #expect(message == "Draft comment on 'Fix login'")
    }

    @Test("Every file in one comment folder is one event")
    func oneCommentIsOneEvent() throws {
        let message = try compose { fixture in
            try fixture.file(Self.thread(Ident.card1, Self.commentA), Self.commentText("With a file."))
            try fixture.file(
                "\(Ident.lane1)/\(Ident.card1)/comments/\(Self.commentA)/attachments/shot.png",
                Data("png".utf8)
            )
        }
        #expect(message == "Comment on 'Fix login'")
    }

    @Test("Several comments fold on the card they landed on")
    func commentsFoldOnTheirCard() throws {
        let sameCard = try compose { fixture in
            for comment in [Self.commentA, Self.commentB, Self.commentC] {
                try fixture.file(Self.thread(Ident.card1, comment), Self.commentText("Note."))
            }
        }
        #expect(subject(of: sameCard) == "3 comments on 'Fix login'")

        let twoCards = try compose { fixture in
            try fixture.file(Self.thread(Ident.card1, Self.commentA), Self.commentText("Note."))
            try fixture.file(Self.thread(Ident.card2, Self.commentB), Self.commentText("Note."))
        }
        #expect(subject(of: twoCards) == "Comment on 2 cards")
    }

    @Test("The card's title is resolved from the snapshot, untitled included")
    func titlesResolveFromTheSnapshot() throws {
        let message = try compose { fixture in
            try baseBoard(fixture)
            try fixture.item("\(Ident.lane2)/\(Ident.card3)", "---\nschema: 1\norder: 1024\n---\n\n")
        } change: { fixture in
            try fixture.file(
                "\(Ident.lane2)/\(Ident.card3)/comments/\(Self.commentA)/index.md",
                Self.commentText("On a nameless card.")
            )
        }
        #expect(message == "Comment on '(untitled)'")
    }

    @Test("A comment-only window never shrugs and never shows a raw path")
    func aCommentOnlyWindowGetsItsOwnSubject() throws {
        let message = try compose { fixture in
            try fixture.file(Self.thread(Ident.card1, Self.commentA), Self.commentText("Note."))
        }
        #expect(message != ChangeNarrator.unnamedSubject)
        #expect(!message.contains("comments/"))
    }

    @Test("Model events keep the subject; a comment rides along as a bullet")
    func modelEventsOutrankComments() throws {
        let message = try compose { fixture in
            try fixture.moveFolder("\(Ident.lane1)/\(Ident.card2)", to: "\(Ident.lane3)/\(Ident.card2)")
            try fixture.file(Self.thread(Ident.card1, Self.commentA), Self.commentText("Note."))
        }
        #expect(subject(of: message) == "Move card 'Ship it' to Done")
        #expect(body(of: message).contains("- Comment on 'Fix login'"))
    }

    @Test("A card carrying its thread into the trash is one event, not one per comment")
    func aMovedCardSwallowsItsThread() throws {
        // The implied-events rule, one level down: the comment files travelled because the card did.
        let message = try compose { fixture in
            try baseBoard(fixture)
            for comment in [Self.commentA, Self.commentB] {
                try fixture.file(Self.thread(Ident.card1, comment), Self.commentText("Note."))
            }
        } change: { fixture in
            try fixture.move("\(Ident.lane1)/\(Ident.card1)", toTrash: Ident.card1)
        }
        #expect(message == "Delete card 'Fix login'")
    }

    // MARK: The chronology

    /// A comment's `index.md` with a `created` stamp — the one field the ordering below reads, written
    /// in the YAML 1.1 timestamp grammar the loader accepts (`FrontmatterFields.date`).
    private static func datedComment(_ body: String, created: String) -> Data {
        Data("---\nschema: 1\nkind: comment\nauthor: Ada\ncreated: \(created)\n---\n\(body)\n".utf8)
    }

    /// **"A commit's comment bullets sort chronologically … never UUID-arbitrary"** — once
    /// 06-history-undo.md ▸ Rules ▸ Auto-commit's rule (blessed 2026-07-31), now the change
    /// narrator's (`strategy/01-git-excision.md` ▸ What is harvested).
    ///
    /// Three comments on one card, each with a *different verb* so the bullets are distinguishable,
    /// and identities deliberately ordered against their chronology: the earliest comment carries the
    /// middle UUID and the latest carries the smallest. Folder-name order would read Edit, Comment,
    /// Delete; the conversation happened in the other order, and that is what the body says.
    @Test("Comment bullets read in the order the conversation did, not in UUID order")
    func commentBulletsSortByCreated() throws {
        let card = "\(Ident.lane1)/\(Ident.card1)"
        let message = try compose { fixture in
            try baseBoard(fixture)
            // Posted first, and edited in this window: the latest `created`, the smallest UUID.
            try fixture.file(
                Self.thread(Ident.card1, Self.commentA),
                Self.datedComment("First draft.", created: "2026-07-31T12:00:00Z")
            )
            // Deleted in this window, so it exists before and moves into `comments/.trash/`.
            try fixture.file(
                Self.thread(Ident.card1, Self.commentC),
                Self.datedComment("Regretted.", created: "2026-07-31T11:00:00Z")
            )
        } change: { fixture in
            try fixture.file(
                Self.thread(Ident.card1, Self.commentA),
                Self.datedComment("Second thoughts.", created: "2026-07-31T12:00:00Z")
            )
            // Posted in this window — the earliest `created`, the middle UUID.
            try fixture.file(
                Self.thread(Ident.card1, Self.commentB),
                Self.datedComment("Said hours ago.", created: "2026-07-31T10:00:00Z")
            )
            try fixture.moveFolder(
                "\(card)/comments/\(Self.commentC)",
                to: "\(card)/comments/.trash/\(Self.commentC)"
            )
        }

        #expect(body(of: message) == [
            "- Comment on 'Fix login'",
            "- Delete comment on 'Fix login'",
            "- Edit comment on 'Fix login'",
        ])
    }

    /// "**folder name on ties**" — and the name that breaks the tie is the *comment's* folder, not the
    /// composite key the groups are gathered under. Two cards, one timestamp: the comment named
    /// `…0001` speaks first even though its card sorts second.
    @Test("Comments created at the same moment fall back to folder name, never to the card's path")
    func tiesFallBackToTheFolderName() throws {
        let stamp = "2026-07-31T09:30:00Z"
        let message = try compose { fixture in
            // On the *first* card, the larger identity.
            try fixture.file(
                Self.thread(Ident.card1, Self.commentC),
                Self.datedComment("On Fix login.", created: stamp)
            )
            // On the second card, the smaller one.
            try fixture.file(
                Self.thread(Ident.card2, Self.commentA),
                Self.datedComment("On Ship it.", created: stamp)
            )
        }

        #expect(body(of: message) == [
            "- Comment on 'Ship it'",
            "- Comment on 'Fix login'",
        ])
    }

    /// The undated sort **after** the dated — `CommentThread.sorted`'s own fallback, applied one layer
    /// up. The undated comment here carries the smallest identity, so folder-name order alone would
    /// have put it first.
    @Test("A comment with no readable created sorts after its dated siblings")
    func undatedCommentsSortLast() throws {
        let message = try compose { fixture in
            try fixture.file(Self.thread(Ident.card1, Self.commentA), Self.commentText("No stamp at all."))
            try fixture.file(
                Self.thread(Ident.card2, Self.commentB),
                Self.datedComment("Stamped.", created: "2026-07-31T08:00:00Z")
            )
        }

        #expect(body(of: message) == [
            "- Comment on 'Ship it'",
            "- Comment on 'Fix login'",
        ])
    }
}

// MARK: - Repair

@Suite("Change narrator ▸ Repair")
struct CommitMessageRepairTests {

    /// The remint's exact shape, assembled rather than provoked.
    ///
    /// What `BoardWriter.remintDuplicateIdentity` does on disk is rename the losing folder in place to
    /// a fresh identity, so the survey reports a rename pair. What the *snapshots* show is only the
    /// arrival — because the previous load withheld the duplicate (`BoardLoader.dedupeIdentities`),
    /// its id was never in a board model at all. Provoking that by writing two folders with one id
    /// would make the test depend on which twin the dedupe happened to keep; the shape the composer
    /// is asked about is this, and it is stated directly.
    private func remintMessage(authorship: ChangeAuthorship) throws -> String {
        let before = try WriterFixture()
        defer { before.tearDown() }
        let after = try WriterFixture()
        defer { after.tearDown() }
        try baseBoard(before)
        try baseBoard(after)
        try after.card(Ident.card4, in: Ident.lane1, order: "1024", title: "Fix login")

        let departed = "\(Ident.lane1)/\(Ident.card3)/index.md"
        let arrived = "\(Ident.lane1)/\(Ident.card4)/index.md"
        let paths = changedPaths(from: before.root, to: after.root).map { path in
            path.path == arrived
                ? ChangedPath(path: arrived, isDeletion: false, isRename: true, isArrival: true)
                : path
        } + [ChangedPath(path: departed, isDeletion: true, isRename: true)]

        return ChangeNarrator.narrative(for: ChangeNarrationRequest(
            boardRoot: after.root,
            changedPaths: paths,
            authorship: authorship,
            isRootCommit: false,
            snapshot: try after.snapshot(),
            previousSnapshot: try before.snapshot()
        ))
    }

    @Test("A heal window's folder remint reads as Repair, not as an arrival")
    func theRemintComposesRepair() throws {
        #expect(try remintMessage(authorship: .heal) == "Repair duplicate of 'Fix login'")
    }

    @Test("The same shape outside a heal window is an ordinary arrival")
    func repairIsHealOnly() throws {
        #expect(subject(of: try remintMessage(authorship: .user)) == "Add card 'Fix login'")
    }
}

// MARK: - The root commit

@Suite("Change narrator ▸ the root commit")
struct CommitMessageRootCommitTests {

    @Test("The repository's first commit has the one fixed subject")
    func theRootCommitIsFixed() throws {
        let fixture = try WriterFixture()
        defer { fixture.tearDown() }
        try baseBoard(fixture)
        let message = ChangeNarrator.narrative(for: ChangeNarrationRequest(
            boardRoot: fixture.root,
            changedPaths: changedPaths(from: fixture.root, to: fixture.root),
            authorship: .user,
            isRootCommit: true,
            snapshot: try fixture.snapshot(),
            previousSnapshot: nil
        ))
        #expect(message == ChangeNarrator.rootSubject)
    }
}
