use bone_document::{EditOutcome, Sketch, SketchEdit, SketchEntity};
use bone_render::{
    Camera2, PickAperture, PickQuery, PickedItem, RenderError, SketchRenderer, SketchScene, Style,
    ViewportExtent, ViewportPx,
};
use bone_types::{Length, Point2, Point3, SketchEntityId, SketchPlaneBasis, Tolerance, UnitVec3};
use uom::si::length::millimeter;

mod common;

const SIDE: u32 = 256;
const CENTER_PX: f32 = 128.0;
const PX_PER_MM: f32 = 10.0;

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

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) -> (Sketch, SketchEntityId) {
    let Ok((next, EditOutcome::Entity(id))) =
        s.apply(SketchEdit::AddEntity(SketchEntity::line(a, b, false)))
    else {
        panic!("add line");
    };
    (next, id)
}

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

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

#[allow(
    clippy::cast_possible_truncation,
    clippy::cast_sign_loss,
    reason = "fixture coordinates are bounded within the small test viewport"
)]
fn world_to_px(x_mm: f32, y_mm: f32) -> PickQuery {
    let x_px = (CENTER_PX + x_mm * PX_PER_MM).round() as u32;
    let y_px = (CENTER_PX - y_mm * PX_PER_MM).round() as u32;
    PickQuery::new(ViewportPx::new(x_px), ViewportPx::new(y_px))
}

struct Fixture {
    scene: SketchScene,
    probe_point: (PickQuery, SketchEntityId),
    probe_line: (PickQuery, SketchEntityId),
    probe_circle: (PickQuery, SketchEntityId),
    probe_arc: (PickQuery, SketchEntityId),
}

fn build_fixture() -> Fixture {
    let s = Sketch::new(plane());

    let (s, line_a) = add_point(s, -6.0, 2.0);
    let (s, line_b) = add_point(s, -6.0, -2.0);
    let (s, line) = add_line(s, line_a, line_b);

    let (s, standalone) = add_point(s, 6.0, 0.0);

    let (s, circle_center) = add_point(s, 0.0, 4.0);
    let (s, circle) = add_circle(s, circle_center, 2.0);

    let (s, arc_center) = add_point(s, 0.0, -4.0);
    let (s, arc_start) = add_point(s, 3.0, -4.0);
    let (s, arc_end) = add_point(s, 0.0, -1.0);
    let (s, arc) = add_arc(s, arc_center, arc_start, arc_end);

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

    Fixture {
        scene,
        probe_point: (world_to_px(6.0, 0.0), standalone),
        probe_line: (world_to_px(-6.0, 0.0), line),
        probe_circle: (world_to_px(2.0, 4.0), circle),
        probe_arc: (
            world_to_px(
                3.0 * core::f32::consts::FRAC_1_SQRT_2,
                -4.0 + 3.0 * core::f32::consts::FRAC_1_SQRT_2,
            ),
            arc,
        ),
    }
}

#[test]
fn each_entity_centroid_round_trips_to_pick_id() {
    let ctx = common::make_context(extent());
    let mut renderer = SketchRenderer::new(ctx.gpu(), ctx.color_format());
    let camera = Camera2::new(extent());
    let style = Style::default();

    let fx = build_fixture();
    let Ok(_) = renderer.render(&ctx, &fx.scene, camera, &style) else {
        panic!("SketchRenderer::render failed");
    };
    let Ok(index) = fx.scene.pick_index() else {
        panic!("pick index build");
    };
    let picker = ctx.picker(index);

    let cases = [
        (
            "point",
            fx.probe_point.0.with_aperture(PickAperture::EXACT),
            PickedItem::Point(fx.probe_point.1),
        ),
        (
            "line",
            fx.probe_line.0.with_aperture(PickAperture::EXACT),
            PickedItem::Line(fx.probe_line.1),
        ),
        (
            "circle",
            fx.probe_circle.0.with_aperture(PickAperture::EXACT),
            PickedItem::Circle(fx.probe_circle.1),
        ),
        (
            "arc",
            fx.probe_arc.0.with_aperture(PickAperture::EXACT),
            PickedItem::Arc(fx.probe_arc.1),
        ),
    ];

    let mismatches: Vec<String> = cases
        .into_iter()
        .filter_map(|(label, query, expected)| match picker.at(query) {
            Ok(Some(got)) if got == expected => None,
            Ok(got) => Some(format!(
                "{label} pick at {query}: expected {expected:?}, got {got:?}"
            )),
            Err(e) => Some(format!("{label} pick at {query}: error {e}")),
        })
        .collect();
    assert!(mismatches.is_empty(), "pick mismatches: {mismatches:#?}");
}

#[test]
fn empty_space_decodes_to_none() {
    let ctx = common::make_context(extent());
    let mut renderer = SketchRenderer::new(ctx.gpu(), ctx.color_format());
    let camera = Camera2::new(extent());
    let style = Style::default();

    let fx = build_fixture();
    let Ok(_) = renderer.render(&ctx, &fx.scene, camera, &style) else {
        panic!("SketchRenderer::render failed");
    };
    let Ok(index) = fx.scene.pick_index() else {
        panic!("pick index");
    };
    let picker = ctx.picker(index);

    let empty = world_to_px(-8.0, 8.0);
    let Ok(hit) = picker.at(empty) else {
        panic!("picker at empty failed");
    };
    assert_eq!(hit, None, "empty pixel expected to decode to None");
}

#[test]
fn repeated_render_picks_deterministic() {
    let ctx = common::make_context(extent());
    let mut renderer = SketchRenderer::new(ctx.gpu(), ctx.color_format());
    let camera = Camera2::new(extent());
    let style = Style::default();

    let fx = build_fixture();
    let Ok(index) = fx.scene.pick_index() else {
        panic!("pick index");
    };

    let mut collect_picks = || {
        let Ok(_) = renderer.render(&ctx, &fx.scene, camera, &style) else {
            panic!("render");
        };
        let pp = ctx.picker(index.clone());
        [
            fx.probe_point.0,
            fx.probe_line.0,
            fx.probe_circle.0,
            fx.probe_arc.0,
        ]
        .into_iter()
        .map(|q| {
            let Ok(raw) = pp.raw_at(q) else {
                panic!("raw_at failed");
            };
            raw.raw()
        })
        .collect::<Vec<_>>()
    };

    let first = collect_picks();
    let second = collect_picks();
    assert_eq!(
        first, second,
        "repeated render produced non-deterministic ids"
    );
}

#[test]
fn aperture_picks_line_when_cursor_misses_by_a_few_pixels() {
    let ctx = common::make_context(extent());
    let mut renderer = SketchRenderer::new(ctx.gpu(), ctx.color_format());
    let camera = Camera2::new(extent());
    let style = Style::default();

    let fx = build_fixture();
    let Ok(_) = renderer.render(&ctx, &fx.scene, camera, &style) else {
        panic!("SketchRenderer::render failed");
    };
    let Ok(index) = fx.scene.pick_index() else {
        panic!("pick index build");
    };
    let picker = ctx.picker(index);

    let line_centroid = fx.probe_line.0;
    #[allow(
        clippy::cast_possible_truncation,
        clippy::cast_sign_loss,
        reason = "stroke half-width is small and bounded; ceil keeps offset positive"
    )]
    let stroke_clearance_px = (style.strokes().stroke_width_px() * 0.5 + 1.0).ceil() as u32;
    let off_axis = PickQuery::new(
        ViewportPx::new(line_centroid.x().value() + stroke_clearance_px),
        line_centroid.y(),
    );

    let Ok(default_hit) = picker.at(off_axis) else {
        panic!("aperture pick failed");
    };
    assert_eq!(
        default_hit,
        Some(PickedItem::Line(fx.probe_line.1)),
        "default aperture should snap a near-miss onto the line"
    );

    let Ok(exact_hit) = picker.at(off_axis.with_aperture(PickAperture::EXACT)) else {
        panic!("exact pick failed");
    };
    assert_eq!(
        exact_hit, None,
        "exact aperture must keep the near-miss as empty space"
    );
}

#[test]
fn aperture_picks_closer_entity_when_two_in_range() {
    let ctx = common::make_context(extent());
    let mut renderer = SketchRenderer::new(ctx.gpu(), ctx.color_format());
    let camera = Camera2::new(extent());
    let style = Style::default();

    let s = Sketch::new(plane());
    let (s, far_id) = add_point(s, 6.0, 0.0);
    let (s, near_id) = add_point(s, 9.0, 0.0);
    let Ok(scene) = SketchScene::extract(&s) else {
        panic!("scene extract");
    };
    let Ok(_) = renderer.render(&ctx, &scene, camera, &style) else {
        panic!("SketchRenderer::render failed");
    };
    let Ok(index) = scene.pick_index() else {
        panic!("pick index build");
    };
    let picker = ctx.picker(index);

    let query = PickQuery::new(ViewportPx::new(212), ViewportPx::new(128))
        .with_aperture(PickAperture::new(30));
    let Ok(hit) = picker.at(query) else {
        panic!("aperture pick failed");
    };
    assert_eq!(
        hit,
        Some(PickedItem::Point(near_id)),
        "wider aperture must pick the nearer of two in-range entities (near {near_id:?} over far {far_id:?})",
    );
}

#[test]
fn out_of_bounds_query_is_rejected() {
    let ctx = common::make_context(extent());
    let Ok(index) = SketchScene::empty().pick_index() else {
        panic!("pick index");
    };
    let picker = ctx.picker(index);
    let cases = [
        (
            "x-axis edge",
            PickQuery::new(ViewportPx::new(SIDE), ViewportPx::new(0)),
        ),
        (
            "y-axis edge",
            PickQuery::new(ViewportPx::new(0), ViewportPx::new(SIDE)),
        ),
    ];
    let failures: Vec<String> = cases
        .into_iter()
        .filter_map(|(label, query)| match picker.at(query) {
            Err(RenderError::PickOutOfBounds { .. }) => None,
            other => Some(format!("{label}: expected OOB, got {other:?}")),
        })
        .collect();
    assert!(failures.is_empty(), "OOB rejection failures: {failures:#?}");
}
