import AppKit
import SwiftUI

/// **WCAG contrast, as arithmetic** — the seam behind 10-accessibility.md's one hard numeric
/// accessibility rule: "The ≥ 4.5:1 automatic-contrast rule binds where text does sit on a
/// user-chosen color: the **board** background (palette pairs verified at design time; arbitrary hex
/// computes its text color at runtime against that threshold)."
///
/// ### Why a separate type rather than a computed property on a view
///
/// `Accommodations`' reason, doubled. A contrast decision made inside `body` is a decision no test
/// can hold still: the inputs would be an environment and a `NSColor` catalog lookup, and the output
/// would be an opaque `AnyShapeStyle`. So the *arithmetic* lives here — four plain numbers in, one
/// number or one `ColorScheme` out — and `BoardTextInk` below is the thin layer that asks AppKit for
/// the two colours the arithmetic needs. Everything in this enum is a pure function of its
/// arguments; nothing in it renders, reads the environment, or touches the main actor.
///
/// ### One code path, two verification stories
///
/// 03-board-ui.md ▸ Styling ▸ Controls describes the palette and the hand-written hex as if they were
/// two mechanisms — "every pair AA-verified at design time … the claim pinned by a computed-contrast
/// unit test over all 12 pairs", against "custom hex is not pickable in-app but stays fully honored
/// from disk (runtime contrast)". They are not. **A pair is a background and the ink its text is
/// drawn in, and nothing can pair them but the code that renders them**: a design-time table of
/// twelve colours cannot choose an ink, and every one of the twelve needs a *different* ink in the
/// two appearances (`chalk` is unreadable under a dark label, `obsidian` under a light one).
///
/// So both paths run this arithmetic, and the difference is entirely in **who checks the answer**:
///
/// - **The palette is verified statically.** `PaletteContrastTests` asserts that the ink this seam
///   *chooses* clears 4.5:1 for all twelve backgrounds in both appearances. That test **is** the
///   design-time verification 03 promises — a new well whose colour could not be read in one
///   appearance fails the suite instead of shipping.
/// - **Hand-written hex is verified at runtime, or not at all.** The value is whatever the author
///   typed into the file, so there is no table to check in advance; the same computation runs, and
///   `InkChoice.meetsAA` reports honestly when a colour has no readable ink at all (see `inkChoice`).
///
/// Which is why nothing below branches on provenance. `BoardTextInk` takes *any* resolved board
/// colour, palette or hex.
enum ContrastMath {

    /// The threshold, from 10-accessibility.md: **≥ 4.5:1**, WCAG AA for body text.
    ///
    /// Named rather than inlined because two different call sites compare against it — the decision
    /// below and the tests that pin it — and a rule with two spellings is a rule that can drift.
    static let aaThreshold: Double = 4.5

    // MARK: - Relative luminance

    /// A single sRGB channel, linearised — WCAG 2.x's transfer function, which is *not* a plain
    /// gamma curve: below the knee the encoding is linear (the `/ 12.92` leg), above it the usual
    /// 2.4 power with the 0.055 offset.
    ///
    /// Both legs matter for the board. Hand-written backgrounds cluster at the extremes — a
    /// near-black `#0A0A0A` is an ordinary thing to type — and those land squarely on the linear leg,
    /// where a naive `pow(c, 2.2)` is wrong by enough to flip a borderline decision.
    private static func linearised(_ channel: Double) -> Double {
        channel <= 0.03928 ? channel / 12.92 : pow((channel + 0.055) / 1.055, 2.4)
    }

    /// **Relative luminance** — WCAG's L, in [0, 1]: black is 0, white is 1, and mid-grey `#808080`
    /// is ≈ 0.2159 rather than 0.5, which is the whole reason this is computed rather than eyeballed.
    ///
    /// Alpha is ignored, deliberately: luminance is only defined for an opaque colour. A translucent
    /// one has no luminance until it is `composite`d over something, and asking for it directly is a
    /// question with no answer — every caller here composites first (see `inkChoice`).
    static func relativeLuminance(of color: SRGBColor) -> Double {
        0.2126 * linearised(color.red)
            + 0.7152 * linearised(color.green)
            + 0.0722 * linearised(color.blue)
    }

    /// The **contrast ratio** between two opaque colours: `(Lʟ + 0.05) / (Lᴅ + 0.05)`, in [1, 21].
    ///
    /// Symmetric by construction — the lighter of the two always takes the numerator — so a caller
    /// never has to know which of "text" and "background" it is holding.
    static func contrastRatio(_ one: SRGBColor, _ other: SRGBColor) -> Double {
        let a = relativeLuminance(of: one)
        let b = relativeLuminance(of: other)
        return (max(a, b) + 0.05) / (min(a, b) + 0.05)
    }

    // MARK: - Alpha compositing

    /// **Source-over compositing** with straight (non-premultiplied) alpha — what
    /// 10-accessibility.md means by "computes against the color composited over its effective
    /// backdrop".
    ///
    /// An `#RRGGBBAA` board background is not a colour until something is behind it, and *what* is
    /// behind it is appearance-dependent (`NSColor.windowBackgroundColor` is near-white in Aqua and
    /// near-black in Dark Aqua), which is why the design says "light and dark resolve differently"
    /// and why the decision has to be recomputed when the appearance flips rather than cached.
    ///
    /// The general form is written out rather than assuming an opaque backdrop, because the backdrop
    /// is a caller's value: composing a wash over a wash has to stay meaningful if a second
    /// translucent layer ever lands between the board colour and the window.
    static func composite(_ source: SRGBColor, over backdrop: SRGBColor) -> SRGBColor {
        let outputAlpha = source.alpha + backdrop.alpha * (1 - source.alpha)
        // A fully transparent result has no colour to report — the components would be a division by
        // zero. Nothing on the board composites two invisible layers, but the function has to be
        // total to be a seam.
        guard outputAlpha > 0 else { return SRGBColor(red: 0, green: 0, blue: 0, alpha: 0) }
        // The two weights are the same for every channel; hoisting them out keeps the three lines
        // below reading as one formula rather than three.
        let sourceWeight = source.alpha
        let backdropWeight = backdrop.alpha * (1 - source.alpha)
        return SRGBColor(
            red: (source.red * sourceWeight + backdrop.red * backdropWeight) / outputAlpha,
            green: (source.green * sourceWeight + backdrop.green * backdropWeight) / outputAlpha,
            blue: (source.blue * sourceWeight + backdrop.blue * backdropWeight) / outputAlpha,
            alpha: outputAlpha
        )
    }

    // MARK: - The decision

    /// The two inks a text surface can be drawn in — the label colour as it resolves in each
    /// appearance, which is exactly what overriding a subtree's `\.colorScheme` selects between.
    ///
    /// They carry **alpha**, and that is not a detail to be normalised away: `NSColor.labelColor` is
    /// 85% black in Aqua and 85% white in Dark Aqua, so the ink that actually lands on the board is
    /// the label *composited over the board colour*, several points of contrast short of pure black
    /// or pure white. Measuring the pure colour would let a background pass this check and still be
    /// hard to read, which is the failure mode the rule exists to prevent.
    struct Ink: Equatable, Sendable {
        /// The ink of the **light** appearance — dark glyphs.
        var light: SRGBColor
        /// The ink of the **dark** appearance — light glyphs.
        var dark: SRGBColor

        subscript(scheme: ColorScheme) -> SRGBColor {
            scheme == .dark ? dark : light
        }
    }

    /// A decision, with the number it was made on.
    ///
    /// The ratio rides along because `meetsAA` is not the whole answer: when neither ink clears the
    /// threshold the app still has to draw *something*, and a caller (a test, a future diagnostic)
    /// deserves to know it is looking at the best of two bad options rather than at a pass.
    struct InkChoice: Equatable, Sendable {
        /// The appearance whose label vocabulary the text should be drawn in.
        var scheme: ColorScheme
        /// The contrast ratio that choice achieves against the composited surface.
        var ratio: Double

        var meetsAA: Bool { ratio >= ContrastMath.aaThreshold }
    }

    /// **The rule**, in one function: given a board colour, the backdrop it is painted over, the two
    /// candidate inks and the appearance the window is actually in, pick the ink the text should use.
    ///
    /// Three cases, in order:
    ///
    /// 1. **Both inks clear 4.5:1** → the **appearance-native** one. The board should look like the
    ///    rest of the system wherever it can afford to; flipping to light glyphs in Aqua when dark
    ///    ones are already legible would be a gratuitous redesign of the user's board.
    /// 2. **Exactly one clears it** → that one, appearance notwithstanding. This is the case the card
    ///    exists for: a `background: #101010` board in the light appearance needs light glyphs, and
    ///    a `#F5F5DC` board in the dark appearance needs dark ones.
    /// 3. **Neither clears it** → the **higher-ratio** one, and `meetsAA` is `false`.
    ///
    /// Case 3 is a real outcome, not a defensive branch. The inks are 85%-alpha labels, so a
    /// hand-written mid-grey — `#6E6E6E` and its neighbours — tops out in the low fours against
    /// *both* of them: there is no text colour in the system's vocabulary that meets AA on it. The
    /// app's answer is the most legible thing available rather than a refusal to paint, because the
    /// alternative rulings are worse: overriding the user's colour would break "the bytes on disk are
    /// left exactly as written" (`Palette`), and inventing an off-vocabulary ink for one board would
    /// make that board's text stop matching every other window in the app. **The value is honoured
    /// and the text is as readable as it can be made** — the same lenient-never-an-error posture
    /// every other styling decision here takes.
    ///
    /// Ties go to the native appearance, which keeps the function total and stable: two inks that
    /// score identically are, by definition, equally legible.
    static func inkChoice(
        background: SRGBColor,
        backdrop: SRGBColor,
        ink: Ink,
        native: ColorScheme
    ) -> InkChoice {
        // The surface the glyphs actually land on. Opaque whenever the backdrop is, which the window
        // background always is — an `#RRGGBBAA` board colour is resolved here and nowhere else.
        let surface = composite(background, over: backdrop)
        let alternate: ColorScheme = native == .dark ? .light : .dark
        let nativeChoice = InkChoice(scheme: native, ratio: ratio(of: ink[native], on: surface))
        let alternateChoice = InkChoice(scheme: alternate, ratio: ratio(of: ink[alternate], on: surface))

        if nativeChoice.meetsAA { return nativeChoice }
        if alternateChoice.meetsAA { return alternateChoice }
        return alternateChoice.ratio > nativeChoice.ratio ? alternateChoice : nativeChoice
    }

    /// One ink's ratio against a surface — composited first, because the label colours are
    /// translucent (see `Ink`).
    static func ratio(of ink: SRGBColor, on surface: SRGBColor) -> Double {
        contrastRatio(composite(ink, over: surface), surface)
    }
}

// MARK: - A colour as four numbers

/// An sRGB colour with **straight alpha**, as four `Double`s — the currency `ContrastMath` works in.
///
/// It exists so the arithmetic can be pure and `Sendable`: `NSColor` is a device-and-appearance
/// dependent catalog lookup, and a contrast rule expressed over it would be a rule that only holds
/// on the machine that ran it. sRGB is also the colour space `background:` hex is *written* in
/// (`NSColor.init(paletteHex:)` — "the colour space the hex digits name, so a value hand-written
/// from a screenshot or a design tool renders as the same colour the author sampled"), so the
/// conversion at the boundary is a no-op for the values this file most cares about.
struct SRGBColor: Equatable, Sendable {
    var red: Double
    var green: Double
    var blue: Double
    var alpha: Double

    init(red: Double, green: Double, blue: Double, alpha: Double = 1) {
        self.red = red
        self.green = green
        self.blue = blue
        self.alpha = alpha
    }

    /// `nil` when the colour cannot be expressed in sRGB at all — a pattern or catalog colour with no
    /// component representation. Every caller treats that the way `Palette` treats an unreadable
    /// value: there is no colour, so fall back rather than guess.
    init?(_ color: NSColor) {
        guard let srgb = color.usingColorSpace(.sRGB) else { return nil }
        self.init(
            red: Double(srgb.redComponent),
            green: Double(srgb.greenComponent),
            blue: Double(srgb.blueComponent),
            alpha: Double(srgb.alphaComponent)
        )
    }
}

// MARK: - The board's application of the rule

/// The board's side of 10-accessibility.md's contrast rule: **which label vocabulary the text sitting
/// on a user-chosen `background:` colour should be drawn in**, resolved against the live appearance.
///
/// It takes the board's `background` field and nothing else about where the value came from. A
/// palette name and a hand-written hex reach the same arithmetic, because the ink they need is the
/// same question — see `ContrastMath`'s "one code path, two verification stories".
///
/// ### Why a `ColorScheme` and not a `Color`
///
/// The text on the board background is not one label. A lane header is a title, an icon, a count
/// badge on a `.quaternary` capsule and — mid-rename — a plain text field, and they are drawn in the
/// *hierarchy* (`.primary`, `.secondary`, `.quaternary`) rather than in named colours. Handing one
/// resolved `Color` to that subtree would flatten the hierarchy at every site that took it and leave
/// every site that didn't in the wrong ink.
///
/// Overriding `\.colorScheme` for the subtree moves the whole vocabulary at once, coherently, and
/// costs the call sites one modifier. It is also what the decision *is*: the two candidate inks are
/// the two appearances' label colours, so choosing an ink and choosing an appearance are the same
/// choice (`ContrastMath.Ink`).
///
/// ### Why it is recomputed rather than cached
///
/// Both inputs move. `NSColor.windowBackgroundColor` and `NSColor.labelColor` are catalog colours
/// that resolve differently per appearance *and* under Increase Contrast, and 10-accessibility.md
/// requires the answer to be "recomputed on appearance change". Reading `\.colorScheme` in `body` and
/// calling this is what makes that free: SwiftUI re-evaluates on the flip, and the resolution below
/// happens inside the appearance being asked about rather than whatever is current.
@MainActor
enum BoardTextInk {

    /// The scheme a board's text should be drawn in, given its `background` field and the appearance
    /// the window is in.
    ///
    /// **Returns `appearance` unchanged exactly when the board paints no background** — a missing
    /// key, a malformed value, a name no table holds. Those paint nothing at all
    /// (`BoardView.boardBackground`), so the surface behind the text is the system's own and the
    /// system's own answer is the right one.
    ///
    /// Everything the board *does* paint is decided here, palette name and hand-written hex alike.
    /// That is not a widening of the hand-written case; it is the only way 03-board-ui.md's
    /// "every pair AA-verified at design time" can be true of a running app. The pair is a background
    /// and an ink, and with the appearance-native label — which is what the board drew before this —
    /// **all twelve palette backgrounds fail 4.5:1 in one of the two appearances**: the ten dark
    /// wells under Aqua's near-black label (`smokey-ocean` at 1.58:1, `obsidian` at 1.00:1), and
    /// `chalk` and `aluminum` under Dark Aqua's near-white one (1.00:1 and 2.44:1). Selecting the ink
    /// is what verifies the pair, and `PaletteContrastTests` is where the verification is kept.
    static func scheme(forBoardBackground background: FieldValue<String>, appearance: ColorScheme) -> ColorScheme {
        choice(forBoardBackground: background, appearance: appearance)?.scheme ?? appearance
    }

    /// `scheme(forBoardBackground:appearance:)` with its reasoning attached, or `nil` when the rule
    /// does not apply. Split out so the decision — including the ratio it was made on, and whether
    /// that ratio actually cleared AA — is inspectable from a test without a rendered view.
    static func choice(
        forBoardBackground background: FieldValue<String>,
        appearance: ColorScheme
    ) -> ContrastMath.InkChoice? {
        guard let color = paintedColor(background) else { return nil }
        return ContrastMath.inkChoice(
            background: color,
            backdrop: windowBackdrop(in: appearance),
            ink: labelInk(),
            native: appearance
        )
    }

    /// **The colour the board actually paints**, or `nil` when it paints none — a palette name, a
    /// `#RRGGBB[AA]` hex, or nothing at all.
    ///
    /// Deliberately `Palette.nsColor(for:)` and not a second, narrower parser. That function is the
    /// single source for what a `background` value means (`Palette` — "the split is what each
    /// *picker* offers, not a namespace"), so routing through it is what makes this predicate
    /// identical to `BoardView.boardBackground`'s `if let`: **the ink is decided for exactly the
    /// surfaces that get painted, and for no others.** Two spellings of "is there a colour here"
    /// could disagree; one cannot.
    ///
    /// The lenient degrade rides along for free — a typo'd name and a malformed hex both answer
    /// `nil`, which is a rendering instruction and never an error (`Palette`'s leading comment).
    static func paintedColor(_ background: FieldValue<String>) -> SRGBColor? {
        guard let value = background.value else { return nil }
        return Palette.nsColor(for: value).flatMap(SRGBColor.init)
    }

    /// What an `#RRGGBBAA` board colour is painted over: the window's own background, in the
    /// appearance asked for — "the board's over the window background; light and dark resolve
    /// differently" (10-accessibility.md).
    ///
    /// It is the right backdrop because it is the literal one: `BoardView` paints its background on
    /// the window's content, with nothing between (`BoardView.boardBackground`).
    static func windowBackdrop(in appearance: ColorScheme) -> SRGBColor {
        resolve(.windowBackgroundColor, in: appearance) ?? Self.fallbackBackdrop[appearance]
    }

    /// The two candidate inks — `NSColor.labelColor` as it resolves in each appearance, which is what
    /// `.primary` draws as on either side of the override.
    ///
    /// Asked of AppKit rather than written down as constants so the app follows the system: under
    /// Increase Contrast the label colours go to full strength, and a hardcoded 85% would then be
    /// measuring an ink the app is no longer drawing.
    static func labelInk() -> ContrastMath.Ink {
        ContrastMath.Ink(
            light: resolve(.labelColor, in: .light) ?? Self.fallbackInk.light,
            dark: resolve(.labelColor, in: .dark) ?? Self.fallbackInk.dark
        )
    }

    // MARK: - Appearance-scoped resolution

    /// Resolves a dynamic colour **inside** an appearance rather than whatever happens to be current.
    ///
    /// This is the whole reason the rule can be stated per-appearance at all: `NSColor.labelColor` is
    /// a catalog lookup, and asking it for components outside a drawing appearance answers for the
    /// app's current one — which, for the light half of the decision made in a dark window, is the
    /// wrong answer twice over.
    ///
    /// **Increase Contrast is folded in** because it changes these very colours: the high-contrast
    /// appearances take the label to full strength and the window background further from mid-grey,
    /// and measuring the standard pair while drawing the accessible one would understate the app's
    /// actual contrast (`Accommodations.prefersIncreasedContrast`, whose constituency this joins —
    /// there is no view environment here to read).
    private static func resolve(_ color: NSColor, in scheme: ColorScheme) -> SRGBColor? {
        guard let appearance = NSAppearance(named: appearanceName(for: scheme)) else { return nil }
        var resolved: SRGBColor?
        appearance.performAsCurrentDrawingAppearance { resolved = SRGBColor(color) }
        return resolved
    }

    private static func appearanceName(for scheme: ColorScheme) -> NSAppearance.Name {
        switch (scheme, Accommodations.prefersIncreasedContrast) {
        case (.dark, true): .accessibilityHighContrastDarkAqua
        case (.dark, false): .darkAqua
        case (_, true): .accessibilityHighContrastAqua
        case (_, false): .aqua
        }
    }

    // MARK: - Fallbacks

    /// Standing in for a resolution that failed — which should not happen, and must not crash or
    /// silently measure black-on-black if it does.
    ///
    /// The numbers are the system's own at the time of writing (`labelColor` is 85% black / 85%
    /// white; `windowBackgroundColor` is a light and a dark near-neutral), so a fallback decision is
    /// the decision the live colours would have produced rather than an arbitrary one.
    private static let fallbackInk = ContrastMath.Ink(
        light: SRGBColor(red: 0, green: 0, blue: 0, alpha: 0.85),
        dark: SRGBColor(red: 1, green: 1, blue: 1, alpha: 0.85)
    )

    private static let fallbackBackdrop = ContrastMath.Ink(
        light: SRGBColor(red: 0.925, green: 0.925, blue: 0.925),
        dark: SRGBColor(red: 0.196, green: 0.196, blue: 0.196)
    )
}

// MARK: - The call sites' one modifier

extension View {

    /// Draws this subtree's text in `scheme`'s label vocabulary — the render-site half of
    /// 10-accessibility.md's runtime-contrast rule (`BoardTextInk`).
    ///
    /// A no-op when `scheme` is the appearance the window is already in — which is the answer for
    /// every board that paints no background at all, and for every coloured one whose native label is
    /// already legible on it. So the modifier goes on unconditionally and the sites stay free of a
    /// branch.
    ///
    /// **Scoped by where it sits in the modifier chain**, deliberately: a subtree is only "text on
    /// the board background" up to the first opaque plate. Put it *inside* the modifier that draws a
    /// plate and the plate keeps the window's appearance; put it outside and the plate flips with the
    /// text. Both are used on the board — see `LaneView.header` and `TrashLaneView.header`.
    func boardTextInk(_ scheme: ColorScheme) -> some View {
        environment(\.colorScheme, scheme)
    }
}
