import Foundation

/// **The OS's own SF Symbols index, read off disk** — the categories, their canonical order, and the
/// search keywords behind them. What `SymbolBrowserPanel` browses.
///
/// ### Why this is a read and not a hand-written list
///
/// There is no public API that enumerates SF Symbols, let alone their categories, so the obvious plan
/// is a curated constant. But `SymbolPickerCatalog.fullCatalog()` already establishes the better one:
/// the data ships with the OS as plain property lists inside `CoreGlyphs.bundle`, and that file
/// already reads one of them (`name_availability.plist`) for exactly this reason — a hand-written
/// inventory is "a hand-written guess that might be stale, and only guesses need checking", where the
/// bundle *is* the running system's answer. This extends that read to the four siblings that make a
/// real browser possible:
///
/// - `categories.plist` — the categories, in **Apple's own display order**, each with a
///   representative glyph. That ordering is a design decision Apple already made and this app has no
///   better one, so it is taken verbatim.
/// - `symbol_categories.plist` — every symbol's category memberships. A symbol may sit in several,
///   and does; the browser lists it under each.
/// - `symbol_order.plist` — the canonical ordering the SF Symbols app itself displays in, which is
///   grouped by shape and family. Sorting a category alphabetically instead would scatter
///   `arrow.up`, `arrow.down` and `arrow.left` across the grid; this keeps families together.
/// - `symbol_search.plist` — per-symbol search keywords. This is what makes "delete" find `trash`
///   and "password" find `key.slash`, which a name-substring search cannot.
///
/// ### The three rulings taken on that data (2026-08-09)
///
/// **Five categories are dropped** — `all`, `whatsnew`, `variable`, `multicolor`, `draw`. They
/// classify *rendering behaviour* or *release vintage*, not subject matter. "Multicolor" as a sibling
/// of "Nature" answers a question nobody browsing for a lane glyph is asking, and `whatsnew` is a
/// category that means something different every autumn.
///
/// **Trademark-restricted symbols are excluded.** `symbol_restrictions.strings` names about six
/// hundred glyphs — the Apple logo, iCloud, FaceTime, Apple Intelligence — each carrying "may only be
/// used to refer to" its product. Offering them in a general glyph browser invites a misuse the app
/// can prevent for the price of one set lookup. A board that already has one on disk still renders it:
/// this narrows what the *picker offers*, never what the renderer honours, which is the same split
/// `03-board-ui.md` draws with "curated in-app, unlimited on disk".
///
/// **`indices` stays**, numerals in every script and all. Letters and digits in circles are genuinely
/// useful for numbered lanes, `symbol_order` puts the Latin ones first, and dropping the Arabic-Indic
/// and Devanagari variants to tidy the grid would be the worse call.
///
/// ### What is *not* filtered
///
/// Names are **not** run through `ItemSymbol.exists`. `fullCatalog()`'s reasoning applies unchanged —
/// the plists are the running OS's inventory, so checking them against the running OS is cost for an
/// answer already given, and thousands of `NSImage` lookups per keystroke is a real cost. (Verified
/// once by hand at the time of writing: every name in `symbol_categories.plist` also appears in
/// `name_availability.plist`, zero misses.) The *curated* lists remain filtered, because those are
/// the guesses.
enum SymbolCatalog {

    // MARK: - Types

    /// One browsable category: what it is called, what it looks like in a sidebar, and what is in it.
    struct Category: Identifiable, Sendable, Equatable {
        /// The OS's own key — `objectsandtools`. Stable across releases and the browser's selection
        /// token, which is why selection survives a reload where a localized title would not.
        let key: String
        /// The sidebar's label.
        let title: String
        /// The representative glyph `categories.plist` names for it.
        let icon: String
        /// Its members, in `symbol_order.plist`'s canonical order.
        let symbols: [String]

        var id: String { key }
    }

    /// Everything one load produces — kept as one value so the loader is a pure function of a bundle
    /// path and the test suite can drive it against a path that isn't there.
    struct Contents: Sendable, Equatable {
        let categories: [Category]
        /// Every offered symbol, canonically ordered — the browser's "All Symbols".
        let allSymbols: [String]
        /// Symbol → its search keywords. Absent for most symbols; the search falls back to the name.
        let keywords: [String: [String]]
    }

    // MARK: - Loading

    /// Where the OS keeps its SF Symbols metadata — read-only system data, present on every Mac that
    /// ships SF Symbols at all. The same bundle `SymbolPickerCatalog` reads.
    static let defaultBundlePath = "/System/Library/CoreServices/CoreGlyphs.bundle"

    /// Categories that classify rendering behaviour or release vintage rather than subject matter —
    /// see this type's own doc comment.
    static let excludedCategoryKeys: Set<String> = ["all", "whatsnew", "variable", "multicolor", "draw"]

    /// The category titles, which are the one thing here that **is** hand-written: the bundle ships
    /// no localized names for its keys, only the keys. Twenty-seven pairs, and `title(forKey:)` falls
    /// back to a title-cased key for one a future OS adds — a new category shows up in the browser
    /// reading a little awkwardly rather than not showing up at all.
    static let categoryTitles: [String: String] = [
        "accessibility": "Accessibility",
        "arrows": "Arrows",
        "automotive": "Automotive",
        "cameraandphotos": "Camera & Photos",
        "commerce": "Commerce",
        "communication": "Communication",
        "connectivity": "Connectivity",
        "devices": "Devices",
        "editing": "Editing",
        "fitness": "Fitness",
        "gaming": "Gaming",
        "health": "Health",
        "home": "Home",
        "human": "Human",
        "indices": "Indices",
        "keyboard": "Keyboard",
        "maps": "Maps",
        "math": "Math",
        "media": "Media",
        "nature": "Nature",
        "objectsandtools": "Objects & Tools",
        "privacyandsecurity": "Privacy & Security",
        "shapes": "Shapes",
        "textformatting": "Text Formatting",
        "time": "Time",
        "transportation": "Transportation",
        "weather": "Weather",
    ]

    /// The default path's contents, loaded once. A `static let` rather than a `lazy var`, for
    /// `SymbolPickerCatalog.cachedFullCatalog`'s reason: the load is synchronous and the result is
    /// immutable and `Sendable`, so Swift's own thread-safe one-time global initialization is the
    /// whole of the cache, with no actor to hang it off.
    private static let cached: Contents = load(bundlePath: defaultBundlePath)

    /// The contents for `bundlePath` — cached at the real system location, reloaded anywhere else,
    /// which is `fullCatalog(bundlePath:)`'s bargain and the honest cost of asking a question the
    /// cache was never built to answer.
    static func contents(bundlePath: String = defaultBundlePath) -> Contents {
        bundlePath == defaultBundlePath ? cached : load(bundlePath: bundlePath)
    }

    static var categories: [Category] { cached.categories }
    static var allSymbols: [String] { cached.allSymbols }

    /// The title for `key` — the table above, else the key title-cased on its word boundaries as best
    /// they can be guessed from a lowercase run (there are none, so `objectsandtools` would come back
    /// as "Objectsandtools"; the table exists precisely so that never happens for a key we know).
    static func title(forKey key: String) -> String {
        categoryTitles[key] ?? key.prefix(1).uppercased() + key.dropFirst()
    }

    /// The load, and its one fallback.
    ///
    /// A bundle that won't open, a resource that isn't there, or a plist whose shape this reader
    /// cannot make sense of all read the same way — as "no inventory" — rather than as several
    /// failure modes to chase, which is `SymbolPickerCatalog.load`'s posture and its reason. The
    /// fallback is a single synthetic category over the app's own curated sets, so the browser always
    /// has something to show and something to search even on a system whose metadata this cannot
    /// read.
    static func load(bundlePath: String) -> Contents {
        guard let bundle = Bundle(path: bundlePath) else { return fallback() }

        // A `.strings` file, but a binary plist inside — a dictionary of name → the sentence
        // explaining what the trademark permits. Only the keys matter here.
        let restricted = Set((plist(bundle, "symbol_restrictions", "strings") as? [String: Any] ?? [:]).keys)
        let order = plist(bundle, "symbol_order", "plist") as? [String] ?? []
        let membership = plist(bundle, "symbol_categories", "plist") as? [String: [String]] ?? [:]
        let keywords = plist(bundle, "symbol_search", "plist") as? [String: [String]] ?? [:]
        let ordered = plist(bundle, "categories", "plist") as? [[String: String]] ?? []

        guard !membership.isEmpty, !ordered.isEmpty else { return fallback() }

        // `symbol_order` is the display order; a symbol missing from it (there are a handful) sorts
        // after everything that is in it, alphabetically among its peers, rather than to the front.
        var rank: [String: Int] = [:]
        rank.reserveCapacity(order.count)
        for (index, name) in order.enumerated() where rank[name] == nil { rank[name] = index }
        func canonical(_ names: [String]) -> [String] {
            names.sorted { lhs, rhs in
                switch (rank[lhs], rank[rhs]) {
                case let (left?, right?): left < right
                case (_?, nil): true
                case (nil, _?): false
                case (nil, nil): lhs < rhs
                }
            }
        }

        var buckets: [String: [String]] = [:]
        var offered = Set<String>()
        for (name, keys) in membership where !restricted.contains(name) {
            var isOffered = false
            for key in keys where !excludedCategoryKeys.contains(key) {
                buckets[key, default: []].append(name)
                isOffered = true
            }
            // A symbol whose every category was dropped is not offered at all — it would be
            // unreachable in the sidebar and would only ever surface from a search, which is a
            // confusing half-presence.
            if isOffered { offered.insert(name) }
        }

        let categories: [Category] = ordered.compactMap { entry in
            guard let key = entry["key"], !excludedCategoryKeys.contains(key),
                  let symbols = buckets[key], !symbols.isEmpty
            else { return nil }
            return Category(
                key: key,
                title: title(forKey: key),
                icon: entry["icon"] ?? "square.grid.2x2",
                symbols: canonical(symbols)
            )
        }
        guard !categories.isEmpty else { return fallback() }

        return Contents(
            categories: categories,
            allSymbols: canonical(Array(offered)),
            keywords: keywords
        )
    }

    private static func plist(_ bundle: Bundle, _ name: String, _ type: String) -> Any? {
        guard let path = bundle.path(forResource: name, ofType: type),
              let data = FileManager.default.contents(atPath: path)
        else { return nil }
        return try? PropertyListSerialization.propertyList(from: data, format: nil)
    }

    /// One synthetic category over the app's own curated vocabulary — see `load(bundlePath:)`.
    private static func fallback() -> Contents {
        let symbols = Set(SymbolPickerCatalog.defaultSet + CuratedSymbols.combined).sorted()
        return Contents(
            categories: [Category(key: "all", title: "All Symbols", icon: "square.grid.2x2", symbols: symbols)],
            allSymbols: symbols,
            keywords: [:]
        )
    }

    // MARK: - Search

    /// Whether `name` (with `keywords`) matches `query` — **pure**, so the AND semantics and the
    /// keyword reach are assertable without a panel on screen.
    ///
    /// A query splits into whitespace-separated tokens and **every** token must appear, as a
    /// case-insensitive substring, either in the name or in one of the keywords. `SymbolPickerCatalog.
    /// filter`'s rule, one dimension wider: that one searches names alone, which is right for a
    /// thirty-six-glyph curated grid and useless against nine thousand, where the word a user reaches
    /// for ("delete", "password", "wifi") is frequently not in the name at all.
    ///
    /// Tokens are matched independently, so `"arrow down"` finds `arrow.down` *and* anything keyworded
    /// both — the Spotlight-ish behaviour a search field is expected to have.
    static func matches(query tokens: [String], name: String, keywords: [String]) -> Bool {
        guard !tokens.isEmpty else { return true }
        let lowered = name.lowercased()
        let loweredKeywords = keywords.map { $0.lowercased() }
        return tokens.allSatisfy { token in
            lowered.contains(token) || loweredKeywords.contains { $0.contains(token) }
        }
    }

    /// `query` split into the lowercased tokens `matches(query:name:keywords:)` wants — trimmed
    /// first, and an empty result of that is "no query" rather than "match nothing", so a freshly
    /// opened search field shows everything.
    static func tokens(_ query: String) -> [String] {
        query.trimmingCharacters(in: .whitespacesAndNewlines)
            .split(whereSeparator: { $0.isWhitespace })
            .map { $0.lowercased() }
    }

    /// `symbols` narrowed to those matching `query`, order preserved.
    static func search(_ query: String, in symbols: [String], keywords: [String: [String]]) -> [String] {
        let tokens = tokens(query)
        guard !tokens.isEmpty else { return symbols }
        return symbols.filter { matches(query: tokens, name: $0, keywords: keywords[$0] ?? []) }
    }
}
