import AppKit
import SwiftUI

/// **The standalone symbol picker** — the symbol combo's face-zone door, and the glyph half of the
/// rhyme the colour combo's face already had: click the swatch, the Colors panel opens; click the
/// glyph, this opens (2026-08-09).
///
/// ### Why a panel and not a popover
///
/// Because the thing it is rhyming with is a panel. `NSColorPanel.shared` is a floating window that
/// **stays up while you keep choosing**, streaming each pick to whatever surface opened it, and every
/// property that makes it feel like a tool rather than a menu follows from that: it can be moved out
/// of the way, it can stay open across several cards, and it does not steal the board's key window
/// permanently. A popover would have been the third popover in a stack (the board popover already
/// hosts a symbol combo whose trigger opens one) and would have closed the moment the user looked
/// away. So: an `NSPanel`, `.utilityWindow`, non-activating, hosting SwiftUI.
///
/// ### Shared, exactly like the colour panel
///
/// One panel, borrowed in turn. `present(…)` hands it a new owner's closure and the previously
/// presenting surface simply stops receiving picks — `SystemColorPanel`'s last-writer-wins, restated
/// for a window this app owns rather than one AppKit owns. That is what lets the card window's
/// sidebar, the board popover and a future caller all use it without any of them coordinating.
///
/// ### What a pick does
///
/// Fires `onSelect` **immediately**, and the panel stays open — the colour panel's continuous
/// behaviour, one dimension over. The receiving surface routes it through `StyleCommand.apply` exactly
/// as a well click does, so a pick here is one undoable step on the same stack, feeding the same
/// batch bracket. Unlike a colour drag there is nothing to debounce: a click is already discrete.
@MainActor
final class SymbolBrowserPanel: NSObject {

    static let shared = SymbolBrowserPanel()

    private var panel: NSPanel?
    private let model = SymbolBrowserModel()

    private override init() { super.init() }

    /// Opens the browser (or re-aims an open one) at one symbol field.
    ///
    /// - Parameters:
    ///   - current: the value as written, so the browser can show which glyph is live.
    ///   - fallback: the level's default — what **Use Default** goes back to, and what the browser
    ///     marks as selected when `current` is absent or unresolvable (`ItemSymbol.name(_:fallback:)`'s
    ///     lenient rule, which this control obeys like every other renderer).
    ///   - onSelect: a name to set, or `nil` to clear the key. `SymbolPicker.onSelect`'s contract
    ///     verbatim, so a caller wires the same closure to both doors.
    func present(current: String?, fallback: String, onSelect: @escaping (String?) -> Void) {
        model.current = current
        model.fallback = fallback
        model.onSelect = onSelect

        let panel = self.panel ?? makePanel()
        self.panel = panel
        panel.makeKeyAndOrderFront(nil)
    }

    /// **The closure is dropped when the window closes, and only then.**
    ///
    /// Not when the surface that opened it goes away, which is the tempting rule and the broken one:
    /// opening this browser dismisses whichever popover the picker was mounted in (the board popover,
    /// the Style… popover), so a claim tied to the opener's lifetime would be released a frame after
    /// it was made and every pick would go nowhere. The panel therefore holds the last closure it was
    /// given — outliving its opener on purpose, exactly as `SharedColorPanelSession` does — until
    /// another surface claims it or the user closes the window.
    ///
    /// Closing it matters because that closure retains its opener's `BoardStore`. One board's worth,
    /// bounded, and released here.
    private func panelWillClose() {
        model.onSelect = nil
    }

    private func makePanel() -> NSPanel {
        let panel = NSPanel(
            contentRect: NSRect(x: 0, y: 0, width: 560, height: 420),
            styleMask: [.titled, .closable, .resizable, .utilityWindow, .nonactivatingPanel],
            backing: .buffered,
            defer: false
        )
        panel.title = "Symbols"
        panel.isFloatingPanel = true
        panel.hidesOnDeactivate = false
        panel.isReleasedWhenClosed = false
        panel.minSize = NSSize(width: 460, height: 320)
        panel.contentView = NSHostingView(rootView: SymbolBrowserView(model: model))
        panel.center()
        // A utility panel that never becomes key could not host a search field; this one must.
        panel.becomesKeyOnlyIfNeeded = false
        NotificationCenter.default.addObserver(
            forName: NSWindow.willCloseNotification,
            object: panel,
            queue: .main
        ) { _ in
            MainActor.assumeIsolated { SymbolBrowserPanel.shared.panelWillClose() }
        }
        return panel
    }
}

// MARK: - The model

/// The browser's live state, shared between the panel (which owns it across presentations) and the
/// SwiftUI view (which observes it).
///
/// A reference type rather than the view's own `@State` because the panel outlives any one
/// presentation: re-aiming the browser at a different card must update the open window, not build a
/// second one. `category` and `query` deliberately **persist** across re-aims — a user who was
/// browsing Nature for one card is very likely still browsing Nature for the next, and resetting
/// their place would be the browser forgetting what it was doing.
@MainActor
@Observable
final class SymbolBrowserModel {

    var current: String?
    var fallback: String = ItemSymbol.card
    /// The selected category's key, or `nil` for **All Symbols**.
    var category: String?
    var query: String = ""

    /// The presenting surface's write. `@ObservationIgnored` because nothing observes it, and
    /// unannotated for `PickerRectControl.onClick`'s reason — this class is already main-actor
    /// isolated, so the closure is called there by construction.
    @ObservationIgnored
    var onSelect: ((String?) -> Void)?

    /// The grid's contents: the selected category (or everything), narrowed by the query.
    ///
    /// The query searches **within the selected category**, not across the whole catalog. Searching
    /// globally from inside a category would make the sidebar selection silently irrelevant the
    /// moment a character was typed; this way the two controls compose, and All Symbols is right
    /// there for a global search.
    var visibleSymbols: [String] {
        let contents = SymbolCatalog.contents()
        let base: [String]
        if let category, let hit = contents.categories.first(where: { $0.key == category }) {
            base = hit.symbols
        } else {
            base = contents.allSymbols
        }
        return SymbolCatalog.search(query, in: base, keywords: contents.keywords)
    }

    /// What the board would actually draw for the current value — the leading-well rule, so the
    /// browser marks the glyph that is on screen rather than the string on disk.
    var resolvedName: String {
        if let current, ItemSymbol.exists(current) { return current }
        return fallback
    }

    func select(_ name: String?) {
        current = name
        onSelect?(name)
    }
}

// MARK: - The view

/// The panel's content: a category sidebar, a search field, and a scrolling grid.
///
/// The shape is the SF Symbols app's, deliberately — it is the arrangement every Mac user who has
/// ever looked for a glyph already knows, and inventing a different one would be novelty for its own
/// sake.
private struct SymbolBrowserView: View {

    @Bindable var model: SymbolBrowserModel

    /// Focus starts in the search field: someone who opened a symbol browser is looking for a
    /// symbol, and the overwhelmingly common next act is to type its name.
    @FocusState private var searchFocused: Bool

    private var pointSize: CGFloat { CardWindowMetrics.bodyPointSize }

    /// The grid's well side — the symbol popover's own enlarged well (`SymbolPickerLayout`), reused
    /// so a glyph is the same size in both surfaces and the eye does not have to re-scale between
    /// them.
    private var wellSide: CGFloat {
        (StyleEditorLayout.wellSide(bodyPointSize: pointSize) * SymbolPickerLayout.gridScale).rounded()
    }

    private var spacing: CGFloat { StyleEditorLayout.wellSpacing(bodyPointSize: pointSize) }

    var body: some View {
        NavigationSplitView {
            sidebar
                .navigationSplitViewColumnWidth(min: 150, ideal: 170, max: 240)
        } detail: {
            detail
        }
        .frame(minWidth: 460, minHeight: 320)
    }

    // MARK: Sidebar

    private var sidebar: some View {
        List(selection: $model.category) {
            Label("All Symbols", systemImage: "square.grid.2x2")
                .tag(String?.none)
            Section("Categories") {
                ForEach(SymbolCatalog.categories) { category in
                    Label(category.title, systemImage: category.icon)
                        .tag(String?.some(category.key))
                }
            }
        }
        .listStyle(.sidebar)
    }

    // MARK: Detail

    private var detail: some View {
        let symbols = model.visibleSymbols
        return VStack(alignment: .leading, spacing: spacing) {
            searchField
            if symbols.isEmpty {
                ContentUnavailableView.search(text: model.query)
            } else {
                grid(symbols)
            }
            Divider()
            footer
        }
        .padding(spacing)
    }

    private var searchField: some View {
        HStack(spacing: spacing) {
            Image(systemName: "magnifyingglass")
                .foregroundStyle(.secondary)
            TextField("Search Symbols", text: $model.query)
                .textFieldStyle(.plain)
                .focused($searchFocused)
                // **Escape steps outward one layer per press** — the app's standing Escape grammar
                // (`BoardRenameField`, the symbol popover's own field): a non-empty query clears
                // itself, an empty one lets the press through to the panel's own close.
                .onKeyPress(.escape) {
                    guard !model.query.isEmpty else { return .ignored }
                    model.query = ""
                    return .handled
                }
            if !model.query.isEmpty {
                Button {
                    model.query = ""
                } label: {
                    Image(systemName: "xmark.circle.fill")
                        .foregroundStyle(.secondary)
                }
                .buttonStyle(.plain)
                .accessibilityLabel("Clear Search")
            }
        }
        .padding(.horizontal, spacing)
        .padding(.vertical, spacing / 2)
        .background(.quaternary, in: RoundedRectangle(cornerRadius: 6))
        .onAppear { searchFocused = true }
    }

    private func grid(_ symbols: [String]) -> some View {
        ScrollView(.vertical) {
            LazyVGrid(
                columns: [GridItem(.adaptive(minimum: wellSide + spacing), spacing: spacing)],
                spacing: spacing
            ) {
                // `id: \.self` is safe and cheap here: the OS's catalog is deduplicated by
                // construction (it is a dictionary's keys), so the names are unique.
                ForEach(symbols, id: \.self) { name in
                    well(name)
                }
            }
            .padding(.vertical, spacing / 2)
        }
    }

    private func well(_ name: String) -> some View {
        let isSelected = model.resolvedName == name
        return Button {
            model.select(name)
        } label: {
            Image(systemName: name)
                .font(.system(size: (pointSize * SymbolPickerLayout.gridScale).rounded()))
                .frame(width: wellSide, height: wellSide)
                .background(
                    RoundedRectangle(cornerRadius: 5)
                        .fill(isSelected ? AnyShapeStyle(Color.accentColor.opacity(0.25)) : AnyShapeStyle(.clear))
                )
                .overlay(
                    RoundedRectangle(cornerRadius: 5)
                        .strokeBorder(isSelected ? Color.accentColor : .clear, lineWidth: 2)
                )
                .contentShape(Rectangle())
        }
        .buttonStyle(.plain)
        .help(name)
        .accessibilityLabel(name)
        .accessibilityAddTraits(isSelected ? [.isSelected] : [])
    }

    /// The live value, stated, and the one control that is not a glyph: **Use Default**, which is the
    /// popover's leading default well restated as a button — the browser has thousands of wells and
    /// no sensible place to hide a special one among them.
    private var footer: some View {
        HStack(spacing: spacing) {
            Image(systemName: model.resolvedName)
                .foregroundStyle(.secondary)
            Text(model.current ?? "Default (\(model.fallback))")
                .font(.caption)
                .foregroundStyle(.secondary)
                .lineLimit(1)
                .truncationMode(.middle)
            Spacer(minLength: spacing)
            Button("Use Default") {
                model.select(nil)
            }
            .disabled(model.current == nil)
        }
    }
}
