use core::num::NonZeroU32;

use bone_document::{Sketch, SketchDimension, SketchEntity};
use bone_types::{Point2, Point3, SketchEntityId, SketchId, SketchPlaneBasis, Tolerance, UnitVec3};
use bone_ui::hotkey::ActionId;

#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub enum SketchTool {
    Point,
    Line,
    CenterpointArc,
    TangentArc,
    ThreePointArc,
    Circle,
    PerimeterCircle,
    CornerRectangle,
    CenterRectangle,
    ThreePointCornerRectangle,
    ThreePointCenterRectangle,
    Parallelogram,
}

impl SketchTool {
    pub const ENTITIES: &'static [Self] = &[
        Self::Point,
        Self::Line,
        Self::CenterpointArc,
        Self::TangentArc,
        Self::ThreePointArc,
        Self::Circle,
        Self::PerimeterCircle,
        Self::CornerRectangle,
        Self::CenterRectangle,
        Self::ThreePointCornerRectangle,
        Self::ThreePointCenterRectangle,
        Self::Parallelogram,
    ];
}

#[derive(Copy, Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
pub enum Plane {
    Xy,
    Yz,
    Zx,
}

impl Plane {
    #[must_use]
    pub fn basis(self) -> SketchPlaneBasis {
        let (x, y) = match self {
            Self::Xy => (UnitVec3::x_axis(), UnitVec3::y_axis()),
            Self::Yz => (UnitVec3::y_axis(), UnitVec3::z_axis()),
            Self::Zx => (UnitVec3::z_axis(), UnitVec3::x_axis()),
        };
        let Ok(basis) = SketchPlaneBasis::new(Point3::origin(), x, y, Tolerance::new(1e-9)) else {
            unreachable!("canonical principal-plane axes are orthonormal");
        };
        basis
    }
}

const fn action_id(value: u32) -> ActionId {
    let Some(nz) = NonZeroU32::new(value) else {
        panic!("ActionId must be non-zero");
    };
    ActionId::new(nz)
}

pub const EXIT_SKETCH_ACTION: ActionId = action_id(1);
pub const UNDO_ACTION: ActionId = action_id(2);
pub const REDO_ACTION: ActionId = action_id(3);

#[derive(Copy, Clone, Debug, PartialEq)]
pub enum ClickAnchor {
    Position(Point2),
    Endpoint(SketchEntityId),
    MidpointOf {
        line: SketchEntityId,
        position: Point2,
    },
}

#[derive(Copy, Clone, Debug, PartialEq)]
pub enum Pending {
    First(ClickAnchor),
    Second(ClickAnchor, ClickAnchor),
}

#[derive(Copy, Clone, Debug, PartialEq)]
pub struct PendingDimension {
    pub proto: SketchDimension,
    pub anchor: Point2,
}

#[derive(Copy, Clone, Debug, Default, PartialEq)]
pub struct DragPins([Option<(SketchEntityId, Point2)>; 3]);

impl DragPins {
    #[cfg(test)]
    #[must_use]
    pub const fn from_array(arr: [Option<(SketchEntityId, Point2)>; 3]) -> Self {
        Self(arr)
    }

    #[must_use]
    pub fn from_sketch_entity(sketch: &Sketch, id: SketchEntityId) -> Option<Self> {
        let entity = sketch.entities().get(id).copied()?;
        let pin = |pid: SketchEntityId| -> Option<(SketchEntityId, Point2)> {
            match sketch.entities().get(pid)? {
                SketchEntity::Point(p) => Some((pid, p.at())),
                _ => None,
            }
        };
        match entity {
            SketchEntity::Point(p) => Some(Self([Some((id, p.at())), None, None])),
            SketchEntity::Line(l) => Some(Self([Some(pin(l.a())?), Some(pin(l.b())?), None])),
            SketchEntity::Arc(a) => Some(Self([
                Some(pin(a.center())?),
                Some(pin(a.start())?),
                Some(pin(a.end())?),
            ])),
            SketchEntity::Circle(c) => Some(Self([Some(pin(c.center())?), None, None])),
        }
    }

    pub fn iter(&self) -> impl Iterator<Item = (SketchEntityId, Point2)> + '_ {
        self.0.iter().filter_map(|p| *p)
    }

    #[must_use]
    pub fn to_targets(self, press: Point2, cursor: Point2) -> Vec<(SketchEntityId, Point2)> {
        let (px, py) = press.coords_mm();
        let (cx, cy) = cursor.coords_mm();
        let (dx, dy) = (cx - px, cy - py);
        self.iter()
            .map(|(id, original)| {
                let (ox, oy) = original.coords_mm();
                (id, Point2::from_mm(ox + dx, oy + dy))
            })
            .collect()
    }
}

#[derive(Copy, Clone, Debug, PartialEq)]
pub struct DragSession {
    pub entity: SketchEntityId,
    pub press: Point2,
    pub pins: DragPins,
}

#[derive(Copy, Clone, Debug, PartialEq)]
pub enum DimensionFlow {
    Editing(PendingDimension),
    Conflict(PendingDimension),
}

#[derive(Copy, Clone, Debug, Default, PartialEq)]
pub struct SketchSession {
    pub tool: Option<SketchTool>,
    pub pending: Option<Pending>,
    pub drag: Option<DragSession>,
    pub dim_flow: Option<DimensionFlow>,
}

#[derive(Clone, Debug, Default, PartialEq)]
pub enum Mode {
    #[default]
    Idle,
    Sketch {
        sketch_id: SketchId,
        session: Box<SketchSession>,
    },
}

impl Mode {
    #[must_use]
    pub fn enter_sketch(sketch_id: SketchId) -> Self {
        Self::Sketch {
            sketch_id,
            session: Box::default(),
        }
    }

    #[must_use]
    fn map_session(self, f: impl FnOnce(SketchSession) -> SketchSession) -> Self {
        match self {
            Self::Sketch { sketch_id, session } => Self::Sketch {
                sketch_id,
                session: Box::new(f(*session)),
            },
            Self::Idle => Self::Idle,
        }
    }

    #[must_use]
    pub fn arm_tool(self, kind: SketchTool) -> Self {
        self.map_session(|_| SketchSession {
            tool: Some(kind),
            ..SketchSession::default()
        })
    }

    #[must_use]
    pub fn disarm_tool(self) -> Self {
        self.map_session(|_| SketchSession::default())
    }

    #[must_use]
    pub fn clear_pending(self) -> Self {
        self.map_session(|s| SketchSession { pending: None, ..s })
    }

    #[must_use]
    pub fn start_drag(self, drag: DragSession) -> Self {
        self.map_session(|s| SketchSession {
            drag: Some(drag),
            ..s
        })
    }

    #[must_use]
    pub fn end_drag(self) -> Self {
        self.map_session(|s| SketchSession { drag: None, ..s })
    }

    #[must_use]
    pub fn start_dimension(self, pending: PendingDimension) -> Self {
        self.map_session(|s| SketchSession {
            tool: None,
            pending: None,
            dim_flow: Some(DimensionFlow::Editing(pending)),
            ..s
        })
    }

    #[must_use]
    pub fn cancel_dimension(self) -> Self {
        self.map_session(|s| match s.dim_flow {
            Some(DimensionFlow::Editing(_)) => SketchSession {
                dim_flow: None,
                ..s
            },
            _ => s,
        })
    }

    #[must_use]
    pub fn start_dim_conflict(self, pending: PendingDimension) -> Self {
        self.map_session(|s| SketchSession {
            dim_flow: Some(DimensionFlow::Conflict(pending)),
            ..s
        })
    }

    #[must_use]
    pub fn cancel_dim_conflict(self) -> Self {
        self.map_session(|s| match s.dim_flow {
            Some(DimensionFlow::Conflict(_)) => SketchSession {
                dim_flow: None,
                ..s
            },
            _ => s,
        })
    }

    #[must_use]
    pub const fn is_sketch(&self) -> bool {
        matches!(self, Self::Sketch { .. })
    }
}

#[cfg(test)]
mod tests {
    use super::{
        ClickAnchor, DimensionFlow, DragPins, DragSession, Mode, Pending, Plane, SketchSession,
        SketchTool,
    };
    use bone_types::{Point2, SketchEntityId, SketchId};

    #[test]
    fn arm_tool_in_sketch_records_kind() {
        let mode = Mode::enter_sketch(SketchId::default()).arm_tool(SketchTool::Line);
        let Mode::Sketch { session, .. } = mode else {
            panic!("expected sketch mode");
        };
        assert_eq!(session.tool, Some(SketchTool::Line));
    }

    #[test]
    fn arm_tool_in_idle_is_noop() {
        assert_eq!(Mode::Idle.arm_tool(SketchTool::Line), Mode::Idle);
    }

    #[test]
    fn arm_tool_clears_pending() {
        let session = SketchSession {
            tool: Some(SketchTool::Line),
            pending: Some(Pending::First(ClickAnchor::Position(Point2::from_mm(
                1.0, 2.0,
            )))),
            ..SketchSession::default()
        };
        let mode = Mode::Sketch {
            sketch_id: SketchId::default(),
            session: Box::new(session),
        }
        .arm_tool(SketchTool::Point);
        let Mode::Sketch { session, .. } = mode else {
            panic!("expected sketch mode");
        };
        assert_eq!(session.tool, Some(SketchTool::Point));
        assert_eq!(session.pending, None);
    }

    #[test]
    fn clear_pending_in_sketch_drops_pending_keeps_tool() {
        let session = SketchSession {
            tool: Some(SketchTool::Line),
            pending: Some(Pending::First(ClickAnchor::Position(Point2::from_mm(
                3.0, 4.0,
            )))),
            ..SketchSession::default()
        };
        let mode = Mode::Sketch {
            sketch_id: SketchId::default(),
            session: Box::new(session),
        }
        .clear_pending();
        let Mode::Sketch { session, .. } = mode else {
            panic!("expected sketch mode");
        };
        assert_eq!(session.tool, Some(SketchTool::Line));
        assert_eq!(session.pending, None);
    }

    #[test]
    fn clear_pending_in_idle_is_noop() {
        assert_eq!(Mode::Idle.clear_pending(), Mode::Idle);
    }

    fn sample_drag_session() -> DragSession {
        let entity = SketchEntityId::default();
        DragSession {
            entity,
            press: Point2::origin(),
            pins: DragPins::from_array([Some((entity, Point2::origin())), None, None]),
        }
    }

    #[test]
    fn start_drag_records_entity_keeps_tool_and_pending() {
        let drag = sample_drag_session();
        let mode = Mode::Sketch {
            sketch_id: SketchId::default(),
            session: Box::new(SketchSession {
                tool: Some(SketchTool::Line),
                pending: Some(Pending::First(ClickAnchor::Position(Point2::from_mm(
                    0.0, 0.0,
                )))),
                ..SketchSession::default()
            }),
        }
        .start_drag(drag);
        let Mode::Sketch { session, .. } = mode else {
            panic!("expected sketch mode");
        };
        assert_eq!(session.drag, Some(drag));
        assert_eq!(session.tool, Some(SketchTool::Line));
        assert!(session.pending.is_some());
    }

    #[test]
    fn end_drag_clears_drag_only() {
        let mode = Mode::Sketch {
            sketch_id: SketchId::default(),
            session: Box::new(SketchSession {
                tool: Some(SketchTool::Line),
                drag: Some(sample_drag_session()),
                ..SketchSession::default()
            }),
        }
        .end_drag();
        let Mode::Sketch { session, .. } = mode else {
            panic!("expected sketch mode");
        };
        assert_eq!(session.drag, None);
        assert_eq!(session.tool, Some(SketchTool::Line));
    }

    #[test]
    fn start_drag_in_idle_is_noop() {
        assert_eq!(Mode::Idle.start_drag(sample_drag_session()), Mode::Idle);
    }

    #[test]
    fn default_mode_is_idle() {
        assert_eq!(Mode::default(), Mode::Idle);
    }

    #[test]
    fn entities_table_holds_day_one_set() {
        assert_eq!(SketchTool::ENTITIES.len(), 12, "day-1 entity tools");
    }

    #[test]
    fn fresh_session_has_no_tool() {
        assert_eq!(SketchSession::default().tool, None);
        assert_eq!(SketchSession::default().pending, None);
    }

    #[test]
    fn is_sketch_distinguishes_states() {
        assert!(!Mode::Idle.is_sketch());
        assert!(Mode::enter_sketch(SketchId::default()).is_sketch());
    }

    #[test]
    fn start_dimension_records_pending_and_clears_tool() {
        use bone_document::{DimensionKind, SketchDimension};
        use bone_types::Length;
        use uom::si::length::millimeter;
        let proto = SketchDimension::Linear {
            a: SketchEntityId::default(),
            b: SketchEntityId::default(),
            value: Length::new::<millimeter>(5.0),
            kind: DimensionKind::Driving,
        };
        let pending = super::PendingDimension {
            proto,
            anchor: Point2::origin(),
        };
        let mode = Mode::enter_sketch(SketchId::default())
            .arm_tool(SketchTool::Line)
            .start_dimension(pending);
        let Mode::Sketch { session, .. } = mode else {
            panic!("expected sketch mode");
        };
        assert_eq!(session.tool, None);
        assert_eq!(session.pending, None);
        assert_eq!(session.dim_flow, Some(DimensionFlow::Editing(pending)));
    }

    #[test]
    fn cancel_dimension_clears_editing_only() {
        use bone_document::{DimensionKind, SketchDimension};
        use bone_types::Length;
        use uom::si::length::millimeter;
        let proto = SketchDimension::Linear {
            a: SketchEntityId::default(),
            b: SketchEntityId::default(),
            value: Length::new::<millimeter>(5.0),
            kind: DimensionKind::Driving,
        };
        let pending = super::PendingDimension {
            proto,
            anchor: Point2::origin(),
        };
        let mode = Mode::enter_sketch(SketchId::default())
            .start_dimension(pending)
            .cancel_dimension();
        let Mode::Sketch { session, .. } = mode else {
            panic!("expected sketch mode");
        };
        assert_eq!(session.dim_flow, None);
    }

    #[test]
    fn cancel_dimension_leaves_conflict_intact() {
        use bone_document::{DimensionKind, SketchDimension};
        use bone_types::Length;
        use uom::si::length::millimeter;
        let proto = SketchDimension::Linear {
            a: SketchEntityId::default(),
            b: SketchEntityId::default(),
            value: Length::new::<millimeter>(5.0),
            kind: DimensionKind::Driving,
        };
        let pending = super::PendingDimension {
            proto,
            anchor: Point2::origin(),
        };
        let mode = Mode::enter_sketch(SketchId::default())
            .start_dim_conflict(pending)
            .cancel_dimension();
        let Mode::Sketch { session, .. } = mode else {
            panic!("expected sketch mode");
        };
        assert_eq!(session.dim_flow, Some(DimensionFlow::Conflict(pending)));
    }

    #[test]
    fn start_dimension_after_existing_keeps_drag_replaces_proto() {
        use bone_document::{DimensionKind, SketchDimension};
        use bone_types::Length;
        use uom::si::length::millimeter;
        let radius = SketchDimension::Radius {
            target: SketchEntityId::default(),
            value: Length::new::<millimeter>(3.0),
            kind: DimensionKind::Driving,
        };
        let diameter = SketchDimension::Diameter {
            target: SketchEntityId::default(),
            value: Length::new::<millimeter>(6.0),
            kind: DimensionKind::Driving,
        };
        let initial = super::PendingDimension {
            proto: radius,
            anchor: Point2::origin(),
        };
        let next = super::PendingDimension {
            proto: diameter,
            anchor: Point2::from_mm(1.0, 0.0),
        };
        let mode = Mode::Sketch {
            sketch_id: SketchId::default(),
            session: Box::new(SketchSession {
                drag: Some(sample_drag_session()),
                dim_flow: Some(DimensionFlow::Editing(initial)),
                ..SketchSession::default()
            }),
        }
        .start_dimension(next);
        let Mode::Sketch { session, .. } = mode else {
            panic!("expected sketch mode");
        };
        assert_eq!(session.dim_flow, Some(DimensionFlow::Editing(next)));
        assert!(session.drag.is_some(), "drag preserved across swap");
    }

    #[test]
    fn principal_planes_are_pairwise_distinct() {
        let planes = [Plane::Xy, Plane::Yz, Plane::Zx];
        let bases: Vec<_> = planes.iter().copied().map(Plane::basis).collect();
        bases.iter().enumerate().for_each(|(i, a)| {
            bases.iter().enumerate().skip(i + 1).for_each(|(j, b)| {
                assert_ne!(a, b, "{:?} == {:?}", planes[i], planes[j]);
            });
        });
    }
}
