import AppKit
import Testing
@testable import Kanban

/// `ColorComboView`'s pure model (`ColorCombo.swift`): item-list composition, selection matching,
/// hex normalization and display-name casing — every rule stated where a test can hold it without
/// an `NSView`, matching `PaletteTests.swift`'s own split between the vocabulary and the view.
///
/// One outer suite, nested by concern — swift-testing discovers nested types as sub-suites, which
/// is what lets `-only-testing:KanbanTests/ColorComboTests` run the whole file while each concern
/// still reads as its own group, `PaletteTests.swift`'s top-level-structs style scoped one level in.
struct ColorComboTests {

    // MARK: - Display names

    struct DisplayName {

        @Test func kebabCaseBecomesTitleCaseWithHyphensAsSpaces() {
            #expect(ColorComboModel.displayName("light-cayenne") == "Light Cayenne")
            #expect(ColorComboModel.displayName("smokey-rich-eggplant") == "Smokey Rich Eggplant")
            #expect(ColorComboModel.displayName("obsidian") == "Obsidian")
            #expect(ColorComboModel.displayName("deep-sky-blue") == "Deep Sky Blue")
        }
    }

    // MARK: - Hex normalization

    struct HexNormalization {

        @Test func sixDigitHexUppercasesUnchanged() {
            #expect(ColorComboModel.normalizedHex("#b6071e") == "#B6071E")
            #expect(ColorComboModel.normalizedHex("#B6071E") == "#B6071E")
        }

        /// Alpha below full opacity is meaningful and stays — only a fully-opaque suffix collapses.
        @Test func eightDigitHexWithPartialAlphaStaysEightDigits() {
            #expect(ColorComboModel.normalizedHex("#b6071e80") == "#B6071E80")
        }

        /// `#RRGGBBFF` — fully opaque, spelled with an explicit alpha byte — collapses to the
        /// six-digit form, so it compares equal to a bare `#RRGGBB` written for the same colour.
        @Test func fullyOpaqueEightDigitHexCollapsesToSixDigits() {
            #expect(ColorComboModel.normalizedHex("#b6071eFF") == "#B6071E")
            #expect(ColorComboModel.normalizedHex("#B6071EFF") == ColorComboModel.normalizedHex("#B6071E"))
        }

        @Test func malformedOrUnprefixedValuesNormalizeToNil() {
            for value in ["", "#", "#12", "#12345", "#1234567", "#GGGGGG", "B6071E", "light-cayenne"] {
                #expect(ColorComboModel.normalizedHex(value) == nil, "'\(value)' should not normalize")
            }
        }
    }

    // MARK: - Selection matching

    struct Matching {

        @Test func nilValueMatchesNone() {
            #expect(ColorComboModel.match(role: .background, value: nil) == .none)
            #expect(ColorComboModel.match(role: .foreground, value: nil) == .none)
        }

        @Test func aNameInTheRolesOwnPaletteMatchesThatRowExactly() {
            #expect(ColorComboModel.match(role: .background, value: "light-cayenne") == .palette("light-cayenne"))
            #expect(ColorComboModel.match(role: .foreground, value: "fern") == .palette("fern"))
        }

        /// Names are matched exactly, like `Palette.nsColor(for:)` — a near-miss is not "the same
        /// row", it is an off-palette value with its own dynamic row.
        @Test func aNearMissNameIsNotAPaletteMatch() {
            let match = ColorComboModel.match(role: .background, value: "Light-Cayenne")
            guard case let .current(_, title) = match else {
                Issue.record("expected .current, got \(match)")
                return
            }
            #expect(title == "Light-Cayenne")
        }

        /// A hex that normalizes to one of the role's own palette hexes selects the **name**, not
        /// the hex — case-insensitively, and with a fully-opaque `#RRGGBBFF` collapsing exactly like
        /// a bare `#RRGGBB` would.
        @Test func aHexEqualToAPaletteColorsHexMatchesItsNamedRow() {
            #expect(ColorComboModel.match(role: .background, value: "#B6071E") == .palette("light-cayenne"))
            #expect(ColorComboModel.match(role: .background, value: "#b6071e") == .palette("light-cayenne"))
            #expect(ColorComboModel.match(role: .background, value: "#B6071EFF") == .palette("light-cayenne"))
            #expect(ColorComboModel.match(role: .background, value: "#b6071eff") == .palette("light-cayenne"))
        }

        /// Partial alpha keeps a value off the palette rows even when its RGB matches one exactly —
        /// the stored colour is genuinely translucent, which no palette entry is.
        @Test func aTranslucentHexNeverMatchesAnOpaquePaletteColor() {
            let match = ColorComboModel.match(role: .background, value: "#B6071E80")
            guard case let .current(swatchValue, _) = match else {
                Issue.record("expected .current, got \(match)")
                return
            }
            #expect(swatchValue == "#B6071E80")
        }

        /// A name from the *other* picker's table — `carnation` is foreground-only — is not one of
        /// `.background`'s twelve, so it falls to the dynamic row, titled with its own display name
        /// since the other table does know it.
        @Test func aForeignPaletteNameFallsToTheDynamicRowNamedFromTheOtherTable() {
            let match = ColorComboModel.match(role: .background, value: "carnation")
            #expect(match == .current(swatchValue: "carnation", title: "Carnation"))
        }

        /// A custom hex nowhere in either table: the dynamic row states it verbatim, uppercased.
        @Test func aCustomHexFallsToTheDynamicRowUppercased() {
            let match = ColorComboModel.match(role: .background, value: "#123456")
            #expect(match == .current(swatchValue: "#123456", title: "#123456"))
            let lowercase = ColorComboModel.match(role: .background, value: "#abcdef")
            #expect(lowercase == .current(swatchValue: "#abcdef", title: "#ABCDEF"))
        }

        /// Unresolvable garbage — neither a name either table knows nor a parseable hex — falls to
        /// the dynamic row exactly as written, no casing applied.
        @Test func garbageFallsToTheDynamicRowVerbatim() {
            let match = ColorComboModel.match(role: .background, value: "chartreuse")
            #expect(match == .current(swatchValue: "chartreuse", title: "chartreuse"))
        }

        /// The three names shared by both tables (`obsidian`, `aluminum`, `chalk`) are in *both*
        /// roles' own palettes, so they match directly and never reach the "foreign name" branch.
        @Test func namesSharedByBothTablesMatchDirectlyInEitherRole() {
            #expect(ColorComboModel.match(role: .background, value: "obsidian") == .palette("obsidian"))
            #expect(ColorComboModel.match(role: .foreground, value: "obsidian") == .palette("obsidian"))
        }
    }

    // MARK: - Item list composition

    struct Menu {

        /// None first, a separator, then exactly the role's twelve, in the palette's own order.
        @Test func baseOrderIsNoneSeparatorThenTheRolesTwelve() {
            let menu = ColorComboModel.menu(role: .background, value: nil)
            var expected: [ColorComboItem] = [.none, .separator]
            expected.append(contentsOf: Palette.backgrounds.map { .palette($0.name) })
            expected.append(contentsOf: [.separator, .other])
            #expect(menu.items == expected)
        }

        @Test func foregroundRoleListsTheForegroundTwelveNotTheBackgroundTwelve() {
            let menu = ColorComboModel.menu(role: .foreground, value: nil)
            let paletteNames = menu.items.compactMap { item -> String? in
                if case let .palette(name) = item { return name }
                return nil
            }
            #expect(paletteNames == Palette.foregrounds.map(\.name))
        }

        /// `Other…` is always last, and there is never more than one dynamic row.
        @Test func otherIsAlwaysLast() {
            for value in [nil, "obsidian", "carnation", "#123456", "chartreuse"] {
                let menu = ColorComboModel.menu(role: .background, value: value)
                #expect(menu.items.last == .other)
            }
        }

        /// No dynamic row, and no selected item beyond the palette rows, when the value is `nil` or
        /// one of the role's own twelve.
        @Test func noDynamicRowWhenTheValueIsNoneOrAPaletteName() {
            let none = ColorComboModel.menu(role: .background, value: nil)
            #expect(!none.items.contains { if case .current = $0 { return true }; return false })
            #expect(none.selectedIndex == 0)
            #expect(none.items[none.selectedIndex] == .none)

            let named = ColorComboModel.menu(role: .background, value: "dark-teal")
            #expect(!named.items.contains { if case .current = $0 { return true }; return false })
            #expect(named.items[named.selectedIndex] == .palette("dark-teal"))
        }

        /// A foreign or unresolvable value inserts exactly one dynamic row, immediately before the
        /// trailing separator and `Other…`, and it is the checked row.
        @Test func dynamicRowAppearsOnlyForAForeignValueAndIsSelected() {
            let menu = ColorComboModel.menu(role: .background, value: "#123456")
            let dynamicRows = menu.items.filter { if case .current = $0 { return true }; return false }
            #expect(dynamicRows.count == 1)
            #expect(menu.items[menu.selectedIndex] == .current(swatchValue: "#123456", title: "#123456"))
            // Immediately before the trailing separator + Other…
            #expect(
                Array(menu.items.suffix(3)) ==
                    [.current(swatchValue: "#123456", title: "#123456"), .separator, .other]
            )
        }

        /// A hex landing exactly on a palette colour selects that named row and adds no dynamic row
        /// at all — the same shape as picking the name directly.
        @Test func anExactHexMatchProducesNoDynamicRow() {
            let byName = ColorComboModel.menu(role: .background, value: "light-cayenne")
            let byHex = ColorComboModel.menu(role: .background, value: "#B6071E")
            #expect(byName.items == byHex.items)
            #expect(byName.selectedIndex == byHex.selectedIndex)
        }
    }
}
