import AppKit
import SwiftUI
import os

// MARK: - BoardWindowHost

/// One board window: the thing that owns a board's session for as long as it is on screen
/// (02-architecture.md § Windows, § Launch and window lifecycle).
///
/// ### It is a lifecycle, not a layout
///
/// Almost everything here is about beginning and ending: acquiring the shared store, stamping the
/// registry, holding the board's security-scoped access, remembering the window's frame, and running
/// the close flush before any of it is let go. The board *itself* — the lane strip and everything in
/// it — is `BoardView`'s (03-board-ui.md); this file hands it the store and the window and stays out
/// of the way.
///
/// ### Every open passes through a loading state
///
/// The window appears **immediately** — welcome click, File ▸ Open…, Finder double-click,
/// restoration alike — at its saved frame, its title carrying the registry record's cached name,
/// and its content area holding `BoardLoadingView` until the first snapshot lands
/// (02-architecture.md § Launch and window lifecycle, ruled 2026-07-29). The walk that produces
/// that snapshot runs **off the main actor** (`BoardStoreRegistry.acquireOffMain`), so a board of
/// any size opens as a live window rather than as a beachball, and every restored window walks
/// independently of every other.
///
/// The window is therefore real, and closeable, before it has a store: ⌘W during the walk cancels
/// it and closes the window. That is why `configureWindow` is in two halves — see
/// `configureLoadingWindow(recordID:)`.
///
/// ### Failure opens welcome, on a row that already exists
///
/// A board that will not load has nothing to show, so its window retires. But its registry
/// record is created **before** the load runs (02-architecture.md § Per-board app state, "a first
/// open that fails fail-fast still records"), so the failure that joins `AppModel.launchFailures`
/// always has a recents row waiting for it — `WelcomeRow.derive` matches the two by path, uniform
/// with the failed-restoration row. This window dismisses itself and welcome comes up.
struct BoardWindowHost: View {

    let ref: BoardWindowRef

    @Environment(AppModel.self) private var appModel
    @Environment(\.openWindow) private var openWindow
    @Environment(\.dismissWindow) private var dismissWindow
    /// The transient search strip's arrival and departure has a reduced variant like every other
    /// appearance in the app (10-accessibility.md; `Motion.transientSearchTransition`).
    @Environment(\.accessibilityReduceMotion) private var reduceMotion

    /// The window's own controller — `@State` so it outlives body evaluations and so SwiftUI keeps it
    /// alive for exactly as long as this window exists.
    @State private var windowController = HostedWindowController()

    /// This window's board popover, open or not (03-board-ui.md § Board popover). `@State` for the
    /// window controller's reason — one per window, living exactly as long as the window — which is
    /// also what makes ⌘I mean "the board in front" rather than "some board": the flag reaches the
    /// menu item through the focus system, like the store.
    @State private var boardInfo = BoardInfoPresentation()

    /// This window's purge alert, open or not (03-board-ui.md § Trash). `@State` for `boardInfo`'s
    /// reason and reaching the menu bar the same way: File ▸ Delete (landing on a trash selection)
    /// and Empty Trash… are menu-bar items, and a menu item cannot present anything of its own.
    @State private var trashConfirmations = TrashConfirmations()

    /// How Board ▸ Open Card reaches this window's card windows. `@State` for `boardInfo`'s reason,
    /// and published the same way: a menu item has no window of its own, and only this view holds
    /// the board half of a card window's `(board, card)` identity — see `CardOpener`.
    @State private var cardOpener = CardOpener()

    /// This window's toolbar search field, as a handle (`BoardSearchPresentation`). `@State` for
    /// `boardInfo`'s reason — one per window — and published the same way, because Edit ▸ Find ⌘F
    /// and the caret-chord commands are menu-bar items that have to reach the frontmost board
    /// window's field.
    @State private var boardSearch = BoardSearchPresentation()

    /// The pre-snapshot surface's grace clock (02 § Launch and window lifecycle). `@State` for
    /// `boardInfo`'s reason — one per window, living exactly as long as the window.
    @State private var loading = BoardLoadingIndicator()

    /// The open walk, so ⌘W can cancel it by name rather than by waiting for SwiftUI's teardown to
    /// get around to it.
    @State private var openWalk = BoardOpenWalk()

    /// The loading window's plain-text stand-in for the titlebar widget
    /// (`boardLoadingTitlebarAccessory`), held only so `configureWindow` can find and remove it once
    /// the real, interactive widget is ready to take its place.
    ///
    /// Not routed through `windowController`'s own accessory slot (`installTitlebarAccessory`),
    /// which is install-once and holds exactly one accessory for this window's whole life — this one
    /// is deliberately temporary, so it is added and removed with the raw `NSWindow` API instead,
    /// from the same `onAttach` closure that already survives the provisional-window swap
    /// (`configureLoadingWindow`).
    @State private var loadingAccessory: NSTitlebarAccessoryViewController?

    /// This board's registry record, from the moment `recordOpen` mints it — which is what the
    /// loading window's title reads (`Self.loadingTitle`). `nil` only for the one body evaluation
    /// that precedes `start()`.
    @State private var recordID: UUID?

    /// This open's security-scoped access, claimed in `start()` and held until the session takes it
    /// over or the open ends.
    ///
    /// `@State` rather than a local in `start()` because the decision surface outlives that call: a
    /// repair *writes into the board*, and a scope released when `start()` returned would be released
    /// exactly before the one write that needs it. Every exit balances it — the session adopts it,
    /// or Cancel and the failure path stop it.
    @State private var access: ScopedAccess?

    /// Whether a person asked for this board (`OpenOrigin`) — claimed beside the access, and read by
    /// exactly one branch: what a failed walk does.
    @State private var origin: OpenOrigin = .attended

    /// The board's URL as this open resolved it — the scoped one where there is one. Held for the
    /// surface's sake, which re-walks and repairs against it long after `start()` has returned.
    @State private var boardURL: URL?

    /// **The repair bracket's ledger**, held between Repair and Open's writes and the store that the
    /// following walk builds (`BoardRepairRun`, `EchoLedger.adopt`).
    ///
    /// It cannot live anywhere else: the repairs run before a store exists and the receipts have to
    /// reach that store's ledger before the store's own landing reload classifies them — arrive late
    /// and the repair's own writes would misattribute as foreign, and the announcer would speak about
    /// them. (Once fed a git board's auto-committer instead; app-managed git is gone now,
    /// `strategy/01-git-excision.md`, 2026-08-08.) Cleared once adopted.
    @State private var repairLedger: EchoLedger?

    @State private var phase: Phase = .opening

    private enum Phase {
        case opening
        /// **The walk refused and a person is looking at it** — the decision surface, in the loading
        /// window's own content area (01-storage-format.md § Malformed input, settled 2026-07-31:
        /// "the loading content transforms in place, never a sheet over a spinner").
        case deciding(BoardDecisionSurfaceModel)
        case open(BoardStore)
        /// The load failed; this window is on its way out and must not try again.
        case failed
    }

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

    var body: some View {
        content
            // Font-derived like everything else the board lays out (`BoardMetrics.windowMinimumSize`,
            // 10-accessibility.md's full-relative-scaling rule): at a large system text size a
            // 640×400 floor would be narrower than two lane headers, and "every lane is always on
            // screen" would degrade into a strip of truncation.
            //
            // **The *system* size, not the zoomed one** (03-board-ui.md ▸ Layout — zoom: "Zoom never
            // moves the window"). Zooming to 200% must not push a floor up under a window the user
            // already sized: moving the window belongs to the right-edge lane drag alone, and a
            // minimum that grew with the level would resize every open board from a menu item.
            .frame(
                minWidth: BoardMetrics.windowMinimumSize(bodyPointSize: BoardMetrics.bodyPointSize).width,
                minHeight: BoardMetrics.windowMinimumSize(bodyPointSize: BoardMetrics.bodyPointSize).height
            )
            .background(WindowAccessor(controller: windowController))
            .navigationTitle(windowTitle)
            .task { await beginOpening() }
            .onDisappear { endSessionIfStillOpen() }
    }

    @ViewBuilder
    private var content: some View {
        switch phase {
        case .opening:
            // **The pre-snapshot loading state** (02 § Launch and window lifecycle): empty for the
            // grace, a centered spinner after it, never a skeleton. The board replaces it in place
            // when `phase` becomes `.open` — a snap, which is what assigning outside `withAnimation`
            // means here.
            BoardLoadingView(indicator: loading)
        case let .deciding(model):
            // **In place.** The same content area the spinner was in, with no transition of its own:
            // 02's snap, read for the surface that arrives instead of a snapshot.
            BoardDecisionSurface(
                model: model,
                onRepairAndOpen: { repairAndOpen(model) },
                onRecheck: { recheck(model) },
                onCancel: { cancelDecision(model, closingWindow: false) }
            )
        case .failed:
            // Nothing to render and nothing worth animating: this window is dismissing itself.
            Color.clear
        case let .open(store):
            // **Whether this board paints a background of its own**, read once and spent twice
            // below — the AppKit half of the chrome decision (`setExtendsContentUnderTitlebar`) and
            // the SwiftUI half (`toolbarBackgroundVisibility`) are one answer, not two, and reading
            // it twice would be the first step towards their disagreeing.
            let custom = BoardBackdrop.isCustom(store.snapshot, root: store.rootURL)
            VStack(spacing: 0) {
                BannerStripView(rows: store.bannerRows) { store.banners.dismiss($0) }
                // **⌘F's fallback**, and only that: the search field's home is the toolbar item
                // (`BoardToolbar`), and this strip exists for the window where the user has taken
                // that item out — "with the field removed from the toolbar, invoking it surfaces the
                // field transiently until the search clears" (03-board-ui.md ▸ Toolbar). It sits
                // directly under the title bar, where the item it stands in for would be.
                if boardSearch.isTransient {
                    BoardSearchBar(store: store, presentation: boardSearch)
                        .transition(Motion.transientSearchTransition(reduced: reduceMotion))
                }
                // The window is handed to the board as a closure, not a value: `WindowAccessor`
                // attaches after this body first runs, and the lane-resize drag needs the *live*
                // window to grow at its right edge (03-board-ui.md § Lane).
                //
                // `openCard` is the host's too, for a different reason — see the property below.
                BoardView(
                    store: store,
                    window: { windowController.window },
                    confirmations: trashConfirmations,
                    openCard: openCard,
                    search: boardSearch
                )
                // **The zoom level enters here and nowhere else** (03-board-ui.md ▸ Layout — zoom).
                // On `BoardView` rather than on the `VStack`, deliberately: the level is the *board's*
                // ruler, so the banner strip and the transient search bar above it — chrome, not the
                // board — stay at the system's size, as do the sheets and popovers this window hosts.
                .environment(\.boardZoom, appModel.zoom.context)
            }
            // The transient strip's two dismissal inputs (`BoardSearchPresentation
            // .transientPersists`): it stays while a query is filtering the board or while the field
            // holds the keyboard, and goes when neither is true.
            .onChange(of: store.searchQuery) { _, query in
                boardSearch.dismissTransientIfCleared(query: query)
            }
            .onChange(of: boardSearch.isFocused) { _, _ in
                boardSearch.dismissTransientIfCleared(query: store.searchQuery)
            }
            // **The window chrome follows the board's background** (03-board-ui.md § Styling ▸
            // Capabilities): a board that paints one runs its content the full height of the frame
            // under a transparent title bar, with `BoardView.boardBackground`'s frosted strip
            // keeping the widget and the toolbar legible over it; a board that paints none keeps
            // the standard chrome untouched.
            //
            // Here rather than in `configureWindow` because it is not a wiring fact but a *live*
            // one: `background` is hand-editable, the watcher reloads on a change to `index.md`, and
            // the chrome has to follow the reading in both directions. `initial: true` because the
            // first render is already a level, not a change — this is the board's first statement
            // about its chrome, and the loading half deliberately made none
            // (`HostedWindowController.extendsUnderTitlebar`).
            .onChange(of: custom, initial: true) { _, custom in
                windowController.setExtendsContentUnderTitlebar(custom)
            }
            // **And the same sentence said to SwiftUI, which is the half that lasts.**
            //
            // `NSWindow.titlebarAppearsTransparent` is not the app's property to hold on a SwiftUI
            // scene window: it is the AppKit face of *this* modifier, and SwiftUI writes it — the
            // resolved value, whether or not it changed — every time it re-applies a window's
            // configuration. A tree that says nothing resolves `.automatic`, so every such pass
            // wrote `false` over the `true` the line above had just set out of band. The board
            // opened correct, because the load lands after the pass that follows window creation,
            // and turned opaque at the next one — a title bar painting its own material and its
            // `titlebarSeparatorStyle` hairline across a picture that is still being drawn full
            // height underneath, which is exactly what the bug's screenshots show (nothing below
            // the bar moves, because `.fullSizeContentView` was never the half that was lost).
            //
            // Stating it here makes SwiftUI's own passes assert the posture the board asked for
            // instead of undoing it. Verified as an A/B against a real configuration pass: the same
            // AppKit write survives with this modifier declared and is reverted within ~15 ms
            // without it.
            //
            // The AppKit write stays, and is not a second owner of the same truth — it is the same
            // value one turn earlier, so the frame in which a `background:` first reads is already
            // drawn under a clear bar. What it cannot do is *stay* said; that is this line's job.
            .toolbarBackgroundVisibility(Self.toolbarBackground(overCustomBackground: custom), for: .windowToolbar)
            // (**The board settings sheet was presented here** between 2026-07-31 and 2026-08-07,
            // when the popover/sheet split was reversed: the sheet retired, its contents rehomed
            // into the popover's Git tab — itself retired 2026-08-08 with the git excision
            // (`strategy/01-git-excision.md`), the git-specific contents gone with it — and this
            // window still has no modal configuration surface at all, now for either reason —
            // 03-board-ui.md ▸ Board settings sheet, marked retired.)
            //
            // "The board in front", for the menu items that act on it (`LaneWidthCommands`), and
            // beside it the window's own popover flag, which is what File ▸ Board Info toggles, its
            // purge-alert host, which the trash's two confirmed commands raise, and its search
            // field, which Edit ▸ Find focuses and the caret-chord commands yield to.
            .focusedSceneValue(\.boardStore, store)
            // **What Edit ▸ Undo/Redo cross with this window in front** — the session's stack, which
            // those rows now read for themselves (13-native-undo.md ▸ Rules ▸ the command-surface
            // bullet, re-ruled 2026-08-08; `UndoCommands.swift`). Read afresh from the model rather
            // than stored, for `windowUndoManager`'s reason exactly: a torn-down board answers
            // nothing rather than a stack with no board behind it.
            .focusedSceneValue(\.undoStack, appModel.session(for: ref)?.undoManager)
            .focusedSceneValue(\.boardSearch, boardSearch)
            // The window's identity beside its store — File ▸ Duplicate flushes a *session*, which
            // is keyed on the window rather than on the board it is showing.
            .focusedSceneValue(\.boardWindowRef, ref)
            .focusedSceneValue(\.boardInfo, boardInfo)
            .focusedSceneValue(\.trashConfirmations, trashConfirmations)
            // Board ▸ Open Card's second half — the same closure `BoardView` gets, so the menu item
            // and the double-click open one window per card by construction.
            .focusedSceneValue(\.cardOpener, cardOpener)
        }
    }

    /// Opens a card's window. `openWindow(value:)` with a ref that already has a window focuses it,
    /// so "at most one card window per card (reopen focuses)" needs no bookkeeping here
    /// (02-architecture.md § Windows).
    private var openCard: @MainActor (ItemID) -> Void {
        { cardID in
            openWindow(id: WindowID.card, value: CardWindowRef(board: ref, cardID: cardID))
        }
    }

    private var windowTitle: String {
        guard case let .open(store) = phase else {
            return Self.loadingTitle(
                record: recordID.flatMap { appModel.boardRegistry.record(id: $0) },
                url: ref.url
            )
        }
        return AppModel.displayName(of: store)
    }

    /// What a window that has no snapshot yet is called — **the registry record's cached name**, the
    /// same no-scan source the welcome row reads (02 § Launch and window lifecycle: "its chrome
    /// carrying the registry record's cached title and icon"; § Per-board app state, "the welcome row
    /// reads only the record — it never opens any board's `index.md`").
    ///
    /// Read back off the record rather than recomputed, so a board that has opened before shows the
    /// title it is known by and a first-ever open shows the folder name — which is that record's
    /// provisional display name, not a second rule. The `nil` fallback is the folder name anyway,
    /// covering only the body evaluation that precedes `recordOpen`.
    ///
    /// Static and pure so the rule is checkable without a window (`BoardWindowHostTests`).
    static func loadingTitle(record: BoardRecord?, url: URL) -> String {
        record?.displayName ?? AppModel.folderDisplayName(of: url)
    }

    /// **What the window toolbar's background does over this board** — gone when the board paints
    /// one of its own, the system's own answer when it does not (03-board-ui.md § Styling ▸
    /// Capabilities: the board's colour or image "paints the full window").
    ///
    /// `.automatic` rather than `.visible` for the ordinary board, deliberately: a board with no
    /// background of its own must keep "the standard chrome exactly as it has always looked", and
    /// only `.automatic` means *whatever the system would have done* — including the scroll-driven
    /// appearance AppKit manages for itself. `.visible` would pin a bar on permanently and would be
    /// this modifier's own version of the bug it exists to fix.
    ///
    /// Static and pure for `loadingTitle`'s reason: this one line is the whole of the rule that a
    /// board's chrome follows its background, and an inversion here is invisible in review and
    /// obvious on screen.
    static func toolbarBackground(overCustomBackground custom: Bool) -> Visibility {
        custom ? .hidden : .automatic
    }

    // MARK: - Opening

    /// Starts the open as a task of its own, so something can hold it.
    ///
    /// `.task` cancels on teardown but hands out no handle, and ⌘W during loading needs one *by
    /// name* — the ruled cancel is an act of the user's, not a consequence of a window that has
    /// already gone away (02 § Launch and window lifecycle). So the walk runs in a task `openWalk`
    /// keeps, and the cancellation handler forwards `.task`'s own cancellation into it, leaving both
    /// routes — the user's ⌘W and any teardown SwiftUI decides on — ending in the same `cancel()`.
    private func beginOpening() async {
        let walk = Task { await start() }
        openWalk.adopt(walk)
        await withTaskCancellationHandler {
            await walk.value
        } onCancel: {
            walk.cancel()
        }
    }

    /// Acquires the board and starts its session, or fails it out to welcome.
    ///
    /// The order is load-bearing, and it now has one more step than the load itself does. Security-
    /// scoped access is claimed **before** anything else, because the record and the load both need
    /// it. The registry record comes **before** the walk — "the registry record is created before
    /// loading" (02 § Per-board app state) — so a fail-fast failure always has a row to land on, and
    /// so the loading window has a name to wear; the walk's first act is a directory read, and a
    /// sandboxed read outside the claimed scope is exactly the one that gets refused. `setOpenNow`
    /// comes **after** the load succeeds and after the window has demonstrably opened — a flag set on
    /// a board that never appeared would hand the next launch a restoration set describing a failure.
    ///
    /// **The walk is the one suspension here**, and everything before it is what makes the window
    /// real while it runs: the record, the loading window's chrome, and the grace clock. Everything
    /// after it is the snap.
    private func start() async {
        guard case .opening = phase else { return }

        // Claimed even on the failure path: an unclaimed stash is a scope nobody balances. The origin
        // rides along — one claim, one dictionary (`AppModel.claimPendingOpen`).
        let claimed = appModel.claimPendingOpen(for: ref)
        access = claimed.access
        origin = claimed.origin
        let url = claimed.access?.url ?? ref.url
        boardURL = url

        // Record before load (settled, 02 § Per-board app state). `displayName` is omitted — an
        // existing record's cached title survives untouched, and a brand-new one takes the folder
        // name, both `recordOpen`'s own rule now. This is also this open's one bookmark mint: a
        // successful load below replaces the name through `syncDisplayState`, which never re-mints.
        let recordID = appModel.boardRegistry.recordOpen(of: url)
        // Published to the view *before* the walk: this is what the title bar reads while loading.
        self.recordID = recordID

        // The window is on screen and the user can act on it from here on — placed where they left
        // it, and closeable.
        configureLoadingWindow(recordID: recordID)
        loading.begin()

        await attemptOpen(url: url, recordID: recordID, skipping: [])
    }

    /// **One walk, and what it lands in** — the loop Repair and Open and Re-check re-enter.
    ///
    /// It is a method rather than the tail of `start()` because the surface's two buttons "re-run the
    /// whole walk" (01-storage-format.md § Malformed input) and must land in exactly the places this
    /// lands: a clean walk proceeds into the ordinary open, and a walk that still refuses
    /// re-aggregates into the *same* surface. Sharing the body is what makes "never a chained second
    /// dialog" structural rather than remembered.
    ///
    /// - Parameter skipping: the surface's consented skips, empty on a first attempt. It reaches the
    ///   walk *and* the store, which retains it for the session (`BoardStore.skippedPaths`).
    private func attemptOpen(url: URL, recordID: UUID, skipping: Set<String>) async {
        let store: BoardStore
        do throws(BoardLoadFailure) {
            guard let acquired = try await appModel.storeRegistry.acquireOffMain(url, skipping: skipping) else {
                // ⌘W landed while the walk was running, and the walk has now finished into a result
                // nobody wants (`acquireOffMain`, discard-on-completion). The window is already
                // closing and the registry kept nothing, so the only thing left to balance is this
                // open's scoped access. There is no open-now flag to clear: `setOpenNow` is below,
                // after the load, so a cancelled open never set one — the very reason it lives there.
                Self.logger.debug("board open cancelled during its walk")
                loading.end()
                releaseAccess()
                return
            }
            store = acquired
        } catch {
            handleWalkFailure(error, url: url, recordID: recordID)
            return
        }

        // **The window retired while this walk was in flight** — Cancel (or ⌘W) pressed during a
        // Re-check, whose walk then landed successfully. The board must not open behind a window that
        // has already gone to welcome, and the reference `acquireOffMain` took has to go back or the
        // registry would hold a watcher for a board nobody is showing.
        if case .failed = phase {
            Self.logger.debug("a walk landed after the open was cancelled — releasing it")
            appModel.storeRegistry.release(store)
            releaseAccess()
            return
        }

        loading.end()

        // **The repair's receipts, into the board's own ledger — before the session's landing reload
        // classifies them.** (01-storage-format.md read, of a now-retired repair-commit path: "On Pro
        // boards the repairs drop heal-marked receipts and commit separately as one repair commit" —
        // every git board's, since 12-editions.md ▸ PIVOT 2026-08-07. App-managed git left the app
        // entirely on 2026-08-08, `strategy/01-git-excision.md`; there is no repair commit now.)
        // Receipts adopted after this line would be receipts the landing reload never classifies, and
        // the repair's own writes would misattribute as foreign.
        if let repairLedger {
            store.echoes.adopt(repairLedger)
            self.repairLedger = nil
        }

        // The load succeeded — the frontmatter can be trusted now, so it replaces whatever
        // provisional or stale name the record above was carrying. Through `syncDisplayState`,
        // deliberately not a second `recordOpen`: this is a display-state refresh, not a second
        // open, and it must not mint this board's bookmark again (02 § Per-board app state, "one
        // bookmark per open board").
        appModel.boardRegistry.syncDisplayState(
            id: recordID,
            title: AppModel.displayName(of: store),
            icon: store.snapshot.icon.value,
            iconColor: store.snapshot.iconColor.value
        )
        appModel.boardRegistry.setOpenNow(id: recordID)
        appModel.beginSession(ref: ref, store: store, recordID: recordID, access: access)
        // The session owns the balance from here (`AppModel.beginSession`), so this window must not
        // stop it on any later path.
        access = nil
        phase = .open(store)

        configureWindow(store: store, recordID: recordID)
        postSkipNoticeIfNeeded(store: store, skipping: skipping)

        // "Opening a board from welcome closes welcome" (02 § Launch and window lifecycle). Harmless
        // when welcome is not open, which is the ordinary case.
        dismissWindow(id: WindowID.welcome)
    }

    /// **The attendance branch** (01-storage-format.md § Malformed input, settled 2026-07-31): the
    /// surface "appears on attended opens only … restoration failures keep the retire-to-welcome-row
    /// landing".
    ///
    /// Three outcomes, and the third is the new one:
    ///
    /// - **A restored open retires**, exactly as it did before this milestone. Nobody is sitting in
    ///   front of a launch that reopened four boards, and "launch never chains dialogs".
    /// - **An attended open whose failure is environmental retires too** — a root that is gone, or a
    ///   root that is a file. There is nothing on disk to repair, so a surface would offer the user a
    ///   decision with no choices in it; welcome's row says the same thing in one line, which is
    ///   where it belonged already. (The carve-out the ruling implies rather than states — Redesign
    ///   Gap 87cd782a.)
    /// - **Anything else transforms into the surface**, in place, in this window.
    ///
    /// A surface is *entered* rather than shown: it holds this window's access, its record, and its
    /// URL for as long as the user is deciding, which is why none of the three is released here.
    private func handleWalkFailure(_ error: BoardLoadFailure, url: URL, recordID: UUID) {
        Self.logger.error("board failed to open: \(error.description, privacy: .public)")

        // Retired while this walk ran (Cancel during a Re-check): the failure has already been
        // recorded and the window is on its way out. A second `retire` would post the row twice.
        if case .failed = phase { return }

        guard Self.landing(for: error, origin: origin) == .decide else {
            loading.end()
            retire(message: error.description)
            return
        }

        // Already deciding: this is a Re-check or a Repair and Open landing, and it re-aggregates in
        // place — the same model, the same window, no second dialog.
        if case let .deciding(model) = phase {
            model.reaggregate(error)
            model.isWorking = false
            return
        }

        loading.end()
        phase = .deciding(BoardDecisionSurfaceModel(failure: error, boardRoot: url))
        // ⌘W now means Cancel (01: the surface's own exit), replacing the loading half's
        // cancel-the-walk closure — a slot rather than a branch, `configureWindow`'s posture.
        windowController.onCloseRequested = {
            guard case let .deciding(model) = phase else { return }
            cancelDecision(model, closingWindow: true)
        }
    }

    /// Where a refused walk lands.
    enum FailureLanding: Equatable {
        /// Welcome, on the board's own recents row — today's landing, unchanged.
        case retire
        /// The decision surface, in this window.
        case decide
    }

    /// **The attendance branch as a pure rule** — two facts in, one landing out, provable without a
    /// window (`BoardDecisionSurfaceTests`).
    ///
    /// It is a static rather than an `if` inside `handleWalkFailure` because it is the ruling's own
    /// sentence and the one thing about this milestone that a regression would make silently wrong:
    /// a launch that started showing surfaces would chain dialogs across four restored boards, and an
    /// attended open that stopped showing one would look exactly like the app before this milestone.
    static func landing(for failure: BoardLoadFailure, origin: OpenOrigin) -> FailureLanding {
        guard origin == .attended else { return .retire }
        return isEnvironmental(failure) ? .retire : .decide
    }

    /// **Nothing on disk to repair** — the environmental carve-out's predicate.
    ///
    /// A single defect, and that defect is a fact about the *root itself* rather than about a file
    /// inside it: `BoardLoader` throws these immediately, before any walk, precisely because there is
    /// nothing to walk. The single-defect check is stated rather than assumed — the loader's
    /// environmental throws are single by construction, and a future aggregate carrying one *among*
    /// repairable defects should show the surface, because the rest of it is still actionable.
    static func isEnvironmental(_ failure: BoardLoadFailure) -> Bool {
        guard failure.defects.count == 1 else { return false }
        switch failure.primary.reason {
        case .unreadableRoot, .notADirectory:
            return true
        case .boardRootMissingIndex, .unparseableYAML, .missingSchema, .malformedSchema,
             .schemaNewerThanApp, .missingOrder, .malformedOrder:
            return false
        }
    }

    // MARK: - The surface's three buttons

    /// **Repair and Open**: apply every chosen fix in one write bracket, then re-run the whole walk
    /// with the skip set (01-storage-format.md § Malformed input).
    ///
    /// The repairs are store-less by necessity and heal-marked by rule (`BoardRepairRun`); the walk
    /// that follows is the ordinary one, so a clean result proceeds into the ordinary open and a
    /// dirty one re-aggregates here. A repair that failed does not abort anything — the batch stops,
    /// the failure shows on the surface, and the walk runs anyway, because "a partial repair simply
    /// re-aggregates on the next walk".
    private func repairAndOpen(_ model: BoardDecisionSurfaceModel) {
        guard let url = boardURL, let recordID, !model.isWorking else { return }
        model.isWorking = true
        model.repairFailure = nil

        let outcome = BoardRepairRun.apply(model.plannedRepairs, boardRoot: url)
        model.repairFailure = outcome.failure
        // Held for the store the walk below may build — see `repairLedger`. Merged with anything an
        // earlier pass left, so two rounds of repair both reach the commit.
        if let existing = repairLedger {
            existing.adopt(outcome.ledger)
        } else {
            repairLedger = outcome.ledger
        }

        let walk = Task { await attemptOpen(url: url, recordID: recordID, skipping: model.skipSet) }
        openWalk.adopt(walk)
    }

    /// **Re-check**: re-run the whole walk with the current skip set, changing nothing on disk
    /// (01: "a disk changed underneath re-aggregates into the *same* surface with the fresh defect
    /// list … a clean walk proceeds to the first snapshot").
    private func recheck(_ model: BoardDecisionSurfaceModel) {
        guard let url = boardURL, let recordID, !model.isWorking else { return }
        model.isWorking = true
        let walk = Task { await attemptOpen(url: url, recordID: recordID, skipping: model.skipSet) }
        openWalk.adopt(walk)
    }

    /// **Cancel** — and ⌘W, which means exactly this while the surface is up (01: "**Cancel** aborts
    /// the open: the window retires and the board lands row-level on welcome, record-before-load
    /// unchanged").
    ///
    /// It is the failure path's own sequence, run against the aggregate the surface was showing: the
    /// record was minted before the walk, so the row is already waiting for this message.
    ///
    /// - Parameter closingWindow: true when AppKit asked (⌘W), where the interception has to be
    ///   released for the close to complete. The button's own press dismisses through SwiftUI.
    private func cancelDecision(_ model: BoardDecisionSurfaceModel, closingWindow: Bool) {
        // A walk may still be in flight behind the surface (Cancel during a Re-check). Its landing is
        // guarded by `phase`, which this sets to `.failed`; cancelling as well means the open is not
        // merely ignored but abandoned.
        openWalk.cancel()
        retire(message: model.failure.description)
        if closingWindow {
            windowController.closeAfterFlush()
        }
    }

    /// The retire-to-welcome landing, in one place: the sequence a failed restore has always had, and
    /// the sequence Cancel now shares with it.
    private func retire(message: String) {
        releaseAccess()
        phase = .failed
        // **The interception has to go with the phase.** `windowShouldClose` returns `false` whenever
        // a closure is installed — that is how the close flush gets its turn — so a retired window
        // whose closure had nothing left to do would *refuse every close request for the rest of the
        // app's life*, quit included. There is nothing to intercept once this window is on its way to
        // welcome: no store, no session, no decision.
        windowController.onCloseRequested = nil
        appModel.recordLaunchFailure(path: ref.path, message: message)
        // The record minted before the walk just changed the registry — welcome, about to appear,
        // must not render the stale list `AppModel` cached before this open began, or the failure
        // would fall through to the unmatched-failures list for want of a row that already exists.
        appModel.refreshRecents()
        openWindow(id: WindowID.welcome)
        dismissWindow(id: WindowID.board, value: ref)
    }

    private func releaseAccess() {
        access?.stop()
        access = nil
    }

    /// **The skip notice** (01: "the opened board carries a warning-tone notice naming the skipped
    /// items, each with Reveal in Finder").
    ///
    /// Written from the walk's own `LoadWarning.userSkipped` entries rather than from the surface's
    /// skip set, deliberately: what the notice owes the user is what actually left the board, and a
    /// skip for a defect that repaired itself between the decision and the walk names nothing.
    ///
    /// **Only the open that carried the skips posts it.** A second window onto the same board
    /// acquires the store that already exists, whose warnings still describe the first open — and a
    /// notice repeated per window would be the app reporting one decision twice. `skipping` is
    /// non-empty for exactly the open that made the decision.
    private func postSkipNoticeIfNeeded(store: BoardStore, skipping: Set<String>) {
        guard !skipping.isEmpty else { return }
        let root = store.rootURL
        let items = store.loadWarnings.compactMap { warning -> RevealTarget? in
            guard case let .userSkipped(path) = warning else { return nil }
            return RevealTarget(path: path, url: root.appendingPathComponent(path))
        }
        store.banners.postSkippedOnOpen(items)
    }

    /// **The half of the wiring a window needs before it has a board** — everything here is about
    /// the *window*, and nothing here mentions the store, which is exactly the split
    /// 02-architecture.md's loading state forces: this runs before the walk, and
    /// `configureWindow(store:recordID:)` runs after it.
    ///
    /// Three things, and each is a rule from § Launch and window lifecycle:
    ///
    /// - **The saved frame**, so the window appears "at its saved frame" rather than at the system's
    ///   cascade and then jumping to the user's place a second later.
    /// - **The frame changes**, so a window the user moves *while it loads* is remembered. Not
    ///   store-dependent and so not worth deferring — the alternative is a slow board's window whose
    ///   move is silently discarded.
    /// - **The close interception**, which is what makes ⌘W during loading mean anything at all. It
    ///   is replaced wholesale by the flushing version once the board is open (see below); a single
    ///   closure branching on `phase` would be the same thing spelled as a state read.
    ///
    /// The title bar carries the record's cached name from the first frame (`windowTitle`) — not
    /// through AppKit's own title display, which the board scene's `.windowToolbarStyle(.unified
    /// (showsTitle: false))` (`KanbanApp`) keeps hidden on every board window unconditionally, but
    /// through a plain-text stand-in widget (`boardLoadingTitlebarAccessory`) installed here.
    /// `configureWindow(store:recordID:)` swaps it for the real, interactive one once the
    /// board-popover widget is there to say the name instead — leaving the loading window with no
    /// name anywhere in its chrome for even one frame is precisely what 02 asks the loading state
    /// not to do.
    private func configureLoadingWindow(recordID: UUID) {
        windowController.onAttach = { window in
            if let saved = appModel.boardRegistry.record(id: recordID)?.windowFrame {
                window.setFrame(HostedWindowController.placementOnCurrentScreens(for: saved), display: true)
            }
            // Fresh per attach, deliberately: this closure re-fires on the provisional-window swap
            // (the comment below), and a stand-in built for a window that is about to be discarded
            // would be a stale reference this state never sees again.
            let accessory = boardLoadingTitlebarAccessory(
                title: Self.loadingTitle(record: appModel.boardRegistry.record(id: recordID), url: ref.url)
            )
            loadingAccessory = accessory
            window.addTitlebarAccessoryViewController(accessory)
        }
        // The window may already be attached — `viewDidMoveToWindow` fires before this task's first
        // suspension — so the placement is applied directly too rather than waiting for a callback
        // that has already happened. The closure stays installed either way: the controller re-fires
        // it if SwiftUI swaps the provisional window for the real one (`HostedWindowController
        // .detach`).
        if let window = windowController.window {
            windowController.onAttach?(window)
        }

        windowController.onFrameChanged = { frame in
            appModel.boardRegistry.updateWindowFrame(
                id: recordID,
                frame: WindowFrame(x: frame.origin.x, y: frame.origin.y, width: frame.width, height: frame.height)
            )
        }

        windowController.onCloseRequested = {
            // **⌘W during the walk** (02 § Launch and window lifecycle: "the walk is cancellable:
            // ⌘W during loading cancels it and closes the window"). The window closes *now* — there
            // is no store, so there is nothing to flush and nothing to wait for — and the walk's
            // tail is wasted work we accept rather than thread a cancellation flag through the
            // loader (`BoardStoreRegistry.acquireOffMain`, discard-on-completion).
            //
            // No open-now flag is cleared here because none was ever set: `setOpenNow` runs after
            // the load, so a board that never finished loading is not in the restoration set. The
            // ordinary user-close *does* clear it, in `AppModel.closeBoard`, which is the path the
            // replacement closure below takes.
            openWalk.cancel()
            windowController.closeAfterFlush()
        }
    }

    /// Wires the rest of the window, once there is a board to wire it to: the store's write-through,
    /// the close flush, the undo stack, the title-bar widget and the toolbar.
    ///
    /// Everything here **carries the store or the session**, which is the whole reason it waits for
    /// them; the window-level half ran before the walk (`configureLoadingWindow(recordID:)`).
    private func configureWindow(store: BoardStore, recordID: UUID) {
        // Filled in here rather than at declaration because the closure captures `openWindow`, an
        // environment action; until the board has loaded there is also nothing for Open Card to act
        // on, which is exactly what the item's `nil` check reads.
        cardOpener.open = openCard

        // The registry's live write-through (02-architecture.md § Per-board app state) — wired
        // the way `onFrameChanged` was a moment ago in the loading half: a closure that reaches into
        // the registry, captured weakly on both sides so neither the store nor this closure's own
        // home keeps the other alive past its window. `syncDisplayState` in `start()` already
        // stamped the values current as of this open, so nothing is fired here immediately; this
        // only fires on the reloads that follow.
        store.displayStateDelegate = { [weak appModel, weak store] in
            guard let appModel, let store else { return }
            appModel.boardRegistry.syncDisplayState(
                id: recordID,
                title: AppModel.displayName(of: store),
                icon: store.snapshot.icon.value,
                iconColor: store.snapshot.iconColor.value
            )
        }

        // **Replacing the loading half's cancel-and-close**: from here the window has a session, so
        // a close is the flush (02 § Windows, "Close flushes") and the user-close that clears the
        // open-now flag. A slot rather than a branch — `onCloseRequested` is one closure, and the
        // window that owns it has moved on.
        windowController.onCloseRequested = {
            Task { @MainActor in
                await appModel.closeBoard(ref: ref, cause: .userClose)
                windowController.closeAfterFlush()
            }
        }

        // This window's answer to "what does ⌘Z act on" (13-native-undo.md ▸ Rules; ▸ Undo routing)
        // — the *session's* stack, read afresh on every ask so a torn-down board answers nothing
        // rather than a stack with no board behind it.
        //
        // **It no longer lights the command surface**, and that sentence used to be this comment's
        // whole point (13 ▸ Rules ▸ the command-surface bullet, re-ruled 2026-08-08): the Edit
        // menu's rows and the toolbar's pair were nil-target `undo:`/`redo:` resolving through
        // `NSWindow.undoManager`, which a SwiftUI window latches empty during creation, before this
        // controller's delegate can install — so `windowWillReturnUndoManager` is never consulted
        // and the surface validated against a stack nothing registers into. Both faces read the
        // session's manager directly now: the rows through `FocusedValues.undoStack` published a few
        // lines up in `body`, the toolbar pair through the explicit target `BoardToolbar` is handed
        // below. This closure stays wired because it is still the right answer wherever *AppKit*
        // asks a delegate for a manager.
        windowController.windowUndoManager = { appModel.session(for: ref)?.undoManager }

        // The loading window's plain-text stand-in (`configureLoadingWindow`) has done its job —
        // found by identity, `HostedWindowController.removeTitlebarAccessory`'s own pattern, because
        // nothing else promises this is the only accessory the window carries by the time it exists.
        if let loadingAccessory, let window = windowController.window,
           let index = window.titlebarAccessoryViewControllers.firstIndex(where: { $0 === loadingAccessory }) {
            window.removeTitlebarAccessoryViewController(at: index)
        }
        self.loadingAccessory = nil

        // **`onAttach` itself has to be replaced here, not just the stand-in it already installed**
        // (Pipeline card a73bad86, reopened). SwiftUI's dismantle-then-make swap of the provisional
        // window for the real one is not guaranteed to land before the load does
        // (`HostedWindowController.detach`'s own doc comment) — a fast load and a slow swap means the
        // real window's `attach` fires *after* this method has already run. Left alone,
        // `configureLoadingWindow`'s closure is still installed at that point, and it builds a fresh
        // stand-in and installs it on whatever window attaches next; `HostedWindowController.attach`
        // then runs `addTitlebarAccessoryIfPossible()` right after `onAttach`, installing the real
        // widget beside it — both in the titlebar, stand-in leading, and nothing left to remove it a
        // second time. "Occasionally" was exactly this ordering. The frame placement half survives,
        // because a late-swapped window is still a window whose saved frame has not been applied yet
        // — only the stand-in-building half goes.
        windowController.onAttach = { window in
            if let saved = appModel.boardRegistry.record(id: recordID)?.windowFrame {
                window.setFrame(HostedWindowController.placementOnCurrentScreens(for: saved), display: true)
            }
        }

        // The window-title widget (03-board-ui.md § Board popover) — **board windows only**, which
        // is why it is installed here rather than in `WindowAccessor`: welcome, the bootstrap and
        // card windows share that machinery and have no board to describe. It goes in after the
        // load rather than at attach because it carries the store; the controller installs it once,
        // whichever of the two arrives second.
        windowController.installTitlebarAccessory(
            boardInfoTitlebarAccessory(
                store: store,
                recents: appModel.styleRecents,
                presentation: boardInfo
            )
        )
        // The widget above now says the board's name itself, so the system title display would only
        // repeat it — the card-window seam (`CardWindowHost.configureWindow`,
        // `HostedWindowController.hideTitle`), applied here for the same reason.
        // `.navigationTitle(windowTitle)` a few lines up in `body` is untouched — `window.title`
        // keeps feeding the Window menu, Exposé, VoiceOver and restoration; only the title bar's own
        // rendering of that string is suppressed.
        //
        // **Not this call's job anymore, keeping it hidden** — the duplicate-name bug this call once
        // let through, occasionally, once the board had been open long enough to re-render: a
        // SwiftUI scene window's `titleVisibility` is SwiftUI's to hold, and it writes the tree's
        // resolved value back on every pass that re-applies a window's configuration, so this
        // out-of-band write alone would survive only until the next board-driven re-render.
        // `KanbanApp`'s board scene now declares
        // `.windowToolbarStyle(.unified(showsTitle: false))` unconditionally, which is what SwiftUI
        // keeps re-asserting; this call stays as the same value one turn earlier — correct from the
        // instant it runs, not the one thing making it last.
        windowController.hideTitle()

        // The board's customizable toolbar (03-board-ui.md ▸ Toolbar) — installed here for the
        // accessory's reason exactly: it carries the store, and it is a board window's, not every
        // hosted window's. Its search item is the search field's home, and it is what tells
        // `boardSearch` whether that home still exists.
        windowController.installToolbar(BoardToolbar.controller(
            store: store,
            search: boardSearch,
            zoom: appModel.zoom,
            appearance: appModel.appearance,
            session: appModel.dragSession,
            // The same manager the Edit menu's rows reach through the focus system — one object, two
            // faces, which is what "the toolbar mirrors the menu" means for this pair now that
            // neither of them goes through the responder chain (13 ▸ Rules, re-ruled 2026-08-08).
            undo: appModel.session(for: ref)?.undoManager
        ))
    }

    // MARK: - Closing

    /// The safety net behind the close interception.
    ///
    /// `windowShouldClose` covers ⌘W, File ▸ Close and the red button — every way a *user* closes a
    /// window. It does not cover a window torn down some other way (a programmatic dismiss, a scene
    /// SwiftUI decides to end), and a board whose session outlived its window would leave a watcher
    /// running over nothing. So the disappear runs the same sequence; `AppModel.closeBoard` is
    /// idempotent precisely so these two can both fire without the flush running twice.
    ///
    /// Deliberately **not** the quit path: quit is `AppDelegate`'s, and it must complete before the
    /// app exits rather than in a task nobody waits for.
    private func endSessionIfStillOpen() {
        guard appModel.session(for: ref) != nil else { return }
        Task { @MainActor in
            await appModel.closeBoard(ref: ref, cause: .userClose)
        }
    }
}

// MARK: - BoardOpenWalk

/// The open walk's handle, held for exactly one reason: **⌘W during loading has to be able to cancel
/// it** (02-architecture.md § Launch and window lifecycle).
///
/// A one-field box rather than the `Task` itself in `@State`, because the thing that cancels it is a
/// closure the window controller holds (`onCloseRequested`) and the thing that fills it is the
/// `.task` that starts the walk — two places that must agree on one task, which is what a reference
/// type is. `@MainActor` like everything else on this path, so the box needs no synchronisation of
/// its own.
///
/// Cancelling does not stop the walk (see `BoardStoreRegistry.acquireOffMain` for why the walk is
/// deliberately not cooperatively cancellable). It stops the *open*: the task that would have
/// adopted the result never does.
@MainActor
final class BoardOpenWalk {

    private var task: Task<Void, Never>?

    init() {}

    func adopt(_ task: Task<Void, Never>) {
        self.task = task
    }

    func cancel() {
        task?.cancel()
    }
}
