import Foundation
import os

/// The template engine — where templates live, and what "create a board from one" means on disk
/// (09-templates.md).
///
/// ### The whole engine is the ordinary loader and the ordinary writer
///
/// A template is a board folder, so discovery is `BoardLoader.load` and instantiation is a tree copy
/// plus the Writer's own remint-and-restamp machinery (`BoardWriter.remintDescendants`,
/// `checkCopiedDescendantsAreStampable`, `applyCopyContract`, `stampCopiedDescendant`,
/// `updateIndex`). There is no template schema, no template catalog in
/// Swift, and no second copy path — which is 09's "dogfood" clause and 02-architecture.md's single
/// write door, both held by having nothing here to hold them with.
///
/// ### What instantiation is
///
/// > copy the tree — **skipping `.trash/`** … — **mint fresh GUIDs** for every lane/card folder,
/// > stamp `created`/`modified` fresh …, and **set the new board's `title` to the user-chosen
/// > document name**. The `template:` key is kept — inert on an ordinary board. **`.git` is never
/// > copied** … Beyond the `template:` residue, the result is indistinguishable from a hand-built
/// > board. (09 ▸ Instantiation)
///
/// Five things follow, and each is the ordinary machinery pointed at a board root rather than a
/// rule this file invents:
///
/// - **The two exclusions are top-level only** (`BoardTreeCopy`): `.git` and `.trash/` mean
///   something at a board root and nowhere else. `.git` is skipped so a template's own repository
///   never rides along into every board it stamps out — once phrased as keeping an instantiated
///   board from silently landing in git mode (06-history-undo.md's no-silent-auto-init, retired with
///   app-managed git, `strategy/01-git-excision.md`); there is no mode left to land in today.
///   `.trash/` is skipped because "a new board isn't born with trash".
/// - **Everything else copies verbatim** — strays, `CLAUDE.user.md`, a seeded `.gitignore`,
///   attachments, card bodies, unknown keys, line endings (09 ▸ Save as Template, "Strays copy
///   through … *and* instantiation alike"), and **symlinks as symlinks**, never traversed
///   (01-storage-format.md § Fractal layout ▸ Rules).
/// - **Fresh identity at every level**: `remintDescendants` from the copied root renames every
///   UUID-shaped folder it can reach, so no id survives from the template. Template GUIDs are inert
///   anyway — "instantiation remints at its own boundary" (01 § Fractal layout ▸ Rules).
/// - **Born today**: `CopyStamps.born` on every `index.md` — `created` *and* `modified` stamped from
///   one `Date` for the whole tree, `modified-by` cleared as on any app-mediated write (01
///   § Frontmatter). The stamps are frontmatter-level; **body bytes are never rewritten** (the
///   round-trip guarantee — `updateIndex` edits by line span).
/// - **The `template:` key rides along untouched**, because `updateIndex` preserves unknown keys by
///   construction rather than by remembering to.
///
/// ### Two readings 09 does not spell out, taken here and stated
///
/// - **A legacy `deleted:` key in a hand-dropped template copies through.** 09's exclusions are
///   `.git` and `.trash/` and stop there; the tombstone model is retired, so a stale `deleted:` is
///   just a key in a file that copies verbatim. The migration has exactly one owner — "Legacy
///   `deleted:` keys migrate on load-and-write, never destroy" (01 § Deletion), run by the store on
///   the new board's first load — and stripping the key here would silently resurrect a card the
///   template's author had deleted, while skipping the card would destroy content. Neither is this
///   engine's call to make; copying honestly and letting the one migrator run is.
/// - **Loose files beside a card's `index.md` are normalized on arrival.** 01's carve-out relocates
///   them into `attachments/`, and 04-interactions.md ▸ Clipboard settled that an **import boundary**
///   does it at write time rather than leaving it for the loader ("A paste is an import boundary, so
///   normalization applies"). Instantiation is the same kind of boundary — the app is materializing
///   the tree — so the new board lands already normalized instead of opening with a warning row
///   about a mess the instantiation itself made. Board- and lane-level strays keep the verbatim
///   posture, exactly as the carve-out is scoped.
///
/// ### What a save is
///
/// Save as Template is the same walk pointed the other way — a board copied *into* the store, minus
/// the same two exclusions — plus one write the app owes: the `template:` key, stamped on the copy
/// through `BoardWriter.updateIndex`. That write is the whole of "the app never stamps a key into
/// store files it didn't write itself … the one writer of keyed files is Save as Template" (09
/// ▸ Storage): discovery, listing and instantiation are all reads, so a hand-dropped board in the
/// store can never gain a key by being looked at. See `saveAsTemplate(boardAt:titled:into:)`.
///
/// ### Atomicity: construct-then-clean, not stage-then-rename
///
/// "A half-instantiated board must never be left at the destination." Two ways to promise that, and
/// the rename-into-place one is the wrong one here:
///
/// - A temp staged **beside** the destination is not reliably writable — the save panel's grant is
///   the item the user named, not its parent (03-board-ui.md's own reasoning for why Duplicate's
///   silent sibling can be refused at all), so staging there could fail for a destination that is
///   perfectly writable.
/// - A temp in `NSTemporaryDirectory()` may be on another volume, where `rename` degrades to a copy —
///   a second full copy of the tree, and no atomicity in exchange (`BoardWriter.atomicReplace` keeps
///   its temp in the same directory for exactly this reason; that trick does not survive being scaled
///   to a folder the app may not write beside).
///
/// So the promise is kept the way `BoardWriter.copyItem` and `BoardDuplicator` keep it: **the
/// destination is created by this call and removed by this call on every exit that is not a board**
/// — cancellation and failure alike, "because a half-copied board is pure residue: nothing was there
/// before, so there is no true state for a reload to show". The one thing never removed is a
/// destination this call did not create: an existing name is the user's, and a create that clobbered
/// one would be the create path silently deleting a board (`BoardWriter.createBoard`'s refusal, at
/// folder scale).
enum TemplateEngine {

    private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "templates")

    // MARK: - Where templates live

    /// The store folder's name in both locations — the bundle's and the app's own.
    static let storeFolderName = "Templates"

    /// The bundled store: `<app bundle>/Contents/Resources/Templates/`, holding one board folder per
    /// template (09 ▸ Definition format). `nil` only if the running bundle has no resources at all,
    /// which is not a state a shipped app is in.
    static var bundledStore: URL? {
        Bundle.main.resourceURL?.appendingPathComponent(storeFolderName, isDirectory: true)
    }

    /// The user store: `<Application Support>/Templates/`, beside the board registry and the
    /// clipboard's staging store — 09's settled location ("Application Support … inside the app
    /// container — friction-free sandbox writes, no location ceremony"; 02-architecture.md
    /// § Per-board app state, "App-wide state has the same home").
    ///
    /// This store needs none of the machinery the registry does: a template is a folder inside the
    /// app's own container, so there is no bookmark to mint and nothing to grant. Spelled through
    /// `AppStateHome` like every other app-wide store (`ClipboardStore.defaultStagingRoot`,
    /// `BoardRegistry.defaultStorageURL`), so all three move together if the home ever does.
    ///
    /// **Named, never created here.** Discovery of a store that does not exist is an empty list, not
    /// a directory the app made on the off-chance: the store is minted by the first Save as Template,
    /// and by Reveal in Finder, both of which are 09's other cards.
    static var userStore: URL {
        AppStateHome.directory.appendingPathComponent(storeFolderName, isDirectory: true)
    }

    // MARK: - Discovery

    /// The candidate template folders in a store, in folder-name order — every visible directory,
    /// which is deliberately *not* narrowed to `.kanban`: "a board folder dropped in becomes a
    /// template" (09 ▸ Storage), and an extension-less board folder is as legal as a suffixed one
    /// (01-storage-format.md § Document packaging).
    ///
    /// Hidden entries and symlinks are excluded — `BoardLoader.directoryCandidates`, the loader's own
    /// listing, so a `.DS_Store` is not a template and a link is never followed out of the store.
    /// A missing store is an empty list.
    static func templateFolders(in store: URL) -> [URL] {
        (try? BoardLoader.directoryCandidates(in: store)) ?? []
    }

    /// Loads one template folder through the ordinary loader.
    ///
    /// The loader's error is handed back whole rather than reworded: the chooser's unloadable row
    /// shows "the loader's fail-fast specifics" (09 ▸ Why this format), and a second taxonomy of
    /// board problems is precisely what a files-first app must not grow.
    ///
    /// **One defect of the walk's aggregate — the first** (`BoardLoadFailure.primary`). The chooser's
    /// unloadable row is one line about a folder the user is not being invited to repair: a template
    /// store is picked from, not opened, and the decision surface exists for the board being opened.
    /// Saying which thing is wrong first is the whole of what that row can act on.
    static func load(templateAt url: URL, origin: BoardTemplate.Origin) -> Result<BoardTemplate, BoardLoadError> {
        do {
            let result = try BoardLoader.load(boardRoot: url)
            return .success(BoardTemplate(url: url, origin: origin, model: result.model))
        } catch {
            return .failure(error.primary)
        }
    }

    /// Every bundled template that loads, in chooser order.
    ///
    /// **A bundled template that does not load is a build defect, not a user's problem**, so it is
    /// logged and skipped rather than surfaced: the unloadable-row treatment 09 specifies exists
    /// because "the user store is hand-editable, so a malformed board there is one edit away" — an
    /// app's own resources are neither hand-edited nor fixable by the person looking at the chooser.
    /// The suite walks this same list and loads each folder, which is 09's "testable for free".
    ///
    /// Order is `template.order`, then display name for anything keyless — 09's chooser order for
    /// the bundled tier.
    static func bundledTemplates() -> [BoardTemplate] {
        guard let store = bundledStore else { return [] }
        var templates: [BoardTemplate] = []
        for folder in templateFolders(in: store) {
            switch load(templateAt: folder, origin: .bundled) {
            case let .success(template):
                templates.append(template)
            case let .failure(error):
                logger.error("bundled template \(folder.lastPathComponent, privacy: .public) failed to load: \(error.description, privacy: .public)")
            }
        }
        return sortedForChooser(templates)
    }

    /// Every folder in `store` as a **chooser row** — loaded templates and unloadable ones alike,
    /// in folder-name order (the caller sorts).
    ///
    /// This is 09's one-bad-template-never-fails-the-chooser clause, and it is the whole of it: the
    /// walk cannot fail as a walk (a missing store is an empty list), and a folder the loader rejects
    /// becomes a row carrying the error rather than an omission or a thrown failure. Nothing here
    /// writes: **listing a store never stamps a key into it** — "a hand-dropped board is never
    /// touched" (09 ▸ Storage).
    static func rows(in store: URL, origin: BoardTemplate.Origin) -> [TemplateRow] {
        templateFolders(in: store).map { folder in
            switch load(templateAt: folder, origin: origin) {
            case let .success(template):
                .template(template)
            case let .failure(error):
                .unloadable(TemplateRow.Unloadable(url: folder, error: error))
            }
        }
    }

    /// The user tier, in 09's within-tier order — every board folder in the user store, whether or
    /// not it loads and whether or not it carries a `template:` key ("a board folder dropped in
    /// becomes a template — **no `template:` key required**").
    static func userRows(in store: URL = userStore) -> [TemplateRow] {
        sortedForChooser(rows(in: store, origin: .user))
    }

    /// **The chooser's whole list**, in 09 ▸ Storage's three-tier order:
    ///
    /// > Chooser order: bundled templates by `template.order`, then keyed user templates by
    /// > `template.order`, then keyless user boards last, sorted by display name.
    ///
    /// The tiers are concatenated rather than sorted together, which is what makes "then" mean
    /// *then*: a user template carrying `order: 1` still lists after every bundled one, because the
    /// store it came from is the sort's outermost key. Within each tier the same two-step rule runs
    /// (`sortedForChooser`), so the tier boundary is the only thing this function decides.
    ///
    /// **Re-read on every call**, and the chooser calls it on every presentation: the store is not
    /// watched (09 asks for a Reveal in Finder affordance, not a live folder), so a board dropped in
    /// while the chooser is open appears the next time the chooser is opened — or, since the drop
    /// usually happens in the Finder window Reveal just opened, when the app comes back to the
    /// front. `TemplateChooserView` wires both.
    static func chooserRows(userStore store: URL = userStore) -> [TemplateRow] {
        bundledTemplates().map(TemplateRow.template) + userRows(in: store)
    }

    /// 09's chooser order within one tier: keyed templates by `template.order`, then keyless ones by
    /// display name. `localizedStandardCompare` for the names, the same Finder ordering every other
    /// name listing in the app uses.
    static func sortedForChooser(_ templates: [BoardTemplate]) -> [BoardTemplate] {
        chooserSorted(templates, order: \.order, name: \.name)
    }

    /// The same rule over chooser rows, which is where it actually meets 09's keyless tier: an
    /// unloadable row reports no order at all, so it sorts by folder name among the keyless boards
    /// without this comparison having to know what an unloadable row is.
    static func sortedForChooser(_ rows: [TemplateRow]) -> [TemplateRow] {
        chooserSorted(rows, order: \.order, name: \.name)
    }

    /// The comparison itself, once: keyed before keyless, `order` ascending among the keyed, name
    /// among the rest — and name as the tie-break between equal orders, so a hand-edited store with
    /// two `order: 100`s still lists in a stable, explicable sequence.
    private static func chooserSorted<Item>(
        _ items: [Item],
        order: (Item) -> Double?,
        name: (Item) -> String
    ) -> [Item] {
        items.sorted { left, right in
            switch (order(left), order(right)) {
            case let (leftOrder?, rightOrder?):
                leftOrder == rightOrder
                    ? name(left).localizedStandardCompare(name(right)) == .orderedAscending
                    : leftOrder < rightOrder
            case (.some, .none):
                true
            case (.none, .some):
                false
            case (.none, .none):
                name(left).localizedStandardCompare(name(right)) == .orderedAscending
            }
        }
    }

    // MARK: - Outcomes

    /// The two ways instantiation ends without a board.
    ///
    /// **There is no `.refused` here**, unlike `BoardDuplicator`: this flow's destination came from
    /// the save panel, and the panel's grant *is* the sandbox's answer — asking the same question
    /// again would be a loop, so a permission failure is an ordinary failure with an ordinary
    /// message (`BoardDuplicator.duplicate(boardAt:titled:into:)` takes the same position for the
    /// same reason).
    enum Failure: Error, Sendable, Equatable {
        /// The user cancelled. The partial is already gone and there is nothing to report — the
        /// duplicate rule, verbatim: a cancelled create never happened.
        case cancelled

        /// Anything else: a full disk, an unreadable template, a name already taken. The one-shot
        /// banner's vocabulary (02-architecture.md § Write-failure surfacing), which the chooser
        /// renders as an alert because a board that was never created has no window to carry a row.
        case failed(BoardWriteError)
    }

    // MARK: - Instantiation

    /// Creates a board at `destination` from `template`, titled `title`.
    ///
    /// `title` is the **user-chosen document name** (`documentName(of:)` off the save panel's URL),
    /// never the template's: 09 ▸ Instantiation says so and 01-storage-format.md § Board naming is
    /// the reason — display name and folder name start out matching. The template's own title is
    /// what seeds the panel's *suggested* name, which is the chooser's end of the same sentence.
    ///
    /// `isCancelled` is read between items and nowhere else, defaulting to the ambient task's own
    /// cancellation — so a caller cancels an instantiation the way it cancels anything else, and a
    /// test can trip it deterministically at item *N* (`BoardDuplicator`'s seam, for its reasons).
    /// 02-architecture.md's Cancel-on-safe-copies rule is *about* copy-shaped work like this; the
    /// in-progress row that offers the button belongs to the chooser surface being built alongside
    /// it, and this parameter is what it attaches to.
    ///
    /// Returns `destination` — the caller opens it through the ordinary open path, so a new board
    /// registers, bookmarks and titles itself like any other.
    @discardableResult
    static func instantiate(
        template: BoardTemplate,
        to destination: URL,
        title: String,
        isCancelled: () -> Bool = { Task.isCancelled }
    ) throws(Failure) -> URL {
        let operation = WriteOperation.createBoard

        func failure(at url: URL, _ message: String) -> Failure {
            .failed(BoardWriteError(operation: operation, path: url.path, reason: .io(message: message)))
        }

        // **Refused, never clobbered**, and checked before anything is created so the cleanup below
        // can never reach a destination this call did not make. The save panel's replace prompt
        // grants access; it does not delete anything, so an occupied name arrives here intact and
        // leaves that way (`BoardWriter.createBoard`'s stance, at folder scale).
        guard !FileManager.default.fileExists(atPath: destination.path) else {
            throw failure(at: destination, "something already exists here")
        }

        // Cancelled before it began is still cancelled — answered here so the empty destination
        // never exists at all.
        if isCancelled() { throw .cancelled }

        do {
            try BoardTreeCopy.createDirectory(at: destination)
        } catch {
            throw failure(at: destination, "could not create board folder: \(error.localizedDescription)")
        }

        do {
            try copyTree(of: template, to: destination, isCancelled: isCancelled)
            try mintIdentitiesAndStamps(at: destination, title: title, operation: operation)
        } catch {
            // Cancelled or failed, the partial goes — the whole of this call's atomicity, and the
            // reason nothing half-made is ever left where the user pointed.
            try? FileManager.default.removeItem(at: destination)
            throw error
        }

        return destination
    }

    /// The copy half: the template's tree, minus the two board-root exclusions, with folder
    /// attributes deliberately **not** carried (`BoardTreeCopy`'s flag documents why — a bundled
    /// template's read-only mode must not mint a read-only board, and a board born today must not
    /// wear the template's dates).
    private static func copyTree(
        of template: BoardTemplate,
        to destination: URL,
        isCancelled: () -> Bool
    ) throws(Failure) {
        do throws(BoardTreeCopy.Stop) {
            try BoardTreeCopy.copy(
                contentsOf: template.url,
                into: destination,
                excludingTopLevel: [BoardLoader.trashFolderName, ".git"],
                carryingFolderAttributes: false,
                isCancelled: isCancelled
            )
        } catch {
            switch error {
            case .cancelled:
                throw .cancelled
            case let .failed(url, underlying):
                throw .failed(BoardWriteError(
                    operation: .createBoard,
                    path: url.path,
                    reason: .io(message: "could not copy the template: \(underlying.localizedDescription)")
                ))
            }
        }
    }

    /// The born half, on the tree already at the destination: fresh identities, fresh stamps, the
    /// chosen title, and the loose-file normalization an import boundary owes.
    ///
    /// **An instantiation is a copy transaction**, which is `BoardWriter.copyItem`'s posture since
    /// 2026-07-29 and for its reason: the whole tree is preflighted for stampability before a single
    /// folder is reminted, and a template carrying one readable-but-uneditable card refuses the create
    /// whole, naming that card (01-storage-format.md § Frontmatter: "preflights the entire subtree and
    /// refuses whole, loudly, naming the offending item — never a partial copy, never a silently
    /// unstamped descendant").
    ///
    /// This retired the former root-strict/descendants-lenient split, which copied such a card
    /// byte-verbatim and skipped its stamp. Two things were wrong with the kindness: an unstamped
    /// descendant keeps a `modified-by` naming somebody who never touched this board, and — since the
    /// tracker sever joined the copy contract — a live `remote` claim on an object the new board has
    /// no relationship with. "Proceed partially, lose a little" is never a verdict (01's leniency
    /// doctrine).
    ///
    /// **The preflight runs on the destination, not the template**, deliberately: the copy has already
    /// applied its top-level exclusions, so `.trash/`'s cards — which are not part of what a template
    /// instantiates — cannot refuse a create they were never going to appear in. Nothing is lost by
    /// preflighting a step later, because `instantiate`'s construct-then-clean removes the whole
    /// destination on any throw (09-templates.md's atomicity).
    private static func mintIdentitiesAndStamps(
        at root: URL,
        title: String,
        operation: WriteOperation
    ) throws(Failure) {
        do throws(BoardWriteError) {
            // Before the remint, so a refusal names folders by the paths the user's template actually
            // has rather than by minted UUIDs they have never seen.
            try BoardWriter.checkCopiedDescendantsAreStampable(of: root, operation: operation)

            var materialized: [URL] = []
            try BoardWriter.remintDescendants(of: root, collecting: &materialized, operation: operation)

            // One `Date` for the whole tree, so the board and every item in it are born at the same
            // instant rather than merely close (`BoardWriter.newDocumentText`'s convention).
            let now = Date()
            // `kind: .board` — an instantiated board's root is the one file whose kind position
            // cannot answer, and this write is where a template's kind-less root gains it
            // (`BoardWriter.updateIndex`'s on-touch backfill; no template migration, by design).
            try BoardWriter.updateIndex(inItemFolder: root, kind: .board, operation: operation) { document in
                // `.born` restamps `created`; the copy contract also severs the reserved tracker keys,
                // which at board level is the `remote` a template could have carried in from the board
                // it was saved from (01 ▸ Identity lifecycle, ruled 2026-07-29).
                BoardWriter.applyCopyContract(to: &document, stamps: .born, now: now)
                document.set(FrontmatterKeys.title, to: .string(title))
            }
            for folder in materialized {
                try BoardWriter.stampCopiedDescendant(at: folder, stamps: .born, now: now, operation: operation)
            }

            // The import boundary's normalization, on the final paths. Lanes only: the carve-out is
            // card-level and one level deep, so `normalizeLooseFiles(inLane:)` is the exact reach.
            // The lane list is the loader's own level detection — `directoryCandidates` (hidden
            // entries and symlinks already out) narrowed by the identity predicate — so a stray
            // folder at board level is never descended into here either.
            // **The seed, before the normalization it governs** (01-storage-format.md § Fractal
            // layout ▸ Rules and 06-history-undo.md ▸ Repository hygiene, ruled 2026-07-31: every
            // board carries a `.gitignore`, and an instantiated board is a board created today). A
            // template that carries one of its own copies through and is honored — the seed writes
            // only into a free name — which is 09-templates.md's "strays copy through" doing the
            // right thing by itself; a template written before the ruling gains the seed here rather
            // than a reload later, so the new board's very first walk has its noise gate.
            try BoardWriter.seedGitignoreIfAbsent(atBoardRoot: root)

            let lanes = ((try? BoardLoader.directoryCandidates(in: root)) ?? [])
                .filter { BoardLoader.isUUIDShaped($0.lastPathComponent) }
            for lane in lanes {
                try BoardWriter.normalizeLooseFiles(inLane: lane)
            }
        } catch {
            throw .failed(error)
        }
    }

    // MARK: - Save as Template

    /// The spacing between user templates' `template.order` values — the bundled store's own
    /// spacing (100, 200, … 1000), so a hand-editor moving one template between two others has room
    /// to write a number in the gap.
    static let userOrderStep: Double = 100

    /// The order the next Save as Template takes: **appended after existing user templates** (09
    /// ▸ Save as Template), which is the highest `template.order` in the store plus one step.
    ///
    /// Read off the store's own rows rather than off a counter, because the store is hand-editable
    /// and a counter would be a second opinion about it. Keyless boards contribute nothing — they
    /// sort by name in their own tier and have no position to be appended after — and neither does
    /// an unloadable folder, which has no key to read.
    static func nextUserOrder(in store: URL = userStore) -> Double {
        guard let highest = rows(in: store, origin: .user).compactMap(\.order).max() else {
            return userOrderStep
        }
        return highest + userOrderStep
    }

    /// Creates the user store if it is not there, and answers it.
    ///
    /// **The store's two minters are Save as Template and Reveal in Finder** (see `userStore`, which
    /// only names it): a store that exists because the app made it on the off-chance would be an
    /// empty folder in the shared container for a user who never used the feature, while a Reveal
    /// that opened nothing — or a save that failed because its own home was missing — would be the
    /// app being pedantic about a directory it owns.
    @discardableResult
    static func createUserStore(at store: URL = userStore) throws -> URL {
        try FileManager.default.createDirectory(at: store, withIntermediateDirectories: true)
        return store
    }

    /// Copies the board at `rootURL` into the user templates store and answers where it landed —
    /// 09 ▸ Save as Template, whose whole contract is the four rules below.
    ///
    /// - **`.git` and `.trash/` are dropped** — the same two top-level exclusions instantiation
    ///   uses, for two different halves of one reason: a template is content, not history ("copying
    ///   it would embed the board's full repo, every attachment version included, in the template
    ///   store"), and a template is not a fork, so the board's trash is not part of what is being
    ///   saved. This is where Save as Template and File ▸ Duplicate part company — Duplicate carries
    ///   both, because a duplicate *is* a fork (03-board-ui.md).
    /// - **Everything else copies verbatim**: "Strays copy through … `CLAUDE.user.md`, a seeded
    ///   `.gitignore`, and other non-schema files carry through Save as Template *and* instantiation
    ///   alike", along with GUIDs and timestamps — both inert, since instantiation remints and
    ///   restamps at its own boundary.
    /// - **A `template:` key is written on the copy** with an order appended after the existing user
    ///   templates, overwriting a stale one the board carried in from its own instantiation.
    /// - **Store collisions auto-rename, Finder-style**, never overwrite and never refuse
    ///   (`BoardDuplicator.uncollidedURL(named:extension:in:)`, the ladder Duplicate seeds
    ///   differently).
    ///
    /// **The close flush is the caller's**, not this function's: it needs a window session, and 09
    /// states the rule where the command lives (`SaveAsTemplateCommand`, mirroring Duplicate's
    /// sequence exactly).
    ///
    /// `isCancelled` is read between items and nowhere else — `BoardDuplicator`'s seam, and the
    /// in-progress row's Cancel at the other end of it. Cancelled or failed, the partial store entry
    /// goes: nothing was there before, so there is no true state for a half-copied template to be.
    @discardableResult
    static func saveAsTemplate(
        boardAt rootURL: URL,
        titled title: String?,
        into store: URL = userStore,
        isCancelled: () -> Bool = { Task.isCancelled }
    ) throws(Failure) -> URL {
        let operation = WriteOperation.saveAsTemplate(title: title)

        func failure(at url: URL, _ message: String) -> Failure {
            .failed(BoardWriteError(operation: operation, path: url.path, reason: .io(message: message)))
        }

        // The store is minted here — first save, first folder.
        do {
            try createUserStore(at: store)
        } catch {
            throw failure(at: store, "could not create the templates folder: \(error.localizedDescription)")
        }

        // **Read before the copy lands**, so the scan that decides "after the existing user
        // templates" cannot see the template being appended and count it as existing.
        let order = nextUserOrder(in: store)

        let destination = BoardDuplicator.uncollidedURL(
            named: rootURL.deletingPathExtension().lastPathComponent,
            extension: rootURL.pathExtension,
            in: store
        )

        // Cancelled before it began is still cancelled — answered before anything is created.
        if isCancelled() { throw .cancelled }

        do {
            try BoardTreeCopy.createDirectory(at: destination)
        } catch {
            throw failure(at: destination, "could not create the template folder: \(error.localizedDescription)")
        }

        do {
            try copyBoard(at: rootURL, into: destination, operation: operation, isCancelled: isCancelled)
            try stampTemplateKey(at: destination, order: order, operation: operation)
        } catch {
            // The ladder made this name and this call made this folder, so removing it destroys
            // nothing that was the user's — the instantiation cleanup's reasoning, at the store.
            try? FileManager.default.removeItem(at: destination)
            throw error
        }

        return destination
    }

    /// The copy half of a save: the board's tree minus the two exclusions.
    ///
    /// **Folder attributes are not carried** (unlike Duplicate, which forks them). The store is
    /// specified as "plain board folders, hand-editable and agent-writable" (09 ▸ Storage), and the
    /// one lock Save as Template stays live under is the *unwritable-location* one — a board on a
    /// read-only DMG being archived (02-architecture.md ▸ Live-reload resilience). Carrying that
    /// board's mode bits inward would mint a read-only template in the user's own store, which is
    /// precisely the thing the store is not. Folder timestamps go with them and are inert: the
    /// timestamps 09 keeps are the frontmatter's, and those ride inside files copied byte for byte.
    private static func copyBoard(
        at rootURL: URL,
        into destination: URL,
        operation: WriteOperation,
        isCancelled: () -> Bool
    ) throws(Failure) {
        do throws(BoardTreeCopy.Stop) {
            try BoardTreeCopy.copy(
                contentsOf: rootURL,
                into: destination,
                excludingTopLevel: [BoardLoader.trashFolderName, ".git"],
                carryingFolderAttributes: false,
                isCancelled: isCancelled
            )
        } catch {
            switch error {
            case .cancelled:
                throw .cancelled
            case let .failed(url, underlying):
                throw .failed(BoardWriteError(
                    operation: operation,
                    path: url.path,
                    reason: .io(message: "could not copy the board: \(underlying.localizedDescription)")
                ))
            }
        }
    }

    /// Writes `template: {order: N}` on the copy's own `index.md`, through the Writer's ordinary
    /// `updateIndex` — the round-trip guarantee, the unknown-key preservation and the atomic replace
    /// all come along, and the body (the board's description, which is about to be the chooser's
    /// blurb) is never rewritten.
    ///
    /// ### On the copy, after it lands
    ///
    /// The only alternative — stamping the source board and copying the result — would write a
    /// `template:` key into the user's *board*, which is not what was asked for. So the write
    /// happens here, on a tree that is already in the store, and it needs **no write bracket**:
    /// brackets exist to keep a watched board's live snapshot honest (02-architecture.md), and this
    /// path is outside every watched board — the store is not watched and the source was only read.
    ///
    /// ### The whole mapping is rewritten, and that is 09's shape
    ///
    /// "**`order` (display position in the chooser) is its only subkey**" (09 ▸ Definition format),
    /// so replacing the mapping loses nothing that can exist today; a stale order from the board's
    /// own instantiation is overwritten, which is exactly what 09 asks for. If the key ever grows a
    /// second subkey, this is the one place that has to learn to merge — nowhere else writes it.
    private static func stampTemplateKey(
        at root: URL,
        order: Double,
        operation: WriteOperation
    ) throws(Failure) {
        do throws(BoardWriteError) {
            try BoardWriter.updateIndex(inItemFolder: root, kind: .board, operation: operation) { document in
                document.set(BoardLoader.templateKey, to: .raw("{order: \(orderText(order))}"))
            }
        } catch {
            throw .failed(error)
        }
    }

    /// `100` rather than `100.0` for a whole number, so the file reads like the bundled templates'
    /// own `template: {order: 100}` — the same rounding `FrontmatterValue` applies to a `.double`.
    private static func orderText(_ order: Double) -> String {
        order == order.rounded() && abs(order) < 1e15 ? String(Int64(order)) : String(order)
    }

    // MARK: - Naming

    /// The document name behind a chosen URL — `~/Boards/Roadmap.kanban` → `Roadmap`.
    ///
    /// An extension-less name is as legal a board as a `.kanban` one (01-storage-format.md
    /// § Document packaging, "Extension-less board folders still open"), so the extension is
    /// stripped rather than required.
    static func documentName(of destination: URL) -> String {
        destination.deletingPathExtension().lastPathComponent
    }

    /// The save panel's suggested file name for a template — "seed the save panel's suggested name
    /// from the template title" (09 ▸ Instantiation), with the package extension on the end so the
    /// board is created as a `.kanban` document by default.
    static func suggestedFileName(for template: BoardTemplate) -> String {
        "\(template.name).kanban"
    }
}
