use core::num::NonZeroU32;

use bone_document::{
    AxisRecipe, CsysAxisRef, CsysRecipe, DatumFeature, ExtrudeEndCondition, ExtrudeFeature,
    ExtrudeOperation, PlaneRecipe, PointRecipe, PrincipalPlane, RevolveAngle, RevolveAxis,
    RevolveFeature, Sketch3, Sketch3Entity, Sketch3Relation, SketchDimension, SweepFeature,
    SweepPath,
};
use bone_types::{
    DatumId, DatumTarget, ExtrudeId, FeatureId, Length, Point2, Point3, PositiveLength, RevolveId,
    SketchEntityId, SketchId, SketchPlaneBasis, SweepId,
};
use bone_ui::hotkey::ActionId;
use uom::si::length::millimeter;

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

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

#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub enum FeatureTool {
    ExtrudedBossBase,
    ExtrudedCut,
    RevolvedBossBase,
    RevolvedCut,
    SweptBossBase,
    SweptCut,
    DatumPlane,
    DatumAxis,
    DatumPoint,
    DatumCoordinateSystem,
    Sketch3D,
}

impl FeatureTool {
    pub const ALL: &'static [Self] = &[
        Self::ExtrudedBossBase,
        Self::ExtrudedCut,
        Self::RevolvedBossBase,
        Self::RevolvedCut,
        Self::SweptBossBase,
        Self::SweptCut,
        Self::DatumPlane,
        Self::DatumAxis,
        Self::DatumPoint,
        Self::DatumCoordinateSystem,
        Self::Sketch3D,
    ];

    pub const EXTRUDE: &'static [Self] = &[Self::ExtrudedBossBase, Self::ExtrudedCut];

    pub const REVOLVE: &'static [Self] = &[Self::RevolvedBossBase, Self::RevolvedCut];

    pub const SWEEP: &'static [Self] = &[Self::SweptBossBase, Self::SweptCut];

    pub const DATUM: &'static [Self] = &[
        Self::DatumPlane,
        Self::DatumAxis,
        Self::DatumPoint,
        Self::DatumCoordinateSystem,
    ];

    pub const SKETCH3: &'static [Self] = &[Self::Sketch3D];

    #[must_use]
    pub const fn is_datum(self) -> bool {
        matches!(
            self,
            Self::DatumPlane | Self::DatumAxis | Self::DatumPoint | Self::DatumCoordinateSystem
        )
    }
}

#[derive(Clone, Debug, PartialEq)]
pub struct DatumArming {
    pub feature: DatumFeature,
    pub target: Option<DatumId>,
}

impl DatumArming {
    #[must_use]
    pub fn new(feature: DatumFeature) -> Self {
        Self {
            feature,
            target: None,
        }
    }
}

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

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

    #[must_use]
    pub fn armed(sketch: SketchId, operation: ExtrudeOperation) -> Self {
        Self::Profile {
            feature: Box::new(extrude_feature(sketch, operation)),
            target: None,
        }
    }

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

    #[must_use]
    pub fn operation(&self) -> ExtrudeOperation {
        match self {
            Self::Profile { feature, .. } => feature.operation,
            Self::AwaitingSketch(operation) => *operation,
        }
    }
}

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 {
    extrude_feature(sketch, ExtrudeOperation::default())
}

#[must_use]
pub fn extrude_feature(sketch: SketchId, operation: ExtrudeOperation) -> ExtrudeFeature {
    ExtrudeFeature {
        operation,
        ..ExtrudeFeature::blind(sketch, default_extrude_depth())
    }
}

#[derive(Clone, Debug, PartialEq)]
pub enum RevolveArming {
    Profile {
        feature: Box<RevolveFeature>,
        target: Option<RevolveId>,
    },
    AwaitingSketch(ExtrudeOperation),
}

impl RevolveArming {
    #[must_use]
    pub fn armed(sketch: SketchId, axis: RevolveAxis, operation: ExtrudeOperation) -> Self {
        Self::Profile {
            feature: Box::new(revolve_feature(sketch, axis, operation)),
            target: None,
        }
    }

    #[must_use]
    pub fn operation(&self) -> ExtrudeOperation {
        match self {
            Self::Profile { feature, .. } => feature.operation,
            Self::AwaitingSketch(operation) => *operation,
        }
    }
}

#[must_use]
pub fn revolve_feature(
    sketch: SketchId,
    axis: RevolveAxis,
    operation: ExtrudeOperation,
) -> RevolveFeature {
    RevolveFeature {
        operation,
        ..RevolveFeature::blind(sketch, axis, RevolveAngle::full())
    }
}

#[derive(Clone, Debug, PartialEq)]
pub enum SweepArming {
    Profile {
        feature: Box<SweepFeature>,
        target: Option<SweepId>,
    },
    AwaitingSketch(ExtrudeOperation),
}

impl SweepArming {
    #[must_use]
    pub fn armed(profile: SketchId, operation: ExtrudeOperation) -> Self {
        Self::Profile {
            feature: Box::new(sweep_feature(profile, operation)),
            target: None,
        }
    }

    #[must_use]
    pub fn operation(&self) -> ExtrudeOperation {
        match self {
            Self::Profile { feature, .. } => feature.operation,
            Self::AwaitingSketch(operation) => *operation,
        }
    }
}

#[must_use]
pub fn sweep_feature(profile: SketchId, operation: ExtrudeOperation) -> SweepFeature {
    SweepFeature {
        operation,
        ..SweepFeature::boss(profile, SweepPath::Sketch(SketchId::default()))
    }
}

const DEFAULT_DATUM_OFFSET_MM: f64 = 10.0;

#[must_use]
pub fn default_datum_plane(reference: FeatureId) -> DatumFeature {
    DatumFeature::plane(PlaneRecipe::Offset {
        from: DatumTarget::Feature(reference),
        distance: Length::new::<millimeter>(DEFAULT_DATUM_OFFSET_MM),
        flip: false,
    })
}

#[must_use]
pub fn default_datum_axis(first_plane: FeatureId, second_plane: FeatureId) -> DatumFeature {
    DatumFeature::axis(AxisRecipe::TwoPlanes {
        a: DatumTarget::Feature(first_plane),
        b: DatumTarget::Feature(second_plane),
    })
}

#[must_use]
pub fn default_datum_point(origin: FeatureId) -> DatumFeature {
    DatumFeature::point(PointRecipe::OnPoint {
        reference: DatumTarget::Feature(origin),
    })
}

#[must_use]
pub fn default_datum_coordinate_system(
    origin: FeatureId,
    x_axis_plane: FeatureId,
    y_axis_plane: FeatureId,
) -> DatumFeature {
    DatumFeature::coordinate_system(CsysRecipe {
        origin: DatumTarget::Feature(origin),
        x_axis: Some(CsysAxisRef {
            target: DatumTarget::Feature(x_axis_plane),
            reverse: false,
        }),
        y_axis: Some(CsysAxisRef {
            target: DatumTarget::Feature(y_axis_plane),
            reverse: false,
        }),
        z_axis: None,
        position: None,
        rotation: None,
    })
}

#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub enum ReferenceKind {
    Face,
    Vertex,
    Body,
}

#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub enum EndConditionKind {
    Blind,
    ThroughAll,
    ThroughAllBoth,
    MidPlane,
    UpToVertex,
    UpToSurface,
    OffsetFromSurface,
    UpToBody,
}

impl EndConditionKind {
    pub const SUPPORTED: &'static [Self] = &[
        Self::Blind,
        Self::ThroughAll,
        Self::ThroughAllBoth,
        Self::MidPlane,
        Self::UpToVertex,
        Self::UpToSurface,
        Self::OffsetFromSurface,
        Self::UpToBody,
    ];

    #[must_use]
    pub fn of(condition: &ExtrudeEndCondition) -> Option<Self> {
        match condition {
            ExtrudeEndCondition::Blind { .. } => Some(Self::Blind),
            ExtrudeEndCondition::ThroughAll => Some(Self::ThroughAll),
            ExtrudeEndCondition::ThroughAllBoth => Some(Self::ThroughAllBoth),
            ExtrudeEndCondition::MidPlane { .. } => Some(Self::MidPlane),
            ExtrudeEndCondition::UpToVertex { .. } => Some(Self::UpToVertex),
            ExtrudeEndCondition::UpToSurface { .. } => Some(Self::UpToSurface),
            ExtrudeEndCondition::OffsetFromSurface { .. } => Some(Self::OffsetFromSurface),
            ExtrudeEndCondition::UpToBody { .. } => Some(Self::UpToBody),
            ExtrudeEndCondition::UpToNext => None,
        }
    }

    #[must_use]
    pub fn needs_depth(self) -> bool {
        matches!(self, Self::Blind | Self::MidPlane)
    }

    #[must_use]
    pub fn needs_reference(self) -> bool {
        matches!(
            self,
            Self::UpToVertex | Self::UpToSurface | Self::OffsetFromSurface | Self::UpToBody
        )
    }

    #[must_use]
    pub fn reference_kind(self) -> Option<ReferenceKind> {
        match self {
            Self::UpToSurface | Self::OffsetFromSurface => Some(ReferenceKind::Face),
            Self::UpToVertex => Some(ReferenceKind::Vertex),
            Self::UpToBody => Some(ReferenceKind::Body),
            Self::Blind | Self::ThroughAll | Self::ThroughAllBoth | Self::MidPlane => None,
        }
    }

    #[must_use]
    pub fn with_depth(self, depth: PositiveLength) -> ExtrudeEndCondition {
        match self {
            Self::MidPlane => ExtrudeEndCondition::MidPlane { depth },
            Self::Blind
            | Self::ThroughAll
            | Self::ThroughAllBoth
            | Self::UpToVertex
            | Self::UpToSurface
            | Self::OffsetFromSurface
            | Self::UpToBody => ExtrudeEndCondition::Blind { 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 const fn next(self) -> Self {
        match self {
            Self::Xy => Self::Yz,
            Self::Yz => Self::Zx,
            Self::Zx => Self::Xy,
        }
    }

    #[must_use]
    pub const fn label(self) -> &'static str {
        match self {
            Self::Xy => "XY",
            Self::Yz => "YZ",
            Self::Zx => "ZX",
        }
    }

    #[must_use]
    pub fn basis(self) -> SketchPlaneBasis {
        PrincipalPlane::from(self).basis()
    }
}

impl From<Plane> for PrincipalPlane {
    fn from(plane: Plane) -> Self {
        match plane {
            Plane::Xy => Self::Xy,
            Plane::Yz => Self::Yz,
            Plane::Zx => Self::Zx,
        }
    }
}

impl From<PrincipalPlane> for Plane {
    fn from(plane: PrincipalPlane) -> Self {
        match plane {
            PrincipalPlane::Xy => Self::Xy,
            PrincipalPlane::Yz => Self::Yz,
            PrincipalPlane::Zx => Self::Zx,
        }
    }
}

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,
    },
    OnEdge {
        entity: 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(Clone, Debug, Default, PartialEq)]
pub struct DragPins(Vec<(SketchEntityId, Point3)>);

impl DragPins {
    #[cfg(test)]
    #[must_use]
    pub fn from_array(arr: [Option<(SketchEntityId, Point3)>; 3]) -> Self {
        Self(arr.into_iter().flatten().collect())
    }

    #[must_use]
    pub fn from_sketch3_entity(sketch: &Sketch3, id: SketchEntityId) -> Option<Self> {
        let entity = sketch.entities().get(id)?;
        let pin = |pid: SketchEntityId| -> Option<(SketchEntityId, Point3)> {
            sketch.point_position(pid).map(|at| (pid, at))
        };
        let pins = match entity {
            Sketch3Entity::Point(p) => vec![(id, p.at())],
            Sketch3Entity::Line(l) => vec![pin(l.a())?, pin(l.b())?],
            Sketch3Entity::Arc(a) => vec![pin(a.center())?, pin(a.start())?, pin(a.end())?],
            Sketch3Entity::Circle(c) => vec![pin(c.center())?],
            Sketch3Entity::Ellipse(e) => vec![pin(e.center())?, pin(e.major())?, pin(e.minor())?],
            Sketch3Entity::Spline(s) => s
                .control()
                .iter()
                .map(|cp| pin(*cp))
                .collect::<Option<Vec<_>>>()?,
        };
        Some(Self(pins))
    }

    pub fn iter(&self) -> impl Iterator<Item = (SketchEntityId, Point3)> + '_ {
        self.0.iter().copied()
    }

    #[must_use]
    pub fn to_targets(
        &self,
        basis: SketchPlaneBasis,
        press: Point2,
        cursor: Point2,
    ) -> Vec<(SketchEntityId, Point3)> {
        let (px, py) = press.coords_mm();
        let (cx, cy) = cursor.coords_mm();
        let (dx, dy) = (cx - px, cy - py);
        let (ax, ay, az) = basis.x_axis().components();
        let (bx, by, bz) = basis.y_axis().components();
        let (wx, wy, wz) = (
            dx.mul_add(ax, dy * bx),
            dx.mul_add(ay, dy * by),
            dx.mul_add(az, dy * bz),
        );
        self.iter()
            .map(|(id, original)| {
                let (ox, oy, oz) = original.coords_mm();
                (id, Point3::from_mm(ox + wx, oy + wy, oz + wz))
            })
            .collect()
    }
}

#[derive(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(Sketch3Relation),
}

#[derive(Copy, Clone, Debug, PartialEq)]
pub struct SplineDraftPoint {
    pub id: SketchEntityId,
    pub fresh: bool,
}

impl SplineDraftPoint {
    #[must_use]
    pub const fn fresh(id: SketchEntityId) -> Self {
        Self { id, fresh: true }
    }

    #[must_use]
    pub const fn reused(id: SketchEntityId) -> Self {
        Self { id, fresh: false }
    }
}

#[derive(Clone, Debug, Default, PartialEq)]
pub struct SketchSession {
    pub tool: Option<SketchTool>,
    pub pending: Option<Pending>,
    pub drag: Option<DragSession>,
    pub dim_flow: Option<DimensionFlow>,
    pub spatial: Option<Spatial>,
    pub spline_draft: Vec<SplineDraftPoint>,
    pub aid_anchor: Option<Point3>,
}

#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub enum SketchPlaneSource {
    Principal(Plane),
    Datum(FeatureId),
}

#[derive(Copy, Clone, Debug, PartialEq)]
pub struct Spatial {
    pub basis: SketchPlaneBasis,
    pub source: SketchPlaneSource,
}

impl Spatial {
    #[must_use]
    pub fn principal(plane: Plane) -> Self {
        Self {
            basis: plane.basis(),
            source: SketchPlaneSource::Principal(plane),
        }
    }

    #[must_use]
    pub fn datum(feature: FeatureId, basis: SketchPlaneBasis) -> Self {
        Self {
            basis,
            source: SketchPlaneSource::Datum(feature),
        }
    }
}

#[derive(Clone, Debug, Default, PartialEq)]
pub enum Mode {
    #[default]
    Idle,
    Sketch {
        sketch_id: SketchId,
        session: Box<SketchSession>,
    },
    Extrude(ExtrudeArming),
    Revolve(RevolveArming),
    Sweep(SweepArming),
    Datum(DatumArming),
}

#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub enum ModeExit {
    None,
    Toggle,
    Dialog,
}

#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub struct ModeLifecycle {
    pub button: Option<FeatureTool>,
    pub exit: ModeExit,
}

#[must_use]
pub fn datum_button(feature: &DatumFeature) -> FeatureTool {
    match feature {
        DatumFeature::Plane(_) => FeatureTool::DatumPlane,
        DatumFeature::Axis(_) => FeatureTool::DatumAxis,
        DatumFeature::Point(_) => FeatureTool::DatumPoint,
        DatumFeature::CoordinateSystem(_) => FeatureTool::DatumCoordinateSystem,
    }
}

impl Mode {
    #[must_use]
    pub fn lifecycle(&self) -> ModeLifecycle {
        match self {
            Self::Idle => ModeLifecycle {
                button: None,
                exit: ModeExit::None,
            },
            Self::Sketch { session, .. } => ModeLifecycle {
                button: session.spatial.map(|_| FeatureTool::Sketch3D),
                exit: ModeExit::Toggle,
            },
            Self::Extrude(arming) => ModeLifecycle {
                button: Some(if arming.operation().is_cut() {
                    FeatureTool::ExtrudedCut
                } else {
                    FeatureTool::ExtrudedBossBase
                }),
                exit: ModeExit::Dialog,
            },
            Self::Revolve(arming) => ModeLifecycle {
                button: Some(if arming.operation().is_cut() {
                    FeatureTool::RevolvedCut
                } else {
                    FeatureTool::RevolvedBossBase
                }),
                exit: ModeExit::Dialog,
            },
            Self::Sweep(arming) => ModeLifecycle {
                button: Some(if arming.operation().is_cut() {
                    FeatureTool::SweptCut
                } else {
                    FeatureTool::SweptBossBase
                }),
                exit: ModeExit::Dialog,
            },
            Self::Datum(arming) => ModeLifecycle {
                button: Some(datum_button(&arming.feature)),
                exit: ModeExit::Dialog,
            },
        }
    }

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

    #[must_use]
    pub fn enter_sketch3(sketch_id: SketchId, spatial: Spatial) -> Self {
        Self::Sketch {
            sketch_id,
            session: Box::new(SketchSession {
                spatial: Some(spatial),
                ..SketchSession::default()
            }),
        }
    }

    #[must_use]
    pub fn cycle_sketch3_plane(self) -> Self {
        self.map_session_spatial(|s, sp| match sp.source {
            SketchPlaneSource::Principal(plane) => SketchSession {
                spatial: Some(Spatial::principal(plane.next())),
                ..s
            },
            SketchPlaneSource::Datum(_) => SketchSession {
                spatial: Some(sp),
                ..s
            },
        })
    }

    #[must_use]
    pub fn is_sketch3(&self) -> bool {
        matches!(self, Self::Sketch { session, .. } if session.spatial.is_some())
    }

    #[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::Revolve(_) | Self::Sweep(_) | Self::Datum(_) => {
                self
            }
        }
    }

    #[must_use]
    fn map_session_spatial(self, f: impl FnOnce(SketchSession, Spatial) -> SketchSession) -> Self {
        self.map_session(|s| match s.spatial {
            Some(spatial) => f(s, spatial),
            None => s,
        })
    }

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

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

    #[must_use]
    pub fn clear_pending(self) -> Self {
        self.map_session(|mut s| {
            s.pending = None;
            s.spline_draft.clear();
            s.aid_anchor = None;
            s
        })
    }

    #[must_use]
    pub fn with_aid_anchor(self, anchor: Point3) -> Self {
        self.map_session(|mut s| {
            s.aid_anchor = Some(anchor);
            s
        })
    }

    #[must_use]
    pub fn spline_draft(&self) -> &[SplineDraftPoint] {
        match self {
            Self::Sketch { session, .. } => &session.spline_draft,
            Self::Idle | Self::Extrude(_) | Self::Revolve(_) | Self::Sweep(_) | Self::Datum(_) => {
                &[]
            }
        }
    }

    #[must_use]
    pub fn spline_draft_fresh(&self) -> Vec<SketchEntityId> {
        self.spline_draft()
            .iter()
            .filter(|point| point.fresh)
            .map(|point| point.id)
            .collect()
    }

    #[must_use]
    pub fn with_spline_point(self, point: SplineDraftPoint) -> Self {
        self.map_session(|mut s| {
            s.spline_draft.push(point);
            s
        })
    }

    #[must_use]
    pub fn clear_spline_draft(self) -> Self {
        self.map_session(|mut s| {
            s.spline_draft.clear();
            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, relation: Sketch3Relation) -> Self {
        self.map_session(|s| SketchSession {
            dim_flow: Some(DimensionFlow::Conflict(relation)),
            ..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 const fn is_revolve(&self) -> bool {
        matches!(self, Self::Revolve(_))
    }

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

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

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

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

    #[must_use]
    pub fn sketch3_tool(&self) -> Option<SketchTool> {
        match self {
            Self::Sketch { session, .. } if session.spatial.is_some() => session.tool,
            _ => None,
        }
    }

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

    #[must_use]
    pub fn sketch3_id(&self) -> Option<SketchId> {
        match self {
            Self::Sketch { sketch_id, session } if session.spatial.is_some() => Some(*sketch_id),
            _ => None,
        }
    }

    #[must_use]
    pub fn sketch3_source(&self) -> Option<SketchPlaneSource> {
        match self {
            Self::Sketch { session, .. } => session.spatial.map(|sp| sp.source),
            _ => None,
        }
    }

    #[must_use]
    pub fn sketch3_basis(&self) -> Option<SketchPlaneBasis> {
        match self {
            Self::Sketch { session, .. } => session.spatial.map(|sp| sp.basis),
            _ => None,
        }
    }

    #[must_use]
    pub fn sketch3_plane(&self) -> Option<Plane> {
        match self.sketch3_source()? {
            SketchPlaneSource::Principal(plane) => Some(plane),
            SketchPlaneSource::Datum(_) => None,
        }
    }
}

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

    #[test]
    fn lifecycle_pins_each_mode_button_and_exit() {
        let sketch = SketchId::default();
        let sketch3 = SketchId::default();
        assert_eq!(
            Mode::Idle.lifecycle(),
            ModeLifecycle {
                button: None,
                exit: ModeExit::None,
            },
        );
        assert_eq!(
            Mode::enter_sketch(sketch).lifecycle(),
            ModeLifecycle {
                button: None,
                exit: ModeExit::Toggle,
            },
        );
        assert_eq!(
            Mode::enter_sketch3(sketch3, Spatial::principal(Plane::Xy)).lifecycle(),
            ModeLifecycle {
                button: Some(FeatureTool::Sketch3D),
                exit: ModeExit::Toggle,
            },
        );
        let boss = Mode::Extrude(ExtrudeArming::profile(sketch)).lifecycle();
        assert_eq!(boss.button, Some(FeatureTool::ExtrudedBossBase));
        assert_eq!(boss.exit, ModeExit::Dialog);
        assert_eq!(
            Mode::Extrude(ExtrudeArming::armed(sketch, ExtrudeOperation::Cut))
                .lifecycle()
                .button,
            Some(FeatureTool::ExtrudedCut),
        );
    }

    #[test]
    fn a_toggle_mode_button_round_trips_to_its_owner() {
        let mode = Mode::enter_sketch3(SketchId::default(), Spatial::principal(Plane::Xy));
        let lifecycle = mode.lifecycle();
        assert_eq!(lifecycle.exit, ModeExit::Toggle);
        assert_eq!(lifecycle.button, Some(FeatureTool::Sketch3D));
    }

    #[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(ExtrudeOperation::default())),
        ]
        .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(ExtrudeOperation::default()));
        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_extrude_then_datum_buttons() {
        assert_eq!(
            FeatureTool::ALL,
            &[
                FeatureTool::ExtrudedBossBase,
                FeatureTool::ExtrudedCut,
                FeatureTool::RevolvedBossBase,
                FeatureTool::RevolvedCut,
                FeatureTool::SweptBossBase,
                FeatureTool::SweptCut,
                FeatureTool::DatumPlane,
                FeatureTool::DatumAxis,
                FeatureTool::DatumPoint,
                FeatureTool::DatumCoordinateSystem,
                FeatureTool::Sketch3D,
            ],
        );
        assert_eq!(
            FeatureTool::EXTRUDE,
            &[FeatureTool::ExtrudedBossBase, FeatureTool::ExtrudedCut],
        );
        assert_eq!(
            FeatureTool::REVOLVE,
            &[FeatureTool::RevolvedBossBase, FeatureTool::RevolvedCut],
        );
        assert_eq!(
            FeatureTool::SWEEP,
            &[FeatureTool::SweptBossBase, FeatureTool::SweptCut],
        );
        assert!(FeatureTool::DATUM.iter().all(|tool| tool.is_datum()));
        assert!(
            FeatureTool::EXTRUDE.iter().all(|tool| !tool.is_datum()),
            "the extrude group carries no datum tools",
        );
    }

    #[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, Point3::origin())), None, None]),
        }
    }

    #[test]
    fn spline_draft_accumulates_then_clears() {
        let id = SketchEntityId::default();
        let mode = Mode::enter_sketch3(SketchId::default(), Spatial::principal(Plane::Xy))
            .with_spline_point(SplineDraftPoint::fresh(id))
            .with_spline_point(SplineDraftPoint::fresh(id))
            .with_spline_point(SplineDraftPoint::fresh(id));
        assert_eq!(
            mode.spline_draft().len(),
            3,
            "every click adds a control point"
        );
        assert!(
            mode.clear_spline_draft().spline_draft().is_empty(),
            "finalize or cancel empties the draft",
        );
    }

    #[test]
    fn escape_path_clears_spline_draft_first() {
        let id = SketchEntityId::default();
        let mode = Mode::enter_sketch3(SketchId::default(), Spatial::principal(Plane::Xy))
            .arm_tool(SketchTool::Spline)
            .with_spline_point(SplineDraftPoint::fresh(id));
        let cleared = mode.clear_pending();
        assert!(
            cleared.spline_draft().is_empty(),
            "clearing the pending chain also drops the spline draft",
        );
    }

    #[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.clone());
        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(),
            15,
            "twelve day-1 entity tools plus the centerline, the spline, and the ellipse",
        );
    }

    #[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::Sketch3Relation;
        use bone_types::Length;
        use uom::si::length::millimeter;
        let relation = Sketch3Relation::Distance {
            a: SketchEntityId::default(),
            b: SketchEntityId::default(),
            value: Length::new::<millimeter>(5.0),
            reference: false,
        };
        let mode = Mode::enter_sketch(SketchId::default())
            .start_dim_conflict(relation)
            .cancel_dimension();
        let Mode::Sketch { session, .. } = mode else {
            panic!("expected sketch mode");
        };
        assert_eq!(session.dim_flow, Some(DimensionFlow::Conflict(relation)));
    }

    #[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 next_cycles_the_three_principal_planes() {
        assert_eq!(Plane::Xy.next(), Plane::Yz);
        assert_eq!(Plane::Yz.next(), Plane::Zx);
        assert_eq!(Plane::Zx.next(), Plane::Xy);
    }

    #[test]
    fn cycle_sketch3_plane_advances_only_in_sketch3() {
        let sketch3 = Mode::enter_sketch3(SketchId::default(), Spatial::principal(Plane::Xy));
        let Mode::Sketch { session, .. } = sketch3.cycle_sketch3_plane() else {
            panic!("cycling a 3D sketch stays in sketch mode");
        };
        let Some(spatial) = session.spatial else {
            panic!("cycling a 3D sketch stays spatial");
        };
        assert_eq!(spatial.source, SketchPlaneSource::Principal(Plane::Yz));
        assert_eq!(Mode::Idle.cycle_sketch3_plane(), Mode::Idle);
    }

    #[test]
    fn every_entity_tool_arms_in_the_3d_sketch() {
        SketchTool::ENTITIES.iter().copied().for_each(|tool| {
            let armed = Mode::enter_sketch3(SketchId::default(), Spatial::principal(Plane::Xy))
                .arm_tool(tool);
            assert_eq!(armed.sketch3_tool(), Some(tool));
        });
    }

    #[test]
    fn sketch3_tool_reads_the_armed_3d_tool_only_in_sketch3() {
        let armed = Mode::enter_sketch3(SketchId::default(), Spatial::principal(Plane::Xy))
            .arm_tool(SketchTool::Line);
        assert_eq!(armed.sketch3_tool(), Some(SketchTool::Line));
        assert_eq!(armed.active_tool(), None);
        assert_eq!(Mode::Idle.sketch3_tool(), None);
    }

    #[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.operation,
            ExtrudeOperation::Boss(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_list_eight_in_canonical_order() {
        assert_eq!(
            EndConditionKind::SUPPORTED,
            &[
                EndConditionKind::Blind,
                EndConditionKind::ThroughAll,
                EndConditionKind::ThroughAllBoth,
                EndConditionKind::MidPlane,
                EndConditionKind::UpToVertex,
                EndConditionKind::UpToSurface,
                EndConditionKind::OffsetFromSurface,
                EndConditionKind::UpToBody,
            ],
        );
    }

    #[test]
    fn up_to_next_maps_to_no_supported_kind() {
        assert_eq!(EndConditionKind::of(&ExtrudeEndCondition::UpToNext), None);
    }

    #[test]
    fn end_condition_kind_of_round_trips_every_supported_variant() {
        use bone_types::{FaceLabel, FaceRole, FeatureId, ImportOrdinal, VertexLabel, VertexRole};
        let Ok(depth) = PositiveLength::new(Length::new::<millimeter>(7.0)) else {
            unreachable!("7 mm is positive and finite");
        };
        let body = FeatureId::default();
        let face = FaceLabel {
            feature: body,
            role: FaceRole::StartCap,
        };
        let vertex = VertexLabel {
            feature: body,
            role: VertexRole::Imported {
                ordinal: ImportOrdinal::new(0),
            },
        };
        [
            ExtrudeEndCondition::Blind { depth },
            ExtrudeEndCondition::ThroughAll,
            ExtrudeEndCondition::ThroughAllBoth,
            ExtrudeEndCondition::MidPlane { depth },
            ExtrudeEndCondition::UpToVertex { vertex },
            ExtrudeEndCondition::UpToSurface { surface: face },
            ExtrudeEndCondition::OffsetFromSurface {
                surface: face,
                offset: Length::new::<millimeter>(2.0),
                reverse_offset: false,
                translate_surface: false,
            },
            ExtrudeEndCondition::UpToBody { body },
        ]
        .into_iter()
        .for_each(|condition| {
            let Some(kind) = EndConditionKind::of(&condition) else {
                panic!("every supported variant maps to a kind");
            };
            assert!(EndConditionKind::SUPPORTED.contains(&kind));
        });
    }

    #[test]
    fn depth_kinds_round_trip_through_with_depth() {
        let Ok(depth) = PositiveLength::new(Length::new::<millimeter>(7.0)) else {
            unreachable!("7 mm is positive and finite");
        };
        [EndConditionKind::Blind, EndConditionKind::MidPlane]
            .into_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 reference_kinds_classify_by_end_condition() {
        use super::ReferenceKind;
        assert_eq!(
            EndConditionKind::UpToSurface.reference_kind(),
            Some(ReferenceKind::Face)
        );
        assert_eq!(
            EndConditionKind::OffsetFromSurface.reference_kind(),
            Some(ReferenceKind::Face)
        );
        assert_eq!(
            EndConditionKind::UpToVertex.reference_kind(),
            Some(ReferenceKind::Vertex)
        );
        assert_eq!(
            EndConditionKind::UpToBody.reference_kind(),
            Some(ReferenceKind::Body)
        );
        assert_eq!(EndConditionKind::Blind.reference_kind(), None);
    }

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