import CoreGraphics
import Foundation
import ImageIO
import Testing
@testable import Kanban

/// `FacetsGenerator` — the Swift port of the reviewed faceted gallery
/// (DESIGN/explorations/board-backgrounds.md ▸ Faceted gallery; `board-backgrounds-faceted.html`).
///
/// What is worth pinning here is not "the picture looks nice", which no test can say, but the four
/// properties the feature is built on:
///
/// - **A seed names a composition** — so the picker's preview and the file written from it are the
///   same picture.
/// - **The triangulation is a triangulation** — it tiles its points' convex hull exactly and every
///   face is Delaunay, checked against the hull rather than against a remembered number.
/// - **The mesh is full bleed** — proved twice over, once as arithmetic on the density constants
///   (margin ≥ 0.92 × cell, which holds for every seed at once) and once on the meshes themselves
///   (boundary points clear the frame, the frame's corners are covered). This is the property the
///   reviewed gallery did *not* have; see `FacetsRecipe.Density`.
/// - **The colour model is HSL** — the reason the swatches look like the ones that were reviewed.

// MARK: - Fixtures

private func recipe(
    hue: FacetsRecipe.Hue = .sky,
    strategy: FacetsRecipe.Strategy = .duo,
    density: FacetsRecipe.Density = .medium,
    tone: FacetsRecipe.Tone = .light,
    saturation: FacetsRecipe.Saturation = .mid,
    seed: UInt64 = 0x5EED
) -> FacetsRecipe {
    FacetsRecipe(
        hue: hue, strategy: strategy, density: density,
        tone: tone, saturation: saturation, seed: seed
    )
}

/// The convex hull, by monotone chain — the region a Delaunay triangulation of these points must
/// cover exactly, which is the sharpest thing that can be asked of the port.
private func convexHull(_ points: [CGPoint]) -> [CGPoint] {
    let sorted = points.sorted { $0.x == $1.x ? $0.y < $1.y : $0.x < $1.x }
    guard sorted.count >= 3 else { return sorted }

    func cross(_ o: CGPoint, _ a: CGPoint, _ b: CGPoint) -> Double {
        Double(a.x - o.x) * Double(b.y - o.y) - Double(a.y - o.y) * Double(b.x - o.x)
    }
    func chain(_ points: [CGPoint]) -> [CGPoint] {
        var hull: [CGPoint] = []
        for point in points {
            while hull.count >= 2, cross(hull[hull.count - 2], hull[hull.count - 1], point) <= 0 {
                hull.removeLast()
            }
            hull.append(point)
        }
        return hull
    }
    // Each half drops its own last point, which is the other half's first.
    return Array(chain(sorted).dropLast()) + Array(chain(sorted.reversed()).dropLast())
}

/// The gallery's own cell dimensions: its 1.15 × 0.775 region (552 × 372 at the 480-wide scale, in
/// unit terms) divided by its 5×3, 9×6 and 14×9 grids.
private let reviewedCells: [(density: FacetsRecipe.Density, width: Double, height: Double)] = [
    (.coarse, 1.15 / 5, 0.775 / 3),
    (.medium, 1.15 / 9, 0.775 / 6),
    (.fine, 1.15 / 14, 0.775 / 9),
]

/// Whether `point` is inside `face`, edges included — the three edge cross-products agreeing in
/// sign. The tolerance admits a point exactly on an edge, which every frame corner shared by two
/// faces is.
private func contains(_ face: FacetsGenerator.Face, _ point: CGPoint) -> Bool {
    func side(_ a: CGPoint, _ b: CGPoint) -> Double {
        Double(b.x - a.x) * Double(point.y - a.y) - Double(b.y - a.y) * Double(point.x - a.x)
    }
    let first = side(face.a, face.b)
    let second = side(face.b, face.c)
    let third = side(face.c, face.a)
    let epsilon = 1e-12
    return (first >= -epsilon && second >= -epsilon && third >= -epsilon)
        || (first <= epsilon && second <= epsilon && third <= epsilon)
}

/// A simple polygon's area, by the shoelace formula.
private func polygonArea(_ polygon: [CGPoint]) -> Double {
    guard polygon.count >= 3 else { return 0 }
    var total = 0.0
    for index in polygon.indices {
        let a = polygon[index]
        let b = polygon[(index + 1) % polygon.count]
        total += Double(a.x) * Double(b.y) - Double(b.x) * Double(a.y)
    }
    return abs(total) / 2
}

/// The image a PNG payload decodes to — the only way to ask what was actually encoded rather than
/// what was handed to the encoder.
private func decoded(_ data: Data) -> CGImage? {
    guard let source = CGImageSourceCreateWithData(data as CFData, nil) else { return nil }
    return CGImageSourceCreateImageAtIndex(source, 0, nil)
}

// MARK: - A seed names a composition

@MainActor
@Suite("Facets ▸ determinism")
struct FacetsDeterminismTests {

    /// The whole reason the generator is pure: the picker's preview, the file written to the board
    /// folder, and a re-render on another Mac next year are one picture.
    @Test("The same recipe and seed render identical bytes")
    func sameSeedSameBytes() throws {
        let first = try #require(FacetsGenerator.pngData(recipe: recipe(), pixelWidth: 96))
        let second = try #require(FacetsGenerator.pngData(recipe: recipe(), pixelWidth: 96))
        #expect(first == second)
    }

    /// Reroll's whole job.
    @Test("A different seed renders different bytes")
    func differentSeedDiffers() throws {
        let first = try #require(FacetsGenerator.pngData(recipe: recipe(seed: 1), pixelWidth: 96))
        let second = try #require(FacetsGenerator.pngData(recipe: recipe(seed: 2), pixelWidth: 96))
        #expect(first != second)
    }

    /// **Normalized space, doing its job**: the composition is the same mesh at any output size, so
    /// the geometry a small preview shows is the geometry the 3072 px file has.
    @Test("Size changes the pixels, never the mesh")
    func sizeDoesNotChangeTheMesh() {
        let mesh = FacetsGenerator.facets(recipe: recipe())
        let again = FacetsGenerator.facets(recipe: recipe())
        #expect(mesh == again)

        let small = FacetsGenerator.render(recipe: recipe(), pixelWidth: 64)
        let large = FacetsGenerator.render(recipe: recipe(), pixelWidth: 640)
        #expect(small?.width == 64)
        #expect(large?.width == 640)
    }

    /// Every axis is part of the identity — a picker that changed one of them and got the same
    /// picture back would be a picker with a dead control.
    @Test("Each axis changes the picture")
    func everyAxisMatters() {
        let base = FacetsGenerator.facets(recipe: recipe())
        #expect(FacetsGenerator.facets(recipe: recipe(hue: .rose)) != base)
        #expect(FacetsGenerator.facets(recipe: recipe(strategy: .trio)) != base)
        #expect(FacetsGenerator.facets(recipe: recipe(density: .fine)) != base)
        #expect(FacetsGenerator.facets(recipe: recipe(tone: .dark)) != base)
        #expect(FacetsGenerator.facets(recipe: recipe(saturation: .rich)) != base)
    }
}

// MARK: - The mesh

@MainActor
@Suite("Facets ▸ the mesh")
struct FacetsMeshTests {

    /// **The triangulation tiles its points' convex hull exactly** — no hole, no overlap, nothing
    /// left over. Total face area against the hull's own area is the whole claim in one number, and
    /// it is the claim that matters here: a hole is a patch of flat ground colour in the middle of
    /// the picture, which is exactly the artefact the small super-triangle in the gallery's own
    /// generator produces and this port's larger one does not (`FacetsGenerator.triangulate`).
    ///
    /// Checked across every density and a dozen seeds rather than one, because a triangulator's
    /// failures are input-shaped.
    ///
    /// The tolerance is what a *hull* can honestly promise: when a point lands essentially on the
    /// line between its two neighbours, the sliver between them has no circumcircle to speak of and
    /// is not made. Swept over 600 meshes the largest such gap is 5.5 × 10⁻⁵ of a unit square; the
    /// smallest hole a *missing face* could leave is a fraction of a cell, and the smallest cell in
    /// the table is fine's at 7.2 × 10⁻³. The threshold sits between the two.
    @Test("The mesh tiles the hull exactly", arguments: [FacetsRecipe.Density.coarse, .medium, .fine])
    func meshTilesTheHull(density: FacetsRecipe.Density) {
        for seed in UInt64(1)...12 {
            var random = FacetsRandom(seed: seed)
            let points = FacetsGenerator.scatter(density, using: &random)
            #expect(points.count == density.columns * density.rows)

            let hull = polygonArea(convexHull(points))
            let tiled = FacetsGenerator.triangulate(points).reduce(0.0) { total, triangle in
                total + FacetsGenerator.Face(
                    a: points[triangle.a], b: points[triangle.b], c: points[triangle.c],
                    color: FacetsColor(hue: 0, saturation: 0, lightness: 0)
                ).area
            }
            #expect(abs(tiled - hull) < 3e-4, "density \(density), seed \(seed): \(tiled) vs hull \(hull)")
        }
    }

    /// **The Delaunay property itself**: no point sits inside another triangle's circumcircle. A
    /// tiling alone could be any triangulation — this is the one the recipe names, and the one whose
    /// fat triangles make the mesh read as facets rather than as splinters.
    ///
    /// The tolerance is relative and tiny; it exists because four points can be *nearly* cocircular,
    /// not because the predicate is soft.
    @Test("Every face is Delaunay", arguments: [FacetsRecipe.Density.coarse, .medium, .fine])
    func facesAreDelaunay(density: FacetsRecipe.Density) {
        for seed in UInt64(1)...12 {
            var random = FacetsRandom(seed: seed)
            let points = FacetsGenerator.scatter(density, using: &random)
            for triangle in FacetsGenerator.triangulate(points) {
                guard let circle = FacetsGenerator.circumcircle(
                    points[triangle.a], points[triangle.b], points[triangle.c]
                ) else {
                    Issue.record("a face with no circumcircle survived")
                    continue
                }
                for (index, point) in points.enumerated()
                where index != triangle.a && index != triangle.b && index != triangle.c {
                    let dx = Double(point.x) - circle.x
                    let dy = Double(point.y) - circle.y
                    #expect(dx * dx + dy * dy >= circle.radiusSquared * (1 - 1e-9),
                            "density \(density), seed \(seed): point \(index) is inside a face's circumcircle")
                }
            }
        }
    }

    /// **The full-bleed inequality** — the one line the whole boundary ring exists to satisfy
    /// (`FacetsRecipe.Density.scatterMargin`).
    ///
    /// A boundary cell's point is placed anywhere in the middle 84% of it, so the worst draw pushes
    /// it 0.92 of a cell *inward* from the outer edge. `margin ≥ 0.92 × cell` on both axes is
    /// therefore exactly "no draw can put a boundary point inside the picture", which is what makes
    /// the frame interior to the hull for **every** seed rather than for most of them.
    ///
    /// It is arithmetic on constants, so it holds for all seeds at once — the sharpest form the
    /// claim has, and the one that would catch a future density added with a margin copied from its
    /// neighbour.
    @Test("Every density's margin outruns its worst jitter draw", arguments: FacetsRecipe.Density.allCases)
    func marginOutrunsTheJitter(density: FacetsRecipe.Density) {
        let cellWidth = (1 + 2 * density.scatterMargin) / Double(density.columns)
        let cellHeight = (FacetsGenerator.frameHeight + 2 * density.scatterMargin) / Double(density.rows)
        #expect(density.scatterMargin >= FacetsGenerator.jitterReach * cellWidth,
                "\(density): margin \(density.scatterMargin) < \(FacetsGenerator.jitterReach * cellWidth)")
        #expect(density.scatterMargin >= FacetsGenerator.jitterReach * cellHeight,
                "\(density): margin \(density.scatterMargin) < \(FacetsGenerator.jitterReach * cellHeight)")
    }

    /// **The reviewed facet size, preserved** — the number a viewer actually reads as "coarse" or
    /// "fine" (`FacetsRecipe.Density`).
    ///
    /// The grid grew when the boundary ring went in (5×3 → 7×5, 9×6 → 10×7, 14×9 → 15×10), and this
    /// is the guard that says it grew *outward*: the cell is still the gallery's 1.15/5, 1.15/9 and
    /// 1.15/14 in unit terms, so the same number of facets falls inside the picture as did in the
    /// swatches that were reviewed. Stretching the cells to reach the edges instead would have kept
    /// the point counts and changed every density's character.
    @Test("The cell size is the gallery's", arguments: reviewedCells)
    func cellSizeMatchesTheGallery(density: FacetsRecipe.Density, width: Double, height: Double) {
        let cellWidth = (1 + 2 * density.scatterMargin) / Double(density.columns)
        let cellHeight = (FacetsGenerator.frameHeight + 2 * density.scatterMargin) / Double(density.rows)
        // 5%, which is what round margins cost: medium and fine land within half a percent on both
        // axes, and coarse's cell comes out 4% shorter — its ring is 1.3 cells deep, so squaring the
        // grid up moved the height and left the width exactly where it was.
        #expect(abs(cellWidth - width) / width < 0.05, "\(density) width \(cellWidth) vs \(width)")
        #expect(abs(cellHeight - height) / height < 0.05, "\(density) height \(cellHeight) vs \(height)")
    }

    /// The facet counts that follow from those cells: how many faces land **inside the picture**,
    /// which is the number the gallery's "≈20 · ≈97 · ≈230" was describing. The ring's own faces are
    /// cropped away and are not part of what anyone judged.
    @Test("The visible facet count matches the reviewed density")
    func visibleDensityMatchesTheGallery() {
        func visible(_ density: FacetsRecipe.Density, seed: UInt64) -> Int {
            FacetsGenerator.facets(recipe: recipe(density: density, seed: seed)).faces.count { face in
                let x = Double(face.a.x + face.b.x + face.c.x) / 3
                let y = Double(face.a.y + face.b.y + face.c.y) / 3
                return x >= 0 && x <= 1 && y >= 0 && y <= FacetsGenerator.frameHeight
            }
        }
        for seed in UInt64(1)...12 {
            #expect((14...30).contains(visible(.coarse, seed: seed)), "coarse: \(visible(.coarse, seed: seed))")
            #expect((62...84).contains(visible(.medium, seed: seed)), "medium: \(visible(.medium, seed: seed))")
            #expect((160...185).contains(visible(.fine, seed: seed)), "fine: \(visible(.fine, seed: seed))")
        }
    }

    /// **Full bleed, checked on the points** — the inequality above, arrived at from the other end.
    ///
    /// Every point in the first and last column sits at or beyond the left and right frame edges,
    /// and every point in the first and last row at or beyond the top and bottom. That is what makes
    /// the picture interior to the convex hull: each of its four sides has a wall of points past it.
    @Test("Every boundary point lands outside the frame on its own side",
          arguments: FacetsRecipe.Density.allCases)
    func boundaryPointsClearTheFrame(density: FacetsRecipe.Density) {
        let rows = density.rows
        for seed in UInt64(1)...12 {
            var random = FacetsRandom(seed: seed)
            let points = FacetsGenerator.scatter(density, using: &random)
            for (index, point) in points.enumerated() {
                // Column-major: index = column × rows + row (see `FacetsGenerator.scatter`).
                let column = index / rows
                let row = index % rows
                if column == 0 { #expect(Double(point.x) <= 0, "\(density)/\(seed): left \(point.x)") }
                if column == density.columns - 1 {
                    #expect(Double(point.x) >= 1, "\(density)/\(seed): right \(point.x)")
                }
                if row == 0 { #expect(Double(point.y) <= 0, "\(density)/\(seed): top \(point.y)") }
                if row == rows - 1 {
                    #expect(Double(point.y) >= FacetsGenerator.frameHeight,
                            "\(density)/\(seed): bottom \(point.y)")
                }
            }
        }
    }

    /// **And full bleed, checked on the pixels that matter**: the four frame corners are each inside
    /// some face. A corner is where a triangulation's coverage fails first, and a corner showing flat
    /// ground colour is the artefact this whole ring was built to remove.
    ///
    /// The total-area check rides along. Over 200 seeds a density the *worst* mesh still covers
    /// 2.15× the frame at coarse and 1.49× at medium and fine, so the arithmetic is never close —
    /// which is the point of a ring sized against the jitter rather than against a taste for how
    /// much overhang looks like enough.
    @Test("The frame's corners are covered", arguments: FacetsRecipe.Density.allCases)
    func frameCornersAreCovered(density: FacetsRecipe.Density) {
        let height = FacetsGenerator.frameHeight
        let corners = [
            CGPoint(x: 0, y: 0), CGPoint(x: 1, y: 0),
            CGPoint(x: 1, y: height), CGPoint(x: 0, y: height),
        ]
        for seed in UInt64(1)...12 {
            let mesh = FacetsGenerator.facets(recipe: recipe(density: density, seed: seed))
            #expect(mesh.faces.reduce(0) { $0 + $1.area } >= height, "\(density)/\(seed): total area")
            for corner in corners {
                #expect(mesh.faces.contains { contains($0, corner) },
                        "density \(density), seed \(seed): corner \(corner) shows ground colour")
            }
        }
    }

    /// No face may be degenerate: a zero-area triangle is a circumcircle the guard should have
    /// refused, and a stroked sliver is a visible scratch across the picture.
    @Test("No face is degenerate")
    func facesHaveArea() {
        for seed in UInt64(1)...12 {
            let mesh = FacetsGenerator.facets(recipe: recipe(density: .fine, seed: seed))
            #expect(mesh.faces.allSatisfy { $0.area > 0 })
        }
    }

    /// Points land inside their own cell's middle band, which is what keeps neighbours from
    /// coinciding — and inside the outset region, which everything above rests on.
    @Test("The scatter stays inside the outset region", arguments: FacetsRecipe.Density.allCases)
    func scatterStaysInTheRegion(density: FacetsRecipe.Density) {
        var random = FacetsRandom(seed: 7)
        let points = FacetsGenerator.scatter(density, using: &random)
        let margin = density.scatterMargin
        #expect(points.allSatisfy { Double($0.x) >= -margin && Double($0.x) <= 1 + margin })
        #expect(points.allSatisfy {
            Double($0.y) >= -margin && Double($0.y) <= FacetsGenerator.frameHeight + margin
        })
    }
}

// MARK: - Colour

@MainActor
@Suite("Facets ▸ colour is HSL")
struct FacetsColorTests {

    /// **Hand-computed CSS `hsl()`**, which is the whole claim: the gallery's swatches are `hsl()`
    /// strings, so a port that reached for `NSColor`'s hue/saturation/**brightness** would render a
    /// different set of colours from the ones that were reviewed.
    @Test("primaryColorHex is the recipe's level as CSS reads it", arguments: [
        (FacetsRecipe.Hue.sky, FacetsRecipe.Tone.light, FacetsRecipe.Saturation.soft, "#E0E5EB"),
        (.amber, .dark, .rich, "#513D1A"),
        (.forest, .light, .rich, "#BFF3D0"),
        (.rose, .dark, .soft, "#32242A"),
        (.clay, .light, .mid, "#EDD7D4"),
        (.iris, .dark, .mid, "#2C2041"),
    ])
    func primaryColorMatchesHSL(
        hue: FacetsRecipe.Hue,
        tone: FacetsRecipe.Tone,
        saturation: FacetsRecipe.Saturation,
        expected: String
    ) {
        let recipe = recipe(hue: hue, tone: tone, saturation: saturation)
        #expect(recipe.primaryColorHex == expected)
    }

    /// The ground the generator paints under the mesh is the recipe's own primary — the value the
    /// board's `background.color` is set to, so the underlay and the picture agree.
    @Test("The ground is the primary colour")
    func groundIsThePrimary() {
        let recipe = recipe(hue: .teal, tone: .light, saturation: .soft)
        #expect(FacetsGenerator.facets(recipe: recipe).ground == recipe.primaryColor)
        #expect(recipe.primaryColorHex == "#E0EBEA")
    }

    /// The strategies' weighted lists, as the gallery states them — mono one hue, duo the complement
    /// at 65/35, trio the triad at 50/30/20 — each summing to 1, which is what the single-draw pick
    /// assumes.
    @Test("The hue lists are the reviewed ones")
    func hueListsMatchTheGallery() {
        #expect(recipe(hue: .sky, strategy: .mono).hues.map(\.degrees) == [215])
        #expect(recipe(hue: .sky, strategy: .duo).hues.map(\.degrees) == [215, 395])
        #expect(recipe(hue: .sky, strategy: .trio).hues.map(\.degrees) == [215, 335, 95])
        for strategy in FacetsRecipe.Strategy.allCases {
            let total = recipe(strategy: strategy).hues.reduce(0) { $0 + $1.weight }
            #expect(abs(total - 1) < 1e-12, "\(strategy) weights must sum to 1")
        }
    }

    /// Lightness stays in the narrow band the recipe names — "brightness stays a narrow per-triangle
    /// jitter around the tone base", which is the property that makes a whole swatch read as one
    /// surface. Hue and saturation jitter around theirs the same way.
    @Test("Every face jitters inside its recipe's bands")
    func facesStayInTheirBands() {
        let recipe = recipe(hue: .forest, strategy: .mono, density: .fine, tone: .dark, saturation: .rich)
        let level = recipe.level
        for face in FacetsGenerator.facets(recipe: recipe).faces {
            #expect(abs(face.color.lightness - level.lightness) <= FacetsRecipe.lightnessJitter)
            #expect(face.color.saturation >= level.saturation * 0.85)
            #expect(face.color.saturation <= level.saturation * 1.15)
            // Mono: one hue, ±3° — wrapped, so 140 ± 3 stays comfortably away from the seam.
            #expect(abs(face.color.hue - 140) <= 3)
        }
    }

    /// The wrap that a triad needs: `H − 120` is negative for every hue below 120°, and a colour at
    /// −112° is a colour at 248°, not a colour at 0.
    @Test("A negative triad hue wraps rather than clamping")
    func negativeHuesWrap() {
        #expect(FacetsColor(hue: -112, saturation: 50, lightness: 50).hue == 248)
        #expect(FacetsColor(hue: 395, saturation: 50, lightness: 50).hue == 35)
        // Saturation and lightness clamp instead — they are percentages, not angles.
        #expect(FacetsColor(hue: 0, saturation: 140, lightness: -8).saturation == 100)
        #expect(FacetsColor(hue: 0, saturation: 140, lightness: -8).lightness == 0)
    }
}

// MARK: - The encoded file

@MainActor
@Suite("Facets ▸ the PNG")
struct FacetsPNGTests {

    /// 16:10, rounded — the aspect the board window is judged at and the one every swatch was
    /// reviewed in.
    @Test("The payload decodes at the requested width and a 16:10 height", arguments: [64, 480, 1024])
    func decodesAtTheRequestedSize(width: Int) throws {
        let data = try #require(FacetsGenerator.pngData(recipe: recipe(), pixelWidth: width))
        let image = try #require(decoded(data))
        #expect(image.width == width)
        #expect(image.height == FacetsGenerator.pixelHeight(forWidth: width))
        #expect(image.height == Int((Double(width) * 10 / 16).rounded()))
    }

    /// It really is a PNG — the first eight bytes of the format's own signature — because the board
    /// frontmatter is about to name this file and `BoardBackdrop.decode` will be asked to read it.
    @Test("The payload is a PNG")
    func payloadIsPNG() throws {
        let data = try #require(FacetsGenerator.pngData(recipe: recipe(), pixelWidth: 64))
        #expect(Array(data.prefix(8)) == [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A])
    }

    /// Opaque, and stated: the backdrop sits under lane plates and card faces, and an image carrying
    /// alpha would let the window's own background through in a way no swatch was reviewed with.
    @Test("The render is opaque")
    func renderIsOpaque() throws {
        let image = try #require(FacetsGenerator.render(recipe: recipe(), pixelWidth: 64))
        #expect(image.alphaInfo == .noneSkipLast)
    }
}
