use bone_document::{
    DimensionKind, DimensionValue, Document, EditOutcome, EvaluatedSketch, FeatureCache,
    FeatureNode, Sketch, SketchDimension, SketchEdit, SketchEntity, SketchRelation,
    evaluate_extrude, evaluate_sketch,
};
use bone_kernel::{
    BrepEdge, BrepFace, BrepSolid, ExtrudeDirection, ExtrudeEndCondition, ExtrudeFeature,
    ExtrudeSense, MergeResult,
};
use bone_types::{
    DocumentId, EdgeLabel, FaceLabel, FeatureId, Length, Point2, Point3, PositiveLength,
    SketchDimensionId, SketchId, SketchPlaneBasis, Tolerance, UnitVec3, millimeter,
};
use slotmap::KeyData;

const TOL: Tolerance = Tolerance::new(1e-9);

fn plane() -> SketchPlaneBasis {
    let Ok(basis) = SketchPlaneBasis::new(
        Point3::origin(),
        UnitVec3::x_axis(),
        UnitVec3::y_axis(),
        Tolerance::new(1e-9),
    ) else {
        panic!("xy plane");
    };
    basis
}

fn mm(v: f64) -> Length {
    Length::new::<millimeter>(v)
}

fn sketch_id(idx: u32) -> SketchId {
    SketchId::from(KeyData::from_ffi((1u64 << 32) | u64::from(idx)))
}

fn document_id(idx: u32) -> DocumentId {
    DocumentId::from(KeyData::from_ffi((1u64 << 32) | u64::from(idx)))
}

fn feature_id(idx: u32) -> FeatureId {
    FeatureId::from(KeyData::from_ffi((1u64 << 32) | u64::from(idx)))
}

fn horizontal_line_sketch(dim_mm: f64) -> Sketch {
    let Ok((s, EditOutcome::Entity(a))) = Sketch::new(plane()).apply(SketchEdit::AddEntity(
        SketchEntity::point(Point2::from_mm(0.0, 0.0)),
    )) else {
        panic!("a");
    };
    let Ok((s, EditOutcome::Entity(b))) = s.apply(SketchEdit::AddEntity(SketchEntity::point(
        Point2::from_mm(1.0, 0.0),
    ))) else {
        panic!("b");
    };
    let Ok((s, EditOutcome::Entity(line))) =
        s.apply(SketchEdit::AddEntity(SketchEntity::line(a, b, false)))
    else {
        panic!("line");
    };
    let Ok((s, _)) = s.apply(SketchEdit::AddRelation(SketchRelation::Fix(a))) else {
        panic!("fix");
    };
    let Ok((s, _)) = s.apply(SketchEdit::AddRelation(SketchRelation::Horizontal(line))) else {
        panic!("horizontal");
    };
    let Ok((s, _)) = s.apply(SketchEdit::AddDimension(SketchDimension::Linear {
        a,
        b,
        value: mm(dim_mm),
        kind: DimensionKind::Driving,
    })) else {
        panic!("dim");
    };
    s
}

fn conflicting_sketch() -> Sketch {
    let Ok((s, EditOutcome::Entity(a))) = Sketch::new(plane()).apply(SketchEdit::AddEntity(
        SketchEntity::point(Point2::from_mm(0.0, 0.0)),
    )) else {
        panic!("a");
    };
    let Ok((s, EditOutcome::Entity(b))) = s.apply(SketchEdit::AddEntity(SketchEntity::point(
        Point2::from_mm(1.0, 0.0),
    ))) else {
        panic!("b");
    };
    let Ok((s, _)) = s.apply(SketchEdit::AddRelation(SketchRelation::Fix(a))) else {
        panic!("fix");
    };
    let Ok((s, _)) = s.apply(SketchEdit::AddDimension(SketchDimension::Linear {
        a,
        b,
        value: mm(5.0),
        kind: DimensionKind::Driving,
    })) else {
        panic!("dim1");
    };
    let Ok((s, _)) = s.apply(SketchEdit::AddDimension(SketchDimension::Linear {
        a,
        b,
        value: mm(10.0),
        kind: DimensionKind::Driving,
    })) else {
        panic!("dim2");
    };
    s
}

#[test]
fn evaluate_sketch_returns_solved_variant_on_success() {
    let sketch = horizontal_line_sketch(10.0);
    let output = evaluate_sketch(&sketch);
    let EvaluatedSketch::Solved(solved) = output else {
        panic!("expected Solved, got {output:?}");
    };
    let order = solved.entity_order();
    let b = order[1];
    let SketchEntity::Point(p) = solved.entities()[b] else {
        panic!("b is point");
    };
    let (x, y) = p.at().coords_mm();
    assert!((x - 10.0).abs() < 1e-9, "x = {x}");
    assert!(y.abs() < 1e-9, "y = {y}");
}

#[test]
fn evaluate_sketch_returns_failed_variant_on_conflict() {
    let sketch = conflicting_sketch();
    let output = evaluate_sketch(&sketch);
    assert!(
        matches!(output, EvaluatedSketch::Failed(_)),
        "expected Failed, got {output:?}"
    );
}

#[test]
fn feature_cache_reuses_solution_for_identical_input() {
    let sketch = horizontal_line_sketch(10.0);
    let mut cache = FeatureCache::new();
    let fid = feature_id(7);
    let first = cache.evaluate(fid, &sketch).clone();
    let second = cache.evaluate(fid, &sketch).clone();
    assert_eq!(first, second);
    assert_eq!(cache.len(), 1);
    assert!(cache.contains(fid));
}

#[test]
fn feature_cache_refreshes_on_input_change() {
    let mut cache = FeatureCache::new();
    let fid = feature_id(7);
    let first = cache.evaluate(fid, &horizontal_line_sketch(10.0)).clone();
    let second = cache.evaluate(fid, &horizontal_line_sketch(20.0)).clone();
    let EvaluatedSketch::Solved(first_solved) = first else {
        panic!("first Solved");
    };
    let EvaluatedSketch::Solved(second_solved) = second else {
        panic!("second Solved");
    };
    assert_ne!(first_solved, second_solved);
    assert_eq!(cache.len(), 1);
}

#[test]
fn feature_cache_keeps_entries_per_feature() {
    let mut cache = FeatureCache::new();
    let a = feature_id(1);
    let b = feature_id(2);
    cache.evaluate(a, &horizontal_line_sketch(10.0));
    cache.evaluate(b, &horizontal_line_sketch(20.0));
    assert_eq!(cache.len(), 2);
    assert!(cache.contains(a));
    assert!(cache.contains(b));
}

#[test]
fn feature_cache_invalidate_drops_entry() {
    let mut cache = FeatureCache::new();
    let fid = feature_id(7);
    cache.evaluate(fid, &horizontal_line_sketch(10.0));
    assert!(cache.invalidate(fid));
    assert!(!cache.contains(fid));
    assert!(!cache.invalidate(fid));
}

#[test]
fn feature_cache_retain_drops_absent_features() {
    let mut cache = FeatureCache::new();
    let keep = feature_id(1);
    let drop = feature_id(2);
    cache.evaluate(keep, &horizontal_line_sketch(10.0));
    cache.evaluate(drop, &horizontal_line_sketch(20.0));
    cache.retain([keep]);
    assert!(cache.contains(keep));
    assert!(!cache.contains(drop));
    assert_eq!(cache.len(), 1);
}

#[test]
fn document_resolves_feature_id_back_to_sketch() {
    let mut doc = Document::new(document_id(1), "d".to_owned());
    let sid = sketch_id(7);
    doc.insert_sketch(sid, "S".to_owned(), horizontal_line_sketch(10.0));
    let feature = doc.feature_tree().iter().find_map(|(fid, node)| {
        matches!(node, FeatureNode::Sketch(id) if id == sid).then_some(fid)
    });
    let Some(fid) = feature else {
        panic!("sketch feature present");
    };
    let Some(sketch) = doc.sketch_of_feature(fid) else {
        panic!("sketch addressable through feature id");
    };
    let Some(direct) = doc.sketch(sid) else {
        panic!("sketch in map");
    };
    assert_eq!(sketch, direct);
}

#[test]
fn feature_cache_refreshes_when_feature_id_is_reused() {
    let mut doc = Document::new(document_id(1), "d".to_owned());
    let sid_a = sketch_id(10);
    let sid_b = sketch_id(11);
    doc.insert_sketch(sid_a, "A".to_owned(), horizontal_line_sketch(10.0));
    let Some(fid_a) = doc.feature_tree().iter().find_map(|(fid, node)| {
        matches!(node, FeatureNode::Sketch(id) if id == sid_a).then_some(fid)
    }) else {
        panic!("A feature present");
    };

    let mut cache = FeatureCache::new();
    let Some(sketch_a) = doc.sketch(sid_a) else {
        panic!("A in map");
    };
    let EvaluatedSketch::Solved(solved_a) = cache.evaluate(fid_a, sketch_a).clone() else {
        panic!("A solves");
    };

    doc.remove_sketch(sid_a);
    doc.insert_sketch(sid_b, "B".to_owned(), horizontal_line_sketch(20.0));
    let Some(fid_b) = doc.feature_tree().iter().find_map(|(fid, node)| {
        matches!(node, FeatureNode::Sketch(id) if id == sid_b).then_some(fid)
    }) else {
        panic!("B feature present");
    };
    assert_eq!(
        fid_a, fid_b,
        "precondition: FeatureTree reuses the id of a removed feature"
    );

    let Some(sketch_b) = doc.sketch(sid_b) else {
        panic!("B in map");
    };
    let EvaluatedSketch::Solved(solved_b) = cache.evaluate(fid_b, sketch_b).clone() else {
        panic!("B solves");
    };
    assert_ne!(solved_a, solved_b, "cache must refresh on reused FeatureId");
}

fn dimensioned_rectangle(width_mm: f64, height_mm: f64) -> (Sketch, SketchDimensionId) {
    let Ok((with_points, points)) = Sketch::new(plane()).apply_all(vec![
        SketchEdit::AddEntity(SketchEntity::point(Point2::from_mm(0.0, 0.0))),
        SketchEdit::AddEntity(SketchEntity::point(Point2::from_mm(7.0, 0.5))),
        SketchEdit::AddEntity(SketchEntity::point(Point2::from_mm(7.5, 3.2))),
        SketchEdit::AddEntity(SketchEntity::point(Point2::from_mm(0.3, 2.9))),
    ]) else {
        panic!("corners");
    };
    let [c0, c1, c2, c3] = [0, 1, 2, 3].map(|i| entity_of(points[i]));
    let Ok((with_edges, edges)) = with_points.apply_all(vec![
        SketchEdit::AddEntity(SketchEntity::line(c0, c1, false)),
        SketchEdit::AddEntity(SketchEntity::line(c1, c2, false)),
        SketchEdit::AddEntity(SketchEntity::line(c2, c3, false)),
        SketchEdit::AddEntity(SketchEntity::line(c3, c0, false)),
    ]) else {
        panic!("edges");
    };
    let [bottom, right, top, left] = [0, 1, 2, 3].map(|i| entity_of(edges[i]));
    let Ok((constrained, outcomes)) = with_edges.apply_all(vec![
        SketchEdit::AddRelation(SketchRelation::Horizontal(bottom)),
        SketchEdit::AddRelation(SketchRelation::Horizontal(top)),
        SketchEdit::AddRelation(SketchRelation::Vertical(right)),
        SketchEdit::AddRelation(SketchRelation::Vertical(left)),
        SketchEdit::AddRelation(SketchRelation::Fix(c0)),
        SketchEdit::AddDimension(SketchDimension::Linear {
            a: c0,
            b: c1,
            value: mm(width_mm),
            kind: DimensionKind::Driving,
        }),
        SketchEdit::AddDimension(SketchDimension::Linear {
            a: c1,
            b: c2,
            value: mm(height_mm),
            kind: DimensionKind::Driving,
        }),
    ]) else {
        panic!("constraints");
    };
    let Some(width_dim) = outcomes.iter().find_map(|outcome| match outcome {
        EditOutcome::Dimension(id) => Some(*id),
        _ => None,
    }) else {
        panic!("width dimension allocated");
    };
    (constrained, width_dim)
}

fn entity_of(outcome: EditOutcome) -> bone_types::SketchEntityId {
    let EditOutcome::Entity(id) = outcome else {
        panic!("expected an entity outcome");
    };
    id
}

fn blind(depth_mm: f64) -> ExtrudeFeature {
    let Ok(depth) = PositiveLength::new(mm(depth_mm)) else {
        panic!("{depth_mm} mm is positive");
    };
    ExtrudeFeature {
        sketch: SketchId::default(),
        direction: ExtrudeDirection::Normal {
            sense: ExtrudeSense::Forward,
        },
        end_condition: ExtrudeEndCondition::Blind { depth },
        draft: None,
        thin_wall: None,
        merge_result: MergeResult::Merge,
    }
}

fn face_labels(solid: &BrepSolid) -> Vec<FaceLabel> {
    solid.iter_faces().map(BrepFace::label).collect()
}

fn edge_labels(solid: &BrepSolid) -> Vec<EdgeLabel> {
    solid.iter_edges().map(BrepEdge::label).collect()
}

#[test]
fn evaluate_extrude_reports_failed_sketch() {
    let extrude = evaluate_extrude(
        feature_id(9),
        &evaluate_sketch(&conflicting_sketch()),
        &blind(5.0),
    );
    assert!(extrude.result().is_err());
    assert!(extrude.solid().is_none());
    assert!(extrude.generation().is_none());
}

#[test]
fn feature_cache_extrude_refreshes_when_sketch_changes() {
    let mut cache = FeatureCache::new();
    let sketch_feature = feature_id(20);
    let extrude_feature = feature_id(21);
    let feature = blind(8.0);

    let (narrow, _) = dimensioned_rectangle(10.0, 5.0);
    let solved_narrow = cache.evaluate(sketch_feature, &narrow).clone();
    let first = cache
        .evaluate_extrude(extrude_feature, &solved_narrow, &feature)
        .generation();
    let cached = cache
        .evaluate_extrude(extrude_feature, &solved_narrow, &feature)
        .generation();
    assert_eq!(first, cached, "identical input hits the cache");

    let (wide, _) = dimensioned_rectangle(25.0, 5.0);
    let solved_wide = cache.evaluate(sketch_feature, &wide).clone();
    let refreshed = cache
        .evaluate_extrude(extrude_feature, &solved_wide, &feature)
        .generation();
    assert_ne!(
        first, refreshed,
        "a changed upstream sketch refreshes the extrude"
    );
    assert!(cache.contains(extrude_feature));
    assert_eq!(cache.len(), 2);
}

proptest::proptest! {
    #[test]
    fn extrude_re_evaluates_under_edited_width(width_mm in 2.0f64..40.0) {
        let extrude_feature = feature_id(42);
        let feature = blind(10.0);
        let (base, width_dim) = dimensioned_rectangle(10.0, 5.0);
        let baseline = evaluate_extrude(extrude_feature, &evaluate_sketch(&base), &feature);

        let Ok((edited, _)) = base.apply(SketchEdit::UpdateDimensionValue {
            id: width_dim,
            value: DimensionValue::Length(mm(width_mm)),
        }) else {
            panic!("width edit applies");
        };
        let widened = evaluate_extrude(extrude_feature, &evaluate_sketch(&edited), &feature);

        let (Some(before), Some(after)) = (baseline.solid(), widened.solid()) else {
            panic!("both widths extrude");
        };
        proptest::prop_assert_eq!(face_labels(before), face_labels(after));
        proptest::prop_assert_eq!(edge_labels(before), edge_labels(after));
        proptest::prop_assert!(after.validate(TOL).is_ok());
        let Some(bbox) = after.bounding_box() else {
            panic!("solid has a bounding box");
        };
        let (dx, _, _) = bbox.extent().coords_mm();
        proptest::prop_assert!((dx - width_mm).abs() < 1e-6, "x-extent {} tracks width {}", dx, width_mm);
    }
}
