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.
///
/// ### Failure opens welcome, on a row that already exists
///
/// A board that will not load has nothing to show, so its window never appears. 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()

    @State private var phase: Phase = .opening

    private enum Phase {
        case opening
        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.
            .frame(
                minWidth: BoardMetrics.windowMinimumSize(bodyPointSize: BoardMetrics.bodyPointSize).width,
                minHeight: BoardMetrics.windowMinimumSize(bodyPointSize: BoardMetrics.bodyPointSize).height
            )
            .background(WindowAccessor(controller: windowController))
            .navigationTitle(windowTitle)
            .task { await start() }
            .onDisappear { endSessionIfStillOpen() }
    }

    @ViewBuilder
    private var content: some View {
        switch phase {
        case .opening, .failed:
            // Nothing to render and nothing worth animating: this window either becomes a board in a
            // moment or dismisses itself.
            Color.clear
        case let .open(store):
            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 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 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)
            .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: (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 "" }
        return AppModel.displayName(of: store)
    }

    // MARK: - Opening

    /// 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** `acquire` — "the registry record is created before
    /// loading" (02 § Per-board app state) — so a fail-fast failure always has a row to land on;
    /// `acquire`'s own first act is the tree walk, 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.
    private func start() async {
        guard case .opening = phase else { return }

        // Claimed even on the failure path: an unclaimed stash is a scope nobody balances.
        let access = appModel.claimPendingAccess(for: ref)
        let url = access?.url ?? ref.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)

        let store: BoardStore
        do throws(BoardLoadError) {
            store = try appModel.storeRegistry.acquire(url)
        } catch {
            Self.logger.error("board failed to open: \(error.description, privacy: .public)")
            access?.stop()
            phase = .failed
            appModel.recordLaunchFailure(path: ref.path, message: error.description)
            // The record above 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)
            return
        }

        // 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)
        phase = .open(store)

        configureWindow(store: store, recordID: recordID)

        // "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)
    }

    /// Wires the window: the saved frame on the way in, frame changes on the way back out, the
    /// close interception that makes the flush unavoidable, and the title-bar widget.
    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

        windowController.onAttach = { window in
            guard let saved = appModel.boardRegistry.record(id: recordID)?.windowFrame else { return }
            window.setFrame(HostedWindowController.placementOnCurrentScreens(for: saved), display: true)
        }
        // The window may already be attached — `viewDidMoveToWindow` fires well before this task's
        // load returns — so the placement is applied directly too rather than waiting for a callback
        // that has already happened.
        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)
            )
        }

        // The registry's live write-through (02-architecture.md § Per-board app state) — wired
        // the same way `onFrameChanged` just was: 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
            )
        }

        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; 06 ▸ 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. The Edit menu's Undo/Redo rows and
        // the toolbar's pair are nil-target `undo:`/`redo:`, so this one line is what lights them
        // up: `NSWindow` validates and crosses them against exactly this manager.
        windowController.boardUndoManager = { appModel.session(for: ref)?.undoManager }

        // 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 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))
    }

    // 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)
        }
    }
}
