import AppKit
import Observation
import SwiftUI

// MARK: - The window's search field, as a handle

/// The board window's search field, reduced to the things anything outside it needs to know:
/// whether it holds the keyboard, where it currently lives, how to give it the keyboard, and how to
/// give the keyboard back.
///
/// `BoardInfoPresentation`'s sibling in every respect — one per window, `@State` in
/// `BoardWindowHost`, published through the focus system so a *menu item* can reach the frontmost
/// board window's field (Edit ▸ Find ⌘F) without anyone keeping a which-window-is-key register.
///
/// ### Why the focus flag is here rather than on the store
///
/// Two consumers need it and neither is inside the field: Edit ▸ Find (which focuses it) and the
/// **caret-chord commands**, which "disable while the field is focused" (04-interactions.md ▸
/// Grammar's caret-chords rule, ▸ Search). Both are menu items, and a menu item reaches a window
/// through `@FocusedValue`. It deliberately does *not* live on `BoardStore` beside `searchQuery`:
/// the query is the board's (every window on it filters alike), while the focus is one window's
/// keyboard — the same split `BoardInfoPresentation` makes for the popover flag.
///
/// **It is not `isEditingInline`, and must never become it** (04 § Search, settled): "the field is a
/// *control*, not a content editor — the focused-editor lockdown does not apply". Board commands
/// stay enabled and act on the selection while a query is being typed, ⌘N included.
///
/// ### The field has two homes, and ⌘F knows which one it is in
///
/// The toolbar item is the field's home and its shipped default (03-board-ui.md ▸ Toolbar: "the
/// search field, nothing else — trailing, the one default item"). But the toolbar is customizable,
/// so the item can be *removed*, and 03 is explicit about what happens then: "⌘F always summons
/// search: with the field removed from the toolbar, invoking it surfaces the field transiently until
/// the search clears." That is the whole of `invokeSearch()` below — one decision, taken against
/// `isInstalledInToolbar`, which the toolbar controller keeps current.
@MainActor
@Observable
final class BoardSearchPresentation {

    /// Whether the search field holds keyboard focus — the caret-chord commands' one input.
    ///
    /// Written by the field itself, on `becomeFirstResponder` and on the field editor ending. It is
    /// therefore *the field's* answer rather than an inference from SwiftUI focus state, which is
    /// what makes it true for the AppKit key-view loop's Tab traversal as well as for ⌘F.
    var isFocused = false

    /// Whether the toolbar currently carries the search item — the field's home.
    ///
    /// Kept current by `BoardToolbar`, which reads the toolbar's contents on every configuration
    /// change. It starts `true` because that is the shipped default; a saved configuration without
    /// the item corrects it as soon as the toolbar joins the window.
    var isInstalledInToolbar = true

    /// Whether the transient host is on screen — ⌘F's fallback when the item has been removed
    /// (03-board-ui.md ▸ Toolbar). `BoardWindowHost` renders it; nothing else may set it.
    private(set) var isTransient = false

    /// Set when the transient host was raised by a ⌘F that had no field to focus yet — the field
    /// arrives one SwiftUI update later, and claims the keyboard when it does.
    private var wantsFocusOnAppear = false

    /// Puts the keyboard in the toolbar's field, **answering whether it could**. `nil` until the
    /// item that hosts that field has been made, which is also exactly when the toolbar has no
    /// search item to focus.
    ///
    /// It is `NSSearchToolbarItem.beginSearchInteraction()` underneath (`adopt`): one call that
    /// expands the item to its focused width *and* moves the keyboard in, rather than a bare
    /// `makeFirstResponder` that would focus a field still at its resting width.
    ///
    /// The answer matters because an installed item is not always a reachable one: pushed into the
    /// system overflow by a narrow window, the field is in no window and cannot take the keyboard.
    /// "⌘F always summons search" (03-board-ui.md ▸ Toolbar), so that case falls through to the
    /// transient host exactly as a removed item does — an item the user cannot type into is a
    /// removal by another name.
    var focusField: (() -> Bool)?

    /// Makes the *transient* field first responder — the same job, one row lower.
    var focusTransientField: (() -> Void)?

    /// Returns the keyboard to the lane strip — **Escape's second step** in an empty field
    /// (04 § Search: "in an empty field it returns focus to the board"). Filled in by `BoardView`,
    /// which owns the strip's `@FocusState`; the field cannot do this itself, because resigning
    /// first responder would leave the window focused and the board's grammar keys dead.
    var focusBoard: (() -> Void)?

    // MARK: - ⌘F

    /// What ⌘F does, given where the field lives right now — the decision, as a value, so 03's
    /// transient clause is testable without a toolbar.
    enum Invocation: Equatable {
        /// The item is installed: focus it, which is all ⌘F ever meant before the toolbar became
        /// customizable.
        case focusToolbarField
        /// The item is gone and the transient host is not up yet: raise it, then focus.
        case surfaceTransiently
        /// The transient host is already up (a second ⌘F, or one during an active search): just
        /// focus it. Re-raising would be a no-op the user could not tell from a re-focus, but
        /// keeping it separate is what makes "surfacing" mean the transition and not the state.
        case focusTransientField
    }

    static func invocation(isInstalledInToolbar: Bool, isTransient: Bool) -> Invocation {
        if isInstalledInToolbar { return .focusToolbarField }
        return isTransient ? .focusTransientField : .surfaceTransiently
    }

    /// **Whether the transient host has earned its place** — 03's "until the search clears", read
    /// honestly: a query still filtering the board keeps it, and so does the keyboard being in it.
    ///
    /// The focus half is not a decoration. Without it, typing a query and deleting it back to empty
    /// would yank the field out from under the caret mid-edit; with it, the surface survives exactly
    /// as long as the user is still searching, and Escape's staged exit (clear, then hand the
    /// keyboard back) dismisses it on the second press, which is the same shape the field's Escape
    /// already has.
    static func transientPersists(query: String, isFocused: Bool) -> Bool {
        !query.isEmpty || isFocused
    }

    /// Edit ▸ Find's whole behaviour on a board window.
    func invokeSearch() {
        switch Self.invocation(isInstalledInToolbar: isInstalledInToolbar, isTransient: isTransient) {
        case .focusToolbarField:
            // A field in the system overflow answers `false` — see `focusField`.
            if focusField?() != true { surfaceTransiently() }
        case .focusTransientField:
            focusTransientField?()
        case .surfaceTransiently:
            surfaceTransiently()
        }
    }

    private func surfaceTransiently() {
        // The strip joins and leaves the window's layout, so it animates in the structural voice —
        // with Reduce Motion asked of AppKit rather than of the environment, because the caller is a
        // menu command whose content is built outside any rendered hierarchy
        // (`Motion.prefersReducedMotion`).
        withAnimation(Motion.structural(reduced: Motion.prefersReducedMotion)) {
            isTransient = true
        }
        // The host renders the field on the next update, so there is usually nothing to focus yet;
        // the flag is claimed by the field as it appears.
        if let focusTransientField {
            focusTransientField()
        } else {
            wantsFocusOnAppear = true
        }
    }

    /// Claimed once, by the transient field as it arrives.
    func consumeFocusOnAppear() -> Bool {
        defer { wantsFocusOnAppear = false }
        return wantsFocusOnAppear
    }

    /// Takes the transient host back down once the search has cleared. Called by the host on every
    /// query and focus change; a no-op whenever the field is in the toolbar, where there is no
    /// transient host to take down.
    func dismissTransientIfCleared(query: String) {
        guard isTransient, !Self.transientPersists(query: query, isFocused: isFocused) else { return }
        withAnimation(Motion.structural(reduced: Motion.prefersReducedMotion)) {
            isTransient = false
        }
        wantsFocusOnAppear = false
        // The field goes with the strip; a stale handle would aim ⌘F at a view that has left the
        // window, and the next surfacing makes a new one.
        focusTransientField = nil
    }
}

/// The focused board window's search field, beside `FocusedValues.boardStore` and
/// `FocusedValues.boardInfo` — see `FocusedBoardStoreKey` for why board-window menu items reach
/// their window this way.
struct FocusedBoardSearchKey: FocusedValueKey {
    typealias Value = BoardSearchPresentation
}

extension FocusedValues {
    var boardSearch: BoardSearchPresentation? {
        get { self[FocusedBoardSearchKey.self] }
        set { self[FocusedBoardSearchKey.self] = newValue }
    }
}

// MARK: - Edit ▸ Find

/// Edit ▸ Find (⌘F) — "Board window: board search" (11-command-nexus.md; 04-interactions.md
/// § Search: "Search field invoked with ⌘F").
///
/// **Focus, not toggle.** ⌘I toggles the board popover because a shortcut that could only ever open
/// would leave that surface with no keyboard way out; the search field's way out is Escape's staged
/// exit, which the field owns, so ⌘F only ever means "put the keyboard here" — and pressing it with
/// the field already focused is a no-op the user cannot tell from a re-focus.
///
/// **Validation is scope and nothing else**, `BoardInfoCommand`'s rule for its reason: searching is
/// not a mutation, so neither the read-only lock nor the focused-editor rule closes it. A board
/// window is the only context it has (the card window's Edit ▸ Find is find-in-text — 05), and with
/// no board in front both focused values are absent, which is the disable.
///
/// **A board window in front is now the whole of the scope**, where m5 additionally required the
/// toolbar's field to exist: with the item removed, ⌘F raises the transient host instead
/// (`BoardSearchPresentation.invokeSearch`), so there is no board window where the row is dead.
/// **The card window's find routes by focus** (05-card-window.md ▸ Preview, comments clause): the
/// body's substrate when the body has the keyboard, the whole rendered thread when the comments pane
/// does, and that editor's own stock find bar when the composer or an inline session does. The rule
/// itself is `CardWindowFind.route`, pure and pinned; this row only asks it.
struct FindCommand: View {

    @FocusedValue(\.boardStore) private var store
    @FocusedValue(\.boardSearch) private var search
    @FocusedValue(\.cardBody) private var cardBody
    @FocusedValue(\.cardComments) private var comments

    /// **The card window wins when it is the focused scene**, which is the whole of 11's split
    /// ("Board window: board search; card window: find-in-text"): the two never both publish, so
    /// this reads as a preference only because a scene value is `nil` in the scene that does not
    /// have one. The card side is the standard find bar over its body surface
    /// (`CardBodyPresentation.findInText`); the board side focuses the search field.
    private var findInText: (() -> Void)? { cardBody?.findInText }

    var body: some View {
        Button("Find") {
            // The pane answers first and says whether it took the key: with the comments pane focused
            // — or its find bar already up — ⌘F is the thread's, and every other case falls through to
            // the body exactly as it did before the pane existed.
            if comments?.invokeFind(hasBody: findInText != nil) == true {
                return
            }
            if let findInText {
                findInText()
            } else {
                search?.invokeSearch()
            }
        }
        .keyboardShortcut("f", modifiers: .command)
        .disabled(findInText == nil && comments == nil && (store == nil || search == nil))
    }
}

// MARK: - The field

/// The board's search field — an `NSSearchField`, made and wired in one place for its **two homes**:
/// the toolbar item that is its default (`BoardToolbar`) and the transient host ⌘F raises when that
/// item has been removed (`BoardSearchBar`).
///
/// ### Why AppKit and not `.searchable`
///
/// Two requirements SwiftUI's modifier does not meet, both normative:
///
/// - **Explicit focus control.** ⌘F must put the keyboard in this field from a *menu item*, and
///   Escape in an empty field must hand the keyboard back to the strip. `.searchable` owns its own
///   focus and offers no handle on either half; a first responder is what both need, so the field
///   has to be a view something can hold.
/// - **Stock `NSSearchField` key behaviour.** 04-interactions.md § Search settles the dispatch as
///   "every key with the field focused acts on the field — stock `NSSearchField` behavior, no
///   pass-throughs", which is a promise about *AppKit's* text-field key handling: the field editor
///   takes the arrows as caret motion, ⇧-arrows as text selection, ⌫ as backspace, ⌘A/⌘X/⌘C/⌘V as
///   the text clipboard, and ⌘Z as the field's own text undo. Hosting the real control is how that
///   sentence is implemented rather than reimplemented.
///
/// Only the two keys the design gives *different* meanings are intercepted (`doCommandBy` below):
/// Return, which is a swallowed no-op because a live filter has nothing to submit, and Escape,
/// whose staging is the design's own and not the cancel button's.
///
/// ### Live per keystroke
///
/// `controlTextDidChange` writes straight through to `BoardStore.searchQuery`, which is the filter
/// (`SearchFilter`) — no debounce, no commit step. The predicate is pure and the boards are one
/// folder deep, so the honest cost of a keystroke is one pass over the snapshot.
///
/// ### The store is the truth, in both directions
///
/// A query cleared by Escape or by a card's creation has to reach the control, and in the toolbar
/// there is no SwiftUI update pass to carry it — so the controller *observes* `searchQuery` and
/// writes it back into the field. The guard against re-assigning the user's own text is not an
/// optimisation: assigning `stringValue` resets the selection and the insertion point.
///
/// ### What nothing here does, and that is the point
///
/// **Losing focus does not clear the query.** "Tab is the keep-filter path: plain key-view traversal
/// moves focus to the board with the query intact, and the whole board grammar then applies over the
/// *filtered* board; ⌘F returns to the field" (04 § Search). Tab is the key-view loop's, untouched;
/// the query survives because only two things ever clear it — Escape and creation — and neither is
/// a blur.
///
/// **Nothing sets `isEditingInline`.** The field is a control, so the focused-editor lockdown stays
/// off and board menu commands keep acting on the selection, ⌘N included. The one narrow exception
/// is the caret chords, which read `BoardSearchPresentation.isFocused` (`caretChordsYield`).
@MainActor
final class BoardSearchFieldController: NSObject, NSSearchFieldDelegate {

    /// Which of the field's two homes this one is — the only thing that differs between them, and it
    /// differs in exactly one place: which focus handle the presentation gets, and who fills it in.
    /// The transient field fills its own in, because a field inside a SwiftUI view is the whole of
    /// that home; the toolbar's is the *item's* to give (`adopt`), because expanding the item and
    /// putting the keyboard in it is one call on `NSSearchToolbarItem` and the field cannot make it.
    enum Home {
        case toolbar
        case transient
    }

    private let store: BoardStore
    private let presentation: BoardSearchPresentation

    /// **Weak**: the field owns this controller (`FocusReportingSearchField.controller`), so the pair
    /// lives exactly as long as whoever holds the field — a toolbar item, or a SwiftUI view.
    private weak var field: FocusReportingSearchField?

    /// The item this field lives in, in the toolbar home — `nil` in the transient one, which has no
    /// item and so nothing to collapse. **Weak**: the item owns the field, which owns this.
    private weak var item: NSSearchToolbarItem?

    /// Makes the field, wires it, and hands it back. The caller owns the result and, through it,
    /// everything here.
    static func makeField(
        store: BoardStore,
        presentation: BoardSearchPresentation,
        home: Home
    ) -> NSSearchField {
        let field = FocusReportingSearchField()
        let controller = BoardSearchFieldController(store: store, presentation: presentation, field: field)
        field.controller = controller
        // The delegate and nothing else: the field's *action* is deliberately unwired, because an
        // action fires on submission and this filter has no submission. Every keystroke arrives as
        // `controlTextDidChange`, which is also how the stock cancel button reaches the store — it
        // clears the text, so it is Escape's first step arriving as an ordinary change to "".
        field.delegate = controller
        field.placeholderString = "Search"
        field.stringValue = store.searchQuery
        field.onFocusChange = { [presentation] focused in
            presentation.isFocused = focused
        }

        // The handle ⌘F pulls, filed under this field's home. Held weakly through the field, which
        // outlives neither its window nor its item.
        switch home {
        case .toolbar:
            // Filled in by `adopt` when the item that hosts this field is built: ⌘F's handle there
            // is the item's expand-and-focus, which is not the field's to offer.
            break
        case .transient:
            presentation.focusTransientField = { [weak field] in
                guard let field, let window = field.window else { return }
                window.makeFirstResponder(field)
            }
        }
        return field
    }

    /// Binds a field made for the toolbar to the `NSSearchToolbarItem` that hosts it — everything
    /// about that home the field alone cannot answer.
    ///
    /// ⌘F becomes `beginSearchInteraction()`, which expands the item to its focused width and moves
    /// the keyboard into the field in one call. **It still answers whether it could**: an item
    /// pushed into the system overflow has no window, and a field in no window cannot take the
    /// keyboard, so ⌘F falls through to the transient strip exactly as a removed item does
    /// (`BoardSearchPresentation.focusField`).
    ///
    /// Called for the installed item only. The customization palette's copy is inert
    /// (`makePaletteField`), so there is no controller here to adopt it and no handle for it to
    /// claim — a palette item that answered ⌘F would be a second live field.
    static func adopt(_ item: NSSearchToolbarItem, presentation: BoardSearchPresentation) {
        let field = item.searchField
        (field as? FocusReportingSearchField)?.controller?.item = item
        presentation.focusField = { [weak item, weak field] in
            guard let item, let field, field.window != nil else { return false }
            item.beginSearchInteraction()
            return true
        }
    }

    /// An inert field for the customization palette's copy of the search item: it looks like the
    /// real one and does nothing, because a palette item that wrote to the store or claimed ⌘F's
    /// handle would be a second live field.
    ///
    /// Three things make it inert and each is load-bearing: no delegate, so a keystroke reaches no
    /// store; no controller, so nothing observes the query; and no `adopt`, so ⌘F's handle stays the
    /// installed item's. The disable is the part a *user* can see —
    /// `NSSearchToolbarItem` overwrites it on assignment, and `WindowToolbarController` puts it back.
    static func makePaletteField() -> NSSearchField {
        let field = NSSearchField()
        field.placeholderString = "Search"
        field.isEnabled = false
        return field
    }

    private init(store: BoardStore, presentation: BoardSearchPresentation, field: FocusReportingSearchField) {
        self.store = store
        self.presentation = presentation
        self.field = field
        super.init()
        trackQuery()
    }

    /// Puts the store's query back into the control when something other than typing changed it.
    private func syncFromStore() {
        guard let field, field.stringValue != store.searchQuery else { return }
        field.stringValue = store.searchQuery
    }

    /// Re-arms itself after every change, `WindowToolbarController.trackValidationState`'s shape and
    /// for its reason: `withObservationTracking` is one-shot, and the field outlives any single
    /// notification. It stops for good when the field has gone.
    private func trackQuery() {
        withObservationTracking {
            _ = store.searchQuery
        } onChange: { [weak self] in
            Task { @MainActor in
                guard let self, self.field != nil else { return }
                self.syncFromStore()
                self.trackQuery()
            }
        }
    }

    // MARK: NSSearchFieldDelegate

    func controlTextDidChange(_ notification: Notification) {
        guard let field = notification.object as? NSSearchField else { return }
        store.searchQuery = field.stringValue
    }

    func controlTextDidEndEditing(_ notification: Notification) {
        presentation.isFocused = false
    }

    /// **Escape is staged and Return is swallowed** (04-interactions.md § Search, settled).
    ///
    /// - `cancelOperation:` — Escape. A non-empty field clears the query and *keeps* the
    ///   keyboard; an empty one hands it back to the board. One press, one layer, which is the
    ///   same shape `BoardView.handleEscape` gives the board side (and the third step of the
    ///   same staircase: with board focus and an active search, Escape clears the search).
    /// - `insertNewline:` — Return. "The filter is live, there is nothing to submit — it never
    ///   reaches the board's rename/create grammar." Returning `true` is that no-op: the key is
    ///   consumed here and the board never sees it.
    ///
    /// Everything else falls through to the field editor untouched, which is the whole of "stock
    /// `NSSearchField` behavior, no pass-throughs".
    func control(_ control: NSControl, textView: NSTextView, doCommandBy selector: Selector) -> Bool {
        switch selector {
        case #selector(NSResponder.cancelOperation(_:)):
            if store.searchQuery.isEmpty {
                presentation.focusBoard?()
                // The keyboard has left, so the field goes back to its resting width with it —
                // grow-on-focus, undone. A no-op in the transient home, which has no item.
                item?.endSearchInteraction()
            } else {
                store.clearSearch()
                control.stringValue = ""
            }
            return true
        case #selector(NSResponder.insertNewline(_:)):
            return true
        default:
            return false
        }
    }
}

/// An `NSSearchField` that says when it takes the keyboard, and keeps its controller alive.
///
/// The gain is reported here rather than through `controlTextDidBeginEditing` because that
/// notification is about an *edit session*, and the caret-chord rule is about focus: a field the
/// user has Tabbed or ⌘F'd into but not yet typed in already owns ⌘←/⌘→ as line-start/end. The loss
/// is the delegate's `controlTextDidEndEditing`, which fires when the field editor goes — the
/// symmetric hook (`resignFirstResponder`) is the field editor's rather than the control's and never
/// reaches this class.
///
/// `controller` is the one strong link in the pair: `NSControl.delegate` is weak, and a controller
/// nobody held would be gone before the first keystroke.
final class FocusReportingSearchField: NSSearchField {

    var onFocusChange: ((Bool) -> Void)?

    var controller: BoardSearchFieldController?

    override func becomeFirstResponder() -> Bool {
        let accepted = super.becomeFirstResponder()
        if accepted { onFocusChange?(true) }
        return accepted
    }
}

// MARK: - The transient host

/// The search field one row below its home — what ⌘F raises when the toolbar item has been removed
/// (03-board-ui.md ▸ Toolbar: "with the field removed from the toolbar, invoking it surfaces the
/// field transiently until the search clears").
///
/// **A strip in the window's content, not an item pushed back into the toolbar.** Re-inserting the
/// item would fight the thing the user just did — the arrangement is theirs, and it is autosaved —
/// and would leave the Customize sheet describing a toolbar that is about to change under it. A
/// find-bar-shaped strip below the title bar is the platform's own answer for a search surface that
/// comes and goes, and it leaves the toolbar exactly as customized.
///
/// It renders the *same* field as the toolbar item, made by the same factory: one implementation of
/// Escape's staging, of the live write-through, and of the focus reporting, whichever home it is in.
struct BoardSearchBar: View {

    let store: BoardStore
    let presentation: BoardSearchPresentation

    /// Reduce Transparency — this bar is one of the board's two glass underlays, beside the
    /// backdrop's title-bar frost ("glass underlays go solid, wherever they appear",
    /// 10-accessibility.md; the design's own example, the card face carousel's page dots, died with
    /// the carousel). `.bar` is a material, so under the setting it becomes the opaque window
    /// background (`Accommodations.Underlay`).
    @Environment(\.accessibilityReduceTransparency) private var reduceTransparency

    private var pointSize: CGFloat { BoardMetrics.bodyPointSize }

    var body: some View {
        VStack(spacing: 0) {
            HStack(spacing: 0) {
                Spacer(minLength: 0)
                BoardSearchFieldView(store: store, presentation: presentation)
                    // A field wide enough for a query, in characters rather than points, so it grows
                    // with the text it holds (10-accessibility.md's full-relative-scaling rule).
                    .frame(width: BoardMetrics.em(17, bodyPointSize: pointSize))
            }
            .padding(.horizontal, BoardMetrics.stripGap(bodyPointSize: pointSize))
            .padding(.vertical, BoardMetrics.lanePlatePadding(bodyPointSize: pointSize))
            .background(Accommodations.underlay(reduceTransparency: reduceTransparency).style)

            Divider()
        }
    }
}

/// The field, hosted for SwiftUI — the transient host's half of the two homes.
private struct BoardSearchFieldView: NSViewRepresentable {

    let store: BoardStore
    let presentation: BoardSearchPresentation

    func makeNSView(context: Context) -> NSSearchField {
        BoardSearchFieldController.makeField(store: store, presentation: presentation, home: .transient)
    }

    /// **Where the ⌘F that raised this bar lands.** The invocation happened one update ago, before
    /// this view existed, so the keyboard is claimed here — after SwiftUI has put the field in a
    /// window, which `makeNSView` cannot promise.
    func updateNSView(_ field: NSSearchField, context: Context) {
        guard presentation.consumeFocusOnAppear() else { return }
        Task { @MainActor in
            guard let window = field.window else { return }
            window.makeFirstResponder(field)
        }
    }
}
