import AppKit
import SwiftUI
import os

// MARK: - Fate

/// Where a card window's card is right now: the card itself, and the lane it is currently in.
///
/// **One resolution answers both of the window's questions**, which is why the lane rides along
/// rather than being looked up a second time: the card is what the window renders, and the lane is
/// half of its subtitle ("⟨board⟩ › ⟨lane⟩", live-updating as the card moves — 05-card-window.md
/// ▸ Window). Two walks over the snapshot could disagree about which lane a card is in for exactly
/// one frame, and that frame is the one where the card just moved.
public struct CardPlacement: Equatable {
    public let card: Card
    public let lane: Lane
}

/// What the current snapshot says about a card window: render this card, or go away.
///
/// A named decision rather than a scattering of `if`s, because 05-card-window.md ▸ Deletion &
/// lifecycle and 02-architecture.md § Live-reload resilience state the same rule from two directions
/// and both have to be true of one piece of code. Making it a value also makes it a *pure* function
/// of a snapshot, which is the only way the deleted-lane case gets tested without a window.
public enum CardWindowFate: Equatable {
    case shows(CardPlacement)
    case dismisses
}

// MARK: - The session seam

/// A card window's editor session: the thing the close flush ends, and now the thing it ends *with
/// something in it* — the window's Edit buffer (05-card-window.md ▸ Edit).
///
/// The ordering it exists for is unchanged (see `CardSessionFlushing`): the board's close flush
/// drives `endSession()` for every card window before any of the board's own pending work is
/// flushed, and a card window closed on its own runs it from its disappear. Ending twice does
/// nothing the second time, which is what makes those two paths safe to both exist.
///
/// **What ending means now**: the Edit buffer's debounce is cancelled and its text written — the
/// "window close" third of 05's flush rule, and on a dismissal caused by a delete the surgical body
/// write 05 ▸ Deletion & lifecycle promises ("a dirty Edit buffer flushes into the card's folder at
/// its new `.trash/` location before the window dismisses ... so the keystrokes survive a later
/// restore"). `BoardStore.writeCardBody` resolves trash cards on purpose for exactly this.
///
/// A *failing* close flush is not this object's problem to solve: it is `DirtyBufferGuard`'s modal
/// moment, which the host runs earlier, on `windowShouldClose`, while there is still a window to
/// present over. By the time this runs on a window that is genuinely going away, the honest thing
/// left to do is try.
@MainActor
final class CardWindowSession: CardSessionFlushing {

    /// The window's Edit buffer. Created with the window and handed its save target once the window
    /// has joined its board (`CardWindowHost.start()`); a session with no target keeps its text
    /// rather than pretending to have written it.
    let body: CardBodyEditSession

    /// The window header's title field — `body`'s sibling, driven by the exact same begin/end/flush
    /// calls (`CardTitleEditSession`'s own doc comment). Created here for `body`'s reason: it needs a
    /// save target once the window has joined its board.
    let title = CardTitleEditSession()

    /// **This window's own undo stack** — 13-native-undo.md ▸ Rules' second level (re-ruled
    /// 2026-07-31): "a card window owns its own stack for the session it represents ... and
    /// `window.undoManager` answers with it".
    ///
    /// It lives here for the comments pane's reason exactly: the close owes the board one coarse step
    /// folded from this stack, and a stack held only by the view would be gone by the time the fold
    /// ran. Every window gesture registers into it through the store's own methods, which take it as
    /// a parameter (`CardWindowUndo`).
    let undo = CardWindowUndo()

    /// The window's comments pane — the thread, the composer's draft buffer, and the one open inline
    /// edit session (05-card-window.md ▸ The comments column).
    ///
    /// It lives **here** rather than as another `@State` beside the body's handles, and the close
    /// flush is why: the pane owes the close three things in a fixed order — the inline session's
    /// flush, the draft's save, then the `comments/.trash/` purge — and this object is the one the
    /// coordinator already drives (`CardSessionFlushing`). A pane held only by the view would have its
    /// close work run wherever SwiftUI happened to tear the view down.
    let comments = CardComments()

    /// The close-time save-or-lose moment, over this window's buffer (02-architecture.md §
    /// Write-failure surfacing: "the one modal moment on the write-failure path").
    let bufferGuard: DirtyBufferGuard

    /// The window's raw-source outlet, wired in by the host once the window exists.
    ///
    /// A closure rather than a stored reference, `CardBodyEditSession.save`'s precedent: the outlet
    /// is window state living beside this object (`CardWindowHost.rawSource`) rather than inside it,
    /// and a session that reached into the view's state would be the wrong direction. `nil` — a
    /// window that has not joined its board — holds nothing, which is true.
    var rawSourceHoldsUnsavedText: (@MainActor () -> Bool)?

    /// The window's raw-source outlet, as the save-or-discard step's two writes: **Apply** (which
    /// validates, and answers `false` when it refuses) and **Cancel**. Wired by the host beside
    /// `rawSourceHoldsUnsavedText`, and for its reason — the outlet is window state living beside
    /// this object rather than inside it.
    var rawSourceApply: (@MainActor () -> Bool)?
    var rawSourceCancel: (@MainActor () -> Void)?
    var rawSourceIsActive: (@MainActor () -> Bool)?

    /// **Where the close registers this session as one board step** —
    /// `BoardStore.registerCardSession(_:inCard:retiring:)`, wired by the host for
    /// `CardBodyEditSession.save`'s reason: this object is a lifecycle, and it stays testable by
    /// having no idea what a board is.
    ///
    /// It answers whether the deferred `comments/.trash/` purge now has an owner — see `endSession()`.
    /// `nil` (a window that never joined its board) means nothing was registered, so the purge is this
    /// object's to run, which is also true.
    var registerSessionStep: (@MainActor (CardWindowUndo, @escaping @MainActor () -> Void) -> Bool)?

    /// **Where the close gives up this window's ownership of its card's comment trash** —
    /// `BoardStore.cardWindowDidClose(inCard:)`, wired by the host beside `registerSessionStep`
    /// (13-native-undo.md ▸ Interaction with the trash, ruled 2026-08-06):
    ///
    /// > "an open card window is itself an owner of its card's comment trash — a retirement firing
    /// > while the card's window is open defers its purge to that window's close ... the close then
    /// > settles by the same gate."
    ///
    /// A seam rather than a store reference, `registerSessionStep`'s reason: this object is a
    /// lifecycle and stays testable by having no idea what a board is. `nil` — a window that never
    /// joined its board — owns nothing on any store, which is also true.
    ///
    /// Its **order** is the load-bearing part, and `endSession()` is where it is spelled.
    var endCardWindowOwnership: (@MainActor () -> Void)?

    /// The Edit buffer's dirty text, a typed-in raw-source outlet, or an inline comment edit session
    /// holding keystrokes its file has not got — see `CardSessionFlushing`.
    ///
    /// **The composer's draft is deliberately not counted.** 05-card-window.md ▸ The comments column
    /// gives it the opposite posture from every other buffer in this window — "Close and quit just
    /// proceed — no DirtyBufferGuard, nothing to lose" — because it is a durable file being edited in
    /// place rather than unsaved work. An inline comment edit *is* the ordinary kind, so it counts
    /// exactly as the body's does (`CardComments.holdsUnsavedContent`).
    var holdsUnsavedContent: Bool {
        body.isDirty || title.isDirty || rawSourceHoldsUnsavedText?() == true || comments.holdsUnsavedContent
    }

    /// **What a restore or a branch switch asks this window to settle** (06-history-undo.md ▸ Rules
    /// ▸ Undo restore vs open Edit sessions).
    ///
    /// ### The predicate is *open*, not *dirty*
    ///
    /// An **open** Edit session is what needs settling even with a clean buffer, because its ~700 ms
    /// saves are on disk and deliberately uncommitted — the stage-around rule's whole point — so a
    /// restore landing over them would either bury text no commit protects or leave the session's
    /// next debounced save to write pre-restore bytes back over the restored card, "a ⌘Z that visibly
    /// doesn't happen". Same reading `CardBodyEditSession.isEditing` records for staging, applied to
    /// the same fact.
    ///
    /// An **open raw-source outlet** counts whether or not it has been typed in, and 06 says why: its
    /// Apply "would write the *entire* pre-switch `index.md` byte-for-byte onto the new branch's
    /// card". A buffer read from before the restore is the hazard; typing is not required for it.
    var settlement: CardSessionSettlement? {
        CardSessionSettlement(
            needsSettling: { [self] in
                body.isEditing || body.isDirty || title.isDirty || rawSourceIsActive?() == true
            },
            saveAll: { [self] in
                // The Edit session ends with its normal commit — "each card's Edit→Preview flip" —
                // title alongside body, the same door either way.
                body.endEditSession()
                title.endEditSession()
                // Apply validates; a refusal is the whole operation's cancellation, and the alert it
                // raised is already on the offending window.
                guard rawSourceIsActive?() == true else { return true }
                return rawSourceApply?() ?? true
            },
            discard: { [self] in
                // The buffer goes back to what disk says; the *disk* goes back to the target state as
                // part of the wholesale operation itself, which is the only party that knows what it
                // is reconciling this card's folder towards (`SessionSettleGate`).
                body.discardBuffer()
                title.discardBuffer()
                rawSourceCancel?()
            }
        )
    }

    private var hasEnded = false

    /// Both `let`s, wired to each other through a local — the guard's two closures need the buffer,
    /// and a stored property cannot be referenced from another's initializer.
    ///
    /// (They are also `let` rather than `lazy var` for a SwiftUI reason worth recording: `@State`
    /// projects a `Binding` through dynamic member lookup for every *settable* property of its
    /// value, so a `lazy var` here would make `session.bufferGuard` at the call site resolve to a
    /// binding rather than to the guard.)
    init() {
        let body = CardBodyEditSession()
        self.body = body
        // Already default-initialized (`let title = CardTitleEditSession()`, above): captured into a
        // local here for `body`'s own reason — the closures below must not close over `self`, which
        // would retain this object through `bufferGuard` right back at itself.
        let title = self.title
        bufferGuard = DirtyBufferGuard(
            attemptSave: { () throws(BoardWriteError) -> Void in
                try body.flushOrThrow()
                try title.flushOrThrow()
            },
            // "Save a copy elsewhere" writes the *buffer*, not the card: the destination is
            // somewhere outside the board the user picked in a panel, so what lands there is the
            // text they were typing, as a file, and nothing about frontmatter or identity travels
            // with it. The title rides along with the body here only in spirit — the copy is the
            // Markdown text, unchanged, since the panel's own file name already carries the title
            // (`CardWindowHost.copyDestination`).
            writeCopy: { url in try Data(body.text.utf8).write(to: url) }
        )
    }

    func endSession() async {
        guard !hasEnded else { return }
        hasEnded = true
        // Once the boundary the auto-committer coalesced on too — "one commit per Edit session,
        // never per save tick" (06-history-undo.md ▸ Rules ▸ Auto-commit, retired with app-managed
        // git, `strategy/01-git-excision.md`, 2026-08-08). What is left is native undo's own version
        // of the same idea: the debounced saves inside the session are ordinary bracketed writes, and
        // what makes them one step is that `endEditSession` registers exactly one (13-native-undo.md
        // ▸ Rules) once the session is known to be over. It is also where the body's *last* fine step
        // joins this window's stack, which is why it has to precede the fold below.
        body.endEditSession()
        // The title field's own session, ended the same door — see `CardTitleEditSession`'s doc
        // comment on why there is no session boundary of its own to end.
        title.endEditSession()
        // **The saves, in the order the comments build fixed**: the inline session's flush, then the
        // draft's (`CardComments.endSession`). Both may register their own last fine step, so both
        // land before the fold.
        comments.endSession()
        // **The coarse close step, and the purge it defers** (13-native-undo.md ▸ Rules ▸ "Window
        // close coarsens"; ▸ Interaction with the trash).
        //
        // This is the one place that knows both halves: the window's stack, which is the session's
        // net effect, and the `comments/.trash/` purge, which must not run while a board step's undo
        // still restores comments out of it. Registering answers whether the step took the purge on —
        // and a board whose substrate declines to keep a step (`registerCardSession`'s three `false`
        // cases above) has already run the purge by the time that answer comes back. The seam stays
        // nil-safe on purpose for whatever substrate registers next (`strategy/01-git-excision.md` ▸
        // Reversibility); today the native stack is the only one that ever says yes.
        //
        // **This window stops owning its card's comment trash first** (ruled 2026-08-06 — the
        // open-window carve-out). While the window is open the store defers every purge of this
        // card's trash, because the entries this session deleted are backed by *this* stack's fine
        // steps and the board-stack inventory cannot see them. The close is where that stops being
        // true, and it has to stop being true **before** either branch below runs: a no-step close
        // that unmarked itself afterwards would defer its own purge into a no-op, and a coarse step
        // registered here can retire in the same breath (its registration clears the redo stack).
        endCardWindowOwnership?()
        let purge: @MainActor () -> Void = { [comments] in comments.purgeTrashNow() }
        if registerSessionStep?(undo, purge) != true {
            purge()
        }
    }
}

// MARK: - CardWindowHost

/// One card window (05-card-window.md).
///
/// ### Its whole identity is `(board, card)`
///
/// Which is why this host is mostly a set of dismissal rules. The window follows its card between
/// lanes for free — the key names neither — and it dismisses whenever the key stops naming a card
/// **on the board**: the card moved into `.trash/` ("entering the trash counts as deleted" —
/// 05-card-window.md ▸ Deletion & lifecycle, resettled 2026-07-28), its *lane* was deleted and took
/// it along, or the card is simply not in this board's snapshot any more, which is what a cross-board
/// move looks like from here.
///
/// ### It can never outlive its board window
///
/// "The board window owns the board" (02 § Components) — so a card window whose board has no live
/// store, or whose board session has gone, dismisses immediately rather than becoming an orphan with
/// a store it acquired by itself. That covers the ordinary case (the board window closed and its
/// flush dismissed this one) and the odd one (the system restoring a card window from a previous
/// launch, which scene restoration is disabled precisely to prevent).
///
/// ### It is the window; `CardWindowView` is the content
///
/// This file owns identity, lifecycle, the title and subtitle, and where the window opens. The
/// two-column composition inside it is `CardWindowView`'s, and what fills those columns — the title
/// field, Preview/Edit, the sidebar's four sections — arrives card by card underneath a composition
/// that does not move. The *window-scoped* state those surfaces need lives here, because a window is
/// what it is scoped to: the body column's mode (`CardBodyPresentation`) and the raw-source outlet
/// (`CardRawSourceSession`), both published through the focus system so the View menu's rows can
/// reach the frontmost card window.
struct CardWindowHost: View {

    let ref: CardWindowRef

    @Environment(AppModel.self) private var appModel
    @Environment(\.dismissWindow) private var dismissWindow

    @State private var windowController = HostedWindowController()
    @State private var session = CardWindowSession()
    @State private var phase: Phase = .opening
    /// This window's body column — the Preview/Edit mode, and the ⌘F hook a menu item reaches
    /// through the focus system (`CardBodyPresentation`).
    @State private var bodyPresentation = CardBodyPresentation()
    /// This window's raw-source outlet — the whole-content-area swap View ▸ Raw Source (⌥⌘E) drives
    /// (`CardRawSourceSession`). Beside the body handle rather than inside it: the two are different
    /// scopes, and the Edit Body row reads both.
    @State private var rawSource = CardRawSourceSession()
    /// This window's attachments section — the listing, the keyboard selection, and the two writes
    /// it starts (05-card-window.md ▸ Attachments). Window-scoped for `CardBodyPresentation`'s
    /// reason: two card windows on one board have two different selections, and the menu bar reaches
    /// the frontmost one through the focus system.
    @State private var attachments = CardAttachments()
    /// This window's printable subject — the card, its lane, its board and its thread, as File ▸ Print…
    /// needs them (`CardPrintSubject`). Window-scoped for `CardBodyPresentation`'s reason: two card
    /// windows are two documents, and the menu bar reaches the frontmost one through the focus system.
    @State private var cardPrint = CardPrintSubject()
    /// This window's card-level actions — today, just Delete Card, promoted off the sidebar's former
    /// Actions section to the toolbar (`CardWindowActions`, `CardToolbar`). Window-scoped for
    /// `CardBodyPresentation`'s reason: File ▸ Delete Card (`DeleteCardCommand`) reaches the
    /// frontmost card window through the focus system exactly as Add Attachment… does.
    @State private var cardActions = CardWindowActions()
    /// This window's thumbnail memory. Held here rather than in the section so it survives every
    /// snapshot the store applies — a cache that died with the view would regenerate every thumbnail
    /// on every reload (`AttachmentThumbnailCache`).
    @State private var thumbnails = AttachmentThumbnailCache()
    /// Whether a close is waiting on the dirty-buffer modal. Set when `windowShouldClose` could not
    /// flush; cleared by the resolution that lets the close resume.
    @State private var isClosePending = false
    /// The two app-wide comment bits, read here for one reason only: the window's **minimum size**
    /// depends on them (`minimumSize`). The panes read them again themselves (`CardWindowView`) —
    /// two readers of one `UserDefaults` key, which is what `@AppStorage` is for and is cheaper than
    /// threading the pair through a view that would then have to publish them back up.
    @AppStorage(AppPreferences.showCommentsKey) private var showComments = true
    @AppStorage(AppPreferences.commentsBesideBodyKey) private var commentsBesideBody = true
    /// The sidebar's own visibility bit, read here for `showComments`' one reason: the window's
    /// **minimum size** depends on it too (`minimumSize`). The toolbar item reads
    /// `AppPreferences.showCardSidebar` directly (`CardToolbar`, an AppKit seam with no `@AppStorage`
    /// of its own), and `CardWindowView` reads this same key again itself — three readers of one
    /// `UserDefaults` key, `showComments`' own arrangement.
    @AppStorage(AppPreferences.showCardSidebarKey) private var showSidebar = true

    private enum Phase {
        case opening
        case open(BoardStore)
        case closing
    }

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

    // MARK: - The lifecycle rule

    /// Whether a card window keyed on `cardID` still has a card, given this board's snapshot.
    ///
    /// **One walk over the lanes is the whole rule** (05-card-window.md ▸ Deletion & lifecycle,
    /// resettled 2026-07-28 — the materialized trash): deletion is a *move*, so a trashed card has
    /// physically left its lane and answers `.dismisses` by simply not being found — "entering the
    /// trash counts as deleted", with no liveness flag to read and no ancestor walk to run. A card
    /// whose lane was deleted, one purged outright and one moved to another board all fall out of the
    /// same absence. Only a card in one of this board's lanes keeps its window.
    ///
    /// Takes the id as the ref stores it — a raw folder name — and compares it as an `ItemID`, so two
    /// case-spellings of one UUID are one card here exactly as they are everywhere else.
    static func cardWindowFate(cardID: String, in snapshot: BoardModel) -> CardWindowFate {
        let identity = ItemID(rawValue: cardID)
        for lane in snapshot.lanes {
            guard let card = lane.cards.first(where: { $0.id == identity }) else { continue }
            return .shows(CardPlacement(card: card, lane: lane))
        }
        return .dismisses
    }

    /// The window's subtitle: "⟨board⟩ › ⟨lane⟩" (05-card-window.md ▸ Window).
    ///
    /// Pure, and taking both names as strings, because the *live* half is what matters and is the
    /// easy half to lose: the board name follows a board rename through
    /// `AppModel.displayName(of:)`, and the lane name follows the card between lanes because it is
    /// re-derived from every snapshot rather than captured when the window opened. A window that
    /// kept showing the lane its card was in an hour ago would be wrong in exactly the case the
    /// subtitle exists for.
    ///
    /// Untitled lanes render the same placeholder the board's lane header does — "Untitled" is a
    /// rendering, never a value (03-board-ui.md § Card face).
    static func subtitle(board: String, lane: String?) -> String {
        "\(board) › \(lane ?? "Untitled")"
    }

    // MARK: - View

    var body: some View {
        content
            // Derived, like every other measurement in this window: the minimum is what the two
            // columns need at the current text size, not a number chosen once at 13pt
            // (`CardWindowMetrics`).
            .frame(minWidth: minimumSize.width, minHeight: minimumSize.height)
            .background(WindowAccessor(controller: windowController))
            .navigationTitle(windowTitle)
            // The window follows its card: both of these are re-derived from every snapshot, so a
            // rename retitles the window and a lane move re-subtitles it with no notification of
            // our own (05-card-window.md ▸ Window).
            .navigationSubtitle(windowSubtitle)
            // Edit ▸ Find (⌘F) is find-in-text in a card window (11-command-nexus.md) — the menu
            // item reaches the frontmost one's body surface through this, exactly as board-window
            // items reach their window's store (`FocusedBoardStoreKey`).
            .focusedSceneValue(\.cardBody, bodyPresentation)
            // **This window's own stack, published for Edit ▸ Undo/Redo** — the second of 13's two
            // levels reaching the menu (13-native-undo.md ▸ Rules ▸ two levels; ▸ the
            // command-surface bullet, re-ruled 2026-08-08). One key serves both levels because a
            // card window's manager is the same face type as a board session's, so which of them a
            // row crosses is decided by which window is in front and by nothing else — no
            // fall-through, as a property of the value rather than a rule (`UndoCommands.swift`).
            .focusedSceneValue(\.undoStack, session.undo.manager)
            // View ▸ Raw Source (⌥⌘E) reaches the frontmost card window the same way, and Edit Body
            // reads it too — "View ▸ Edit Body (⌘E) disables while source mode is active"
            // (05-card-window.md ▸ Raw source outlet).
            .focusedSceneValue(\.cardRawSource, rawSource)
            // File ▸ Add Attachment… (⇧⌘A) and File ▸ Reveal in Finder's card-window scope reach the
            // frontmost card window the same way (11-command-nexus.md).
            .focusedSceneValue(\.cardAttachments, attachments)
            // File ▸ Add Comment and the two View-menu comment toggles reach the frontmost card
            // window the same way — the toggles read their own persisted bits and use this only to
            // know a card window is in front at all (11-command-nexus.md scopes all three to the card
            // window).
            .focusedSceneValue(\.cardComments, session.comments)
            // File ▸ Print… (⌘P) reaches the frontmost card window the same way — the card-window scope
            // of a row the board window answers with its whole board (11-command-nexus.md).
            .focusedSceneValue(\.cardPrint, cardPrint)
            // File ▸ Delete Card reaches the frontmost card window the same way — the toolbar's own
            // Delete Card item reads this same handle directly, wired at install time rather than
            // through the focus system (`configureWindow`).
            .focusedSceneValue(\.cardWindowActions, cardActions)
            // The raw-source outlet's detailed alert, presented over this window — a validation
            // refusal on Apply, or a file that could not be opened as source. It hangs *here* rather
            // than inside the editor because the second of those fires while source mode is still
            // closed, when there is no editor on screen to present it from.
            .alert(
                rawSource.alert?.title ?? "",
                isPresented: Binding(
                    get: { rawSource.alert != nil },
                    set: { presented in
                        guard !presented else { return }
                        rawSource.dismissAlert()
                    }
                ),
                presenting: rawSource.alert
            ) { _ in
                // One button, because there is one thing to do: OK returns to the text, which is
                // exactly where it was. Nothing was written, so there is nothing to retry or discard.
                Button("OK") { rawSource.dismissAlert() }
            } message: { alert in
                Text(alert.message)
            }
            // The one modal moment (02-architecture.md § Write-failure surfacing), presented over
            // the window whose close it is holding up — which is why it hangs here and not on the
            // board: the text being saved is this window's.
            .dirtyBufferAlert(session.bufferGuard) { Self.copyDestination(named: windowTitle) }
            .task { start() }
            .onChange(of: shouldDismiss, initial: true) { _, dismisses in
                guard dismisses else { return }
                dismissWindow(id: WindowID.card, value: ref)
            }
            // The modal resolved — by a retry that landed, a copy saved elsewhere, or a knowing
            // discard — so the close it was holding may finish. `DirtyBufferGuard` has no fourth
            // "leave it open" branch by design, so reaching `.idle` always means the close resumes.
            .onChange(of: session.bufferGuard.phase) { _, phase in
                guard isClosePending, phase == .idle else { return }
                isClosePending = false
                windowController.closeAfterFlush()
            }
            // **The toolbar's Show Sidebar item, nudged fresh** — `WindowToolbarController`'s own
            // observation tracking only sees `@Observable` reads (its header explains why: menu
            // commands and AppKit have no `@Environment` to poll), and `AppPreferences.showCardSidebar`
            // is a plain `UserDefaults` read, not one. The toolbar's own click already revalidates
            // itself (`toggleFired`); this is the same nudge for the View-menu row's write, which is
            // the only other place this bit changes.
            .onChange(of: showSidebar) { _, _ in windowController.revalidateToolbar() }
            .onDisappear { finish() }
    }

    /// **The minimum grows only while the comments pane is beside the body, and shrinks while the
    /// sidebar is hidden** (05-card-window.md ▸ Composition) — the stacked mount's own reason, and
    /// View ▸ Show Sidebar's mirror of it: a window with neither costs the width of neither.
    private var minimumSize: CGSize {
        CardWindowMetrics.minimumSize(
            bodyPointSize: CardWindowMetrics.bodyPointSize,
            sidebar: showSidebar,
            commentsColumn: showComments && commentsBesideBody
        )
    }

    @ViewBuilder
    private var content: some View {
        if case let .open(store) = phase, let placement {
            CardWindowView(
                card: placement.card,
                store: store,
                // The app's list, not the board's: the quick-style recents the sidebar's embedded
                // editor feeds are app-wide state (02-architecture.md § Per-board app state), so
                // they come from the model every window shares rather than from this board's store.
                recents: appModel.styleRecents,
                // The same argument one field over: the labels this user reached for lately are
                // app-wide state (`LabelRecents`), so they come from the model every window shares.
                labelRecents: appModel.labelRecents,
                cardFolder: Self.cardFolder(root: store.rootURL, placement: placement),
                bodyPresentation: bodyPresentation,
                bodySession: session.body,
                titleSession: session.title,
                rawSource: rawSource,
                // "Under the read-only lock the controls disable in place — an in-content mutation
                // menu validation can't reach" (05 ▸ Preview). The checkbox is that control, and
                // the store's own lock is the whole predicate — and it is the attachments section's
                // predicate too ("the attachment row's ⌫/Remove shares the posture").
                isEditable: !store.isReadOnly,
                attachments: attachments,
                comments: session.comments,
                thumbnails: thumbnails,
                undo: session.undo,
                fileDrop: CardWindowDropDelegate(store: store, cardID: placement.card.id),
                onToggleTask: { offset, checked in
                    store.toggleTaskMarker(inCard: placement.card.id, bodyOffset: offset, checked: checked)
                }
            )
            // **The listing is the snapshot's, republished** — `Card.attachments`, which the loader
            // fills from `attachments/`'s top-level files in Finder order. Every write in the
            // section is bracketed, so the reload that refreshes this arrives by itself and the
            // section never lists a directory of its own (05 ▸ Attachments; the one-way flow).
            .onChange(of: placement.card.attachments, initial: true) { _, names in
                attachments.names = names
            }
            // **The hero is the snapshot's too**, and republished the same way, so the row that
            // offers "Remove Hero" is the row the card face is actually banding from (05 ▸
            // Attachments; 03-board-ui.md § Card face ▸ Hero image). A malformed key reads as no
            // hero, which is exactly what the face draws.
            .onChange(of: placement.card.hero.value, initial: true) { _, hero in
                attachments.hero = hero
            }
            // **⌘V in this window pastes a Finder-copied file onto this card** (04-interactions.md ▸
            // Clipboard's file-URL branch; the image-data branch retired 2026-08-09 for the
            // attachments header's paste-image affordance, wired below through `configureAttachments`).
            // Here rather than inside `CardWindowView` because the availability is the clipboard's
            // observable reading and this is where a store, a card id and the app-wide clipboard are
            // all in scope at once — the same join `configureAttachments` makes for the other writes.
            .cardWindowPaste(
                store: store,
                cardID: placement.card.id,
                clipboard: appModel.clipboard
            )
            .onChange(of: Self.cardFolder(root: store.rootURL, placement: placement), initial: true) { _, folder in
                // Re-derived from the store's *current* root, `cardFolder`'s rule: a mid-session
                // folder rename moves the board, and rows resolving against where it used to be
                // would open nothing.
                attachments.cardFolder = folder
                session.comments.cardFolder = folder
                cardPrint.cardFolder = folder
            }
            // **File ▸ Print…'s subject, re-derived from every snapshot** for the folder's and the
            // announcer's reason: a card renamed, restyled, relabelled or moved to another lane prints as
            // it is now (`CardPrintSubject`). The thread is a closure rather than a value, so ⌘P reads the
            // comments at the moment it is pressed rather than whatever the pane last saw.
            .onChange(of: placement.card, initial: true) { _, card in
                cardPrint.card = card
                cardPrint.readThread = { store.commentThread(inCard: card.id) }
            }
            .onChange(of: placement.lane.title.value, initial: true) { _, title in
                cardPrint.laneTitle = title
            }
            .onChange(of: AppModel.displayName(of: store), initial: true) { _, title in
                cardPrint.boardTitle = title
            }
            // The announcer's subject, re-derived from every snapshot for the folder's reason: a card
            // renamed mid-session is announced under its new name ("New comment on '⟨card⟩'").
            .onChange(of: placement.card.title.value, initial: true) { _, title in
                session.comments.cardTitle = title
            }
            .onChange(of: store.isReadOnly, initial: true) { _, locked in
                attachments.isEditable = !locked
                session.comments.isEditable = !locked
                // Delete Card's own read-only gate — the sidebar Actions button's `.disabled(store
                // .isReadOnly)`, carried over verbatim to its toolbar and menu-row twins.
                cardActions.isDeletable = !locked
            }
            // **The thread re-reads on every landed reload** (05 ▸ The comments column: "the pane
            // reloads its thread from the same FSEvents stream").
            //
            // *Any* reload, not a filtered one, and that is a deliberate choice worth stating: the
            // store's observable surface publishes counters and a `BoardModel` — it does not vend the
            // changed paths, and comments are outside the snapshot entirely (01-storage-format.md
            // § Enhanced schema), so there is nothing to filter *on* here.
            //
            // **`landedReloads`, not `snapshotGeneration`**, and for that same sentence's reason: a
            // comment arriving changes the tree and leaves the model value-equal, and a value-equal
            // landing skips the snapshot assignment (blessed 2026-07-31). Watching the applied counter
            // would mean the one kind of change this pane exists to notice is the one kind it would
            // sleep through.
            // Re-reading one card's thread is a handful of small files and happens only while a card
            // window is open; filtering would mean either widening the store's surface to carry paths,
            // or the pane keeping its own watcher — a second stream over the same tree, which the
            // one-way flow rules out. The path shape is read on the other side of the re-read instead,
            // where there *are* two pictures to compare: the pane diffs its threads and consumes the
            // ledger's comment receipts through `CommentPath.classify` to tell a foreign arrival from
            // its own echo (`CardComments.reload`). `initial:` is deliberately absent: `start()`
            // already did the opening read, after the residue sweep that has to precede it.
            .onChange(of: store.landedReloads) { _, _ in
                session.comments.reload()
            }
        } else {
            // Nothing to render and nothing worth animating: this window is on its way out.
            Color.clear
        }
    }

    /// `<root>/<lane>/<card>` — the card's own folder, which is what its body's relative images and
    /// links resolve against (05-card-window.md ▸ Preview).
    ///
    /// Built off the store's *current* `rootURL` rather than the ref's captured one, for
    /// `BoardStore.boardItem`'s reason: a mid-session folder rename moves the board, and a preview
    /// resolving images against where the board used to be would quietly stop showing them.
    static func cardFolder(root: URL, placement: CardPlacement) -> URL {
        root
            .appendingPathComponent(placement.lane.id.rawValue, isDirectory: true)
            .appendingPathComponent(placement.card.id.rawValue, isDirectory: true)
    }

    /// Where this window's card is in this board's snapshot, or `nil` when it is not — which is the
    /// same condition `shouldDismiss` reads, one moment before the window goes.
    private var placement: CardPlacement? {
        guard case let .open(store) = phase,
              case let .shows(placement) = Self.cardWindowFate(cardID: ref.cardID, in: store.snapshot)
        else { return nil }
        return placement
    }

    /// The card's title, with the face's own untitled rendering — a card with no `title` key is
    /// "Untitled" in the title bar and the Window menu, never a blank strip.
    private var windowTitle: String {
        guard let placement else { return "" }
        return placement.card.title.value ?? "Untitled"
    }

    private var windowSubtitle: String {
        guard case let .open(store) = phase, let placement else { return "" }
        return Self.subtitle(
            board: AppModel.displayName(of: store),
            lane: placement.lane.title.value
        )
    }

    /// The dismissal decision, re-evaluated on every snapshot the store applies.
    ///
    /// Two clauses, and the second is the safety net: the board's session vanishing means the board
    /// window has finished tearing down, and a card window still on screen at that point has nothing
    /// behind it. It is deliberately redundant with the close flush, which dismisses these windows
    /// itself — a net is only useful when the thing it backs up has already failed.
    private var shouldDismiss: Bool {
        guard case let .open(store) = phase else { return false }
        guard appModel.session(for: ref.board) != nil else { return true }
        return Self.cardWindowFate(cardID: ref.cardID, in: store.snapshot) == .dismisses
    }

    // MARK: - Opening

    /// Joins the board's session, or dismisses.
    ///
    /// **`liveStore(for:)` first, and no fallback to `acquire` on a closed board.** A card window
    /// never opens a board: doing so would put a store — and a watcher — behind a window that,
    /// by 02's ownership rule, is not allowed to exist. The `acquire` below can only hit the
    /// already-open path, which is why its failure is logged rather than surfaced.
    private func start() {
        guard case .opening = phase else { return }

        guard appModel.storeRegistry.liveStore(for: ref.boardURL) != nil else {
            Self.logger.debug("card window has no live board — dismissing")
            phase = .closing
            dismissWindow(id: WindowID.card, value: ref)
            return
        }

        let store: BoardStore
        do throws(BoardLoadFailure) {
            store = try appModel.storeRegistry.acquire(ref.boardURL)
        } catch {
            Self.logger.error("card window could not acquire its board: \(error.description, privacy: .public)")
            phase = .closing
            dismissWindow(id: WindowID.card, value: ref)
            return
        }

        appModel.registerCardWindow(ref, session: session)
        phase = .open(store)
        configureSession(store: store)
        configureWindow()
        openCommentThread(store: store)
    }

    /// **The card window's open, comment-side** — the crash-residue sweep, then the thread read
    /// (01-storage-format.md § Enhanced schema: "crash residue sweeps at the next card-window open,
    /// armed-then-cleared like every heal memo").
    ///
    /// It runs from `start()` rather than from a `.task` on the pane, and the reason is the pane's
    /// own visibility: Show Comments off means no pane at all, and the residue of a session that died
    /// mid-delete must still be swept — it is the app's leftovers, not a feature of the pane. The
    /// same goes for the close purge, which rides the session's end for the same reason.
    ///
    /// The pane's two window-scoped facts are set *before* the read, because both of them are things
    /// the read's results are resolved against: the folder every comment's attachments hang off, and
    /// whether the lock is on.
    ///
    /// **And the window takes ownership of its card's comment trash here** (13-native-undo.md ▸
    /// Interaction with the trash, ruled 2026-08-06): "an open card window is itself an owner of its
    /// card's comment trash". From this line until `CardWindowSession.endSession` gives it back, a
    /// purge of this card's trash — a foreign step's retirement, another window's close — defers
    /// entirely, because the fine steps backing this session's deletes live on a stack the board's
    /// inventory cannot see. The sweep below is unaffected and runs as it always has: it is gated on
    /// `backedContent`, and a window that has not made a gesture yet holds nothing that gate is
    /// missing.
    private func openCommentThread(store: BoardStore) {
        store.cardWindowDidOpen(inCard: ref.cardIdentity)
        if case let .shows(placement) = Self.cardWindowFate(cardID: ref.cardID, in: store.snapshot) {
            session.comments.cardFolder = Self.cardFolder(root: store.rootURL, placement: placement)
        }
        session.comments.isEditable = !store.isReadOnly
        session.comments.open()
    }

    /// Points this window's Edit buffer at its card, and the mode flip at the buffer.
    ///
    /// Both are seams the two types deliberately leave open (`CardBodyEditSession.save`,
    /// `CardBodyPresentation.flushEdits`) so that neither the buffer nor the mode has to know what a
    /// board is — this is the one place that knows both, which is also the only place that could
    /// wire them wrongly, and it is four lines long.
    ///
    /// The store is captured **weakly**: it outlives this window by refcount, not by ownership, and a
    /// debounced save that fired after the board had gone should write nothing rather than resurrect
    /// a store the registry has released.
    private func configureSession(store: BoardStore) {
        let cardID = ref.cardIdentity
        session.body.save = { [weak store] text in
            guard let store else { return .vanished }
            return store.writeCardBody(inCard: cardID, body: text)
        }
        session.title.save = { [weak store] title in
            guard let store else { return .vanished }
            return store.commitCardTitle(inCard: cardID, title: title)
        }
        Self.configureUndo(session, store: store, cardID: cardID)
        bodyPresentation.flushEdits = { [session] in
            session.body.endEditSession()
            session.title.endEditSession()
        }
        bodyPresentation.beginEdits = { [session] in
            session.body.beginEditSession()
            session.title.beginEditSession()
        }
        // **No stage-around wire here any more** (06-history-undo.md ▸ Rules ▸ Auto-commit, widened
        // 2026-07-31 — recorded because its absence is the change): the Edit→Preview flip used to open
        // and close the committer's exclusion, and the unit is now the *window*, so the exclusion is
        // opened by `AppModel.registerCardWindow` and released by `unregisterCardWindow` — after the
        // session's own last writes. A flip that still moved it would un-hold the folder in the middle
        // of a session whose comment posts and draft saves are supposed to be inside one commit.
        Self.configureRawSource(
            rawSource,
            body: session.body,
            title: session.title,
            presentation: bodyPresentation,
            store: store,
            cardID: cardID
        )
        // The other half of the outlet's wiring: the session answers for this window's unsaved
        // content, and the outlet is the half that does not live inside it (`CardWindowSession`).
        session.rawSourceHoldsUnsavedText = { [rawSource] in rawSource.holdsUnsavedText }
        // The save-or-discard step's half of the same wiring (06-history-undo.md ▸ Branch switching):
        // Save All *applies* an open outlet — validation included, so a refusal cancels the whole
        // operation — and Discard leaves it without writing.
        session.rawSourceIsActive = { [rawSource] in rawSource.isActive }
        session.rawSourceApply = { [rawSource] in rawSource.applyAndLeave() }
        session.rawSourceCancel = { [rawSource] in rawSource.cancel() }
        Self.configureAttachments(
            attachments, store: store, cardID: cardID, undo: session.undo, clipboard: appModel.clipboard
        )
        Self.configureComments(session.comments, store: store, cardID: cardID, on: session.undo)
        Self.configureActions(cardActions, store: store, cardID: cardID)
    }

    /// Points this window's card-level actions at its card — **the one place File ▸ Delete Card and
    /// the toolbar's Delete Card item learn which card they act on**, `configureAttachments`'s pattern
    /// applied to the seam Actions ▸ Delete left behind when the sidebar section retired.
    ///
    /// The store is captured **weakly**, `configureAttachments`'s own reason: a toolbar click or menu
    /// row still firing after the board window has gone should write nothing rather than resurrect a
    /// released store.
    ///
    /// `static`, and taking every collaborator as a parameter, for `configureAttachments`'s reason:
    /// which card a gesture lands on is invisible in a running window until it is wrong, and this
    /// shape is what lets a test drive the real wiring rather than a re-typed copy of it.
    static func configureActions(_ actions: CardWindowActions, store: BoardStore, cardID: ItemID) {
        actions.deleteCard = { [weak store] in
            store?.deleteCard(cardID)
        }
    }

    /// Points this window's session at **its own undo stack** — the three seams the two-level model
    /// is made of (13-native-undo.md ▸ Rules, re-ruled 2026-07-31).
    ///
    /// 1. the body Edit session's one step registers on *this window's* stack, not the board's;
    /// 2. the window's Undo/Redo disable under the board's read-only lock, and the stack survives it;
    /// 3. the close folds the window's stack into one coarse board step, which then owes the deferred
    ///    `comments/.trash/` purge.
    ///
    /// The store is captured **weakly**, `configureSession`'s rule: a session ending after the board
    /// window has gone registers nothing rather than resurrecting a released store — and a window with
    /// no board keeps the purge itself, which is what the `false` says.
    ///
    /// `static`, and taking every collaborator as a parameter, for `configureComments`' reason: which
    /// stack a gesture lands on is invisible in a running window until it is wrong, and this shape is
    /// what lets a test drive the real wiring rather than a re-typed copy of it.
    static func configureUndo(_ session: CardWindowSession, store: BoardStore, cardID: ItemID) {
        session.body.registerUndo = { [weak store, undo = session.undo] priorBody, newBody in
            store?.registerBodyEdit(inCard: cardID, priorBody: priorBody, newBody: newBody, on: undo)
        }
        session.title.registerUndo = { [weak store, undo = session.undo] priorTitle, newTitle in
            store?.registerTitleEdit(inCard: cardID, priorTitle: priorTitle, newTitle: newTitle, on: undo)
        }
        session.undo.isReadOnly = { [weak store] in store?.isReadOnly ?? false }
        session.registerSessionStep = { [weak store] undo, purge in
            store?.registerCardSession(undo, inCard: cardID, retiring: purge) ?? false
        }
        // The close half of the open-window carve-out (13 ▸ Interaction with the trash, ruled
        // 2026-08-06). Its open half is `CardWindowHost.openCommentThread`, beside the residue sweep;
        // the pair has to be wired from the two places the window already calls into the store, and
        // these are they. A released store owns nothing to give up, which is what `weak` says.
        session.endCardWindowOwnership = { [weak store] in store?.cardWindowDidClose(inCard: cardID) }
    }

    /// Points the comments pane at its card — **the one place every comment gesture learns which card
    /// it acts on** (05-card-window.md ▸ The comments column).
    ///
    /// Every seam is one of the store's own bracketed methods, unchanged, which is the same rule the
    /// attachments section keeps: there is deliberately no comment write of this window's own to keep
    /// in step with the store's, so a post made here and a post made by anything else take one path —
    /// one bracket, one undo step, one commit shape.
    ///
    /// The store is captured **weakly**, `configureSession`'s rule: a save still landing after the
    /// board window has gone should write nothing rather than resurrect a released store. A `nil`
    /// store answers what a vanished card answers — an empty thread, a save that did not land, a
    /// delete that did not happen — which is exactly what the pane's own guards expect.
    ///
    /// `static`, and taking every collaborator as a parameter, for `configureRawSource`'s reason: the
    /// target resolution is invisible in a running window until it is wrong, and this shape is what
    /// lets a test drive the real wiring rather than a re-typed copy of it.
    ///
    /// - Parameter undo: **this window's stack** — where every comment gesture's fine step lands
    ///   (13-native-undo.md ▸ Rules ▸ two levels). Not optional and not defaulted: a comments pane
    ///   only ever exists inside a card window, so a call with no window would be a call with no
    ///   answer to which stack it meant.
    static func configureComments(
        _ comments: CardComments,
        store: BoardStore,
        cardID: ItemID,
        on undo: CardWindowUndo
    ) {
        comments.readThread = { [weak store] in store?.commentThread(inCard: cardID) ?? .empty }
        comments.readDraft = { [weak store] in store?.commentDraft(inCard: cardID) }
        comments.sweepTrashResidue = { [weak store] in store?.sweepCommentTrashResidue(inCard: cardID) }
        comments.purgeTrash = { [weak store] in store?.purgeCommentTrash(inCard: cardID) }
        // Detection is the thread read's, the repair is the store's batch, and the notice is the
        // banner surface's — "the relocation-style warning-tone notice names the repair". This
        // closure is only the join, which is why it is three lines and lives here rather than on
        // either side of it.
        comments.displaceSquatters = { [weak store] squatters in
            guard let store else { return }
            store.banners.postDisplacedClaimedNames(store.displaceCommentClaimedNames(squatters))
        }
        comments.deleteComment = { [weak store] id in
            store?.deleteComment(id, inCard: cardID, on: undo) ?? false
        }
        comments.editComment = { [weak store] id, body in
            store?.editComment(id, inCard: cardID, body: body) ?? false
        }
        comments.registerCommentEdit = { [weak store] id, prior, new in
            store?.registerCommentEdit(id, inCard: cardID, priorBody: prior, newBody: new, on: undo)
        }
        comments.importAttachments = { [weak store] urls, target in
            store?.importCommentAttachments(urls, inCard: cardID, target: target)
        }
        comments.removeAttachment = { [weak store] name, target in
            store?.removeCommentAttachment(named: name, inCard: cardID, target: target)
        }
        comments.composer.save = { [weak store] text in
            store?.saveCommentDraft(inCard: cardID, body: text)
        }
        comments.composer.post = { [weak store] in
            store?.postComment(inCard: cardID, on: undo)
        }
        // The announcer's gate: which of this thread's changes the app itself wrote, consumed once per
        // reload (10-accessibility.md — "app-mediated echoes never announce", per comment). A store
        // that has gone vouches for nothing, which is the conservative direction and also the one the
        // announcement cannot reach anyway — a released store has no window left to speak in.
        comments.vouchedComments = { [weak store] in
            store?.vouchedComments(inCard: cardID) ?? []
        }
    }

    /// Points the attachments section at its card — **the one place Add Attachment…, Remove and the
    /// paste-image affordance learn which card they act on** (05-card-window.md ▸ Attachments).
    ///
    /// Every seam is the store's or the clipboard's own bracketed method, unchanged:
    /// `importAttachments(_:toCard:)` is the *same* call the board window's Finder drop makes, so a
    /// file added through ⇧⌘A, through the header's plus, through a drop anywhere in this window, and
    /// through a drop on the card's face on the board all take one path — one collision rename, one
    /// set of banners, one commit shape. `pasteImage`/`canPasteImage` are `ClipboardStore
    /// .pasteImage(intoCard:in:)`/`.canPasteImage(intoCard:in:)` the same way — the exact seam the
    /// retired ⌘V image branch used, and the board's "Paste Image into Card" context-menu row still
    /// uses (`CardFaceView`). There is deliberately no card-window import or paste of its own to keep
    /// in step with either.
    ///
    /// The store and the clipboard are captured **weakly**, `configureSession`'s rule: a panel or a
    /// paste still running after the board window has gone should write nothing rather than
    /// resurrect a released store — and the clipboard is app-wide and always outlives any one window
    /// in practice, but the same discipline costs nothing to apply uniformly.
    ///
    /// `static`, and taking every collaborator as a parameter, for `configureRawSource`'s reason:
    /// the target resolution is invisible in a running window until it is wrong, and this shape is
    /// what lets a test drive the real wiring rather than a re-typed copy of it.
    static func configureAttachments(
        _ attachments: CardAttachments,
        store: BoardStore,
        cardID: ItemID,
        undo: CardWindowUndo,
        clipboard: ClipboardStore
    ) {
        attachments.importFiles = { [weak store] urls in
            store?.importAttachments(urls, toCard: cardID)
        }
        attachments.removeFile = { [weak store] name in
            store?.removeAttachment(named: name, fromCard: cardID)
        }
        // **The window's own stack**, like every other gesture issued in this window
        // (13-native-undo.md ▸ Rules ▸ two levels): Set as Hero is a restyle, and a restyle made here
        // folds into this session's close step rather than landing on the board's.
        attachments.setHeroFile = { [weak store] name in
            store?.setHero(name, onCard: cardID, on: undo)
        }
        // **The retired ⌘V image branch's seam, now the header's control's** (04-interactions.md ▸
        // Clipboard, re-ruled 2026-08-09). No undo step here either, `setHeroFile`'s neighbor and an
        // import's own reason: an attachment arrival is not on the stack (13-native-undo.md ▸ Out of
        // scope).
        attachments.pasteImage = { [weak store, weak clipboard] in
            guard let store, let clipboard else { return }
            clipboard.pasteImage(intoCard: cardID, in: store)
        }
        attachments.canPasteImage = { [weak store, weak clipboard] in
            guard let store, let clipboard else { return false }
            return CardPasteImageAffordance.isVisible(clipboard: clipboard, cardID: cardID, in: store)
        }
    }

    /// Points the raw-source outlet at its card — the outlet's three seams (05-card-window.md ▸ Raw
    /// source outlet), wired in the one place that knows both a buffer and a board.
    ///
    /// **The flush is the Preview flip**, not a second mechanism: "Entering source mode flushes any
    /// pending title/body edits first" and "Leaving Edit flushes the debounce (mode flip, raw-source
    /// entry, window close)" are the same sentence read from two directions, so putting the entry
    /// through `setMode(.preview)` makes the flush structural — and settles the exit state at the same
    /// time, because a window that genuinely left Edit on the way in has Preview waiting for it on the
    /// way out (`CardRawSourceSession`). The unconditional `flush()` behind it costs nothing on a
    /// clean buffer and covers the case where the mode was already Preview with a save still owed (a
    /// tick suspended under the read-only lock, say).
    ///
    /// The store is captured **weakly**, `configureSession`'s rule: an outlet still holding a closure
    /// after the board window has gone should write nothing rather than resurrect a released store.
    ///
    /// `static`, and taking every collaborator as a parameter, for the reason the fate and subtitle
    /// rules are: the ordering above is the whole of "flush, *then* read fresh", it is invisible in a
    /// running window until it is wrong, and this shape is what lets a test drive the real wiring
    /// rather than a re-typed copy of it.
    static func configureRawSource(
        _ rawSource: CardRawSourceSession,
        body: CardBodyEditSession,
        title: CardTitleEditSession,
        presentation: CardBodyPresentation,
        store: BoardStore,
        cardID: ItemID
    ) {
        rawSource.flushPendingEdits = { [body, title, presentation] in
            presentation.setMode(.preview)
            body.flush()
            title.flush()
        }
        rawSource.read = { [weak store] in
            guard let store else { return .vanished }
            return store.readCardSource(inCard: cardID)
        }
        rawSource.apply = { [weak store] text in
            guard let store else { return .vanished }
            return store.applyCardSource(inCard: cardID, text: text)
        }
    }

    /// Size and placement — **the remembered frame first, the cascade second** (05-card-window.md
    /// ▸ Window: "New windows open at the last-used card-window size, cascaded; frames restore per
    /// card across relaunch where state restoration allows").
    ///
    /// Two memories, and they answer different questions:
    ///
    /// - **The last-used size is app-wide.** 02 § Per-board app state files "the last-used
    ///   card-window size" under App-wide state explicitly, so it lives in `AppPreferences` and a
    ///   card window opened on any board inherits the size of the last one the user sized.
    /// - **The frame is per card**, and lives on the board's registry record beside the board
    ///   window's own frame — same home, same identity-keyed record, same files-first rule (nothing
    ///   app-private is ever written into the board folder). "Where state restoration allows" is
    ///   this: AppKit's scene restoration is disabled app-wide on purpose (`KanbanApp`), so a card
    ///   window does not come back by itself at relaunch — but the *next* time the user opens that
    ///   card, it opens where they left it.
    ///
    /// A card with a remembered frame therefore does **not** cascade: a cascade over a deliberate
    /// placement would move a window the user had already put somewhere. Only the windows with
    /// nothing remembered take the running cascade point, which is what keeps a burst of freshly
    /// opened cards from landing on top of each other.
    private func configureWindow() {
        // Read once here rather than per callback: this window's board has a session by now (the
        // caller just registered against it), and the record id is what both memories are keyed on.
        let recordID = appModel.session(for: ref.board)?.recordID

        // The window's customizable toolbar — Edit Body · Raw Source · Add Attachment, "the
        // window's three committed functions" (03-board-ui.md ▸ Toolbar; 05-card-window.md ▸
        // Window), joined by Show Sidebar and, since the sidebar's Actions section retired, Delete
        // Card and Reveal in Finder (below). It carries the four window-scoped handles above rather
        // than a store, which is why it is installed here and not at attach: those are this window's,
        // and so is it. Show Sidebar needs none of them — its read and write default to
        // `AppPreferences.showCardSidebar` / `.setShowCardSidebar`, the same app-wide bit every card
        // window answers to, so this call leaves the two injectable parameters unnamed.
        windowController.installToolbar(
            CardToolbar.controller(
                body: bodyPresentation,
                rawSource: rawSource,
                attachments: attachments,
                actions: cardActions
            )
        )

        // **No title in the title bar** — the card's name is shown as part of the card's body
        // instead (the large-title text at the top of the body column, `bodyColumn`), so the chrome
        // does not say it twice. `window.title` itself is untouched — `.navigationTitle(windowTitle)`
        // on this view still sets it every time the card renames or a new card's window opens — so
        // the Window menu, Mission Control/Exposé, VoiceOver and state restoration all keep naming
        // this window correctly; only the title *bar's* rendering of that string is suppressed
        // (`HostedWindowController.hideTitle`). Board windows call the same thing now, for the same
        // reason, once their board-popover widget has a name of its own to say
        // (`BoardWindowHost.configureWindow`) — only the restore-bootstrap window still keeps
        // AppKit's `.visible` default.
        windowController.hideTitle()

        // **This window's own stack** (13-native-undo.md ▸ Rules ▸ two levels, re-ruled 2026-07-31 —
        // superseding the shared-stack wiring): "a card window owns its own stack for the session it
        // represents". ⌘Z with this window in front walks the gestures made *here*, newest first,
        // and when they run out it beeps — "no fall-through: exhausting the window's stack ... never
        // reaches board history" (13 ▸ Undo routing). What board history gets is the one coarse step
        // this session registers when the window closes.
        //
        // **The Edit menu does not arrive through here**, which is what the same rule's 2026-08-08
        // re-ruling changed: `window.undoManager` is latched empty by SwiftUI before this
        // controller's delegate installs, so the rows read this same manager through
        // `FocusedValues.undoStack` instead (published in `body` above; `UndoCommands.swift`). The
        // closure stays wired for AppKit's own asks, which is the one caller it always had a right
        // to.
        //
        // The card's *text* surfaces are untouched by either wiring — the body editor and the
        // raw-source editor each vend their own manager to the responder chain, and the rows route
        // to the first responder's manager while one of them holds the keyboard, which is what keeps
        // typing undo above either stack (13 ▸ Undo routing, unchanged).
        windowController.windowUndoManager = { [session] in session.undo.manager }

        windowController.onAttach = { window in
            if let recordID,
               let saved = appModel.boardRegistry.cardWindowFrame(id: recordID, cardID: ref.cardIdentity) {
                // Repositioned onto a live screen when the saved one is gone — the board window's
                // own rule, shared rather than restated (`HostedWindowController.placement`).
                window.setFrame(HostedWindowController.placementOnCurrentScreens(for: saved), display: true)
                return
            }
            if let size = AppPreferences.lastCardWindowSize {
                window.setContentSize(size)
            }
            // `cascadeTopLeft(from:)` both places this window and returns the origin for the next
            // one, so the running point is the whole cascade.
            appModel.cardCascadePoint = window.cascadeTopLeft(from: appModel.cardCascadePoint)
        }
        if let window = windowController.window {
            windowController.onAttach?(window)
        }

        // **The close flushes first** (05-card-window.md ▸ Edit: "flushed on leaving Edit, entering
        // source mode, and window close"). Intercepting `windowShouldClose` rather than saving from
        // `onDisappear` is what makes the failure case possible at all: by the time a window has
        // disappeared there is nothing left to present a modal over, and the design's one modal
        // moment is precisely a close that could not save (02-architecture.md § Write-failure
        // surfacing).
        windowController.onCloseRequested = { closeAfterFlushing() }

        windowController.onFrameChanged = { frame in
            if let recordID {
                appModel.boardRegistry.updateCardWindowFrame(
                    id: recordID,
                    cardID: ref.cardIdentity,
                    frame: WindowFrame(x: frame.origin.x, y: frame.origin.y, width: frame.width, height: frame.height)
                )
            }
            guard let window = windowController.window else { return }
            let size = window.contentRect(forFrameRect: frame).size
            guard size != AppPreferences.lastCardWindowSize else { return }
            AppPreferences.setLastCardWindowSize(size)
        }
    }

    // MARK: - Closing

    /// The close, held open exactly as long as the buffer needs.
    ///
    /// A clean buffer closes immediately — which is every window that was only read, and every
    /// window whose last keystroke was more than the debounce ago. A dirty one is flushed through
    /// `DirtyBufferGuard`, and only a genuine write *failure* stops the close: a suspended save
    /// (read-only lock) and a vanished card do not, because neither has anywhere for the text to
    /// land and both were already visible to the user as the standing lock row or a card that left
    /// the board (`CardBodyEditSession.flushOrThrow`).
    private func closeAfterFlushing() {
        guard session.body.isDirty || session.title.isDirty else {
            windowController.closeAfterFlush()
            return
        }
        if session.bufferGuard.beginClose() {
            windowController.closeAfterFlush()
        } else {
            // The alert is presenting; the close resumes from the phase change, above.
            isClosePending = true
        }
    }

    /// The save panel behind the modal's "Save a Copy…". Pre-filled with the card's name and a `.md`
    /// extension, because what it writes is the Markdown body the user was typing — not the card,
    /// which cannot exist outside a board.
    private static func copyDestination(named title: String) -> URL? {
        let panel = NSSavePanel()
        panel.nameFieldStringValue = "\(title.isEmpty ? "Untitled" : title).md"
        panel.canCreateDirectories = true
        panel.isExtensionHidden = false
        panel.allowsOtherFileTypes = true
        panel.prompt = "Save"
        panel.message = "Choose where to keep these changes."
        guard panel.runModal() == .OK else { return nil }
        return panel.url
    }

    /// Leaves the session and lets the store go.
    ///
    /// The release rides **behind** the session's end rather than beside it: a session that has
    /// something to commit needs the store it is committing through, and a refcount that hit zero
    /// first would have stopped the watcher underneath it.
    ///
    /// **Unregistering rides behind it too** (06-history-undo.md ▸ Rules ▸ Auto-commit: "window close
    /// flushes the session as one commit"), which is new in this milestone and is the whole ordering
    /// the one-commit rule rests on: unregistering is what releases the committer's stage-around, and
    /// releasing it before `endSession()` had written the body's last keystrokes, posted the draft and
    /// purged `comments/.trash/` would leave a debounce free to fire over a half-finished session —
    /// two commits where the design promises one. The board's own close flush drives the same two
    /// steps in the same order through `CloseFlushCoordinator`, one window at a time.
    private func finish() {
        guard case let .open(store) = phase else { return }
        phase = .closing
        Task { @MainActor in
            await session.endSession()
            appModel.unregisterCardWindow(ref)
            appModel.storeRegistry.release(store)
        }
    }
}
