use bone_document::{
    BlobHash, BlobKind, Document, DocumentFolder, DocumentHeader, PrincipalPlane, Sketch3,
    Sketch3Entity, Sketch3Relation, SketchPlaneRef, SketchRegistry, SketchRegistryEntry,
    evaluate_extrude, evaluate_sketch, from_str, load, save, to_string,
};
use bone_kernel::{
    AxisRecipe, BrepFace, ContourSelection, DatumFeature, ExtrudeFeature, ExtrudeOperation,
    MergeResult, PlaneRecipe, RevolveAngle, RevolveAxis, RevolveFeature, ThinWall,
    ThinWallDirection,
};
use bone_types::{
    Angle, DatumTarget, DocumentId, ExtrudeId, FaceFingerprint, FaceLabel, FaceRef, FaceRole,
    FeatureId, Length, Plane3, Point3, PositiveLength, RevolveId, RollbackMarker, SketchEntityId,
    SketchId, SketchPlaneBasis, SuppressionState, Tolerance, UnitVec3, degree, millimeter,
};
use slotmap::{Key, KeyData};
use tempfile::{TempDir, tempdir};

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 is orthogonal");
    };
    basis
}

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

fn deg(v: f64) -> Angle {
    Angle::new::<degree>(v)
}

fn ok_dir() -> TempDir {
    let Ok(dir) = tempdir() else {
        panic!("tempdir");
    };
    dir
}

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 extrude_id(idx: u32) -> ExtrudeId {
    ExtrudeId::from(KeyData::from_ffi((1u64 << 32) | u64::from(idx)))
}

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

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

fn blind_revolve(sketch: SketchId) -> RevolveFeature {
    let Ok(angle) = RevolveAngle::new(deg(90.0)) else {
        panic!("90 degrees is a valid revolve angle");
    };
    let Ok(thickness) = PositiveLength::new(mm(1.5)) else {
        panic!("1.5 mm is a positive thickness");
    };
    RevolveFeature {
        thin_wall: Some(ThinWall {
            thickness,
            direction: ThinWallDirection::MidPlane,
            thickness2: None,
            cap_ends: false,
            auto_fillet: None,
        }),
        contours: ContourSelection::Contour(sketch_entity_id(2)),
        ..RevolveFeature::blind(sketch, RevolveAxis::SketchLine(sketch_entity_id(1)), angle)
    }
}

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

fn blind_extrude(sketch: SketchId) -> ExtrudeFeature {
    let Ok(depth) = PositiveLength::new(mm(10.0)) else {
        panic!("positive depth");
    };
    ExtrudeFeature::blind(sketch, depth)
}

fn separate_extrude(sketch: SketchId) -> ExtrudeFeature {
    ExtrudeFeature {
        operation: ExtrudeOperation::Boss(MergeResult::Separate),
        ..blind_extrude(sketch)
    }
}

fn sample_face_ref(feature: FeatureId) -> FaceRef {
    FaceRef::new(
        FaceLabel {
            feature,
            role: FaceRole::EndCap,
        },
        FaceFingerprint {
            plane: Plane3::new_unchecked(
                Point3::from_mm(0.0, 0.0, 10.0),
                UnitVec3::x_axis(),
                UnitVec3::y_axis(),
            ),
            centroid: Point3::from_mm(0.0, 0.0, 10.0),
        },
    )
}

fn add_point(s: &Sketch3, x: f64, y: f64) -> (Sketch3, SketchEntityId) {
    s.with_entity(Sketch3Entity::point(Point3::from_mm(x, y, 0.0)))
}

fn add_line(s: &Sketch3, a: SketchEntityId, b: SketchEntityId) -> Sketch3 {
    s.with_entity(Sketch3Entity::line(a, b, false)).0
}

fn rectangle() -> Sketch3 {
    let (s, _p0) = add_point(&Sketch3::on_plane(plane()), 0.0, 0.0);
    let (s, _p1) = add_point(&s, 10.0, 0.0);
    let (s, _p2) = add_point(&s, 10.0, 5.0);
    let (s, _p3) = add_point(&s, 0.0, 5.0);
    s
}

fn assert_save(doc: &Document, folder: &DocumentFolder) {
    let Ok(()) = save(doc, folder) else {
        panic!("save clean");
    };
}

fn assert_load(folder: &DocumentFolder) -> Document {
    let Ok(doc) = load(folder) else {
        panic!("load clean");
    };
    doc
}

fn read_file(path: &std::path::Path) -> String {
    let Ok(text) = std::fs::read_to_string(path) else {
        panic!("read {}", path.display());
    };
    text
}

fn write_file(path: &std::path::Path, contents: &str) {
    let Ok(()) = std::fs::write(path, contents) else {
        panic!("write {}", path.display());
    };
}

fn folder_bytes(root: &std::path::Path) -> std::collections::BTreeMap<std::path::PathBuf, Vec<u8>> {
    let Ok(entries) = std::fs::read_dir(root) else {
        panic!("read dir {}", root.display());
    };
    entries
        .map(|entry| {
            let Ok(entry) = entry else {
                panic!("dir entry under {}", root.display());
            };
            entry.path()
        })
        .flat_map(|path| {
            if path.is_dir() {
                folder_bytes(&path).into_iter().collect::<Vec<_>>()
            } else {
                let Ok(bytes) = std::fs::read(&path) else {
                    panic!("read {}", path.display());
                };
                vec![(path, bytes)]
            }
        })
        .collect()
}

#[test]
fn rectangle_roundtrips_through_folder() {
    let dir = ok_dir();
    let folder = DocumentFolder::new(dir.path().join("part.bone"));

    let mut doc = Document::new(document_id(1), "part".to_owned());
    doc.insert_sketch(sketch_id(1), "Sketch1".to_owned(), rectangle());
    doc.set_parameter("width".to_owned(), 10.0);

    assert_save(&doc, &folder);
    let loaded = assert_load(&folder);

    assert_eq!(loaded.name(), doc.name());
    assert_eq!(loaded.registry().order(), doc.registry().order());
    assert_eq!(loaded.feature_tree(), doc.feature_tree());
    assert_eq!(loaded.sketches_map(), doc.sketches_map());
    assert_eq!(loaded.parameters().get("width"), Some(10.0));
}

fn rich_sketch_with_every_variant() -> Sketch3 {
    let n = UnitVec3::z_axis();
    let (s, p0) = add_point(&Sketch3::on_plane(plane()), 0.0, 0.0);
    let (s, p1) = add_point(&s, 10.0, 0.0);
    let (s, p2) = add_point(&s, 5.0, 6.0);
    let (s, major) = add_point(&s, 9.0, 6.0);
    let (s, minor) = add_point(&s, 5.0, 9.0);
    let (s, ctrl) = add_point(&s, 2.0, 8.0);
    let (s, line) = s.with_entity(Sketch3Entity::line(p0, p1, false));
    let (s, line2) = s.with_entity(Sketch3Entity::line(p0, p2, true));
    let (s, arc) = s.with_entity(Sketch3Entity::arc(p0, p1, p2, n, false));
    let (s, circle) = s.with_entity(Sketch3Entity::circle(p0, mm(3.0), n, false));
    let (s, _ellipse) = s.with_entity(Sketch3Entity::ellipse(p2, major, minor, n, false));
    let (s, _spline) = s.with_entity(Sketch3Entity::spline(vec![p0, ctrl, p2], true, false));
    let relations = [
        Sketch3Relation::Coincident(p1, p2),
        Sketch3Relation::OnPlane {
            point: p0,
            normal: n,
            offset: mm(0.0),
        },
        Sketch3Relation::AlongAxis {
            line,
            axis: UnitVec3::x_axis(),
        },
        Sketch3Relation::Perpendicular(line, line2),
        Sketch3Relation::Parallel(line, line2),
        Sketch3Relation::Tangent(line, circle),
        Sketch3Relation::Equal(line, line2),
        Sketch3Relation::Concentric(arc, circle),
        Sketch3Relation::Midpoint { point: p2, line },
        Sketch3Relation::PointOnLine {
            point: p1,
            line: line2,
        },
        Sketch3Relation::Symmetric {
            a: p1,
            b: p2,
            axis: line,
        },
        Sketch3Relation::Distance {
            a: p0,
            b: p1,
            value: mm(10.0),
            reference: false,
        },
        Sketch3Relation::Angle {
            a: line,
            b: line2,
            value: deg(45.0),
            reference: false,
        },
        Sketch3Relation::Radius {
            target: circle,
            value: mm(3.0),
            reference: true,
        },
        Sketch3Relation::Diameter {
            target: circle,
            value: mm(6.0),
            reference: false,
        },
        Sketch3Relation::Fix(p0),
    ];
    relations
        .into_iter()
        .fold(s, |acc, relation| acc.with_relation(relation).0)
}

#[test]
fn rich_sketch_with_every_variant_roundtrips() {
    let dir = ok_dir();
    let folder = DocumentFolder::new(dir.path().join("rich.bone"));
    let s = rich_sketch_with_every_variant();
    let mut doc = Document::new(document_id(1), "rich".to_owned());
    doc.insert_sketch(sketch_id(1), "Rich".to_owned(), s.clone());

    assert_save(&doc, &folder);
    let loaded = assert_load(&folder);
    let Some(loaded_sketch) = loaded.sketch3(sketch_id(1)) else {
        panic!("sketch missing after load");
    };
    assert_eq!(loaded_sketch, &s);
}

#[test]
fn load_refuses_unknown_schema_major() {
    use bone_types::{SchemaHeader, SchemaVersion};

    let dir = ok_dir();
    let folder = DocumentFolder::new(dir.path().join("future.bone"));
    let mut doc = Document::new(document_id(1), "future".to_owned());
    doc.insert_sketch(sketch_id(1), "Sketch1".to_owned(), rectangle());
    assert_save(&doc, &folder);

    let header_path = folder.document_file();
    let text = read_file(&header_path);
    write_file(&header_path, &text.replace("major: 1,", "major: 9999,"));

    let load_result = load(&folder).map_err(bone_document::FolderError::into_kind);
    let Err(bone_document::FolderErrorKind::UnsupportedMajor {
        found, supported, ..
    }) = load_result
    else {
        panic!("expected UnsupportedMajor");
    };
    assert_eq!(
        found,
        SchemaVersion::new(9999, SchemaHeader::BONE_DOCUMENT_MINOR)
    );
    assert_eq!(
        supported,
        SchemaVersion::new(
            SchemaHeader::BONE_DOCUMENT_MAJOR,
            SchemaHeader::BONE_DOCUMENT_MINOR
        )
    );
}

#[test]
fn load_reports_unsupported_major_ahead_of_unknown_fields() {
    let dir = ok_dir();
    let folder = DocumentFolder::new(dir.path().join("future_major.bone"));
    let mut doc = Document::new(document_id(1), "future".to_owned());
    doc.insert_sketch(sketch_id(1), "Sketch1".to_owned(), rectangle());
    assert_save(&doc, &folder);

    let header_path = folder.document_file();
    let text = read_file(&header_path);
    let bumped = text.replace("major: 1,", "major: 9999,");
    assert_ne!(bumped, text, "schema major pattern missing");
    let injected = bumped.replace(
        "DocumentHeader(\n    schema:",
        "DocumentHeader(\n    mystery_field: 7,\n    schema:",
    );
    assert_ne!(injected, bumped, "document header anchor missing");
    write_file(&header_path, &injected);

    let result = load(&folder).map_err(bone_document::FolderError::into_kind);
    let Err(bone_document::FolderErrorKind::UnsupportedMajor { found, .. }) = result else {
        panic!("a newer major must refuse outright, not surface a raw field error: {result:?}");
    };
    assert_eq!(found.major, 9999);
}

#[test]
fn load_refuses_unknown_schema_name() {
    let dir = ok_dir();
    let folder = DocumentFolder::new(dir.path().join("foreign.bone"));
    let mut doc = Document::new(document_id(1), "foreign".to_owned());
    doc.insert_sketch(sketch_id(1), "Sketch1".to_owned(), rectangle());
    assert_save(&doc, &folder);

    let header_path = folder.document_file();
    let text = read_file(&header_path);
    write_file(
        &header_path,
        &text.replace("\"bone-document\"", "\"kicad-pcb\""),
    );

    let load_result = load(&folder).map_err(bone_document::FolderError::into_kind);
    let Err(bone_document::FolderErrorKind::UnknownSchema { found, .. }) = load_result else {
        panic!("expected UnknownSchema");
    };
    assert_eq!(found, "kicad-pcb");
}

#[test]
fn save_ships_vcs_files() {
    let dir = ok_dir();
    let folder = DocumentFolder::new(dir.path().join("vcs.bone"));
    let doc = Document::new(document_id(1), "vcs".to_owned());
    assert_save(&doc, &folder);

    let gitignore = read_file(&folder.path().join(".gitignore"));
    assert!(gitignore.contains("caches/"));
    let gitattributes = read_file(&folder.path().join(".gitattributes"));
    assert!(gitattributes.contains("* text=auto eol=lf"));
    assert!(gitattributes.contains("*.brep text eol=lf"));
    assert!(gitattributes.contains("*.labels text eol=lf"));
    let caches_tag = read_file(&folder.caches_dir().join("CACHEDIR.TAG"));
    assert!(caches_tag.starts_with("Signature: 8a477f597d28d172789f06886806bc55"));
    let caches_gitignore = read_file(&folder.caches_dir().join(".gitignore"));
    assert_eq!(caches_gitignore, "*\n!.gitignore\n!CACHEDIR.TAG\n");
}

#[test]
fn save_is_idempotent_byte_for_byte() {
    let dir = ok_dir();
    let folder = DocumentFolder::new(dir.path().join("idem.bone"));
    let mut doc = Document::new(document_id(1), "idem".to_owned());
    doc.insert_sketch(sketch_id(1), "S".to_owned(), rectangle());
    assert_save(&doc, &folder);
    let Ok(first) = std::fs::read(folder.document_file()) else {
        panic!("read first");
    };
    assert_save(&doc, &folder);
    let Ok(second) = std::fs::read(folder.document_file()) else {
        panic!("read second");
    };
    assert_eq!(first, second, "document.ron drifted between saves");
}

#[test]
fn loaded_document_preserves_slotmap_keys() {
    let dir = ok_dir();
    let folder = DocumentFolder::new(dir.path().join("keys.bone"));

    let (s, a) = add_point(&Sketch3::on_plane(plane()), 0.0, 0.0);
    let (s, b) = add_point(&s, 1.0, 0.0);
    let s = add_line(&s, a, b);

    let mut doc = Document::new(document_id(1), "keys".to_owned());
    let id = sketch_id(1);
    doc.insert_sketch(id, "S".to_owned(), s.clone());
    assert_save(&doc, &folder);

    let loaded = assert_load(&folder);
    let Some(loaded_sketch) = loaded.sketch3(id) else {
        panic!("sketch");
    };
    let first_id = loaded_sketch.entity_order()[0];
    assert_eq!(first_id.data(), a.data());
    let entries: Vec<(SketchId, SketchRegistryEntry)> = loaded
        .registry()
        .iter()
        .map(|(id, e)| (id, SketchRegistryEntry::clone(e)))
        .collect();
    assert_eq!(entries.len(), 1);
    let (_, entry) = &entries[0];
    assert_eq!(entry.label, "S");
    assert!(
        std::path::Path::new(&entry.filename)
            .extension()
            .is_some_and(|ext| ext.eq_ignore_ascii_case("ron"))
    );
}

#[test]
fn folder_reserves_removed_ids_across_roundtrip() {
    let dir = ok_dir();
    let folder = DocumentFolder::new(dir.path().join("hist.bone"));

    let mut doc = Document::new(document_id(1), "doc".to_owned());
    doc.insert_sketch(sketch_id(1), "S".to_owned(), rectangle());
    doc.insert_sketch(sketch_id(2), "T".to_owned(), rectangle());
    let Some(removed_feature) = doc.feature_tree().feature_of_sketch(sketch_id(2)) else {
        panic!("feature node for sketch 2");
    };
    doc.remove_sketch(sketch_id(2));
    assert_save(&doc, &folder);

    let Ok(mut reloaded) = load(&folder) else {
        panic!("reload");
    };
    assert_eq!(
        reloaded.registry().order().len(),
        1,
        "the recipe lists only the live sketch, not a tombstone vector"
    );

    let reborn_sketch = reloaded.allocate_sketch();
    assert_ne!(
        reborn_sketch,
        sketch_id(2),
        "a removed sketch id stays reserved across the folder round-trip"
    );
    reloaded.insert_sketch(reborn_sketch, "U".to_owned(), rectangle());
    let Some(reborn_feature) = reloaded.feature_tree().feature_of_sketch(reborn_sketch) else {
        panic!("feature node for the reborn sketch");
    };
    assert_ne!(
        reborn_feature, removed_feature,
        "a removed feature id stays reserved across the folder round-trip"
    );
}

#[test]
fn push_sketch_is_idempotent_on_duplicate_id() {
    let mut doc = Document::new(document_id(1), "dup".to_owned());
    let id = sketch_id(1);
    doc.insert_sketch(id, "S".to_owned(), rectangle());
    doc.insert_sketch(id, "S-renamed".to_owned(), rectangle());
    let sketch_nodes = doc
        .feature_tree()
        .iter()
        .filter(|(_, n)| matches!(n, bone_document::FeatureNode::Sketch(_)))
        .count();
    assert_eq!(
        sketch_nodes, 1,
        "duplicate sketch id must not spawn a second feature node"
    );
}

#[test]
fn removing_sketch_drops_stale_file() {
    let dir = ok_dir();
    let folder = DocumentFolder::new(dir.path().join("drop.bone"));
    let mut doc = Document::new(document_id(1), "drop".to_owned());
    let id = sketch_id(1);
    doc.insert_sketch(id, "S".to_owned(), rectangle());
    assert_save(&doc, &folder);
    assert!(folder.sketch_path(id).exists());
    doc.remove_sketch(id);
    assert_save(&doc, &folder);
    assert!(!folder.sketch_path(id).exists());
}

#[test]
fn registry_kept_sorted_by_order_across_roundtrip() {
    let dir = ok_dir();
    let folder = DocumentFolder::new(dir.path().join("order.bone"));
    let mut doc = Document::new(document_id(1), "order".to_owned());
    let rect = rectangle();
    let a = sketch_id(1);
    let b = sketch_id(2);
    let c = sketch_id(3);
    doc.insert_sketch(a, "A".to_owned(), rect.clone());
    doc.insert_sketch(b, "B".to_owned(), rect.clone());
    doc.insert_sketch(c, "C".to_owned(), rect);
    assert_save(&doc, &folder);
    let loaded = assert_load(&folder);
    assert_eq!(loaded.registry().order(), &[a, b, c]);
    let _: &SketchRegistry = loaded.registry();
}

#[test]
fn f64_special_values_roundtrip_bit_identically() {
    let dir = ok_dir();
    let folder = DocumentFolder::new(dir.path().join("bits.bone"));
    let specials: [(f64, f64); 7] = [
        (-0.0, 0.0),
        (0.0, 1.0),
        (f64::INFINITY, 2.0),
        (f64::NEG_INFINITY, 3.0),
        (f64::MIN_POSITIVE, 4.0),
        (f64::MIN_POSITIVE / 2.0, 5.0),
        (1.0 + f64::EPSILON, 6.0),
    ];
    let sketch = specials
        .into_iter()
        .fold(Sketch3::on_plane(plane()), |s, (x, y)| {
            s.with_entity(Sketch3Entity::point(Point3::from_mm(x, y, 0.0)))
                .0
        });
    let mut doc = Document::new(document_id(1), "bits".to_owned());
    doc.insert_sketch(sketch_id(1), "S".to_owned(), sketch.clone());
    assert_save(&doc, &folder);
    let loaded = assert_load(&folder);
    let Some(back) = loaded.sketch3(sketch_id(1)) else {
        panic!("sketch missing");
    };
    let coords = |s: &Sketch3| -> Vec<(f64, f64)> {
        s.entity_order()
            .iter()
            .map(|id| match &s.entities()[*id] {
                Sketch3Entity::Point(p) => {
                    let (x, y, _z) = p.at().coords_mm();
                    (x, y)
                }
                other => panic!("expected Point, got {other:?}"),
            })
            .collect()
    };
    let originals = coords(&sketch);
    let reloaded = coords(back);
    assert_eq!(originals.len(), reloaded.len());
    originals
        .iter()
        .zip(reloaded.iter())
        .for_each(|((ax, ay), (bx, by))| {
            assert_eq!(ax.to_bits(), bx.to_bits(), "x bits drift: {ax} -> {bx}");
            assert_eq!(ay.to_bits(), by.to_bits(), "y bits drift: {ay} -> {by}");
        });
}

#[test]
fn load_refuses_sketch_file_unsupported_major() {
    let dir = ok_dir();
    let folder = DocumentFolder::new(dir.path().join("bad-sketch-schema.bone"));
    let mut doc = Document::new(document_id(1), "bad".to_owned());
    doc.insert_sketch(sketch_id(1), "S".to_owned(), rectangle());
    assert_save(&doc, &folder);

    let sketch_path = folder.sketch_path(sketch_id(1));
    let text = read_file(&sketch_path);
    let patched = text.replace("major: 1,", "major: 9999,");
    assert_ne!(patched, text, "schema major pattern missing");
    write_file(&sketch_path, &patched);

    let result = load(&folder).map_err(bone_document::FolderError::into_kind);
    let Err(bone_document::FolderErrorKind::UnsupportedMajor { name, found, .. }) = result else {
        panic!("expected UnsupportedMajor on sketch file, got {result:?}");
    };
    assert_eq!(name, "bone-document");
    assert_eq!(found.major, 9999);
}

#[test]
fn load_refuses_tampered_sketch_integrity() {
    let dir = ok_dir();
    let folder = DocumentFolder::new(dir.path().join("neg.bone"));
    let (s, center) = add_point(&Sketch3::on_plane(plane()), 0.0, 0.0);
    let (s, _circle) = s.with_entity(Sketch3Entity::circle(
        center,
        mm(1.0),
        UnitVec3::z_axis(),
        false,
    ));
    let mut doc = Document::new(document_id(1), "neg".to_owned());
    doc.insert_sketch(sketch_id(1), "S".to_owned(), s);
    assert_save(&doc, &folder);

    let sketch_path = folder.sketch_path(sketch_id(1));
    let text = read_file(&sketch_path);
    let patched = text.replace("radius: 0.001,", "radius: -0.001,");
    assert_ne!(patched, text, "radius pattern missing");
    write_file(&sketch_path, &patched);

    let result = load(&folder).map_err(bone_document::FolderError::into_kind);
    let Err(bone_document::FolderErrorKind::SketchIntegrity { source, .. }) = result else {
        panic!("expected SketchIntegrity, got {result:?}");
    };
    assert!(matches!(
        source,
        bone_document::Sketch3IntegrityError::DegenerateEntity(_)
    ));
}

fn patch_header(path: &std::path::Path, f: impl FnOnce(&mut DocumentHeader)) {
    let text = read_file(path);
    let Ok(mut header) = from_str::<DocumentHeader>(&text) else {
        panic!("parse header");
    };
    f(&mut header);
    let Ok(out) = to_string(&header) else {
        panic!("serialize header");
    };
    write_file(path, &out);
}

#[test]
fn load_refuses_dangling_feature_tree_sketch() {
    let dir = ok_dir();
    let folder = DocumentFolder::new(dir.path().join("dangle.bone"));
    let mut doc = Document::new(document_id(1), "dangle".to_owned());
    doc.insert_sketch(sketch_id(1), "S".to_owned(), rectangle());
    assert_save(&doc, &folder);

    patch_header(&folder.document_file(), |h| {
        h.feature_tree.push_sketch(sketch_id(99));
    });

    let result = load(&folder).map_err(bone_document::FolderError::into_kind);
    let Err(bone_document::FolderErrorKind::DanglingTreeSketch { id }) = result else {
        panic!("expected DanglingTreeSketch, got {result:?}");
    };
    assert_eq!(id, sketch_id(99));
}

#[test]
fn load_refuses_dangling_rollback_marker() {
    let dir = ok_dir();
    let folder = DocumentFolder::new(dir.path().join("dangle_rollback.bone"));
    let mut doc = Document::new(document_id(1), "dangle".to_owned());
    doc.insert_sketch(sketch_id(1), "S".to_owned(), rectangle());
    assert_save(&doc, &folder);

    let bogus = feature_id(9999);
    patch_header(&folder.document_file(), |h| {
        h.rollback = RollbackMarker::Above(bogus);
    });

    let result = load(&folder).map_err(bone_document::FolderError::into_kind);
    let Err(bone_document::FolderErrorKind::DanglingRollback { id }) = result else {
        panic!("expected DanglingRollback, got {result:?}");
    };
    assert_eq!(id, bogus);
}

#[test]
fn load_refuses_rollback_marker_on_a_datum() {
    let dir = ok_dir();
    let folder = DocumentFolder::new(dir.path().join("rollback_datum.bone"));
    let mut doc = Document::new(document_id(1), "datum".to_owned());
    doc.insert_sketch(sketch_id(1), "S".to_owned(), rectangle());
    let Some((datum, _)) = doc.feature_tree().iter().next() else {
        panic!("the seeded tree opens with a datum");
    };
    assert_save(&doc, &folder);

    patch_header(&folder.document_file(), |h| {
        h.rollback = RollbackMarker::Above(datum);
    });

    let result = load(&folder).map_err(bone_document::FolderError::into_kind);
    let Err(bone_document::FolderErrorKind::RollbackOnDatum { id }) = result else {
        panic!("expected RollbackOnDatum, got {result:?}");
    };
    assert_eq!(id, datum);
}

#[test]
fn load_refuses_dangling_suppressed_feature() {
    let dir = ok_dir();
    let folder = DocumentFolder::new(dir.path().join("dangle_suppressed.bone"));
    let mut doc = Document::new(document_id(1), "dangle".to_owned());
    doc.insert_sketch(sketch_id(1), "S".to_owned(), rectangle());
    assert_save(&doc, &folder);

    let bogus = feature_id(9999);
    patch_header(&folder.document_file(), |h| {
        h.suppressed.insert(bogus);
    });

    let result = load(&folder).map_err(bone_document::FolderError::into_kind);
    let Err(bone_document::FolderErrorKind::DanglingSuppressed { id }) = result else {
        panic!("expected DanglingSuppressed, got {result:?}");
    };
    assert_eq!(id, bogus);
}

#[test]
fn load_refuses_suppressed_datum_feature() {
    let dir = ok_dir();
    let folder = DocumentFolder::new(dir.path().join("suppressed_datum.bone"));
    let mut doc = Document::new(document_id(1), "datum".to_owned());
    doc.insert_sketch(sketch_id(1), "S".to_owned(), rectangle());
    let Some((datum, _)) = doc.feature_tree().iter().next() else {
        panic!("the seeded tree opens with a datum");
    };
    assert_save(&doc, &folder);

    patch_header(&folder.document_file(), |h| {
        h.suppressed.insert(datum);
    });

    let result = load(&folder).map_err(bone_document::FolderError::into_kind);
    let Err(bone_document::FolderErrorKind::SuppressedDatum { id }) = result else {
        panic!("expected SuppressedDatum, got {result:?}");
    };
    assert_eq!(id, datum);
}

#[test]
fn extrude_roundtrips_through_folder() {
    let dir = ok_dir();
    let folder = DocumentFolder::new(dir.path().join("extrude.bone"));
    let mut doc = Document::new(document_id(1), "extrude".to_owned());
    doc.insert_sketch(sketch_id(1), "S".to_owned(), rectangle());
    doc.insert_extrude(extrude_id(1), blind_extrude(sketch_id(1)));
    assert_save(&doc, &folder);

    assert!(
        folder.extrude_path(extrude_id(1)).exists(),
        "extrude lands in its own file under extrudes/"
    );
    let document_ron = read_file(&folder.document_file());
    assert!(
        !document_ron.contains("ExtrudeFeature"),
        "extrude feature must not inline into document.ron:\n{document_ron}"
    );

    let loaded = assert_load(&folder);
    assert_eq!(loaded, doc, "full document survives the round-trip");
    let Some(feature) = loaded.feature_tree().feature_of_extrude(extrude_id(1)) else {
        panic!("extrude node survives the round-trip");
    };
    assert_eq!(
        loaded.extrude_of_feature(feature),
        Some(&blind_extrude(sketch_id(1)))
    );
    assert_eq!(loaded.feature_tree().edges().len(), 1);
}

#[test]
fn revolve_roundtrips_through_folder() {
    let dir = ok_dir();
    let folder = DocumentFolder::new(dir.path().join("revolve.bone"));
    let mut doc = Document::new(document_id(1), "revolve".to_owned());
    doc.insert_sketch(sketch_id(1), "S".to_owned(), rectangle());
    doc.insert_revolve(revolve_id(1), blind_revolve(sketch_id(1)));
    assert_save(&doc, &folder);

    assert!(
        folder.revolve_path(revolve_id(1)).exists(),
        "revolve lands in its own file under revolves/"
    );
    let document_ron = read_file(&folder.document_file());
    assert!(
        !document_ron.contains("RevolveFeature"),
        "revolve feature must not inline into document.ron:\n{document_ron}"
    );

    let loaded = assert_load(&folder);
    assert_eq!(loaded, doc, "full document survives the round-trip");
    let Some(feature) = loaded.feature_tree().feature_of_revolve(revolve_id(1)) else {
        panic!("revolve node survives the round-trip");
    };
    assert_eq!(
        loaded.revolve_of_feature(feature),
        Some(&blind_revolve(sketch_id(1)))
    );
    assert_eq!(loaded.feature_tree().edges().len(), 1);
}

#[test]
fn renamed_extrude_label_survives_folder_roundtrip() {
    let dir = ok_dir();
    let folder = DocumentFolder::new(dir.path().join("labeled.bone"));
    let mut doc = Document::new(document_id(1), "labeled".to_owned());
    doc.insert_sketch(sketch_id(1), "S".to_owned(), rectangle());
    doc.insert_extrude(extrude_id(1), blind_extrude(sketch_id(1)));
    let Ok(()) = doc.rename_extrude(extrude_id(1), "Boss") else {
        panic!("rename accepts a non-empty label");
    };
    assert_save(&doc, &folder);

    let loaded = assert_load(&folder);
    assert_eq!(
        loaded.extrude_label(extrude_id(1)),
        Some("Boss"),
        "the file's stored label is read back, not recomputed from the id",
    );
}

#[test]
fn two_extrudes_keep_canonical_edge_order_through_folder() {
    let dir = ok_dir();
    let folder = DocumentFolder::new(dir.path().join("two_extrude.bone"));
    let mut doc = Document::new(document_id(1), "two".to_owned());
    doc.insert_sketch(sketch_id(1), "S".to_owned(), rectangle());
    doc.insert_extrude(extrude_id(5), blind_extrude(sketch_id(1)));
    doc.insert_extrude(extrude_id(2), blind_extrude(sketch_id(1)));
    assert_save(&doc, &folder);

    let loaded = assert_load(&folder);
    assert_eq!(
        loaded.feature_tree(),
        doc.feature_tree(),
        "save then load must preserve feature-tree equality with multiple edges"
    );
    assert_eq!(loaded.feature_tree().edges().len(), 2);
}

#[test]
fn load_refuses_tree_extrude_without_file() {
    let dir = ok_dir();
    let folder = DocumentFolder::new(dir.path().join("missing_extrude.bone"));
    let mut doc = Document::new(document_id(1), "missing".to_owned());
    doc.insert_sketch(sketch_id(1), "S".to_owned(), rectangle());
    doc.insert_extrude(extrude_id(1), blind_extrude(sketch_id(1)));
    assert_save(&doc, &folder);

    let Ok(()) = std::fs::remove_file(folder.extrude_path(extrude_id(1))) else {
        panic!("remove extrude file");
    };

    let result = load(&folder).map_err(bone_document::FolderError::into_kind);
    let Err(bone_document::FolderErrorKind::MissingExtrudeFile { id }) = result else {
        panic!("expected MissingExtrudeFile, got {result:?}");
    };
    assert_eq!(id, extrude_id(1));
}

#[test]
fn removing_extrude_drops_stale_file() {
    let dir = ok_dir();
    let folder = DocumentFolder::new(dir.path().join("drop_extrude.bone"));
    let mut doc = Document::new(document_id(1), "drop".to_owned());
    doc.insert_sketch(sketch_id(1), "S".to_owned(), rectangle());
    doc.insert_extrude(extrude_id(1), blind_extrude(sketch_id(1)));
    assert_save(&doc, &folder);
    assert!(folder.extrude_path(extrude_id(1)).exists());

    doc.remove_extrude(extrude_id(1));
    assert_save(&doc, &folder);
    assert!(!folder.extrude_path(extrude_id(1)).exists());

    let loaded = assert_load(&folder);
    assert!(
        loaded
            .feature_tree()
            .feature_of_extrude(extrude_id(1))
            .is_none()
    );
}

#[test]
fn load_refuses_extrude_with_unregistered_sketch() {
    let dir = ok_dir();
    let folder = DocumentFolder::new(dir.path().join("ghost_sketch.bone"));
    let mut doc = Document::new(document_id(1), "ghost".to_owned());
    doc.insert_extrude(extrude_id(1), blind_extrude(sketch_id(99)));
    assert_save(&doc, &folder);

    let result = load(&folder).map_err(bone_document::FolderError::into_kind);
    let Err(bone_document::FolderErrorKind::DanglingExtrudeSketch { extrude, sketch }) = result
    else {
        panic!("expected DanglingExtrudeSketch, got {result:?}");
    };
    assert_eq!(extrude, extrude_id(1));
    assert_eq!(sketch, sketch_id(99));
}

#[test]
fn load_refuses_orphan_registered_sketch() {
    let dir = ok_dir();
    let folder = DocumentFolder::new(dir.path().join("orphan.bone"));
    let mut doc = Document::new(document_id(1), "orphan".to_owned());
    doc.insert_sketch(sketch_id(1), "S".to_owned(), rectangle());
    assert_save(&doc, &folder);

    patch_header(&folder.document_file(), |h| {
        h.feature_tree.remove_sketch(sketch_id(1));
    });

    let result = load(&folder).map_err(bone_document::FolderError::into_kind);
    let Err(bone_document::FolderErrorKind::OrphanRegistered { id }) = result else {
        panic!("expected OrphanRegistered, got {result:?}");
    };
    assert_eq!(id, sketch_id(1));
}

fn closed_rectangle(width_mm: f64) -> Sketch3 {
    let (s, p0) = add_point(&Sketch3::on_plane(plane()), 0.0, 0.0);
    let (s, p1) = add_point(&s, width_mm, 0.0);
    let (s, p2) = add_point(&s, width_mm, 5.0);
    let (s, p3) = add_point(&s, 0.0, 5.0);
    [(p0, p1), (p1, p2), (p2, p3), (p3, p0)]
        .into_iter()
        .fold(s, |s, (a, b)| add_line(&s, a, b))
}

fn evaluated_solid(doc: &Document, sketch: SketchId, extrude: ExtrudeId) -> bone_kernel::BrepSolid {
    let Some(sketch_value) = doc.sketch3(sketch) else {
        panic!("sketch present");
    };
    let Some(feature_id) = doc.feature_tree().feature_of_extrude(extrude) else {
        panic!("extrude node present");
    };
    let Some(feature) = doc.extrude_of_feature(feature_id) else {
        panic!("extrude feature present");
    };
    let evaluated = evaluate_sketch(sketch_value);
    let extruded = evaluate_extrude(feature_id, &evaluated, feature);
    let Some(solid) = extruded.solid() else {
        panic!("rectangle extrudes to a solid");
    };
    solid.clone()
}

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

#[test]
fn width_change_yields_text_diff_and_new_blob() {
    let dir = ok_dir();
    let folder = DocumentFolder::new(dir.path().join("edit.bone"));
    let sid = sketch_id(1);
    let eid = extrude_id(1);
    let mut doc = Document::new(document_id(1), "edit".to_owned());
    doc.insert_sketch(sid, "S".to_owned(), closed_rectangle(10.0));
    doc.insert_extrude(eid, blind_extrude(sid));
    assert_save(&doc, &folder);

    let document_before = read_file(&folder.document_file());
    let sketch_before = read_file(&folder.sketch_path(sid));
    let solid_before = evaluated_solid(&doc, sid, eid);
    let Ok(hash_before) = bone_document::write_solid(&folder, &solid_before) else {
        panic!("cache first solid");
    };

    doc.replace_sketch(sid, closed_rectangle(14.0));
    assert_save(&doc, &folder);

    let document_after = read_file(&folder.document_file());
    let sketch_after = read_file(&folder.sketch_path(sid));
    let solid_after = evaluated_solid(&doc, sid, eid);
    let Ok(hash_after) = bone_document::write_solid(&folder, &solid_after) else {
        panic!("cache widened solid");
    };

    assert_ne!(
        sketch_before, sketch_after,
        "a width edit must change the sketch file"
    );
    assert_eq!(
        document_before, document_after,
        "a sketch-data edit must not churn document.ron"
    );
    let Ok(raw) = std::fs::read(folder.sketch_path(sid)) else {
        panic!("read sketch bytes");
    };
    assert!(
        std::str::from_utf8(&raw).is_ok() && !raw.contains(&0u8),
        "sketch file stays text, no binary noise"
    );

    assert_ne!(hash_before, hash_after, "wider box content-addresses anew");
    assert!(
        folder.blob_path(hash_before, BlobKind::BREP).exists(),
        "previous geometry blob survives content-addressing"
    );
    assert!(
        folder.blob_path(hash_after, BlobKind::BREP).exists(),
        "new geometry blob is written"
    );
    assert_eq!(
        face_labels(&solid_before),
        face_labels(&solid_after),
        "a width change preserves face labels, so blob churn is geometry-only"
    );
}

fn sample_solid(feature: FeatureId) -> bone_kernel::BrepSolid {
    let evaluated = evaluate_sketch(&closed_rectangle(10.0));
    let extruded = evaluate_extrude(feature, &evaluated, &blind_extrude(SketchId::null()));
    let Some(solid) = extruded.solid() else {
        panic!("rectangle extrudes to a solid");
    };
    solid.clone()
}

#[test]
fn imported_body_id_stays_reserved_across_roundtrip() {
    let dir = ok_dir();
    let folder = DocumentFolder::new(dir.path().join("body.bone"));

    let mut doc = Document::new(document_id(1), "body".to_owned());
    let Ok((_, first)) = doc.import_body(|feature| Ok::<_, ()>(sample_solid(feature))) else {
        panic!("import the first body");
    };
    assert_save(&doc, &folder);

    let mut reloaded = assert_load(&folder);
    let Ok((_, second)) = reloaded.import_body(|feature| Ok::<_, ()>(sample_solid(feature))) else {
        panic!("import a body after reload");
    };
    assert_ne!(
        first, second,
        "a body id allocated before the save is not handed out again after the load"
    );
}

#[test]
fn blob_path_matches_locked_shape() {
    let folder = DocumentFolder::new("/tmp/part.bone");
    let hash = BlobHash::of(b"example");
    let path = folder.blob_path(hash, BlobKind::STEP);
    let Ok(suffix) = path.strip_prefix("/tmp/part.bone/blobs") else {
        panic!("blob under blobs/: {}", path.display());
    };
    let components: Vec<String> = suffix
        .components()
        .map(|c| c.as_os_str().to_string_lossy().into_owned())
        .collect();
    assert_eq!(
        components.len(),
        2,
        "<aa>/<rest>.<kind>: got {components:?}"
    );
    assert_eq!(components[0].len(), 2, "fanout is 2 hex chars");
    let Some(stem) = components[1].strip_suffix(".step") else {
        panic!("extension is .step: {}", components[1]);
    };
    assert_eq!(stem.len(), 30, "remaining 30 hex chars");
    let hex = hash.truncated_128_hex();
    assert_eq!(components[0], hex[..2]);
    assert_eq!(stem, &hex[2..]);
}

#[test]
fn phase_three_history_state_round_trips_through_the_folder() {
    let dir = ok_dir();
    let folder = DocumentFolder::new(dir.path().join("phase3.bone"));

    let base_sketch = sketch_id(1);
    let base_extrude = extrude_id(1);
    let cap_sketch = sketch_id(2);
    let cap_extrude = extrude_id(2);

    let mut doc = Document::new(document_id(1), "phase3".to_owned());
    doc.insert_sketch(base_sketch, "Base".to_owned(), rectangle());
    doc.insert_extrude(base_extrude, blind_extrude(base_sketch));
    let Some(base_body) = doc.feature_tree().feature_of_extrude(base_extrude) else {
        panic!("base extrude feature present");
    };

    doc.insert_sketch(cap_sketch, "Top".to_owned(), rectangle());
    let face = sample_face_ref(base_body);
    let Ok(()) = doc.bind_sketch_to_face(cap_sketch, face) else {
        panic!("the face binding is acyclic");
    };
    doc.insert_extrude(cap_extrude, separate_extrude(cap_sketch));

    let Some(cap_sketch_feature) = doc.feature_tree().feature_of_sketch(cap_sketch) else {
        panic!("cap sketch feature present");
    };
    let Some(cap_extrude_feature) = doc.feature_tree().feature_of_extrude(cap_extrude) else {
        panic!("cap extrude feature present");
    };
    doc.roll_to_here(cap_sketch_feature);
    doc.suppress(cap_extrude_feature);

    assert_save(&doc, &folder);
    let loaded = assert_load(&folder);

    assert_eq!(loaded, doc, "every phase-3 state survives the round-trip");
    assert_eq!(
        loaded.sketch_plane_binding(cap_sketch),
        Some(bone_document::SketchPlaneRef::Face(face)),
        "the face ref held by the bound sketch persists",
    );
    assert_eq!(
        loaded.rollback(),
        RollbackMarker::Above(cap_sketch_feature),
        "the rollback marker persists, keyed by a stable feature id",
    );
    assert_eq!(
        loaded.suppression_state(cap_extrude_feature),
        SuppressionState::Suppressed,
        "the suppressed set persists",
    );
    assert_eq!(
        loaded.suppression_state(base_body),
        SuppressionState::Active,
        "a feature absent from the suppressed set loads active",
    );

    let before = folder_bytes(folder.path());
    assert_save(&loaded, &folder);
    let after = folder_bytes(folder.path());
    assert_eq!(
        before, after,
        "saving the reloaded document reproduces every file byte for byte",
    );
}

#[test]
fn phase_three_header_ron_surface() {
    let base_sketch = sketch_id(1);
    let base_extrude = extrude_id(1);
    let cap_sketch = sketch_id(2);
    let cap_extrude = extrude_id(2);

    let mut doc = Document::new(document_id(1), "phase3".to_owned());
    doc.insert_sketch(base_sketch, "Base".to_owned(), rectangle());
    doc.insert_extrude(base_extrude, blind_extrude(base_sketch));
    let Some(base_body) = doc.feature_tree().feature_of_extrude(base_extrude) else {
        panic!("base extrude feature present");
    };

    doc.insert_sketch(cap_sketch, "Top".to_owned(), rectangle());
    let Ok(()) = doc.bind_sketch_to_face(cap_sketch, sample_face_ref(base_body)) else {
        panic!("the face binding is acyclic");
    };
    doc.insert_extrude(cap_extrude, separate_extrude(cap_sketch));

    let Some(cap_sketch_feature) = doc.feature_tree().feature_of_sketch(cap_sketch) else {
        panic!("cap sketch feature present");
    };
    let Some(cap_extrude_feature) = doc.feature_tree().feature_of_extrude(cap_extrude) else {
        panic!("cap extrude feature present");
    };
    doc.roll_to_here(cap_sketch_feature);
    doc.suppress(cap_extrude_feature);

    let Ok(ron) = to_string(doc.header()) else {
        panic!("ron");
    };
    insta::assert_snapshot!("phase_three_header", ron);
}

#[test]
fn datum_features_round_trip_through_the_folder() {
    let dir = ok_dir();
    let folder = DocumentFolder::new(dir.path().join("datum.bone"));
    let mut doc = Document::new(document_id(1), "datum".to_owned());

    let (Some(xy), Some(yz)) = (
        doc.feature_tree()
            .principal_plane_feature(PrincipalPlane::Xy),
        doc.feature_tree()
            .principal_plane_feature(PrincipalPlane::Yz),
    ) else {
        panic!("the seeded tree carries the principal planes");
    };

    let mount_plane = doc.commit_datum(DatumFeature::plane(PlaneRecipe::Offset {
        from: DatumTarget::Feature(xy),
        distance: mm(25.0),
        flip: false,
    }));
    let axis = doc.commit_datum(DatumFeature::axis(AxisRecipe::TwoPlanes {
        a: DatumTarget::Feature(xy),
        b: DatumTarget::Feature(yz),
    }));
    let Ok(()) = doc.rename_datum(mount_plane, "Mounting Plane") else {
        panic!("rename accepts a non-empty label");
    };

    let sketch = doc.allocate_sketch();
    doc.insert_sketch(sketch, "On Datum".to_owned(), Sketch3::on_plane(plane()));
    let Some(plane_feature) = doc.feature_tree().feature_of_datum(mount_plane) else {
        panic!("the datum plane resolves to a feature node");
    };
    let Ok(()) = doc.bind_sketch_to_datum_plane(sketch, plane_feature) else {
        panic!("binding a sketch onto a datum plane is acyclic");
    };

    assert_save(&doc, &folder);
    let loaded = assert_load(&folder);

    assert_eq!(loaded, doc, "every datum survives the folder round-trip");
    assert_eq!(loaded.datum_label(mount_plane), Some("Mounting Plane"));
    assert_eq!(loaded.datum_label(axis), Some("Axis1"));
    assert_eq!(
        loaded.sketch_plane_binding(sketch),
        Some(SketchPlaneRef::Datum(plane_feature)),
        "the sketch keeps its datum-plane binding across the round-trip",
    );
}
