import Foundation
import SwiftUI

/// **A reusable SF Symbol picker** — a single well showing the resolved symbol, opening a curated
/// grid with a search escape hatch (03-board-ui.md § Styling ▸ Controls: "its leading well is the
/// level's default symbol and removes the `icon` key … Any other SF Symbol name works written by
/// hand … No full-browser escape hatch in-app; the raw file is the escape hatch"). `StyleEditor.swift`
/// already builds that grid once, aimed at `background`/`icon` together and multiplexed across three
/// anchors; this file builds the *symbol half alone*, aimed at any single field a caller names, so a
/// control that only ever needs one glyph — a saved search, a smart filter, a future per-item
/// affordance — is not forced to carry the style editor's background section or its `BoardStore`
/// coupling to get one.
///
/// ### Why the curated set differs from `CuratedSymbols`
///
/// `CuratedSymbols.all` is grouped by what a *board item* is (status/flow, containers, people…) —
/// this control has no board item in mind, so `SymbolPickerCatalog.defaultSet` is a smaller,
/// ungrouped 36 chosen for the general "boards and projects" case instead. The two lists are free to
/// diverge; nothing here reads the other.
///
/// ### The one thing `CuratedSymbols` never needed
///
/// The style editor's curated grid has no search and no full-catalog fallback ("no full-browser
/// escape hatch in-app" is a statement about *that* surface). This picker adds one anyway, because a
/// general-purpose control cannot assume its 36 will always contain what the caller is after — a
/// search with nothing to search would just move the dead end from "no matching well" to "no way to
/// look further".

// MARK: - The symbol catalogs

/// The picker's two symbol lists: the curated 36-glyph grid it opens with, and the OS's full
/// inventory it searches into once the grid alone isn't enough.
enum SymbolPickerCatalog {

    /// The picker's curated grid, in order — a general "boards and projects" set rather than the
    /// style editor's kanban-item groupings, chosen so a first-run picker with no caller-supplied
    /// `symbols` still shows something broadly useful. A stored constant, not a computed property,
    /// for `CuratedSymbols.all`'s own reason: the list is the design decision, and `available` is the
    /// only thing the OS gets a say in.
    static let defaultSet: [String] = [
        "star", "flag", "heart", "bolt", "flame", "leaf", "drop", "sun.max", "moon", "sparkles",
        "tag", "bookmark", "pin", "bell", "paperplane", "tray", "folder", "archivebox", "doc.text",
        "list.bullet", "checklist", "calendar", "clock", "hammer", "wrench.and.screwdriver",
        "paintbrush", "lightbulb", "brain", "book", "graduationcap", "briefcase", "cart", "house",
        "airplane", "gamecontroller", "globe",
    ]

    /// The set this Mac can actually draw — `CuratedSymbols.available`'s rule, mirrored: a curated
    /// list is a convenience, never a claim about the running system.
    static var available: [String] { defaultSet.filter(ItemSymbol.exists) }

    /// Where the OS keeps the full SF Symbols inventory — read-only system metadata, present on
    /// every Mac that ships SF Symbols at all.
    private static let defaultBundlePath = "/System/Library/CoreServices/CoreGlyphs.bundle"

    /// The default path's catalog, loaded once. A `static let` rather than a `lazy var`: the load is
    /// synchronous and the result is a plain `[String]` — Sendable, immutable once computed — so
    /// Swift's usual thread-safe one-time global initialization is the whole of the "cache" this
    /// needs, with no actor to hang it off.
    private static let cachedFullCatalog: [String] = load(bundlePath: defaultBundlePath)

    /// Every SF Symbol name the running OS knows, sorted and deduplicated — the search grid's source.
    ///
    /// **Not filtered through `ItemSymbol.exists`.** The plist this reads already reflects the
    /// running OS's own inventory (it *is* the OS's inventory), and running a few thousand
    /// `NSImage(systemSymbolName:)` lookups against it on every search keystroke would be pure cost
    /// for an answer the file has already given for free. A curated list is different: it is a
    /// hand-written guess that might be stale, and only guesses need checking.
    ///
    /// `bundlePath` defaults to the real system location and is cached there; any other path — the
    /// test suite's nonexistent one, chiefly — reloads (and re-falls-back) on every call, which is
    /// the honest cost of asking a question the cache was never built to answer.
    static func fullCatalog(bundlePath: String = defaultBundlePath) -> [String] {
        bundlePath == defaultBundlePath ? cachedFullCatalog : load(bundlePath: bundlePath)
    }

    /// The plist read, and its one fallback: a bundle that won't open, a resource that isn't there,
    /// or a `"symbols"` key that isn't the dictionary this format has always used all read the same
    /// way — as "no inventory to read" — rather than as three different failure modes to chase. The
    /// merged curated set is never empty, so the picker always has *something* to search, even on a
    /// system whose metadata this reader cannot make sense of.
    private static func load(bundlePath: String) -> [String] {
        guard
            let bundle = Bundle(path: bundlePath),
            let plistPath = bundle.path(forResource: "name_availability", ofType: "plist"),
            let data = FileManager.default.contents(atPath: plistPath),
            let plist = try? PropertyListSerialization.propertyList(from: data, format: nil),
            let root = plist as? [String: Any],
            let symbols = root["symbols"] as? [String: Any]
        else {
            return Set(defaultSet + CuratedSymbols.all).sorted()
        }
        return symbols.keys.sorted()
    }

    /// `symbols` narrowed to the names matching `query` — pure, so the AND semantics and the
    /// order-preservation are assertable without a picker on screen.
    ///
    /// Whitespace-trimmed first, and an empty result of that is "no query", not "match nothing" — a
    /// freshly opened search field must show the full catalog, not a blank grid. A non-empty query
    /// splits into whitespace-separated tokens, every one of which must appear, case-insensitively,
    /// somewhere in the name: `"wrench screw"` finds `wrench.and.screwdriver` the way a Spotlight-style
    /// search would, rather than requiring the words adjacent or in order.
    static func filter(_ query: String, in symbols: [String]) -> [String] {
        let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines)
        guard !trimmed.isEmpty else { return symbols }
        let tokens = trimmed.split(whereSeparator: { $0.isWhitespace }).map { $0.lowercased() }
        return symbols.filter { name in
            let lowered = name.lowercased()
            return tokens.allSatisfy { lowered.contains($0) }
        }
    }
}

// MARK: - Geometry

/// The picker's font-derived geometry — well side, well spacing, the fixed 6×6 grid, and the
/// popover's own padding — following `StyleEditorLayout`'s derivation rather than restating it: the
/// base well side and spacing are read straight off `StyleEditorLayout`'s statics, then the grid's
/// wells and glyphs scale up by `gridScale` — a deliberate, user-tuned enlargement (the picker's grid
/// is this popover's whole subject, where the style editor's is one section among several), still
/// anchored to the shared base so the two components move together at every text size. The at-rest
/// button keeps the unscaled side (`restSide`) — it sits inline with a text field and matches that
/// field's height, not the grid's. Only the shape wraps a picker's own frame around them — six
/// columns fixed (not a
/// caller-configurable count, since a picker has no anchor-width story the way `StyleEditorLayout`'s
/// sidebar/popover split does), and a total padded width that is fixed for the same reason the
/// style editor's popover frame is: a popover is a window this app sizes, and a resizing one across
/// keystrokes would be distracting rather than helpful.
struct SymbolPickerLayout: Equatable {

    static let columns = 6
    static let rows = 6
    /// The grid's enlargement over the style editor's well size — glyphs read at a glance rather
    /// than in miniature.
    static let gridScale: CGFloat = 1.3

    /// The at-rest button's side — the unscaled base, matched to the style editor's wells and to
    /// the text-field height the button sits beside.
    var restSide: CGFloat
    /// The glyph's own point size inside a grid well — the body size under `gridScale`, since a
    /// symbol renders at the font size, not the frame; a bigger well alone would just add margin.
    var glyphPointSize: CGFloat
    var wellSide: CGFloat
    var wellSpacing: CGFloat
    /// The gap between the search field and the grid below it — one figure rather than a pixel
    /// literal, so Dynamic Type moves it with everything else (10-accessibility.md's full-relative-
    /// scaling rule).
    var searchSpacing: CGFloat
    /// The popover's own inset, on all four sides.
    var contentPadding: CGFloat
    var gridWidth: CGFloat
    /// The search grid's scroll cap — six rows tall, so a long result list scrolls inside the popover
    /// rather than growing it.
    var gridHeight: CGFloat
    /// The grid's width plus its padding on both sides — the popover's fixed width.
    var popoverWidth: CGFloat

    static func metrics(bodyPointSize: CGFloat) -> SymbolPickerLayout {
        let baseSide = StyleEditorLayout.wellSide(bodyPointSize: bodyPointSize)
        let side = (baseSide * gridScale).rounded()
        let spacing = StyleEditorLayout.wellSpacing(bodyPointSize: bodyPointSize)
        let padding = StyleEditorLayout.sectionSpacing(bodyPointSize: bodyPointSize)
        let gridWidth = (side * CGFloat(columns) + spacing * CGFloat(columns - 1)).rounded()
        let gridHeight = (side * CGFloat(rows) + spacing * CGFloat(rows - 1)).rounded()
        return SymbolPickerLayout(
            restSide: baseSide,
            glyphPointSize: (bodyPointSize * gridScale).rounded(),
            wellSide: side,
            wellSpacing: spacing,
            searchSpacing: spacing,
            contentPadding: padding,
            gridWidth: gridWidth,
            gridHeight: gridHeight,
            popoverWidth: (gridWidth + padding * 2).rounded()
        )
    }
}

// MARK: - The control

/// A single symbol well that opens a curated grid — the reusable primitive `03-board-ui.md`'s
/// full-browser refusal ("the raw file is the escape hatch") leaves room for: not a new in-app way to
/// hand-edit `icon`, but a control any caller can aim at one symbol field without wiring up a
/// `BoardStore`, a `StyleTarget`, or the two-dimension batch machinery `StyleEditorView` carries for
/// the board's own background+icon editor.
///
/// **View-local state only** — the popover's presented flag lives here, its search text lives with
/// the popover content. Nothing about a store, an undo stack, or a target set is known to this type;
/// `onSelect` is the whole of its contract with a caller, exactly as a `Picker`'s `selection` binding
/// would be.
struct SymbolPicker: View {

    /// The committed symbol name, or `nil` for "no override" — read alongside `fallback` rather than
    /// pre-resolved by the caller, so this view (and only this view) has to know the lenient-render
    /// rule (`ItemSymbol.name(_:fallback:)`'s rule, restated for a plain `String?` since a caller here
    /// may have no `FieldValue` at all).
    let current: String?
    /// The level default shown when `current` is absent or unresolvable, and the grid's leading well.
    let fallback: String
    /// The curated grid's contents. Defaults to `SymbolPickerCatalog.available` so a caller with no
    /// opinion gets the general-purpose set; a caller styling a specific domain (a template chooser,
    /// say) can supply its own.
    var symbols: [String] = SymbolPickerCatalog.available
    /// Whether the popover offers the search field and full-catalog fallback at all. `false` collapses
    /// the picker to the curated grid alone — a caller with no use for the OS's whole inventory
    /// (a fixed small vocabulary) is not forced to carry the search chrome anyway.
    var searchable: Bool = true
    /// The name to set, or `nil` to clear back to the default — mirrors `StyleChange`'s `set`/`remove`
    /// split without importing that type, since a caller outside the styling system has no `StyleChange`
    /// to hand back.
    let onSelect: (String?) -> Void

    @State private var isPresented = false

    private var pointSize: CGFloat { CardWindowMetrics.bodyPointSize }

    /// What the well actually draws — `current` if this system can resolve it, `fallback` otherwise.
    /// The same lenient rule `ItemSymbol.name(_:fallback:)` states for a `FieldValue`, restated here
    /// because this control's `current` is already a plain optional string by the time it arrives.
    private var resolvedName: String {
        if let current, ItemSymbol.exists(current) { return current }
        return fallback
    }

    var body: some View {
        let layout = SymbolPickerLayout.metrics(bodyPointSize: pointSize)
        Button {
            isPresented = true
        } label: {
            Image(systemName: resolvedName)
                .imageScale(.medium)
                .frame(width: layout.restSide, height: layout.restSide)
        }
        .buttonStyle(.bordered)
        .help("Symbol")
        .accessibilityLabel("Symbol")
        .accessibilityValue(resolvedName)
        .popover(isPresented: $isPresented, arrowEdge: .bottom) {
            SymbolPickerPopoverContent(
                current: current,
                fallback: fallback,
                symbols: symbols,
                searchable: searchable,
                layout: layout,
                onSelect: { name in
                    onSelect(name)
                    isPresented = false
                }
            )
        }
    }
}

// MARK: - The popover's content

/// The popover's body: the search field (when `searchable`), and either the curated grid or a
/// live search result — never both, since a query and the at-rest curated set answer the same
/// question two different ways.
private struct SymbolPickerPopoverContent: View {

    let current: String?
    let fallback: String
    let symbols: [String]
    let searchable: Bool
    let layout: SymbolPickerLayout
    let onSelect: (String?) -> Void

    @State private var query = ""

    var body: some View {
        VStack(alignment: .leading, spacing: layout.searchSpacing) {
            if searchable {
                searchField
            }
            resultBody
        }
        .padding(layout.contentPadding)
        .frame(width: layout.popoverWidth)
    }

    private var searchField: some View {
        TextField("Search Symbols", text: $query)
            .textFieldStyle(.roundedBorder)
            // **Escape steps outward one layer per press** (`BoardRenameField`'s idiom, the app's
            // standing Escape grammar): a non-empty query clears itself and keeps the popover open,
            // an empty one lets the press through to the popover's own dismissal.
            .onKeyPress(.escape) {
                guard !query.isEmpty else { return .ignored }
                query = ""
                return .handled
            }
    }

    @ViewBuilder
    private var resultBody: some View {
        let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines)
        if trimmed.isEmpty {
            SymbolWellGrid(wells: curatedWells, layout: layout) { well in
                onSelect(well.isDefault ? nil : well.name)
            }
        } else {
            let matches = SymbolPickerCatalog.filter(query, in: SymbolPickerCatalog.fullCatalog())
            if matches.isEmpty {
                Text("No matches")
                    .font(.caption)
                    .foregroundStyle(.secondary)
                    .frame(maxWidth: .infinity, alignment: .center)
                    .padding(.vertical, layout.wellSpacing)
            } else {
                ScrollView(.vertical) {
                    SymbolWellGrid(wells: matchWells(matches), layout: layout) { well in
                        onSelect(well.name)
                    }
                }
                .frame(height: layout.gridHeight)
            }
        }
    }

    /// The at-rest grid: the leading default well, then up to 35 more from `symbols` — 03-board-ui.md
    /// § Styling ▸ Controls' "leading well is the level's default symbol" rule, restated for this
    /// control's plain-optional `current`/`fallback` pair.
    ///
    /// `fallback` is dropped from the trailing set if present, so the default is never drawn twice —
    /// which is also why the trailing set is 35 rather than 36: the two together fill the 6×6 grid
    /// exactly when `fallback` was one of `symbols` to begin with (as it is for the card level, whose
    /// default `doc.text` sits inside `SymbolPickerCatalog.defaultSet`), and fall one well short of
    /// full when it wasn't (board and lane) — a quieter outcome than a grid that overflows its own
    /// 6×6 cap.
    private var curatedWells: [SymbolPickerWell] {
        let isDefaultSelected = current.map { !ItemSymbol.exists($0) } ?? true
        var wells = [SymbolPickerWell(
            id: 0,
            name: fallback,
            label: "Default (\(fallback))",
            isSelected: isDefaultSelected,
            isDefault: true
        )]
        let trailing = symbols.filter { $0 != fallback }
        for (index, name) in trailing.prefix(SymbolPickerLayout.columns * SymbolPickerLayout.rows - 1).enumerated() {
            wells.append(SymbolPickerWell(
                id: index + 1,
                name: name,
                label: name,
                isSelected: current == name,
                isDefault: false
            ))
        }
        return wells
    }

    private func matchWells(_ matches: [String]) -> [SymbolPickerWell] {
        matches.enumerated().map { index, name in
            SymbolPickerWell(id: index, name: name, label: name, isSelected: current == name, isDefault: false)
        }
    }
}

// MARK: - Wells

/// One well in either grid: what it draws, what it is called, and whether it is the leading default.
private struct SymbolPickerWell: Identifiable {
    let id: Int
    let name: String
    let label: String
    let isSelected: Bool
    /// Whether this is the leading "no override" well — drawn quieter (`StyleWellFace`'s
    /// `.defaultSymbol` treatment) so "no symbol set" and "this symbol set" read differently at a
    /// glance, and selected by `onSelect(nil)` rather than `onSelect(well.name)`.
    let isDefault: Bool
}

/// One well's face: the glyph, tinted by whether it is the default. `StyleEditor.swift`'s
/// `StyleWellFace` already draws this exact shape, but as a `private` type it is not this file's to
/// reach — a small sibling here, rather than widening that file's access for one caller outside it.
private struct SymbolWellFace: View {

    let name: String
    let isDefault: Bool
    let size: CGFloat
    /// The glyph's font size — set explicitly (`SymbolPickerLayout.glyphPointSize`) rather than
    /// inherited, since the grid's enlargement lives in the font, not the frame.
    let glyphPointSize: CGFloat

    var body: some View {
        Image(systemName: ItemSymbol.exists(name) ? name : "questionmark.square.dashed")
            .font(.system(size: glyphPointSize))
            .foregroundStyle(isDefault ? AnyShapeStyle(.secondary) : AnyShapeStyle(.primary))
            .frame(width: size, height: size)
    }
}

/// One grid of wells: Tab-reachable buttons, arrow-navigable as a grid — `StyleWellGrid`'s pattern,
/// mirrored rather than shared for the same reason `SymbolWellFace` is its own type. The duplication
/// is small (one `move(_:)` handler) and the alternative — exporting `StyleWellGrid` generically out
/// of the style editor — would widen a file whose whole point is staying anchor-agnostic to a second,
/// unrelated caller.
private struct SymbolWellGrid: View {

    let wells: [SymbolPickerWell]
    let layout: SymbolPickerLayout
    let onSelect: (SymbolPickerWell) -> Void

    @FocusState private var focused: Int?
    @Environment(\.colorSchemeContrast) private var contrast

    var body: some View {
        LazyVGrid(
            columns: Array(
                repeating: GridItem(.flexible(minimum: layout.wellSide), spacing: layout.wellSpacing),
                count: SymbolPickerLayout.columns
            ),
            spacing: layout.wellSpacing
        ) {
            ForEach(wells) { well in
                Button {
                    onSelect(well)
                } label: {
                    SymbolWellFace(
                        name: well.name,
                        isDefault: well.isDefault,
                        size: layout.wellSide,
                        glyphPointSize: layout.glyphPointSize
                    )
                        .overlay(selectionRing(well.isSelected))
                        .contentShape(Rectangle())
                }
                .buttonStyle(.plain)
                .focusable()
                .focused($focused, equals: well.id)
                .help(well.label)
                .accessibilityLabel(well.label)
                .accessibilityAddTraits(well.isSelected ? [.isSelected] : [])
            }
        }
        .onKeyPress(keys: [.leftArrow, .rightArrow, .upArrow, .downArrow], phases: .down) { press in
            move(press.key)
        }
    }

    private func selectionRing(_ isSelected: Bool) -> some View {
        RoundedRectangle(cornerRadius: max(1, (layout.wellSide * 0.25).rounded()))
            .strokeBorder(
                isSelected ? AnyShapeStyle(Color.accentColor) : AnyShapeStyle(.clear),
                lineWidth: Accommodations.borderWidth(2, contrast: contrast)
            )
            .padding(-Accommodations.borderWidth(2, contrast: contrast) / 2)
    }

    /// One step per press, clamped at the ends — `StyleWellGrid.move(_:)`'s rule, restated for this
    /// grid's own fixed column count.
    private func move(_ key: KeyEquivalent) -> KeyPress.Result {
        let delta: Int
        switch key {
        case .leftArrow: delta = -1
        case .rightArrow: delta = 1
        case .upArrow: delta = -SymbolPickerLayout.columns
        case .downArrow: delta = SymbolPickerLayout.columns
        default: return .ignored
        }
        let current = focused ?? 0
        let next = min(max(0, current + delta), wells.count - 1)
        focused = next
        return .handled
    }
}
