use bone_document::{Document, EditOutcome, Sketch, SketchEdit, SketchEntity};
use bone_interop::{StepError, body_of, read, write};
use bone_kernel::{
    BrepFace, BrepSolid, ExtrudeDirection, ExtrudeEndCondition, ExtrudeFeature, ExtrudeSense,
    MergeResult,
};
use bone_types::{
    DocumentId, ExtrudeId, FaceLabel, FeatureId, Length, Point2, Point3, PositiveLength,
    SketchEntityId, SketchId, SketchPlaneBasis, StepSchema, Tolerance, UnitVec3, millimeter,
};
use slotmap::KeyData;

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

fn xy_basis() -> SketchPlaneBasis {
    let Ok(basis) = SketchPlaneBasis::new(
        Point3::origin(),
        UnitVec3::x_axis(),
        UnitVec3::y_axis(),
        TOL,
    ) else {
        panic!("orthonormal axes");
    };
    basis
}

fn ffi_key(n: u64) -> KeyData {
    KeyData::from_ffi((1u64 << 32) | n)
}

fn sketch_id(n: u64) -> SketchId {
    SketchId::from(ffi_key(n))
}

fn extrude_id(n: u64) -> ExtrudeId {
    ExtrudeId::from(ffi_key(n))
}

fn import_feature() -> FeatureId {
    FeatureId::from(ffi_key(99))
}

fn add_point(sketch: Sketch, x: f64, y: f64) -> (Sketch, SketchEntityId) {
    let Ok((next, EditOutcome::Entity(id))) = sketch.apply(SketchEdit::AddEntity(
        SketchEntity::point(Point2::from_mm(x, y)),
    )) else {
        panic!("add point");
    };
    (next, id)
}

fn add_line(sketch: Sketch, a: SketchEntityId, b: SketchEntityId) -> Sketch {
    let Ok((next, _)) = sketch.apply(SketchEdit::AddEntity(SketchEntity::line(a, b, false))) else {
        panic!("add line");
    };
    next
}

fn add_circle(sketch: Sketch, center: SketchEntityId, radius_mm: f64) -> Sketch {
    let Ok((next, _)) = sketch.apply(SketchEdit::AddEntity(SketchEntity::circle(
        center,
        Length::new::<millimeter>(radius_mm),
        false,
    ))) else {
        panic!("add circle");
    };
    next
}

fn rectangle_sketch() -> Sketch {
    let (sketch, p0) = add_point(Sketch::new(xy_basis()), 0.0, 0.0);
    let (sketch, p1) = add_point(sketch, 4.0, 0.0);
    let (sketch, p2) = add_point(sketch, 4.0, 2.0);
    let (sketch, p3) = add_point(sketch, 0.0, 2.0);
    let sketch = add_line(sketch, p0, p1);
    let sketch = add_line(sketch, p1, p2);
    let sketch = add_line(sketch, p2, p3);
    add_line(sketch, p3, p0)
}

fn circle_sketch(radius_mm: f64) -> Sketch {
    let (sketch, center) = add_point(Sketch::new(xy_basis()), 0.0, 0.0);
    add_circle(sketch, center, radius_mm)
}

fn donut_sketch() -> Sketch {
    let (sketch, center) = add_point(Sketch::new(xy_basis()), 0.0, 0.0);
    let sketch = add_circle(sketch, center, 10.0);
    add_circle(sketch, center, 4.0)
}

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

fn document(name: &str, sketch: Sketch, depth_mm: f64) -> Document {
    let mut document = Document::new(DocumentId::default(), name.to_owned());
    let sketch_key = sketch_id(1);
    document.insert_sketch(sketch_key, "Sketch1".to_owned(), sketch);
    document.insert_extrude(extrude_id(1), blind(sketch_key, depth_mm));
    document
}

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

fn step_round_trip_keeps_labels(document: &Document) {
    let Ok(expected) = body_of(document) else {
        panic!("document evaluates to one body");
    };
    let Ok(dir) = tempfile::tempdir() else {
        panic!("temp dir");
    };
    let path = dir.path().join("part.step");
    let Ok(()) = write(document, &path, StepSchema::Ap214) else {
        panic!("write step");
    };
    let Ok(outcome) = read(&path, import_feature()) else {
        panic!("read step");
    };
    assert!(outcome.is_labeled(), "matching sidecar restores labels");
    assert_eq!(face_labels(&expected), face_labels(outcome.solid()));
    assert!(outcome.solid().validate(TOL).is_ok());
}

#[test]
fn cube_step_round_trip_keeps_labels() {
    step_round_trip_keeps_labels(&document("cube", rectangle_sketch(), 4.0));
}

#[test]
fn cylinder_step_round_trip_keeps_labels() {
    step_round_trip_keeps_labels(&document("cylinder", circle_sketch(5.0), 8.0));
}

#[test]
fn donut_step_round_trip_keeps_labels() {
    step_round_trip_keeps_labels(&document("donut", donut_sketch(), 6.0));
}

#[test]
fn header_carries_document_name() {
    let document = document("bracket", rectangle_sketch(), 4.0);
    let Ok(dir) = tempfile::tempdir() else {
        panic!("temp dir");
    };
    let path = dir.path().join("export.step");
    let Ok(()) = write(&document, &path, StepSchema::Ap214) else {
        panic!("write step");
    };
    let Ok(text) = std::fs::read_to_string(&path) else {
        panic!("read back");
    };
    assert!(
        text.contains("FILE_NAME('bracket'"),
        "header file_name carries the document name, not the step path"
    );
}

#[test]
fn apostrophe_in_document_name_round_trips() {
    let document = document("nel's bracket", rectangle_sketch(), 4.0);
    let Ok(dir) = tempfile::tempdir() else {
        panic!("temp dir");
    };
    let path = dir.path().join("part.step");
    let Ok(()) = write(&document, &path, StepSchema::Ap214) else {
        panic!("an apostrophe in the document name must not break the header");
    };
    let Ok(text) = std::fs::read_to_string(&path) else {
        panic!("read back");
    };
    assert!(
        text.contains("FILE_NAME('nel''s bracket'"),
        "an apostrophe is doubled per ISO 10303-21"
    );
    let Ok(outcome) = read(&path, import_feature()) else {
        panic!("read step");
    };
    assert!(outcome.is_labeled());
}

#[test]
fn step_export_is_byte_deterministic() {
    let document = document("cube", rectangle_sketch(), 4.0);
    let Ok(dir) = tempfile::tempdir() else {
        panic!("temp dir");
    };
    let path = dir.path().join("part.step");
    let Ok(()) = write(&document, &path, StepSchema::Ap214) else {
        panic!("write first");
    };
    let Ok(a) = std::fs::read(&path) else {
        panic!("read first");
    };
    let Ok(()) = write(&document, &path, StepSchema::Ap214) else {
        panic!("write second");
    };
    let Ok(b) = std::fs::read(&path) else {
        panic!("read second");
    };
    assert_eq!(a, b, "same document and path write byte-identical step");
}

#[test]
fn missing_sidecar_imports_dumb_body() {
    let document = document("cube", rectangle_sketch(), 4.0);
    let Ok(expected) = body_of(&document) else {
        panic!("one body");
    };
    let Ok(dir) = tempfile::tempdir() else {
        panic!("temp dir");
    };
    let path = dir.path().join("part.step");
    let Ok(()) = write(&document, &path, StepSchema::Ap214) else {
        panic!("write step");
    };
    let mut labels = path.clone().into_os_string();
    labels.push(".labels");
    let Ok(()) = std::fs::remove_file(std::path::PathBuf::from(labels)) else {
        panic!("remove sidecar");
    };
    let Ok(outcome) = read(&path, import_feature()) else {
        panic!("read step");
    };
    assert!(!outcome.is_labeled(), "no sidecar yields a dumb body");
    assert_eq!(
        outcome.solid().iter_faces().count(),
        expected.iter_faces().count()
    );
}

#[test]
fn ap242_export_is_unsupported() {
    let document = document("cube", rectangle_sketch(), 4.0);
    let Ok(dir) = tempfile::tempdir() else {
        panic!("temp dir");
    };
    let path = dir.path().join("part.step");
    assert!(matches!(
        write(&document, &path, StepSchema::Ap242E2),
        Err(StepError::SchemaUnsupported(StepSchema::Ap242E2))
    ));
}

#[test]
fn empty_document_has_no_body() {
    let document = Document::new(DocumentId::default(), "empty".to_owned());
    let Ok(dir) = tempfile::tempdir() else {
        panic!("temp dir");
    };
    let path = dir.path().join("part.step");
    assert!(matches!(
        write(&document, &path, StepSchema::Ap214),
        Err(StepError::BodyCount { count: 0 })
    ));
}

#[test]
fn multiple_bodies_are_rejected() {
    let mut document = Document::new(DocumentId::default(), "two".to_owned());
    let first = sketch_id(1);
    let second = sketch_id(2);
    document.insert_sketch(first, "A".to_owned(), rectangle_sketch());
    document.insert_sketch(second, "B".to_owned(), circle_sketch(5.0));
    document.insert_extrude(extrude_id(1), blind(first, 4.0));
    document.insert_extrude(extrude_id(2), blind(second, 6.0));
    let Ok(dir) = tempfile::tempdir() else {
        panic!("temp dir");
    };
    let path = dir.path().join("part.step");
    assert!(matches!(
        write(&document, &path, StepSchema::Ap214),
        Err(StepError::BodyCount { count: 2 })
    ));
}

#[test]
fn lone_unresolved_extrude_reports_dangling() {
    let mut document = Document::new(DocumentId::default(), "pending".to_owned());
    document.insert_extrude(extrude_id(1), blind(sketch_id(1), 4.0));
    let Ok(dir) = tempfile::tempdir() else {
        panic!("temp dir");
    };
    let path = dir.path().join("part.step");
    assert!(matches!(
        write(&document, &path, StepSchema::Ap214),
        Err(StepError::DanglingExtrude { .. })
    ));
}

#[test]
fn unresolved_body_is_not_silently_dropped() {
    let mut document = Document::new(DocumentId::default(), "mixed".to_owned());
    document.insert_sketch(sketch_id(1), "A".to_owned(), rectangle_sketch());
    document.insert_extrude(extrude_id(1), blind(sketch_id(1), 4.0));
    document.insert_extrude(extrude_id(2), blind(sketch_id(2), 6.0));
    let Ok(dir) = tempfile::tempdir() else {
        panic!("temp dir");
    };
    let path = dir.path().join("part.step");
    assert!(matches!(
        write(&document, &path, StepSchema::Ap214),
        Err(StepError::BodyCount { count: 2 })
    ));
    assert!(!path.exists(), "a rejected export leaves no file behind");
}

#[test]
fn sidecar_failure_leaves_no_orphan_step() {
    let document = document("cube", rectangle_sketch(), 4.0);
    let Ok(dir) = tempfile::tempdir() else {
        panic!("temp dir");
    };
    let path = dir.path().join("part.step");
    let mut blocker = path.clone().into_os_string();
    blocker.push(".labels");
    let Ok(()) = std::fs::create_dir(std::path::PathBuf::from(blocker)) else {
        panic!("block the sidecar path with a directory");
    };
    assert!(write(&document, &path, StepSchema::Ap214).is_err());
    assert!(
        !path.exists(),
        "no labelless step is written when the sidecar cannot be"
    );
}

#[test]
fn cylinder_export_writes_step_and_sidecar() {
    let document = document("cyl", circle_sketch(5.0), 8.0);
    let Ok(dir) = tempfile::tempdir() else {
        panic!("temp dir");
    };
    let path = dir.path().join("cyl.step");
    let Ok(()) = write(&document, &path, StepSchema::Ap214) else {
        panic!("write step");
    };
    assert!(path.exists());
    let mut labels = path.clone().into_os_string();
    labels.push(".labels");
    assert!(std::path::PathBuf::from(labels).exists());
}
