import Testing
@testable import Kanban

/// **The symbol picker's pure seams** (03-board-ui.md § Styling ▸ Controls, the general-purpose
/// picker `SymbolPicker.swift` builds beside the style editor's own curated grid): the curated
/// default set, the full-catalog loader and its cache, and the search filter's AND semantics.
/// SwiftUI rendering — the well grid, the popover's arrow-key navigation — is deliberately untested,
/// exactly as `StyleEditor.swift`'s own wells are.
@Suite("SymbolPicker ▸ the curated default set")
struct SymbolPickerCatalogDefaultSetTests {

    @Test("Exactly 36 entries, all unique")
    func shape() {
        #expect(SymbolPickerCatalog.defaultSet.count == 36)
        #expect(Set(SymbolPickerCatalog.defaultSet).count == SymbolPickerCatalog.defaultSet.count)
    }

    @Test("Every entry is one this system can actually draw — pins the list against typos")
    func everyNameResolves() {
        let missing = SymbolPickerCatalog.defaultSet.filter { !ItemSymbol.exists($0) }
        #expect(missing.isEmpty, "unknown SF Symbol names: \(missing)")
        #expect(SymbolPickerCatalog.available.count == SymbolPickerCatalog.defaultSet.count)
    }
}

@Suite("SymbolPicker ▸ the colour row")
struct SymbolPickerColorSetTests {

    /// Eleven since the palette grew (2026-08-09), seven before it — stated as the grid arithmetic
    /// rather than as a literal, because the claim is *the leading None plus these fills the grid
    /// exactly*, and that is what breaks when either the palette or the column count moves.
    @Test("The leading None plus the tints fills the colour grid exactly")
    func shape() {
        #expect(SymbolPickerCatalog.colorSet.count
            == SymbolPickerLayout.colorColumns * SymbolPickerLayout.colorRows - 1)
        #expect(SymbolPickerCatalog.colorSet.count == 11)
        #expect(Set(SymbolPickerCatalog.colorSet).count == SymbolPickerCatalog.colorSet.count)
    }

    @Test("Every entry is a palette name that resolves — pins the list against typos and palette drift")
    func everyNameResolves() {
        let missing = SymbolPickerCatalog.colorSet.filter { Palette.color(named: $0) == nil }
        #expect(missing.isEmpty, "unresolvable palette names: \(missing)")
    }
}

@Suite("SymbolPicker ▸ filter")
struct SymbolPickerFilterTests {

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

    @Test("A single token matches case-insensitively, as a substring")
    func singleTokenSubstring() {
        let symbols = ["star", "star.fill", "flag", "flag.checkered"]
        #expect(SymbolPickerCatalog.filter("star", in: symbols) == ["star", "star.fill"])
        #expect(SymbolPickerCatalog.filter("STAR", in: symbols) == ["star", "star.fill"])
        #expect(SymbolPickerCatalog.filter("Fla", in: symbols) == ["flag", "flag.checkered"])
    }

    @Test("Multiple tokens are an AND — every token must appear somewhere in the name")
    func multiTokenIsAnAnd() {
        let symbols = ["wrench.and.screwdriver", "screwdriver", "wrench"]
        #expect(SymbolPickerCatalog.filter("wrench screw", in: symbols) == ["wrench.and.screwdriver"])
        #expect(SymbolPickerCatalog.filter("screw wrench", in: symbols) == ["wrench.and.screwdriver"])
    }

    @Test("Input order is preserved")
    func orderPreserved() {
        let symbols = ["zebra.star", "apple.star", "mango.star"]
        #expect(SymbolPickerCatalog.filter("star", in: symbols) == symbols)
    }

    @Test("No match returns an empty array")
    func noMatchIsEmpty() {
        #expect(SymbolPickerCatalog.filter("xyzzy-nonexistent", in: ["star", "flag"]).isEmpty)
    }
}

@Suite("SymbolPicker ▸ the full catalog")
struct SymbolPickerFullCatalogTests {

    @Test("The default path returns a sorted, unique, non-empty list containing well-known names")
    func defaultPathLoads() {
        let catalog = SymbolPickerCatalog.fullCatalog()
        #expect(!catalog.isEmpty)
        #expect(catalog == catalog.sorted())
        #expect(Set(catalog).count == catalog.count)
        #expect(catalog.contains("star"))
        #expect(catalog.contains("folder"))
    }

    @Test("Two calls against the default path return identical results — the cache is coherent")
    func cacheIsCoherent() {
        #expect(SymbolPickerCatalog.fullCatalog() == SymbolPickerCatalog.fullCatalog())
    }

    @Test("A nonexistent bundle path falls back to the merged curated set, sorted and unique")
    func nonexistentPathFallsBack() {
        // `CuratedSymbols.all` was one flat list; 2026-08-09 split it into three level-specific sets
        // plus `combined`, the union this fallback still reads (`SymbolPicker.swift`'s `load`).
        let expected = Set(SymbolPickerCatalog.defaultSet + CuratedSymbols.combined).sorted()
        #expect(SymbolPickerCatalog.fullCatalog(bundlePath: "/nonexistent") == expected)
    }
}
