import AppKit
import Testing
@testable import Kanban

/// The colour vocabulary `background` and `iconColor` are written in (03-board-ui.md § Styling ▸
/// Capabilities): a kebab-case palette name, or a `#RRGGBB[AA]` hex — and **nil for everything
/// else**, which is a rendering instruction, never an error.
///
/// The palette tables themselves are pinned by name *and* hex rather than merely counted: they
/// are a carried-over design artefact ("the pathfinder's palettes … carry over as the starting
/// point"), and their names are what users hand-write into files — a silent rename or a shifted
/// hex would change what an existing board renders as.

// MARK: - The tables

struct PaletteTableTests {

    /// The pathfinder's twelve, plus the four 2026-08-09 hue-gap fills, each **at its hue position**
    /// rather than appended — the order is the ring, and this list is what pins it.
    @Test func bothTablesHoldTheSixteenNamedColoursInHueOrder() {
        #expect(Palette.foregrounds.map(\.name) == [
            "obsidian", "aluminum", "soapstone", "chalk",
            "carnation", "rich-grapefruit", "smokey-tangerine", "rich-lime",
            "fern", "light-jade", "light-teal", "deep-sky-blue",
            "rich-indigo", "pale-violet", "rich-magenta", "deep-cool-granite",
        ])
        #expect(Palette.backgrounds.map(\.name) == [
            "obsidian", "shale", "aluminum", "chalk",
            "light-cayenne", "light-mocha", "smokey-mocha", "smokey-lime",
            "smokey-fern", "dark-jade", "dark-teal", "smokey-ocean",
            "smokey-indigo", "smokey-rich-eggplant", "smokey-magenta", "intense-cool-shale",
        ])
    }

    /// The two tables are **one structure** (`Palette`'s own doc comment): four neutrals plus a
    /// twelve-stop ring each, paired stop for stop. A table grown on one side alone would break the
    /// pairing silently — every picker would still work, and the design would quietly stop being a
    /// design.
    @Test func theTwoTablesAreTheSameShape() {
        #expect(Palette.foregrounds.count == 16)
        #expect(Palette.backgrounds.count == 16)
        #expect(Set(Palette.foregrounds.map(\.name)).count == 16, "a name is listed twice")
        #expect(Set(Palette.backgrounds.map(\.name)).count == 16, "a name is listed twice")
        // Four neutrals lead each table; the twelve after them are the ring.
        #expect(Palette.foregrounds.dropFirst(4).count == 12)
        #expect(Palette.backgrounds.dropFirst(4).count == 12)
    }

    /// Every hex is spelled the one way the app emits them — uppercase `#RRGGBB` — so a panel pick
    /// that lands on a palette colour matches by string (`Palette.name(forHex:in:)`, `SystemColorPanel.
    /// changeColor(_:)`'s own case-insensitive compare) and comes back as the *name*. A lowercase
    /// entry would still resolve and would still round-trip; it would just quietly stop being
    /// recognised as the palette colour it is.
    @Test func everyHexIsSixUppercaseDigits() {
        for entry in Palette.foregrounds + Palette.backgrounds {
            #expect(entry.hex == entry.hex.uppercased(), "'\(entry.name)' is not uppercase")
            #expect(entry.hex.count == 7 && entry.hex.hasPrefix("#"), "'\(entry.name)' is not #RRGGBB")
            let digitsAreHex = entry.hex.dropFirst().allSatisfy { $0.isHexDigit }
            #expect(digitsAreHex, "'\(entry.name)' has a non-hex digit")
        }
    }

    /// The tint row's source: the icon palette minus the four greys and minus `deep-cool-granite`.
    ///
    /// **Eleven is load-bearing**, not incidental — `SymbolPickerLayout`'s colour grid is four wide
    /// and its leading None takes the first cell, so eleven is exactly what fills three rows. The
    /// palette grew to sixteen partly to make that true; this is the assertion that keeps the two
    /// facts tied together.
    @Test func theTintRingIsTheIconPaletteWithoutItsNeutrals() {
        #expect(Palette.tints.map(\.name) == [
            "carnation", "rich-grapefruit", "smokey-tangerine", "rich-lime",
            "fern", "light-jade", "light-teal", "deep-sky-blue",
            "rich-indigo", "pale-violet", "rich-magenta",
        ])
        #expect(Palette.tints.count == SymbolPickerLayout.colorColumns * SymbolPickerLayout.colorRows - 1)
        // Every tint is a foreground, and no grey slipped in.
        for tint in Palette.tints {
            #expect(Palette.foregrounds.contains { $0.name == tint.name && $0.hex == tint.hex })
        }
    }

    @Test func everyPaletteNameResolvesToItsOwnHex() throws {
        for entry in Palette.foregrounds + Palette.backgrounds {
            let byName = try #require(
                Palette.nsColor(for: entry.name),
                "palette name '\(entry.name)' did not resolve"
            )
            let byHex = try #require(
                NSColor(paletteHex: entry.hex),
                "palette hex '\(entry.hex)' for '\(entry.name)' is not parseable"
            )
            #expect(byName == byHex, "'\(entry.name)' resolved to something other than \(entry.hex)")
        }
    }

    /// The three names appearing in both tables are the same colour in each — `obsidian`,
    /// `aluminum` and `chalk` are one colour with one hex, listed twice because both *pickers*
    /// offer them. Resolution searches foregrounds first, so a drift would make the same written
    /// name mean two different things depending on which field it landed in.
    @Test func namesSharedByBothTablesCarryOneHex() {
        for name in ["obsidian", "aluminum", "chalk"] {
            let foreground = Palette.foregrounds.first { $0.name == name }?.hex
            let background = Palette.backgrounds.first { $0.name == name }?.hex
            #expect(foreground != nil && foreground == background, "'\(name)' differs between the two tables")
        }
    }

    /// Both tables answer either field: the foreground/background split is what each picker
    /// offers, not a namespace (`Palette.nsColor(for:)`). A hand-written `background: carnation`
    /// — a name only the icon-tint table lists — must resolve, not read as garbage.
    @Test func aNameFromEitherTableResolvesRegardlessOfWhichFieldItCameFrom() {
        #expect(Palette.nsColor(for: "carnation") != nil)          // foregrounds only
        #expect(Palette.nsColor(for: "intense-cool-shale") != nil) // backgrounds only
    }
}

// MARK: - Name matching

struct PaletteNameMatchingTests {

    /// Names are matched **exactly**, kebab-case as the tables spell them. A near-miss degrades
    /// like any other unknown value rather than being guessed at — the same posture `ItemSymbol`
    /// takes towards a typo'd symbol name.
    @Test func nameMatchingIsCaseSensitiveAndExact() {
        #expect(Palette.nsColor(for: "deep-sky-blue") != nil)
        #expect(Palette.nsColor(for: "Deep-Sky-Blue") == nil)
        #expect(Palette.nsColor(for: "DEEP-SKY-BLUE") == nil)
        #expect(Palette.nsColor(for: "deep sky blue") == nil)
        #expect(Palette.nsColor(for: "deepskyblue") == nil)
        #expect(Palette.nsColor(for: " fern") == nil)
        #expect(Palette.nsColor(for: "fern ") == nil)
    }

    /// `color(for:)` folds all three `FieldValue` shapes into one answer, exactly as
    /// `ItemSymbol.name(_:fallback:)` does: to a renderer, a missing key, a malformed one, and a
    /// valid-but-unknown name are one case — *there is no colour, so use your default*.
    @Test func everyUnusableFieldShapeReadsAsNoColour() {
        #expect(Palette.color(for: .valid("fern")) != nil)
        #expect(Palette.color(for: .valid("#FF0000")) != nil)
        #expect(Palette.color(for: FieldValue<String>.missing) == nil)
        #expect(Palette.color(for: .malformed(raw: "[a, b]")) == nil)
        #expect(Palette.color(for: .valid("chartreuse")) == nil)
    }
}

// MARK: - Hex parsing

/// `#RRGGBB[AA]` in **sRGB** — the colour space the digits name, so a value sampled from a
/// screenshot renders as the colour the author sampled.
struct PaletteHexTests {

    private func components(_ hex: String) throws -> (CGFloat, CGFloat, CGFloat, CGFloat) {
        let color = try #require(NSColor(paletteHex: hex), "'\(hex)' did not parse")
        let srgb = try #require(color.usingColorSpace(.sRGB), "'\(hex)' is not sRGB-convertible")
        return (srgb.redComponent, srgb.greenComponent, srgb.blueComponent, srgb.alphaComponent)
    }

    private func expectComponents(
        _ hex: String,
        _ expected: (red: CGFloat, green: CGFloat, blue: CGFloat, alpha: CGFloat)
    ) throws {
        let (red, green, blue, alpha) = try components(hex)
        let tolerance: CGFloat = 0.001
        #expect(abs(red - expected.red) < tolerance, "\(hex): red \(red) != \(expected.red)")
        #expect(abs(green - expected.green) < tolerance, "\(hex): green \(green) != \(expected.green)")
        #expect(abs(blue - expected.blue) < tolerance, "\(hex): blue \(blue) != \(expected.blue)")
        #expect(abs(alpha - expected.alpha) < tolerance, "\(hex): alpha \(alpha) != \(expected.alpha)")
    }

    @Test func sixDigitHexParsesToItsSRGBComponentsAtFullOpacity() throws {
        try expectComponents("#FF0000", (red: 1, green: 0, blue: 0, alpha: 1))
        try expectComponents("#00FF00", (red: 0, green: 1, blue: 0, alpha: 1))
        try expectComponents("#0000FF", (red: 0, green: 0, blue: 1, alpha: 1))
        try expectComponents("#000000", (red: 0, green: 0, blue: 0, alpha: 1))
        try expectComponents("#FFFFFF", (red: 1, green: 1, blue: 1, alpha: 1))
        // The rich-board fixture's board background — an ordinary hand-written value.
        try expectComponents("#1E1E1E", (red: 30 / 255, green: 30 / 255, blue: 30 / 255, alpha: 1))
    }

    @Test func eightDigitHexParsesItsTrailingPairAsAlpha() throws {
        try expectComponents("#FF000080", (red: 1, green: 0, blue: 0, alpha: 128 / 255))
        try expectComponents("#00FF00FF", (red: 0, green: 1, blue: 0, alpha: 1))
        try expectComponents("#0000FF00", (red: 0, green: 0, blue: 1, alpha: 0))
    }

    /// Hex digits are case-insensitive — unlike palette *names*. Two different vocabularies with
    /// two different rules, deliberately: `#ff0000` is the same number as `#FF0000`, where
    /// `Fern` is simply not a name the palette has.
    @Test func hexDigitsAreCaseInsensitive() throws {
        let lower = try #require(NSColor(paletteHex: "#a1b2c3"))
        let upper = try #require(NSColor(paletteHex: "#A1B2C3"))
        #expect(lower == upper)
    }

    /// Garbage resolves to nothing — no throw, no default colour, no partial read of a truncated
    /// value. Every one of these leaves the bytes on disk untouched and the surface undecorated.
    @Test func malformedAndUnknownValuesResolveToNil() {
        for value in [
            "",                 // the empty string
            "#",                // a lone marker
            "#12",              // too short
            "#12345",           // five digits
            "#1234567",         // seven — neither RGB nor RGBA
            "#123456789",       // nine
            "#GGGGGG",          // right length, not hex
            "#12345G",          // one bad digit
            "not-a-color",      // kebab-case, but not a name the palette has
            "FF0000",           // hex digits without the '#' read as a name, and no name matches
            "rgb(255,0,0)",     // a different colour vocabulary entirely
        ] {
            #expect(Palette.nsColor(for: value) == nil, "'\(value)' should not resolve")
            #expect(Palette.color(named: value) == nil, "'\(value)' should not resolve to a SwiftUI Color")
        }
    }
}
