import Foundation

/// Harvested 2026-08-08 from `Kanban/Git/` per `strategy/01-git-excision.md`, with its git ties
/// severed — this is the designated core of the future activity feed / foreign-change journal. The
/// "previous snapshot" this narrator diffs against is supplied by the caller by contract: the git
/// stack supplied it from HEAD, and the journal will supply it from memory/snapshots.

// MARK: - ChangeNarrator

/// **What a commit says** (06-history-undo.md ▸ Commit messages) — a pure, total function from two
/// board snapshots plus a changed-path list to one commit message.
///
/// ### The shape of the rule
///
/// "Messages compose at commit time from a structural diff of two board snapshots (last-committed
/// vs. current) — **never by intercepting operations**. Items match by id across the *whole* board,
/// so a lane change is distinguishable from delete+add and a cross-lane move reads as a move."
/// Everything below follows from that one sentence: there is no write-site vocabulary anywhere in
/// this file, no `WriteOperation`, no receipt. A foreign `mv` and the app's own drag produce the same
/// two snapshots and therefore the same message — "origin lives in the author field, not in message
/// prose" — which is why `ChangeAuthorship` reaches only two rules here: the root commit's fixed
/// subject, and the heal window's Repair reading of a folder remint.
///
/// ### Purity, and where the impurity went
///
/// Nothing in here reads a file, opens a repository, or asks what time it is. Both snapshots and the
/// guide's text arrive as values on `ChangeNarrationRequest`, resolved once per window by whoever is
/// driving the narration — the previous snapshot included. That is what makes the whole vocabulary —
/// every subject form, every fold, every trash reading — testable with two snapshots and no store at
/// all.
///
/// ### The changed-path list is a filter, not a hint
///
/// A debounce window splits into as many as three commits (foreign, heal, user — 06 ▸ Interaction
/// with external writers), and each stages *its own paths*. All three compose against the same HEAD,
/// so the only thing keeping each message about its own commit is `request.changedPaths`: an event is
/// composed **only when one of the paths it was read from is in this commit's list**. The same filter
/// earns three more rules for free — a card whose folder is staged around for an open Edit session
/// composes nothing, a `.gitignore`d attachment never composes a phantom Attach, and a stray-only
/// window still commits with its strays named.
enum ChangeNarrator {

    /// **A genuinely mixed window says so** (06 ▸ Commit messages, re-ruled 2026-07-31 — "retiring
    /// the bare 'Update board' fallback"):
    ///
    /// > **"Mixed update — N changes"**, always with a bulleted body naming every event, so the
    /// > oneline log stays scannable and never dresses a grab-bag as one thing; when every event in
    /// > the window shares one item — the card-window session flush's usual shape — the subject keeps
    /// > the name: **"Mixed update — N changes to card '⟨title⟩'"**.
    ///
    /// The named case is the reason this exists: a card window's close flush is *by construction* a
    /// window of one card's changes, and "Update board" was the one subject that could not say which
    /// card a whole session belonged to.
    ///
    /// - Parameters:
    ///   - count: how many events the body will list — the subject counts changes, not commits.
    ///   - item: the rendered noun phrase every event shares ("card 'Fix login'"), or `nil` when they
    ///     genuinely span the board.
    static func mixedSubject(_ count: Int, item: String? = nil) -> String {
        let changes = count == 1 ? "1 change" : "\(count) changes"
        guard let item else { return "Mixed update — \(changes)" }
        return "Mixed update — \(changes) to \(item)"
    }

    /// The subject a window with no describable event at all falls to.
    ///
    /// Vanishingly rare and deliberately kept: the tree's commit condition is the *tree*, not the
    /// snapshot diff, so a window whose every path composed nothing still commits rather than leaving
    /// the tree dirty (06 ▸ Commit messages ▸ Non-snapshot files commit too). "0 changes" would be a
    /// lie about a commit that does contain something; this says what it honestly is.
    static let unnamedSubject = "Update board"

    /// **An untitled item reads "(untitled)" — never a bare `""`** (06 ▸ Commit messages: "a
    /// pathfinder edge fixed, not carried").
    ///
    /// Deliberately *not* `AccessibilityPhrases.untitled` ("Untitled"), which is the word the board
    /// face and VoiceOver draw. This is a git log, where the parenthesized lowercase form is the
    /// long-standing convention for "no value here" and cannot be mistaken for a card actually
    /// titled "Untitled".
    static let untitledPlaceholder = "(untitled)"

    /// **~40 characters, in subjects only** (06) — "keeping `git log --oneline` sane"; body lines
    /// carry full titles.
    static let titleLimit = 40

    /// What a card's home is called when that home is the trash — the one "lane name" that is not a
    /// lane's title, so "Move card 'X' to …" and the plural fold have something true to say about a
    /// container with no identity of its own (`BoardModel.trash`).
    static let trashDestination = "the trash"

    /// What a key's old (or new) side reads as when the frontmatter simply did not carry it — the
    /// `(untitled)` convention applied to a value rather than to a name, so "sprint: (none) → 42"
    /// says *added* without a second sentence shape for it.
    static let absentValuePlaceholder = "(none)"

    /// **The subject the one snapshot-less window gets** (06 ▸ Rules ▸ Abnormal repo states): a first
    /// window with no previous snapshot to diff against describes the whole board as arriving, never a
    /// folded diff-from-empty.
    ///
    /// Defined here rather than borrowed from a substrate, so the narrator stays a pure function of
    /// two snapshots (`strategy/01-git-excision.md` ▸ What is harvested).
    static let rootSubject = "Initial board state"

    // MARK: - Entry point

    /// One request in, one whole message out — a subject, and a body when there is more to say.
    static func narrative(for request: ChangeNarrationRequest) -> String {
        // **The one commit with a subject of its own** (06 ▸ Rules ▸ Abnormal repo states): "it
        // commits the whole tree as *Initial board state*, never a folded diff-from-empty: there is
        // no last-committed snapshot to diff against."
        guard !request.isRootCommit else { return rootSubject }
        return assemble(events(for: request), request: request)
    }

    /// Every event this commit is composed of — model events in board reading order, then the
    /// path-shaped ones.
    ///
    /// Split out from `message(for:)` so a test can assert *what was seen* apart from *how it was
    /// worded*: the two halves fail differently, and a diff bug should not have to be read out of a
    /// phrasing assertion.
    static func events(for request: ChangeNarrationRequest) -> [Event] {
        let changed = Set(request.changedPaths.map(\.path))
        var model: [Event] = []
        if let previous = request.previousSnapshot, let current = request.snapshot {
            model = modelEvents(from: previous, to: current, request: request)
                .filter { event in event.paths.contains(where: changed.contains) }
        }
        return model + pathEvents(for: request, claimedBy: model)
    }

    // MARK: - Assembly

    /// Subject, then a blank line and a body when one helps.
    ///
    /// - One event: its own subject, plus its detail line where it has one.
    /// - Several: a subject chosen from the **headline** events, and a bullet naming *every* event —
    ///   "so the oneline log stays scannable and the full message stays complete".
    private static func assemble(_ events: [Event], request: ChangeNarrationRequest) -> String {
        guard !events.isEmpty else { return unnamedSubject }
        if events.count == 1 {
            guard let detail = events[0].detail else { return events[0].subject }
            return "\(events[0].subject)\n\n\(detail)"
        }
        let body = events.map { "- \($0.bullet)" }.joined(separator: "\n")
        let subject = subject(for: headline(of: events))
            ?? mixedSubject(events.count, item: sharedItem(of: events, request: request))
        return "\(subject)\n\n\(body)"
    }

    /// The events allowed to *choose* the subject, in the order 06 ranks them.
    ///
    /// - **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'."
    /// - **Model events keep the subject when present**; non-snapshot changes "then ride as body
    ///   bullets — recorded, never silently absorbed under an unrelated subject."
    private static func headline(of events: [Event]) -> [Event] {
        let candidates = events.filter { !$0.implied }
        let model = candidates.filter(\.kind.isModel)
        if !model.isEmpty { return model }
        if !candidates.isEmpty { return candidates }
        return events
    }

    /// "A single event is the subject; several events of one kind fold into a plural subject, with
    /// shared destinations preserved" — or `nil`, which is the genuinely mixed window the caller names
    /// with `mixedSubject(_:item:)`.
    private static func subject(for headline: [Event]) -> String? {
        guard let first = headline.first else { return nil }
        if headline.count == 1 { return first.subject }
        guard Set(headline.map(\.kind)).count == 1 else { return nil }
        let destinations = Set(headline.compactMap(\.destination))
        return first.kind.plural(headline.count, destination: destinations.count == 1 ? destinations.first : nil)
    }

    /// **The one item every event in this window belongs to, rendered** — "card 'Fix login'" — or
    /// `nil` when they span more than one.
    ///
    /// Read from the events' own **paths** rather than from a field each event would have to remember
    /// to carry: an event's paths are the only thing in this engine that is always true about where it
    /// came from, and a card's folder is the one component that survives a lane move. A window with a
    /// path that belongs to no card at all — the board's own `index.md`, a lane, a stray, the agent
    /// guide — is by definition not one card's window, so a single `nil` answer decides the whole
    /// question.
    private static func sharedItem(of events: [Event], request: ChangeNarrationRequest) -> String? {
        var folder: String?
        for event in events {
            for path in event.paths {
                guard let card = cardFolder(of: path) else { return nil }
                if let folder, folder != card { return nil }
                folder = card
            }
        }
        guard let folder else { return nil }
        let title = cardTitlesByPath(request)[folder] ?? untitledPlaceholder
        return "card \(quotedSubject(title))"
    }

    /// The `<lane>/<card>` (or `.trash/<card>`) folder a board-root-relative path belongs to, or `nil`
    /// when it belongs to no card — the board's `index.md`, a lane's, a root stray.
    ///
    /// Comment paths answer through `CommentPath`, which already knows the thread's two containers, so
    /// the one rule about where a card lives is not spelled twice.
    private static func cardFolder(of path: String) -> String? {
        if let comment = CommentPath.classify(path) { return comment.cardPath }
        let components = path.split(separator: "/", omittingEmptySubsequences: true).map(String.init)
        guard components.count >= 2, BoardLoader.isUUIDShaped(components[1]) else { return nil }
        guard components[0] == Paths.trashFolder || BoardLoader.isUUIDShaped(components[0]) else { return nil }
        return "\(components[0])/\(components[1])"
    }

    // MARK: - The structural diff

    private static func modelEvents(
        from previous: BoardModel,
        to current: BoardModel,
        request: ChangeNarrationRequest
    ) -> [Event] {
        let previousLanes = laneIndex(of: previous)
        let currentLanes = laneIndex(of: current)

        var events = boardEvents(from: previous, to: current)
        let lanes = laneEvents(previous: previousLanes, current: currentLanes)
        events += lanes
        events += laneSequenceEvent(from: previous, to: current)
        events += cardEvents(
            previous: cardIndex(of: previous),
            current: cardIndex(of: current),
            previousLanes: previousLanes,
            currentLanes: currentLanes,
            laneOutcomes: lanes,
            authorship: request.authorship,
            renames: renamedPaths(in: request)
        )
        events += cardSequenceEvents(from: previous, to: current, currentLanes: currentLanes)
        return events
    }

    // MARK: Board level

    private static func boardEvents(from previous: BoardModel, to current: BoardModel) -> [Event] {
        let paths = [Paths.boardIndex]
        var events: [Event] = []
        if title(previous.title) != title(current.title) {
            events.append(renameEvent(
                kind: .renameBoard,
                noun: "board",
                old: title(previous.title),
                new: title(current.title),
                paths: paths
            ))
        }
        if previous.body != current.body {
            events.append(Event(kind: .editBoard, subject: "Edit board description", paths: paths))
        }
        if styleDiffers(previous.background, previous.icon, previous.iconColor,
                        current.background, current.icon, current.iconColor) {
            events.append(Event(kind: .restyleBoard, subject: "Restyle board", paths: paths))
        }
        events += metadataEvents(
            previous: previous.document,
            current: current.document,
            noun: "board",
            // The trio's board phrasing is count-less and title-less ("Relabel board") — there is one
            // board and naming it adds nothing. The custom-key event is the one place 06 spells the
            // board's title out ("Change custom key on board '⟨title⟩'"), so it gets it separately.
            itemTitle: nil,
            customKeyItemTitle: title(current.title),
            kinds: (.relabelBoard, .assignBoard, .dueBoard, .changeKeyBoard),
            paths: paths
        )
        return events
    }

    // MARK: Lane level

    /// A lane the diff can see: on the strip, or as one of the trash's opaque rows.
    ///
    /// `BoardDiff.LaneEntry`'s twin rather than a shared type, deliberately: the announcer asks three
    /// questions of a lane and this asks a dozen, at a granularity (which *field* changed) the digest
    /// is explicitly built not to keep — see `BoardDiff`'s own note, which says a future message
    /// composer "will phrase the very same counts differently without either of them knowing about
    /// the other".
    private struct LaneEntry {
        let container: ItemContainer
        let title: FieldValue<String>
        let document: FrontmatterDocument
        let path: String
        /// The live lane, or `nil` for a trash row — which has no body, style or width to compare
        /// (03-board-ui.md § Trash: the row draws a title and a count and takes no styling accents).
        let live: Lane?

        var displayTitle: String { ChangeNarrator.title(title) }
    }

    private static func laneIndex(of board: BoardModel) -> [ItemID: LaneEntry] {
        var index: [ItemID: LaneEntry] = [:]
        for lane in board.lanes {
            index[lane.id] = LaneEntry(
                container: .board,
                title: lane.title,
                document: lane.document,
                path: Paths.index(lane.id.rawValue),
                live: lane
            )
        }
        for lane in board.trashedLanes {
            index[lane.id] = LaneEntry(
                container: .trash,
                title: lane.title,
                document: lane.document,
                path: Paths.index(Paths.trashFolder, lane.id.rawValue),
                live: nil
            )
        }
        return index
    }

    private static func laneEvents(
        previous: [ItemID: LaneEntry],
        current: [ItemID: LaneEntry]
    ) -> [Event] {
        var events: [Event] = []

        // Arrivals and departures, ordered by folder spelling — an identity on one side only has no
        // position on the other to sort by, and a message must not depend on dictionary order.
        for id in current.keys.sorted(by: idOrder) where previous[id] == nil {
            guard let lane = current[id] else { continue }
            events.append(Event(
                kind: .addLane,
                subject: "Add lane \(quotedSubject(lane.displayTitle))",
                bullet: "Add lane \(quoted(lane.displayTitle))",
                paths: [lane.path]
            ))
        }
        for id in previous.keys.sorted(by: idOrder) where current[id] == nil {
            guard let lane = previous[id] else { continue }
            // **The trash pair by diff shape alone**: an item leaving the tree entirely is a
            // permanent deletion, whatever container it left from.
            events.append(Event(
                kind: .purgeLane,
                subject: "Permanently delete lane \(quotedSubject(lane.displayTitle))",
                bullet: "Permanently delete lane \(quoted(lane.displayTitle))",
                paths: [lane.path]
            ))
        }

        for id in current.keys.sorted(by: idOrder) {
            guard let new = current[id], let old = previous[id] else { continue }

            // The crossing, decided by shape and nothing else: into `.trash/` is Delete, out of it is
            // Restore (06 ▸ Commit messages, the trash pair).
            if old.container != new.container {
                let deleting = new.container == .trash
                events.append(Event(
                    kind: deleting ? .deleteLane : .restoreLane,
                    subject: "\(deleting ? "Delete" : "Restore") lane \(quotedSubject(new.displayTitle))",
                    bullet: "\(deleting ? "Delete" : "Restore") lane \(quoted(new.displayTitle))",
                    paths: [old.path, new.path]
                ))
                continue
            }

            let paths = [new.path]
            if old.displayTitle != new.displayTitle {
                events.append(renameEvent(
                    kind: .renameLane,
                    noun: "lane",
                    old: old.displayTitle,
                    new: new.displayTitle,
                    paths: paths
                ))
            }
            if let oldLane = old.live, let newLane = new.live {
                if oldLane.body != newLane.body {
                    events.append(Event(
                        kind: .editLane,
                        subject: "Edit lane \(quotedSubject(new.displayTitle))",
                        bullet: "Edit lane \(quoted(new.displayTitle))",
                        paths: paths
                    ))
                }
                if styleDiffers(oldLane.background, oldLane.icon, oldLane.iconColor,
                                newLane.background, newLane.icon, newLane.iconColor) {
                    events.append(Event(
                        kind: .restyleLane,
                        subject: "Restyle lane \(quotedSubject(new.displayTitle))",
                        bullet: "Restyle lane \(quoted(new.displayTitle))",
                        paths: paths
                    ))
                }
                let width = newLane.width.value ?? 1
                if (oldLane.width.value ?? 1) != width {
                    events.append(Event(
                        kind: .resizeLane,
                        subject: "Resize lane \(quotedSubject(new.displayTitle)) to \(width)×",
                        bullet: "Resize lane \(quoted(new.displayTitle)) to \(width)×",
                        paths: paths
                    ))
                }
                // **The fold, in the reading rather than the key** (03-board-ui.md § Lane ▸ Collapsed
                // lanes): `LaneLayoutMath.isCollapsed` is what the strip draws off, so a `collapsed:
                // false` appearing beside an absent key changes no lane on screen and composes
                // nothing — the same "sequence, not raw `order`" discipline the reorder rule follows.
                //
                // Two kinds rather than one, on the trash pair's precedent (06 ▸ Commit messages): the
                // directions are distinguished by diff shape alone, and each says its own word.
                let collapsed = LaneLayoutMath.isCollapsed(newLane)
                if LaneLayoutMath.isCollapsed(oldLane) != collapsed {
                    let verb = collapsed ? "Collapse" : "Expand"
                    events.append(Event(
                        kind: collapsed ? .collapseLane : .expandLane,
                        subject: "\(verb) lane \(quotedSubject(new.displayTitle))",
                        bullet: "\(verb) lane \(quoted(new.displayTitle))",
                        paths: paths
                    ))
                }
            }
            events += metadataEvents(
                previous: old.document,
                current: new.document,
                noun: "lane",
                itemTitle: new.displayTitle,
                customKeyItemTitle: new.displayTitle,
                kinds: (.relabelLane, .assignLane, .dueLane, .changeKeyLane),
                paths: paths
            )
        }

        return events
    }

    /// **Sequence, not raw `order`** (06): "an order change that *repositions* an item among its
    /// siblings composes Reorder", while a renumber's rescale composes nothing. Lanes present on only
    /// one side drop out of both sequences first, so an add, a delete or a rescale can never trip
    /// this — only an actual repositioning does. It is also why "a midpoint-exhaustion renumber
    /// batches with the insert or move that triggered it": the insert changes the sequence by exactly
    /// the inserted item, which the filter removes.
    private static func laneSequenceEvent(from previous: BoardModel, to current: BoardModel) -> [Event] {
        let previousIDs = Set(previous.lanes.map(\.id))
        let currentIDs = Set(current.lanes.map(\.id))
        guard previous.lanes.map(\.id).filter(currentIDs.contains)
                != current.lanes.map(\.id).filter(previousIDs.contains)
        else { return [] }
        return [Event(
            kind: .reorderLanes,
            subject: "Reorder lanes",
            paths: current.lanes.map { Paths.index($0.id.rawValue) }
        )]
    }

    // MARK: Card level

    /// A card plus where it sits — the whole-board lookup that tells a move (an id landing in a
    /// different lane) from an add or a delete (an id present on one side only).
    private struct CardEntry {
        let card: Card
        let container: ItemContainer
        /// The lane this card is in, or `nil` when it sits loose in the trash.
        let lane: ItemID?
        let path: String
        let attachmentFolder: String

        var displayTitle: String { ChangeNarrator.title(card.title) }
    }

    private static func cardIndex(of board: BoardModel) -> [ItemID: CardEntry] {
        var index: [ItemID: CardEntry] = [:]
        for lane in board.lanes {
            for card in lane.cards {
                index[card.id] = CardEntry(
                    card: card,
                    container: .board,
                    lane: lane.id,
                    path: Paths.index(lane.id.rawValue, card.id.rawValue),
                    attachmentFolder: Paths.attachments(lane.id.rawValue, card.id.rawValue)
                )
            }
        }
        for card in board.trash {
            index[card.id] = CardEntry(
                card: card,
                container: .trash,
                lane: nil,
                path: Paths.index(Paths.trashFolder, card.id.rawValue),
                attachmentFolder: Paths.attachments(Paths.trashFolder, card.id.rawValue)
            )
        }
        return index
    }

    private static func cardEvents(
        previous: [ItemID: CardEntry],
        current: [ItemID: CardEntry],
        previousLanes: [ItemID: LaneEntry],
        currentLanes: [ItemID: LaneEntry],
        laneOutcomes: [Event],
        authorship: ChangeAuthorship,
        renames: (arrivals: Set<String>, departures: Set<String>)
    ) -> [Event] {
        var events: [Event] = []
        let arrivedLanes = Set(laneOutcomes.filter { $0.kind == .addLane }.flatMap(\.paths))
        let restoredLanes = Set(laneOutcomes.filter { $0.kind == .restoreLane }.flatMap(\.paths))

        for id in current.keys.sorted(by: idOrder) where previous[id] == nil {
            guard let entry = current[id] else { continue }

            // **The duplicate-id remint reads as Repair, not as an arrival** (06 ▸ Commit messages:
            // "app-mediated and heal-marked, so its separate heal commit names the remint directly
            // instead of reading the folder swap as Permanently delete + Add"). The shape it is
            // recognized by, inside a heal-classed window: a folder git paired as a *rename*, whose
            // departed twin the previous snapshot never held — because the loader withheld it as the
            // duplicate it was (`BoardLoader.dedupeIdentities`). Nothing else in a heal window has
            // that shape: a loose-file relocation renames a file rather than an identity folder, and
            // a legacy tombstone's migration moves an id the previous snapshot *did* hold.
            if case .heal = authorship,
               renames.arrivals.contains(entry.path),
               let departed = remintedTwin(of: entry, among: renames.departures, previous: previous) {
                events.append(Event(
                    kind: .repairDuplicate,
                    subject: "Repair duplicate of \(quotedSubject(entry.displayTitle))",
                    bullet: "Repair duplicate of \(quoted(entry.displayTitle))",
                    paths: [entry.path, departed]
                ))
                continue
            }

            // A card that arrived *with* its lane is the lane's event: body material, never a
            // subject — and when the lane came back out of the trash, so did the card, which is a
            // restore rather than an arrival however new the id looks to the diff.
            let lanePath = entry.lane.flatMap { currentLanes[$0]?.path }
            let restored = lanePath.map(restoredLanes.contains) ?? false
            let implied = restored || (lanePath.map(arrivedLanes.contains) ?? false)
            let destination = destinationName(of: entry, lanes: currentLanes)
            events.append(restored
                ? Event(
                    kind: .restoreCard,
                    subject: "Restore card \(quotedSubject(entry.displayTitle))",
                    bullet: "Restore card \(quoted(entry.displayTitle))",
                    implied: true,
                    paths: [entry.path]
                )
                : Event(
                    kind: .addCard,
                    subject: "Add card \(quotedSubject(entry.displayTitle))",
                    bullet: "Add card \(quoted(entry.displayTitle)) to \(destination)",
                    detail: "to \(destination)",
                    destination: destination,
                    implied: implied,
                    paths: [entry.path]
                ))
        }

        for id in previous.keys.sorted(by: idOrder) where current[id] == nil {
            guard let entry = previous[id] else { continue }
            // A card whose *lane* went into the trash went with it — it is not in the snapshot at all
            // any more (a trashed lane is opaque), and calling that a permanent deletion would be a
            // lie about where the file is. Its fate is its lane's.
            switch fateOfLane(of: entry, previousLanes: previousLanes, currentLanes: currentLanes) {
            case .trashed:
                events.append(Event(
                    kind: .deleteCard,
                    subject: "Delete card \(quotedSubject(entry.displayTitle))",
                    bullet: "Delete card \(quoted(entry.displayTitle))",
                    implied: true,
                    paths: [entry.path]
                ))
            case .gone:
                events.append(purgeEvent(entry, implied: true))
            case .standing:
                events.append(purgeEvent(entry, implied: false))
            }
        }

        for id in current.keys.sorted(by: idOrder) {
            guard let new = current[id], let old = previous[id] else { continue }
            events += cardChangeEvents(old: old, new: new, currentLanes: currentLanes)
        }

        return events
    }

    private static func purgeEvent(_ entry: CardEntry, implied: Bool) -> Event {
        Event(
            kind: .purgeCard,
            subject: "Permanently delete card \(quotedSubject(entry.displayTitle))",
            bullet: "Permanently delete card \(quoted(entry.displayTitle))",
            implied: implied,
            paths: [entry.path]
        )
    }

    /// Every event for a card present in both snapshots — **independent checks, not a priority
    /// chain**: a card that changed lane *and* title in one window produces both events, exactly as
    /// two separate cards would.
    private static func cardChangeEvents(
        old: CardEntry,
        new: CardEntry,
        currentLanes: [ItemID: LaneEntry]
    ) -> [Event] {
        var events: [Event] = []
        let paths = old.path == new.path ? [new.path] : [old.path, new.path]

        if old.container != new.container {
            // The crossing rule: into the trash is Delete, out of it is Restore — never a move,
            // however far the folder travelled.
            let deleting = new.container == .trash
            events.append(Event(
                kind: deleting ? .deleteCard : .restoreCard,
                subject: "\(deleting ? "Delete" : "Restore") card \(quotedSubject(new.displayTitle))",
                bullet: "\(deleting ? "Delete" : "Restore") card \(quoted(new.displayTitle))",
                paths: paths
            ))
        } else if old.lane != new.lane {
            let from = destinationName(of: old, lanes: currentLanes)
            let to = destinationName(of: new, lanes: currentLanes)
            events.append(Event(
                kind: .moveCard,
                subject: "Move card \(quotedSubject(new.displayTitle)) to \(truncated(to))",
                bullet: "Move card \(quoted(new.displayTitle)) from \(from) to \(to)",
                detail: "\(from) → \(to)",
                destination: to,
                paths: paths
            ))
        }

        if old.displayTitle != new.displayTitle {
            events.append(renameEvent(
                kind: .renameCard,
                noun: "card",
                old: old.displayTitle,
                new: new.displayTitle,
                paths: paths
            ))
        }
        if old.card.body != new.card.body {
            events.append(Event(
                kind: .editCard,
                subject: "Edit card \(quotedSubject(new.displayTitle))",
                bullet: "Edit card \(quoted(new.displayTitle))",
                paths: paths
            ))
        }
        if styleDiffers(old.card.background, old.card.icon, old.card.iconColor,
                        new.card.background, new.card.icon, new.card.iconColor) {
            events.append(Event(
                kind: .restyleCard,
                subject: "Restyle card \(quotedSubject(new.displayTitle))",
                bullet: "Restyle card \(quoted(new.displayTitle))",
                paths: paths
            ))
        }

        // Attach / Remove — one event per file, named by the card it belongs to. Both ends' folders
        // are listed so a file that arrived with a moved card is still filtered in.
        let before = Set(old.card.attachments)
        let after = Set(new.card.attachments)
        for file in after.subtracting(before).sorted() {
            events.append(Event(
                kind: .attachFile,
                subject: "Attach \(quotedSubject(file)) to card \(quotedSubject(new.displayTitle))",
                bullet: "Attach \(quoted(file)) to card \(quoted(new.displayTitle))",
                destination: new.displayTitle,
                paths: [Paths.join(new.attachmentFolder, file), Paths.join(old.attachmentFolder, file)]
            ))
        }
        for file in before.subtracting(after).sorted() {
            events.append(Event(
                kind: .removeFile,
                subject: "Remove \(quotedSubject(file)) from card \(quotedSubject(new.displayTitle))",
                bullet: "Remove \(quoted(file)) from card \(quoted(new.displayTitle))",
                destination: new.displayTitle,
                paths: [Paths.join(old.attachmentFolder, file), Paths.join(new.attachmentFolder, file)]
            ))
        }

        events += metadataEvents(
            previous: old.card.document,
            current: new.card.document,
            noun: "card",
            itemTitle: new.displayTitle,
            customKeyItemTitle: new.displayTitle,
            kinds: (.relabelCard, .assignCard, .dueCard, .changeKeyCard),
            paths: paths
        )
        return events
    }

    /// Reorder within a lane — and within the trash — by the same common-subsequence rule the lanes
    /// use one level up, so a card added, deleted or moved in or out never trips it.
    private static func cardSequenceEvents(
        from previous: BoardModel,
        to current: BoardModel,
        currentLanes: [ItemID: LaneEntry]
    ) -> [Event] {
        func repositioned(_ before: [Card], _ after: [Card]) -> Bool {
            let beforeIDs = Set(before.map(\.id))
            let afterIDs = Set(after.map(\.id))
            return before.map(\.id).filter(afterIDs.contains) != after.map(\.id).filter(beforeIDs.contains)
        }

        var events: [Event] = []
        let previousByID = Dictionary(uniqueKeysWithValues: previous.lanes.map { ($0.id, $0) })
        for lane in current.lanes {
            guard let old = previousByID[lane.id], repositioned(old.cards, lane.cards) else { continue }
            let destination = currentLanes[lane.id]?.displayTitle ?? title(lane.title)
            events.append(Event(
                kind: .reorderCards,
                subject: "Reorder cards in \(truncated(destination))",
                bullet: "Reorder cards in \(destination)",
                destination: destination,
                paths: (old.cards + lane.cards).map { Paths.index(lane.id.rawValue, $0.id.rawValue) }
            ))
        }
        if repositioned(previous.trash, current.trash) {
            events.append(Event(
                kind: .reorderCards,
                subject: "Reorder cards in \(trashDestination)",
                destination: trashDestination,
                paths: (previous.trash + current.trash).map { Paths.index(Paths.trashFolder, $0.id.rawValue) }
            ))
        }
        return events
    }

    // MARK: The reserved metadata trio, and every other key

    /// **The full schema-1 surface, plus the reserved trio, deliberately** (06 ▸ The external gap,
    /// closed): "label, assignee, and due changes compose … even though 01-storage-format.md reserves
    /// those keys out of this version's UI — external writers … are exactly who touches them."
    ///
    /// **A change to any other unmodeled or custom key says what it is** (06, re-ruled 2026-07-31 —
    /// "first lines self-describe; generics are a last resort, kept very rare"):
    ///
    /// > **"Change custom key on card 'X'"** (board and lane likewise — "Change custom key on board
    /// > '⟨title⟩'"; several keys fold plural), the body naming each key with its old → new values.
    ///
    /// The named generic it retired ("Update card 'X'") is 06's own last resort and survives only
    /// "for a change in a known file that is neither a vocabulary event nor a key change — a shape
    /// that should almost never occur". Nothing composes that shape today: a known file whose diff is
    /// neither is the *bookkeeping* rule's silence, which composes nothing at all. So the vocabulary
    /// keeps no case for it, and the zero-event floor stays `unnamedSubject`'s.
    ///
    /// Read off `unknownFields`, which is exactly "every key the schema does not own" — so the
    /// bookkeeping keys are excluded by construction rather than by a list kept in step: `modified`,
    /// `created`, `modified-by` and the on-touch heal's backfilled `kind` are all schema-owned, and a
    /// diff touching only those composes nothing (06 ▸ the bookkeeping rule).
    ///
    /// **`labels` is the one key added back by hand** (2026-08-09): it joined `schemaOwned` when the
    /// owner claimed it for first-party use (`FrontmatterKeys.labels`), which took it out of
    /// `unknownFields` and would have taken the **Relabel** event with it — the one event here that
    /// 06-history-undo.md names verbatim, and which predates the control by months precisely because
    /// the composer is origin-agnostic. Being schema-owned changes where the *sidebar* renders the key
    /// and nothing about whether a change to it is worth narrating; an agent, a tracker sync and the
    /// app's own submenu all still move it, and all three still say "Relabel". `assignees` and `due`
    /// are untouched — they are still reserved, still unknown keys, and still arrive through the
    /// dictionary above.
    ///
    /// Values compare **as written** (`rawValue`) rather than as parsed YAML: the composer's business
    /// is that the file changed, and re-deriving an equivalence between `[a, b]` and `[a,b]` would be
    /// a second YAML semantics to keep honest.
    private static func metadataEvents(
        previous: FrontmatterDocument,
        current: FrontmatterDocument,
        noun: String,
        itemTitle: String?,
        customKeyItemTitle: String,
        kinds: (label: Kind, assignee: Kind, due: Kind, customKey: Kind),
        paths: [String]
    ) -> [Event] {
        var before = Dictionary(previous.unknownFields.map { ($0.key, $0.rawValue) }, uniquingKeysWith: { _, last in last })
        var after = Dictionary(current.unknownFields.map { ($0.key, $0.rawValue) }, uniquingKeysWith: { _, last in last })
        // `labels`, put back by hand — see the note above. Absent stays absent (no entry), and the
        // text is `FrontmatterField.rawValue`'s own rule restated, so a key with no span of its own
        // (an uneditable whole-frontmatter flow mapping) compares by the parse's rendering exactly as
        // its neighbours in the dictionary do.
        func labelsText(of document: FrontmatterDocument) -> String? {
            guard let value = document.value(for: Keys.labels) else { return nil }
            return document.rawValue(for: Keys.labels) ?? value.description
        }
        before[Keys.labels] = labelsText(of: previous)
        after[Keys.labels] = labelsText(of: current)
        guard before != after else { return [] }

        func phrase(_ verb: String, _ render: (String) -> String) -> String {
            guard let itemTitle else { return "\(verb) \(noun)" }
            return "\(verb) \(noun) \(render(itemTitle))"
        }
        func event(_ kind: Kind, _ verb: String) -> Event {
            Event(
                kind: kind,
                subject: phrase(verb, quotedSubject),
                bullet: phrase(verb, quoted),
                paths: paths
            )
        }
        func changed(_ key: String) -> Bool { before[key] != after[key] }

        var events: [Event] = []
        if changed(Keys.labels) { events.append(event(kinds.label, "Relabel")) }
        if changed(Keys.assignees) { events.append(event(kinds.assignee, "Assign")) }
        if changed(Keys.due) { events.append(event(kinds.due, "Set due date on")) }

        // **Everything else the schema does not own says what it is** (re-ruled 2026-07-31). The
        // *item* is still what the subject names — one event however many keys moved — but the verb
        // now describes the change instead of shrugging at it, and the keys themselves ride the body.
        let trio: Set<String> = [Keys.labels, Keys.assignees, Keys.due]
        let keys = Set(before.keys).union(after.keys).subtracting(trio).filter(changed).sorted()
        if !keys.isEmpty {
            let subject = keys.count == 1 ? "custom key" : "\(keys.count) custom keys"
            events.append(Event(
                kind: kinds.customKey,
                subject: "Change \(subject) on \(noun) \(quotedSubject(customKeyItemTitle))",
                bullet: "Change \(subject) on \(noun) \(quoted(customKeyItemTitle))",
                detail: keys
                    .map { "\($0): \(before[$0] ?? absentValuePlaceholder) → \(after[$0] ?? absentValuePlaceholder)" }
                    .joined(separator: "\n"),
                destination: customKeyItemTitle,
                paths: paths
            ))
        }
        return events
    }

    // MARK: - Path events

    /// **Non-snapshot files commit too** (06): "beside the snapshot diff it receives the changed-path
    /// list, and non-snapshot paths compose *path-shaped events* — `CLAUDE.md` composes 'Update agent
    /// guide (vN)' … any other non-snapshot path composes 'Update '⟨path⟩'', folding plural".
    ///
    /// Four kinds of path never compose one:
    ///
    /// - a path a model event was already read from — it is already described;
    /// - an `index.md` the model *could* have spoken for and did not: that is the bookkeeping rule, and
    ///   reporting the file would smuggle a bumped `modified` stamp back in as an event;
    /// - anything under `.trash/`, whose whole content is the model's business (a purge of a trashed
    ///   lane is one event, not one line per file inside it);
    /// - the departing end of a **rename**, which its arrival already speaks for — the loose-file
    ///   relocation, the remint, a displaced squatter.
    ///
    /// **One path shape is not a generic** (06 ▸ Commit messages ▸ Vocabulary, "Replace 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: 'Replace attachment 'photo.png' — card 'X'',
    /// never the anonymous path generic". An unchanged listing is exactly "no model event claimed
    /// this path", so the rule needs no second question of the snapshot — see `replacedAttachment`.
    private static func pathEvents(for request: ChangeNarrationRequest, claimedBy model: [Event]) -> [Event] {
        let claimed = Set(model.flatMap(\.paths))
        var comments: [String: CommentGroup] = [:]
        var events: [Event] = []
        lazy var cardTitles = cardTitlesByPath(request)

        for changed in request.changedPaths.sorted(by: { $0.path < $1.path }) {
            let path = changed.path
            guard !claimed.contains(path), !(changed.isRename && changed.isDeletion) else { continue }

            // **The comment verb family, before every other rule** (01-storage-format.md § Enhanced
            // schema): a trashed card's thread lives under `.trash/`, which the model-silence rule
            // would otherwise swallow whole.
            if let comment = CommentPath.classify(path) {
                let folder = commentFolder(of: path) ?? comment.cardPath
                comments[
                    CommentGroup.key(comment),
                    default: CommentGroup(comment: comment, folder: folder)
                ].add(changed)
                continue
            }
            guard !Paths.isModelSilent(path) else { continue }

            // **N is a pure function of the file's content** (06; the m10 agent-guide card's deferred
            // bullet): the version marker on the guide's first line, read here rather than tagged at
            // the write site — "the no-interception rule stands". A guide filename with no marker is
            // not an old guide, it is somebody else's file (`AgentGuide.installedVersion`), and it
            // composes as the ordinary path it is. **Both claimed names** (`CLAUDE.md` and its
            // `AGENTS.md` twin, ruled 2026-08-09) share this shape — the twin carries the identical
            // marker line by construction, so the same version parse answers for either path.
            if AgentGuide.targetFilenames.contains(path),
               let version = request.agentGuideText.flatMap(AgentGuide.installedVersion(of:)) {
                events.append(Event(kind: .agentGuide, subject: "Update agent guide (v\(version))", paths: [path]))
                continue
            }

            // **Replace**, before the anonymous generic — the file is still listed, so the model had
            // nothing to say and the path says it instead.
            if let replaced = replacedAttachment(at: path) {
                let card = cardTitles[replaced.cardFolder] ?? untitledPlaceholder
                events.append(Event(
                    kind: .replaceFile,
                    subject: "Replace attachment \(quotedSubject(replaced.file)) — card \(quotedSubject(card))",
                    bullet: "Replace attachment \(quoted(replaced.file)) — card \(quoted(card))",
                    destination: card,
                    paths: [path]
                ))
                continue
            }

            events.append(Event(
                kind: .updatePath,
                subject: "Update \(quotedSubject(path))",
                bullet: "Update \(quoted(path))",
                paths: [path]
            ))
        }
        return commentEvents(comments, model: model, request: request) + events
    }

    /// **A card attachment rewritten in place** — the `Replace` shape, read from the path alone.
    ///
    /// `<lane>/<card>/attachments/<file>`, and nothing else: an attachment one folder deeper is not a
    /// shape this app writes, and a trashed card's is unreachable here (everything under `.trash/` is
    /// model-silent and never gets this far). Whether the *listing* changed is the caller's question,
    /// already answered — an added or removed file was claimed by its own Attach/Remove event before
    /// this path was ever reached.
    private static func replacedAttachment(at path: String) -> (cardFolder: String, file: String)? {
        let components = path.split(separator: "/", omittingEmptySubsequences: true).map(String.init)
        guard components.count == 4,
              components[2] == Paths.attachmentsFolder,
              let folder = cardFolder(of: path)
        else { return nil }
        return (folder, components[3])
    }

    // MARK: - The comment verb family

    /// Every changed path inside **one comment folder**, gathered so a comment that had its
    /// `index.md` and two attachments rewritten is one event rather than three.
    private struct CommentGroup {
        let comment: CommentPath

        /// This comment's own folder, board-root-relative — the key its `created` is looked up under
        /// (`ChangeNarrationRequest.commentTimestamps`) and the name the chronology's tie-break reads.
        let folder: String

        var paths: [String] = []
        var hasArrival = false
        var hasSurvivor = false

        static func key(_ comment: CommentPath) -> String {
            switch comment.kind {
            case .draft: "\(comment.cardPath)|draft"
            case let .comment(id): "\(comment.cardPath)|comment|\(id.rawValue)"
            case let .trashed(id): "\(comment.cardPath)|trashed|\(id.rawValue)"
            }
        }

        /// The folder's last component — the UUID a comment is named by, or `.draft`.
        var folderName: String {
            folder.split(separator: "/", omittingEmptySubsequences: true).last.map(String.init) ?? folder
        }

        mutating func add(_ changed: ChangedPath) {
            paths.append(changed.path)
            if changed.isArrival { hasArrival = true }
            if !changed.isDeletion { hasSurvivor = true }
        }
    }

    /// **The comment folder a changed path sits in**, board-root-relative — `<card>/comments/<uuid>`,
    /// `<card>/comments/.trash/<uuid>` or `<card>/comments/.draft` — or `nil` for a path that is not
    /// inside a thread at all.
    ///
    /// Shared with the flush that reads each of those folders' `created`
    /// (`ChangeNarrationRequest.commentTimestamps`), so the key a timestamp is *filed*
    /// under and the key it is *looked up* by have one definition. Everything about where a thread
    /// lives is still `CommentPath.classify`'s; this only says how many of the path's components that
    /// classification consumed.
    static func commentFolder(of path: String) -> String? {
        guard let comment = CommentPath.classify(path) else { return nil }
        let components = path.split(separator: "/", omittingEmptySubsequences: true).map(String.init)
        // `classify` has already guaranteed these counts — `<lane>/<card>/comments/<entry>` for a
        // comment or the draft, one deeper for `comments/.trash/<uuid>`.
        let depth: Int
        switch comment.kind {
        case .comment, .draft: depth = 4
        case .trashed: depth = 5
        }
        guard components.count >= depth else { return nil }
        return components.prefix(depth).joined(separator: "/")
    }

    /// **When each of this window's comments was created**, keyed by `commentFolder(of:)`'s own
    /// spelling — the chronology a commit's comment bullets sort by (06 ▸ Rules ▸ Auto-commit, blessed
    /// 2026-07-31: "by the comments' own `created`, folder name on ties").
    ///
    /// Relocated 2026-08-08 from `GitAutoCommitter.swift` with the git excision: the flush that called
    /// this is gone, but the read itself is git-free — one `index.md` per touched comment folder, off
    /// the working tree — so it stays beside the vocabulary that consumes it
    /// (`ChangeNarrationRequest.commentTimestamps`) rather than leaving with its old caller.
    ///
    /// **Missing is normal, not a defect.** A comment whose folder left the tree in this very window
    /// (the close purge), one whose `index.md` does not parse, one written by hand with no `created`
    /// at all — each is simply absent here and sorts after its dated siblings in folder-name order,
    /// which is `CommentThread.sorted`'s own fallback for the same field. Nothing here is reported —
    /// a commit message is the wrong place to discover a defect.
    static func commentTimestamps(for changed: [ChangedPath], boardRoot: URL) -> [String: Date] {
        var timestamps: [String: Date] = [:]
        var seen: Set<String> = []
        for path in changed {
            guard let folder = commentFolder(of: path.path), seen.insert(folder).inserted else { continue }
            let index = boardRoot
                .appendingPathComponent(folder)
                .appendingPathComponent(IntegrityRules.indexFileName)
            guard let data = try? Data(contentsOf: index),
                  let document = try? BoardLoader.parseDocument(data, path: folder),
                  let created = document.created.value
            else { continue }
            timestamps[folder] = created
        }
        return timestamps
    }

    /// **The comment verb family** (01-storage-format.md § Enhanced schema, the `kind: comment` block:
    /// "foreign comment changes are described by **path shape** — the 'Update agent guide (vN)'
    /// mechanism: a changed path under `…/comments/<uuid>/` composes 'Comment on ⟨card title⟩' / 'Edit
    /// comment on…' / 'Delete comment on…'"; 05-card-window.md ▸ The comments column for the post's
    /// own wording, and the same design session for the draft's "Draft comment on 'X'").
    ///
    /// ### Why this is path shape and not a diff
    ///
    /// "Comments are window-scoped, outside the board snapshot" — the stated exception to snapshot
    /// completeness. So the composer's usual question ("what do the two boards say") has no answer
    /// here, and the family is read off *where a file sits* plus the one fact the survey already
    /// knows: whether HEAD had that path (`ChangedPath.isArrival`). Arrival in a fresh comment
    /// folder is a post — the app's own post arrives as the `.draft` rename, and a foreign writer's
    /// arrives as a plain addition, and both read identically, which is the origin-agnostic rule one
    /// level down.
    ///
    /// ### What stays silent
    ///
    /// A comment whose *card* moved, was deleted, restored or purged: its files travelled because the
    /// card did, and the card's own event already says so. Naming a card's twelve comments beside
    /// "Delete card 'X'" would be the implied-events mistake with a longer body.
    private static func commentEvents(
        _ groups: [String: CommentGroup],
        model: [Event],
        request: ChangeNarrationRequest
    ) -> [Event] {
        guard !groups.isEmpty else { return [] }
        let titles = cardTitlesByPath(request)
        let relocated = relocatedCardFolders(in: model)
        // A comment that left `comments/<id>/` for `comments/.trash/<id>/` is one event, named at the
        // arriving end — the rename rule, restated for the case git did not pair the two halves.
        let trashedElsewhere = Set(groups.values.compactMap { group -> String? in
            guard case let .trashed(id) = group.comment.kind else { return nil }
            return "\(group.comment.cardPath)|comment|\(id.rawValue)"
        })

        var events: [Event] = []
        for key in chronological(groups, timestamps: request.commentTimestamps) {
            guard let group = groups[key], !group.paths.isEmpty else { continue }
            guard !relocated.contains(group.comment.cardPath) else { continue }
            let card = titles[group.comment.cardPath] ?? untitledPlaceholder

            func event(_ kind: Kind, _ verb: String) -> Event {
                Event(
                    kind: kind,
                    subject: "\(verb) on \(quotedSubject(card))",
                    bullet: "\(verb) on \(quoted(card))",
                    destination: card,
                    paths: group.paths
                )
            }

            switch group.comment.kind {
            case .draft:
                events.append(event(.commentDrafted, "Draft comment"))

            case .trashed:
                // Arriving in `comments/.trash/` is the delete; leaving it is the close purge.
                events.append(group.hasSurvivor
                    ? event(.commentDeleted, "Delete comment")
                    : event(.commentPurged, "Permanently delete comment"))

            case .comment:
                if !group.hasSurvivor {
                    // The folder is gone. If this window also put it in `comments/.trash/`, that end
                    // already spoke; otherwise it left the thread outright and this is the delete.
                    guard !trashedElsewhere.contains(key) else { continue }
                    events.append(event(.commentDeleted, "Delete comment"))
                    continue
                }
                events.append(group.hasArrival
                    ? event(.commentPosted, "Comment")
                    : event(.commentEdited, "Edit comment"))
            }
        }
        return events
    }

    /// **A commit's comment bullets sort chronologically** (06-history-undo.md ▸ Rules ▸ Auto-commit,
    /// blessed 2026-07-31):
    ///
    /// > "by the comments' own `created`, folder name on ties — event order reads as the conversation
    /// > did, never UUID-arbitrary."
    ///
    /// The ladder is `CommentThread.sorted`'s, one layer up and over folders rather than over parsed
    /// comments: `created` ascending, **the undated after the dated**, folder name on ties — compared
    /// as the canonical lowercase spelling, the corpus-wide rule for every folder-name tie-break
    /// (01-storage-format.md § Ordering). The thread the bullets describe is sorted by that ladder on
    /// screen; a commit describing the same thread in a different order would be the same events told
    /// out of sequence.
    ///
    /// **Chronology is global rather than per card**, recorded as a judgment call. A window's comment
    /// events almost always belong to one card — the card-window session flush is that window by
    /// construction — and where they do not, "the order the events happened in" is still the honest
    /// reading of a list of events; grouping by card first would sort a conversation by which lane its
    /// card sits in. The group key breaks the last tie so the order is total, which keeps a message
    /// reproducible rather than hash-ordered.
    private static func chronological(
        _ groups: [String: CommentGroup],
        timestamps: [String: Date]
    ) -> [String] {
        groups.keys.sorted { lhs, rhs in
            guard let left = groups[lhs], let right = groups[rhs] else { return lhs < rhs }
            switch (timestamps[left.folder], timestamps[right.folder]) {
            case let (leftDate?, rightDate?) where leftDate != rightDate:
                return leftDate < rightDate
            case (.some, .none):
                return true
            case (.none, .some):
                return false
            default:
                break
            }
            let leftName = IntegrityRules.canonicalIdentity(left.folderName)
            let rightName = IntegrityRules.canonicalIdentity(right.folderName)
            return leftName == rightName ? lhs < rhs : leftName < rightName
        }
    }

    /// The card folders this commit says moved, arrived or went — the set a comment path checks
    /// itself against before speaking.
    private static func relocatedCardFolders(in model: [Event]) -> Set<String> {
        let relocating: Set<Kind> = [
            .addCard, .deleteCard, .restoreCard, .purgeCard, .moveCard, .repairDuplicate,
        ]
        return Set(model.filter { relocating.contains($0.kind) }.flatMap(\.paths).compactMap { path in
            let suffix = "/" + Paths.boardIndex
            guard path.hasSuffix(suffix) else { return nil }
            return String(path.dropLast(suffix.count))
        })
    }

    /// Card titles by card-folder path, from whichever snapshot still holds the card — the current one
    /// first, since a comment usually lands on a card that is still there.
    private static func cardTitlesByPath(_ request: ChangeNarrationRequest) -> [String: String] {
        var titles: [String: String] = [:]
        for board in [request.previousSnapshot, request.snapshot].compactMap({ $0 }) {
            for lane in board.lanes {
                for card in lane.cards {
                    titles["\(lane.id.rawValue)/\(card.id.rawValue)"] = title(card.title)
                }
            }
            for card in board.trash {
                titles["\(Paths.trashFolder)/\(card.id.rawValue)"] = title(card.title)
            }
        }
        return titles
    }

    // MARK: - Shapes

    /// One describable change. Text is carried already-rendered: `subject` is the truncated form,
    /// `bullet` the full-title one, and `destination` the raw value the plural fold compares.
    struct Event: Equatable {
        let kind: Kind
        let subject: String
        let bullet: String
        /// The extra body line a sole event gets where the subject alone leaves something out.
        let detail: String?
        /// What several of this kind must agree on to keep it in a plural subject ("Move 3 cards to
        /// Done").
        let destination: String?
        /// A foregone consequence of another event already reported — body material, never a subject.
        let implied: Bool
        /// The board-root-relative paths this event was read from. The commit's own path list is
        /// filtered against these, which is what keeps a split window's messages about their own
        /// commits.
        let paths: [String]

        init(
            kind: Kind,
            subject: String,
            bullet: String? = nil,
            detail: String? = nil,
            destination: String? = nil,
            implied: Bool = false,
            paths: [String]
        ) {
            self.kind = kind
            self.subject = subject
            self.bullet = bullet ?? subject
            self.detail = detail
            self.destination = destination
            self.implied = implied
            self.paths = paths
        }
    }

    /// The verb-plus-noun grouping that decides what folds with what — **06's vocabulary, one case
    /// each**: Add / Delete / Move / Rename / Edit / Restyle / Resize / Reorder over cards, lanes and
    /// the board, Attach / Remove / **Replace** for attachment files, Repair for the remint, the trash
    /// pair, the reserved metadata trio, the custom-key change, and the two path shapes.
    enum Kind: Hashable {
        case addCard, deleteCard, restoreCard, purgeCard, moveCard, renameCard, editCard, restyleCard
        case relabelCard, assignCard, dueCard, changeKeyCard
        case attachFile, removeFile, replaceFile, reorderCards, repairDuplicate

        case addLane, deleteLane, restoreLane, purgeLane, renameLane, editLane, restyleLane, resizeLane
        /// The lane fold's two directions (03-board-ui.md § Lane ▸ Collapsed lanes) — two kinds for
        /// the trash pair's reason: they are told apart by diff shape alone and each says its own word.
        case collapseLane, expandLane
        case relabelLane, assignLane, dueLane, changeKeyLane, reorderLanes

        case renameBoard, editBoard, restyleBoard, relabelBoard, assignBoard, dueBoard, changeKeyBoard

        case agentGuide, updatePath

        /// The comment verb family (01-storage-format.md § Enhanced schema) — path-shaped like the
        /// guide, because "comments are window-scoped, outside the board snapshot".
        case commentPosted, commentEdited, commentDeleted, commentDrafted, commentPurged

        /// Whether this event came from the snapshot diff — the rank that lets "model events keep the
        /// subject when present" be one comparison rather than a list.
        ///
        /// The comment family is **not** model, deliberately: a lane move that carries a comment
        /// change in the same window keeps the lane's subject, and a comment-only window still gets
        /// its own subject rather than "Update board", because the headline falls through to the
        /// non-model events before it ever reaches the fallback.
        var isModel: Bool {
            switch self {
            case .agentGuide, .updatePath,
                 .commentPosted, .commentEdited, .commentDeleted, .commentDrafted, .commentPurged:
                false
            default:
                true
            }
        }

        /// The subject N events of this kind fold into, with a shared destination kept when every one
        /// of them agrees on it ("moved 3 cards, all to Done" is worth saying in a subject; "to 3
        /// different places" isn't).
        func plural(_ count: Int, destination: String?) -> String {
            let target = destination.map { " to \(ChangeNarrator.truncated($0))" } ?? ""
            switch self {
            case .addCard: return "Add \(count) cards\(target)"
            case .deleteCard: return "Delete \(count) cards"
            case .restoreCard: return "Restore \(count) cards"
            case .purgeCard: return "Permanently delete \(count) cards"
            case .moveCard: return "Move \(count) cards\(target)"
            case .renameCard: return "Rename \(count) cards"
            case .editCard: return "Edit \(count) cards"
            case .restyleCard: return "Restyle \(count) cards"
            case .relabelCard: return "Relabel \(count) cards"
            case .assignCard: return "Assign \(count) cards"
            case .dueCard: return "Set due date on \(count) cards"
            case .changeKeyCard:
                guard let destination else { return "Change custom keys on \(count) cards" }
                return "Change custom keys on card \(ChangeNarrator.quotedSubject(destination))"
            case .attachFile:
                guard let destination else { return "Attach \(count) files" }
                return "Attach \(count) files to card \(ChangeNarrator.quotedSubject(destination))"
            case .removeFile:
                guard let destination else { return "Remove \(count) files" }
                return "Remove \(count) files from card \(ChangeNarrator.quotedSubject(destination))"
            case .replaceFile:
                guard let destination else { return "Replace \(count) attachments" }
                return "Replace \(count) attachments — card \(ChangeNarrator.quotedSubject(destination))"
            case .reorderCards:
                guard let destination else { return "Reorder cards in \(count) lanes" }
                return "Reorder cards in \(ChangeNarrator.truncated(destination))"
            case .repairDuplicate: return "Repair \(count) duplicates"

            case .addLane: return "Add \(count) lanes"
            case .deleteLane: return "Delete \(count) lanes"
            case .restoreLane: return "Restore \(count) lanes"
            case .purgeLane: return "Permanently delete \(count) lanes"
            case .renameLane: return "Rename \(count) lanes"
            case .editLane: return "Edit \(count) lanes"
            case .restyleLane: return "Restyle \(count) lanes"
            case .resizeLane: return "Resize \(count) lanes"
            case .collapseLane: return "Collapse \(count) lanes"
            case .expandLane: return "Expand \(count) lanes"
            case .relabelLane: return "Relabel \(count) lanes"
            case .assignLane: return "Assign \(count) lanes"
            case .dueLane: return "Set due date on \(count) lanes"
            case .changeKeyLane:
                guard let destination else { return "Change custom keys on \(count) lanes" }
                return "Change custom keys on lane \(ChangeNarrator.quotedSubject(destination))"

            // A board has one title, one description, one style — and a lane reorder is a single
            // whole-board event. None of these can actually recur; the switch stays exhaustive.
            case .reorderLanes: return "Reorder lanes"
            case .renameBoard: return "Rename board"
            case .editBoard: return "Edit board description"
            case .restyleBoard: return "Restyle board"
            case .relabelBoard: return "Relabel board"
            case .assignBoard: return "Assign board"
            case .dueBoard: return "Set due date on board"
            // One board, so a plural of *events* here is a plural of keys already folded into one
            // event — unreachable in practice, and count-less if it ever is.
            case .changeKeyBoard:
                guard let destination else { return "Change custom keys on board" }
                return "Change custom keys on board \(ChangeNarrator.quotedSubject(destination))"

            case .agentGuide: return "Update agent guide"
            case .updatePath: return "Update \(count) files"

            // **The family's plurals fold on the card**, which is the only destination a comment has.
            // The post's is the one shape that will not go verb-first without inventing a verb the
            // design never wrote ("Post…", "Add…"), so it counts the noun the other three already
            // count: "3 comments on 'X'".
            case .commentPosted:
                guard let destination else { return "Comment on \(count) cards" }
                return "\(count) comments on \(ChangeNarrator.quotedSubject(destination))"
            case .commentEdited:
                guard let destination else { return "Edit \(count) comments" }
                return "Edit \(count) comments on \(ChangeNarrator.quotedSubject(destination))"
            case .commentDeleted:
                guard let destination else { return "Delete \(count) comments" }
                return "Delete \(count) comments on \(ChangeNarrator.quotedSubject(destination))"
            case .commentPurged:
                guard let destination else { return "Permanently delete \(count) comments" }
                return "Permanently delete \(count) comments on \(ChangeNarrator.quotedSubject(destination))"
            // One draft per card, so several can only mean several cards.
            case .commentDrafted: return "Draft comment on \(count) cards"
            }
        }
    }

    /// The frontmatter keys 06 names one by one — reserved out of this version's UI
    /// (01-storage-format.md § Enhanced schema) and therefore ordinary unknown keys everywhere else
    /// in the app, which is exactly why the composer has to name them here.
    private enum Keys {
        static let labels = "labels"
        static let assignees = "assignees"
        static let due = "due"
    }

    // MARK: - Paths

    /// Board-root-relative paths in git's own spelling, and the one question the path filter asks.
    enum Paths {
        static let boardIndex = IntegrityRules.indexFileName
        static let trashFolder = IntegrityRules.trashFolderName
        static let attachmentsFolder = "attachments"

        static func join(_ folder: String, _ name: String) -> String { "\(folder)/\(name)" }

        static func index(_ components: String...) -> String {
            (components + [boardIndex]).joined(separator: "/")
        }

        static func attachments(_ parent: String, _ item: String) -> String {
            "\(parent)/\(item)/\(attachmentsFolder)"
        }

        /// Whether a path is one the *snapshot* speaks for, so that its silence means "nothing
        /// describable changed" rather than "nobody looked".
        ///
        /// Every `index.md` in the board's fractal layout, and everything inside `.trash/`. An
        /// attachment is deliberately **not** here: the model carries attachment *names*, so an added
        /// or removed file composes its own event, while a rewritten one — same name, new bytes — has
        /// nothing in the snapshot to show for it and composes **Replace** off its path instead
        /// (`replacedAttachment(at:)`, 06 ▸ Commit messages ▸ Vocabulary, added 2026-07-31 — "never
        /// the anonymous path generic").
        /// Whether a path could make the *snapshot* differ at all — every path the model speaks for,
        /// plus attachments, whose names it carries.
        ///
        /// The flush asks it before it materializes the previous snapshot: a window of nothing but
        /// strays and the agent guide composes path-shaped events, and reading two whole boards to
        /// describe a changed `.gitignore` would be work with no reader.
        static func mightAffectSnapshot(_ path: String) -> Bool {
            isModelSilent(path) || path.split(separator: "/").contains(Substring(attachmentsFolder))
        }

        static func isModelSilent(_ path: String) -> Bool {
            if path == boardIndex { return true }
            if path == trashFolder || path.hasPrefix(trashFolder + "/") { return true }
            let components = path.split(separator: "/").map(String.init)
            guard components.count == 2 || components.count == 3, components.last == boardIndex
            else { return false }
            return components.dropLast().allSatisfy(BoardLoader.isUUIDShaped)
        }
    }

    // MARK: - Rendering

    static func title(_ field: FieldValue<String>) -> String {
        guard let value = field.value, !value.isEmpty else { return untitledPlaceholder }
        return value
    }

    static func truncated(_ text: String) -> String {
        text.count > titleLimit ? String(text.prefix(titleLimit)) + "…" : text
    }

    /// **Single quotes**, which is the form every example in 06 and on this card is written in ("Move
    /// card 'Fix login' to Doing", "Repair duplicate of 'Fix login'", "Update '⟨path⟩'") — the
    /// pathfinder's double quotes are not carried.
    static func quoted(_ text: String) -> String { "'\(text)'" }

    static func quotedSubject(_ text: String) -> String { quoted(truncated(text)) }

    /// **06's own rename form**, subject and bullet alike: "Rename lane 'Todo' → 'Doing'" — both ends
    /// in the subject, so no detail line is needed to say what it used to be called.
    private static func renameEvent(
        kind: Kind,
        noun: String,
        old: String,
        new: String,
        paths: [String]
    ) -> Event {
        Event(
            kind: kind,
            subject: "Rename \(noun) \(quotedSubject(old)) → \(quotedSubject(new))",
            bullet: "Rename \(noun) \(quoted(old)) → \(quoted(new))",
            paths: paths
        )
    }

    // MARK: - Small rules

    /// The three cosmetic fields a board, a lane and a card all carry. Any difference among them is
    /// one Restyle, however many of the three moved.
    private static func styleDiffers(
        _ oldBackground: FieldValue<String>, _ oldIcon: FieldValue<String>, _ oldIconColor: FieldValue<String>,
        _ newBackground: FieldValue<String>, _ newIcon: FieldValue<String>, _ newIconColor: FieldValue<String>
    ) -> Bool {
        oldBackground != newBackground || oldIcon != newIcon || oldIconColor != newIconColor
    }

    /// Where a card is, said the way a subject says it: its lane's title, or the trash.
    private static func destinationName(of entry: CardEntry, lanes: [ItemID: LaneEntry]) -> String {
        guard let lane = entry.lane else { return trashDestination }
        return lanes[lane]?.displayTitle ?? untitledPlaceholder
    }

    /// What became of the lane a departed card was in — the fact that decides whether its departure
    /// is a permanent deletion or a delete that rode along with its lane.
    private enum LaneFate { case standing, trashed, gone }

    private static func fateOfLane(
        of entry: CardEntry,
        previousLanes: [ItemID: LaneEntry],
        currentLanes: [ItemID: LaneEntry]
    ) -> LaneFate {
        guard let lane = entry.lane else { return .standing }
        guard let now = currentLanes[lane] else {
            return previousLanes[lane] == nil ? .standing : .gone
        }
        return now.container == .trash ? .trashed : .standing
    }

    /// The departed half of a duplicate-id remint: a rename-paired departure sitting in the **same
    /// parent folder** as the arrival (the remint renames in place — `BoardWriter.renameFolder`)
    /// whose identity the previous snapshot never held, because the loader withheld it as the
    /// duplicate it was.
    private static func remintedTwin(
        of entry: CardEntry,
        among departures: Set<String>,
        previous: [ItemID: CardEntry]
    ) -> String? {
        let parent = entry.path.parentOfItemFolder
        return departures.sorted().first { departure in
            guard departure != entry.path,
                  departure.parentOfItemFolder == parent,
                  let identity = departure.itemFolderName
            else { return false }
            return previous[ItemID(rawValue: identity)] == nil
        }
    }

    /// Both ends of every rename libgit2 paired up in this commit, split by direction.
    private static func renamedPaths(
        in request: ChangeNarrationRequest
    ) -> (arrivals: Set<String>, departures: Set<String>) {
        var arrivals: Set<String> = []
        var departures: Set<String> = []
        for changed in request.changedPaths where changed.isRename {
            if changed.isDeletion { departures.insert(changed.path) } else { arrivals.insert(changed.path) }
        }
        return (arrivals, departures)
    }

    /// A deterministic order for identities present on one side only — the folder spelling, which is
    /// the primary key on disk.
    private static func idOrder(_ lhs: ItemID, _ rhs: ItemID) -> Bool {
        lhs.rawValue < rhs.rawValue
    }
}

// MARK: - Path shorthands

private extension String {

    /// `<lane>/<card>/index.md` → `<lane>`: the folder an item's own folder sits in.
    var parentOfItemFolder: String {
        let components = split(separator: "/").map(String.init)
        guard components.count > 2 else { return "" }
        return components.dropLast(2).joined(separator: "/")
    }

    /// `<lane>/<card>/index.md` → `<card>`: the identity folder an `index.md` describes.
    var itemFolderName: String? {
        let components = split(separator: "/").map(String.init)
        guard components.count >= 2 else { return nil }
        return components[components.count - 2]
    }
}
