import AppKit
import Testing
@testable import Kanban

/// **`SymbolCatalog`'s pure seams** — the OS category read behind `SymbolBrowserPanel` (2026-08-09).
/// The panel itself, its sidebar and its grid are deliberately untested, exactly as every other
/// SwiftUI surface in this app is; what is asserted here is the shape of the data it browses and the
/// search that narrows it.
///
/// The load reads real system plists, so a few of these are assertions about **this machine's**
/// SF Symbols inventory. That is the point rather than a compromise: the whole reason the categories
/// are read instead of hand-written is that the OS's answer is the true one, and a test that stubbed
/// it would only be checking the stub.

@Suite("SymbolCatalog ▸ the categories")
struct SymbolCatalogCategoryTests {

    @Test("The system load yields many categories, each non-empty and uniquely keyed")
    func categoriesLoad() {
        let categories = SymbolCatalog.categories
        #expect(categories.count > 10, "only \(categories.count) categories — the read has degraded to its fallback")
        #expect(Set(categories.map(\.key)).count == categories.count, "a category key is listed twice")
        for category in categories {
            #expect(!category.symbols.isEmpty, "'\(category.key)' is empty")
            #expect(!category.title.isEmpty, "'\(category.key)' has no title")
        }
    }

    /// The five non-semantic categories are dropped — see `SymbolCatalog`'s doc comment. `multicolor`
    /// is the one that would hurt most if it came back: it is the largest category in the file and
    /// classifies rendering, not subject.
    @Test("Rendering-mode and vintage categories are not offered")
    func nonSemanticCategoriesAreExcluded() {
        let keys = Set(SymbolCatalog.categories.map(\.key))
        for excluded in SymbolCatalog.excludedCategoryKeys {
            #expect(!keys.contains(excluded), "'\(excluded)' should not be browsable")
        }
    }

    /// Every offered category has a hand-written title — the one curated constant in this file, and
    /// the one that decays silently: a key gaining no entry falls back to a title-cased key, which
    /// reads as "Objectsandtools" rather than failing.
    @Test("Every offered category has a real title, not the title-cased fallback")
    func everyCategoryHasATitle() {
        for category in SymbolCatalog.categories {
            #expect(
                SymbolCatalog.categoryTitles[category.key] != nil,
                "no title for '\(category.key)' — add one to SymbolCatalog.categoryTitles"
            )
        }
    }

    /// Each category's representative glyph is one this system can draw. It is a name out of the OS's
    /// own plist, so a failure here means the read is misaligned with the running inventory rather
    /// than that somebody made a typo.
    @Test("Every category icon renders on this OS")
    func categoryIconsRender() {
        let missing = SymbolCatalog.categories.filter { !ItemSymbol.exists($0.icon) }
        #expect(missing.isEmpty, "unrenderable category icons: \(missing.map(\.key))")
    }

    /// A spot check that the categories mean what they say — `leaf` under Nature, `trash` under
    /// Objects & Tools, `arrow.up` under Arrows. Cheap, and it would catch a key/value transposition
    /// that every structural assertion above would sail through.
    @Test("Well-known glyphs sit in the categories a user would look in")
    func membershipIsPlausible() throws {
        func symbols(_ key: String) throws -> [String] {
            try #require(SymbolCatalog.categories.first { $0.key == key }, "no '\(key)' category").symbols
        }
        #expect(try symbols("nature").contains("leaf"))
        #expect(try symbols("objectsandtools").contains("trash"))
        #expect(try symbols("arrows").contains("arrow.up"))
        #expect(try symbols("time").contains("timer"))
    }

    /// Trademark-restricted glyphs are not offered — `SymbolCatalog`'s second ruling. `applelogo` is
    /// the clearest case; `icloud` is the one a picker would plausibly have surfaced by accident.
    @Test("Trademark-restricted glyphs are not offered")
    func restrictedSymbolsAreExcluded() {
        let offered = Set(SymbolCatalog.allSymbols)
        for restricted in ["applelogo", "icloud", "faceid"] where ItemSymbol.exists(restricted) {
            #expect(!offered.contains(restricted), "'\(restricted)' is trademark-restricted and should not be offered")
        }
    }

    /// **Everything offered is drawable.** The catalog is not filtered through `ItemSymbol.exists` at
    /// read time (that would be thousands of lookups for an answer the plist already gave), so this
    /// is the test that earns that shortcut — sampled rather than exhaustive, because exhaustive is
    /// exactly the cost the shortcut exists to avoid.
    @Test("A wide sample of the offered catalog renders on this OS")
    func offeredSymbolsRender() {
        let all = SymbolCatalog.allSymbols
        #expect(all.count > 1000, "only \(all.count) symbols — the read has degraded to its fallback")
        let step = max(1, all.count / 400)
        let sample = stride(from: 0, to: all.count, by: step).map { all[$0] }
        let missing = sample.filter { !ItemSymbol.exists($0) }
        #expect(missing.isEmpty, "offered but unrenderable: \(missing)")
    }

    /// Every category's members are a subset of the "All Symbols" list — the sidebar and the
    /// all-symbols view cannot disagree about what exists.
    @Test("No category offers a symbol the all-symbols list does not")
    func categoriesAreSubsetsOfAll() {
        let all = Set(SymbolCatalog.allSymbols)
        for category in SymbolCatalog.categories {
            let strays = category.symbols.filter { !all.contains($0) }
            #expect(strays.isEmpty, "'\(category.key)' offers \(strays.prefix(5)) which All Symbols does not")
        }
    }

    /// A bundle that is not there degrades to the app's own curated vocabulary rather than to an
    /// empty browser — `SymbolPickerCatalog.fullCatalog`'s own fallback posture, restated.
    @Test("A nonexistent bundle falls back to the curated sets, never to nothing")
    func nonexistentBundleFallsBack() {
        let contents = SymbolCatalog.load(bundlePath: "/nonexistent")
        let expected = Set(SymbolPickerCatalog.defaultSet + CuratedSymbols.combined).sorted()
        #expect(contents.allSymbols == expected)
        #expect(contents.categories.count == 1)
        #expect(contents.categories.first?.symbols == expected)
        #expect(contents.keywords.isEmpty)
    }

    @Test("Two reads of the system path agree — the cache is coherent")
    func cacheIsCoherent() {
        #expect(SymbolCatalog.contents().allSymbols == SymbolCatalog.contents().allSymbols)
        #expect(SymbolCatalog.categories.map(\.key) == SymbolCatalog.contents().categories.map(\.key))
    }

    @Test("An unknown key title-cases rather than coming back empty")
    func unknownTitleFallsBack() {
        #expect(SymbolCatalog.title(forKey: "nature") == "Nature")
        #expect(SymbolCatalog.title(forKey: "somethingnew") == "Somethingnew")
    }
}

// MARK: - Search

@Suite("SymbolCatalog ▸ search")
struct SymbolCatalogSearchTests {

    private let keywords = [
        "trash": ["delete", "remove", "garbage"],
        "key.slash": ["password", "security"],
        "star": ["favorite"],
    ]

    @Test("An empty or whitespace-only query returns the input unchanged")
    func emptyQueryIsANoOp() {
        let symbols = ["star", "flag", "heart"]
        #expect(SymbolCatalog.search("", in: symbols, keywords: [:]) == symbols)
        #expect(SymbolCatalog.search("   \t\n", in: symbols, keywords: [:]) == symbols)
    }

    /// **The reason this exists beside `SymbolPickerCatalog.filter`**: the word a user reaches for is
    /// frequently not in the name. A name-only search finds nothing for "delete".
    @Test("A keyword matches a symbol whose name does not contain the query at all")
    func keywordsAreSearched() {
        let symbols = ["trash", "star", "key.slash"]
        #expect(SymbolCatalog.search("delete", in: symbols, keywords: keywords) == ["trash"])
        #expect(SymbolCatalog.search("password", in: symbols, keywords: keywords) == ["key.slash"])
        // The name-only filter genuinely cannot do this — the contrast is the justification.
        #expect(SymbolPickerCatalog.filter("delete", in: symbols).isEmpty)
    }

    @Test("Names still match as case-insensitive substrings")
    func namesAreSearched() {
        let symbols = ["star", "star.fill", "flag"]
        #expect(SymbolCatalog.search("STAR", in: symbols, keywords: [:]) == ["star", "star.fill"])
    }

    @Test("Multiple tokens are an AND across names and keywords together")
    func multiTokenIsAnAnd() {
        let symbols = ["trash", "trash.slash", "star"]
        let keywords = ["trash.slash": ["delete", "disabled"], "trash": ["delete"]]
        #expect(SymbolCatalog.search("delete slash", in: symbols, keywords: keywords) == ["trash.slash"])
        #expect(SymbolCatalog.search("delete trash", in: symbols, keywords: keywords) == ["trash", "trash.slash"])
    }

    @Test("Input order is preserved — the canonical ordering survives a search")
    func orderPreserved() {
        let symbols = ["zebra.star", "apple.star", "mango.star"]
        #expect(SymbolCatalog.search("star", in: symbols, keywords: [:]) == symbols)
    }

    @Test("No match returns an empty list")
    func noMatchIsEmpty() {
        #expect(SymbolCatalog.search("xyzzy-nonexistent", in: ["star"], keywords: [:]).isEmpty)
    }

    @Test("Tokenizing trims and lowercases, and an empty query yields no tokens")
    func tokenizing() {
        #expect(SymbolCatalog.tokens("  Arrow  UP ") == ["arrow", "up"])
        #expect(SymbolCatalog.tokens("   ").isEmpty)
    }

    @Test("An empty token list matches everything — 'no query' is not 'match nothing'")
    func emptyTokensMatchEverything() {
        #expect(SymbolCatalog.matches(query: [], name: "anything", keywords: []))
    }

    /// Against the real catalog: the words a user would actually type find the glyphs they mean.
    @Test("Real searches find real glyphs")
    func realSearchesWork() {
        let contents = SymbolCatalog.contents()
        func find(_ query: String) -> [String] {
            SymbolCatalog.search(query, in: contents.allSymbols, keywords: contents.keywords)
        }
        #expect(find("trash").contains("trash"))
        #expect(find("wrench screw").contains("wrench.and.screwdriver"))
        #expect(find("calendar").contains("calendar"))
        #expect(find("qwertyuiop-nope").isEmpty)
    }
}

// MARK: - The shared rectangle chrome

/// **The rhyme, asserted.** The owner's 2026-08-10 ruling asked for both pickers to become "a
/// rectangle (slightly oversized)... about 4:6 ratio of height to width" — and the way that is made
/// true is structural — one metrics value, one base control — so the test is about the structure
/// rather than about two numbers that happen to agree today.
@Suite("PickerRect ▸ the shared chrome")
struct PickerRectMetricsTests {

    @Test("Every figure scales with the body font, and reproduces the shipped numbers at 13pt")
    func metricsAtTheStandardBody() {
        let metrics = PickerRectMetrics.metrics(bodyPointSize: 13)
        // Owner's ruling, 2026-08-10: "50% taller" than the retired two-zone chrome's 18pt (→ 27pt),
        // and "about 4:6 ratio" — width is height × 1.5 (`PickerRectMetrics`'s own doc comment).
        #expect(metrics.height == 27)
        #expect(metrics.width == 41)
        #expect(metrics.cornerRadius == 3)
        #expect(metrics.fieldRadius == 4)
    }

    @Test("A larger text size grows every figure, and none collapses to zero")
    func metricsScale() {
        let small = PickerRectMetrics.metrics(bodyPointSize: 11)
        let large = PickerRectMetrics.metrics(bodyPointSize: 24)
        #expect(large.height > small.height)
        #expect(large.width > small.width)
        for metrics in [PickerRectMetrics.metrics(bodyPointSize: 8), small, large] {
            #expect(metrics.height >= 1)
            #expect(metrics.width >= 1)
            #expect(metrics.cornerRadius >= 1)
        }
    }

    /// **The owner's 2026-08-10 figures, pinned.** Height is 2.1 em; width is derived from height
    /// (`height × 1.5`) rather than its own independent em multiple, which is what keeps the 4:6
    /// ratio exact — to rounding — at every body size instead of the two figures drifting apart.
    @Test("Height is 2.1 em, width is 1.5 × height, at every body size")
    func heightAndWidthHoldTheRatio() {
        for size in [8.0, 11.0, 13.0, 17.0, 24.0, 36.0] as [CGFloat] {
            let metrics = PickerRectMetrics.metrics(bodyPointSize: size)
            #expect(metrics.height == max(1, (size * 2.1).rounded()), "height drifted from 2.1 em at \(size)pt")
            #expect(metrics.width == max(1, (metrics.height * 1.5).rounded()), "width drifted from 1.5 × height at \(size)pt")
            // 4:6 as a ratio, within the slack one rounding step introduces at the smallest sizes.
            #expect(abs(metrics.width / metrics.height - 1.5) < 0.06, "ratio drifted from 4:6 at \(size)pt")
        }
    }

    /// The field radius runs a point outside the face's so the two rounded rects stay concentric —
    /// a small thing, and exactly the kind of thing that drifts when two files own it.
    @Test("The field's radius stays outside the face's")
    func radiiAreConcentric() {
        for size in [11.0, 13.0, 17.0, 24.0] as [CGFloat] {
            let metrics = PickerRectMetrics.metrics(bodyPointSize: size)
            #expect(metrics.fieldRadius >= metrics.cornerRadius)
        }
    }

    /// **The actual-rect rule, unaffected by the trigger's removal.** `SymbolGlyphControl` sizes a
    /// glyph off whichever face rect it is actually handed at draw time — now always the control's
    /// own `bounds`, since there is no trigger strip left to subtract. Both the point-size rule and
    /// the overshoot-scaling rule are pure static functions on `SymbolGlyphControl`, assertable with
    /// no control on screen and no draw.
    @Test("A glyph's point size and drawn size follow the actual face rect, not the pure metrics")
    func glyphSizesOffTheActualFaceRect() {
        // A short, wide rect — the shape the card sidebar's row actually proposes now that the field
        // is wider than it is tall. The binding dimension is height, not width.
        let wideRect = NSRect(x: 0, y: 0, width: 200, height: 27)
        #expect(SymbolGlyphControl.glyphPointSize(forFace: wideRect) == 27)

        // A tall, narrow rect, for symmetry — the binding dimension flips to width.
        let tallRect = NSRect(x: 0, y: 0, width: 12, height: 40)
        #expect(SymbolGlyphControl.glyphPointSize(forFace: tallRect) == 12)

        // A glyph configured within its rect never needs to grow.
        let snugSize = NSSize(width: 27, height: 27)
        #expect(SymbolGlyphControl.fittedSize(for: snugSize, in: wideRect) == snugSize)

        // A glyph wider than the point size it was configured at genuinely overshoots a *narrow*
        // rect on its long axis (the realistic case: `tallRect`'s 12pt binding dimension, a symbol a
        // touch wider than tall at that size) and must be scaled down proportionally, not clipped.
        let elongated = NSSize(width: 16, height: 12)
        let fitted = SymbolGlyphControl.fittedSize(for: elongated, in: tallRect)
        #expect(fitted.width < elongated.width, "the wide axis must actually shrink")
        #expect(fitted.width <= tallRect.width)
        #expect(fitted.height <= tallRect.height)
        #expect(abs(fitted.width / fitted.height - elongated.width / elongated.height) < 0.001,
                 "the scale-down must preserve the glyph's own aspect ratio")
    }

    /// **The two controls are the same control.** Both are `PickerRectControl`s and both take their
    /// geometry from the same value, so a change to one lands on the other — which is the whole of
    /// the parity claim, and cheaper to assert than any pair of measurements. There are no zones left
    /// to compare (`PickerRect.swift`'s own retirement of the trigger strip); the whole bounds is the
    /// one hit zone on both, so intrinsic size is the whole of what "same chrome" means now.
    @MainActor
    @Test("Both rectangles are the same chrome, at the same size")
    func bothRectanglesShareTheChrome() {
        let colour = ColorSwatchControl(frame: .zero)
        let symbol = SymbolGlyphControl(frame: .zero)
        for control in [colour as PickerRectControl, symbol] {
            control.metrics = .metrics(bodyPointSize: 13)
        }
        #expect(colour.intrinsicContentSize.height == symbol.intrinsicContentSize.height)
        #expect(colour.intrinsicContentSize.width == symbol.intrinsicContentSize.width)
    }
}
