use bone_document::{
    BlobHash, BlobKind, Document, DocumentFolder, DocumentHeader, EditOutcome, Sketch,
    SketchDimension, SketchEdit, SketchEntity, SketchRegistry, SketchRegistryEntry, SketchRelation,
    evaluate_extrude, evaluate_sketch, from_str, load, save, to_string,
};
use bone_kernel::{
    BrepFace, ExtrudeDirection, ExtrudeEndCondition, ExtrudeFeature, ExtrudeSense, MergeResult,
};
use bone_types::{
    Angle, DocumentId, ExtrudeId, Length, Point2, Point3, PositiveLength, SketchEntityId, SketchId,
    SketchPlaneBasis, 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 blind_extrude(sketch: SketchId) -> ExtrudeFeature {
    let Ok(depth) = PositiveLength::new(mm(10.0)) 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 rectangle() -> Sketch {
    let script = [
        SketchEdit::AddEntity(SketchEntity::point(Point2::from_mm(0.0, 0.0))),
        SketchEdit::AddEntity(SketchEntity::point(Point2::from_mm(10.0, 0.0))),
        SketchEdit::AddEntity(SketchEntity::point(Point2::from_mm(10.0, 5.0))),
        SketchEdit::AddEntity(SketchEntity::point(Point2::from_mm(0.0, 5.0))),
    ];
    let Ok((s, _)) = Sketch::new(plane()).apply_all(script) else {
        panic!("rectangle");
    };
    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());
    };
}

#[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 add_entity_returning_id(s: Sketch, entity: SketchEntity) -> (Sketch, SketchEntityId) {
    let Ok((next, EditOutcome::Entity(id))) = s.apply(SketchEdit::AddEntity(entity)) else {
        panic!("add entity {entity:?}");
    };
    (next, id)
}

fn rich_sketch_with_every_variant() -> Sketch {
    let s = Sketch::new(plane());
    let (s, p0) = add_entity_returning_id(s, SketchEntity::point(Point2::from_mm(0.0, 0.0)));
    let (s, p1) = add_entity_returning_id(s, SketchEntity::point(Point2::from_mm(10.0, 0.0)));
    let (s, p2) = add_entity_returning_id(s, SketchEntity::point(Point2::from_mm(5.0, 6.0)));
    let (s, line) = add_entity_returning_id(s, SketchEntity::line(p0, p1, false));
    let (s, line2) = add_entity_returning_id(s, SketchEntity::line(p0, p2, true));
    let (s, arc) = add_entity_returning_id(s, SketchEntity::arc(p0, p1, p2, false));
    let (s, circle) = add_entity_returning_id(s, SketchEntity::circle(p0, mm(3.0), false));
    let relations = [
        SketchRelation::Coincident(p0, line),
        SketchRelation::Horizontal(line),
        SketchRelation::Vertical(line2),
        SketchRelation::Parallel(line, line2),
        SketchRelation::Perpendicular(line, line2),
        SketchRelation::Tangent(line, circle),
        SketchRelation::Equal(line, line2),
        SketchRelation::Concentric(arc, circle),
        SketchRelation::Midpoint { point: p2, line },
        SketchRelation::Fix(p0),
    ];
    let s = relations.into_iter().fold(s, |s, relation| {
        let Ok((next, _)) = s.apply(SketchEdit::AddRelation(relation)) else {
            panic!("relation {relation:?}");
        };
        next
    });
    let dimensions = [
        SketchDimension::Linear {
            a: p0,
            b: p1,
            value: mm(10.0),
            kind: bone_document::DimensionKind::Driving,
        },
        SketchDimension::Radius {
            target: circle,
            value: mm(3.0),
            kind: bone_document::DimensionKind::Driven,
        },
        SketchDimension::Diameter {
            target: circle,
            value: mm(6.0),
            kind: bone_document::DimensionKind::Driving,
        },
        SketchDimension::Angular {
            a: line,
            b: line2,
            value: deg(45.0),
            kind: bone_document::DimensionKind::Driving,
        },
    ];
    dimensions.into_iter().fold(s, |s, dim| {
        let Ok((next, _)) = s.apply(SketchEdit::AddDimension(dim)) else {
            panic!("dim {dim:?}");
        };
        next
    })
}

#[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.sketch(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, 1));
    assert_eq!(
        supported,
        SchemaVersion::new(
            SchemaHeader::BONE_DOCUMENT_MAJOR,
            SchemaHeader::BONE_DOCUMENT_MINOR
        )
    );
}

#[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 mut s = Sketch::new(plane());
    let Ok((s_next, EditOutcome::Entity(a))) = s.apply(SketchEdit::AddEntity(SketchEntity::point(
        Point2::from_mm(0.0, 0.0),
    ))) else {
        panic!("a");
    };
    s = s_next;
    let Ok((s_next, EditOutcome::Entity(b))) = s.apply(SketchEdit::AddEntity(SketchEntity::point(
        Point2::from_mm(1.0, 0.0),
    ))) else {
        panic!("b");
    };
    s = s_next;
    let Ok((s_next, _)) = s.apply(SketchEdit::AddEntity(SketchEntity::line(a, b, false))) else {
        panic!("line");
    };
    s = s_next;

    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.sketch(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 feature_tree_bytes_survive_insert_remove_insert_cycle() {
    let dir = ok_dir();
    let with_history = DocumentFolder::new(dir.path().join("hist.bone"));
    let fresh = DocumentFolder::new(dir.path().join("fresh.bone"));

    let rect = rectangle();
    let mut doc_a = Document::new(document_id(1), "doc".to_owned());
    doc_a.insert_sketch(sketch_id(1), "S".to_owned(), rect.clone());
    doc_a.insert_sketch(sketch_id(2), "T".to_owned(), rect.clone());
    doc_a.remove_sketch(sketch_id(2));
    assert_save(&doc_a, &with_history);

    let mut doc_b = Document::new(document_id(1), "doc".to_owned());
    doc_b.insert_sketch(sketch_id(1), "S".to_owned(), rect);
    assert_save(&doc_b, &fresh);

    let bytes_a = read_file(&with_history.document_file());
    let bytes_b = read_file(&fresh.document_file());
    assert_eq!(
        bytes_a, bytes_b,
        "remove then re-insert leaks tombstones into document.ron"
    );
}

#[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()
        .try_fold(Sketch::new(plane()), |s, (x, y)| {
            s.apply(SketchEdit::AddEntity(SketchEntity::point(Point2::from_mm(
                x, y,
            ))))
            .map(|(next, _)| next)
        });
    let Ok(sketch) = sketch else {
        panic!("apply points");
    };
    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.sketch(sketch_id(1)) else {
        panic!("sketch missing");
    };
    let coords = |s: &Sketch| -> Vec<(f64, f64)> {
        s.entity_order()
            .iter()
            .map(|id| match s.entities()[*id] {
                SketchEntity::Point(p) => p.at().coords_mm(),
                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 Ok((s, EditOutcome::Entity(center))) = Sketch::new(plane()).apply(SketchEdit::AddEntity(
        SketchEntity::point(Point2::from_mm(0.0, 0.0)),
    )) else {
        panic!("center");
    };
    let Ok((s, _)) = s.apply(SketchEdit::AddEntity(SketchEntity::circle(
        center,
        mm(1.0),
        false,
    ))) else {
        panic!("circle");
    };
    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::SketchEditError::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 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 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) -> Sketch {
    let s = Sketch::new(plane());
    let (s, p0) = add_entity_returning_id(s, SketchEntity::point(Point2::from_mm(0.0, 0.0)));
    let (s, p1) = add_entity_returning_id(s, SketchEntity::point(Point2::from_mm(width_mm, 0.0)));
    let (s, p2) = add_entity_returning_id(s, SketchEntity::point(Point2::from_mm(width_mm, 5.0)));
    let (s, p3) = add_entity_returning_id(s, SketchEntity::point(Point2::from_mm(0.0, 5.0)));
    [(p0, p1), (p1, p2), (p2, p3), (p3, p0)]
        .into_iter()
        .fold(s, |s, (a, b)| {
            let Ok((next, _)) = s.apply(SketchEdit::AddEntity(SketchEntity::line(a, b, false)))
            else {
                panic!("rectangle edge");
            };
            next
        })
}

fn evaluated_solid(doc: &Document, sketch: SketchId, extrude: ExtrudeId) -> bone_kernel::BrepSolid {
    let Some(sketch_value) = doc.sketch(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"
    );
}

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