import Foundation
import os

// MARK: - The launch plan

/// What the app does with its first run-loop turn — decided once, in `KanbanApp.init()`, and never
/// re-derived (02-architecture.md § Launch and window lifecycle).
///
/// It exists because there are now **three** answers to "which window appears at launch", not two:
/// welcome, the registry's restoration pass, and — for the accessibility audit suite
/// (10-accessibility.md ▸ Verification) — a synthetic board the app builds for itself. A `Bool` held
/// two of them; a third would have meant two booleans and an implicit precedence between them, which
/// is exactly the shape that grows a launch bug nobody can reproduce.
///
/// The decision is a pure function of three facts, so it is provable without a `UserDefaults` domain,
/// a live registry, or a running app — `AppModel.shouldRestoreAtLaunch`'s own reason for being pulled
/// out of `App.init` in the first place, applied one level up.
enum LaunchPlan: Equatable, Sendable {

    /// Nothing to restore and no fixture asked for: the welcome window, which is the ordinary
    /// first-launch and tidied-away case.
    case welcome

    /// The registry's flagged boards reopen (`BoardRegistry.restorables()`).
    case restoreBoards

    /// The UI-test fixture board is built and opened. See `UITestLaunch`.
    case uiTestFixture

    /// **The fixture wins outright**, and that is the whole precedence rule: a UI-test launch must
    /// never reopen the developer's own boards, both because the audit needs a board whose contents
    /// the test knows and because a test run has no business touching real documents.
    static func decide(
        isUITestFixtureLaunch: Bool,
        restorePreference: Bool,
        hasRestorables: Bool
    ) -> LaunchPlan {
        if isUITestFixtureLaunch { return .uiTestFixture }
        return AppModel.shouldRestoreAtLaunch(
            preference: restorePreference,
            hasRestorables: hasRestorables
        ) ? .restoreBoards : .welcome
    }

}

// MARK: - UITestLaunch

/// **The UI suites' boards**, and the launch arguments that ask for them — the accessibility audit's
/// fixture (10-accessibility.md ▸ Verification: "Xcode's accessibility audit … runs in UI tests over
/// every surface — board (trash shown and hidden), card window (Preview, Edit, raw source), welcome,
/// template chooser, board popover") and, since the end-to-end pass, two more shapes that the audit
/// never needed: a **large** board for reflow and launch cost, and a **malformed** one whose only job
/// is to fail to load.
///
/// ### Why the app builds the board instead of being handed one
///
/// The obvious shape — the UI test writes a board to a temp folder and passes its path — **cannot
/// work here, and the reason is the sandbox**. `Kanban.entitlements` grants
/// `files.user-selected.read-write` and nothing else, so a path arriving on the command line is a
/// path the app may not read: there is no open panel behind it and no bookmark for it. The app can
/// only reach files it was granted, files it ships, and its own container.
///
/// So the flag carries no payload and the app builds the board **inside its own container**
/// (`NSTemporaryDirectory()`, which sandboxes to `…/Containers/dev.rzen.indie.Kanban/Data/tmp`),
/// through the ordinary `BoardWriter` — the app's single write door (02-architecture.md § Layering).
/// Nothing here knows the storage format: it calls `createBoard`, `createLane`, `createCard`,
/// `writeBody`, `importAttachments` and `deleteCardToTrash` exactly as the board window does, so the
/// fixture is a board the app made, not a board a test file *believes* is well-formed. A format
/// change that broke this would break the app first.
///
/// **The one deliberate exception is the malformed variant**, which builds its board through the very
/// same door and then overwrites exactly one card's `index.md` with raw bytes. That write is the
/// point of the variant — there is no Writer call that produces an unparseable file, and there should
/// not be one — and it happens *last*, so everything around the broken file is still a board the app
/// made.
///
/// ### It is inert without the flag
///
/// Every entry point below is reached only from `LaunchPlan.uiTestFixture`, and that case is reached
/// only when `--ui-test-fixture-board` is on the command line. No document open, no URL scheme and no
/// menu item can produce it; a shipped app never runs a line of this. It is compiled into the release
/// binary anyway rather than hidden behind `#if DEBUG`, because the thing being audited must be the
/// app that ships — an accessibility pass over a differently-compiled binary is a pass over a
/// different app.
///
/// ### What the flag also switches off
///
/// **The registry and the clipboard's staging store move into the scratch directory** with the board.
/// Without that, every audit run would stamp a temp folder into the user's real recents list
/// (`BoardRegistry.defaultStorageURL`), where it would sit for good as an unavailable row pointing at a
/// directory that no longer exists — and its launch sweep would collect the user's real staged copy
/// (`ClipboardStore.defaultStagingRoot`). Tying them to the same flag rather than to separate
/// arguments is deliberate: they are one decision — "this launch is synthetic" — and a second argument
/// is a second chance to apply only half of it.
///
/// The honest residual: `UserDefaults` is **not** redirected, so an audit run can still write the
/// three app-wide scalars (`AppPreferences`) into the real domain. They are a window size, a restore
/// toggle this launch never consults, and the quick-style recents list — no documents, nothing
/// destructive, and redirecting a defaults domain from inside the process is not something the
/// platform actually supports. It is stated rather than fixed.
enum UITestLaunch {

    private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "ui-test-launch")

    // MARK: - The flag

    /// The launch argument every UI suite passes (`KanbanUITests/UITestSupport.swift`), on its own or
    /// beside a variant flag. It means "this launch is synthetic" and nothing narrower — which board
    /// is `FixtureVariant`'s to say.
    ///
    /// `--`-prefixed on purpose: a single-dash `-key value` pair is swallowed by `UserDefaults`'
    /// `NSArgumentDomain` and would silently become a preference, which is precisely the kind of
    /// side effect a test-only switch must not have.
    static let fixtureFlag = "--ui-test-fixture-board"

    /// Whether `arguments` asks for a fixture board — **pure**, so the rule is pinned by
    /// `UITestLaunchTests` rather than by launching an app and looking.
    ///
    /// Exact match, not a prefix: `--ui-test-fixture-boards-elsewhere` is not this flag, and a
    /// `hasPrefix` check that accepted it would be a launch switch with a fuzzy edge. That is also
    /// why the variants below wear *sibling* flags rather than a `=value` suffix or a
    /// `--flag value` pair — a suffix would demand exactly the prefix matching this rules out, and a
    /// pair is the shape `NSArgumentDomain` swallows.
    ///
    /// **A variant flag on its own is enough**, which is belt over braces rather than a second
    /// spelling: every call site passes `fixtureFlag` too (it is what "this launch is synthetic"
    /// means), but a test bundle that passed only `--ui-test-fixture-large` must not get a launch
    /// that reopens the developer's real boards into a scratch registry's blind spot.
    static func isFixtureLaunch(arguments: [String]) -> Bool {
        arguments.contains(fixtureFlag)
            || FixtureVariant.allCases.contains { arguments.contains($0.flag) }
    }

    /// The running process's answer to the same question.
    static var isFixtureLaunch: Bool {
        isFixtureLaunch(arguments: ProcessInfo.processInfo.arguments)
    }

    // MARK: - The variants

    /// **Which fixture board a launch asks for.** Three shapes, because the three suites that consume
    /// them are asking three different questions:
    ///
    /// - `standard` — the audit's board (three lanes, six cards, a rich card, a trashed card). It is
    ///   also the end-to-end suite's board: every golden flow is expressed against a shape small
    ///   enough to state in a sentence, so an assertion about a lane's card count is readable.
    /// - `large` — many lanes × many cards, for the masonry, the reflow, and the launch-cost
    ///   measurements. Nothing about it is *interesting*; the point is that there is a lot of it.
    /// - `malformed` — a well-formed board with exactly one unparseable card `index.md`, for the
    ///   fail-fast pass (01-storage-format.md § Malformed input). It is the only variant whose
    ///   *successful* materialization is expected to produce a *failed* load.
    ///
    /// The raw value is the flag's tail, so the flag and the case can never drift; the flag is
    /// double-dashed for `fixtureFlag`'s reason and exact-matched for its reason too.
    enum FixtureVariant: String, CaseIterable, Sendable {
        case standard
        case large
        case malformed

        /// The launch argument naming this variant. Paired with `fixtureFlag` at every call site.
        var flag: String { "--ui-test-fixture-\(rawValue)" }

        /// The board's title — and, through `fixtureBoardURL(for:)`, its folder name and its window
        /// title, so a test can wait on `app.windows["Audit Board"]` and its neighbours.
        ///
        /// Distinct per variant on purpose: a suite that waited on the wrong title would otherwise
        /// pass against the wrong board, and the malformed variant's whole assertion is that *no*
        /// window by its name ever appears.
        var boardTitle: String {
            switch self {
            case .standard: UITestLaunch.boardTitle
            case .large: "Large Board"
            case .malformed: "Malformed Board"
            }
        }
    }

    /// The variant `arguments` asks for — `standard` when none is named, which is what the audit
    /// suite's bare `--ui-test-fixture-board` has always meant.
    ///
    /// **Declaration order breaks a tie**, so a launch naming two variants is deterministic rather
    /// than dependent on argument order. Nothing produces that today; stating the rule is cheaper
    /// than discovering it.
    static func variant(arguments: [String]) -> FixtureVariant {
        FixtureVariant.allCases.first { arguments.contains($0.flag) } ?? .standard
    }

    /// The running process's answer to the same question.
    static var variant: FixtureVariant {
        variant(arguments: ProcessInfo.processInfo.arguments)
    }

    // MARK: - The scratch directory

    /// Everything a fixture launch writes, under one removable root inside the app's container.
    ///
    /// One folder rather than two loose paths so `prepareScratchDirectory()` can promise a clean
    /// start with a single `removeItem` — a fixture board and a registry that disagreed about which
    /// run they belonged to would be worse than either being stale.
    static var scratchRoot: URL {
        URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true)
            .appendingPathComponent("LaneworkUITestFixture", isDirectory: true)
    }

    /// Where the fixture launch's registry lives — beside the board rather than in Application
    /// Support, which is the whole point (see the type's note).
    static var registryStorageURL: URL {
        scratchRoot.appendingPathComponent("board-registry.json", isDirectory: false)
    }

    /// Where the fixture launch's clipboard snapshots live, on the registry's terms: an audit run's
    /// launch sweep would otherwise collect the developer's own staged copy. Redirected by the same
    /// flag, because it is the same one decision.
    static var clipboardStagingRoot: URL {
        scratchRoot.appendingPathComponent("Clipboard", isDirectory: true)
    }

    /// A fixture board's own folder. `.kanban`-suffixed because a board the app made through the
    /// ordinary create path is a document, and the audit should be looking at the shape a user's
    /// board actually has (01-storage-format.md § Document packaging).
    ///
    /// One folder per variant, all under the one scratch root: the root is wiped per launch anyway,
    /// so the separation buys nothing at runtime — it buys a *name*, which is what a suite waits on.
    static func fixtureBoardURL(for variant: FixtureVariant) -> URL {
        scratchRoot.appendingPathComponent("\(variant.boardTitle).kanban", isDirectory: true)
    }

    /// The audit fixture's folder — `fixtureBoardURL(for: .standard)`, kept as a name because that
    /// board is the one every caller predating the variants meant.
    static var fixtureBoardURL: URL {
        fixtureBoardURL(for: .standard)
    }

    /// Wipes and recreates the scratch root, and answers the registry URL for the caller's convenience.
    /// Called once, from `KanbanApp.init()`, **before** the model reads its registry — which is also why
    /// the return value is discardable: that caller now names both redirected homes explicitly
    /// (`registryStorageURL`, `clipboardStagingRoot`) rather than taking one of them from here.
    ///
    /// **Wiped rather than reused**: every audit test launches its own app instance, and an audit is
    /// only meaningful against a board whose contents the test knows — a previous run's leftovers
    /// (a card the test deleted, a lane it renamed) would make the next run's tree something nobody
    /// wrote down. Removal failures are logged and swallowed: the create below will fail loudly and
    /// visibly on welcome if the directory is genuinely unusable, and there is no launch this early
    /// that an alert could belong to.
    @discardableResult
    static func prepareScratchDirectory() -> URL {
        let root = scratchRoot
        do {
            try FileManager.default.removeItem(at: root)
        } catch CocoaError.fileNoSuchFile {
            // The ordinary first-run case, not a failure.
        } catch {
            logger.error("could not clear the UI-test scratch directory: \(error.localizedDescription, privacy: .public)")
        }
        do {
            try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
        } catch {
            logger.error("could not create the UI-test scratch directory: \(error.localizedDescription, privacy: .public)")
        }
        return registryStorageURL
    }

    // MARK: - The standard board's shape

    /// The standard board's title — and, through `fixtureBoardURL`, its folder name and its window
    /// title, so a test can wait on `app.windows["Audit Board"]`.
    static let boardTitle = "Audit Board"

    /// The lane titles, in board order. Three because the tree the audit walks should have more than
    /// one container to walk *between*, and because the smoke script's cut-and-paste step needs a
    /// destination lane that is not the source.
    static let laneTitles = ["To Do", "Doing", "Done"]

    /// The card titles, per lane, in card order.
    ///
    /// **The first lane is deliberately the crowded one.** Two of the audit's riskiest claims are
    /// about a lane wide enough to lay its cards out in interior masonry columns — that VoiceOver
    /// reads them by `order` and not column-major (10-accessibility.md ▸ Logical order), and that the
    /// `accessibilitySortPriority`-inside-a-`Layout` mechanism holding that up actually survives — and
    /// neither is observable in a lane with one card in it.
    static let cardTitles: [[String]] = [
        ["Draft the release notes", "Check the trash grammar", "Confirm the rotor jumps", "Ship the audit"],
        ["Write the smoke script"],
        ["Wire the launch fixture"],
    ]

    /// The card that gets the rich body, named by `(lane, card)` index — the one the card-window
    /// audits open.
    ///
    /// It carries the structures 10-accessibility.md makes claims about ("Preview renders to the
    /// accessibility tree as structured text — headings navigable by rotor, lists and tables read as
    /// such; task-list checkboxes are real accessible checkboxes"), so the Preview audit has
    /// something to audit and the Edit and raw-source audits open a document with real content in it
    /// rather than an empty text view.
    static let richCardIndex = (lane: 1, card: 0)

    /// The rich card's body. Every element in it is one 10-accessibility.md names: two heading
    /// levels for the rotor, a bulleted list, a task list (live checkboxes), a table, a fenced code
    /// block, a link, and an image with alt text ("body images use Markdown alt text when present,
    /// else the filename" — the file need not exist for the alt text to be the thing under audit).
    static let richCardBody = """
        ## What this card is for

        It is the accessibility audit's specimen: every structure 10-accessibility.md makes a claim \
        about appears once, so Preview has something to render into the tree.

        ### Structures

        - A bulleted list item
        - A second one, with a [link](https://example.com) in it

        - [ ] An unchecked task
        - [x] A checked task

        | Surface | Audited |
        | --- | --- |
        | Board | Yes |
        | Card window | Yes |

        ```swift
        let audit = try app.performAccessibilityAudit()
        ```

        ![A diagram of the board's accessibility tree](attachments/tree.png)
        """

    /// The attachment the rich card carries, so the card face shows a paperclip, the card element's
    /// value says "1 attachment" (`AccessibilityPhrases.cardValue`), and the card window's sidebar
    /// has a real attachment row to audit.
    static let attachmentName = "notes.txt"

    private static let attachmentBody = """
        The audit fixture's attachment. Its only job is to exist, so the attachments section has a row.
        """

    /// **The rich card's comment thread** — three comments, because the pane's accessibility audit
    /// needs one of each shape 10-accessibility.md's comments row can take (▸ Comments: "each comment
    /// is one flattened element — author, date, edited state, body").
    ///
    /// In order: an ordinary comment (author line, date, body), an **authorless** one (the date alone
    /// carries the line — "missing renders unattributed", and no placeholder stands in for a name),
    /// and one that has been **edited** (its author line ends "· edited", which is `modified`
    /// differing from `created` and no extra field). Between them they cover every branch of
    /// `CommentAuthorLine.text(author:timestamp:isEdited:)` that a written file can produce.
    static let commentBodies = [
        """
        The audit's specimen thread. This one is ordinary: a name, a date, and a paragraph of \
        Markdown with some *emphasis* in it.
        """,
        """
        This one has no `author` key at all, so it renders unattributed — a date and a body, and no \
        placeholder standing in for a name.
        """,
        """
        And this one has been edited since it was posted, so its author line carries the edited marker.
        """
    ]

    /// Which comment gets its `author` key removed, by index into `commentBodies`.
    static let authorlessCommentIndex = 1

    /// Which comment is edited after posting, by index into `commentBodies`, and what it is edited to.
    static let editedCommentIndex = 2

    static let editedCommentBody = """
        And this one has been edited since it was posted, so its author line carries the edited \
        marker — this sentence is the edit.
        """

    /// The card that is deleted into `.trash/`, named by `(lane, card)` index.
    ///
    /// A trash with something in it is the only way the trash-shown audit reaches the elements
    /// 10-accessibility.md specifies for it — ordinary card elements whose context menu offers Delete
    /// and Reveal in Finder and never Open. An empty column audits its own label and stops there.
    static let trashedCardIndex = (lane: 0, card: 1)

    // MARK: - The large board's shape

    /// The large board's lanes and cards — **8 × 40**, which is 320 cards.
    ///
    /// The numbers are a budget, not a maximum. They are large enough that the two things the large
    /// board exists to exercise actually happen — a lane wide enough to lay out in several interior
    /// masonry columns has plenty to lay out, and the board's reflow, scrolling and select-all all
    /// have real work to do — and small enough that materializing it (a folder and an `index.md` per
    /// card, through the ordinary Writer) stays in the seconds a UI test can afford. Raising them is
    /// a decision about how long every performance run takes; they are stated here so that decision
    /// is made in one place.
    static let largeLaneCount = 8
    static let largeCardsPerLane = 40

    static func largeLaneTitle(_ index: Int) -> String {
        "Lane \(index + 1)"
    }

    /// A large-board card's title — unique across the whole board, and **deliberately of four
    /// different lengths**.
    ///
    /// A wall of identical one-line cards would lay out as a perfect grid, which is exactly the case
    /// masonry has nothing to do. Cycling the length gives the layout genuinely different card
    /// heights to balance, so a reflow measured against this board is measuring the work the real
    /// algorithm does.
    static func largeCardTitle(lane laneIndex: Int, card cardIndex: Int) -> String {
        let base = "Card \(laneIndex + 1)-\(cardIndex + 1)"
        let tail = String(repeating: " with a longer title that wraps", count: cardIndex % 4)
        return base + tail
    }

    // MARK: - The malformed board's shape

    /// The malformed board's lanes and cards — small, because nothing about this variant is about
    /// size. Two lanes so the tree has a shape at all, and two cards in the first so the broken one
    /// has an intact sibling the loader walked past on its way to it.
    static let malformedLaneTitles = ["Intact", "Also intact"]

    static let malformedCardTitles: [[String]] = [
        ["A good card", "The malformed card"],
        ["Another good card"],
    ]

    /// Which card gets the raw overwrite, named by `(lane, card)` index.
    static let malformedCardIndex = (lane: 0, card: 1)

    /// A string appearing only in the malformed file, so a test can *find* that file on disk without
    /// knowing the UUID the Writer minted for it — and then assert its bytes are exactly what were
    /// written, which is the "nothing was silently repaired" half of fail-fast.
    static let malformationMarker = "lanework-ui-test-malformed-fixture"

    /// The bytes written over the doomed card's `index.md`.
    ///
    /// **The frontmatter opens a flow sequence and never closes it**, so the YAML between the
    /// delimiters cannot parse — `BoardLoader` rejects the whole board with `.unparseableYAML` naming
    /// this file, which is the failure the fail-fast pass is about. Everything else in the file is
    /// well-formed on purpose: the delimiters are there, the body is ordinary text, and the two
    /// required fields are present in spirit. The one thing wrong with it is the one thing under
    /// test.
    static let malformedIndexText = """
        ---
        schema: 1
        title: The malformed card
        order: [1024
        ---

        \(malformationMarker): this card's frontmatter opens a flow sequence and never closes it.
        The loader must reject the whole board rather than repair, skip, or rewrite anything — and
        these bytes must still be here, unchanged, after the app has refused to open the board.
        """

    // MARK: - Materialization

    /// Builds the fixture board `variant` asks for and answers its URL — every write through
    /// `BoardWriter`, in the order a user would have produced them, with the malformed variant's one
    /// raw overwrite as the documented exception (see this type's note).
    ///
    /// Called from `RestoreBootstrapView` rather than from `KanbanApp.init()`: it is filesystem work,
    /// and the launch path already has a place for filesystem work that has to happen before the
    /// first real window (that view's whole reason for existing). A throw surfaces as a launch
    /// failure on welcome — the same treatment a board that fails to restore gets — so a broken
    /// fixture is visible rather than a suite that quietly audits an empty screen.
    ///
    /// **The malformed variant does not throw here.** Building it succeeds; *loading* it is what
    /// fails, one layer up, through the ordinary board-window path — which is the whole point, since
    /// a fixture that failed to build would surface a message about the fixture rather than the
    /// loader's own sentence about the offending file.
    static func materializeFixtureBoard(_ variant: FixtureVariant = .standard) throws -> URL {
        switch variant {
        case .standard: try materializeStandardBoard()
        case .large: try materializeLargeBoard()
        case .malformed: try materializeMalformedBoard()
        }
    }

    /// The audit suite's board (see `boardTitle` and the constants above it).
    private static func materializeStandardBoard() throws -> URL {
        let root = fixtureBoardURL(for: .standard)
        try BoardWriter.createBoard(at: root, title: boardTitle)

        var laneURLs: [URL] = []
        for title in laneTitles {
            let id = try BoardWriter.createLane(inBoard: root, title: title)
            laneURLs.append(root.appendingPathComponent(id.rawValue, isDirectory: true))
        }

        var cardURLs: [[URL]] = []
        for (laneIndex, titles) in cardTitles.enumerated() {
            var lane: [URL] = []
            for title in titles {
                let id = try BoardWriter.createCard(inLane: laneURLs[laneIndex], title: title)
                lane.append(laneURLs[laneIndex].appendingPathComponent(id.rawValue, isDirectory: true))
            }
            cardURLs.append(lane)
        }

        let richCard = cardURLs[richCardIndex.lane][richCardIndex.card]
        try BoardWriter.writeBody(inItemFolder: richCard, body: richCardBody)
        try importFixtureAttachment(into: richCard)
        try seedCommentThread(into: richCard, cardTitle: cardTitles[richCardIndex.lane][richCardIndex.card])

        // The delete goes last so the trashed card's identity is one the lanes above have already
        // finished with — and through the ordinary delete door, so `.trash/` ends up holding exactly
        // what a user's ⌘⌫ would have put there: the `modified` stamp that positions it, its `order`
        // riding along untouched.
        let doomed = cardURLs[trashedCardIndex.lane][trashedCardIndex.card]
        try BoardWriter.deleteCardToTrash(at: doomed, inBoard: root)

        return root
    }

    /// The performance suite's board — `largeLaneCount` lanes of `largeCardsPerLane` cards, built
    /// through the same three Writer calls the standard board uses and nothing else.
    ///
    /// No rich body, no attachment, no trashed card: every one of those is a *feature* the audit
    /// wanted a specimen of, and this board is not about features. What it is about is quantity, and
    /// quantity is the only thing that differs.
    private static func materializeLargeBoard() throws -> URL {
        let root = fixtureBoardURL(for: .large)
        try BoardWriter.createBoard(at: root, title: FixtureVariant.large.boardTitle)

        for laneIndex in 0 ..< largeLaneCount {
            let laneID = try BoardWriter.createLane(inBoard: root, title: largeLaneTitle(laneIndex))
            let laneURL = root.appendingPathComponent(laneID.rawValue, isDirectory: true)
            for cardIndex in 0 ..< largeCardsPerLane {
                _ = try BoardWriter.createCard(
                    inLane: laneURL,
                    title: largeCardTitle(lane: laneIndex, card: cardIndex)
                )
            }
        }

        return root
    }

    /// The fail-fast suite's board: an ordinary small board, with one card's `index.md` overwritten
    /// by `malformedIndexText` **after** every Writer call has finished.
    ///
    /// The order is the whole design. Building first means the board around the broken file is one
    /// the app made — right frontmatter, right ranks, right stamps — so the load that follows fails
    /// for exactly one reason and the loader's sentence names exactly one file. Overwriting first, or
    /// hand-writing the tree, would have produced a board whose *many* problems the loader would
    /// report whichever it reached first.
    ///
    /// The write is `Data.write`, not `BoardWriter.atomicReplace`: the Writer refuses to produce a
    /// file its own loader would reject, which is a guarantee worth keeping rather than a door worth
    /// opening. This is the one place in the app that goes around it, and it is unreachable without
    /// the launch flag.
    private static func materializeMalformedBoard() throws -> URL {
        let root = fixtureBoardURL(for: .malformed)
        try BoardWriter.createBoard(at: root, title: FixtureVariant.malformed.boardTitle)

        var cardURLs: [[URL]] = []
        for (laneIndex, laneTitle) in malformedLaneTitles.enumerated() {
            let laneID = try BoardWriter.createLane(inBoard: root, title: laneTitle)
            let laneURL = root.appendingPathComponent(laneID.rawValue, isDirectory: true)
            var lane: [URL] = []
            for title in malformedCardTitles[laneIndex] {
                let cardID = try BoardWriter.createCard(inLane: laneURL, title: title)
                lane.append(laneURL.appendingPathComponent(cardID.rawValue, isDirectory: true))
            }
            cardURLs.append(lane)
        }

        let doomed = cardURLs[malformedCardIndex.lane][malformedCardIndex.card]
            .appendingPathComponent(BoardLoader.indexFileName, isDirectory: false)
        try Data(malformedIndexText.utf8).write(to: doomed, options: .atomic)

        return root
    }

    /// Builds the rich card's thread **the way the composer does** — a draft saved, then posted, once
    /// per body — so the fixture's `comments/` is a folder the app made: minted identities, the
    /// `kind: comment` field table, `created`/`modified` restamped at the post.
    ///
    /// Two of the three then need a shape no gesture in the app produces, and each is applied
    /// afterwards, narrowly:
    ///
    /// - **The edit** is an ordinary Writer call (`editComment`), which is exactly what an inline edit
    ///   session's save does — so the edited marker in the fixture is the real mechanism, not a
    ///   hand-set field.
    /// - **The two frontmatter amendments** are raw writes, for the malformed variant's reason (see
    ///   this type's note). Neither shape has a door in the app: it always writes the account's full
    ///   name, and it cannot post a comment an hour ago. Both shapes are ordinary on disk, though —
    ///   agents and tracker sync write comments with no `author` at all (01-storage-format.md ▸
    ///   Enhanced schema), and every comment that has ever been edited was posted before it. Both go
    ///   through `FrontmatterDocument`, so the rest of each file is byte-identical to what the Writer
    ///   produced.
    ///
    /// The backdating is not decoration: `created` and `modified` serialize to the second, and a
    /// comment posted and edited inside one second would render as **not** edited — the marker is
    /// `modified` differing from `created` and no extra field (`Comment.isEdited`).
    private static func seedCommentThread(into cardFolder: URL, cardTitle: String) throws {
        var posted: [ItemID] = []
        for body in commentBodies {
            try BoardWriter.saveCommentDraft(inCard: cardFolder, body: body, cardTitle: cardTitle)
            posted.append(try BoardWriter.postComment(inCard: cardFolder, cardTitle: cardTitle).id)
        }

        try BoardWriter.editComment(
            at: CommentThread.commentFolder(posted[editedCommentIndex], inCard: cardFolder),
            body: editedCommentBody,
            cardTitle: cardTitle
        )

        try amendComment(posted[authorlessCommentIndex], inCard: cardFolder) { document in
            document.remove(FrontmatterKeys.author)
        }
        try amendComment(posted[editedCommentIndex], inCard: cardFolder) { document in
            document.set(FrontmatterKeys.created, to: .date(Date().addingTimeInterval(-3600)))
        }
    }

    /// One comment's frontmatter, amended in place — the fixture's narrow way around the Writer, kept
    /// to one function so both amendments share its round trip and neither invents a second one.
    private static func amendComment(
        _ id: ItemID,
        inCard cardFolder: URL,
        _ amend: (inout FrontmatterDocument) -> Void
    ) throws {
        let indexURL = CommentThread
            .commentFolder(id, inCard: cardFolder)
            .appendingPathComponent(BoardLoader.indexFileName, isDirectory: false)
        var document = try FrontmatterDocument.parse(String(decoding: try Data(contentsOf: indexURL), as: UTF8.self))
        amend(&document)
        try Data(document.serialized().utf8).write(to: indexURL, options: .atomic)
    }

    /// Writes the attachment's source into the scratch root and imports it the way a Finder drop
    /// would (`BoardWriter.importAttachments`), so the card ends up with a real `attachments/`
    /// folder rather than a hand-placed file the loader would have to normalize.
    private static func importFixtureAttachment(into cardFolder: URL) throws {
        let source = scratchRoot.appendingPathComponent(attachmentName, isDirectory: false)
        try attachmentBody.write(to: source, atomically: true, encoding: .utf8)
        _ = try BoardWriter.importAttachments([source], intoCard: cardFolder)
        try? FileManager.default.removeItem(at: source)
    }
}
