import CoreGraphics
import Foundation

// MARK: - FacetsRecipe

/// **What a generated board background is made of** — the axis set the faceted gallery swept and the
/// review settled (DESIGN/explorations/board-backgrounds.md ▸ Faceted gallery, 2026-08-07).
///
/// The recipe plus its `seed` is the whole of the picture: `FacetsGenerator` is pure, so the same
/// pair renders the same mesh at any output size, on any machine, forever. That is what makes the
/// recipe worth being a value — a board can carry one, a picker can offer one, and neither has to
/// hold a bitmap to mean something.
///
/// ### The colour model is HSL, not HSB
///
/// The gallery is a web page and its swatches are CSS `hsl()`, so the numbers below — saturations of
/// 20/42/68, lightnesses of 90/88/85 — are HSL numbers and only mean what the reviewer saw when they
/// are read as HSL. `FacetsColor` converts; nothing here reaches for `NSColor`, whose `saturation`
/// and `brightness` are the other model's and would land somewhere else entirely.
///
/// ### The generator source is authoritative
///
/// Every constant here restates one in `board-backgrounds-faceted.html`'s `genFacets`/`triangleColor`
/// pair. Where the two ever disagree the HTML is the reviewed artefact and this is the port.
public struct FacetsRecipe: Sendable, Equatable, Hashable {

    /// The base hue — the one every strategy below builds its list from.
    public var hue: Hue

    /// How many hues the mesh draws from, and in what proportion.
    public var strategy: Strategy

    /// How finely the frame is diced.
    public var density: Density

    /// Which end of the lightness range the whole swatch sits at.
    public var tone: Tone

    /// How much colour there is at that lightness.
    public var saturation: Saturation

    /// The composition's identity. Two renders of the same recipe under the same seed are the same
    /// picture; changing it alone is the gallery's Reroll button.
    public var seed: UInt64

    public init(
        hue: Hue,
        strategy: Strategy,
        density: Density,
        tone: Tone,
        saturation: Saturation,
        seed: UInt64
    ) {
        self.hue = hue
        self.strategy = strategy
        self.density = density
        self.tone = tone
        self.saturation = saturation
        self.seed = seed
    }

    // MARK: Axes

    /// The eight-hue wheel sweep 1 established and the finals return to (the faceted round narrowed
    /// the *gallery* to four representatives to keep 216 swatches reviewable — it never narrowed the
    /// wheel).
    public enum Hue: Sendable, Equatable, Hashable, CaseIterable {
        case clay
        case amber
        case olive
        case forest
        case teal
        case sky
        case iris
        case rose

        /// Degrees on the colour wheel.
        public var degrees: Double {
            switch self {
            case .clay: 8
            case .amber: 38
            case .olive: 80
            case .forest: 140
            case .teal: 175
            case .sky: 215
            case .iris: 262
            case .rose: 335
            }
        }
    }

    /// How many hues a swatch draws from. The weights are the reviewed ones and they sum to 1 by
    /// construction, which is what `FacetsGenerator`'s single-draw weighted pick assumes.
    public enum Strategy: Sendable, Equatable, Hashable, CaseIterable {
        /// One hue; only the lightness jitter draws the mesh.
        case mono
        /// The base plus its complement, 65/35 — a dominant field with contrasting inclusions.
        case duo
        /// The contrasting triad H, H+120°, H−120°, weighted 50/30/20.
        case trio
    }

    /// Vertex count, as a grid of jittered cells laid over a region **larger than the picture** —
    /// and the size of that overhang, which is per-density for the reason below.
    ///
    /// ### The cell size is the gallery's; the ring is new
    ///
    /// What a viewer reads as "coarse" or "fine" is the size of a facet, not the number of points,
    /// so the numbers preserved from the reviewed gallery are the **cell dimensions** — 0.23 for
    /// coarse, 0.128 for medium, 0.0827 for fine (its 1.15/5, 1.15/9, 1.15/14 in unit terms). The
    /// grid then simply has however many cells it takes to cover the frame *plus* a boundary ring,
    /// which is where the extra columns and rows come from: 5×3 → 7×5, 9×6 → 10×7, 14×9 → 15×10.
    ///
    /// ### The ring is the full-bleed guarantee
    ///
    /// The gallery used one margin for all three densities (0.075 in unit terms) and got away with
    /// it: a mesh only covers its points' convex hull, and at medium and fine that margin left the
    /// frame covered often enough that nobody looking at swatches would notice. It is not a
    /// guarantee, though — a boundary point is placed anywhere in the middle 84% of its cell, so the
    /// worst draw puts it 0.92 of a cell *inward* of the region's edge, and against a margin of only
    /// 0.075 every density could land inside the picture: coarse by 0.163, medium by 0.044, fine by
    /// 0.0042. Each of those is a notch of flat ground colour on the frame edge, and coarse's — a
    /// sixth of the frame's height — is one anybody would see.
    ///
    /// So the margin is sized against the cell instead of fixed: **margin ≥ 0.92 × cell** on both
    /// axes, which is exactly the statement "even the worst jitter draw leaves every boundary-cell
    /// point at or beyond the frame edge". The whole frame is then interior to the hull and the mesh
    /// is full-bleed by construction rather than by luck. `FacetsGeneratorTests` holds the inequality.
    ///
    /// The ring's own triangles are drawn and then cropped away, which is what an oversized canvas
    /// costs: a third of coarse's faces are never seen. That is the trade the gallery was already
    /// making, made big enough to be a promise.
    public enum Density: Sendable, Equatable, Hashable, CaseIterable {
        case coarse
        case medium
        case fine

        public var columns: Int {
            switch self {
            case .coarse: 7
            case .medium: 10
            case .fine: 15
            }
        }

        public var rows: Int {
            switch self {
            case .coarse: 5
            case .medium: 7
            case .fine: 10
            }
        }

        /// How far the scatter runs past the frame on every side, in width units — the sacrificial
        /// ring. Symmetric on both axes because the frame is, and the cells are very nearly square.
        public var scatterMargin: Double {
            switch self {
            case .coarse: 0.305
            case .medium: 0.14
            case .fine: 0.12
            }
        }
    }

    /// Which end of the lightness range the swatch sits at. **Not a light/dark *pair*** — a board
    /// carries one background image and the app has no appearance-conditional backdrop, so this is a
    /// choice the author makes once, like choosing a photograph.
    public enum Tone: Sendable, Equatable, Hashable, CaseIterable {
        case light
        case dark
    }

    /// The saturation band. Rich rows get a little lightness headroom so the saturation actually
    /// shows — which is why the level below carries both numbers rather than a saturation alone.
    public enum Saturation: Sendable, Equatable, Hashable, CaseIterable {
        case soft
        case mid
        case rich
    }

    // MARK: The derived numbers

    /// One tone × saturation cell: the base saturation and lightness every triangle jitters around.
    public struct Level: Sendable, Equatable, Hashable {
        public var saturation: Double
        public var lightness: Double
    }

    /// **The per-triangle lightness jitter, ±4.5** — the same for both tones, because the narrow
    /// band *is* the recipe: "brightness stays a narrow per-triangle jitter around the tone base".
    /// Widening it on either end would stop the mesh reading as one surface catching light.
    public static let lightnessJitter: Double = 4.5

    /// This recipe's saturation/lightness cell.
    public var level: Level {
        switch (tone, saturation) {
        case (.light, .soft): Level(saturation: 20, lightness: 90)
        case (.light, .mid): Level(saturation: 42, lightness: 88)
        case (.light, .rich): Level(saturation: 68, lightness: 85)
        case (.dark, .soft): Level(saturation: 16, lightness: 17)
        case (.dark, .mid): Level(saturation: 34, lightness: 19)
        case (.dark, .rich): Level(saturation: 52, lightness: 21)
        }
    }

    /// The hues a triangle is picked from, with the weights that pick it. First entry is always the
    /// base hue, which is also the ground the mesh is painted over.
    public var hues: [WeightedHue] {
        let base = hue.degrees
        switch strategy {
        case .mono:
            return [WeightedHue(degrees: base, weight: 1)]
        case .duo:
            return [
                WeightedHue(degrees: base, weight: 0.65),
                WeightedHue(degrees: base + 180, weight: 0.35),
            ]
        case .trio:
            return [
                WeightedHue(degrees: base, weight: 0.5),
                WeightedHue(degrees: base + 120, weight: 0.3),
                WeightedHue(degrees: base - 120, weight: 0.2),
            ]
        }
    }

    /// One entry of the weighted hue list.
    public struct WeightedHue: Sendable, Equatable, Hashable {
        public var degrees: Double
        public var weight: Double
    }

    /// **The ground the mesh is painted over** — the base hue at the level's own saturation and
    /// lightness, un-jittered. The rect the generator fills before the first triangle lands.
    public var primaryColor: FacetsColor {
        FacetsColor(hue: hue.degrees, saturation: level.saturation, lightness: level.lightness)
    }

    /// The same colour as `#RRGGBB`, uppercase — **what the board's `background.color` gets set to**
    /// when a generated image is applied (`BoardStore.applyGeneratedBackground`).
    ///
    /// It is the honest fallback rather than a decoration: the colour underlay is what shows while
    /// the backdrop decodes, what shows if the file is later deleted from the folder by hand, and
    /// what a board copied without its image degrades to. Picking the mesh's own ground means all
    /// three land on the picture's average rather than on white.
    public var primaryColorHex: String { primaryColor.hexString }
}

// MARK: - FacetsColor

/// **One colour in the gallery's own model** — HSL, in degrees and percent, converted to sRGB on
/// demand (see `FacetsRecipe`'s note on why this is not HSB).
///
/// Stored as it was computed rather than as components, so a colour can be compared, hashed and
/// printed in the numbers the recipe is written in.
public struct FacetsColor: Sendable, Equatable, Hashable {

    /// Degrees, wrapped into 0..<360 — the `mod360` the generator applies before every emission.
    public var hue: Double

    /// Percent, clamped 0…100.
    public var saturation: Double

    /// Percent, clamped 0…100.
    public var lightness: Double

    public init(hue: Double, saturation: Double, lightness: Double) {
        self.hue = Self.wrapped(hue)
        self.saturation = min(max(saturation, 0), 100)
        self.lightness = min(max(lightness, 0), 100)
    }

    /// CSS's own `hsl()` → sRGB, component-wise in 0…1. The chroma/secondary/match-lightness form,
    /// which is the one the specification is written in and the one every browser implements.
    public var components: (red: Double, green: Double, blue: Double) {
        let saturation = saturation / 100
        let lightness = lightness / 100
        let chroma = (1 - abs(2 * lightness - 1)) * saturation
        let sextant = hue / 60
        let secondary = chroma * (1 - abs(sextant.truncatingRemainder(dividingBy: 2) - 1))
        let match = lightness - chroma / 2

        let (red, green, blue): (Double, Double, Double) = switch sextant {
        case ..<1: (chroma, secondary, 0)
        case ..<2: (secondary, chroma, 0)
        case ..<3: (0, chroma, secondary)
        case ..<4: (0, secondary, chroma)
        case ..<5: (secondary, 0, chroma)
        default: (chroma, 0, secondary)
        }
        return (red + match, green + match, blue + match)
    }

    /// `#RRGGBB`, uppercase — the spelling `Palette`'s hex reader and the colour panel's round trip
    /// both already speak (`NSColor.paletteHexString`), so a generated colour is indistinguishable
    /// from a hand-written one on disk.
    public var hexString: String {
        let (red, green, blue) = components
        return String(
            format: "#%02X%02X%02X",
            Self.byte(red), Self.byte(green), Self.byte(blue)
        )
    }

    /// The colour as CoreGraphics wants it, in the space the digits name. **`space` is passed in
    /// rather than made here** so a render creates one sRGB space for a whole mesh instead of one
    /// per triangle.
    func cgColor(in space: CGColorSpace) -> CGColor? {
        let (red, green, blue) = components
        return CGColor(colorSpace: space, components: [CGFloat(red), CGFloat(green), CGFloat(blue), 1])
    }

    private static func byte(_ component: Double) -> Int {
        min(max(Int((component * 255).rounded()), 0), 255)
    }

    /// Degrees into 0..<360, negatives included — `trio`'s third hue is `H − 120`, which is negative
    /// for every hue below clay's 8°.
    private static func wrapped(_ degrees: Double) -> Double {
        let wrapped = degrees.truncatingRemainder(dividingBy: 360)
        return wrapped < 0 ? wrapped + 360 : wrapped
    }
}
