use std::collections::{BTreeMap, BTreeSet};

use bone_kernel::{BrepSolid, ExtrudeFeature};
use bone_types::{
    BodyId, BuildFailure, FaceRef, FeatureId, GeometryGeneration, RebuildError, RebuildStatus,
    RollbackMarker,
};

use crate::Sketch;
use crate::document::{Document, FeatureNode, FeatureTree};
use crate::evaluator::{
    EvaluatedExtrude, EvaluatedSketch, ExtrudeError, evaluate_extrude, evaluate_sketch,
};
use crate::matcher::unique_face;

#[derive(Copy, Clone, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct RebuildPass(u64);

impl RebuildPass {
    #[must_use]
    const fn succ(self) -> Self {
        Self(self.0.saturating_add(1))
    }
}

#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub enum RecomputeScope {
    Full,
    Edited(FeatureId),
}

#[derive(Copy, Clone, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct RebuildCost(usize);

#[derive(Copy, Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct RebuildBudget(usize);

impl RebuildBudget {
    pub const INTERACTIVE: Self = Self(2048);

    #[must_use]
    pub const fn new(limit: usize) -> Self {
        Self(limit)
    }
}

impl RebuildCost {
    #[must_use]
    pub const fn new(weight: usize) -> Self {
        Self(weight)
    }

    #[must_use]
    pub const fn within(self, budget: RebuildBudget) -> bool {
        self.0 <= budget.0
    }
}

#[derive(Copy, Clone, Debug, PartialEq, Eq)]
enum SkipState {
    Active,
    Suppressed,
    RolledBack,
}

#[derive(Clone)]
enum NodeOutput {
    Datum,
    Sketch(EvaluatedSketch),
    Extrude(EvaluatedExtrude),
    Imported(BrepSolid),
    Unbuilt,
}

#[derive(Clone, PartialEq)]
enum ParentDigest {
    Datum,
    Sketch(EvaluatedSketch),
    Geometry(GeometryGeneration),
    Absent,
}

#[derive(Clone, PartialEq)]
enum PayloadKey {
    Datum,
    Sketch(Sketch, Option<FaceRef>),
    Extrude(Option<ExtrudeFeature>),
    Imported(BodyId),
}

#[derive(Clone, PartialEq)]
struct NodeFingerprint {
    payload: PayloadKey,
    parents: Vec<(FeatureId, ParentDigest)>,
}

struct NodeMemo {
    fingerprint: NodeFingerprint,
    output: NodeOutput,
    digest: ParentDigest,
    skip: SkipState,
    status: RebuildStatus,
    built_at: RebuildPass,
}

type Memo = BTreeMap<FeatureId, NodeMemo>;

#[derive(Default)]
pub struct EvaluatedModel {
    memo: Memo,
    order: Vec<FeatureId>,
    pass: RebuildPass,
}

impl EvaluatedModel {
    #[must_use]
    pub fn new() -> Self {
        Self::default()
    }

    pub fn recompute(
        &mut self,
        document: &Document,
        suppressed: &BTreeSet<FeatureId>,
        rollback: RollbackMarker,
        scope: RecomputeScope,
    ) {
        let tree = document.feature_tree();
        debug_assert!(
            tree.find_cycle().is_none(),
            "recompute requires an acyclic feature graph",
        );
        debug_assert!(
            tree.order_violation().is_none(),
            "recompute requires every parent to precede its child in feature order",
        );
        let live: BTreeSet<FeatureId> = tree.iter().map(|(id, _)| id).collect();
        let rolled = tree.rolled_back(rollback);
        let suppressed = closed_over_descendants(tree, suppressed);
        let pass = self.pass.succ();

        let retained: Memo = core::mem::take(&mut self.memo)
            .into_iter()
            .filter(|(id, _)| live.contains(id))
            .collect();
        let dirty = dirty_set(tree, &live, &retained, &suppressed, &rolled, scope);
        let order = tree.topo_order();

        self.memo = order.iter().fold(retained, |mut memo, &id| {
            if !dirty.contains(&id) {
                return memo;
            }
            let Some(node) = tree.node(id) else {
                return memo;
            };
            let fingerprint = NodeFingerprint {
                payload: payload_key(document, node),
                parents: parent_digests(tree, id, &memo),
            };
            let skip = skip_state(id, &suppressed, &rolled);
            let unchanged = memo
                .get(&id)
                .is_some_and(|entry| entry.fingerprint == fingerprint && entry.skip == skip);
            if !unchanged {
                let entry = build_entry(document, id, node, &memo, fingerprint, skip, pass);
                memo.insert(id, entry);
            }
            memo
        });
        self.order = order;
        self.pass = pass;
    }

    #[must_use]
    pub fn status(&self, id: FeatureId) -> Option<RebuildStatus> {
        self.memo.get(&id).map(|entry| entry.status)
    }

    #[must_use]
    pub fn body(&self, id: FeatureId) -> Option<&BrepSolid> {
        self.memo
            .get(&id)
            .and_then(|entry| output_solid(&entry.output))
    }

    pub fn bodies(&self) -> impl Iterator<Item = (FeatureId, &BrepSolid)> + '_ {
        self.order
            .iter()
            .filter_map(move |&id| self.body(id).map(|solid| (id, solid)))
    }

    #[must_use]
    pub fn built_at(&self, id: FeatureId) -> Option<RebuildPass> {
        self.memo.get(&id).map(|entry| entry.built_at)
    }

    #[must_use]
    pub fn rebuild_cost(&self) -> RebuildCost {
        RebuildCost(
            self.bodies()
                .map(|(_, solid)| solid.iter_faces().count() + solid.iter_edges().count())
                .sum(),
        )
    }
}

fn closed_over_descendants(tree: &FeatureTree, seed: &BTreeSet<FeatureId>) -> BTreeSet<FeatureId> {
    seed.iter()
        .flat_map(|&feature| tree.descendants(feature))
        .chain(seed.iter().copied())
        .collect()
}

fn dirty_set(
    tree: &FeatureTree,
    live: &BTreeSet<FeatureId>,
    retained: &Memo,
    suppressed: &BTreeSet<FeatureId>,
    rolled: &BTreeSet<FeatureId>,
    scope: RecomputeScope,
) -> BTreeSet<FeatureId> {
    let scoped = match scope {
        RecomputeScope::Full => live.clone(),
        RecomputeScope::Edited(root) => subtree(tree, root),
    };
    let appeared = live.iter().copied().filter(|id| !retained.contains_key(id));
    let reskinned = retained
        .iter()
        .filter(|(id, entry)| entry.skip != skip_state(**id, suppressed, rolled))
        .flat_map(|(id, _)| subtree(tree, *id));
    appeared
        .chain(reskinned)
        .filter(|id| live.contains(id))
        .fold(scoped, |mut acc, id| {
            acc.insert(id);
            acc
        })
}

fn subtree(tree: &FeatureTree, root: FeatureId) -> BTreeSet<FeatureId> {
    tree.descendants(root).into_iter().chain([root]).collect()
}

fn skip_state(
    id: FeatureId,
    suppressed: &BTreeSet<FeatureId>,
    rolled: &BTreeSet<FeatureId>,
) -> SkipState {
    match (rolled.contains(&id), suppressed.contains(&id)) {
        (true, _) => SkipState::RolledBack,
        (false, true) => SkipState::Suppressed,
        (false, false) => SkipState::Active,
    }
}

fn payload_key(document: &Document, node: FeatureNode) -> PayloadKey {
    match node {
        FeatureNode::Origin | FeatureNode::PrincipalPlane(_) => PayloadKey::Datum,
        FeatureNode::Sketch(sketch) => document.sketch(sketch).map_or(PayloadKey::Datum, |value| {
            PayloadKey::Sketch(value.clone(), document.sketch_plane_binding(sketch))
        }),
        FeatureNode::Extrude(extrude) => PayloadKey::Extrude(document.extrude(extrude).copied()),
        FeatureNode::ImportedBody(body) => PayloadKey::Imported(body),
    }
}

fn parent_digests(
    tree: &FeatureTree,
    id: FeatureId,
    memo: &Memo,
) -> Vec<(FeatureId, ParentDigest)> {
    tree.parents(id)
        .into_iter()
        .map(|parent| {
            let digest = memo
                .get(&parent)
                .map_or(ParentDigest::Absent, |entry| entry.digest.clone());
            (parent, digest)
        })
        .collect()
}

fn build_entry(
    document: &Document,
    id: FeatureId,
    node: FeatureNode,
    memo: &Memo,
    fingerprint: NodeFingerprint,
    skip: SkipState,
    pass: RebuildPass,
) -> NodeMemo {
    let output = match skip {
        SkipState::Active => node_output(document, id, node, memo),
        SkipState::Suppressed | SkipState::RolledBack => NodeOutput::Unbuilt,
    };
    let digest = digest_of(&output, skip);
    let status = status_of(&output, skip);
    NodeMemo {
        fingerprint,
        output,
        digest,
        skip,
        status,
        built_at: pass,
    }
}

fn node_output(document: &Document, id: FeatureId, node: FeatureNode, memo: &Memo) -> NodeOutput {
    match node {
        FeatureNode::Origin | FeatureNode::PrincipalPlane(_) => NodeOutput::Datum,
        FeatureNode::Sketch(sketch) => {
            document
                .sketch(sketch)
                .map_or(NodeOutput::Unbuilt, |value| {
                    let evaluated = match document.sketch_plane_binding(sketch) {
                        None => evaluate_sketch(value),
                        Some(face) => face_bound_sketch(memo, value, face),
                    };
                    NodeOutput::Sketch(evaluated)
                })
        }
        FeatureNode::Extrude(extrude) => {
            let feature = document.extrude(extrude);
            let profile = parent_sketch(document.feature_tree(), id, memo);
            match (feature, profile) {
                (Some(feature), Some(profile)) => {
                    NodeOutput::Extrude(evaluate_extrude(id, profile, feature))
                }
                _ => NodeOutput::Unbuilt,
            }
        }
        FeatureNode::ImportedBody(body) => document
            .imported_body(body)
            .map_or(NodeOutput::Unbuilt, |solid| {
                NodeOutput::Imported(solid.clone())
            }),
    }
}

fn face_bound_sketch(memo: &Memo, value: &Sketch, face: FaceRef) -> EvaluatedSketch {
    let Some(entry) = memo.get(&face.label.feature) else {
        return EvaluatedSketch::PlaneUnresolved(RebuildError::DanglingReference(
            face.entity_ref(),
        ));
    };
    let Some(solid) = output_solid(&entry.output) else {
        return EvaluatedSketch::PlaneUnresolved(RebuildError::UpstreamUnresolved);
    };
    let Some(matched) = unique_face(solid, face.label) else {
        return EvaluatedSketch::PlaneUnresolved(RebuildError::DanglingReference(
            face.entity_ref(),
        ));
    };
    match solid.face_plane_basis(matched.id()) {
        Some(basis) => evaluate_sketch(&value.with_plane(basis)),
        None => EvaluatedSketch::PlaneUnresolved(RebuildError::NonPlanarSketchTarget),
    }
}

fn output_solid(output: &NodeOutput) -> Option<&BrepSolid> {
    match output {
        NodeOutput::Extrude(extrude) => extrude.solid(),
        NodeOutput::Imported(solid) => Some(solid),
        NodeOutput::Datum | NodeOutput::Sketch(_) | NodeOutput::Unbuilt => None,
    }
}

fn parent_sketch<'memo>(
    tree: &FeatureTree,
    id: FeatureId,
    memo: &'memo Memo,
) -> Option<&'memo EvaluatedSketch> {
    tree.parents(id).into_iter().find_map(|parent| {
        memo.get(&parent).and_then(|entry| match &entry.output {
            NodeOutput::Sketch(sketch) => Some(sketch),
            NodeOutput::Datum
            | NodeOutput::Extrude(_)
            | NodeOutput::Imported(_)
            | NodeOutput::Unbuilt => None,
        })
    })
}

fn digest_of(output: &NodeOutput, skip: SkipState) -> ParentDigest {
    match skip {
        SkipState::Suppressed | SkipState::RolledBack => ParentDigest::Absent,
        SkipState::Active => match output {
            NodeOutput::Datum => ParentDigest::Datum,
            NodeOutput::Sketch(sketch) => ParentDigest::Sketch(sketch.clone()),
            NodeOutput::Extrude(extrude) => extrude
                .generation()
                .map_or(ParentDigest::Absent, ParentDigest::Geometry),
            NodeOutput::Imported(solid) => {
                ParentDigest::Geometry(GeometryGeneration::from_solid_key(solid.content_key()))
            }
            NodeOutput::Unbuilt => ParentDigest::Absent,
        },
    }
}

fn status_of(output: &NodeOutput, skip: SkipState) -> RebuildStatus {
    match skip {
        SkipState::Suppressed | SkipState::RolledBack => RebuildStatus::UpToDate,
        SkipState::Active => match output {
            NodeOutput::Datum | NodeOutput::Imported(_) => RebuildStatus::UpToDate,
            NodeOutput::Unbuilt => RebuildStatus::NeedsRebuild,
            NodeOutput::Sketch(sketch) => match sketch {
                EvaluatedSketch::Solved(_) => RebuildStatus::UpToDate,
                EvaluatedSketch::Failed(_) => {
                    RebuildStatus::Error(RebuildError::Build(BuildFailure::UnsolvedSketch))
                }
                EvaluatedSketch::PlaneUnresolved(error) => RebuildStatus::Error(*error),
            },
            NodeOutput::Extrude(extrude) => match extrude.result() {
                Ok(_) => RebuildStatus::UpToDate,
                Err(ExtrudeError::UnsolvedSketch(_)) => {
                    RebuildStatus::Error(RebuildError::Build(BuildFailure::UnsolvedSketch))
                }
                Err(ExtrudeError::Kernel(_)) => {
                    RebuildStatus::Error(RebuildError::Build(BuildFailure::Kernel))
                }
                Err(ExtrudeError::PlaneUnresolved(_)) => {
                    RebuildStatus::Error(RebuildError::UpstreamUnresolved)
                }
            },
        },
    }
}

#[cfg(test)]
mod tests {
    use std::collections::BTreeSet;

    use bone_types::{
        BuildFailure, DocumentId, FaceRef, FeatureId, RebuildError, RebuildStatus, RollbackMarker,
    };
    use proptest::prelude::*;

    use super::{EvaluatedModel, RebuildBudget, RebuildCost, RecomputeScope};
    use crate::document::Document;
    use crate::test_support::{
        blind_extrude, chain_handles, circle_sketch, end_cap_ref, full_model, open_chain_sketch,
        placeholder_fingerprint, push_chain, push_face_bound_chain, side_face_label,
    };

    fn body_max_z(model: &EvaluatedModel, body: FeatureId) -> f64 {
        let Some(solid) = model.body(body) else {
            panic!("the body is built");
        };
        let Some(aabb) = solid.bounding_box() else {
            panic!("a built body has a bounding box");
        };
        aabb.max().coords_mm().2
    }

    fn body_min_z(model: &EvaluatedModel, body: FeatureId) -> f64 {
        let Some(solid) = model.body(body) else {
            panic!("the body is built");
        };
        let Some(aabb) = solid.bounding_box() else {
            panic!("a built body has a bounding box");
        };
        aabb.min().coords_mm().2
    }

    fn body_keys(model: &EvaluatedModel) -> Vec<(FeatureId, [u8; 16])> {
        model
            .bodies()
            .map(|(id, solid)| (id, solid.content_key().bytes()))
            .collect()
    }

    #[test]
    fn an_extrude_chain_builds_one_body() {
        let mut document = Document::new(DocumentId::default(), "doc".to_owned());
        let chain = push_chain(&mut document, "Sketch1", 5.0, 4.0);
        let model = full_model(&document);

        assert!(
            model.body(chain.extrude).is_some(),
            "the extrude yields a body"
        );
        assert_eq!(model.status(chain.extrude), Some(RebuildStatus::UpToDate));
        assert_eq!(model.status(chain.sketch), Some(RebuildStatus::UpToDate));
        assert_eq!(model.bodies().count(), 1, "one extrude, one body");
    }

    #[test]
    fn recompute_is_idempotent_without_an_edit() {
        let mut document = Document::new(DocumentId::default(), "doc".to_owned());
        let chain = push_chain(&mut document, "Sketch1", 5.0, 4.0);
        let mut model = full_model(&document);
        let first = model.built_at(chain.extrude);

        model.recompute(
            &document,
            &BTreeSet::new(),
            RollbackMarker::AtEnd,
            RecomputeScope::Edited(chain.sketch),
        );

        assert_eq!(
            model.built_at(chain.extrude),
            first,
            "an edit scope with no real input change must not re-evaluate the subtree",
        );
    }

    #[test]
    fn recompute_cascade_closes_a_raw_suppressed_sketch() {
        let mut document = Document::new(DocumentId::default(), "doc".to_owned());
        let chain = push_chain(&mut document, "Sketch1", 5.0, 4.0);
        let mut model = EvaluatedModel::new();
        let suppressed = BTreeSet::from([chain.sketch]);
        model.recompute(
            &document,
            &suppressed,
            RollbackMarker::AtEnd,
            RecomputeScope::Full,
        );

        assert!(model.body(chain.extrude).is_none(), "no profile, no body");
        assert_eq!(
            model.status(chain.sketch),
            Some(RebuildStatus::UpToDate),
            "a suppressed feature is not an error",
        );
        assert_eq!(
            model.status(chain.extrude),
            Some(RebuildStatus::UpToDate),
            "recompute closes the suppressed set over descendants, so the child is suppressed not stranded",
        );
    }

    #[test]
    fn a_cascaded_suppression_marks_the_extrude_suppressed_not_stranded() {
        let mut document = Document::new(DocumentId::default(), "doc".to_owned());
        let chain = push_chain(&mut document, "Sketch1", 5.0, 4.0);
        document.suppress(chain.sketch);
        let mut model = EvaluatedModel::new();
        model.recompute(
            &document,
            document.suppressed(),
            document.rollback(),
            RecomputeScope::Full,
        );

        assert!(
            model.body(chain.extrude).is_none(),
            "a suppressed chain builds no body",
        );
        assert_eq!(
            model.status(chain.sketch),
            Some(RebuildStatus::UpToDate),
            "a suppressed feature is not an error",
        );
        assert_eq!(
            model.status(chain.extrude),
            Some(RebuildStatus::UpToDate),
            "the cascaded child is suppressed, not a stranded needs-rebuild",
        );
    }

    #[test]
    fn rolling_back_above_an_extrude_drops_its_body() {
        let mut document = Document::new(DocumentId::default(), "doc".to_owned());
        let chain = push_chain(&mut document, "Sketch1", 5.0, 4.0);
        let mut model = EvaluatedModel::new();
        model.recompute(
            &document,
            &BTreeSet::new(),
            RollbackMarker::Above(chain.extrude),
            RecomputeScope::Full,
        );

        assert!(
            model.body(chain.extrude).is_none(),
            "a rolled-back extrude is not evaluated",
        );
        assert_eq!(model.bodies().count(), 0);
        assert_eq!(model.status(chain.sketch), Some(RebuildStatus::UpToDate));
    }

    #[test]
    fn a_feature_both_rolled_back_and_suppressed_stays_up_to_date() {
        let mut document = Document::new(DocumentId::default(), "doc".to_owned());
        let chain = push_chain(&mut document, "Sketch1", 5.0, 4.0);
        let mut model = EvaluatedModel::new();
        let suppressed = BTreeSet::from([chain.extrude]);
        model.recompute(
            &document,
            &suppressed,
            RollbackMarker::Above(chain.extrude),
            RecomputeScope::Full,
        );

        assert!(
            model.body(chain.extrude).is_none(),
            "a feature that is both rolled back and suppressed builds nothing",
        );
        assert_eq!(
            model.status(chain.extrude),
            Some(RebuildStatus::UpToDate),
            "overlapping skip states collapse to a single up-to-date skip, never an error",
        );
    }

    #[test]
    fn a_suppression_outside_the_edit_scope_still_takes_effect() {
        let mut document = Document::new(DocumentId::default(), "doc".to_owned());
        let a = push_chain(&mut document, "Sketch1", 5.0, 4.0);
        let b = push_chain(&mut document, "Sketch2", 6.0, 4.0);
        let mut model = full_model(&document);
        assert!(
            model.body(b.extrude).is_some(),
            "b builds before suppression"
        );

        document.replace_sketch(a.sketch_id, circle_sketch(7.0));
        let suppressed = BTreeSet::from([b.sketch]);
        model.recompute(
            &document,
            &suppressed,
            RollbackMarker::AtEnd,
            RecomputeScope::Edited(a.sketch),
        );

        assert!(
            model.body(b.extrude).is_none(),
            "suppressing a feature outside the edit scope must still strand its body",
        );
        assert_eq!(
            model.status(b.sketch),
            Some(RebuildStatus::UpToDate),
            "a suppressed feature is not an error",
        );
        assert!(
            model.body(a.extrude).is_some(),
            "the edited chain still builds"
        );
    }

    #[test]
    fn a_rollback_outside_the_edit_scope_still_takes_effect() {
        let mut document = Document::new(DocumentId::default(), "doc".to_owned());
        let a = push_chain(&mut document, "Sketch1", 5.0, 4.0);
        let b = push_chain(&mut document, "Sketch2", 6.0, 4.0);
        let mut model = full_model(&document);
        assert!(model.body(b.extrude).is_some(), "b builds before rollback");

        document.replace_sketch(a.sketch_id, circle_sketch(7.0));
        model.recompute(
            &document,
            &BTreeSet::new(),
            RollbackMarker::Above(b.extrude),
            RecomputeScope::Edited(a.sketch),
        );

        assert!(
            model.body(b.extrude).is_none(),
            "rolling back a feature outside the edit scope must still drop its body",
        );
    }

    #[test]
    fn an_unbuildable_profile_reports_a_build_error() {
        let mut document = Document::new(DocumentId::default(), "doc".to_owned());
        let sketch_id = document.allocate_sketch();
        document.insert_sketch(sketch_id, "Sketch1".to_owned(), open_chain_sketch());
        let extrude_id = document.commit_extrude(blind_extrude(sketch_id, 4.0));
        let chain = chain_handles(&document, sketch_id, extrude_id);
        let model = full_model(&document);

        assert_eq!(
            model.status(chain.sketch),
            Some(RebuildStatus::UpToDate),
            "the open chain solves, only the profile is unbuildable",
        );
        assert_eq!(
            model.status(chain.extrude),
            Some(RebuildStatus::Error(RebuildError::Build(
                BuildFailure::Kernel
            ))),
            "an open profile is a hard build error, not a stale mark",
        );
        assert!(model.body(chain.extrude).is_none());
    }

    proptest! {
        #[test]
        fn rebuilding_twice_is_bit_identical(radius in 1.0f64..20.0, depth in 1.0f64..20.0) {
            let mut document = Document::new(DocumentId::default(), "doc".to_owned());
            push_chain(&mut document, "Sketch1", radius, depth);
            let first = body_keys(&full_model(&document));
            let second = body_keys(&full_model(&document));
            prop_assert_eq!(first, second, "two rebuilds of one document must agree byte for byte");
        }

        #[test]
        fn editing_one_chain_leaves_the_other_untouched(
            radius_a in 1.0f64..10.0,
            radius_b in 1.0f64..10.0,
            edited in 11.0f64..20.0,
        ) {
            let mut document = Document::new(DocumentId::default(), "doc".to_owned());
            let a = push_chain(&mut document, "Sketch1", radius_a, 4.0);
            let b = push_chain(&mut document, "Sketch2", radius_b, 4.0);
            let mut model = full_model(&document);

            let b_sketch_before = model.built_at(b.sketch);
            let b_extrude_before = model.built_at(b.extrude);
            let b_body_before = model.body(b.extrude).map(bone_kernel::BrepSolid::content_key);
            let a_extrude_before = model.built_at(a.extrude);

            document.replace_sketch(a.sketch_id, circle_sketch(edited));
            model.recompute(
                &document,
                &BTreeSet::new(),
                RollbackMarker::AtEnd,
                RecomputeScope::Edited(a.sketch),
            );

            prop_assert_eq!(model.built_at(b.sketch), b_sketch_before, "sibling sketch must not re-evaluate");
            prop_assert_eq!(model.built_at(b.extrude), b_extrude_before, "sibling extrude must not re-evaluate");
            prop_assert_eq!(
                model.body(b.extrude).map(bone_kernel::BrepSolid::content_key),
                b_body_before,
                "the untouched body must stay byte-identical",
            );
            prop_assert!(
                model.built_at(a.extrude) > a_extrude_before,
                "the edited chain's extrude must rebuild",
            );
        }
    }

    #[test]
    #[cfg_attr(
        debug_assertions,
        ignore = "frame budget assertions are only meaningful in release builds"
    )]
    fn single_edit_rebuild_under_frame_budget() {
        use std::time::{Duration, Instant};

        const BASE_MM: f64 = 4.0;
        const STEP_MM: f64 = 0.5;
        const STEPS: u32 = 16;

        let mut document = Document::new(DocumentId::default(), "doc".to_owned());
        let chain = push_chain(&mut document, "Sketch1", 5.0, BASE_MM);
        let mut model = full_model(&document);

        let step = |document: &mut Document, model: &mut EvaluatedModel, radius_mm: f64| {
            document.replace_sketch(chain.sketch_id, circle_sketch(radius_mm));
            let started = Instant::now();
            model.recompute(
                document,
                &BTreeSet::new(),
                RollbackMarker::AtEnd,
                RecomputeScope::Edited(chain.sketch),
            );
            let elapsed = started.elapsed();
            assert!(
                model.body(chain.extrude).is_some(),
                "the edit rebuilds a body"
            );
            elapsed
        };

        let _warmup = step(&mut document, &mut model, 5.0);
        let durations: Vec<Duration> = (0..STEPS)
            .map(|i| step(&mut document, &mut model, 5.0 + STEP_MM * f64::from(i)))
            .collect();
        let sorted = {
            let mut v = durations.clone();
            v.sort_unstable();
            v
        };
        let median = sorted[sorted.len() / 2];
        let budget = Duration::from_millis(16);
        assert!(
            median <= budget,
            "median single-edit recompute {median:?} exceeds {budget:?} frame budget; samples {durations:?}",
        );
    }

    #[test]
    fn a_face_bound_sketch_builds_a_disjoint_body_on_the_cap() {
        let mut document = Document::new(DocumentId::default(), "doc".to_owned());
        let base = push_chain(&mut document, "Base", 6.0, 4.0);
        let probe = full_model(&document);
        let cap = end_cap_ref(&probe, base.extrude);
        let top = push_face_bound_chain(&mut document, "Top", cap, 3.0, 5.0);
        let model = full_model(&document);

        let descendants = document.feature_tree().descendants(base.extrude);
        assert!(
            descendants.contains(&top.sketch) && descendants.contains(&top.extrude),
            "the face binding records a dependency from the cap's body onto the new chain",
        );
        assert_eq!(model.status(top.sketch), Some(RebuildStatus::UpToDate));
        assert!(model.body(top.extrude).is_some());
        assert_eq!(model.bodies().count(), 2, "two separate bodies, no boolean");

        let cap_z = body_max_z(&model, base.extrude);
        assert!(
            (body_min_z(&model, top.extrude) - cap_z).abs() < 1.0e-6,
            "the derived plane sits on the cap so the second body rises from it",
        );
    }

    #[test]
    fn an_upstream_edit_moves_the_face_bound_body() {
        let mut document = Document::new(DocumentId::default(), "doc".to_owned());
        let base = push_chain(&mut document, "Base", 6.0, 4.0);
        let probe = full_model(&document);
        let cap = end_cap_ref(&probe, base.extrude);
        let top = push_face_bound_chain(&mut document, "Top", cap, 3.0, 5.0);
        let mut model = full_model(&document);
        let before = model.built_at(top.extrude);

        document.insert_extrude(base.extrude_id, blind_extrude(base.sketch_id, 7.0));
        model.recompute(
            &document,
            &BTreeSet::new(),
            RollbackMarker::AtEnd,
            RecomputeScope::Edited(base.extrude),
        );

        assert!(
            model.built_at(top.extrude) > before,
            "deepening the base re-resolves the face and rebuilds the downstream body",
        );
        assert!((body_max_z(&model, base.extrude) - 7.0).abs() < 1.0e-6);
        assert!(
            (body_min_z(&model, top.extrude) - 7.0).abs() < 1.0e-6,
            "the face-bound body follows the cap to its new height",
        );
    }

    #[test]
    fn a_deleted_cap_dangles_the_face_bound_chain() {
        let mut document = Document::new(DocumentId::default(), "doc".to_owned());
        let base = push_chain(&mut document, "Base", 6.0, 4.0);
        let probe = full_model(&document);
        let cap = end_cap_ref(&probe, base.extrude);
        let top = push_face_bound_chain(&mut document, "Top", cap, 3.0, 5.0);
        let mut model = full_model(&document);
        assert!(model.body(top.extrude).is_some(), "the chain builds first");

        document.remove_extrude(base.extrude_id);
        model.recompute(
            &document,
            &BTreeSet::new(),
            RollbackMarker::AtEnd,
            RecomputeScope::Full,
        );

        assert!(
            matches!(
                model.status(top.sketch),
                Some(RebuildStatus::Error(RebuildError::DanglingReference(_)))
            ),
            "a removed cap leaves the face-bound sketch dangling, never a silent wrong plane",
        );
        assert_eq!(
            model.status(top.extrude),
            Some(RebuildStatus::Error(RebuildError::UpstreamUnresolved)),
            "the downstream extrude fails as upstream-unresolved, not as a dangling owner",
        );
        assert!(model.body(top.extrude).is_none());
    }

    #[test]
    fn a_non_planar_target_is_a_typed_rebuild_error() {
        let mut document = Document::new(DocumentId::default(), "doc".to_owned());
        let base = push_chain(&mut document, "Base", 6.0, 4.0);
        let probe = full_model(&document);
        let side = side_face_label(&probe, base.extrude);
        let top = push_face_bound_chain(
            &mut document,
            "Top",
            FaceRef::new(side, placeholder_fingerprint()),
            1.0,
            2.0,
        );
        let model = full_model(&document);

        assert_eq!(
            model.status(top.sketch),
            Some(RebuildStatus::Error(RebuildError::NonPlanarSketchTarget)),
            "a sketch bound to a cylindrical side fails with the non-planar contract",
        );
        assert!(model.body(top.extrude).is_none());
    }

    #[test]
    fn rebuild_cost_sums_topology_and_gates_on_the_budget() {
        let mut document = Document::new(DocumentId::default(), "doc".to_owned());
        push_chain(&mut document, "Sketch1", 5.0, 4.0);
        let model = full_model(&document);
        let cost = model.rebuild_cost();

        assert!(
            cost > RebuildCost::default(),
            "a built body contributes faces and edges to the cost",
        );
        assert!(
            cost.within(RebuildBudget::INTERACTIVE),
            "a single extrude stays under the interactive budget, so the rebuild is eager",
        );
        assert!(
            !cost.within(RebuildBudget::new(0)),
            "a zero budget forces the deferred path for any non-empty model",
        );
        assert_eq!(
            EvaluatedModel::new().rebuild_cost(),
            RebuildCost::default(),
            "an empty model costs nothing to rebuild",
        );
    }

    proptest! {
        #[test]
        fn the_face_bound_body_tracks_the_cap_across_depth_edits(
            base_depth in 2.0f64..12.0,
            edited_depth in 2.0f64..12.0,
        ) {
            let mut document = Document::new(DocumentId::default(), "doc".to_owned());
            let base = push_chain(&mut document, "Base", 6.0, base_depth);
            let probe = full_model(&document);
            let cap = end_cap_ref(&probe, base.extrude);
            let top = push_face_bound_chain(&mut document, "Top", cap, 3.0, 5.0);
            let mut model = full_model(&document);

            document.insert_extrude(base.extrude_id, blind_extrude(base.sketch_id, edited_depth));
            model.recompute(
                &document,
                &BTreeSet::new(),
                RollbackMarker::AtEnd,
                RecomputeScope::Edited(base.extrude),
            );

            prop_assert!(
                (body_min_z(&model, top.extrude) - edited_depth).abs() < 1.0e-6,
                "the face-bound body always rises from the current cap height",
            );
        }
    }
}
