import Foundation
import Testing
@testable import Kanban

/// The card window's sidebar sections, reduced to the rules a unit test can hold (05-card-window.md
/// ▸ The attributes sidebar).
///
/// The views are not the point and are not tested. What is tested is the three seams under them,
/// each of which fails *silently* — the failure mode this file exists for:
///
/// - **Details** shows frontmatter the app deliberately does not understand, so nothing downstream
///   can notice when a key goes missing, arrives out of order, or renders as a YAML indicator. The
///   only check on it is a test that reads a file and says what the rows must be.
/// - **Style** pins its own target — this window's card, always, never the selection-aware target the
///   Style… popover carries — and (2026-08-08) hands the compact `SymbolPicker` the card-flavored
///   `CuratedSymbols.availableCards` set (one of the three level-specific sets `CuratedSymbols` split
///   into on 2026-08-09) rather than the picker's own smaller general-purpose default. A target wired
///   to the wrong card, or a curated list quietly narrowed back to the picker's default, would look
///   completely normal until it restyled the wrong card or hid a symbol the vocabulary used to offer.
/// - **The sidebar's geometry** is a grid of fixed-size wells in a column sized from font metrics:
///   at the wrong column count the last well in each row is simply unreachable, at no text size
///   anyone tests by eye. (The `StyleEditorLayout.sidebar` variant below now describes geometry no
///   view in the app currently requests — the Style section's grid moved into `SymbolPicker`'s own
///   popover on 2026-08-08 — but the pure function is still correct and still worth pinning.)

// MARK: - Details ▸ which keys

@Suite("Card details ▸ keys")
struct CardDetailsKeyTests {

    /// One card carrying every kind of key at once: the schema's own, an agent overlay, four
    /// reserved enhanced-schema names, and a key written twice.
    private static let card = """
    ---
    schema: 1
    title: Fix login
    order: 1024
    project: lanework       # agent overlay
    labels: [a, b, c]
    assignees:
      - ada
      - grace
    due: 2026-08-01
    remote: {name: origin, branch: main}
    created: 2026-01-01T09:00:00Z
    modified: 2026-02-02T09:00:00Z
    modified-by: claude
    background: {color: mint}
    icon: flag
    iconColor: carnation
    project: overlay-rewritten
    ---
    Body text.

    """

    @Test("Every unknown key appears, in file order")
    func unknownKeysInFileOrder() throws {
        let rows = CardDetails.rows(of: try FrontmatterDocument.parse(Self.card))

        // File order, verbatim — 01-storage-format.md preserves it and the sidebar honors it. The
        // twice-written `project` reads once, and it reads where its *winning* occurrence sits: the
        // effective view a duplicate collapses to (`FrontmatterDocument.parse`, last-wins), which is
        // also the order the file itself takes the moment anything rewrites that key.
        #expect(rows.map(\.key) == ["assignees", "due", "remote", "project"])
    }

    @Test("Reserved enhanced-schema keys are ordinary unknown keys in this version")
    func reservedKeysAreShown() throws {
        let rows = CardDetails.rows(of: try FrontmatterDocument.parse(Self.card))

        // "`labels`, `assignees`, `due`, `remote`, … are ordinary unknown keys in this version and
        // appear here like any other — no special rendering" (05 ▸ Details). The day they gain
        // meaning they leave this section for a control; until then, hiding them would hide data the
        // file plainly has.
        //
        // **`labels` is the first to leave, and this is the day** (2026-08-09): the owner claimed it
        // for first-party use, it joined `schemaOwned`, and the Labels section is the control the
        // sentence above anticipated. Its absence here is therefore the *rule* holding rather than an
        // exception to it — the three that are still reserved are still here.
        for reserved in ["assignees", "due", "remote"] {
            #expect(rows.contains { $0.key == reserved }, "\(reserved) belongs in Details")
        }
        #expect(!rows.contains { $0.key == FrontmatterKeys.labels }, "labels has its own section now")
    }

    @Test("Every schema-owned key is excluded — including the ones the sidebar shows elsewhere")
    func schemaKeysAreExcluded() throws {
        let rows = CardDetails.rows(of: try FrontmatterDocument.parse(Self.card))
        let keys = Set(rows.map(\.key))

        // The eleven the app owns. `title`, `created`/`modified`/`modified-by` and the three style
        // keys have their own surfaces in this very window (the title field, the date line, the Style
        // section), and `schema`/`order`/`width`/`deleted` are structure — a Details row for any of
        // them would be the same fact stated twice, in a section whose whole premise is "keys the app
        // does not own".
        #expect(keys.isDisjoint(with: FrontmatterKeys.schemaOwned))
        #expect(keys == ["assignees", "due", "remote", "project"])
    }

    @Test("A card with no unknown keys has no section at all")
    func theSectionDisappearsWithNothingToShow() throws {
        // The visibility rule, as the seam states it: "shown only when any exist" (05 ▸ Details).
        // Empty rows are the view's whole condition, so this is that condition.
        let plain = "---\nschema: 1\ntitle: Plain\norder: 1024\n---\nBody.\n"
        #expect(CardDetails.rows(of: try FrontmatterDocument.parse(plain)).isEmpty)

        // Style keys and stamps are not "details" either — a styled, stamped card still has none.
        let styled = """
        ---
        schema: 1
        title: Styled
        order: 1024
        background: {color: mint}
        icon: flag
        iconColor: carnation
        created: 2026-01-01T09:00:00Z
        modified: 2026-02-02T09:00:00Z
        modified-by: claude
        width: 2
        deleted: 2026-03-03T09:00:00Z
        ---

        """
        #expect(CardDetails.rows(of: try FrontmatterDocument.parse(styled)).isEmpty)
    }

    @Test("The rows come off the snapshot's own card, unknown keys and order intact")
    func theSnapshotCarriesTheKeys() throws {
        let fixture = try WriterFixture()
        defer { fixture.tearDown() }
        try fixture.item("", Item.board)
        try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo"))
        try fixture.item("\(Ident.lane1)/\(Ident.card1)", Self.card)

        // Nothing was added to the loader for this section: `Card.document` is the whole parsed
        // `index.md` and has been since the model existed, so the section reads what the last reload
        // read — no second parse, no directory walk, no freshness rule of its own.
        let model = try BoardLoader.load(boardRoot: fixture.root).model
        let card = try #require(model.lanes.first?.cards.first)

        #expect(CardDetails.rows(of: card.document).map(\.key) == ["assignees", "due", "remote", "project"])
        // And the key that left: it is read as a field now, not shown as raw text.
        #expect(card.labels.value == ["a", "b", "c"])
    }
}

// MARK: - Details ▸ which text

@Suite("Card details ▸ values")
struct CardDetailsValueTests {

    private func rows(_ frontmatter: String) throws -> [String: String] {
        let text = "---\nschema: 1\norder: 1024\n\(frontmatter)---\nBody.\n"
        return Dictionary(uniqueKeysWithValues: CardDetails.rows(of: try FrontmatterDocument.parse(text))
            .map { ($0.key, $0.value) })
    }

    @Test("A one-line value renders as the author's own bytes")
    func singleLineValuesAreVerbatim() throws {
        let values = try rows("""
        plain: lanework
        flow-map: {name: origin, branch: main}
        flow-seq: [a, b, c]
        hex: "#FF8800"
        quoted: "a # b"
        commented: kept       # this comment is the line's, not the value's
        stamp: 2026-08-01T09:00:00Z
        number: 2048

        """)

        // The rawest honest form: quotes, braces, brackets, a hash inside a quoted scalar — all of it
        // exactly as typed, because a section that exists to show what the app did *not* interpret
        // must not quietly re-serialize it.
        #expect(values["plain"] == "lanework")
        #expect(values["flow-map"] == "{name: origin, branch: main}")
        #expect(values["flow-seq"] == "[a, b, c]")
        #expect(values["hex"] == "\"#FF8800\"")
        #expect(values["quoted"] == "\"a # b\"")
        #expect(values["stamp"] == "2026-08-01T09:00:00Z")
        #expect(values["number"] == "2048")
        // A trailing comment is the *line's*, and dropping it is the engine's own read-side rule —
        // the bytes on disk keep it.
        #expect(values["commented"] == "kept")
    }

    @Test("A value spanning several lines reads as its text, not as its YAML syntax")
    func multiLineValuesFallBackToTheParsedReading() throws {
        let values = try rows("""
        notes: |
          first line
          second line
        folded: >-
          wrapped
          prose
        block-seq:
          - ada
          - grace
        block-map:
          name: origin
          branch: main
        wrapped-flow: [one,
          two]

        """)

        // The raw span of a block scalar carries `|`, `>-` and the continuation indent — YAML syntax
        // the *value* does not have. So these fall back to the engine's own reading, which is the
        // text the author meant rather than the punctuation that encoded it.
        #expect(values["notes"] == "first line\nsecond line")
        #expect(values["folded"] == "wrapped prose")
        #expect(values["block-seq"] == "[ada, grace]")
        #expect(values["block-map"] == "{name: origin, branch: main}")
        #expect(values["wrapped-flow"] == "[one, two]")
    }

    @Test("A key with no value reads as null rather than as a blank")
    func emptyValuesReadAsNull() throws {
        let values = try rows("""
        sphere:
        explicit: null
        tilde: ~
        empty-string: ""
        only-a-comment:   # nothing but this
        space-after: " "

        """)

        // A row showing a key and nothing beside it reads as a bug in the app; YAML's own word for
        // the value is the honest thing to draw. An explicitly *empty string* is a different value
        // and keeps its own bytes.
        #expect(values["sphere"] == "null")
        #expect(values["explicit"] == "null")
        #expect(values["tilde"] == "~")
        #expect(values["only-a-comment"] == "null")
        #expect(values["empty-string"] == "\"\"")
        #expect(values["space-after"] == "\" \"")
    }

    @Test("A duplicated key reads once, with the winning occurrence's value")
    func duplicateKeysCollapseLastWins() throws {
        let document = try FrontmatterDocument.parse("""
        ---
        schema: 1
        order: 1024
        project: first
        sphere: home
        project: second
        ---
        Body.

        """)
        let rows = CardDetails.rows(of: document)

        // 01-storage-format.md's deliberate divergence from strict YAML, surfaced: the section shows
        // what the app *reads*, and the app reads the last occurrence. Two rows for one key would say
        // the card has a value it does not have.
        #expect(rows.map(\.key) == ["sphere", "project"])
        #expect(rows.map(\.value) == ["home", "second"])
    }

    @Test("Frontmatter the editor cannot address still renders — readable-but-uneditable")
    func anUneditableDocumentStillShowsItsKeys() throws {
        // A whole-frontmatter flow mapping: no key has a line of its own, so `rawValue` has no span
        // to read and every value falls back to the parsed reading. The document refuses *writes*
        // (`uneditableShape`) — this section only reads, so it shows the keys like any other card's.
        let document = try FrontmatterDocument.parse("""
        ---
        {schema: 1, order: 1024, project: lanework, labels: [a, b]}
        ---
        Body.

        """)
        #expect(document.uneditableShape != nil)
        #expect(CardDetails.rows(of: document).map(\.key) == ["project"])
        #expect(CardDetails.rows(of: document).map(\.value) == ["lanework"])
        #expect(document.labels == .valid(["a", "b"]), "and the schema-owned one still reads")
    }

    @Test("Nothing a parsed document can hold makes a row throw or vanish")
    func exoticShapesAreLenient() throws {
        // "Values render as plain text, leniently — exotic YAML shapes display best-effort, never
        // error" (05 ▸ Details). Every shape here has *some* row, and none of them is empty: an
        // unreadable value must still say that the key is there, since the raw source outlet is the
        // only way to fix it and the user has to know to go there.
        let values = try rows("""
        nested: {a: {b: [1, 2, {c: d}]}}
        booleans: [true, false, yes, no]
        unicode: "日本語 — ✂ \\u00e9"
        anchored: &a value
        aliased: *a
        colon-in-value: "key: not a key"
        tabbed: "a\\tb"
        big: 123456789012345678901234567890
        exponent: 1.2e+34
        infinity: .inf
        not-a-number: .nan
        dashes: "- not a list"

        """)

        #expect(values.count == 12)
        for (key, value) in values {
            #expect(!value.isEmpty, "\(key) rendered as nothing at all")
        }
        // An alias resolves to `.null` in the snapshot's value view, but its *source span* is what
        // the row shows — so an aliased value states the alias rather than lying about being empty.
        #expect(values["aliased"] == "*a")
        #expect(values["infinity"] == ".inf")
        #expect(values["not-a-number"] == ".nan")
        #expect(values["colon-in-value"] == "\"key: not a key\"")
    }
}

// MARK: - Style ▸ the anchor

@Suite("Card sidebar ▸ style anchoring")
struct CardStyleAnchorTests {

    @Test("The section's target is this window's card, and only this window's card")
    func theTargetIsTheWindowsCard() {
        let card = ItemID(rawValue: Ident.card1)

        // The card window has no selection and inherits none: "the two embedded anchors need none of
        // this and get none — the card sidebar dismisses with its card's window" (`StyleEditorSession`).
        // A target derived from anything that moves is the one way this anchor could restyle
        // something the user is not looking at.
        #expect(CardStyleSection.target(forCard: card) == .items([card]))

        // Never the board — the fallback the *selection-aware* anchor takes with nothing selected,
        // and the one this anchor must never reach: a card window styling the whole board would
        // repaint every lane behind it.
        #expect(CardStyleSection.target(forCard: card) != .board)
    }

    @Test("A card respelled in caps is the same target")
    func theTargetIsKeyedByIdentityNotSpelling() {
        // The window's own key rule (`CardWindowRef`), so the section cannot disagree with the window
        // it sits in about which card it is aimed at.
        let lower = ItemID(rawValue: Ident.card1)
        let upper = ItemID(rawValue: Ident.card1.uppercased())
        #expect(CardStyleSection.target(forCard: lower) == CardStyleSection.target(forCard: upper))
    }

    /// **The sidebar's `SymbolPicker` is fed `CuratedSymbols.availableCards`, not the picker's own
    /// smaller default** — the design ruling behind the 2026-08-08 grid-to-picker swap ("so a card's
    /// curated vocabulary doesn't shrink"), restated for the 2026-08-09 three-set split. Not a test
    /// *of* the view (this file's own stance), but of the two catalogs the ruling is a claim about: if
    /// `CuratedSymbols.cards` ever shrank to `SymbolPickerCatalog.defaultSet`'s size or below, the
    /// sidebar's explicit `symbols:` argument would have quietly become a no-op, and this is the
    /// tripwire for that.
    @Test("The card sidebar's curated symbol vocabulary is at least as wide as the picker's own default")
    func theCuratedSetPassedToTheSidebarPickerDoesNotShrinkIt() {
        #expect(CuratedSymbols.availableCards.count > SymbolPickerCatalog.defaultSet.count)
        // And it is still the same *available* set every other curated surface reads — filtered
        // through `ItemSymbol.exists` (`CuratedSymbols.availableCards`'s own doc comment), never the
        // unfiltered `cards`, so the sidebar never offers a well this Mac cannot draw.
        #expect(CuratedSymbols.availableCards.allSatisfy(ItemSymbol.exists))
    }
}

// MARK: - Style ▸ the anchor's geometry

@Suite("Card sidebar ▸ style editor layout")
struct StyleEditorLayoutTests {

    /// How wide `columns` wells and the gaps between them actually draw, at a given text size.
    private func gridWidth(columns: Int, bodyPointSize: CGFloat) -> CGFloat {
        CGFloat(columns) * StyleEditorLayout.wellSide(bodyPointSize: bodyPointSize)
            + CGFloat(columns - 1) * StyleEditorLayout.wellSpacing(bodyPointSize: bodyPointSize)
    }

    @Test("The popover anchor keeps its settled geometry at the standard text size")
    func thePopoverKeepsItsSettledGeometry() {
        // 268 points and 7 + 6 background wells are 03-board-ui.md's own numbers ("narrow enough to
        // sit beside a card"). Making the anchor font-derived (10-accessibility.md ▸ Text scaling)
        // must not have moved them at the size they were settled for — the point of the multiples is
        // that the default text size renders exactly what it always did.
        let popover = StyleEditorLayout.popover(bodyPointSize: 13)
        #expect(popover.width == 268)
        #expect(popover.padding == 14)
        #expect(popover.backgroundColumns == 7)
        #expect(popover.symbolColumns == 8)
        #expect(popover.symbolGridMaximumHeight == 168)
        #expect(popover.wellSide == 20)
        #expect(popover.wellSpacing == 6)
    }

    /// The other half of the same claim: **the popover grows with the text**, so its seven wells
    /// still fit at a large system text size instead of overflowing a frame frozen at 268 points.
    @Test("The popover's frame grows with the text, and its wells keep fitting inside it")
    func thePopoverScales() {
        var previousWidth: CGFloat = 0
        for size in [11.0, 13.0, 16.0, 18.0, 24.0, 36.0] as [CGFloat] {
            let popover = StyleEditorLayout.popover(bodyPointSize: size)
            #expect(popover.width! > previousWidth, "the popover must widen with the text at \(size)pt")
            previousWidth = popover.width!

            // The column counts are the design's and never move; what has to hold is that they
            // still fit, insets included.
            #expect(popover.backgroundColumns == 7)
            let content = popover.width! - 2 * popover.padding
            #expect(gridWidth(columns: 7, bodyPointSize: size) <= content, "7 wells overflow at \(size)pt")
            #expect(gridWidth(columns: 8, bodyPointSize: size) <= content, "8 wells overflow at \(size)pt")
        }
    }

    @Test("The sidebar anchor takes the column it is given and adds nothing to it")
    func theSidebarBringsNoGeometryOfItsOwn() {
        let layout = StyleEditorLayout.sidebar(contentWidth: 169, bodyPointSize: 13)

        // No width: the sidebar's is `CardWindowMetrics`' one decision. No padding: the section stack
        // is already inset by a gutter, and insetting twice would narrow the grids for nothing.
        #expect(layout.width == nil)
        #expect(layout.padding == 0)
        // No inner scroller: the sidebar is already a scroll view, and a scroll view inside a scroll
        // view is a scroll view that fights (`CardWindowView`'s rule).
        #expect(layout.symbolGridMaximumHeight == nil)
    }

    @Test("The grids fit the sidebar at every text size, and waste no room doing it")
    func theGridsFitTheSidebar() {
        for size in [11.0, 13.0, 16.0, 18.0, 24.0, 36.0] as [CGFloat] {
            let available = CardWindowMetrics.sidebarContentWidth(bodyPointSize: size)
            let layout = StyleEditorLayout.sidebar(contentWidth: available, bodyPointSize: size)

            #expect(layout.backgroundColumns == layout.symbolColumns, "one column count for one column")
            // Fits — a grid wider than its column puts the last well of every row out of reach of
            // the pointer, at a text size nobody checks by eye.
            #expect(gridWidth(columns: layout.backgroundColumns, bodyPointSize: size) <= available,
                    "overflows at \(size)pt")
            // And is maximal: one more well would not have fitted, so the wells are as large a set as
            // the column can show rather than an arbitrary count that happened to be safe.
            #expect(gridWidth(columns: layout.backgroundColumns + 1, bodyPointSize: size) > available,
                    "under-packed at \(size)pt")
            // And the count is *stable* across text sizes, because the column and the wells scale on
            // the same ruler — the sidebar is 26 body characters wide and a well is 1.55 body ems,
            // and neither of those ratios moves. Without that the grid would collapse to a strip at
            // a large text size, which is a palette the user can no longer scan.
            #expect(layout.backgroundColumns >= 4, "the grid collapsed to a strip at \(size)pt")
        }
    }

    @Test("The sidebar's grids are narrower than the popover's, at the standard text size")
    func theSidebarIsTheNarrowerAnchor() {
        // Which is the entire reason this type exists: the popover's 7 wells across do not fit a
        // 26-character column, so an editor with one hard-coded frame could not have both anchors.
        let layout = StyleEditorLayout.sidebar(
            contentWidth: CardWindowMetrics.sidebarContentWidth(bodyPointSize: 13),
            bodyPointSize: 13
        )
        #expect(layout.backgroundColumns < StyleEditorLayout.popover(bodyPointSize: 13).backgroundColumns)
        #expect(layout.backgroundColumns >= 4, "a grid this narrow would be a strip, not a palette")
    }

    @Test("A column too narrow for even one well still asks for one")
    func theFitRuleIsTotal() {
        // Total over any width, because the caller is a layout system: a zero proposal during a
        // window's first frame must not produce a grid of zero columns, which is a division by zero
        // waiting in `LazyVGrid`.
        #expect(StyleEditorLayout.columns(fitting: 0, bodyPointSize: 13) == 1)
        #expect(StyleEditorLayout.columns(fitting: -100, bodyPointSize: 13) == 1)
        #expect(StyleEditorLayout.columns(fitting: StyleEditorLayout.wellSide(bodyPointSize: 13),
                                          bodyPointSize: 13) == 1)
    }

    @Test("The sidebar's content width is the column minus its two gutters")
    func theContentWidthIsTheColumnMinusItsGutters() {
        for size in [11.0, 13.0, 24.0] as [CGFloat] {
            #expect(
                CardWindowMetrics.sidebarContentWidth(bodyPointSize: size)
                    == CardWindowMetrics.sidebarWidth(bodyPointSize: size)
                        - 2 * CardWindowMetrics.gutter(bodyPointSize: size)
            )
        }
        // 26 characters at half an em: 26 × 0.5 × 16 = 208, the text width the column was sized for.
        #expect(CardWindowMetrics.sidebarContentWidth(bodyPointSize: 16) == 208)
    }
}
