use core::num::NonZeroU32;

use bone_document::{
    ExtrudeDirection, ExtrudeEndCondition, ExtrudeFeature, ExtrudeSense, MergeResult, Sketch,
    SketchDimension, SketchEntity,
};
use bone_types::{
    ExtrudeId, Length, Point2, Point3, PositiveLength, SketchEntityId, SketchId, SketchPlaneBasis,
    Tolerance, UnitVec3,
};
use bone_ui::hotkey::ActionId;
use uom::si::length::millimeter;

#[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)]
pub enum FeatureTool {
    ExtrudedBossBase,
    ExtrudedCut,
}

impl FeatureTool {
    pub const ALL: &'static [Self] = &[Self::ExtrudedBossBase, Self::ExtrudedCut];
}

#[derive(Copy, Clone, Debug, PartialEq)]
pub enum ExtrudeArming {
    Profile {
        feature: ExtrudeFeature,
        target: Option<ExtrudeId>,
    },
    AwaitingSketch,
}

impl ExtrudeArming {
    #[must_use]
    pub fn profile(sketch: SketchId) -> Self {
        Self::Profile {
            feature: default_extrude_feature(sketch),
            target: None,
        }
    }

    #[must_use]
    pub fn edit(target: ExtrudeId, feature: ExtrudeFeature) -> Self {
        Self::Profile {
            feature,
            target: Some(target),
        }
    }
}

const DEFAULT_EXTRUDE_DEPTH_MM: f64 = 10.0;

#[must_use]
pub fn default_extrude_depth() -> PositiveLength {
    let Ok(depth) = PositiveLength::new(Length::new::<millimeter>(DEFAULT_EXTRUDE_DEPTH_MM)) else {
        unreachable!("constant default extrude depth is positive and finite");
    };
    depth
}

#[must_use]
pub fn default_extrude_feature(sketch: SketchId) -> ExtrudeFeature {
    ExtrudeFeature {
        sketch,
        direction: ExtrudeDirection::Normal {
            sense: ExtrudeSense::Forward,
        },
        end_condition: ExtrudeEndCondition::Blind {
            depth: default_extrude_depth(),
        },
        draft: None,
        thin_wall: None,
        merge_result: MergeResult::Merge,
    }
}

#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub enum EndConditionKind {
    Blind,
    MidPlane,
}

impl EndConditionKind {
    pub const SUPPORTED: &'static [Self] = &[Self::Blind, Self::MidPlane];

    #[must_use]
    pub fn of(condition: &ExtrudeEndCondition) -> Option<Self> {
        match condition {
            ExtrudeEndCondition::Blind { .. } => Some(Self::Blind),
            ExtrudeEndCondition::MidPlane { .. } => Some(Self::MidPlane),
            _ => None,
        }
    }

    #[must_use]
    pub fn with_depth(self, depth: PositiveLength) -> ExtrudeEndCondition {
        match self {
            Self::Blind => ExtrudeEndCondition::Blind { depth },
            Self::MidPlane => ExtrudeEndCondition::MidPlane { depth },
        }
    }
}

#[must_use]
pub fn end_condition_depth(condition: &ExtrudeEndCondition) -> Option<PositiveLength> {
    match condition {
        ExtrudeEndCondition::Blind { depth } | ExtrudeEndCondition::MidPlane { depth } => {
            Some(*depth)
        }
        _ => None,
    }
}

#[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 ESCAPE_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>,
    },
    Extrude(ExtrudeArming),
}

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::Extrude(_) => self,
        }
    }

    #[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 { .. })
    }

    #[must_use]
    pub const fn is_extrude(&self) -> bool {
        matches!(self, Self::Extrude(_))
    }

    #[must_use]
    pub fn sketch_id(&self) -> Option<SketchId> {
        match self {
            Self::Sketch { sketch_id, .. } => Some(*sketch_id),
            Self::Idle | Self::Extrude(_) => None,
        }
    }

    #[must_use]
    pub fn active_tool(&self) -> Option<SketchTool> {
        match self {
            Self::Sketch { session, .. } => session.tool,
            Self::Idle | Self::Extrude(_) => None,
        }
    }

    #[must_use]
    pub fn dim_flow(&self) -> Option<DimensionFlow> {
        match self {
            Self::Sketch { session, .. } => session.dim_flow,
            Self::Idle | Self::Extrude(_) => None,
        }
    }
}

#[cfg(test)]
mod tests {
    use super::{
        ClickAnchor, DEFAULT_EXTRUDE_DEPTH_MM, DimensionFlow, DragPins, DragSession,
        EndConditionKind, ExtrudeArming, ExtrudeDirection, ExtrudeEndCondition, ExtrudeSense,
        FeatureTool, MergeResult, Mode, Pending, Plane, SketchSession, SketchTool,
        default_extrude_feature, end_condition_depth,
    };
    use bone_types::{Length, Point2, PositiveLength, SketchEntityId, SketchId};
    use uom::si::length::millimeter;

    #[test]
    fn sketch_mode_projections_read_session() {
        let id = SketchId::default();
        let mode = Mode::enter_sketch(id).arm_tool(SketchTool::Line);
        assert_eq!(mode.sketch_id(), Some(id));
        assert_eq!(mode.active_tool(), Some(SketchTool::Line));
        assert!(!mode.is_extrude());
    }

    #[test]
    fn off_sketch_projections_are_empty() {
        [Mode::Idle, Mode::Extrude(ExtrudeArming::AwaitingSketch)]
            .into_iter()
            .for_each(|mode| {
                assert_eq!(mode.sketch_id(), None);
                assert_eq!(mode.active_tool(), None);
                assert_eq!(mode.dim_flow(), None);
            });
    }

    #[test]
    fn session_transforms_leave_extrude_untouched() {
        let mode = Mode::Extrude(ExtrudeArming::AwaitingSketch);
        assert_eq!(mode.clone().arm_tool(SketchTool::Line), mode);
        assert_eq!(mode.clone().disarm_tool(), mode);
        assert_eq!(mode.clone().clear_pending(), mode);
    }

    #[test]
    fn feature_tool_all_lists_both_extrude_buttons() {
        assert_eq!(
            FeatureTool::ALL,
            &[FeatureTool::ExtrudedBossBase, FeatureTool::ExtrudedCut],
        );
    }

    #[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]);
            });
        });
    }

    #[test]
    fn default_extrude_feature_is_blind_forward_merge() {
        let feature = default_extrude_feature(SketchId::default());
        assert!(matches!(
            feature.direction,
            ExtrudeDirection::Normal {
                sense: ExtrudeSense::Forward
            }
        ));
        assert!(matches!(
            feature.end_condition,
            ExtrudeEndCondition::Blind { .. }
        ));
        assert_eq!(feature.merge_result, MergeResult::Merge);
        assert!(feature.draft.is_none());
        assert!(feature.thin_wall.is_none());
        let Some(depth) = end_condition_depth(&feature.end_condition) else {
            panic!("blind carries depth");
        };
        assert!((depth.get().get::<millimeter>() - DEFAULT_EXTRUDE_DEPTH_MM).abs() < 1e-9);
    }

    #[test]
    fn supported_end_conditions_are_blind_and_midplane() {
        assert_eq!(
            EndConditionKind::SUPPORTED,
            &[EndConditionKind::Blind, EndConditionKind::MidPlane],
        );
    }

    #[test]
    fn end_condition_kind_round_trips_through_depth() {
        let Ok(depth) = PositiveLength::new(Length::new::<millimeter>(7.0)) else {
            unreachable!("7 mm is positive and finite");
        };
        EndConditionKind::SUPPORTED.iter().for_each(|kind| {
            let condition = kind.with_depth(depth);
            assert_eq!(EndConditionKind::of(&condition), Some(*kind));
            assert_eq!(end_condition_depth(&condition), Some(depth));
        });
    }

    #[test]
    fn extrude_arming_profile_carries_the_sketch() {
        let id = SketchId::default();
        let ExtrudeArming::Profile { feature, target } = ExtrudeArming::profile(id) else {
            panic!("profile arming holds a feature");
        };
        assert_eq!(feature.sketch, id);
        assert_eq!(target, None);
    }
}
