use std::process::Command;

use bone_document::{
    BlobKind, DimensionKind, Document, DocumentFolder, EditOutcome, Sketch, SketchDimension,
    SketchEdit, SketchEntity, evaluate_extrude, evaluate_sketch, load, save, write_solid,
};
use bone_kernel::{
    BrepSolid, ExtrudeDirection, ExtrudeEndCondition, ExtrudeFeature, ExtrudeSense, MergeResult,
};
use bone_types::{
    DocumentId, ExtrudeId, Length, Point2, Point3, PositiveLength, SketchId, SketchPlaneBasis,
    Tolerance, UnitVec3, millimeter,
};
use slotmap::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
}

fn mm(v: f64) -> Length {
    Length::new::<millimeter>(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 jj_available() -> bool {
    let Ok(out) = Command::new("jj").arg("--version").output() else {
        return false;
    };
    out.status.success()
}

fn run(cmd: &mut Command) -> String {
    let Ok(out) = cmd.output() else {
        panic!("{cmd:?}");
    };
    assert!(
        out.status.success(),
        "{cmd:?} failed: {}\n{}",
        String::from_utf8_lossy(&out.stdout),
        String::from_utf8_lossy(&out.stderr),
    );
    String::from_utf8_lossy(&out.stdout).into_owned()
}

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

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

#[test]
fn jj_diff_on_dimension_edit_is_text_only() {
    if !jj_available() {
        eprintln!("skip: jj not on PATH");
        return;
    }

    let dir = ok_dir();
    let folder = DocumentFolder::new(dir.path().join("demo.bone"));

    let mut doc = Document::new(document_id(1), "demo".to_owned());
    let sid = sketch_id(1);
    let rect = rectangle();
    let Some(&a) = rect.entity_order().first() else {
        panic!("rect has points");
    };
    let Some(&b) = rect.entity_order().get(1) else {
        panic!("rect has points");
    };
    let Ok((rect, EditOutcome::Dimension(dim_id))) =
        rect.apply(SketchEdit::AddDimension(SketchDimension::Linear {
            a,
            b,
            value: mm(10.0),
            kind: DimensionKind::Driving,
        }))
    else {
        panic!("dim");
    };
    doc.insert_sketch(sid, "Sketch1".to_owned(), rect.clone());
    assert_save(&doc, &folder);

    run(Command::new("jj")
        .arg("git")
        .arg("init")
        .arg("--colocate")
        .current_dir(folder.path()));
    run(Command::new("jj")
        .arg("config")
        .arg("set")
        .arg("--repo")
        .arg("user.name")
        .arg("test")
        .current_dir(folder.path()));
    run(Command::new("jj")
        .arg("config")
        .arg("set")
        .arg("--repo")
        .arg("user.email")
        .arg("test@example.com")
        .current_dir(folder.path()));
    run(Command::new("jj")
        .arg("describe")
        .arg("--message")
        .arg("initial")
        .current_dir(folder.path()));
    run(Command::new("jj").arg("new").current_dir(folder.path()));

    let reopened = assert_load(&folder);
    let Some(sketch) = reopened.sketch(sid) else {
        panic!("sketch missing");
    };
    let Ok((updated_sketch, _)) = sketch.clone().apply(SketchEdit::UpdateDimensionValue {
        id: dim_id,
        value: bone_document::DimensionValue::Length(mm(12.5)),
    }) else {
        panic!("update dim");
    };
    let mut next_doc = reopened.clone();
    next_doc.replace_sketch(sid, updated_sketch);
    assert_save(&next_doc, &folder);

    let diff = run(Command::new("jj")
        .arg("diff")
        .arg("--git")
        .current_dir(folder.path()));
    assert!(
        diff.contains("sketches/"),
        "expected sketch file in diff:\n{diff}"
    );
    assert!(
        !diff.contains("document.ron"),
        "a dimension edit must leave the recipe stable, only the sketch file changes:\n{diff}"
    );
    assert!(
        diff.contains('+') && diff.contains('-'),
        "expected text diff markers:\n{diff}"
    );
    assert!(
        !diff.contains("Binary files"),
        "expected no binary diff:\n{diff}"
    );
}

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

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

fn closed_rectangle() -> Sketch {
    let s = Sketch::new(plane());
    let (s, p0) = add_point(s, 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);
    [(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 blind_extrude(sketch: SketchId, depth_mm: f64) -> ExtrudeFeature {
    let Ok(depth) = PositiveLength::new(mm(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 evaluated_solid(doc: &Document, sketch: SketchId, extrude: ExtrudeId) -> 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()
}

#[test]
fn jj_diff_on_extrude_edit_is_text_only() {
    if !jj_available() {
        eprintln!("skip: jj not on PATH");
        return;
    }

    let dir = ok_dir();
    let folder = DocumentFolder::new(dir.path().join("slab.bone"));
    let sid = sketch_id(1);
    let eid = extrude_id(1);

    let mut doc = Document::new(document_id(1), "slab".to_owned());
    doc.insert_sketch(sid, "Sketch1".to_owned(), closed_rectangle());
    doc.insert_extrude(eid, blind_extrude(sid, 10.0));
    assert_save(&doc, &folder);
    let Ok(_baseline_blob) = write_solid(&folder, &evaluated_solid(&doc, sid, eid)) else {
        panic!("cache the depth-10 solid");
    };

    run(Command::new("jj")
        .arg("git")
        .arg("init")
        .arg("--colocate")
        .current_dir(folder.path()));
    run(Command::new("jj")
        .arg("config")
        .arg("set")
        .arg("--repo")
        .arg("user.name")
        .arg("test")
        .current_dir(folder.path()));
    run(Command::new("jj")
        .arg("config")
        .arg("set")
        .arg("--repo")
        .arg("user.email")
        .arg("test@example.com")
        .current_dir(folder.path()));
    run(Command::new("jj")
        .arg("describe")
        .arg("--message")
        .arg("initial")
        .current_dir(folder.path()));
    run(Command::new("jj").arg("new").current_dir(folder.path()));

    let document_before = std::fs::read_to_string(folder.document_file()).unwrap_or_default();

    let mut next = assert_load(&folder);
    next.insert_extrude(eid, blind_extrude(sid, 14.0));
    assert_save(&next, &folder);
    let Ok(edited_blob) = write_solid(&folder, &evaluated_solid(&next, sid, eid)) else {
        panic!("cache the depth-14 solid");
    };

    let document_after = std::fs::read_to_string(folder.document_file()).unwrap_or_default();
    assert_eq!(
        document_before, document_after,
        "an extrude-depth edit must not churn document.ron; geometry is not in the recipe"
    );
    assert!(
        folder.blob_path(edited_blob, BlobKind::BREP).exists(),
        "the widened slab content-addresses a fresh brep blob"
    );

    let diff = run(Command::new("jj")
        .arg("diff")
        .arg("--git")
        .current_dir(folder.path()));
    assert!(
        diff.contains("extrudes/"),
        "expected the extrude file in the diff:\n{diff}"
    );
    assert!(
        diff.contains(".brep"),
        "expected a new geometry blob in the diff:\n{diff}"
    );
    assert!(
        diff.contains('+') && diff.contains('-'),
        "expected text diff markers:\n{diff}"
    );
    assert!(
        !diff.contains("Binary files"),
        "expected no binary diff:\n{diff}"
    );
}
