import Foundation

/// Turns a mutation into a filesystem operation — the single point through which every write
/// the app makes reaches disk (02-architecture.md § Layering ▸ Components). Stateless by
/// construction: there is no in-flight buffer, no queue, no coalescing. **A write is done when
/// the rename completes**, and a failed write is a failure the caller sees, so views — which
/// render only what is on disk — can never show phantom state (02-architecture.md §
/// Write-failure surfacing).
///
/// Four rules from 01-storage-format.md live here and are not negotiable per call site:
///
/// - **Atomic writes** (§ Fractal layout ▸ Rules): temp file, rename over `index.md`. Every
///   write, no exceptions — a reader (this app's watcher, an agent, git) never sees a partial
///   file, and a crash mid-write leaves the previous content intact.
/// - **Round-trip, never re-serialize**: mutations go through `FrontmatterDocument`, which
///   edits by line span, so unknown keys and their order, comments, blank lines, line endings,
///   and the body survive every write by construction rather than by remembering to preserve
///   them.
/// - **`modified` stamped, `modified-by` cleared** (§ Frontmatter): on every app-mediated write
///   path. Absence of `modified-by` means "the board's user, via the app"; the file is being
///   rewritten anyway, so clearing an external writer's self-reported stamp costs nothing.
/// - **Encoding** (§ Fractal layout ▸ Rules): writes are BOM-less UTF-8; reads are strict
///   UTF-8, and a file that does not decode is a loud, specific error rather than a
///   lossy best guess.
public enum BoardWriter: Sendable {

    // MARK: - The uniform per-file mutation

    /// Rewrites one item's `index.md`: read fresh, refuse what cannot be edited, apply `edits`,
    /// stamp, write atomically.
    ///
    /// The order of the four steps is the contract:
    ///
    /// 1. **Read fresh from disk**, never from a snapshot. The snapshot a caller is holding may
    ///    be seconds stale — an agent or a hand-editor may have rewritten the file since — and
    ///    the round-trip guarantee is only worth anything against the bytes actually there.
    /// 2. **Refuse an uneditable shape before `edits` runs** (`FrontmatterDocument.uneditableShape`):
    ///    the settled readable-but-uneditable rule. Such a file loads and renders fine, but a
    ///    surgical edit of it cannot be expressed, so the write fails loudly instead of
    ///    corrupting it. Refusing up front also means `edits` never observes a document it
    ///    cannot affect.
    /// 3. **`edits`, then the stamps** — `modified` set and `modified-by` removed *after* the
    ///    caller's closure, so the stamp always wins over anything the closure did with those
    ///    two keys, and no call site has to remember them.
    /// 4. **Atomic replace.**
    ///
    /// The **one path that deliberately bypasses this** is the card window's raw-source Apply
    /// (05-card-window.md): it writes the user's text byte-for-byte and does *not* clear a
    /// `modified-by` the user typed or kept — the validated-then-verbatim contract outranks the
    /// clearing rule (01-storage-format.md § Frontmatter). That path goes through
    /// `atomicReplace` directly; it does not belong here.
    public static func updateIndex(
        inItemFolder folder: URL,
        operation: WriteOperation,
        edits: (inout FrontmatterDocument) -> Void
    ) throws(BoardWriteError) {
        let indexURL = folder.appendingPathComponent(BoardLoader.indexFileName)
        var document = try readDocument(at: indexURL, operation: operation)
        // The read just above is *the* title-enrichment point for every case that funnels
        // through here (renumber, delete, restore, style, and the tail of move/copy): shadow
        // the parameter so every failure from here on — the uneditable-shape refusal, the
        // atomic replace — names the item.
        let operation = operation.withTitle(document.title.value)
        try checkEditable(document, at: indexURL, operation: operation)

        edits(&document)
        document.set(FrontmatterKeys.modified, to: .date(Date()))
        document.remove(FrontmatterKeys.modifiedBy)

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

    // MARK: - Atomic replace

    /// Writes `text` over `fileURL` atomically: a hidden temp file in the **same directory**,
    /// then a rename over the destination.
    ///
    /// Same directory because a rename is only atomic within one filesystem — a temp in
    /// `NSTemporaryDirectory()` could land on another volume and degrade to a copy. Dot-prefixed
    /// (`.index.md.lanework-<uuid>`) because `BoardLoader.directoryCandidates` skips hidden
    /// entries: residue from a crashed write is invisible to a load rather than a stray warning
    /// or, worse, a candidate. The UUID keeps concurrent writers off each other's temp file.
    ///
    /// `Data(text.utf8)` is BOM-less UTF-8 by construction — the encoding contract, with no
    /// encoder to configure and no failure case to handle. On any failure the temp file is
    /// removed best-effort and `.io` is thrown: the destination is either the old bytes or the
    /// new ones, never a mix, and never a directory littered with half-written files.
    static func atomicReplace(text: String, at fileURL: URL, operation: WriteOperation) throws(BoardWriteError) {
        let directory = fileURL.deletingLastPathComponent()
        let tempURL = directory.appendingPathComponent(".\(fileURL.lastPathComponent).lanework-\(UUID().uuidString)")

        do {
            try Data(text.utf8).write(to: tempURL)
        } catch {
            try? FileManager.default.removeItem(at: tempURL)
            throw BoardWriteError(
                operation: operation,
                path: fileURL.path,
                reason: .io(message: "could not write temporary file: \(error.localizedDescription)")
            )
        }

        // POSIX `rename` rather than `FileManager.replaceItemAt`: it atomically overwrites an
        // existing destination *and* handles one that does not exist yet (the create paths),
        // without inventing a second temp file of its own.
        let status = tempURL.withUnsafeFileSystemRepresentation { source in
            fileURL.withUnsafeFileSystemRepresentation { destination in
                guard let source, let destination else { return EINVAL }
                return rename(source, destination) == 0 ? 0 : errno
            }
        }
        guard status == 0 else {
            try? FileManager.default.removeItem(at: tempURL)
            throw BoardWriteError(
                operation: operation,
                path: fileURL.path,
                reason: .io(message: "could not replace file: \(String(cString: strerror(status)))")
            )
        }
    }

    // MARK: - Create

    /// Creates a new board: the folder (if it does not already exist) and its `index.md` — the
    /// write side of `.kanban` packaging (01-storage-format.md § Document packaging). Two
    /// callers: template instantiation, which passes the user-chosen document name as `title`
    /// so the window title and the Finder name start out matching (§ Board naming), and a bare
    /// "New Board" flow, which passes `nil` and lets the UI fall back to the folder name.
    ///
    /// - `rootURL` is created if missing (`withIntermediateDirectories: true`); an existing
    ///   *empty* folder is simply filled in, since that is exactly what an interrupted create or
    ///   a hand-made folder looks like.
    /// - **Refuses to clobber an existing board**: if `index.md` is already there, the call
    ///   fails `.io` naming the path and the existing file is never touched — a create must
    ///   never overwrite a board that already exists.
    /// - `title == nil` writes no `title` key at all — the folder-name fallback (§ Board
    ///   naming) is a *missing* key, not an empty string, which would be a real (if blank) title.
    /// - Key order: `schema`, `title` (only when supplied), `created`, `modified`. No `order` —
    ///   that field belongs to lanes and cards, never the board root.
    /// - Extension-less board folders are exactly as legal a target as a `.kanban`-suffixed one
    ///   (§ Document packaging, "Extension-less board folders still open") — this call never
    ///   looks at `rootURL`'s extension.
    public static func createBoard(at rootURL: URL, title: String?) throws(BoardWriteError) {
        let operation = WriteOperation.createBoard
        let indexURL = rootURL.appendingPathComponent(BoardLoader.indexFileName)

        guard !FileManager.default.fileExists(atPath: indexURL.path) else {
            throw BoardWriteError(operation: operation, path: indexURL.path, reason: .io(message: "a board already exists here"))
        }

        do {
            try FileManager.default.createDirectory(at: rootURL, withIntermediateDirectories: true)
        } catch {
            throw BoardWriteError(
                operation: operation,
                path: rootURL.path,
                reason: .io(message: "could not create board folder: \(error.localizedDescription)")
            )
        }

        try atomicReplace(text: newDocumentText(title: title, order: nil), at: indexURL, operation: operation)
    }

    /// Creates a lane in a board: mints a fresh lowercase-UUIDv4 folder directly under
    /// `rootURL`, appends it after the board's current visible lanes, and writes its
    /// `index.md`. Returns the new identity. See `createChild(inParent:title:operation:)` for
    /// the shared mechanics.
    public static func createLane(inBoard rootURL: URL, title: String?) throws(BoardWriteError) -> ItemID {
        try createChild(inParent: rootURL, title: title, operation: .createLane)
    }

    /// Creates a card in a lane: mints a fresh lowercase-UUIDv4 folder directly under
    /// `laneURL`, appends it after the lane's current visible cards, and writes its `index.md`.
    /// Returns the new identity. See `createChild(inParent:title:operation:)` for the shared
    /// mechanics.
    public static func createCard(inLane laneURL: URL, title: String?) throws(BoardWriteError) -> ItemID {
        try createChild(inParent: laneURL, title: title, operation: .createCard)
    }

    /// The shared body of `createLane`/`createCard` — a lane under a board and a card under a
    /// lane are the same operation one level apart (01-storage-format.md § Fractal layout,
    /// "Level is position"): check the parent, place the new identity after the current visible
    /// siblings, mint it, write its file.
    ///
    /// - **The parent must already exist as a directory.** Anything else — missing, or a file
    ///   where a folder belongs — is a loud `.unreadable` naming `parentFolder` up front, never
    ///   a silent `mkdir` into a broken location.
    /// - **Order is `max + 1024` among visible siblings** (`Ranks.append(toVisible:)`), read by
    ///   `visibleSiblings(of:operation:)` — the identical strict scan `renumberVisibleChildren`
    ///   uses, so the two can never disagree about who counts as a sibling. An empty parent's
    ///   first child lands at `1024`, the board convention (01-storage-format.md § Ordering).
    /// - **Two-step creation is inherent, not a shortcut taken here**: the folder is created,
    ///   then `index.md` is written, as two separate filesystem operations — there is no atomic
    ///   "create a directory with content already in it" primitive to reach for. A crash between
    ///   the two leaves a UUID-shaped folder with no `index.md`, exactly the shape
    ///   `BoardLoader`'s `.missingIndex` skip-and-warn rule (and `visibleSiblings`'s own
    ///   `fileExists` check) already tolerate — see that type's doc comment.
    private static func createChild(
        inParent parentFolder: URL,
        title: String?,
        operation: WriteOperation
    ) throws(BoardWriteError) -> ItemID {
        try checkIsDirectory(parentFolder, describedAs: "parent folder", operation: operation)

        let siblings = try visibleSiblings(of: parentFolder, operation: operation, requireEditable: false)
        let order = Ranks.append(toVisible: siblings.map(\.order))

        let folder = try mintUUIDFolder(in: parentFolder, operation: operation)
        let indexURL = folder.appendingPathComponent(BoardLoader.indexFileName)
        try atomicReplace(text: newDocumentText(title: title, order: order), at: indexURL, operation: operation)

        return ItemID(rawValue: folder.lastPathComponent)
    }

    /// The frontmatter text for a file the app is minting outright — never a rewrite, so this
    /// goes through `FrontmatterDocument(body:)` and `set`, not `updateIndex`: there is no prior
    /// document to read fresh, refuse an uneditable shape from, or stamp over. `created` and
    /// `modified` share one `Date()` so the two stamps are identical, not merely close;
    /// `modified-by` is never written, matching the engine's absence-means-app-authored
    /// convention (§ Frontmatter). Key order — `schema`, `title` (only when supplied), `order`
    /// (only when supplied — `nil` for a board, always present for a lane/card), `created`,
    /// `modified` — is simply the order `set` is called in, since each call appends a fresh key
    /// before the closing delimiter of an otherwise-empty document.
    private static func newDocumentText(title: String?, order: Double?) -> String {
        var document = FrontmatterDocument(body: "")
        document.set(FrontmatterKeys.schema, to: .int(BoardLoader.supportedSchema))
        if let title {
            document.set(FrontmatterKeys.title, to: .string(title))
        }
        if let order {
            document.set(FrontmatterKeys.order, to: .double(order))
        }
        let now = Date()
        document.set(FrontmatterKeys.created, to: .date(now))
        document.set(FrontmatterKeys.modified, to: .date(now))
        return document.serialized()
    }

    /// A fresh lowercase-UUIDv4 folder under `parentFolder` — the create path's mint, which
    /// materializes the folder as well as naming it. The naming rule itself lives in
    /// `freshUUIDName(in:avoiding:)`, shared with the move and copy paths so every identity the
    /// app mints is minted one way.
    private static func mintUUIDFolder(in parentFolder: URL, operation: WriteOperation) throws(BoardWriteError) -> URL {
        let candidate = parentFolder.appendingPathComponent(
            freshUUIDName(in: parentFolder, avoiding: []),
            isDirectory: true
        )
        do {
            try FileManager.default.createDirectory(at: candidate, withIntermediateDirectories: false)
        } catch {
            throw BoardWriteError(
                operation: operation,
                path: candidate.path,
                reason: .io(message: "could not create folder: \(error.localizedDescription)")
            )
        }
        return candidate
    }

    /// A fresh lowercase-UUIDv4 folder *name* for `parentFolder` — the folder-naming convention
    /// itself (01-storage-format.md § Fractal layout ▸ Rules, "Folder names are lowercase
    /// UUIDv4"). `UUID().uuidString` is uppercase; `.lowercased()` is what makes the name match
    /// `BoardLoader.isUUIDShaped`, which is case-sensitive by design.
    ///
    /// Two exclusions, both re-minted rather than assumed away: a name already on disk in
    /// `parentFolder` (so the caller's `createDirectory`/`moveItem`/`copyItem` cannot lose a
    /// race with an existing entry), and any name in `taken` — the identities a collision repair
    /// is minting *away* from, which are not necessarily on disk here. A freshly minted UUID
    /// hitting either is astronomically unlikely — 122 bits of randomness per mint — but the
    /// loop body is trivial precisely because the case it handles essentially never fires.
    private static func freshUUIDName(in parentFolder: URL, avoiding taken: Set<String>) -> String {
        var name: String
        repeat {
            name = UUID().uuidString.lowercased()
        } while taken.contains(name)
            || FileManager.default.fileExists(atPath: parentFolder.appendingPathComponent(name).path)
        return name
    }

    /// The folder-exists check every operation runs before touching the filesystem any further —
    /// shared instead of folded into its callers because "does this folder exist" has nothing to
    /// do with siblings, minting, or moving, and inlining it would bury the one thing a caller
    /// most needs to see at a glance: a missing or wrong-shaped folder is rejected before
    /// anything else happens. `role` names it the way the failing user action would ("parent
    /// folder", "item folder") — the error goes straight into the write-failure banner.
    private static func checkIsDirectory(
        _ url: URL,
        describedAs role: String,
        operation: WriteOperation
    ) throws(BoardWriteError) {
        var isDirectory: ObjCBool = false
        guard FileManager.default.fileExists(atPath: url.path, isDirectory: &isDirectory) else {
            throw BoardWriteError(operation: operation, path: url.path, reason: .unreadable(message: "\(role) does not exist"))
        }
        guard isDirectory.boolValue else {
            throw BoardWriteError(operation: operation, path: url.path, reason: .unreadable(message: "\(role) is not a directory"))
        }
    }

    // MARK: - Renumber

    /// Renumbers a parent's visible children to whole multiples of 1024 — the renumber fallback
    /// for exhausted midpoint precision (01-storage-format.md § Ordering), and **the sole
    /// exception to "a reorder rewrites only the moved item"**. Uniform across levels: the
    /// parent is a lane (renumbering its cards) or the board root (renumbering its lanes).
    ///
    /// - **Runs over loaded, valid children** (`visibleSiblings(of:operation:)`): one that fails
    ///   to parse, or that lacks a usable `order`, fails the whole operation before anything is
    ///   written. A renumber is bookkeeping inside a user action that already succeeded in
    ///   principle — it must not be the thing that discovers a broken sibling halfway through
    ///   rewriting the lane.
    /// - **Display order is the assignment order** (`Ranks.isOrderedForDisplay`: `order`
    ///   ascending, folder name breaking ties) — the same rule the loader sorts by, so a
    ///   renumber is guaranteed to be sequence-preserving: nothing visibly moves.
    ///
    /// Each child's rewrite is atomic; the batch is not. An interrupted renumber leaves some
    /// siblings renumbered and some not — every `order` still a valid float, display order
    /// still deterministic, and the next renumber finishes the job. That is the accepted cost
    /// noted in § Ordering, which the deterministic tie-break exists to make harmless.
    public static func renumberVisibleChildren(of parentFolder: URL) throws(BoardWriteError) {
        let operation = WriteOperation.renumberChildren
        let visible = try visibleSiblings(of: parentFolder, operation: operation, requireEditable: true)

        let ordered = Ranks.sortedForDisplay(visible, order: { $0.order }, name: { $0.folder.lastPathComponent })
        for (child, rank) in zip(ordered, Ranks.renumbered(count: ordered.count)) {
            try updateIndex(inItemFolder: child.folder, operation: operation) { document in
                document.set(FrontmatterKeys.order, to: .double(rank))
            }
        }
    }

    /// The strict visible-sibling scan shared by `renumberVisibleChildren` and the create
    /// operations' order assignment — extracted into one place so the two can never disagree
    /// about who counts as a sibling. Walks `parentFolder`'s UUID-shaped children holding an
    /// `index.md` (`BoardLoader.directoryCandidates`/`isUUIDShaped` — the same level-detection
    /// rule the loader uses), each parsed strictly:
    ///
    /// - **Tombstones are inert to ordering** (§ Deletion): a child whose `deleted` key is
    ///   *present* is not counted — presence, not validity, is the test, exactly as
    ///   `Lane`/`Card.isDeleted` reads it (an explicit `deleted: null` is absence to both).
    /// - **Strays are untouched**: non-UUID-shaped folders, and UUID-shaped folders without an
    ///   `index.md` (an interrupted two-step create — the loader's own `.missingIndex` warning
    ///   tolerates exactly this), are skipped here for the same reasons `BoardLoader` skips them.
    /// - A visible sibling's missing or malformed `order` fails the *whole* operation, naming
    ///   that sibling's file, before anything is written — the same discover-before-you-write
    ///   guarantee `renumberVisibleChildren`'s batch depends on.
    /// - `requireEditable` scopes the readable-but-uneditable pre-flight to the caller that
    ///   will actually *rewrite* the siblings: renumber passes `true` (it must not discover an
    ///   unwritable sibling halfway through the batch), the creates pass `false` — a create
    ///   only *reads* its siblings' orders, and a flow-mapping sibling that loads and renders
    ///   normally (01-storage-format.md § Frontmatter) must not block creating a new item
    ///   beside it.
    private static func visibleSiblings(
        of parentFolder: URL,
        operation: WriteOperation,
        requireEditable: Bool
    ) throws(BoardWriteError) -> [(folder: URL, order: Double)] {
        let candidates: [URL]
        do {
            candidates = try BoardLoader.directoryCandidates(in: parentFolder)
        } catch {
            throw BoardWriteError(
                operation: operation,
                path: parentFolder.path,
                reason: .unreadable(message: error.description)
            )
        }

        var visible: [(folder: URL, order: Double)] = []
        for folder in candidates where BoardLoader.isUUIDShaped(folder.lastPathComponent) {
            let indexURL = folder.appendingPathComponent(BoardLoader.indexFileName)
            guard FileManager.default.fileExists(atPath: indexURL.path) else { continue }

            let document = try readDocument(at: indexURL, operation: operation)
            guard document.deleted.isMissing else { continue }
            if requireEditable {
                try checkEditable(document, at: indexURL, operation: operation)
            }

            switch document.order {
            case .missing:
                throw BoardWriteError(
                    operation: operation,
                    path: indexURL.path,
                    reason: .unreadable(message: "missing required 'order' field")
                )
            case let .malformed(raw):
                throw BoardWriteError(
                    operation: operation,
                    path: indexURL.path,
                    reason: .unreadable(message: "malformed 'order' field: \(raw)")
                )
            case let .valid(order):
                visible.append((folder: folder, order: order))
            }
        }
        return visible
    }

    // MARK: - Move

    /// Moves a lane or card — and the whole tree beneath it — under another parent, in the same
    /// board or across boards. **A move is a physical folder move and the UUID travels
    /// unchanged** (01-storage-format.md § Fractal layout ▸ Rules, "Identity lifecycle: moves
    /// keep the UUID, copies mint fresh ones"): nothing below the moved root is read or
    /// rewritten, so nested cards, `attachments/`, and strays arrive byte-identical by
    /// construction rather than by copying carefully.
    ///
    /// Exactly one file is rewritten — the moved root's `index.md`, and only to carry its new
    /// `order` (§ Ordering, "a reorder rewrites only the moved item's `index.md`"), stamped and
    /// `modified-by`-cleared like every other app write. `order` is the caller's explicit rank
    /// (a drop between two siblings), or `nil` to append after the destination's visible
    /// siblings.
    ///
    /// **The import boundary is the one place within-board uniqueness is enforced** (§ Fractal
    /// layout ▸ Rules). `sourceBoardRoot` and `destinationBoardRoot` are compared by resolved,
    /// standardized path; when they differ, this is an import, and an arriving UUID that already
    /// exists anywhere in the destination board — **tombstones included, because they are on
    /// disk** — is degraded to a copy: a fresh UUID is minted for that folder and nothing else.
    /// Degradation is **per folder, at the finest grain**: a lane arriving with one colliding
    /// card is still a lane *move*, and only that card arrives reminted. A remint is an identity
    /// repair, not an edit — the folder is renamed and its files' bytes are never touched, so
    /// the reminted item's `modified`, `modified-by`, and history are exactly what the source
    /// had. Every repair is reported in `MoveResult.reminted`; a same-board move never remints,
    /// and the same UUID living in two boards is not an error anywhere else — boards are
    /// **independent identity namespaces**, and forks only ever meet here.
    ///
    /// The order of operations is the contract:
    ///
    /// 1. **Validate before anything else**: source and destination parent must be existing
    ///    directories, and the source must be UUID-shaped — this writer moves lanes and cards,
    ///    and a stray is not one (§ Fractal layout ▸ Rules, "Name shape gates level detection").
    /// 2. **Pre-flight the moved root's editability** (`readDocument` + `checkEditable`): the
    ///    move must rewrite that file at the destination, so a file that cannot be round-tripped
    ///    refuses *before* the folder moves — discover-before-you-write, the same guarantee
    ///    `renumberVisibleChildren` depends on. Only the root needs it: a move never rewrites a
    ///    nested file, so an uneditable card inside a moved lane is none of this call's business.
    /// 3. **Compute the destination `order` before the move**, off the destination's visible
    ///    siblings — while the moved item is still elsewhere and so cannot count itself.
    /// 4. **Scan the destination board's identities** (depth 1 and 2, `directoryCandidates` +
    ///    `isUUIDShaped`; strays skipped, tombstones kept) — but only on an import, since a
    ///    same-board move cannot collide with anything but itself.
    /// 5. **Move the folder** (`FileManager.moveItem`, which degrades to copy+remove across
    ///    volumes). A colliding *root* is renamed by moving it straight to its minted name
    ///    rather than moving and then renaming: one filesystem operation instead of two, and it
    ///    is also the only way the repair works when the collision is a sibling sitting in the
    ///    very destination parent — a plain move onto an existing name fails outright.
    /// 6. **Repair the arrived tree's children** — the moved root's direct UUID-shaped children,
    ///    which is every level a compound arrival can carry (a lane's cards; a card has no
    ///    UUID-shaped children).
    /// 7. **Rewrite the moved root's `index.md`** with the rank from step 3.
    ///
    /// **Atomic per filesystem operation, not per gesture** — the accepted edge. A rename that
    /// fails mid-repair, or the `updateIndex` that fails once the folder has already arrived,
    /// leaves the item at the destination with a stale `order` and possibly a partly repaired
    /// tree. Every value on disk is still valid, the failure is surfaced verbatim through the
    /// thrown error (02-architecture.md § Write-failure surfacing), and the caller's reload
    /// shows the true state — nothing is silently retried or rolled back behind the user's back.
    ///
    /// **A move whose destination is the item's current parent degrades to a plain reorder** —
    /// no folder to move, no boundary to cross, just the `order` rewrite (step 7) with the
    /// appended rank computed over the siblings *excluding the item itself*, which unlike every
    /// other move is already there to be miscounted. A drop that lands back in its own lane is
    /// the same gesture as one that lands elsewhere; the writer doing the right degenerate
    /// thing keeps the API total instead of leaking a `FileManager` name collision.
    public static func moveItem(
        at sourceFolder: URL,
        toParent destinationParent: URL,
        sourceBoardRoot: URL,
        destinationBoardRoot: URL,
        order: Double?
    ) throws(BoardWriteError) -> MoveResult {
        let sourceName = sourceFolder.lastPathComponent

        // Which case this is — an ordinary move, or the same-parent degenerate reorder — is
        // decided from the two URLs alone, before anything on disk is even looked at, so the
        // right vocabulary word is in hand for every pre-flight check that follows rather than
        // being retrofitted once the branch below is reached.
        let isReorder = isSameLocation(sourceFolder.deletingLastPathComponent(), destinationParent)
        var operation: WriteOperation = isReorder ? .reorder(title: nil) : .move(title: nil)

        try checkIsDirectory(sourceFolder, describedAs: "item folder", operation: operation)
        try checkIsDirectory(destinationParent, describedAs: "destination parent folder", operation: operation)
        try checkIsUUIDShaped(sourceFolder, operation: operation)
        // The pre-flight's own read is where this move/reorder learns the moved root's title;
        // every failure from here on in this call reuses the enriched value.
        operation = try checkIndexIsRewritable(inItemFolder: sourceFolder, operation: operation)

        if isReorder {
            let rank: Double
            if let order {
                rank = order
            } else {
                let siblings = try visibleSiblings(of: destinationParent, operation: operation, requireEditable: false)
                rank = Ranks.append(toVisible: siblings.filter { $0.folder.lastPathComponent != sourceName }.map(\.order))
            }
            try updateIndex(inItemFolder: sourceFolder, operation: operation) { document in
                document.set(FrontmatterKeys.order, to: .double(rank))
            }
            return MoveResult(id: ItemID(rawValue: sourceName), reminted: [])
        }

        let rank = try destinationOrder(order, inParent: destinationParent, operation: operation)

        let isImport = !isSameLocation(sourceBoardRoot, destinationBoardRoot)
        let existing = isImport ? identities(inBoard: destinationBoardRoot) : []
        var reserved = existing
        var reminted: [MoveResult.Remint] = []

        var arrivedName = sourceName
        if existing.contains(sourceName) {
            arrivedName = freshUUIDName(in: destinationParent, avoiding: reserved)
            reserved.insert(arrivedName)
            reminted.append(MoveResult.Remint(from: ItemID(rawValue: sourceName), to: ItemID(rawValue: arrivedName)))
        }
        let arrivedRoot = destinationParent.appendingPathComponent(arrivedName, isDirectory: true)

        do {
            try FileManager.default.moveItem(at: sourceFolder, to: arrivedRoot)
        } catch {
            throw BoardWriteError(
                operation: operation,
                path: sourceFolder.path,
                reason: .io(message: "could not move folder: \(error.localizedDescription)")
            )
        }

        if isImport {
            let children = childCandidates(of: arrivedRoot)
            reserved.formUnion(children.map(\.lastPathComponent))
            for child in children where existing.contains(child.lastPathComponent) {
                let fresh = freshUUIDName(in: arrivedRoot, avoiding: reserved)
                reserved.insert(fresh)
                try renameFolder(child, toSiblingNamed: fresh, operation: operation)
                reminted.append(MoveResult.Remint(
                    from: ItemID(rawValue: child.lastPathComponent),
                    to: ItemID(rawValue: fresh)
                ))
            }
        }

        try updateIndex(inItemFolder: arrivedRoot, operation: operation) { document in
            document.set(FrontmatterKeys.order, to: .double(rank))
        }

        return MoveResult(id: ItemID(rawValue: arrivedName), reminted: reminted)
    }

    /// Every identity a board already holds: its UUID-shaped lane folders and their UUID-shaped
    /// card folders — the two depths where identity lives (§ Fractal layout, "Level is
    /// position"). **Tombstoned items count**: a tombstone is a live folder on disk carrying a
    /// `deleted:` key, and arriving on top of one would be exactly the duplicate the import
    /// boundary exists to prevent (a Put Back would then resurrect a twin). Strays do not count
    /// — they are not identities at all — and neither does anything deeper: a card has no
    /// UUID-shaped children, and a UUID-shaped folder under `attachments/` is content, not a
    /// level.
    ///
    /// Reads through the loader's own door (`directoryCandidates`), so the writer's idea of what
    /// a board contains can never drift from the loader's. An unreadable folder yields no
    /// candidates rather than failing: the same degradation the loader applies below the root,
    /// and the conservative direction here — a missed identity remints nothing, and a duplicate
    /// UUID in one board is the unspecified-behavior case the design already names, not a
    /// corruption.
    private static func identities(inBoard boardRoot: URL) -> Set<String> {
        var identities: Set<String> = []
        for lane in childCandidates(of: boardRoot) {
            identities.insert(lane.lastPathComponent)
            for card in childCandidates(of: lane) {
                identities.insert(card.lastPathComponent)
            }
        }
        return identities
    }

    /// A folder's UUID-shaped subfolders in deterministic order — `directoryCandidates` (hidden
    /// entries and symlinks already excluded) narrowed by `isUUIDShaped`, which is the loader's
    /// level-detection rule and therefore the only definition of "an identity-bearing child"
    /// this writer is allowed to have.
    private static func childCandidates(of folder: URL) -> [URL] {
        let candidates = (try? BoardLoader.directoryCandidates(in: folder)) ?? []
        return candidates.filter { BoardLoader.isUUIDShaped($0.lastPathComponent) }
    }

    /// Renames a folder in place, keeping its parent — the whole of an identity repair, and of a
    /// copy's remint. The folder's contents, `index.md` included, are never opened.
    private static func renameFolder(
        _ folder: URL,
        toSiblingNamed name: String,
        operation: WriteOperation
    ) throws(BoardWriteError) {
        let destination = folder.deletingLastPathComponent().appendingPathComponent(name, isDirectory: true)
        do {
            try FileManager.default.moveItem(at: folder, to: destination)
        } catch {
            throw BoardWriteError(
                operation: operation,
                path: folder.path,
                reason: .io(message: "could not rename folder: \(error.localizedDescription)")
            )
        }
    }

    /// Whether two URLs name the same place on disk — symlinks resolved, `..`/`.` standardized
    /// away. The import boundary turns on this one comparison, so it is deliberately about
    /// *location*, not spelling: `/tmp/B.kanban` and `/private/tmp/B.kanban/.` are one board,
    /// and treating them as two would remint every arriving item for nothing.
    private static func isSameLocation(_ lhs: URL, _ rhs: URL) -> Bool {
        lhs.resolvingSymlinksInPath().standardizedFileURL.path
            == rhs.resolvingSymlinksInPath().standardizedFileURL.path
    }

    // MARK: - Copy

    /// Copies a lane or card — and the whole tree beneath it — under another parent, minting
    /// **fresh UUIDs for every folder it materializes** (01-storage-format.md § Fractal layout ▸
    /// Rules, "Identity lifecycle: moves keep the UUID, copies mint fresh ones"). The copy is a
    /// new identity at every level: a copied lane's cards are new cards, not the same cards seen
    /// twice. Returns the new root's identity. This is the item-level copy — ⌘C/⌘V, ⌥-drag
    /// duplicate, the cross-board drag default; whole-board copies (Save as Template, File ▸
    /// Duplicate) keep their GUIDs and are not this call.
    ///
    /// Everything travels verbatim: `attachments/` and its subfolders, strays, bodies, unknown
    /// keys, comments, line endings. Only the schema's provenance stamps move, per `stamps`:
    ///
    /// - `.fork` — an ordinary copy **keeps `created`** (it is a fork of something that really
    ///   was created then) while `modified` is stamped and `modified-by` cleared, because a
    ///   paste or a duplicate is an app write (§ Frontmatter).
    /// - `.born` — template instantiation stamps `created` **and** `modified` fresh, from one
    ///   `Date` for the whole tree: a card made from a template is born today, not forked from
    ///   the template (09-templates.md).
    ///
    /// Two deliberate leniencies below the root, both of them "what a hand copy would do":
    ///
    /// - A nested `index.md` that is **readable-but-uneditable** (§ Frontmatter), or that cannot
    ///   be read at all, is copied byte-verbatim and simply not stamped. Refusing an entire copy
    ///   because one nested card is a flow mapping would be hostile, and the file arrives
    ///   *exactly* as it was rather than corrupted — its stale `modified-by` attribution
    ///   surviving is the self-reported-provenance honest limit § Frontmatter already
    ///   acknowledges. The **root** gets no such leniency: it must be rewritten (it needs its
    ///   new `order`), so an unreadable or uneditable root refuses the copy up front, before
    ///   anything is materialized.
    /// - A nested UUID-shaped folder with **no `index.md`** — interrupted-create residue — is
    ///   copied and reminted like any other, and not rewritten: the same skip the loader applies
    ///   to it (`.missingIndex`).
    ///
    /// **All-or-nothing at the destination**, unlike a move: any failure once copying has begun
    /// removes the partially copied tree best-effort and rethrows, because a half-copied item is
    /// pure residue — nothing was there before, so there is no true state for a reload to show.
    /// The source is never touched on any path.
    public static func copyItem(
        at sourceFolder: URL,
        toParent destinationParent: URL,
        order: Double?,
        stamps: CopyStamps
    ) throws(BoardWriteError) -> ItemID {
        var operation: WriteOperation = .copy(title: nil)
        try checkIsDirectory(sourceFolder, describedAs: "item folder", operation: operation)
        try checkIsDirectory(destinationParent, describedAs: "destination parent folder", operation: operation)
        try checkIsUUIDShaped(sourceFolder, operation: operation)
        // The pre-flight's own read is where this copy learns the source root's title; every
        // failure from here on in this call — including inside the materialized-but-not-yet-
        // stamped tree below — reuses the enriched value.
        operation = try checkIndexIsRewritable(inItemFolder: sourceFolder, operation: operation)

        let rank = try destinationOrder(order, inParent: destinationParent, operation: operation)

        let rootName = freshUUIDName(in: destinationParent, avoiding: [])
        let root = destinationParent.appendingPathComponent(rootName, isDirectory: true)
        do {
            try FileManager.default.copyItem(at: sourceFolder, to: root)
        } catch {
            try? FileManager.default.removeItem(at: root)
            throw BoardWriteError(
                operation: operation,
                path: sourceFolder.path,
                reason: .io(message: "could not copy folder: \(error.localizedDescription)")
            )
        }

        do {
            var copied: [URL] = []
            try remintDescendants(of: root, collecting: &copied, operation: operation)

            let now = Date()
            try updateIndex(inItemFolder: root, operation: operation) { document in
                if case .born = stamps {
                    document.set(FrontmatterKeys.created, to: .date(now))
                }
                document.set(FrontmatterKeys.order, to: .double(rank))
            }
            for folder in copied {
                try stampCopiedDescendant(at: folder, stamps: stamps, now: now, operation: operation)
            }
        } catch {
            try? FileManager.default.removeItem(at: root)
            throw error
        }

        return ItemID(rawValue: rootName)
    }

    /// Renames every UUID-shaped folder beneath `folder` to a fresh mint, depth first, and
    /// collects where they ended up — "copies mint fresh UUIDs for **every** folder they
    /// materialize", which for a copied lane means its cards too. Recursion rather than a
    /// two-level walk because the rule is about folders, not levels; non-UUID-shaped folders
    /// (`attachments/` and anything under it, strays) are neither renamed nor descended into,
    /// exactly as the loader treats them.
    ///
    /// Each child is renamed *before* being descended into, so the collected URLs are the final
    /// ones — a caller can stamp them without re-deriving any path. The mint only has to avoid
    /// what is on disk in that folder: the siblings not yet renamed are still there under their
    /// original names, and `freshUUIDName` checks for exactly that.
    private static func remintDescendants(
        of folder: URL,
        collecting copied: inout [URL],
        operation: WriteOperation
    ) throws(BoardWriteError) {
        for child in childCandidates(of: folder) {
            let fresh = freshUUIDName(in: folder, avoiding: [])
            try renameFolder(child, toSiblingNamed: fresh, operation: operation)

            let renamed = folder.appendingPathComponent(fresh, isDirectory: true)
            copied.append(renamed)
            try remintDescendants(of: renamed, collecting: &copied, operation: operation)
        }
    }

    /// Stamps one copied folder below the root — best-effort by design (see `copyItem`): a
    /// missing, unreadable, or uneditable `index.md` is left exactly as the copy found it rather
    /// than failing the gesture. `order` is not touched: a nested item keeps its rank among its
    /// own siblings, which travelled with it.
    private static func stampCopiedDescendant(
        at folder: URL,
        stamps: CopyStamps,
        now: Date,
        operation: WriteOperation
    ) throws(BoardWriteError) {
        let indexURL = folder.appendingPathComponent(BoardLoader.indexFileName)
        guard FileManager.default.fileExists(atPath: indexURL.path),
              let copied = try? readDocument(at: indexURL, operation: operation),
              copied.uneditableShape == nil
        else { return }

        try updateIndex(inItemFolder: folder, operation: operation) { document in
            if case .born = stamps {
                document.set(FrontmatterKeys.created, to: .date(now))
            }
        }
    }

    // MARK: - Tombstone

    /// Tombstones a lane or card in place: writes `deleted: <now>` into its own `index.md` —
    /// the whole of a delete (01-storage-format.md § Deletion). The folder never moves, never
    /// renames, and nothing beneath it is touched: hiding the subtree is the renderer's
    /// ancestor walk, not a stored flag, so deleting a lane rewrites *only* the lane's own
    /// file — its cards' files are exactly as they were.
    ///
    /// **Board-root deletion is structurally unreachable at this layer**: `checkIsUUIDShaped`
    /// — the same guard `moveItem`/`copyItem` lean on — refuses any folder whose name isn't
    /// UUID-shaped, and a board root never is (§ Board naming). A board-level `deleted:` key
    /// is legal-but-meaningless per the frontmatter table (the loader ignores and warns on
    /// it), but this call is simply never able to *produce* one: it has no board-root code
    /// path to fall through, only a refusal.
    ///
    /// Deleting an **already-tombstoned** item is not refused — it just refreshes the
    /// timestamp, a harmless rewrite (the gesture happened again; this layer does not police
    /// liveness, the store's UI does). Goes through `updateIndex`, so the usual contract
    /// applies: fresh read, refuse an uneditable shape, `modified` stamped and `modified-by`
    /// cleared, atomic replace.
    public static func deleteItem(at itemFolder: URL) throws(BoardWriteError) {
        let operation = WriteOperation.delete(title: nil)
        try checkIsDirectory(itemFolder, describedAs: "item folder", operation: operation)
        try checkIsUUIDShaped(itemFolder, operation: operation)

        try updateIndex(inItemFolder: itemFolder, operation: operation) { document in
            document.set(FrontmatterKeys.deleted, to: .date(Date()))
        }
    }

    /// Put Back: removes the `deleted` key, undoing exactly what `deleteItem` wrote.
    /// **Position-perfect by construction** — the folder never moved, so the item simply
    /// re-enters the visible set at its recorded `order` among its current siblings
    /// (01-storage-format.md § Deletion). `FrontmatterDocument.remove` takes *every*
    /// occurrence of the key, so a hand-duplicated `deleted` line cannot resurrect the
    /// tombstone the instant the winning occurrence is gone.
    ///
    /// Restoring an item that **isn't** tombstoned is not refused — it is a harmless stamped
    /// rewrite, the same shrug `deleteItem` gives an already-deleted item: this layer does not
    /// police liveness (a second, independent liveness check here could only drift from the
    /// store UI's own, which is what actually decides whether Put Back is offered at all).
    public static func restoreItem(at itemFolder: URL) throws(BoardWriteError) {
        let operation = WriteOperation.restore(title: nil)
        try checkIsDirectory(itemFolder, describedAs: "item folder", operation: operation)
        try checkIsUUIDShaped(itemFolder, operation: operation)

        try updateIndex(inItemFolder: itemFolder, operation: operation) { document in
            document.remove(FrontmatterKeys.deleted)
        }
    }

    /// Physical removal — Delete Immediately / Empty Trash (03-board-ui.md): deletes the
    /// folder tree from disk. Irreversible, and distinct from tombstoning — this call does
    /// **not** require the item to be tombstoned first, since Delete Immediately skips the
    /// tombstone stage by design.
    ///
    /// **A folder that is already gone is success, not an error** — checked first, before the
    /// shape guard below. A Finder deletion converges on exactly the end state a purge would
    /// produce (01-storage-format.md § Deletion, "a folder that disappears without a
    /// tombstone... is also a delete"), so there is nothing left here to distinguish: a stray
    /// path that never existed and a once-real item someone already threw away in Finder both
    /// purge cleanly, silently, without inspecting what used to be there.
    ///
    /// When the folder *does* exist, `checkIsUUIDShaped` guards the same unreachability
    /// `deleteItem`/`restoreItem` rely on: a board root or a stray never purges through this
    /// call, only a lane or a card.
    public static func purgeItem(at itemFolder: URL) throws(BoardWriteError) {
        let operation = WriteOperation.purge(title: nil)
        guard FileManager.default.fileExists(atPath: itemFolder.path) else { return }

        try checkIsUUIDShaped(itemFolder, operation: operation)
        do {
            try FileManager.default.removeItem(at: itemFolder)
        } catch {
            throw BoardWriteError(
                operation: operation,
                path: itemFolder.path,
                reason: .io(message: "could not remove folder: \(error.localizedDescription)")
            )
        }
    }

    // MARK: - Attachments

    /// The one folder this app ever creates under a card — every other subfolder under
    /// `attachments/` is tolerated but never made or named by the app (01-storage-format.md §
    /// Attachments). Internal rather than `private`: `importAttachments` and `listAttachments`
    /// must never disagree about which folder holds a card's files.
    static let attachmentsFolderName = "attachments"

    /// Imports files into a card's `attachments/` folder, creating it on first import — the
    /// write side of 01-storage-format.md § Attachments. **Never refuses the drop**: a name
    /// already taken is auto-renamed Finder-style (`shot.png` → `shot 2.png` → `shot 3.png`, …)
    /// rather than overwritten or bounced.
    ///
    /// A multi-file drop imports **in order, one finished copy at a time** — not a transaction
    /// across the batch: the first failure stops it and throws naming the failing source file,
    /// but every file already imported stays landed, because each import already completed as
    /// its own write before the next one starts. Returns the landed names, in input order, for
    /// exactly the files that made it in.
    ///
    /// The order of checks is the contract:
    ///
    /// 1. **`cardFolder` must be an existing, UUID-shaped directory** — attachments belong to
    ///    cards, the same shape guard `deleteItem`/`restoreItem`/`purgeItem` lean on
    ///    (`checkIsUUIDShaped`): a lane or a board root is refused before anything else happens.
    /// 2. **`attachments/` is created if missing** (`.io` naming `cardFolder` on failure) — the
    ///    one exception to "subfolders are never created by the app" (§ Attachments); every
    ///    other folder under it is the user's or a hand-editor's, left alone.
    /// 3. **Each source is validated before it is touched**: must exist and be a regular file —
    ///    not a directory, which is out of this call's scope entirely — refused `.unreadable`
    ///    naming the source *before* any copy for that file is attempted, so a batch never
    ///    partially copies something it was about to refuse.
    /// 4. **The collision-free name is decided, then copied to directly** — no temp name and
    ///    rename, unlike `atomicReplace`: `FileManager.copyItem` lands the bytes straight under
    ///    the final name. `copyItem` is not atomic, so on any failure the partial destination is
    ///    removed best-effort and `.io` is thrown naming the source file — "no half-copied
    ///    attachment is ever left in `attachments/`" (02-architecture.md § Write-failure
    ///    surfacing) is a promise about the *failure path*, not a stronger atomicity claim
    ///    `copyItem` cannot make. A crash mid-copy (process death, not a caught `Error`) can
    ///    still leave a partial file on disk — the accepted limit of a non-atomic copy, the same
    ///    one an ordinary Finder copy has.
    public static func importAttachments(
        _ sourceURLs: [URL],
        intoCard cardFolder: URL
    ) throws(BoardWriteError) -> [ImportedAttachment] {
        // Before the per-file loop starts, no single source is implicated yet — the first
        // source's own (original, pre-collision-rename) name stands in for the batch; an empty
        // `sourceURLs` (nothing was actually dropped) falls back to the empty string rather than
        // crashing on `first!`.
        let batchOperation = WriteOperation.importAttachment(filename: sourceURLs.first?.lastPathComponent ?? "")
        try checkIsDirectory(cardFolder, describedAs: "card folder", operation: batchOperation)
        try checkIsUUIDShaped(cardFolder, operation: batchOperation)

        let attachmentsFolder = cardFolder.appendingPathComponent(attachmentsFolderName, isDirectory: true)
        do {
            try FileManager.default.createDirectory(at: attachmentsFolder, withIntermediateDirectories: true)
        } catch {
            throw BoardWriteError(
                operation: batchOperation,
                path: cardFolder.path,
                reason: .io(message: "could not create attachments folder: \(error.localizedDescription)")
            )
        }

        var landed: [ImportedAttachment] = []
        for sourceURL in sourceURLs {
            // Each source names itself — the ORIGINAL filename, not the Finder-style renamed one
            // decided a few lines down, because the operation describes what the user dropped.
            let operation = WriteOperation.importAttachment(filename: sourceURL.lastPathComponent)
            var isDirectory: ObjCBool = false
            guard FileManager.default.fileExists(atPath: sourceURL.path, isDirectory: &isDirectory) else {
                throw BoardWriteError(
                    operation: operation,
                    path: sourceURL.path,
                    reason: .unreadable(message: "file does not exist")
                )
            }
            guard !isDirectory.boolValue else {
                throw BoardWriteError(
                    operation: operation,
                    path: sourceURL.path,
                    reason: .unreadable(message: "is a folder, not a file — importing a folder is not supported")
                )
            }

            let name = freshAttachmentName(for: sourceURL.lastPathComponent, in: attachmentsFolder)
            let destinationURL = attachmentsFolder.appendingPathComponent(name)
            do {
                try FileManager.default.copyItem(at: sourceURL, to: destinationURL)
            } catch {
                try? FileManager.default.removeItem(at: destinationURL)
                throw BoardWriteError(
                    operation: operation,
                    path: sourceURL.path,
                    reason: .io(message: "could not copy file: \(error.localizedDescription)")
                )
            }
            landed.append(ImportedAttachment(sourceURL: sourceURL, fileName: name))
        }
        return landed
    }

    /// The Finder-style collision-free name for `originalName` landing in `folder`: the name
    /// itself when nothing on disk claims it yet, else the base name suffixed `" 2"`, `" 3"`, …
    /// — counting up from 2 against what is on disk *at decision time*, one collision at a time.
    ///
    /// Splits `originalName` the way `URL` itself does (`deletingPathExtension`/
    /// `pathExtension`), which is also exactly how Finder does it: an extension-less name
    /// suffixes directly (`"notes"` → `"notes 2"`), and a multi-dot name splits after the
    /// *last* dot (`"archive.tar.gz"` → `"archive.tar 2.gz"`, not `"archive 2.tar.gz"`) — both
    /// accepted as what Finder itself produces, not worked around.
    ///
    /// `fileExists` is the one test, and it is true for a directory as much as a file — a
    /// same-named *subfolder* blocks the name exactly like a file would, so an import never
    /// overwrites, renames, or descends into one; it just renames the incoming file instead.
    private static func freshAttachmentName(for originalName: String, in folder: URL) -> String {
        guard FileManager.default.fileExists(atPath: folder.appendingPathComponent(originalName).path) else {
            return originalName
        }

        let nameURL = URL(fileURLWithPath: originalName)
        let base = nameURL.deletingPathExtension().lastPathComponent
        let ext = nameURL.pathExtension

        var counter = 2
        while true {
            let candidate = ext.isEmpty ? "\(base) \(counter)" : "\(base) \(counter).\(ext)"
            guard FileManager.default.fileExists(atPath: folder.appendingPathComponent(candidate).path) else {
                return candidate
            }
            counter += 1
        }
    }

    /// The card's flat attachment listing (01-storage-format.md § Attachments, "the app's
    /// attachment surfaces … are flat: top-level files only"): the top-level *files* of
    /// `attachments/`, subfolders and hidden files excluded, symlinks excluded, sorted
    /// `localizedStandardCompare` — Finder order, so `"shot 2.png"` sorts before `"shot
    /// 10.png"` rather than after it.
    ///
    /// A **missing** `attachments/` lists as `[]`, not an error: nothing has been imported yet
    /// is an ordinary state, not a malformed one. `cardFolder` itself missing, or not a
    /// directory, *is* `.unreadable` — that is a caller asking about a card that isn't there,
    /// not an empty listing. Purely a read: nothing here ever creates `attachments/` or
    /// disturbs anything inside it, subfolders included.
    public static func listAttachments(ofCard cardFolder: URL) throws(BoardWriteError) -> [String] {
        let operation = WriteOperation.listAttachments
        try checkIsDirectory(cardFolder, describedAs: "card folder", operation: operation)

        let attachmentsFolder = cardFolder.appendingPathComponent(attachmentsFolderName, isDirectory: true)
        guard let entries = try? FileManager.default.contentsOfDirectory(
            at: attachmentsFolder,
            includingPropertiesForKeys: [.isRegularFileKey, .isSymbolicLinkKey],
            options: [.skipsHiddenFiles]
        ) else {
            return []
        }

        let files = entries.filter { url in
            guard let values = try? url.resourceValues(forKeys: [.isRegularFileKey, .isSymbolicLinkKey]) else {
                return false
            }
            return values.isRegularFile == true && values.isSymbolicLink != true
        }
        return files.map(\.lastPathComponent).sorted { $0.localizedStandardCompare($1) == .orderedAscending }
    }

    // MARK: - Move/copy pre-flight

    /// The rank a moved or copied root lands on: the caller's explicit value — a drop between
    /// two siblings, whose midpoint only the caller knows — or `max + 1024` over the
    /// destination's visible siblings when it has none (`Ranks.append(toVisible:)`, tombstones
    /// inert). Computed *before* the item arrives, so it cannot count itself as its own sibling.
    ///
    /// `requireEditable: false` for the same reason the creates pass it: this only *reads* the
    /// siblings' orders. A flow-mapping sibling that loads and renders normally must not block
    /// dropping an item beside it.
    private static func destinationOrder(
        _ order: Double?,
        inParent parentFolder: URL,
        operation: WriteOperation
    ) throws(BoardWriteError) -> Double {
        if let order { return order }
        let siblings = try visibleSiblings(of: parentFolder, operation: operation, requireEditable: false)
        return Ranks.append(toVisible: siblings.map(\.order))
    }

    /// Refuses a folder that is not a lane or a card. Level detection is by name shape
    /// (01-storage-format.md § Fractal layout ▸ Rules), so a stray — `notes/`, an uppercase
    /// UUID, a hand-made folder — is not an item, and moving, copying, deleting, restoring, or
    /// purging one as if it were would invent (or destroy) an identity the loader would
    /// otherwise just ignore. Shared by every operation that must never reach a board root: a
    /// board root's folder name is never UUID-shaped (§ Board naming), so this one check is
    /// what makes board-root deletion/restore/purge structurally unreachable at this layer.
    private static func checkIsUUIDShaped(_ folder: URL, operation: WriteOperation) throws(BoardWriteError) {
        guard BoardLoader.isUUIDShaped(folder.lastPathComponent) else {
            throw BoardWriteError(
                operation: operation,
                path: folder.path,
                reason: .unreadable(message: "folder name is not UUID-shaped: only lanes and cards are valid here")
            )
        }
    }

    /// The discover-before-you-write pre-flight `moveItem` and `copyItem` both run on the root
    /// they are about to relocate: that file *will* be rewritten at the destination (its `order`,
    /// plus the stamps), so one that cannot be read or cannot be edited in place refuses the
    /// whole gesture while nothing has happened yet — rather than after the folder has already
    /// travelled, or with a copy already materialized.
    ///
    /// Returns `operation` enriched with the title this same read just learned
    /// (`WriteOperation.withTitle`) — the caller's *only* read of the moved/copied root before it
    /// travels, so this is the one place `moveItem`/`copyItem` can learn it at all. Returned
    /// rather than discarded so every failure after the pre-flight passes (the `FileManager`
    /// move/copy itself, the post-arrival `updateIndex`) also names the item.
    private static func checkIndexIsRewritable(
        inItemFolder folder: URL,
        operation: WriteOperation
    ) throws(BoardWriteError) -> WriteOperation {
        let indexURL = folder.appendingPathComponent(BoardLoader.indexFileName)
        let document = try readDocument(at: indexURL, operation: operation)
        let operation = operation.withTitle(document.title.value)
        try checkEditable(document, at: indexURL, operation: operation)
        return operation
    }

    // MARK: - Reading

    /// Reads and parses an `index.md` for rewriting. **Strict, byte-faithful UTF-8**:
    /// `String(validating:as:)` rejects malformed sequences outright and — unlike Foundation's
    /// NSString-backed decoders — does not silently swallow a leading BOM, which would turn a
    /// rewrite of a BOM'd file into a whole-file byte change. A file that does not decode, or
    /// whose frontmatter does not parse, is `.unreadable` with the specifics: the app declines
    /// to write a file it cannot round-trip (01-storage-format.md § Fractal layout ▸ Rules).
    private static func readDocument(at url: URL, operation: WriteOperation) throws(BoardWriteError) -> FrontmatterDocument {
        let data: Data
        do {
            data = try Data(contentsOf: url)
        } catch {
            throw BoardWriteError(
                operation: operation,
                path: url.path,
                reason: .unreadable(message: "could not read file: \(error.localizedDescription)")
            )
        }

        guard let text = String(validating: data, as: UTF8.self) else {
            throw BoardWriteError(operation: operation, path: url.path, reason: .unreadable(message: "file is not UTF-8"))
        }

        do {
            return try FrontmatterDocument.parse(text)
        } catch {
            throw BoardWriteError(operation: operation, path: url.path, reason: .unreadable(message: error.description))
        }
    }

    private static func checkEditable(
        _ document: FrontmatterDocument,
        at url: URL,
        operation: WriteOperation
    ) throws(BoardWriteError) {
        if let shape = document.uneditableShape {
            throw BoardWriteError(operation: operation, path: url.path, reason: .uneditableFrontmatter(shape))
        }
    }
}

// MARK: - Move and copy vocabulary

/// What a move did to identity: where the item ended up, plus every remint the import boundary
/// performed on the way in (01-storage-format.md § Fractal layout ▸ Rules, "Identity lifecycle").
///
/// `id` is the source identity unchanged for every ordinary move — same board or not — and the
/// minted one when the arriving root itself collided. `reminted` is empty for every move that
/// crossed no import boundary, and for every import that found no collision; when it is not,
/// each entry is a folder whose UUID the destination board already held, repaired in place.
/// Callers need both: `id` to select or scroll to the moved item afterwards, `reminted` to
/// re-point anything that was holding the old identities (a selection, an open card window).
///
/// Content is not part of this: a remint renames a folder and touches no bytes, so nothing in
/// here implies an edit.
public struct MoveResult: Sendable, Equatable {
    public let id: ItemID
    public let reminted: [Remint]

    public struct Remint: Sendable, Equatable {
        public let from: ItemID
        public let to: ItemID
    }
}

/// How a copy stamps the files it materializes — the one axis on which the two kinds of copy
/// differ (01-storage-format.md § Fractal layout ▸ Rules; § Frontmatter).
public enum CopyStamps: Sendable {
    /// An ordinary copy — paste, duplicate, a cross-board drag: **keeps `created`**, because a
    /// fork really was created when its original was, stamps `modified`, and clears
    /// `modified-by` (a copy is an app write).
    case fork

    /// Template instantiation (09-templates.md): stamps `created` **and** `modified` fresh and
    /// strips `modified-by` — the result is born today, not forked from the template.
    case born
}

// MARK: - Attachment vocabulary

/// One imported attachment: where it came from and the name it landed under. `fileName` is the
/// post-collision-rename name — `sourceURL.lastPathComponent` when nothing on disk claimed it,
/// the Finder-style `"… 2"` (or higher) variant otherwise (`importAttachments`). `sourceURL` is
/// carried through unchanged so a caller can report the batch (which files came from where)
/// without re-deriving it from input order.
public struct ImportedAttachment: Sendable, Equatable {
    public let sourceURL: URL
    public let fileName: String
}

// MARK: - Write operation vocabulary

/// What the Writer was doing when it failed — a closed vocabulary, not a string (settled,
/// 02-architecture.md § Write-failure surfacing). The banner layer switches exhaustively over
/// this to phrase user-facing text, so a new operation here is a compile-time hole there, never
/// a silent default. `title` is the affected item's title where the operation learned it before
/// failing (nil when the failure struck before the title could be read).
public enum WriteOperation: Sendable, Equatable, CustomStringConvertible {
    case createBoard
    case createLane
    case createCard
    case move(title: String?)
    case reorder(title: String?)
    case copy(title: String?)
    case delete(title: String?)     // tombstone
    case restore(title: String?)
    case purge(title: String?)
    case style(title: String?)      // updateIndex on behalf of styling flows (03-board-ui.md)
    case importAttachment(filename: String)
    case listAttachments
    case renumberChildren           // order-maintenance sweep (compaction)

    /// Fills in the title once the Writer has read it off the document the operation is acting
    /// on — identity for the six cases with no title slot at all: `createBoard`/`createLane`/
    /// `createCard` are minting a file, not reading one; `importAttachment`'s "title" is the
    /// filename it already carries; `listAttachments` and `renumberChildren` name no single item.
    /// Called once, right where the operation's `readDocument` succeeds — `updateIndex` itself
    /// (which covers every case that funnels through it: renumber, delete, restore, style, and
    /// the tail end of move/copy) and the move/copy pre-flight, before the folder travels or the
    /// copy materializes. Every failure past that point reuses the enriched value, because the
    /// case is immutable once a caller has it in hand — there is nothing to "forget" later.
    public func withTitle(_ title: String?) -> WriteOperation {
        switch self {
        case .createBoard, .createLane, .createCard, .importAttachment, .listAttachments, .renumberChildren:
            self
        case .move: .move(title: title)
        case .reorder: .reorder(title: title)
        case .copy: .copy(title: title)
        case .delete: .delete(title: title)
        case .restore: .restore(title: title)
        case .purge: .purge(title: title)
        case .style: .style(title: title)
        }
    }

    /// A short imperative phrase — `"move 'Fix login'"`, `"create card"`, `"import attachment
    /// 'photo.png'"` — for logs and diagnostics **only**: `BoardWriteError.description` (test
    /// failures, `po error`, console output), never the banner's text. The banner owns every
    /// word a user sees and switches exhaustively over the case itself to produce it
    /// (02-architecture.md § Write-failure surfacing); this description exists purely so a
    /// developer reading a raw error gets English without the banner layer's help.
    public var description: String {
        switch self {
        case .createBoard: "create board"
        case .createLane: "create lane"
        case .createCard: "create card"
        case let .move(title): Self.phrase("move", title)
        case let .reorder(title): Self.phrase("reorder", title)
        case let .copy(title): Self.phrase("copy", title)
        case let .delete(title): Self.phrase("delete", title)
        case let .restore(title): Self.phrase("restore", title)
        case let .purge(title): Self.phrase("purge", title)
        case let .style(title): Self.phrase("style", title)
        case let .importAttachment(filename): "import attachment '\(filename)'"
        case .listAttachments: "list attachments"
        case .renumberChildren: "renumber children"
        }
    }

    private static func phrase(_ verb: String, _ title: String?) -> String {
        guard let title else { return verb }
        return "\(verb) '\(title)'"
    }
}

// MARK: - Error

/// A write that did not happen, said out loud: which operation, which file, and why —
/// the vocabulary 02-architecture.md § Write-failure surfacing renders in the banner
/// ("Couldn't move 'Fix login' — disk full"). Nothing here is swallowed or retried behind the
/// user's back; a one-shot action fails once and waits for them to act again.
public struct BoardWriteError: Error, Sendable, Equatable, CustomStringConvertible {
    /// What was being attempted when the write failed — the closed `WriteOperation` vocabulary,
    /// not a string (settled, 02-architecture.md § Write-failure surfacing): the banner switches
    /// exhaustively over this case to produce its user-facing phrasing, so this field carries no
    /// English of its own — that lives only in `WriteOperation.description`, for logs.
    public let operation: WriteOperation

    /// The file or folder involved. Absolute at this layer: the writer works in URLs and has no
    /// board root to be relative to (contrast `BoardLoadError.path`, which is root-relative).
    public let path: String

    public let reason: Reason

    public var description: String { "\(operation.description): \(path): \(reason.description)" }

    public enum Reason: Sendable, Equatable, CustomStringConvertible {
        /// The file is missing, is not UTF-8, or its frontmatter does not parse — `message`
        /// carries the specifics. A rewrite the app cannot round-trip is not attempted.
        case unreadable(message: String)

        /// The settled readable-but-uneditable refusal (01-storage-format.md § Frontmatter):
        /// the file loads and renders, but its frontmatter has a shape the surgical editor
        /// cannot address, so writing it would risk corruption. Names the shape.
        case uneditableFrontmatter(FrontmatterDocument.UneditableShape)

        /// Any I/O failure writing the temp file or renaming it into place — disk full,
        /// permissions, volume error. The destination still holds its previous bytes.
        case io(message: String)

        public var description: String {
            switch self {
            case let .unreadable(message):
                "unreadable: \(message)"
            case let .uneditableFrontmatter(shape):
                "frontmatter cannot be edited in place: \(shape.description)"
            case let .io(message):
                message
            }
        }
    }
}
