import Foundation

// MARK: - CommentTarget

/// Which of a card's two **authoring surfaces** a write is aimed at: the composer's draft, or one
/// posted comment being edited inline (05-card-window.md ▸ The comments column — the hover-target
/// carve-out's two destinations).
///
/// It exists because the pair is a *choice the view makes* and the store must not re-derive: which
/// surface the pointer was over when a file was dropped is knowledge only the window has, and the
/// alternative — two near-identical store methods — would put the choice in the call site's name
/// instead of in a value a test can hold.
///
/// `comments/.trash/` is deliberately not a case: a deleted comment is undo's backing store and
/// "never a UI surface" (01-storage-format.md § Enhanced schema), so there is no gesture that could
/// aim at one.
public enum CommentTarget: Sendable, Equatable {
    /// `comments/.draft/` — the composer's backing file.
    case draft
    /// `comments/<uuid>/` — a posted comment with an inline edit session open over it.
    case comment(ItemID)

    /// Where the target lives, given the card's folder. One resolution, so a view and a write can
    /// never disagree about which folder "the composer" means.
    public func folder(inCard cardFolder: URL) -> URL {
        switch self {
        case .draft: CommentThread.draftFolder(inCard: cardFolder)
        case let .comment(id): CommentThread.commentFolder(id, inCard: cardFolder)
        }
    }
}

// MARK: - The comment gestures

/// **The comment thread at the store boundary** — every comment write the app makes, bracketed, with
/// its inverse registered where 13-native-undo.md says one belongs.
///
/// An extension in its own file for `BoardStoreHistory.swift`'s reason, and with its reason for
/// living on `BoardStore` at all: comments are read by the *card window* and written through the
/// *board's* store — one store per board, whatever the window (02-architecture.md § Components), so a
/// comment posted here takes the same bracket, the same receipts and the same banner surface as every
/// other write in the app.
///
/// **Which stack the steps land on is not the store's answer** (re-ruled 2026-07-31 — the
/// session-coarsening model, 13 ▸ Rules ▸ two levels): every gesture below takes the issuing window's
/// stack as a parameter, because a comment gesture is a *card window's* gesture and "the board stack
/// never carries a granular comment step" (13 ▸ Interaction with the trash). Board history sees the
/// window's session as one coarse step when the window closes (`registerCardSession`).
///
/// ### What registers a step, and what deliberately does not
///
/// - **`postComment`** — inverse: the rename back to `.draft`. Move-based, no capture.
/// - **`deleteComment`** — inverse: the move back out of `comments/.trash/`. Move-based, no capture.
/// - **`registerCommentEdit`** — one *session*, at its commit point, holding the bytes it opened on
///   (added 2026-07-31 with the window stack, which is where 13 now puts the inline edit). The
///   per-tick `editComment` write registers nothing, exactly as a card body's per-tick save does not.
/// - **`saveCommentDraft` registers nothing**: a draft is a durable file being edited in place, with
///   no session boundary to coalesce at and no meaning for "undo" that emptying it does not already
///   have (05-card-window.md ▸ The comments column).
/// - **`purgeCommentTrash` registers nothing** — the permanent-delete posture. What it *does* consult
///   is the stack: since "every purge of `comments/.trash/` is per-entry behind the ownership gate"
///   (13 ▸ Interaction with the trash, ruled 2026-08-06) a purge removes only what no live step backs,
///   so it can no longer stale a step by construction. Lazy invalidation still stands for the
///   staleness a *foreign* hand leaves — **the expectations validate disk, never the snapshot**
///   (`HistoryStaleness`), which is exactly why that works: comments are not in the snapshot at all.
@MainActor
extension BoardStore {

    // MARK: Reading

    /// One card's thread, read fresh from disk — **window-scoped**, never cached on the store and
    /// never part of `snapshot` (01-storage-format.md § Enhanced schema: "the board snapshot never
    /// loads comment content", so the board walk stays O(cards)).
    ///
    /// `.empty` for an id that names no card on the board — the same vanished-target answer every
    /// other card-scoped call gives (`boardItem`).
    public func commentThread(inCard id: ItemID) -> CommentThread {
        guard let card = commentSubject(id) else { return .empty }
        return CommentThread.load(inCard: card.folder, path: card.path)
    }

    /// **The composer's own file, read** — `comments/.draft/`, excluded from the thread listing and
    /// therefore asked for by name (`CommentThread.loadDraft`).
    ///
    /// `nil` for a card with no draft, an unreadable one, or an id that names no card — the same
    /// vanished-target answer `commentThread(inCard:)` gives, and the same "nothing to restore".
    public func commentDraft(inCard id: ItemID) -> CommentDraft? {
        guard let card = commentSubject(id) else { return nil }
        return CommentThread.loadDraft(inCard: card.folder)
    }

    /// **Which of this card's comments the app itself just wrote** — the ledger's receipts, classified
    /// by path shape and retired (`EchoLedger.vouchedComments(inCard:cardPath:)`).
    ///
    /// The card window asks this on every landed reload, before deciding whether its thread's changes
    /// are worth announcing: "app-mediated echoes never announce" (10-accessibility.md ▸ Live board
    /// announcements) needs a per-comment answer, and the board reload cannot give one because comments
    /// are not in the snapshot it compares.
    ///
    /// `[]` for a card that is gone — the same vanished-target answer every other card-scoped call
    /// gives, and the conservative direction: nothing vouched for means everything speaks.
    public func vouchedComments(inCard id: ItemID) -> Set<ItemID> {
        guard let card = commentSubject(id) else { return [] }
        return echoes.vouchedComments(inCard: card.folder, cardPath: card.path)
    }

    // MARK: The draft

    /// Saves the composer's draft — one bracket, no step.
    ///
    /// - Returns: what landed, or `nil` when the write failed (the banner is already
    ///   `performWrite`'s) or the card is gone.
    @discardableResult
    public func saveCommentDraft(inCard id: ItemID, body: String) -> CommentDraftOutcome? {
        guard let card = commentSubject(id) else { return nil }
        return try? performWrite { () throws(BoardWriteError) -> CommentDraftOutcome in
            try BoardWriter.saveCommentDraft(inCard: card.folder, body: body, cardTitle: card.title)
        }
    }

    /// **Posts the draft, and registers the one step the gesture owes** (⌘↩ or the Comment button).
    ///
    /// The step's predicate is the brief the ruling gives it: the undo needs the posted folder still
    /// at its path **and `.draft` still absent** — a draft the user has started typing since is not
    /// this step's to overwrite — and the redo needs the mirror. Both are `HistoryExpectation`s over
    /// disk, and the `.absent` half is the same one an undone create uses.
    ///
    /// **The redo replays the captured identity and the captured instant**, never fresh ones: a redo
    /// that re-minted would post a *different* comment, and any step registered above this one naming
    /// the posted id would name nothing.
    ///
    /// **The step lands on the issuing window's stack** (13 ▸ Rules ▸ two levels, re-ruled
    /// 2026-07-31): the composer is a card window's surface, so the window passes its own
    /// (`CardWindowHost.configureComments`). `nil` is the board's stack — a caller with no window,
    /// which in the app is nobody and in a test is the shortest way to drive the write.
    @discardableResult
    public func postComment(inCard id: ItemID, on window: CardWindowUndo? = nil) -> ItemID? {
        guard let card = commentSubject(id) else { return nil }
        guard let posted = try? performWrite({ () throws(BoardWriteError) -> PostedComment in
            try BoardWriter.postComment(inCard: card.folder, cardTitle: card.title)
        }) else {
            return nil
        }

        let title = card.title
        // **Anchored to the card, not to its folder** (13 ▸ Rules, ruled 2026-07-31): both halves of
        // the move name the card's identity plus the thread position, and the card's folder is
        // resolved at apply time — so a lane move under an open window leaves this step, and the
        // coarse step folding it, exactly as sound as they were.
        let cardAnchor = HistoryAnchor.card(id)
        let operation = WriteOperation.postComment(title: title)
        registerStep(
            HistoryPhrase.comment,
            subject: title,
            on: window,
            undoExpects: [.present(.comment(posted.id, inCard: id)), .absent(.commentDraft(inCard: id))],
            redoExpects: [.present(.commentDraft(inCard: id)), .absent(.comment(posted.id, inCard: id))]
        ) { store in
            try BoardWriter.unpostComment(
                posted.id,
                inCard: try store.requiredFolder(for: cardAnchor, operation),
                cardTitle: title
            )
        } redo: { store in
            try BoardWriter.repostComment(
                as: posted.id,
                inCard: try store.requiredFolder(for: cardAnchor, operation),
                stamping: posted.posted,
                cardTitle: title
            )
        }
        return posted.id
    }

    // MARK: Editing

    /// An inline edit session's save — one bracket, **no step**, exactly as a card body's ~700 ms
    /// tick writes no step: a session is not a save (`registerCommentEdit`, below).
    ///
    /// - Returns: whether bytes were written; `false` also for a card or comment that is gone.
    @discardableResult
    public func editComment(_ commentID: ItemID, inCard id: ItemID, body: String) -> Bool {
        guard let card = commentSubject(id) else { return false }
        let folder = CommentThread.commentFolder(commentID, inCard: card.folder)
        let wrote = try? performWrite { () throws(BoardWriteError) -> Bool in
            try BoardWriter.editComment(at: folder, body: body, cardTitle: card.title)
        }
        return wrote ?? false
    }

    /// **Registers one inline edit session as one step** — `registerBodyEdit`'s shape one level down,
    /// and its reasons verbatim.
    ///
    /// The window stack is what made this possible and what made it necessary. 13-native-undo.md's
    /// no-byte-capture rule is about the *board* stack, where a comment's granular history has no
    /// business ("the board stack never carries a granular comment step"); the re-ruled two-level
    /// model puts every gesture issued in a card window on that window's own stack at fine grain,
    /// "comment post/delete/**edit**" named among them. So an inline edit registers where the body's
    /// Edit session registers, at the same kind of boundary — Save, ⌘↩, or the close flush — with the
    /// bytes the session opened on (`CommentEditSession.sessionStart`).
    ///
    /// Cancel registers nothing, and needs no rule of its own: it writes the session-start bytes back,
    /// so the session's net effect is nothing and there is nothing to undo.
    ///
    /// Its predicate is the body's: "body steps compare bytes" (13 ▸ Rules), against the comment's own
    /// folder — so a foreign edit to that comment skips the step, and a foreign edit to the card or to
    /// a sibling comment leaves it alone.
    public func registerCommentEdit(
        _ commentID: ItemID,
        inCard id: ItemID,
        priorBody: String,
        newBody: String,
        on window: CardWindowUndo? = nil
    ) {
        guard priorBody != newBody, let card = commentSubject(id) else { return }
        let title = card.title
        let comment = HistoryAnchor.comment(commentID, inCard: id)
        let operation = WriteOperation.editComment(title: title)

        registerStep(
            HistoryPhrase.name(.edit, kind: .comment),
            subject: title,
            on: window,
            undoExpects: [.present(comment, .body(newBody))],
            redoExpects: [.present(comment, .body(priorBody))]
        ) { store in
            _ = try BoardWriter.editComment(
                at: try store.requiredFolder(for: comment, operation),
                body: priorBody,
                cardTitle: title
            )
        } redo: { store in
            _ = try BoardWriter.editComment(
                at: try store.requiredFolder(for: comment, operation),
                body: newBody,
                cardTitle: title
            )
        }
    }

    // MARK: Delete and its inverse

    /// **Deletes a comment — a move into `comments/.trash/`, immediate, no confirm, undoable**
    /// (01-storage-format.md § Enhanced schema; 05-card-window.md ▸ The comments column).
    ///
    /// The step is the move read backwards, `moveToTrash`'s registration one level down and without
    /// its rank half: a comment's trash has no order, so there is no `.order` after-value to compare
    /// and existence is the whole predicate. The container rides in the *anchor* exactly as it rides
    /// in the path at board level — the undo expects the comment in `comments/.trash/`, the redo
    /// expects it back in the thread — so a foreign restore or a foreign re-delete skips the right
    /// half by itself, while the card's own lane is nowhere in either claim.
    ///
    /// **Both sides of the move are declared**, `postComment`'s shape (added 2026-07-31 with the
    /// window stack): the undo needs the trashed folder there *and the live path free*, because the
    /// move back would otherwise land on top of whatever now sits at it — and because the close fold
    /// reads these lists as the state the session left. A move that named only its destination would
    /// leave the session's fold claiming a comment is still at a path it has left, and the coarse step
    /// would be stale the moment it was registered (`CardWindowUndo.netEffect`).
    ///
    /// **The step lands on the issuing window's stack** — `postComment`'s rule, and the one 13's
    /// comments paragraph states outright: "the step lives on the card window's own stack ... so the
    /// old stale-after-close skip scenario cannot arise". What reaches the board is the close step
    /// that folds it, whose undo restores the comment from a `comments/.trash/` the same step is
    /// keeping alive (`registerCardSession`).
    ///
    /// The `.present(trashed)` half of the undo's expectations is also this step's **backing claim**,
    /// read off it by `HistoryStep.backing(declaredBy:)` and carried into the coarse step by the fold:
    /// it is what tells the next window open's residue sweep that the folder in `comments/.trash/` is
    /// a live step's, not a dead session's leftovers (13 ▸ Interaction with the trash, ruled
    /// 2026-07-31).
    @discardableResult
    public func deleteComment(_ commentID: ItemID, inCard id: ItemID, on window: CardWindowUndo? = nil) -> Bool {
        guard let card = commentSubject(id) else { return false }
        let folder = card.folder
        let title = card.title
        let live = CommentThread.commentFolder(commentID, inCard: folder)

        let landed: Void? = try? performWrite { () throws(BoardWriteError) -> Void in
            _ = try BoardWriter.deleteComment(at: live, cardTitle: title)
        }
        guard landed != nil else { return false }

        // `postComment`'s anchoring, for its reason: the pair of anchors is the move, and the card's
        // folder is resolved at apply time rather than baked into the step (13 ▸ Rules, ruled
        // 2026-07-31).
        let cardAnchor = HistoryAnchor.card(id)
        let comment = HistoryAnchor.comment(commentID, inCard: id)
        let trashed = HistoryAnchor.trashedComment(commentID, inCard: id)
        let operation = WriteOperation.deleteComment(title: title)
        registerStep(
            HistoryPhrase.name(.delete, kind: .comment),
            subject: title,
            on: window,
            undoExpects: [.present(trashed), .absent(comment)],
            redoExpects: [.present(comment), .absent(trashed)]
        ) { store in
            try BoardWriter.restoreComment(
                commentID,
                inCard: try store.requiredFolder(for: cardAnchor, operation),
                cardTitle: title
            )
        } redo: { store in
            _ = try BoardWriter.deleteComment(
                at: try store.requiredFolder(for: comment, operation),
                cardTitle: title
            )
        }
        return true
    }

    // MARK: The purge, and the crash residue it leaves

    /// **Purges one card's `comments/.trash/`, entry by entry, of everything undo no longer needs**
    /// (01-storage-format.md § Enhanced schema, re-ruled 2026-07-31; 13-native-undo.md ▸ Interaction
    /// with the trash, re-ruled 2026-08-06).
    ///
    /// The call site moved with the 2026-07-31 re-ruling: the window's close no longer purges on its
    /// own, it hands this work to the coarse close step as that step's **retirement**
    /// (`HistoryStep.Retirement`), and the purge runs when the step leaves the board stack **cleanly**
    /// — undone-and-superseded, or dropped off the end — or when the board session ends. A **stale
    /// skip is not a clean exit** and does not purge (the decoupling ruled 2026-07-31): that step's
    /// backing survives to session end instead, where `clear()` retires it and this method finally
    /// runs. (On a git board the step was never kept, so the retirement fired at the close flush
    /// instead, which is where the purge always ran there — "purge rides the close flush"; app-managed
    /// git is gone now, `strategy/01-git-excision.md`, so every board takes the native path above.)
    ///
    /// ### Entries, not the container — the ownership gate, on this side too
    ///
    /// What changed on 2026-08-06 is *what* a purge is allowed to remove:
    ///
    /// > "**Every purge of `comments/.trash/` is per-entry behind the ownership gate** (ruled
    /// > 2026-08-06 — the container-whole retirement purge retires): a step's retirement and a no-step
    /// > close remove only entries no live step still backs — the same `backedContent` inventory the
    /// > sweep consults, making it one condition, *three* consumers. The container-whole purge assumed
    /// > one owning step per card's comment trash, and two sessions over the same card broke it: the
    /// > second step's retirement — or a mere reopen-and-close that registered nothing — emptied the
    /// > first step's backing out from under it, silently killing an undo the stack still promised.
    /// > Under the gate a purge cannot stale a live step by construction; an entry that outlives its
    /// > owner is collected by whichever consumer runs next (the next retirement, close, open-time
    /// > sweep, or session end — convergence, not a leak)."
    ///
    /// So this reads the same inventory `sweepCommentTrashResidue(inCard:)` reads, through the same
    /// filter (`unownedTrashedComments`), and removes with the same per-entry primitive. The two
    /// consumers differ only in their bracket and their trigger, which is all they ever should have.
    ///
    /// ### And the carve-out the inventory cannot cover
    ///
    /// > "One carve-out: **an open card window is itself an owner of its card's comment trash** — a
    /// > retirement firing while the card's window is open defers its purge to that window's close,
    /// > because entries deleted in the live session are backed by the window's fine steps, which the
    /// > board-stack inventory cannot see; the close then settles by the same gate."
    ///
    /// `openCardWindows` is that ownership, and the deferral is total rather than per entry: a live
    /// session's fine steps are on the *window's* stack, so `backedContent` is not merely incomplete
    /// about them, it is silent. The close is where the answer becomes knowable again — its coarse
    /// step becomes the owner, or the no-step close arrives here with the window already unmarked
    /// (`BoardStore.cardWindowDidClose(inCard:)`, run first by `CardWindowSession.endSession`).
    ///
    /// **One bracket, no step — and no bracket at all for an empty remainder**, which is every close
    /// on a card nothing was deleted in. Leftover comment steps on a stack are not pruned here and
    /// must not be: invalidation is lazy (13 ▸ Rules), so a step whose backing something *else* removed
    /// stays on the stack, looks full, and skips with the ordinary info-tone banner the first time it
    /// is crossed. Under the gate this method is no longer one of those somethings.
    public func purgeCommentTrash(inCard id: ItemID) {
        // The carve-out, before anything else: an open window owns its card's whole comment trash.
        guard !openCardWindows.contains(id) else { return }
        guard let card = commentSubject(id) else { return }
        let folder = card.folder
        let unowned = unownedTrashedComments(inCard: id, at: folder)
        guard !unowned.isEmpty else { return }
        try? performWrite { () throws(BoardWriteError) -> Void in
            try Self.purgeTrashedComments(unowned, inCard: folder)
        }
    }

    /// **The crash-residue sweep**, run when a card window opens (§ Enhanced schema: "crash residue
    /// sweeps at the next card-window open, armed-then-cleared like every heal memo").
    ///
    /// ### Residue is what no live step owns — the stack is asked first
    ///
    /// The ruling of 2026-07-31 (13-native-undo.md ▸ Interaction with the trash; § Enhanced schema's
    /// "residue defined as content no live coarse step owns"):
    ///
    /// > "residue is defined by the purge-deferral condition itself: `comments/.trash/` content
    /// > referenced by a live coarse step on the board stack is a step's **backing, not residue** —
    /// > the open-time sweep consults the stack and skips owned content, re-arming when the owning
    /// > step leaves the stack (which is exactly when the deferred purge wanted to run; one condition,
    /// > two consumers). **Reopening a window can therefore never destroy its prior session's undo
    /// > backing.** Unowned content sweeps as before."
    ///
    /// The defect it closes is the reopen: close a window that deleted a comment (the coarse step
    /// registers, its undo restores from `comments/.trash/`), open the same card again, and this sweep
    /// used to empty the trash out from under a step still sitting on the board's stack — leaving a
    /// ⌘Z that could only skip.
    ///
    /// So the gate is `HistoryProviding.backedContent`, and it is the same fact the deferred purge
    /// waits on, asked as an inventory instead of as a moment. Nothing here has to *re-arm* by hand:
    /// when the owning step retires, its retirement runs the deferred purge and the content is gone;
    /// and a sweep that found nothing left to do rests, which clears the memo (`HealScheduler` step 1)
    /// so a later picture is a fresh one.
    ///
    /// **A step a stale skip popped still counts as an owner** (the skip-purge decoupling, ruled
    /// 2026-07-31): "a stale-skipped step's backing instead survives to board-session end ... the skip
    /// is exactly when the user may want to inspect what the collision left". Nothing here says so —
    /// see `unownedTrashedComments(inCard:at:)`, which is where the whole gate now lives.
    ///
    /// ### Entries, not the container — which is now every purge's posture
    ///
    /// This was the first consumer to purge per entry, because the whole folder is exactly what it may
    /// not assume it owns. It is no longer the only one: **"every purge of `comments/.trash/` is
    /// per-entry behind the ownership gate ... the same `backedContent` inventory the sweep consults,
    /// making it one condition, *three* consumers"** (13 ▸ Interaction with the trash, ruled
    /// 2026-08-06). The container-whole `BoardWriter.purgeCommentTrash(inCard:)` retired from
    /// production with that ruling; what this and `purgeCommentTrash(inCard:)` share is the filter
    /// (`unownedTrashedComments`) and the removal (`purgeTrashedComments`), so the two can differ only
    /// in bracket and trigger.
    ///
    /// The per-entry primitive is the same one an undone create removes its folder with, over the
    /// entries the listing already narrowed to identity shape; a hand-editor's stray in there keeps
    /// the verbatim posture either way.
    ///
    /// The rest is the same six steps every scheduled heal gets, through the same engine: **rest**
    /// when there is nothing unowned (which is every open on a board that closed cleanly, and costs no
    /// bracket at all), defer under a read-only lock, compare the signature, arm before attempting,
    /// one bracket, clear on success. The signature is computed from **the entries actually purged**,
    /// so the picture the memo remembers is the work that was attempted rather than everything the
    /// trash happened to hold.
    ///
    /// **Silent** — `HealNotice.none`. `comments/.trash/` is "never a UI surface", and the residue is
    /// the app's own leftovers from a session that died; there is nothing here a user could act on.
    ///
    /// The memo is board-wide and keyed by class, so two cards' residue swept in turn re-arm each
    /// other's picture. That is harmless rather than tolerated: the picture *is* the work, the write
    /// half re-verifies against disk, and a sweep with nothing to remove is a no-op.
    public func sweepCommentTrashResidue(inCard id: ItemID) {
        guard let card = commentSubject(id) else { return }
        let folder = card.folder
        let residue = unownedTrashedComments(inCard: id, at: folder)
        heals.run(
            .commentTrashResidue,
            signature: Set(residue.map { "comment-trash:\(card.path)/\($0.rawValue)" }),
            on: self
        ) { () throws(BoardWriteError) -> Void in
            try Self.purgeTrashedComments(residue, inCard: folder)
        }
    }

    // MARK: The one condition the three consumers read

    /// **The entries in one card's `comments/.trash/` no live step is holding** — the ownership gate,
    /// as a list (13-native-undo.md ▸ Interaction with the trash, ruled 2026-07-31 and widened
    /// 2026-08-06 to "every purge").
    ///
    /// **The board's stack, never a window's**: a window's own fine comment steps die with the window
    /// that owns them, and what can outlive a close is the coarse step the close folded the session
    /// into, which is here. That is also exactly why an *open* window is given ownership of its card's
    /// whole trash by `purgeCommentTrash(inCard:)` rather than by anything in this list — a live
    /// session's holds are not merely missing from `backedContent`, they are invisible to it.
    ///
    /// **A step a stale skip popped still counts as an owner** (the skip-purge decoupling, ruled
    /// 2026-07-31). Nothing here says so — that is `backedContent`'s answer, and keeping it there is
    /// what makes the pair one condition read three times rather than three conditions kept in step by
    /// hand.
    private func unownedTrashedComments(inCard id: ItemID, at folder: URL) -> [ItemID] {
        let backed = history?.backedContent ?? []
        return CommentThread.trashedCommentIDs(inCard: folder).filter { commentID in
            !backed.contains(.trashedComment(commentID, inCard: id))
        }
    }

    /// Removes the named entries from one card's `comments/.trash/`, in order — the removal half the
    /// sweep and the purge share, so "per entry, never the container" has one spelling.
    ///
    /// A failure stops the batch and throws, and everything already removed stays removed:
    /// `purgeItem(at:)`'s posture, unchanged, and the reason the callers can each keep their own
    /// bracket around it.
    private static func purgeTrashedComments(_ ids: [ItemID], inCard folder: URL) throws(BoardWriteError) {
        for commentID in ids {
            try BoardWriter.purgeItem(at: CommentThread.trashedCommentFolder(commentID, inCard: folder))
        }
    }

    // MARK: The authoring surfaces' attachments

    /// **Imports files into the draft's or one comment's `attachments/`** — the composer's and the
    /// inline editor's drop carve-out and paperclip (05-card-window.md ▸ The comments column).
    ///
    /// One bracket, **no step**: an attachment import registers nothing at card level either
    /// (`importAttachments`), and 13-native-undo.md's inventory does not grow because a file landed
    /// one folder deeper.
    ///
    /// A vanished card, or a target folder that is not an authoring surface, writes nothing — the
    /// Writer's own guard, reached through the ordinary bracket so a failure banners like any other.
    public func importCommentAttachments(_ urls: [URL], inCard id: ItemID, target: CommentTarget) {
        guard !urls.isEmpty, let card = commentSubject(id) else { return }
        let folder = target.folder(inCard: card.folder)
        try? performWrite { () throws(BoardWriteError) -> Void in
            _ = try BoardWriter.importCommentAttachments(urls, intoComment: folder)
        }
    }

    /// **Moves one authoring chip's file to the system Trash** — never a hard delete, the sidebar
    /// row's rule one level down (05-card-window.md ▸ The comments column).
    ///
    /// A name that is no longer there is a silent no-op rather than a failure: the reload is the
    /// authority on what a folder holds (`BoardWriter.trashAttachment`).
    public func removeCommentAttachment(named name: String, inCard id: ItemID, target: CommentTarget) {
        guard !name.isEmpty, let card = commentSubject(id) else { return }
        let folder = target.folder(inCard: card.folder)
        try? performWrite { () throws(BoardWriteError) -> Void in
            _ = try BoardWriter.removeCommentAttachment(named: name, fromComment: folder)
        }
    }

    // MARK: The thread's claimed names

    /// Displaces the claimed names one thread read found squatted — `comments/.draft`,
    /// `comments/.trash`, and a comment's own `attachments` (01-storage-format.md § Fractal layout
    /// ▸ Rules, the level-uniform displacement, applied at the two levels the thread adds).
    ///
    /// **One bracket over the batch, and no memo**, which is the one place this heal differs from its
    /// board-level twin (`displaceClaimedNames`) — deliberately, and worth stating:
    /// `HealScheduler`'s memo is keyed by defect *class*, and these squatters are the same class as
    /// the board walk's. Sharing the key would have one card's thread picture overwrite the board's
    /// and back again on every reload, so the two would spend their memos fighting instead of guarding.
    /// What replaces the memo here is the trigger: a thread is re-read when its window opens or its
    /// files change, not on a timer, so there is no hot loop for a memo to break.
    ///
    /// - Parameter squatters: the defects a `CommentThread` read reported (its `defects`, filtered).
    /// - Returns: what was actually moved aside, for the caller's warning-tone notice — the Writer
    ///   re-verifies each against disk and answers `nil` for one that freed itself.
    @discardableResult
    public func displaceCommentClaimedNames(_ squatters: [ClaimedNameSquatter]) -> [BannerCenter.Displacement] {
        guard !squatters.isEmpty else { return [] }
        let root = rootURL
        var displaced: [BannerCenter.Displacement] = []
        try? performWrite { () throws(BoardWriteError) -> Void in
            for squatter in squatters {
                guard let freed = try BoardWriter.displaceClaimedName(squatter, atBoardRoot: root) else {
                    continue
                }
                displaced.append(BannerCenter.Displacement(name: squatter.name, movedTo: freed))
            }
        }
        return displaced
    }

    // MARK: - Resolving the card

    /// Where a card's thread lives and what to call the gesture — the three facts every comment write
    /// needs, resolved off the snapshot the way every other card-scoped call resolves them
    /// (`boardItem`): `nil` for an id that names no live card, which is the vanished-target guard.
    ///
    /// The path is rebuilt from `rootURL` rather than remembered, so a board renamed mid-session
    /// writes at its new location.
    private func commentSubject(_ id: ItemID) -> (folder: URL, path: String, title: String?)? {
        guard let item = Self.boardItem(id, in: snapshot), let cardID = item.cardID else { return nil }
        return (
            folder: ItemPath.card(lane: item.laneID, id: cardID).folder(under: rootURL),
            path: "\(item.laneID.rawValue)/\(cardID.rawValue)",
            title: item.title
        )
    }
}
