import AppKit
import Foundation
import Testing
@testable import Kanban

/// **An arbitrary colour picked from the system panel, all the way to disk and back** (2026-08-09,
/// the card that put an **Other…** on the Style… popover and the symbol popover's tint row).
///
/// The guarantee already held — `FrontmatterValue.emitScalar` quotes a `#` because YAML says it must,
/// `BackgroundField.flowText` quotes unconditionally, `Palette.nsColor(for:)` takes the hex branch —
/// but it held as an *emergent property of four files that do not mention each other*. Nothing
/// asserted it end to end, and the one link that would break silently is the sharpest: `#` opens a
/// YAML comment, so an unquoted `background: #2E1A51` parses as a key with **no value at all**. A
/// colour would vanish on save and the file would still be valid YAML.
///
/// These are the assertions that make the chain a contract rather than a coincidence.

// MARK: - The codec

@Suite("Custom colour ▸ the hex codec")
struct CustomColorCodecTests {

    /// `NSColor → paletteHexString → NSColor` is the identity on any sRGB colour the panel can
    /// return, to within the 8-bit quantisation the format has.
    @Test("A panel colour survives the trip to hex and back")
    func opaqueColoursRoundTrip() throws {
        for (red, green, blue) in [
            (0.0, 0.0, 0.0), (1.0, 1.0, 1.0), (0.18, 0.10, 0.32), (0.42, 0.71, 0.60), (1.0, 0.0, 0.5),
        ] {
            let picked = NSColor(srgbRed: red, green: green, blue: blue, alpha: 1)
            let hex = try #require(picked.paletteHexString, "no hex for \(red),\(green),\(blue)")
            let parsed = try #require(NSColor(paletteHex: hex))
            let tolerance = 1.0 / 255 + 0.0001
            #expect(abs(parsed.redComponent - red) < tolerance)
            #expect(abs(parsed.greenComponent - green) < tolerance)
            #expect(abs(parsed.blueComponent - blue) < tolerance)
            #expect(parsed.alphaComponent == 1)
        }
    }

    /// **Full opacity collapses to six digits.** A colour the user never touched the opacity slider
    /// on has to be written exactly as a curated palette entry would be, or it would never match one
    /// (`Palette.name(forHex:in:)`'s case-insensitive compare, `SystemColorPanel.changeColor(_:)`'s
    /// own rule) and a panel pick that landed dead on `fern` would store an anonymous hex instead of
    /// the name.
    @Test("Opacity is written only when there is some")
    func alphaCollapsesAtFullOpacity() throws {
        let opaque = NSColor(srgbRed: 0.5, green: 0.25, blue: 0.75, alpha: 1)
        #expect(opaque.paletteHexString == "#8040BF")
        let translucent = NSColor(srgbRed: 0.5, green: 0.25, blue: 0.75, alpha: 0.5)
        #expect(translucent.paletteHexString == "#8040BF80")
        #expect(try #require(NSColor(paletteHex: "#8040BF80")).alphaComponent == 128.0 / 255)
    }

    /// A panel colour landing exactly on a palette entry comes back as the **name**, in both
    /// tables — the rule `SystemColorPanel` applies before handing a value to a caller, and the
    /// reason picking `Light Cayenne` from the panel twice does not drift into `#B6071E`.
    @Test("A palette-exact pick resolves to the palette name, never the hex")
    func paletteExactPicksResolveToNames() throws {
        for entry in Palette.backgrounds {
            let picked = try #require(NSColor(paletteHex: entry.hex))
            let hex = try #require(picked.paletteHexString)
            #expect(Palette.name(forHex: hex, in: Palette.backgrounds) == entry.name)
        }
        for entry in Palette.foregrounds {
            let picked = try #require(NSColor(paletteHex: entry.hex))
            let hex = try #require(picked.paletteHexString)
            #expect(Palette.name(forHex: hex, in: Palette.foregrounds) == entry.name)
        }
    }

    /// A colour that is *not* a palette entry stays a hex — the other half of the same rule, which
    /// would otherwise be satisfied by a function that always returned a name.
    @Test("An off-palette pick stays a hex")
    func offPaletteStaysHex() throws {
        let picked = NSColor(srgbRed: 0.181, green: 0.102, blue: 0.318, alpha: 1)
        let hex = try #require(picked.paletteHexString)
        #expect(Palette.name(forHex: hex, in: Palette.backgrounds) == nil)
        #expect(Palette.name(forHex: hex, in: Palette.foregrounds) == nil)
        #expect(Palette.nsColor(for: hex) != nil, "a hex the pickers store must still resolve")
    }
}

// MARK: - The YAML boundary

/// **The one place the chain could break silently**: `#` is YAML's comment introducer, so a hex
/// emitted as a plain scalar is a key with no value.
@Suite("Custom colour ▸ the YAML boundary")
struct CustomColorEmissionTests {

    @Test("A hex is emitted quoted, because unquoted it would be a comment")
    func hexIsQuoted() {
        #expect(FrontmatterValue.string("#2E1A51").yamlText == "\"#2E1A51\"")
        #expect(FrontmatterValue.string("#2E1A5180").yamlText == "\"#2E1A5180\"")
        // The control: a palette name needs no quoting and does not get any.
        #expect(FrontmatterValue.string("smokey-ocean").yamlText == "smokey-ocean")
    }

    /// Proof that the quoting is *necessary*, not merely present — the emitter's round-trip probe is
    /// only as good as the claim it is testing, so state the claim.
    @Test("Unquoted, a hex really does parse as nothing")
    func unquotedHexParsesAsNothing() throws {
        let document = try FrontmatterDocument.parse("---\niconColor: #2E1A51\n---\nBody.\n")
        #expect(document.iconColor.value == nil, "an unquoted hex must not be readable as a colour")
        let quoted = try FrontmatterDocument.parse("---\niconColor: \"#2E1A51\"\n---\nBody.\n")
        #expect(quoted.iconColor.value == "#2E1A51")
    }
}

// MARK: - End to end, on disk

@MainActor
@Suite("Custom colour ▸ through the store to disk")
struct CustomColorWriteTests {

    private func fixture() throws -> WriterFixture {
        let fixture = try WriterFixture()
        try fixture.item("", """
        ---
        schema: 1
        title: Board
        ---
        Board description.

        """)
        try fixture.item("11111111-1111-4111-8111-111111111111", """
        ---
        schema: 1
        title: Todo
        order: 1024
        ---
        Lane body.

        """)
        return fixture
    }

    private let lane = ItemID(rawValue: "11111111-1111-4111-8111-111111111111")

    /// A hex chosen from the panel lands on disk **quoted**, and the app's own reader gets the same
    /// colour back out. The `background` half — a flow mapping, always double-quoted.
    @Test("An arbitrary background hex round-trips through a real write")
    func backgroundHexRoundTrips() throws {
        let fixture = try fixture()
        defer { fixture.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)
        let picked = NSColor(srgbRed: 0.181, green: 0.102, blue: 0.318, alpha: 1)
        let value = try #require(picked.paletteHexString)

        store.applyStyle(to: .items([lane]), background: .set(value), icon: .keep)

        let text = try fixture.indexText("11111111-1111-4111-8111-111111111111")
        #expect(text.contains("background: {color: \"\(value)\"}"), "written as: \(text)")

        let reloaded = try BoardLoader.load(boardRoot: fixture.root).model
        let written = try #require(reloaded.lanes.first { $0.id == lane })
        #expect(written.background == .valid(value))
        // To within the 8-bit quantisation `#RRGGBB` has — the format's own precision, not a
        // slackness in the round trip.
        let resolved = try #require(Palette.nsColor(for: value))
        let tolerance = 1.0 / 255 + 0.0001
        #expect(abs(resolved.redComponent - picked.redComponent) < tolerance)
        #expect(abs(resolved.greenComponent - picked.greenComponent) < tolerance)
        #expect(abs(resolved.blueComponent - picked.blueComponent) < tolerance)
        #expect(resolved.alphaComponent == 1)
    }

    /// The `iconColor` half — a plain scalar, so the quoting is `emitScalar`'s round-trip probe
    /// rather than an unconditional rule. Different code path, same guarantee.
    @Test("An arbitrary icon-tint hex round-trips through a real write")
    func iconColorHexRoundTrips() throws {
        let fixture = try fixture()
        defer { fixture.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)
        let picked = NSColor(srgbRed: 0.85, green: 0.32, blue: 0.74, alpha: 1)
        let value = try #require(picked.paletteHexString)

        store.applyStyle(to: .items([lane]), background: .keep, icon: .keep, iconColor: .set(value))

        let text = try fixture.indexText("11111111-1111-4111-8111-111111111111")
        #expect(text.contains("iconColor: \"\(value)\""), "written as: \(text)")

        let reloaded = try BoardLoader.load(boardRoot: fixture.root).model
        let written = try #require(reloaded.lanes.first { $0.id == lane })
        #expect(written.iconColor == .valid(value))
        #expect(Palette.color(for: written.iconColor) != nil)
    }

    /// A translucent pick — the panel's opacity slider — keeps its alpha across the trip. Eight
    /// digits is a shape the schema names (`#RRGGBB[AA]`) and the one the emitter has to quote just
    /// as carefully.
    @Test("A translucent pick keeps its alpha on disk")
    func translucentHexRoundTrips() throws {
        let fixture = try fixture()
        defer { fixture.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)
        let picked = NSColor(srgbRed: 0.2, green: 0.4, blue: 0.6, alpha: 0.5)
        let value = try #require(picked.paletteHexString)
        #expect(value.count == 9, "a translucent colour must carry its alpha pair")

        store.applyStyle(to: .items([lane]), background: .set(value), icon: .keep)

        let reloaded = try BoardLoader.load(boardRoot: fixture.root).model
        let written = try #require(reloaded.lanes.first { $0.id == lane })
        let stored = try #require(written.background.value)
        let colour = try #require(Palette.nsColor(for: stored))
        #expect(abs(colour.alphaComponent - 0.5) < 1.0 / 255 + 0.0001)
    }

    /// Every one of the four new palette colours is a value the write path handles as a **name**,
    /// not as the hex it stands for — the additions joined the vocabulary, they did not become a
    /// special case.
    @Test("The 2026-08-09 palette additions write as names")
    func newPaletteNamesWriteAsNames() throws {
        let fixture = try fixture()
        defer { fixture.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)
        for name in ["smokey-lime", "dark-jade", "smokey-indigo", "smokey-magenta"] {
            store.applyStyle(to: .items([lane]), background: .set(name), icon: .keep)
            let text = try fixture.indexText("11111111-1111-4111-8111-111111111111")
            #expect(text.contains("background: {color: \"\(name)\"}"), "written as: \(text)")
            let reloaded = try BoardLoader.load(boardRoot: fixture.root).model
            let written = try #require(reloaded.lanes.first { $0.id == lane })
            #expect(Palette.color(for: written.background) != nil, "'\(name)' did not resolve after a round trip")
        }
    }
}
