import CoreGraphics
import SwiftUI
import Testing
@testable import Kanban

/// The two seams m11's visual-accommodations card added, and the one it made font-derived
/// (10-accessibility.md ▸ Text scaling & visual accommodations):
///
/// 1. **`BoardMetrics`** — the board strip's geometry as a pure function of the body point size, so
///    "Card face, lane header, and masonry metrics derive from font metrics, so layout survives the
///    largest system text sizes" is a fact a suite can assert rather than something to be verified by
///    eye at three text sizes. Nothing here renders; what is pinned is that the numbers *move*, that
///    they move in the right direction, and that they land on the board's settled figures at the
///    standard 13pt body — which is the whole claim that making the board scale did not redesign it.
/// 2. **`Accommodations`** — Increase Contrast and Reduce Transparency as decisions separable from
///    the drawing, `Motion`'s pattern for its reason (`AnyShapeStyle` is opaque, so a claim about a
///    fill is only testable through a small `Equatable` enum).
/// 3. **`LaneLayoutMath` under a scaled gap** — the no-horizontal-scroll invariant, re-asserted at
///    the text sizes the gap now varies over. It is 03-board-ui.md's headline layout rule and the one
///    thing a font-derived gap could plausibly have broken.

// MARK: - The board's font-derived geometry

@Suite("Board metrics ▸ the standard text size is unchanged")
struct BoardMetricsSettledFiguresTests {

    /// The system body font is 13pt at the standard macOS text size, and every multiple in
    /// `BoardMetrics` was chosen to reproduce the board's existing numbers there.
    ///
    /// This is the load-bearing test of the whole conversion: the milestone's mandate was to make the
    /// board *scale*, not to move it, so a default-text-size board must lay out on exactly the
    /// figures it laid out on before. A drift here is a visual regression nothing else would catch.
    @Test("Every figure lands on the board's settled number at 13pt")
    func theStandardSizeReproducesTheSettledFigures() {
        let size: CGFloat = 13
        #expect(BoardMetrics.stripGap(bodyPointSize: size) == 12)
        #expect(BoardMetrics.laneCornerRadius(bodyPointSize: size) == 10)
        #expect(BoardMetrics.lanePlatePadding(bodyPointSize: size) == 6)
        #expect(BoardMetrics.laneStackSpacing(bodyPointSize: size) == 8)
        #expect(BoardMetrics.laneHeaderSpacing(bodyPointSize: size) == 6)
        #expect(BoardMetrics.laneAccentBandHeight(bodyPointSize: size) == 5)
        // The fold's own figure (03-board-ui.md § Lane ▸ Collapsed lanes): the strip is the ruling's
        // ~44pt, and the chevron's reserve is 18 — which is also the header's whole trailing budget
        // now that the new-card button that once shared it is gone (m-remove-header-add).
        #expect(BoardMetrics.collapsedLaneWidth(bodyPointSize: size) == 44)
        #expect(BoardMetrics.laneCollapseButtonReserve(bodyPointSize: size) == 18)
        #expect(BoardMetrics.laneHeaderTrailingReserve(bodyPointSize: size) == 18)
        #expect(BoardMetrics.cardCornerRadius(bodyPointSize: size) == 8)
        #expect(BoardMetrics.cardStripeWidth(bodyPointSize: size) == 4)
        // The hero band (03-board-ui.md § Card face ▸ Hero image) — 36pt at the standard body, inside
        // the ruling's 2.5–3× and deliberately under the 44pt a plain one-line card is tall, so the
        // title still dominates the face it bands.
        #expect(BoardMetrics.cardHeroHeight(bodyPointSize: size) == 36)
        #expect(BoardMetrics.cardHeroHeight(bodyPointSize: size)
            < BoardMetrics.nominalCardHeight(bodyPointSize: size))
        #expect(BoardMetrics.cardContentPadding(bodyPointSize: size) == 10)
        #expect(BoardMetrics.cardRowSpacing(bodyPointSize: size) == 6)
        #expect(BoardMetrics.cardSpacing(bodyPointSize: size) == 8)
        #expect(BoardMetrics.nominalCardHeight(bodyPointSize: size) == 44)
        #expect(BoardMetrics.trashHeaderHorizontalPadding(bodyPointSize: size) == 10)
        #expect(BoardMetrics.trashHeaderVerticalPadding(bodyPointSize: size) == 8)
        #expect(BoardMetrics.trashHatchSpacing(bodyPointSize: size) == 7)
        #expect(BoardMetrics.resizeHandleWidth(bodyPointSize: size) == 12)
        #expect(BoardMetrics.resizeHandleOverhang(bodyPointSize: size) == 8)
    }

    /// The board window's floor is the one 02/03 never named in points but that the host has always
    /// carried — 640 × 400, reproduced at the standard size.
    @Test("The window minimum reproduces its settled size at 13pt")
    func theWindowMinimumIsUnchanged() {
        let minimum = BoardMetrics.windowMinimumSize(bodyPointSize: 13)
        #expect(minimum.width == 637)
        #expect(minimum.height == 403)
    }
}

@Suite("Board metrics ▸ everything scales")
struct BoardMetricsScalingTests {

    /// The text sizes the suite sweeps: below the standard, the standard, and the range a user who
    /// has turned the system text size up actually lands in.
    private static let sizes: [CGFloat] = [11, 13, 16, 18, 24, 36]

    /// **The whole point of the type.** Every figure grows with the text size — which is what
    /// "layout survives the largest system text sizes" means arithmetically: a card's padding cannot
    /// stay at 10 points while the title inside it doubles.
    ///
    /// Two claims, because rounding to whole points makes them different claims. **Never smaller**
    /// between adjacent sizes — a figure that shrank as the text grew would be a bug — and
    /// **strictly larger** across the whole range, which is what separates a real derivation from a
    /// fixed point size wearing a function's clothes. Adjacent sizes may legitimately share a value
    /// for the smallest figures (0.3 em of a 16pt body and of an 18pt body both round to 5), and
    /// that is the rounding doing its job: a hairline has no fractional setting worth having.
    @Test("Every metric grows with the body point size")
    func everyMetricIsMonotone() {
        let metrics: [(String, (CGFloat) -> CGFloat)] = [
            ("stripGap", { BoardMetrics.stripGap(bodyPointSize: $0) }),
            ("laneCornerRadius", { BoardMetrics.laneCornerRadius(bodyPointSize: $0) }),
            ("lanePlatePadding", { BoardMetrics.lanePlatePadding(bodyPointSize: $0) }),
            ("laneStackSpacing", { BoardMetrics.laneStackSpacing(bodyPointSize: $0) }),
            ("laneHeaderSpacing", { BoardMetrics.laneHeaderSpacing(bodyPointSize: $0) }),
            ("laneHeaderInset", { BoardMetrics.laneHeaderInset(bodyPointSize: $0) }),
            ("laneAccentBandHeight", { BoardMetrics.laneAccentBandHeight(bodyPointSize: $0) }),
            ("collapsedLaneWidth", { BoardMetrics.collapsedLaneWidth(bodyPointSize: $0) }),
            ("laneCollapseButtonReserve", { BoardMetrics.laneCollapseButtonReserve(bodyPointSize: $0) }),
            ("laneHeaderTrailingReserve", { BoardMetrics.laneHeaderTrailingReserve(bodyPointSize: $0) }),
            ("badgeHorizontalPadding", { BoardMetrics.badgeHorizontalPadding(bodyPointSize: $0) }),
            ("cardCornerRadius", { BoardMetrics.cardCornerRadius(bodyPointSize: $0) }),
            ("cardStripeWidth", { BoardMetrics.cardStripeWidth(bodyPointSize: $0) }),
            ("cardContentPadding", { BoardMetrics.cardContentPadding(bodyPointSize: $0) }),
            ("cardHeroHeight", { BoardMetrics.cardHeroHeight(bodyPointSize: $0) }),
            ("cardRowSpacing", { BoardMetrics.cardRowSpacing(bodyPointSize: $0) }),
            ("cardSpacing", { BoardMetrics.cardSpacing(bodyPointSize: $0) }),
            ("nominalCardHeight", { BoardMetrics.nominalCardHeight(bodyPointSize: $0) }),
            ("cardReplicaWidth", { BoardMetrics.cardReplicaWidth(bodyPointSize: $0) }),
            ("replicaPadding", { BoardMetrics.replicaPadding(bodyPointSize: $0) }),
            ("trashHeaderHorizontalPadding", { BoardMetrics.trashHeaderHorizontalPadding(bodyPointSize: $0) }),
            ("trashHatchSpacing", { BoardMetrics.trashHatchSpacing(bodyPointSize: $0) }),
            ("resizeHandleWidth", { BoardMetrics.resizeHandleWidth(bodyPointSize: $0) }),
        ]
        for (name, metric) in metrics {
            for (smaller, larger) in zip(Self.sizes, Self.sizes.dropFirst()) {
                #expect(metric(smaller) <= metric(larger),
                        "\(name) shrank from \(smaller)pt to \(larger)pt")
            }
            #expect(metric(Self.sizes.first!) < metric(Self.sizes.last!),
                    "\(name) is fixed across the whole range")
        }
    }

    /// **The header's trailing reserve stays ahead of the header's own furniture.**
    ///
    /// 03-board-ui.md's graceful-truncation rule says a long lane title truncates rather than
    /// colliding with the collapse chevron, and the reserve is what makes that true. It has to stay
    /// wider than a glyph-and-margin at every size, which is the fixed-point-size failure this
    /// conversion exists to remove: at 24pt the glyph alone approaches a stale fixed reserve. (Once
    /// the sum of two glyphs' room — the new-card button and the chevron — this is now the chevron's
    /// alone, the button having been removed.)
    @Test("The header's trailing reserve stays wider than the glyph it reserves for")
    func theTrailingReserveOutgrowsItsGlyph() {
        for size in Self.sizes {
            #expect(BoardMetrics.laneHeaderTrailingReserve(bodyPointSize: size) > size,
                    "the reserve is narrower than one em at \(size)pt")
            #expect(
                BoardMetrics.laneHeaderTrailingReserve(bodyPointSize: size)
                    > BoardMetrics.laneHeaderSpacing(bodyPointSize: size),
                "the reserve is narrower than the header's own spacing at \(size)pt"
            )
        }
    }

    /// The card plate's proportions hold at every size — the stripe stays a stripe rather than
    /// becoming a band, and the plate's inset stays wider than the stripe it sits beside (which is
    /// what keeps "colouring a card never shifts its title" from becoming "colouring a card eats its
    /// title").
    @Test("The card plate keeps its proportions at every text size")
    func theCardPlateKeepsItsProportions() {
        for size in Self.sizes {
            let stripe = BoardMetrics.cardStripeWidth(bodyPointSize: size)
            let padding = BoardMetrics.cardContentPadding(bodyPointSize: size)
            let radius = BoardMetrics.cardCornerRadius(bodyPointSize: size)
            #expect(stripe < padding, "the stripe is wider than the plate's inset at \(size)pt")
            #expect(stripe < radius, "the stripe is wider than the corner it rounds into at \(size)pt")
            #expect(BoardMetrics.nominalCardHeight(bodyPointSize: size) > 2 * padding + size,
                    "the nominal height cannot hold one line of title at \(size)pt")
        }
    }

    /// `em` is total and never yields a non-positive length: a zero-width stripe or a zero-height
    /// band is a shape SwiftUI is asked to draw and cannot, and the pathological inputs (a
    /// degenerate point size, a vanishing multiple) have to land somewhere.
    @Test("The unit is floored at one point, whatever it is handed")
    func theUnitIsTotal() {
        #expect(BoardMetrics.em(0.3, bodyPointSize: 0) == 1)
        #expect(BoardMetrics.em(0, bodyPointSize: 13) == 1)
        #expect(BoardMetrics.em(-1, bodyPointSize: 13) == 1)
        #expect(BoardMetrics.em(0.01, bodyPointSize: 13) == 1)
    }
}

// MARK: - The no-horizontal-scroll invariant, under a gap that moves

@Suite("Board metrics ▸ the strip still never scrolls")
struct ScaledStripDivisionTests {

    /// **The invariant 03-board-ui.md § Layout — full visibility is built on, re-checked now that
    /// the gap varies with the text size.**
    ///
    /// The whole strip — `totalUnits` standard widths plus `totalUnits + 1` gaps — must fill the
    /// window's width exactly, at every text size and every lane count. A larger text size therefore
    /// buys a larger gap out of the lanes' own width: the lanes compress, the strip does not grow,
    /// and horizontal scroll never appears. That is "the degenerate case is accepted, not floored",
    /// holding through the scaling change rather than despite it.
    @Test("The lanes plus the gaps fill the window exactly at every text size")
    func theDivisionIsExactAtEveryTextSize() {
        let stripWidth: CGFloat = 1400
        for size in [11.0, 13.0, 16.0, 18.0, 24.0, 36.0] as [CGFloat] {
            let gap = BoardMetrics.stripGap(bodyPointSize: size)
            for units in 1...12 {
                let standard = LaneLayoutMath.standardWidth(
                    stripWidth: stripWidth, totalUnits: units, gap: gap)
                let drawn = standard * CGFloat(units) + gap * CGFloat(units + 1)
                #expect(abs(drawn - stripWidth) < 0.001,
                        "\(units) units at \(size)pt drew \(drawn) into \(stripWidth)")
            }
        }
    }

    /// The same window and the same lane count: a **larger** text size means **narrower** lanes,
    /// never a wider strip. This is the direction the invariant depends on — the alternative would be
    /// a strip that overflowed its window and had to scroll.
    @Test("A larger text size narrows the lanes rather than widening the strip")
    func aLargerTextSizeCompressesTheLanes() {
        let stripWidth: CGFloat = 1400
        var previous = CGFloat.greatestFiniteMagnitude
        for size in [11.0, 13.0, 16.0, 18.0, 24.0, 36.0] as [CGFloat] {
            let standard = LaneLayoutMath.standardWidth(
                stripWidth: stripWidth,
                totalUnits: 5,
                gap: BoardMetrics.stripGap(bodyPointSize: size)
            )
            #expect(standard < previous, "lanes did not compress at \(size)pt")
            previous = standard
        }
    }
}

// MARK: - Increase Contrast

@Suite("Accommodations ▸ Increase Contrast")
struct IncreaseContrastTests {

    /// "Increase Contrast strengthens borders and the selection indicator" (10-accessibility.md), as
    /// one flat point rather than a factor — see `Accommodations.borderWidth` for why.
    @Test("A stroke goes one point heavier under Increase Contrast")
    func aStrokeGoesHeavier() {
        for base in [1.0, 1.5, 2.0, 2.5, 3.0] as [CGFloat] {
            #expect(Accommodations.borderWidth(base, contrast: .standard) == base)
            #expect(Accommodations.borderWidth(base, contrast: .increased) == base + 1)
        }
    }

    /// The widths encode a hierarchy — a hovered card reads heavier than a selected one, a selected
    /// tile heavier than an unselected one — and the setting must not flatten it. A flat addend is
    /// order-preserving; a multiplier applied to only some sites would not have been.
    @Test("Strengthening preserves the hierarchy the widths encode")
    func strengtheningIsOrderPreserving() {
        let ordered: [CGFloat] = [1, 1.5, 2, 2.5, 3]
        for contrast in [ColorSchemeContrast.standard, .increased] {
            let strengthened = ordered.map { Accommodations.borderWidth($0, contrast: contrast) }
            #expect(strengthened == strengthened.sorted(), "the order collapsed under \(contrast)")
            #expect(Set(strengthened).count == ordered.count, "two widths merged under \(contrast)")
        }
    }

    /// The half that is easy to miss: a card plate and a lane plate carry **no** resting border, so
    /// "this is one card and that is another" is carried by a fill boundary alone — exactly the
    /// distinction the setting exists to rescue. Under Increase Contrast they gain one.
    @Test("Resting plates gain an outline only under Increase Contrast")
    func restingPlatesGainAnOutline() {
        #expect(!Accommodations.drawsRestingBorder(contrast: .standard))
        #expect(Accommodations.drawsRestingBorder(contrast: .increased))
    }

    /// Alpha is the other way a border can be weak. A width bump alone would leave the marquee's
    /// 50%-alpha edge and the drag shadow's 55%-alpha dashes just as hard to see, two points wider.
    @Test("A faded accent goes to full strength under Increase Contrast")
    func afadedAccentGoesFull() {
        for base in [0.5, 0.55, 0.6] {
            #expect(Accommodations.accentOpacity(base, contrast: .standard) == base)
            #expect(Accommodations.accentOpacity(base, contrast: .increased) == 1)
        }
    }

    /// The selected lane's glow is the resting treatment's softness, not the indicator itself:
    /// under Increase Contrast it yields to the solid full-alpha ring, because "strengthens
    /// borders" means crisper and a blur is the one thing a border cannot become.
    @Test("The selection halo yields to Increase Contrast")
    func theHaloYieldsToIncreaseContrast() {
        #expect(Accommodations.drawsSelectionHalo(contrast: .standard))
        #expect(!Accommodations.drawsSelectionHalo(contrast: .increased))
    }
}

// MARK: - Reduce Transparency

@Suite("Accommodations ▸ Reduce Transparency")
struct ReduceTransparencyTests {

    /// "Glass underlays go solid, wherever they appear" (10-accessibility.md). The board carries
    /// two materials — the transient search bar's `.bar` and the backdrop's title-bar frost — and
    /// the rule is one rule: both take the same solid, whatever their weights without it.
    @Test("Both glass underlays go solid")
    func glassGoesSolid() {
        #expect(Accommodations.underlay(reduceTransparency: false) == .glass)
        #expect(Accommodations.underlay(reduceTransparency: true) == .solid)
        #expect(Accommodations.frost(reduceTransparency: false) == .frost)
        #expect(Accommodations.frost(reduceTransparency: true) == .solid)
    }

    /// The washes are not glass — they composite at an alpha rather than sampling a backdrop — but
    /// they fail the same way for the same user, because what is behind them is a colour the *user*
    /// chose (03-board-ui.md § Styling). Each one goes opaque under the setting, and each keeps its
    /// own weight without it: the trash header is heavier than its plate, because the header is the
    /// whole of "you are looking at the trash".
    @Test("Every translucent wash goes opaque, and keeps its weight otherwise")
    func washesGoOpaque() {
        #expect(Accommodations.trashPlateWash(reduceTransparency: true) == .opaque)
        #expect(Accommodations.trashHeaderWash(reduceTransparency: true) == .opaque)
        #expect(Accommodations.dragShadowWash(reduceTransparency: true) == .opaque)

        #expect(Accommodations.trashPlateWash(reduceTransparency: false) == .translucent(opacity: 0.35))
        #expect(Accommodations.trashHeaderWash(reduceTransparency: false) == .translucent(opacity: 0.5))
        #expect(Accommodations.dragShadowWash(reduceTransparency: false) == .translucent(opacity: 0.5))
    }

    /// The trash column's two surfaces are a pair, and the pair has to stay legible as one: the
    /// header must read as heavier than the plate under it, or the column stops announcing itself.
    @Test("The trash header stays heavier than its plate")
    func theTrashHeaderStaysHeavier() {
        guard case let .translucent(header) = Accommodations.trashHeaderWash(reduceTransparency: false),
              case let .translucent(plate) = Accommodations.trashPlateWash(reduceTransparency: false)
        else {
            Issue.record("both washes should be translucent without the setting")
            return
        }
        #expect(header > plate)
    }
}
