use bone_document::{Sketch, SketchEntity};
use bone_types::{Length, Point2, SketchEntityId};
use uom::si::length::millimeter;

#[derive(Copy, Clone, Debug, PartialEq)]
pub enum SnapKind {
    Endpoint(SketchEntityId),
    Midpoint(SketchEntityId),
    Tangent(SketchEntityId),
    Horizontal,
    Vertical,
}

#[derive(Copy, Clone, Debug, PartialEq)]
pub struct SnapHit {
    pub kind: SnapKind,
    pub world: Point2,
}

#[derive(Copy, Clone, Debug, PartialEq)]
pub struct Anchor {
    pub pos: Point2,
    pub id: Option<SketchEntityId>,
}

#[must_use]
pub fn detect(
    cursor: Point2,
    anchor: Option<Anchor>,
    sketch: &Sketch,
    tolerance: Length,
) -> Option<SnapHit> {
    let tol = finite_tol(tolerance)?;
    endpoint_snap(cursor, anchor, sketch, tol)
        .or_else(|| midpoint_snap(cursor, anchor, sketch, tol))
        .or_else(|| anchor.and_then(|a| tangent_snap(cursor, a.pos, sketch, tol)))
        .or_else(|| anchor.and_then(|a| axis_snap(cursor, a.pos, tol)))
}

#[must_use]
pub fn detect_endpoint_only(cursor: Point2, sketch: &Sketch, tolerance: Length) -> Option<SnapHit> {
    endpoint_snap(cursor, None, sketch, finite_tol(tolerance)?)
}

fn finite_tol(tolerance: Length) -> Option<f64> {
    let tol = tolerance.get::<millimeter>();
    (tol.is_finite() && tol > 0.0).then_some(tol)
}

fn endpoint_snap(
    cursor: Point2,
    anchor: Option<Anchor>,
    sketch: &Sketch,
    tol: f64,
) -> Option<SnapHit> {
    let anchor_id = anchor.and_then(|a| a.id);
    sketch
        .entity_order()
        .iter()
        .filter_map(|id| match sketch.entities().get(*id) {
            Some(SketchEntity::Point(p)) => Some((*id, p.at())),
            _ => None,
        })
        .filter(|(id, _)| anchor_id != Some(*id))
        .filter_map(|(id, at)| {
            let d = distance(at, cursor);
            (d <= tol).then_some((d, id, at))
        })
        .min_by(|a, b| a.0.total_cmp(&b.0))
        .map(|(_, id, at)| SnapHit {
            kind: SnapKind::Endpoint(id),
            world: at,
        })
}

fn midpoint_snap(
    cursor: Point2,
    anchor: Option<Anchor>,
    sketch: &Sketch,
    tol: f64,
) -> Option<SnapHit> {
    let anchor_id = anchor.and_then(|a| a.id);
    sketch
        .entity_order()
        .iter()
        .filter_map(|id| line_midpoint(sketch, *id, anchor_id).map(|mid| (*id, mid)))
        .filter_map(|(id, mid)| {
            let d = distance(mid, cursor);
            (d <= tol).then_some((d, id, mid))
        })
        .min_by(|a, b| a.0.total_cmp(&b.0))
        .map(|(_, id, mid)| SnapHit {
            kind: SnapKind::Midpoint(id),
            world: mid,
        })
}

fn tangent_snap(cursor: Point2, anchor: Point2, sketch: &Sketch, tol: f64) -> Option<SnapHit> {
    sketch
        .entity_order()
        .iter()
        .filter_map(|id| curve_target(sketch, *id).map(|t| (*id, t)))
        .filter_map(|(id, target)| {
            tangent_world(anchor, cursor, target).and_then(|world| {
                let d = distance(world, cursor);
                (d <= tol).then_some((d, id, world))
            })
        })
        .min_by(|a, b| a.0.total_cmp(&b.0))
        .map(|(_, id, world)| SnapHit {
            kind: SnapKind::Tangent(id),
            world,
        })
}

fn axis_snap(cursor: Point2, anchor: Point2, tol: f64) -> Option<SnapHit> {
    let (cx, cy) = cursor.coords_mm();
    let (ax, ay) = anchor.coords_mm();
    let dx = (cx - ax).abs();
    let dy = (cy - ay).abs();
    if dx == 0.0 && dy == 0.0 {
        return None;
    }
    if dy <= tol && dy < dx {
        Some(SnapHit {
            kind: SnapKind::Horizontal,
            world: Point2::from_mm(cx, ay),
        })
    } else if dx <= tol && dx <= dy {
        Some(SnapHit {
            kind: SnapKind::Vertical,
            world: Point2::from_mm(ax, cy),
        })
    } else {
        None
    }
}

fn line_midpoint(
    sketch: &Sketch,
    id: SketchEntityId,
    exclude_anchor: Option<SketchEntityId>,
) -> Option<Point2> {
    let SketchEntity::Line(line) = sketch.entities().get(id)? else {
        return None;
    };
    if exclude_anchor.is_some_and(|aid| line.a() == aid || line.b() == aid) {
        return None;
    }
    let a = endpoint_position(sketch, line.a())?;
    let b = endpoint_position(sketch, line.b())?;
    let (ax, ay) = a.coords_mm();
    let (bx, by) = b.coords_mm();
    Some(Point2::from_mm(0.5 * (ax + bx), 0.5 * (ay + by)))
}

fn endpoint_position(sketch: &Sketch, id: SketchEntityId) -> Option<Point2> {
    match sketch.entities().get(id)? {
        SketchEntity::Point(p) => Some(p.at()),
        _ => None,
    }
}

#[derive(Copy, Clone)]
struct CurveTarget {
    center: Point2,
    radius: f64,
    arc_sweep: Option<(f64, f64)>,
}

fn curve_target(sketch: &Sketch, id: SketchEntityId) -> Option<CurveTarget> {
    match sketch.entities().get(id)? {
        SketchEntity::Circle(c) => {
            let center = endpoint_position(sketch, c.center())?;
            let radius = c.radius().get::<millimeter>();
            (radius.is_finite() && radius > 0.0).then_some(CurveTarget {
                center,
                radius,
                arc_sweep: None,
            })
        }
        SketchEntity::Arc(a) => {
            let center = endpoint_position(sketch, a.center())?;
            let start = endpoint_position(sketch, a.start())?;
            let end = endpoint_position(sketch, a.end())?;
            let radius = distance(center, start);
            if !(radius.is_finite() && radius > 0.0) {
                return None;
            }
            let (cx, cy) = center.coords_mm();
            let (sx, sy) = start.coords_mm();
            let (ex, ey) = end.coords_mm();
            let start_angle = (sy - cy).atan2(sx - cx);
            let end_angle = (ey - cy).atan2(ex - cx);
            let sweep = (end_angle - start_angle).rem_euclid(std::f64::consts::TAU);
            (sweep.is_finite() && sweep > 0.0).then_some(CurveTarget {
                center,
                radius,
                arc_sweep: Some((start_angle, sweep)),
            })
        }
        _ => None,
    }
}

fn tangent_world(anchor: Point2, cursor: Point2, target: CurveTarget) -> Option<Point2> {
    let (ax, ay) = anchor.coords_mm();
    let (cx, cy) = target.center.coords_mm();
    let dx = ax - cx;
    let dy = ay - cy;
    let d2 = dx * dx + dy * dy;
    let r2 = target.radius * target.radius;
    if d2 <= r2 {
        return None;
    }
    let l = (d2 - r2).sqrt();
    let along = r2 / d2;
    let perp = target.radius * l / d2;
    let along_xy = (dx * along, dy * along);
    let perp_xy = (-dy * perp, dx * perp);
    let t1 = Point2::from_mm(cx + along_xy.0 + perp_xy.0, cy + along_xy.1 + perp_xy.1);
    let t2 = Point2::from_mm(cx + along_xy.0 - perp_xy.0, cy + along_xy.1 - perp_xy.1);
    [t1, t2]
        .into_iter()
        .filter(|t| in_arc_sweep(*t, target))
        .min_by(|a, b| distance(*a, cursor).total_cmp(&distance(*b, cursor)))
}

fn in_arc_sweep(point: Point2, target: CurveTarget) -> bool {
    let Some((start_angle, sweep)) = target.arc_sweep else {
        return true;
    };
    let (cx, cy) = target.center.coords_mm();
    let (px, py) = point.coords_mm();
    let theta = (py - cy).atan2(px - cx);
    (theta - start_angle).rem_euclid(std::f64::consts::TAU) <= sweep
}

fn distance(a: Point2, b: Point2) -> f64 {
    let (ax, ay) = a.coords_mm();
    let (bx, by) = b.coords_mm();
    (ax - bx).hypot(ay - by)
}

#[cfg(test)]
mod tests {
    use super::*;
    use bone_document::{EditOutcome, SketchEdit, SketchEntity};
    use bone_types::{Point3, SketchPlaneBasis, Tolerance, UnitVec3};

    fn xy_basis() -> SketchPlaneBasis {
        let Ok(basis) = SketchPlaneBasis::new(
            Point3::origin(),
            UnitVec3::x_axis(),
            UnitVec3::y_axis(),
            Tolerance::new(1e-9),
        ) else {
            panic!("xy basis");
        };
        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)
    }

    fn tol() -> Length {
        Length::new::<millimeter>(0.8)
    }

    fn anchor_at(pos: Point2) -> Anchor {
        Anchor { pos, id: None }
    }

    #[test]
    fn no_snap_on_empty_sketch() {
        let sketch = Sketch::new(xy_basis());
        assert!(detect(Point2::from_mm(0.0, 0.0), None, &sketch, tol()).is_none());
    }

    #[test]
    fn endpoint_within_tolerance_wins() {
        let sketch = Sketch::new(xy_basis());
        let (sketch, p) = add_point(sketch, 5.0, 5.0);
        let cursor = Point2::from_mm(5.05, 5.0);
        let Some(hit) = detect(cursor, None, &sketch, tol()) else {
            panic!("expected endpoint snap");
        };
        assert_eq!(hit.kind, SnapKind::Endpoint(p));
        let (x, y) = hit.world.coords_mm();
        assert!((x - 5.0).abs() < 1e-9);
        assert!((y - 5.0).abs() < 1e-9);
    }

    #[test]
    fn endpoint_outside_tolerance_no_snap() {
        let sketch = Sketch::new(xy_basis());
        let (sketch, _) = add_point(sketch, 0.0, 0.0);
        let cursor = Point2::from_mm(10.0, 0.0);
        assert!(detect(cursor, None, &sketch, tol()).is_none());
    }

    #[test]
    fn midpoint_of_line_snaps() {
        let sketch = Sketch::new(xy_basis());
        let (sketch, a) = add_point(sketch, 0.0, 0.0);
        let (sketch, b) = add_point(sketch, 10.0, 0.0);
        let (sketch, line) = add_line(sketch, a, b);
        let cursor = Point2::from_mm(5.05, 0.05);
        let Some(hit) = detect(cursor, None, &sketch, tol()) else {
            panic!("expected midpoint snap");
        };
        assert_eq!(hit.kind, SnapKind::Midpoint(line));
        let (x, y) = hit.world.coords_mm();
        assert!((x - 5.0).abs() < 1e-9);
        assert!((y - 0.0).abs() < 1e-9);
    }

    #[test]
    fn endpoint_beats_midpoint_when_both_in_range() {
        let sketch = Sketch::new(xy_basis());
        let (sketch, a) = add_point(sketch, 0.0, 0.0);
        let (sketch, b) = add_point(sketch, 4.0, 0.0);
        let (sketch, _) = add_line(sketch, a, b);
        let cursor = Point2::from_mm(0.05, 0.05);
        let Some(hit) = detect(cursor, None, &sketch, tol()) else {
            panic!("expected snap");
        };
        assert!(matches!(hit.kind, SnapKind::Endpoint(_)));
    }

    #[test]
    fn horizontal_axis_with_anchor() {
        let sketch = Sketch::new(xy_basis());
        let cursor = Point2::from_mm(5.0, 0.05);
        let Some(hit) = detect(
            cursor,
            Some(anchor_at(Point2::from_mm(0.0, 0.0))),
            &sketch,
            tol(),
        ) else {
            panic!("expected horizontal snap");
        };
        assert_eq!(hit.kind, SnapKind::Horizontal);
        let (x, y) = hit.world.coords_mm();
        assert!((x - 5.0).abs() < 1e-9);
        assert!((y - 0.0).abs() < 1e-9);
    }

    #[test]
    fn vertical_axis_with_anchor() {
        let sketch = Sketch::new(xy_basis());
        let cursor = Point2::from_mm(0.05, 5.0);
        let Some(hit) = detect(
            cursor,
            Some(anchor_at(Point2::from_mm(0.0, 0.0))),
            &sketch,
            tol(),
        ) else {
            panic!("expected vertical snap");
        };
        assert_eq!(hit.kind, SnapKind::Vertical);
        let (x, y) = hit.world.coords_mm();
        assert!((x - 0.0).abs() < 1e-9);
        assert!((y - 5.0).abs() < 1e-9);
    }

    #[test]
    fn axis_snap_requires_anchor() {
        let sketch = Sketch::new(xy_basis());
        let cursor = Point2::from_mm(5.0, 0.05);
        assert!(detect(cursor, None, &sketch, tol()).is_none());
    }

    #[test]
    fn axis_snap_skipped_when_cursor_equals_anchor() {
        let sketch = Sketch::new(xy_basis());
        let pos = Point2::from_mm(3.0, 4.0);
        assert!(detect(pos, Some(anchor_at(pos)), &sketch, tol()).is_none());
    }

    #[test]
    fn tangent_snap_to_circle_picks_real_tangent_point() {
        let sketch = Sketch::new(xy_basis());
        let (sketch, c) = add_point(sketch, 5.0, 0.0);
        let (sketch, circle) = add_circle(sketch, c, 3.0);
        let cursor = Point2::from_mm(3.2, 2.4);
        let Some(hit) = detect(
            cursor,
            Some(anchor_at(Point2::from_mm(0.0, 0.0))),
            &sketch,
            tol(),
        ) else {
            panic!("expected tangent snap");
        };
        assert_eq!(hit.kind, SnapKind::Tangent(circle));
        let (x, y) = hit.world.coords_mm();
        assert!((x - 3.2).abs() < 1e-9, "x={x}");
        assert!((y - 2.4).abs() < 1e-9, "y={y}");
    }

    #[test]
    fn tangent_snap_skips_when_anchor_inside_circle() {
        let sketch = Sketch::new(xy_basis());
        let (sketch, c) = add_point(sketch, 0.0, 0.0);
        let (sketch, _) = add_circle(sketch, c, 5.0);
        let cursor = Point2::from_mm(3.5, 1.5);
        assert!(
            detect(
                cursor,
                Some(anchor_at(Point2::from_mm(1.0, 0.0))),
                &sketch,
                tol(),
            )
            .is_none(),
        );
    }

    #[test]
    fn tangent_snap_skips_when_anchor_on_circle() {
        let sketch = Sketch::new(xy_basis());
        let (sketch, c) = add_point(sketch, 0.0, 0.0);
        let (sketch, _) = add_circle(sketch, c, 5.0);
        let cursor = Point2::from_mm(3.5, 1.5);
        assert!(
            detect(
                cursor,
                Some(anchor_at(Point2::from_mm(5.0, 0.0))),
                &sketch,
                tol(),
            )
            .is_none(),
        );
    }

    #[test]
    fn tangent_snap_to_arc_picks_tangent_in_sweep() {
        let sketch = Sketch::new(xy_basis());
        let (sketch, center) = add_point(sketch, 5.0, 0.0);
        let (sketch, start) = add_point(sketch, 5.0, 3.0);
        let (sketch, end) = add_point(sketch, 5.0, -3.0);
        let (sketch, arc) = add_arc(sketch, center, start, end);
        let cursor = Point2::from_mm(3.2, 2.4);
        let Some(hit) = detect(
            cursor,
            Some(anchor_at(Point2::from_mm(0.0, 0.0))),
            &sketch,
            tol(),
        ) else {
            panic!("expected tangent snap on arc");
        };
        assert_eq!(hit.kind, SnapKind::Tangent(arc));
        let (x, y) = hit.world.coords_mm();
        assert!((x - 3.2).abs() < 1e-9, "x={x}");
        assert!((y - 2.4).abs() < 1e-9, "y={y}");
    }

    #[test]
    fn tangent_snap_to_arc_skips_when_no_tangent_in_sweep() {
        let sketch = Sketch::new(xy_basis());
        let (sketch, center) = add_point(sketch, 5.0, 0.0);
        let (sketch, start) = add_point(sketch, 8.0, 0.0);
        let (sketch, end) = add_point(sketch, 5.0, 3.0);
        let (sketch, _) = add_arc(sketch, center, start, end);
        let cursor = Point2::from_mm(3.2, 2.4);
        assert!(
            detect(
                cursor,
                Some(anchor_at(Point2::from_mm(0.0, 0.0))),
                &sketch,
                tol()
            )
            .is_none(),
        );
    }

    #[test]
    fn midpoint_snap_skips_line_anchored_at_endpoint() {
        let sketch = Sketch::new(xy_basis());
        let (sketch, a) = add_point(sketch, 0.0, 0.0);
        let (sketch, b) = add_point(sketch, 4.0, 4.0);
        let (sketch, _) = add_line(sketch, a, b);
        let cursor = Point2::from_mm(2.5, 2.5);
        let anchor = Anchor {
            pos: Point2::from_mm(0.0, 0.0),
            id: Some(a),
        };
        assert!(detect(cursor, Some(anchor), &sketch, tol()).is_none());
    }

    #[test]
    fn endpoint_filter_uses_anchor_id_not_position() {
        let sketch = Sketch::new(xy_basis());
        let (sketch, anchor_pt) = add_point(sketch, 0.0, 0.0);
        let (sketch, twin) = add_point(sketch, 0.0, 0.0);
        let cursor = Point2::from_mm(0.01, 0.01);
        let anchor = Anchor {
            pos: Point2::from_mm(0.0, 0.0),
            id: Some(anchor_pt),
        };
        let Some(hit) = detect(cursor, Some(anchor), &sketch, tol()) else {
            panic!("twin should still snap");
        };
        assert_eq!(hit.kind, SnapKind::Endpoint(twin));
    }

    #[test]
    fn endpoint_skips_active_anchor_point() {
        let sketch = Sketch::new(xy_basis());
        let (sketch, p) = add_point(sketch, 0.0, 0.0);
        let cursor = Point2::from_mm(0.01, 0.01);
        let anchor = Anchor {
            pos: Point2::from_mm(0.0, 0.0),
            id: Some(p),
        };
        let hit = detect(cursor, Some(anchor), &sketch, tol());
        assert!(
            !matches!(hit.map(|h| h.kind), Some(SnapKind::Endpoint(_))),
            "endpoint snap must skip the active anchor by id",
        );
    }

    #[test]
    fn position_anchor_does_not_suppress_endpoint() {
        let sketch = Sketch::new(xy_basis());
        let (sketch, p) = add_point(sketch, 0.0, 0.0);
        let cursor = Point2::from_mm(0.01, 0.01);
        let anchor = anchor_at(Point2::from_mm(0.0, 0.0));
        let Some(hit) = detect(cursor, Some(anchor), &sketch, tol()) else {
            panic!("position anchor must not filter coincident endpoint");
        };
        assert_eq!(hit.kind, SnapKind::Endpoint(p));
    }

    #[test]
    fn zero_tolerance_returns_none() {
        let sketch = Sketch::new(xy_basis());
        let (sketch, _) = add_point(sketch, 0.0, 0.0);
        assert!(
            detect(
                Point2::from_mm(0.0, 0.0),
                None,
                &sketch,
                Length::new::<millimeter>(0.0),
            )
            .is_none()
        );
    }
}
