import AppKit
import SwiftUI
import os

// MARK: - HostedWindowController

/// The `NSWindow` behind a SwiftUI scene, and the three things this app needs from it that SwiftUI
/// does not expose: the window's frame as the user changes it, a chance to run work *before* the
/// window closes, and the window object itself for placement.
///
/// ### The delegate is proxied, never replaced
///
/// SwiftUI owns its windows' delegates and uses them — scene teardown, tabbing, restoration all ride
/// through it — so assigning `window.delegate = self` and walking away breaks the window in ways
/// that show up much later and look like SwiftUI bugs. This object therefore **inserts itself in
/// front** of whatever delegate is already there: it implements the three methods it cares about and
/// forwards them on by hand, and for every other selector it claims to respond exactly when the
/// previous delegate does and forwards the message wholesale through `forwardingTarget(for:)`. The
/// `responds(to:)` override is what makes that safe — `NSWindow` caches which delegate methods exist
/// at the moment the delegate is set, and a proxy that under-reported would silently swallow half of
/// SwiftUI's own callbacks.
///
/// The alternative that was considered and rejected: observing `NSWindow.willCloseNotification`
/// instead of intercepting `windowShouldClose`. It cannot work for the close flush — by the time
/// that notification arrives the close has already been decided, and the flush's whole job is to
/// happen *first* (02-architecture.md § Windows). Move and resize, which have nothing to veto, could
/// have gone either way; they are delegate methods here so there is one mechanism rather than two.
@MainActor
final class HostedWindowController: NSObject, NSWindowDelegate {

    /// The window, once the view hierarchy has one. Weak: the window owns the view that owns nothing
    /// here, and a strong reference would keep a closed window alive.
    private(set) weak var window: NSWindow?

    /// Whoever was the delegate before us — SwiftUI's own, in practice. Weak for the same reason
    /// `NSWindow.delegate` is: it is not ours to keep alive.
    ///
    /// `nonisolated(unsafe)` because the two proxying overrides below (`responds(to:)` and
    /// `forwardingTarget(for:)`) override `NSObject` methods that are not actor-isolated and cannot
    /// be made so. The property is written only on the main actor, and every read is a message the
    /// Objective-C runtime is delivering to a window delegate — which AppKit does on the main thread.
    /// The alternative, `MainActor.assumeIsolated`, would turn any hypothetical off-main
    /// `respondsToSelector:` into a crash; a stale read of a weak reference is the milder failure.
    private nonisolated(unsafe) weak var previousDelegate: NSWindowDelegate?

    /// Called once, when the window first appears. Placement (the saved frame, the card cascade)
    /// happens here.
    var onAttach: ((NSWindow) -> Void)?

    /// Called on `windowDidMove` and at the end of a live resize — not during one, because saving a
    /// frame per mouse-moved event would write the registry file hundreds of times for one drag.
    var onFrameChanged: ((NSRect) -> Void)?

    /// Called instead of closing, when non-`nil`. The handler runs the close flush and then closes
    /// the window itself through `closeAfterFlush()`. `nil` means "close normally", which is every
    /// window that has nothing to flush.
    var onCloseRequested: (() -> Void)?

    /// **The stack this window's ⌘Z crosses**, asked for afresh every time AppKit wants it —
    /// 13-native-undo.md ▸ Rules' two levels (re-ruled 2026-07-31): a **board** window answers with
    /// its session's stack, and a **card** window with its own, "standard per-window AppKit scoping".
    ///
    /// A closure rather than a stored manager for two reasons: a board window's session does not
    /// exist yet when the window attaches, and it stops existing at teardown while the window is
    /// still closing — answering `nil` then is what keeps a torn-down board's stack from being
    /// reachable through a window that outlived it by a run-loop turn.
    ///
    /// `nil` on every window that has no stack of its own (welcome, the bootstrap, the template
    /// chooser), which `BoardUndoRouting` reads as "the platform default".
    var windowUndoManager: (() -> UndoManager?)?

    /// The text manager this window hands back while a field editor holds the keyboard, and the one
    /// it hands back when there is no board — 06-history-undo.md ▸ Undo routing, via
    /// `BoardUndoRouting`. Created on demand, per window, which is what AppKit itself would have
    /// done for a window whose delegate answered nothing.
    private lazy var textUndoManager = UndoManager()

    /// Set by `closeAfterFlush()` so the re-entrant `windowShouldClose` lets the close through
    /// instead of starting a second flush.
    private var isFlushed = false

    /// The titlebar accessory this window shows, once something has given it one — today the board
    /// popover's window-title widget (03-board-ui.md § Board popover), and only on board windows.
    /// `nil` on welcome, the bootstrap and card windows, which is why it is a slot rather than a
    /// constructor argument.
    private var titlebarAccessory: NSTitlebarAccessoryViewController?

    /// This window's toolbar, once something has given it one — the board and card windows'
    /// customizable toolbars (03-board-ui.md ▸ Toolbar). A slot for the accessory's reason: welcome
    /// and the bootstrap window have none, and the two that do only learn what goes in it after
    /// their board has loaded.
    private var toolbarController: WindowToolbarController?

    /// Whether this window's title is hidden from the title bar — **card and board windows**: the
    /// card's name is shown as part of the card's body instead of the chrome (05-card-window.md ▸
    /// Window), and the board's is said by the board-popover widget in the titlebar instead
    /// (03-board-ui.md ▸ Board popover; `BoardWindowHost.configureWindow`). `nil` leaves AppKit's own
    /// default (`.visible`) untouched — the restore-bootstrap window's posture, the one
    /// `HostedWindowController`-hosted window with no opinion here, the same "nothing to do" posture
    /// `titlebarAccessory` has on welcome (which never attaches a controller at all), the bootstrap
    /// window, and now — for that slot specifically — card windows too.
    ///
    /// A slot, not a one-shot write, for the accessory and toolbar's own reason: the value has to
    /// survive the provisional-window swap (`detach()`'s doc comment) and reapply itself when the
    /// real window attaches, which a write made once at `onAttach` time would not survive if that
    /// closure only fired for the provisional window. `NSWindow.title` itself is a different slot
    /// entirely — SwiftUI's `navigationTitle` sets it directly, and it is left alone on purpose: the
    /// Window menu, Mission Control/Exposé, VoiceOver and state restoration all read the string, not
    /// what the chrome draws from it.
    private var titleVisibility: NSWindow.TitleVisibility?

    /// Whether this window's content runs the full height of the frame, under a transparent title
    /// bar — **a board window carrying a custom background**, and nothing else (03-board-ui.md §
    /// Styling ▸ Capabilities: the board's colour or image "paints the full window"; `BoardView
    /// .boardBackground` draws the frosted strip that keeps the chrome legible over it).
    ///
    /// `nil` leaves AppKit's own posture untouched, exactly as `titleVisibility` does — the welcome,
    /// bootstrap and card windows have no opinion, and neither does a board window while it loads
    /// (the flag is driven off the snapshot, which does not exist yet). `nil` and `false` therefore
    /// render identically; they differ only in whether this controller has *said* anything, which is
    /// what keeps the loading half from having to state a default it does not own.
    ///
    /// A slot rather than a one-shot write, and **repeat-safe rather than install-once** — the
    /// `hideTitle` pattern, for a stronger version of its reason: the value has to survive the
    /// provisional-window swap (`detach()`), *and* it genuinely changes over a window's life. A
    /// `background:` edited on disk reloads the snapshot, and the chrome follows it in both
    /// directions.
    private var extendsUnderTitlebar: Bool?

    /// **Which `WindowAccessor` view this controller is currently attached through** — the identity
    /// `detach(through:)` checks before it tears anything down.
    ///
    /// SwiftUI's dismantle-then-make pair for the background representable is not guaranteed to
    /// arrive in the order it logically pairs in (`detach()`'s own doc comment: "observed on macOS
    /// 26" is an empirical note, not a documented ordering). A content swap deep in a window's tree —
    /// the raw-source outlet replacing the whole card-window content area is the one this was traced
    /// to — can make SwiftUI recreate this representable, and if the *old* instance's `dismantleNSView`
    /// is delivered **after** the *new* instance has already attached, an identity-blind `detach()`
    /// would tear down the toolbar, the titlebar accessory and the delegate proxy that the newer
    /// attach just installed — leaving the window with no toolbar at all, which collapses a two-line
    /// title-and-subtitle down to AppKit's single combined line (the malformed titlebar this guards
    /// against). Tracking *which* view is the current owner is what lets a stale teardown recognize
    /// itself as stale and refuse, rather than winning a race it does not know it is in.
    ///
    /// `nil` for every caller that attaches without naming a view — every direct call in this file's
    /// own tests — which keeps their `attach`/`detach` pair exactly as unconditional as it always was;
    /// the guard only ever engages when both sides of a call name one.
    private weak var attachedThroughView: NSView?

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

    // MARK: Attachment

    /// - Parameter view: The `WindowAccessor`'s own sensing view, when the caller is one — `nil` for
    ///   a caller with no view of its own (every direct call this file's tests make), which attaches
    ///   exactly as before. Recorded **before** the same-window early return, so a fresh view mounted
    ///   over an unchanged window still updates who owns the next `detach(through:)`.
    func attach(to window: NSWindow, through view: NSView? = nil) {
        if let view {
            attachedThroughView = view
        }
        guard self.window !== window else { return }
        self.window = window
        if window.delegate !== self {
            // Guarding against self-proxying: re-attaching to a window we already front would
            // otherwise make `previousDelegate` point at this object and every forwarded selector an
            // infinite loop.
            previousDelegate = window.delegate
            window.delegate = self
        }
        onAttach?(window)
        // After `onAttach`, so placement has already happened: an accessory handed over before the
        // window existed is installed here instead, and one handed over later installs immediately.
        addTitlebarAccessoryIfPossible()
        applyToolbarIfPossible()
        applyTitleVisibilityIfPossible()
        applyTitlebarExtensionIfPossible()
    }

    /// Puts the previous delegate back and takes the titlebar accessory and toolbar off the window —
    /// **without discarding them**. The delegate half is a no-op if something else has since taken
    /// the delegate, because stomping a third party's would be the bug this whole file exists to
    /// avoid.
    ///
    /// The held chrome survives a detach deliberately: its lifetime is this controller's, not the
    /// sensing view's. SwiftUI dismantles and re-makes the background representable while it moves a
    /// scene's content into its final window (observed on macOS 26: install arrives before any
    /// window, a dismantle follows, and only *then* does the real window attach) — so chrome
    /// discarded here would never reach the window it was made for. `attach` reinstalls whatever is
    /// held; a controller that is genuinely done takes its slots down with it.
    ///
    /// - Parameter view: The `WindowAccessor` view being dismantled, when the caller is one — `nil`
    ///   for a caller with no view (every direct call this file's tests make), which detaches exactly
    ///   as before, unconditionally. A named view that is **not** the one `attachedThroughView` last
    ///   recorded is refused outright: a fresher `attach(to:through:)` already owns this window's
    ///   chrome, and this call is a stale teardown arriving for a view that lost the race — see
    ///   `attachedThroughView`.
    func detach(through view: NSView? = nil) {
        if let view, view !== attachedThroughView { return }
        removeTitlebarAccessory()
        removeToolbar()
        guard let window, window.delegate === self else { return }
        window.delegate = previousDelegate
        self.window = nil
    }

    // MARK: Titlebar accessory

    /// Gives this window a titlebar accessory — **once**, whatever the caller does.
    ///
    /// The guard is the whole of the install-once rule: AppKit keeps accessories in an array and
    /// would happily hold two identical widgets, and a board window's host may configure itself more
    /// than once (the load returns, the window attaches, SwiftUI re-evaluates). Installing before
    /// the window exists is legal — the accessory is held and goes in at `attach`.
    func installTitlebarAccessory(_ accessory: NSTitlebarAccessoryViewController) {
        guard titlebarAccessory == nil else { return }
        titlebarAccessory = accessory
        addTitlebarAccessoryIfPossible()
    }

    private func addTitlebarAccessoryIfPossible() {
        guard let window, let accessory = titlebarAccessory,
              !window.titlebarAccessoryViewControllers.contains(where: { $0 === accessory })
        else { return }
        window.addTitlebarAccessoryViewController(accessory)
    }

    /// Removes ours and only ours, by identity: the index is looked up rather than assumed, because
    /// nothing promises this app owns the only accessory a window carries.
    private func removeTitlebarAccessory() {
        guard let window, let accessory = titlebarAccessory,
              let index = window.titlebarAccessoryViewControllers.firstIndex(where: { $0 === accessory })
        else { return }
        window.removeTitlebarAccessoryViewController(at: index)
    }

    // MARK: Toolbar

    /// Gives this window its toolbar — **once**, `installTitlebarAccessory`'s rule and for its
    /// reason: a host may configure itself more than once, and a second toolbar would replace the
    /// first along with the search field the first was hosting.
    ///
    /// Installing before the window exists is legal — the toolbar is held and goes on at `attach`.
    func installToolbar(_ controller: WindowToolbarController) {
        guard toolbarController == nil else { return }
        toolbarController = controller
        applyToolbarIfPossible()
    }

    /// The window is SwiftUI's, and SwiftUI leaves `toolbar` alone for a scene that declares no
    /// `.toolbar` modifier — which the board and card windows deliberately do not, since their
    /// toolbars are `NSToolbar`s (see `WindowToolbarController` for why). The identity check is what
    /// keeps a re-attach from replacing a live toolbar with itself and rebuilding every item.
    private func applyToolbarIfPossible() {
        guard let window, let toolbarController, window.toolbar !== toolbarController.toolbar else { return }
        window.toolbar = toolbarController.toolbar
        // The first honest answer to "what is installed", now that the toolbar has built its items
        // from the saved configuration.
        toolbarController.reportInstalledItems()
        toolbarController.revalidate()
    }

    private func removeToolbar() {
        guard let window, let toolbarController, window.toolbar === toolbarController.toolbar else { return }
        window.toolbar = nil
    }

    /// Re-validates this window's toolbar items against their current predicates, on demand.
    ///
    /// `WindowToolbarController`'s own observation tracking (`trackValidationState`) only notices
    /// `@Observable` reads changing; a predicate that reads a plain `UserDefaults`-backed bit instead
    /// (`AppPreferences.showCardSidebar`, `CardToolbar`) is invisible to it. A no-op before a toolbar
    /// exists, which covers every window kind that has none.
    func revalidateToolbar() {
        toolbarController?.revalidate()
    }

    // MARK: Title visibility

    /// Hides this window's title from the title bar, leaving the toolbar exactly as it renders today
    /// — the card-window seam (`CardWindowHost`, 05-card-window.md ▸ Window) and, since the
    /// board-popover widget grew to say the board's name itself, the board-window one too
    /// (`BoardWindowHost`, 03-board-ui.md ▸ Board popover). `window.title` is untouched by this call
    /// on purpose; see the property's doc comment for why.
    ///
    /// Safe to call whenever the caller learns it wants this — before the window exists (the value is
    /// held and applied at `attach`) or after (applied immediately) — and safe to call more than once,
    /// unlike the accessory and toolbar slots: writing `NSWindow.titleVisibility` twice has no side
    /// effect worth guarding against, so this is not an install-once seam.
    func hideTitle() {
        titleVisibility = .hidden
        applyTitleVisibilityIfPossible()
    }

    private func applyTitleVisibilityIfPossible() {
        guard let window, let titleVisibility else { return }
        window.titleVisibility = titleVisibility
    }

    // MARK: Content under the title bar

    /// Runs this window's content the full height of its frame, under a transparent title bar — or
    /// puts the standard chrome back (see `extendsUnderTitlebar`).
    ///
    /// Safe whenever the caller learns the answer — before the window exists (held, applied at
    /// `attach`) or after (applied now) — and safe to call repeatedly with the same value, which
    /// matters more here than for `hideTitle`: the board window drives this off its snapshot, so it
    /// is called on every reload that changes the reading and on plenty that do not.
    ///
    /// **Not undone at `detach`**, `titleVisibility`'s posture: the slot survives the provisional-
    /// window swap and reapplies itself to whichever window attaches next, and a window that is
    /// genuinely going away takes its chrome with it.
    func setExtendsContentUnderTitlebar(_ flag: Bool) {
        extendsUnderTitlebar = flag
        applyTitlebarExtensionIfPossible()
    }

    /// The two AppKit knobs the effect needs, and they are one decision: `fullSizeContentView` is
    /// what lets the content view reach under the title bar, and `titlebarAppearsTransparent` is
    /// what stops the title bar from painting its own material over it. Either alone is a visible
    /// half-state — an opaque bar over the board, or a board that stops at a bar that no longer
    /// draws.
    ///
    /// **The second knob does not stay written, and this is not the place that keeps it.** On a
    /// SwiftUI scene window `titlebarAppearsTransparent` is SwiftUI's: it is the AppKit face of the
    /// view tree's resolved `toolbarBackgroundVisibility(for: .windowToolbar)`, and SwiftUI writes
    /// the resolved value — changed or not — on every pass in which it re-applies a window's
    /// configuration. A tree that states nothing resolves `.automatic`, so each of those passes put
    /// `false` back over what this method had set, and the board's title bar went opaque some time
    /// after the board opened rather than at once. So the board *also* says it in SwiftUI
    /// (`BoardWindowHost`, which owns the reasoning), and this write is what makes the posture true
    /// for the turn in which the board's `background:` first reads, not what makes it last.
    ///
    /// The `fullSizeContentView` half has no such second owner and survives on its own.
    private func applyTitlebarExtensionIfPossible() {
        guard let window, let extendsUnderTitlebar else { return }
        window.titlebarAppearsTransparent = extendsUnderTitlebar
        if extendsUnderTitlebar {
            window.styleMask.insert(.fullSizeContentView)
        } else {
            window.styleMask.remove(.fullSizeContentView)
        }
    }

    /// Closes the window for real, after the flush has run. `performClose` rather than `close` so the
    /// standard path runs — SwiftUI's own delegate gets its callbacks, tabbing behaves — with the
    /// flag telling our own `windowShouldClose` to stand aside.
    func closeAfterFlush() {
        isFlushed = true
        window?.performClose(nil)
    }

    // MARK: NSWindowDelegate

    func windowShouldClose(_ sender: NSWindow) -> Bool {
        guard !isFlushed, let onCloseRequested else {
            return previousDelegate?.windowShouldClose?(sender) ?? true
        }
        onCloseRequested()
        // The window stays open with everything still on screen while the flush runs — which is also
        // what makes 02's "close waits for in-flight operations" implementable here later: the
        // banner's spinner has somewhere to spin.
        return false
    }

    /// The window-level half of 06-history-undo.md ▸ Undo routing (see `BoardUndoRouting`, which
    /// owns the rule and the reasoning): this window's stack when the keyboard is on the content, a
    /// text manager of this window's own while a field editor has it.
    ///
    /// **Answered here rather than forwarded**, unlike the proxy's other selectors, on the one
    /// condition that this window has a stack: `responds(to:)` reports this method whatever the
    /// previous delegate does, so a `nil` return would leave a window with *no* undo manager at all
    /// rather than the one AppKit creates for a delegate that stays silent. A window with no stack
    /// still defers to SwiftUI's delegate if it has an opinion.
    func windowWillReturnUndoManager(_ window: NSWindow) -> UndoManager? {
        let board = windowUndoManager?()
        if board == nil, let previousDelegate,
           previousDelegate.responds(to: #selector(NSWindowDelegate.windowWillReturnUndoManager(_:))),
           let inherited = previousDelegate.windowWillReturnUndoManager?(window) {
            return inherited
        }
        return BoardUndoRouting.undoManager(
            isTextEditing: BoardUndoRouting.isTextEditing(window.firstResponder),
            board: board,
            textFallback: textUndoManager
        )
    }

    func windowDidMove(_ notification: Notification) {
        reportFrame()
        previousDelegate?.windowDidMove?(notification)
    }

    func windowDidEndLiveResize(_ notification: Notification) {
        reportFrame()
        previousDelegate?.windowDidEndLiveResize?(notification)
    }

    private func reportFrame() {
        guard let window else { return }
        onFrameChanged?(window.frame)
    }

    // MARK: Proxying

    override func responds(to aSelector: Selector!) -> Bool {
        if super.responds(to: aSelector) { return true }
        return previousDelegate?.responds(to: aSelector) ?? false
    }

    override func forwardingTarget(for aSelector: Selector!) -> Any? {
        guard let previousDelegate, previousDelegate.responds(to: aSelector) else { return nil }
        return previousDelegate
    }

    // MARK: Placement

    /// Where a saved frame should actually open — the settled rule in 02-architecture.md § Windows,
    /// "per-board frame memory (repositioned onto a live screen if the saved one is gone)".
    ///
    /// Pure, and taking the screens as an argument, because the interesting case is a display that is
    /// *not attached right now*: a board last closed on an external monitor must not reopen at
    /// coordinates nobody can see. Asking `NSScreen` inside would make that untestable and would
    /// hide the rule inside a window callback.
    ///
    /// Intersection, not containment, is the test: a window straddling two displays or hanging
    /// slightly off the bottom of one is where the user left it, and AppKit's own
    /// `constrainFrameRect(_:to:)` nudges the remainder into view when the frame is set. Only a frame
    /// that lands on *no* live screen is relocated, and then it keeps its size and centers on the
    /// fallback — size is a preference, position is a place, and the place is what stopped existing.
    static func placement(for saved: WindowFrame, onScreens visibleFrames: [NSRect], fallback: NSRect) -> NSRect {
        let frame = NSRect(x: saved.x, y: saved.y, width: saved.width, height: saved.height)
        if visibleFrames.contains(where: { $0.intersects(frame) }) {
            return frame
        }
        return NSRect(
            x: fallback.midX - frame.width / 2,
            y: fallback.midY - frame.height / 2,
            width: frame.width,
            height: frame.height
        )
    }

    /// `placement(for:onScreens:fallback:)` against the screens attached right now.
    static func placementOnCurrentScreens(for saved: WindowFrame) -> NSRect {
        let visibleFrames = NSScreen.screens.map(\.visibleFrame)
        let fallback = NSScreen.main?.visibleFrame ?? visibleFrames.first ?? NSRect(x: 0, y: 0, width: 1440, height: 900)
        return placement(for: saved, onScreens: visibleFrames, fallback: fallback)
    }
}

// MARK: - WindowAccessor

/// Hands a SwiftUI view's `NSWindow` to a `HostedWindowController`.
///
/// A zero-size, hidden `NSView` whose only job is `viewDidMoveToWindow()` — the moment AppKit itself
/// declares the window known. The alternative idiom (read `view.window` from a dispatched block after
/// `makeNSView`) is a guess about timing that is usually right; this one is never wrong.
struct WindowAccessor: NSViewRepresentable {

    let controller: HostedWindowController

    func makeCoordinator() -> HostedWindowController { controller }

    func makeNSView(context: Context) -> NSView {
        let view = WindowSensingView()
        // **Named**, not bare — this instance is the token `attach(to:through:)`/`detach(through:)`
        // race-guard on (`HostedWindowController.attachedThroughView`), so a stale dismantle for a
        // view a later `makeNSView` has already superseded refuses to undo that newer attach's work.
        view.onWindow = { [controller, weak view] window in
            guard let view else { return }
            controller.attach(to: window, through: view)
        }
        return view
    }

    func updateNSView(_ nsView: NSView, context: Context) {}

    static func dismantleNSView(_ nsView: NSView, coordinator: HostedWindowController) {
        coordinator.detach(through: nsView)
    }
}

/// Draws nothing and wants no space — it is a hook wearing a view's clothes. Hosted as a
/// `.background`, so even its zero-size frame is out of the layout's way.
private final class WindowSensingView: NSView {

    var onWindow: ((NSWindow) -> Void)?

    override var intrinsicContentSize: NSSize { .zero }

    override func viewDidMoveToWindow() {
        super.viewDidMoveToWindow()
        guard let window else { return }
        onWindow?(window)
    }
}
