use std::path::PathBuf;

use bone_document::{EditOutcome, Sketch, SketchEdit, SketchEntity};
use bone_render::{
    Camera2, OffscreenContext, PixelDiff, PixelDiffThreshold, PixelsPerMm, SketchRenderer,
    SketchScene, SnapshotFrame, Style, ViewportExtent, decode_png, encode_png,
};
use bone_types::{Length, Point2, Point3, SketchEntityId, SketchPlaneBasis, Tolerance, UnitVec3};
use uom::si::length::millimeter;

mod common;

const REAL_GOLDEN: &str = "tests/goldens/construction_real_256.png";
const DASHED_GOLDEN: &str = "tests/goldens/construction_dashed_256.png";
const UPDATE_ENV: &str = "BONE_UPDATE_CONSTRUCTION_GOLDENS";
const DIFF_TOLERANCE: f64 = 16.0 / 255.0;
const DISTINCT_MIN_PIXELS: u32 = 50;

fn extent() -> ViewportExtent {
    common::extent_square(256)
}

fn plane() -> SketchPlaneBasis {
    let Ok(basis) = SketchPlaneBasis::new(
        Point3::origin(),
        UnitVec3::x_axis(),
        UnitVec3::y_axis(),
        Tolerance::new(1e-9),
    ) else {
        panic!("xy plane basis is orthogonal");
    };
    basis
}

fn add_point(s: Sketch, x: f64, y: f64) -> (Sketch, SketchEntityId) {
    let Ok((next, EditOutcome::Entity(id))) = s.apply(SketchEdit::AddEntity(SketchEntity::point(
        Point2::from_mm(x, y),
    ))) else {
        panic!("add point");
    };
    (next, id)
}

fn add_line(s: Sketch, a: SketchEntityId, b: SketchEntityId, construction: bool) -> Sketch {
    let Ok((next, _)) = s.apply(SketchEdit::AddEntity(SketchEntity::line(
        a,
        b,
        construction,
    ))) else {
        panic!("add line");
    };
    next
}

fn add_circle(s: Sketch, center: SketchEntityId, radius_mm: f64, construction: bool) -> Sketch {
    let Ok((next, _)) = s.apply(SketchEdit::AddEntity(SketchEntity::circle(
        center,
        Length::new::<millimeter>(radius_mm),
        construction,
    ))) else {
        panic!("add circle");
    };
    next
}

fn add_arc(
    s: Sketch,
    center: SketchEntityId,
    start: SketchEntityId,
    end: SketchEntityId,
    construction: bool,
) -> Sketch {
    let Ok((next, _)) = s.apply(SketchEdit::AddEntity(SketchEntity::arc(
        center,
        start,
        end,
        construction,
    ))) else {
        panic!("add arc");
    };
    next
}

fn build_scene(construction: bool) -> SketchScene {
    let s = Sketch::new(plane());

    let (s, la) = add_point(s, -0.9, 0.35);
    let (s, lb) = add_point(s, 0.9, 0.35);
    let s = add_line(s, la, lb, construction);

    let (s, cc) = add_point(s, -0.5, -0.3);
    let s = add_circle(s, cc, 0.25, construction);

    let (s, ac) = add_point(s, 0.5, -0.3);
    let (s, astart) = add_point(s, 0.85, -0.3);
    let (s, aend) = add_point(s, 0.5, 0.05);
    let s = add_arc(s, ac, astart, aend, construction);

    let Ok(scene) = SketchScene::extract(&s) else {
        panic!("scene extract");
    };
    scene
}

fn render_scene(ctx: &OffscreenContext, scene: &SketchScene) -> SnapshotFrame {
    let camera = Camera2::new(ctx.extent()).with_zoom(PixelsPerMm::new(100.0));
    let mut renderer = SketchRenderer::new(ctx.gpu(), ctx.color_format());
    let style = Style::default();
    let Ok(frame) = renderer.render(ctx, scene, camera, &style) else {
        panic!("SketchRenderer::render failed");
    };
    frame
}

fn golden_path(name: &str) -> PathBuf {
    PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(name)
}

fn match_or_update_golden(frame: &SnapshotFrame, name: &str) {
    let path = golden_path(name);
    if std::env::var(UPDATE_ENV).is_ok() {
        let Ok(bytes) = encode_png(frame) else {
            panic!("encode_png failed");
        };
        if let Some(parent) = path.parent()
            && let Err(e) = std::fs::create_dir_all(parent)
        {
            panic!("create goldens dir {}: {e}", parent.display());
        }
        if let Err(e) = std::fs::write(&path, &bytes) {
            panic!("write golden {}: {e}", path.display());
        }
        return;
    }
    let Ok(bytes) = std::fs::read(&path) else {
        panic!(
            "golden missing at {}: rerun with {UPDATE_ENV}=1 to generate",
            path.display()
        );
    };
    let Ok((golden_extent, golden_rgba)) = decode_png(&bytes) else {
        panic!("failed to decode golden PNG at {}", path.display());
    };
    assert_eq!(
        golden_extent,
        frame.extent(),
        "golden extent drift for {name}"
    );
    let threshold = PixelDiffThreshold::new(DIFF_TOLERANCE);
    let Ok(report) = PixelDiff::compare(frame, &golden_rgba, threshold) else {
        panic!("PixelDiff rejected inputs for {name}");
    };
    assert!(
        report.is_clean(),
        "{name} drifted: {} mismatches, worst {:?}, backend {}",
        report.over_threshold(),
        report.worst(),
        frame.backend(),
    );
}

#[test]
fn real_and_dashed_goldens_match() {
    let ctx = common::make_context(extent());
    let real = render_scene(&ctx, &build_scene(false));
    match_or_update_golden(&real, REAL_GOLDEN);
    let dashed = render_scene(&ctx, &build_scene(true));
    match_or_update_golden(&dashed, DASHED_GOLDEN);
}

#[test]
fn construction_render_differs_from_real_render() {
    let ctx = common::make_context(extent());
    let real = render_scene(&ctx, &build_scene(false));
    let dashed = render_scene(&ctx, &build_scene(true));
    assert_eq!(real.extent(), dashed.extent());
    let Ok(report) = PixelDiff::compare(&real, dashed.rgba(), PixelDiffThreshold::EXACT) else {
        panic!("PixelDiff rejected inputs");
    };
    assert!(
        report.over_threshold() >= DISTINCT_MIN_PIXELS,
        "construction styling not distinct from real: only {} pixels differ",
        report.over_threshold(),
    );
}
