import Foundation

// MARK: - Outcomes

/// What one draft save produced on disk — the composer's slow cadence has four possible answers and
/// the caller needs to tell them apart (the create is what a first keystroke does, the delete is the
/// emptied-draft rule firing, and `.unchanged` is the ~30 s tick finding nothing to do).
public enum CommentDraftOutcome: Sendable, Equatable {
    /// `comments/.draft/` did not exist and now does, carrying the ordinary comment schema.
    case created
    /// Its body span was replaced and `modified` stamped.
    case updated
    /// The bytes on disk already said this. Nothing was written and no `mtime` moved.
    case unchanged
    /// **The emptied-draft rule**: no text, no attachments, so the folder is gone
    /// (01-storage-format.md § Enhanced schema — "a draft emptied of text with no attachments is
    /// deleted by the app, never litter").
    case deleted
}

/// What a post landed: the fresh identity, and the instant `created` and `modified` were both
/// stamped with. Both halves are what a redo replays (13-native-undo.md — a redo repeats the values
/// its gesture wrote, never re-derives them).
public struct PostedComment: Sendable, Equatable {
    public let id: ItemID
    /// One `Date` for both stamps, so a freshly posted comment reads "not edited" by construction
    /// rather than by rounding (`Comment.isEdited`).
    public let posted: Date
}

// MARK: - The comment writer

/// **The comment thread's write primitives** — `BoardWriter`'s own vocabulary one level down
/// (01-storage-format.md § Enhanced schema, storage specified 2026-07-29; 05-card-window.md ▸ The
/// comments column).
///
/// An extension in its own file for `BoardStoreHistory.swift`'s reason: this is the same type, and
/// the rules it obeys are that type's rules — atomic temp+rename through `atomicReplace`, surgical
/// span edits through `FrontmatterDocument`, `modified` stamped and `modified-by` cleared on every
/// content write, receipts dropped in the EchoLedger by the four disk primitives — but the *subject*
/// is one thing, and a reader looking for what a comment write does should find it in one place.
///
/// ### What is different one level down, and why
///
/// - **The title every operation carries is the card's.** A comment has no `title` key at all, and
///   the path-shaped verb family names the card ("Comment on '⟨card⟩'"), so each entry point takes
///   `cardTitle` and hands it to its `WriteOperation`. `withTitle` is identity for all five.
/// - **`author` survives.** `updateIndex` clears `modified-by` and only that, so the lenient
///   self-reported `author` rides through every rewrite untouched, which is the field table's whole
///   point ("unlike `modified-by` it survives app writes").
/// - **Nothing here has an `order`.** Chronology is the thread's order, so there are no ranks to
///   mint, no ladder to thread, and no renumber to fall back on.
/// - **No byte capture, anywhere** (13-native-undo.md): the delete is a move, its inverse is the
///   move back, and the post's inverse is the rename back. Nothing in this file reads a body in
///   order to hold it.
extension BoardWriter {

    // MARK: - The draft

    /// **Saves the card's single draft** into `comments/.draft/` — create on the first save, a body
    /// span replacement on every one after (05-card-window.md ▸ The comments column: "The composer
    /// edits `comments/.draft/`", written "on composer blur, window close, quit, and a lazy interval
    /// (~30 s)").
    ///
    /// The sequence:
    ///
    /// 1. **It must be a card** (`checkIsCardFolder`) — a thread hangs off a card and nothing else.
    /// 2. **The emptied-draft rule first**, before anything is created: a save with no text and no
    ///    attachments *removes* the folder, and a save with no text against a draft that does not
    ///    exist creates nothing at all. Typing one character and deleting it must not leave a folder
    ///    behind ("never litter").
    /// 3. **Create**, minting `comments/` on the way if the card has never had one: `schema`,
    ///    `author`, `created`, `modified`, `kind: comment`. The two stamps share one `Date`.
    /// 4. **Update**: the body span and the `modified` stamp, with the identical-bytes gate
    ///    `writeBody` has and for its reason — the ~30 s tick fires whether or not anything changed,
    ///    and a no-op save that stamped `modified` would make an untouched draft look edited every
    ///    half minute (and, on a Pro board, commit).
    ///
    /// **"No attachments" is the listing the user sees** (`BoardLoader.attachmentNames`): top-level
    /// regular files in `attachments/`, which is exactly the set the composer renders as chips. A
    /// draft holding only an empty `attachments/` folder, or only a subfolder, has nothing to lose
    /// and is deleted — the chips are the promise, not the directory.
    @discardableResult
    public static func saveCommentDraft(
        inCard cardFolder: URL,
        body: String,
        cardTitle: String?
    ) throws(BoardWriteError) -> CommentDraftOutcome {
        let operation = WriteOperation.saveCommentDraft(title: cardTitle)
        try checkIsDirectory(cardFolder, describedAs: "card folder", operation: operation)
        try checkIsCardFolder(cardFolder, operation: operation)

        let draft = CommentThread.draftFolder(inCard: cardFolder)
        let exists = IntegrityRules.node(at: draft) == .directory

        if body.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
           !exists || BoardLoader.attachmentNames(in: draft).isEmpty {
            guard exists else { return .unchanged }
            do {
                try FileManager.default.removeItem(at: draft)
            } catch {
                throw BoardWriteError(
                    operation: operation,
                    path: draft.path,
                    reason: .io(message: "could not remove the emptied draft: \(error.localizedDescription)")
                )
            }
            EchoLedger.current?.recordDeletion(at: draft)
            return .deleted
        }

        let indexURL = draft.appendingPathComponent(BoardLoader.indexFileName)
        // A folder with no `index.md` takes the create path rather than failing: that is the shape an
        // interrupted create leaves, and two-step-create tolerance is one of the fractal rules that
        // apply here verbatim (01-storage-format.md § Enhanced schema).
        guard exists, FileManager.default.fileExists(atPath: indexURL.path) else {
            if !exists {
                do {
                    try FileManager.default.createDirectory(at: draft, withIntermediateDirectories: true)
                } catch {
                    throw BoardWriteError(
                        operation: operation,
                        path: draft.path,
                        reason: .io(message: "could not create the draft folder: \(error.localizedDescription)")
                    )
                }
            }
            try atomicReplace(text: newCommentText(body: body), at: indexURL, operation: operation)
            return .created
        }

        return try writeCommentBody(at: draft, body: body, operation: operation) ? .updated : .unchanged
    }

    /// **Posts the draft**: `comments/.draft/` renamed to a fresh lowercase UUID, `created` and
    /// `modified` restamped, **in one bracket** (01-storage-format.md § Enhanced schema, ruled
    /// 2026-07-29: "posting renames it to a fresh lowercase UUID and restamps `created`/`modified` in
    /// the same bracket — chronology is post time, not drafting time — one commit").
    ///
    /// **One `Date` for both stamps**, `newDocumentText`'s convention and here it is load-bearing:
    /// the edited indicator is `modified` differing from `created`, so two `Date()` calls straddling
    /// a second boundary would post a comment that renders as already edited.
    ///
    /// **The rename is the identity mint**, so nothing is copied and no bytes move: `attachments/`,
    /// strays and every unknown key arrive exactly as the draft held them. The `author` the draft was
    /// created with rides through the restamp untouched, which is what makes posting a draft written
    /// last week still attributed to whoever wrote it.
    ///
    /// A draft folder with no readable `index.md` is minted one first (the create path's tolerance);
    /// a draft whose frontmatter cannot be round-tripped refuses the post before the rename —
    /// discover-before-you-write, `moveItem`'s rule, and the gesture the user pressed is the thing
    /// that failed rather than some other comment being tolerated.
    public static func postComment(
        inCard cardFolder: URL,
        cardTitle: String?
    ) throws(BoardWriteError) -> PostedComment {
        let operation = WriteOperation.postComment(title: cardTitle)
        try checkIsDirectory(cardFolder, describedAs: "card folder", operation: operation)
        try checkIsCardFolder(cardFolder, operation: operation)

        let thread = CommentThread.folder(inCard: cardFolder)
        let draft = CommentThread.draftFolder(inCard: cardFolder)
        guard IntegrityRules.node(at: draft) == .directory else {
            throw BoardWriteError(
                operation: operation,
                path: draft.path,
                reason: .unreadable(message: "there is no draft to post")
            )
        }

        let draftIndexURL = draft.appendingPathComponent(BoardLoader.indexFileName)
        if !FileManager.default.fileExists(atPath: draftIndexURL.path) {
            try atomicReplace(text: newCommentText(body: ""), at: draftIndexURL, operation: operation)
        }
        _ = try checkIndexIsRewritable(inItemFolder: draft, operation: operation)

        let name = freshUUIDName(in: thread, avoiding: [])
        try renameFolder(draft, toSiblingNamed: name, operation: operation)
        let posted = thread.appendingPathComponent(name, isDirectory: true)

        let now = Date()
        try restampComment(at: posted, to: now, operation: operation)
        return PostedComment(id: ItemID(rawValue: name), posted: now)
    }

    // MARK: - Editing

    /// **An inline comment edit's save** — the body-edit session in miniature (05-card-window.md ▸
    /// The comments column: "debounced saves to the comment's own file keep it crash-safe, Save (or
    /// ⌘↩) ends the session as its commit point").
    ///
    /// `writeBody`'s three properties, one level down: the body span and nothing else changes, the
    /// bytes above the closing delimiter are the bytes they were, and **identical bytes write
    /// nothing** (returning `false` with an untouched `mtime`). `modified` is stamped and
    /// `modified-by` cleared; `author` and `created` are not touched, which is exactly how "· edited"
    /// comes to be true without a field existing for it.
    ///
    /// **Cancel is not here.** The session reverts to its own start-of-session bytes, which is the
    /// UI session's business (phase 2) and not a Writer primitive: there is no capture in this file.
    ///
    /// - Returns: `true` when bytes were written, `false` when the body on disk already matched.
    @discardableResult
    public static func editComment(
        at commentFolder: URL,
        body: String,
        cardTitle: String?
    ) throws(BoardWriteError) -> Bool {
        let operation = WriteOperation.editComment(title: cardTitle)
        try checkIsCommentFolder(commentFolder, operation: operation)
        return try writeCommentBody(at: commentFolder, body: body, operation: operation)
    }

    // MARK: - Delete, restore, purge

    /// **Deleting a comment is a move into `comments/.trash/`** (01-storage-format.md § Enhanced
    /// schema, re-ruled 2026-07-29: "the materialized-trash pattern one level down, joining `.draft`
    /// in the claimed names, excluded from the thread, never a UI surface").
    ///
    /// `deleteCardToTrash`'s body one level down, minus the thing a comment does not have: there is
    /// no rank to mint, because the trash a comment lands in has no order at all — it is undo's
    /// backing store for the life of one window, not a browsable column.
    ///
    /// **The stamp is the container rule's plainest instance, again**: the move changes the comment's
    /// container, so `updateIndex` stamps `modified` and clears `modified-by` without a trash branch
    /// existing anywhere (§ Enhanced schema: "The container-change stamping rule applies — the move
    /// stamps `modified`"). `kind: .comment` is passed rather than derived so the on-touch backfill
    /// cannot mistake a folder inside a `.trash` for a board-trash resident.
    ///
    /// **No confirm, no capture** — undo is the net, and its inverse is `restoreComment`.
    @discardableResult
    public static func deleteComment(
        at commentFolder: URL,
        cardTitle: String?
    ) throws(BoardWriteError) -> ItemID {
        let operation = WriteOperation.deleteComment(title: cardTitle)
        try checkIsCommentFolder(commentFolder, operation: operation)
        // The move rewrites this file at the destination, so a file that cannot be round-tripped
        // refuses before the folder travels (`moveItem`'s discover-before-you-write rule).
        _ = try checkIndexIsRewritable(inItemFolder: commentFolder, operation: operation)

        let trash = commentFolder
            .deletingLastPathComponent()
            .appendingPathComponent(IntegrityRules.commentTrashFolderName, isDirectory: true)
        try moveComment(commentFolder, into: trash, operation: operation)
        return ItemID(rawValue: commentFolder.lastPathComponent)
    }

    /// **The delete's inverse: the move back out** (13-native-undo.md ▸ Interaction with the trash,
    /// the comment clause: "a comment delete is a move into the card's `comments/.trash/` … so its
    /// inverse is the ordinary move back").
    ///
    /// It stamps for the reason the forward move does — the container changed again — and it takes
    /// `.deleteComment`'s own vocabulary word, `.delete`'s "there is no `restore` case" precedent one
    /// level down: a failed restore is a comment that could not be moved, and inventing a sixth
    /// operation to say so would name a gesture the user never pressed (they pressed ⌘Z).
    public static func restoreComment(
        _ id: ItemID,
        inCard cardFolder: URL,
        cardTitle: String?
    ) throws(BoardWriteError) {
        let operation = WriteOperation.deleteComment(title: cardTitle)
        let trashed = CommentThread.trashedCommentFolder(id, inCard: cardFolder)
        try checkIsDirectory(trashed, describedAs: "comment folder", operation: operation)
        try moveComment(trashed, into: CommentThread.folder(inCard: cardFolder), operation: operation)
    }

    /// **Empties one card's `comments/.trash/` whole** — and **no production caller does that any
    /// more** (13-native-undo.md ▸ Interaction with the trash, ruled 2026-08-06):
    ///
    /// > "**Every purge of `comments/.trash/` is per-entry behind the ownership gate** (ruled
    /// > 2026-08-06 — the container-whole retirement purge retires) ... The container-whole purge
    /// > assumed one owning step per card's comment trash, and two sessions over the same card broke
    /// > it."
    ///
    /// Both consumers — the retirement/no-step-close purge and the open-time residue sweep — now
    /// filter by `HistoryProviding.backedContent` and remove per entry through `purgeItem(at:)`
    /// (`BoardStore.purgeCommentTrash(inCard:)`, `BoardStore.sweepCommentTrashResidue(inCard:)`). A
    /// primitive that empties the container cannot express the gate, so nothing above it may call this.
    ///
    /// It is **kept rather than deleted**, deliberately and narrowly: `WriteOperation.purgeCommentTrash`
    /// is the vocabulary's word for this work and owns a bespoke user-facing sentence
    /// (`BannerCenter` — "Couldn't tidy up deleted comments") plus rows in three exhaustive switches,
    /// and retiring the case would be a ripple across files for no behavioural gain. Read this as
    /// legacy: the shape the ruling retired, still exercised by `CommentWriteTests` so the case's
    /// plumbing stays honest, and the home of the operation vocabulary the gated purge's per-entry
    /// failures do not use.
    ///
    /// `emptyTrash`'s rules, one level down and for its reasons:
    ///
    /// - **The entries, not the container.** Only identity-shaped children are removed; a stray a
    ///   hand-editor put in there keeps the verbatim posture, and the emptied folder is left standing
    ///   because the next delete would only recreate it.
    /// - Removal is per entry, in order; a failure stops the batch and throws, and everything already
    ///   removed stays removed.
    /// - A card with no thread trash removes nothing and answers `[]`.
    ///
    /// **It registers no undo step** — the permanent-delete posture (13-native-undo.md).
    @discardableResult
    public static func purgeCommentTrash(inCard cardFolder: URL) throws(BoardWriteError) -> [ItemID] {
        let operation = WriteOperation.purgeCommentTrash
        var purged: [ItemID] = []
        for entry in childCandidates(of: CommentThread.trashFolder(inCard: cardFolder)) {
            do {
                try FileManager.default.removeItem(at: entry)
            } catch {
                throw BoardWriteError(
                    operation: operation,
                    path: entry.path,
                    reason: .io(message: "could not remove folder: \(error.localizedDescription)")
                )
            }
            EchoLedger.current?.recordDeletion(at: entry)
            purged.append(ItemID(rawValue: entry.lastPathComponent))
        }
        return purged
    }

    // MARK: - The post's inverse family

    /// **The post's inverse: the posted folder renamed back to `.draft`** (13-native-undo.md, the
    /// comment clause: "post-undo naturally rides the same rail" — the move-based inverse family,
    /// with no byte capture anywhere in it).
    ///
    /// **A rename and nothing else, so it stamps nothing.** The folder keeps its parent, so no
    /// container changed, and the existing write discipline answers without a rule of its own: "a
    /// heal that only renames or relocates folders and files never opens `index.md` and stamps
    /// nothing" (§ Validation and healing) — an identity change, not an edit. The post-time
    /// `created`/`modified` therefore survive on the un-posted draft, and the next post restamps them
    /// anyway, which is the whole reason the post restamps at all.
    ///
    /// **It refuses a `.draft` that is already there**, rather than clobbering it: two drafts cannot
    /// exist, and one the user has typed since is not this step's to overwrite. The step's own
    /// staleness predicate expects exactly that absence, so in practice the refusal is unreachable —
    /// it stands because the Writer owns the bytes and promises this against every caller.
    public static func unpostComment(
        _ id: ItemID,
        inCard cardFolder: URL,
        cardTitle: String?
    ) throws(BoardWriteError) {
        let operation = WriteOperation.postComment(title: cardTitle)
        let posted = CommentThread.commentFolder(id, inCard: cardFolder)
        try checkIsDirectory(posted, describedAs: "comment folder", operation: operation)

        let draft = CommentThread.draftFolder(inCard: cardFolder)
        guard IntegrityRules.node(at: draft) == nil else {
            throw BoardWriteError(
                operation: operation,
                path: draft.path,
                reason: .io(message: "a draft is already here")
            )
        }
        try renameFolder(posted, toSiblingNamed: IntegrityRules.commentDraftFolderName, operation: operation)
    }

    /// **The post, replayed** — the same captured identity and the same captured instant
    /// (13-native-undo.md: a redo repeats the values its gesture wrote). Re-minting here would produce
    /// a *different* comment, and every step registered above this one on the stack that names the
    /// posted id would then name nothing — `recreateItem`'s reasoning, one level down.
    public static func repostComment(
        as id: ItemID,
        inCard cardFolder: URL,
        stamping instant: Date,
        cardTitle: String?
    ) throws(BoardWriteError) {
        let operation = WriteOperation.postComment(title: cardTitle)
        let draft = CommentThread.draftFolder(inCard: cardFolder)
        try checkIsDirectory(draft, describedAs: "draft folder", operation: operation)

        let posted = CommentThread.commentFolder(id, inCard: cardFolder)
        guard IntegrityRules.node(at: posted) == nil else {
            throw BoardWriteError(
                operation: operation,
                path: posted.path,
                reason: .io(message: "something already exists here")
            )
        }
        try renameFolder(draft, toSiblingNamed: id.rawValue, operation: operation)
        try restampComment(at: posted, to: instant, operation: operation)
    }

    // MARK: - The authoring surfaces' attachments

    /// **Imports files into one comment's (or the draft's) `attachments/`** — the write behind the
    /// composer's and the inline editor's drop carve-out and their paperclip affordance
    /// (05-card-window.md ▸ The comments column, ruled 2026-07-29: "a file dropped within the
    /// composer's bounds imports to the draft's `attachments/` … and the same pair applies within an
    /// inline comment edit session, targeting that comment's").
    ///
    /// **It is `importAttachments` with a different shape guard and nothing else** — the collision
    /// rename, the per-file validation, the partial cleanup and the import receipt are all
    /// `BoardWriter.importFiles`', shared rather than restated, so a file added to a comment behaves
    /// exactly like one added to a card.
    ///
    /// **No stamp, deliberately.** `index.md` is never opened: importing a file says nothing about the
    /// comment's text, and `relocateLooseFiles` already settles that reading one level up ("relocating
    /// a stray says nothing about the card's content, so no `modified` stamp is written"). It also
    /// keeps the draft's own rule honest — a draft that has only ever been dropped on still has the
    /// `created`/`modified` pair the post is about to restamp.
    @discardableResult
    public static func importCommentAttachments(
        _ sourceURLs: [URL],
        intoComment folder: URL
    ) throws(BoardWriteError) -> [ImportedAttachment] {
        let batchOperation = WriteOperation.importAttachment(filename: sourceURLs.first?.lastPathComponent ?? "")
        try checkIsAuthoringFolder(folder, operation: batchOperation)
        return try importFiles(sourceURLs, intoAttachmentsOf: folder, batchOperation: batchOperation)
    }

    /// **Moves one of a comment's (or the draft's) attachments to the system Trash** — the authoring
    /// chip's Remove (05-card-window.md ▸ The comments column: "Chips on an authoring surface carry
    /// remove (to the **system** Trash — the sidebar row's rule); a posted comment's chips are
    /// read-only").
    ///
    /// The *posted*-chip half of that sentence is enforced in the view, not here: the Writer's job is
    /// that the file goes to the Trash rather than being destroyed, and an inline edit session over a
    /// posted comment is an authoring surface too. `BoardWriter.trashAttachment` is the whole body.
    @discardableResult
    public static func removeCommentAttachment(
        named name: String,
        fromComment folder: URL
    ) throws(BoardWriteError) -> URL? {
        let operation = WriteOperation.removeAttachment(filename: name)
        try checkIsAuthoringFolder(folder, operation: operation)
        return try trashAttachment(named: name, fromItemFolder: folder, operation: operation)
    }

    /// Refuses any folder that is not a place a user can author attachments into: a **posted
    /// comment**, or the card's single **draft**.
    ///
    /// `checkIsCommentFolder` widened by exactly one name, and widened here rather than there on
    /// purpose: `editComment` and `deleteComment` must keep refusing `.draft` (the composer owns it,
    /// and posting is its only way into the thread), while an attachment import has no such
    /// asymmetry — the draft is precisely where the composer's chips land. `comments/.trash/` stays
    /// out of both, because a deleted comment is undo's and never a surface.
    private static func checkIsAuthoringFolder(
        _ folder: URL,
        operation: WriteOperation
    ) throws(BoardWriteError) {
        try checkIsDirectory(folder, describedAs: "comment folder", operation: operation)
        let name = folder.lastPathComponent
        guard folder.deletingLastPathComponent().lastPathComponent.lowercased()
                == IntegrityRules.commentsFolderName,
              IntegrityRules.isIdentityShaped(name)
                  || name.lowercased() == IntegrityRules.commentDraftFolderName
        else {
            throw BoardWriteError(
                operation: operation,
                path: folder.path,
                reason: .unreadable(message: "folder is not a comment")
            )
        }
    }

    // MARK: - Shared mechanics

    /// The frontmatter text for a comment the app is minting outright — `newDocumentText`'s twin, and
    /// its key order minus the two fields a comment has not got.
    ///
    /// `schema`, `author`, `created`, `modified`, `kind` — simply the order `set` is called in, with
    /// `kind` last where the common table puts it and where the on-touch backfill would append one.
    ///
    /// **`author` is the macOS account's full name** (01-storage-format.md § Enhanced schema: "the
    /// app writes the macOS account's full name (the identity 06-history-undo.md's derived default
    /// already uses)"). `NSFullUserName()` is that identity; 06's derived commit default is Pro's and
    /// is not coded yet, so this is the first place the app spells it. An empty answer — a stripped
    /// account record — writes **no key at all** rather than `author: ""`: missing renders
    /// unattributed, and an empty string is a real (if blank) author, the `title` rule's precedent.
    private static func newCommentText(body: String) -> String {
        var document = FrontmatterDocument(body: body)
        document.set(FrontmatterKeys.schema, to: .int(BoardLoader.supportedSchema))
        let author = NSFullUserName()
        if !author.isEmpty {
            document.set(FrontmatterKeys.author, to: .string(author))
        }
        let now = Date()
        document.set(FrontmatterKeys.created, to: .date(now))
        document.set(FrontmatterKeys.modified, to: .date(now))
        document.set(FrontmatterKeys.kind, to: .string(IntegrityRules.ObjectKind.comment.rawValue))
        return document.serialized()
    }

    /// Replaces a comment's body span and stamps — `writeBody`'s four steps against a folder that is
    /// not identity-shaped when it is the draft, which is the only reason this is not that call.
    private static func writeCommentBody(
        at folder: URL,
        body: String,
        operation: WriteOperation
    ) throws(BoardWriteError) -> Bool {
        let indexURL = folder.appendingPathComponent(BoardLoader.indexFileName)
        var document = try readDocument(at: indexURL, operation: operation)
        try checkEditable(document, at: indexURL, operation: operation)

        guard document.body != body else { return false }

        document.body = body
        IntegrityRules.healOnTouch(&document, kind: .comment)
        document.set(FrontmatterKeys.modified, to: .date(Date()))
        document.remove(FrontmatterKeys.modifiedBy)

        try atomicReplace(text: document.serialized(), at: indexURL, operation: operation)
        return true
    }

    /// Sets `created` and `modified` to one instant — the post's whole frontmatter edit, spelled
    /// directly rather than through `updateIndex` for one reason: `updateIndex` stamps `modified`
    /// with a `Date()` of its own, and a post whose two stamps came from two clock reads could land
    /// either side of a second boundary and render as edited the moment it appeared.
    ///
    /// Everything else `updateIndex` would have done is here in its order: refuse an uneditable
    /// shape, run the on-touch backfill, clear `modified-by`, replace atomically.
    private static func restampComment(
        at folder: URL,
        to instant: Date,
        operation: WriteOperation
    ) throws(BoardWriteError) {
        let indexURL = folder.appendingPathComponent(BoardLoader.indexFileName)
        var document = try readDocument(at: indexURL, operation: operation)
        try checkEditable(document, at: indexURL, operation: operation)

        IntegrityRules.healOnTouch(&document, kind: .comment)
        document.set(FrontmatterKeys.created, to: .date(instant))
        document.set(FrontmatterKeys.modified, to: .date(instant))
        document.remove(FrontmatterKeys.modifiedBy)

        try atomicReplace(text: document.serialized(), at: indexURL, operation: operation)
    }

    /// The physical half of both container-changing comment moves — into `comments/.trash/` and back
    /// out of it. `moveIntoTrash`'s shape: mint the destination if absent, move, then rewrite the
    /// arrived `index.md` so the container change stamps.
    private static func moveComment(
        _ folder: URL,
        into destination: URL,
        operation: WriteOperation
    ) throws(BoardWriteError) {
        do {
            try FileManager.default.createDirectory(at: destination, withIntermediateDirectories: true)
        } catch {
            throw BoardWriteError(
                operation: operation,
                path: destination.path,
                reason: .io(message: "could not create the folder: \(error.localizedDescription)")
            )
        }

        let arrived = destination.appendingPathComponent(folder.lastPathComponent, isDirectory: true)
        do {
            try FileManager.default.moveItem(at: folder, to: arrived)
        } catch {
            throw BoardWriteError(
                operation: operation,
                path: folder.path,
                reason: .io(message: "could not move the comment: \(error.localizedDescription)")
            )
        }
        // The move pair reads correctly from either end, `moveIntoTrash`'s receipt and for its
        // reason: the thread sees an absence, the trash sees an arrival.
        EchoLedger.current?.recordMove(from: folder, to: arrived)

        try updateIndex(inItemFolder: arrived, kind: .comment, operation: operation) { _ in }
    }

    /// Refuses any folder that is not a **posted comment**: identity-shaped, directly under a card's
    /// `comments/`.
    ///
    /// `checkIsCardFolder`'s mirror one level down, and it deliberately refuses the two dot-named
    /// folders as well as everything else: `.draft` is the composer's, reached through
    /// `saveCommentDraft`, and a folder inside `comments/.trash/` is undo's — neither is editable or
    /// deletable as a comment, and letting either through this door would put a surface with no
    /// window behind it on disk (`readRawSource`'s rule).
    private static func checkIsCommentFolder(
        _ folder: URL,
        operation: WriteOperation
    ) throws(BoardWriteError) {
        try checkIsDirectory(folder, describedAs: "comment folder", operation: operation)
        // The parent is named explicitly rather than asked of `placement`, which answers `.comment`
        // for a folder inside `comments/.trash/` too — correct for the *kind* it stamps, and exactly
        // the case this guard has to keep out.
        guard IntegrityRules.isIdentityShaped(folder.lastPathComponent),
              folder.deletingLastPathComponent().lastPathComponent.lowercased()
                  == IntegrityRules.commentsFolderName
        else {
            throw BoardWriteError(
                operation: operation,
                path: folder.path,
                reason: .unreadable(message: "folder is not a comment")
            )
        }
    }
}
