import hashlib
import json
import os
import re
import tempfile
import zipfile

import FreeCAD as App
import Part
import Sketcher


FREECAD_COMMIT = "0108fd4b4850cc46e625b60e53cea7a7bbe69f8d"
BUILDER_STAGE_TYPES = {
    "Part::Fuse", "Part::Cut", "Part::Common", "Part::Extrusion", "Part::Revolution", "Part::Loft", "Part::Sweep", "Part::Fillet", "Part::Chamfer",
    "PartDesign::Pad", "PartDesign::Pocket", "PartDesign::Revolution", "PartDesign::Groove", "PartDesign::AdditiveLoft", "PartDesign::SubtractiveLoft",
    "PartDesign::AdditivePipe", "PartDesign::SubtractivePipe", "PartDesign::Fillet", "PartDesign::Chamfer", "PartDesign::Draft", "PartDesign::Thickness",
    "PartDesign::Mirrored", "PartDesign::MultiTransform", "PartDesign::LinearPattern", "PartDesign::PolarPattern", "PartDesign::Hole",
}


def version_text():
    return ".".join(str(value) for value in App.Version()[:3])


def square_sketch(body, name, half_size=2.0, z=0.0):
    sketch = body.newObject("Sketcher::SketchObject", name)
    points = [(-half_size, -half_size), (half_size, -half_size), (half_size, half_size), (-half_size, half_size)]
    for index, start in enumerate(points):
        end = points[(index + 1) % len(points)]
        sketch.addGeometry(Part.LineSegment(App.Vector(start[0], start[1], 0), App.Vector(end[0], end[1], 0)), False)
    sketch.Placement.Base.z = z
    return sketch


def shape_summary(shape):
    if shape is None or shape.isNull():
        return {"valid": False, "solids": 0, "faces": 0, "edges": 0, "vertices": 0, "volume": 0.0, "area": 0.0, "brepSha256": None}
    brep = shape.exportBrepToString()
    bounds = shape.BoundBox
    geometry = {
        "solids": len(shape.Solids),
        "faces": len(shape.Faces),
        "edges": len(shape.Edges),
        "vertices": len(shape.Vertexes),
        "volume": round(float(shape.Volume), 9),
        "area": round(float(shape.Area), 9),
        "bounds": [round(float(value), 9) for value in (bounds.XMin, bounds.YMin, bounds.ZMin, bounds.XMax, bounds.YMax, bounds.ZMax)],
        "vertexPoints": sorted([round(float(vertex.Point.x), 9), round(float(vertex.Point.y), 9), round(float(vertex.Point.z), 9)] for vertex in shape.Vertexes),
        "edgeLengths": sorted(round(float(edge.Length), 9) for edge in shape.Edges),
        "faceAreas": sorted(round(float(face.Area), 9) for face in shape.Faces),
    }
    return {
        "valid": bool(shape.isValid()),
        "solids": len(shape.Solids),
        "faces": len(shape.Faces),
        "edges": len(shape.Edges),
        "vertices": len(shape.Vertexes),
        "volume": float(shape.Volume),
        "area": float(shape.Area),
        "brepSha256": hashlib.sha256(brep.encode("utf-8")).hexdigest(),
        "geometryDigest": digest_json(geometry),
    }


def history_entry(obj, name):
    try:
        history = obj.getElementHistory(name)
    except Exception:
        return []
    result = []
    for source, mapped, children in history:
        result.append({"object": getattr(source, "Name", ""), "typeId": getattr(source, "TypeId", ""), "mappedName": mapped, "children": list(children or [])})
    return result


def relation_from_mapped_name(mapped_name):
    if not mapped_name:
        return "preserved"
    if ":G" in mapped_name:
        return "generated"
    if ":M" in mapped_name:
        return "modified"
    return "preserved"


def normalize_mapped_name(mapped_name):
    # StringHasher IDs are process-local. Preserve the token grammar while
    # removing only the volatile hash-table slot assigned in this process.
    return re.sub(r":H-?[0-9a-fA-F]+", ":H#", mapped_name or "")


def source_element_name(mapped_name):
    match = re.search(r"(?:^|[;(])((?:Face|Edge|Vertex)\d+)", mapped_name or "")
    if match:
        return match.group(1)
    local_token = re.search(r"(?:^|[;(])(#[a-zA-Z0-9]+:[0-9a-fA-F]+)", mapped_name or "")
    if local_token:
        return local_token.group(1)
    sketch_token = re.search(r"(?:^|[;(])(g\d+(?:v\d+)?;SKT)", mapped_name or "")
    if sketch_token:
        return sketch_token.group(1)
    indexed_token = re.search(r"(?:^|[;(])(#[0-9a-fA-F]+)", mapped_name or "")
    return indexed_token.group(1) if indexed_token else ""


def linked_objects(obj):
    result = set()

    def collect(value):
        if hasattr(value, "Name") and getattr(value, "Document", None) is obj.Document:
            result.add(value.Name)
        elif isinstance(value, (list, tuple)):
            for item in value:
                collect(item)

    for property_name in obj.PropertiesList:
        try:
            property_type = obj.getTypeIdOfProperty(property_name)
        except Exception:
            continue
        if "PropertyLink" not in property_type:
            continue
        try:
            collect(getattr(obj, property_name))
        except Exception:
            pass
    result.discard(obj.Name)
    return sorted(result)


def relation_records(obj, names):
    records = []
    for entry in names:
        history = entry.get("history") or []
        sources = []
        for item in history:
            if item.get("object") == obj.Name or not item.get("object"):
                continue
            sources.append({
                "sourceObject": item.get("object"),
                "sourceTypeId": item.get("typeId"),
                "sourceElement": source_element_name(item.get("mappedName")),
                "mappedName": normalize_mapped_name(item.get("mappedName")),
                "children": sorted(normalize_mapped_name(child) for child in (item.get("children") or [])),
            })
        records.append({
            "resultName": entry["name"],
            "mappedName": normalize_mapped_name(entry.get("mappedName")),
            "indexedName": entry.get("indexedName") or "",
            "relation": relation_from_mapped_name(entry.get("mappedName")),
            "sources": sorted(sources, key=lambda source: (source["sourceObject"], source["sourceElement"], source["mappedName"])),
        })
    return records


def digest_json(value):
    payload = json.dumps(value, sort_keys=True, separators=(",", ":"))
    return hashlib.sha256(payload.encode("utf-8")).hexdigest()


def native_mapping(obj, kind, index, name, mapped_name, mapped_ids, indexed_name, indexed_ids, history):
    reference_name = mapped_name or indexed_name or name
    source_refs = []
    for item in history[1:]:
        source_name = item.get("object")
        source_mapped_name = item.get("mappedName") or ""
        if not source_name:
            continue
        source_refs.append({
            "objectId": source_name,
            # FreeCAD exposes the source object and mapped name, but not a
            # persistent source subshape id in this API response.
            "persistentId": "%s:mapped:%s" % (source_name, source_mapped_name or "%s%d" % (kind, index)),
            "stageId": source_name,
            "mappedName": source_mapped_name,
        })
    return {
        "kind": kind.lower(),
        "resultIndex": index - 1,
        "resultPersistentId": "%s:%s:%d" % (obj.Name, kind.lower(), index - 1),
        "reference": {
            "name": reference_name,
            "stringIds": list(mapped_ids or indexed_ids or []),
            "indexedName": {"type": kind, "index": index},
        },
        "relation": relation_from_mapped_name(mapped_name),
        "sourceRefs": source_refs,
        "nativeApi": "getElementMappedName/getElementIndexedName/getElementHistory",
        "mappedName": mapped_name,
        "indexedName": indexed_name,
        "mappedStringIds": list(mapped_ids or []),
        "indexedStringIds": list(indexed_ids or []),
    }


def stage_report(obj):
    shape = getattr(obj, "Shape", None)
    if shape is None or shape.isNull():
        return None
    names = []
    native_mapped_names = []
    native_evidence_complete = True
    mapped_name_entries = 0
    indexed_name_entries = 0
    history_entry_count = 0
    for kind, count in (("Face", len(shape.Faces)), ("Edge", len(shape.Edges)), ("Vertex", len(shape.Vertexes))):
        for index in range(1, count + 1):
            name = "%s%d" % (kind, index)
            try:
                mapped, mapped_ids = shape.getElementMappedName(name, True)
            except Exception:
                mapped, mapped_ids = "", []
            try:
                indexed, indexed_ids = shape.getElementIndexedName(name, True)
            except Exception:
                indexed, indexed_ids = "", []
            history = history_entry(obj, name)
            history_entry_count += len(history)
            if not mapped and not indexed:
                native_evidence_complete = False
            if mapped:
                mapped_name_entries += 1
            if indexed:
                indexed_name_entries += 1
            names.append({
                "name": name,
                "mappedName": mapped,
                "mappedStringIds": list(mapped_ids or []),
                "indexedName": indexed,
                "indexedStringIds": list(indexed_ids or []),
                "history": history,
            })
            native_mapped_names.append(native_mapping(obj, kind, index, name, mapped, mapped_ids, indexed, indexed_ids, history))
    relations = relation_records(obj, names)
    native_evidence = {
        "schemaVersion": 1,
        "stageId": obj.Name,
        "resultObjectId": obj.Name,
        "status": "native-evidence" if native_evidence_complete and native_mapped_names else "missing",
        "mappedNames": native_mapped_names,
        "source": "FreeCAD 1.1.1 runtime API",
        "mappedNameApiEntries": mapped_name_entries,
        "indexedNameApiEntries": indexed_name_entries,
        # FreeCAD exposes MappedName only for derived/builder results. Primitive
        # and support stages legitimately expose IndexedName without a private
        # token; record that boundary explicitly instead of treating it as a
        # failed token capture.
        "privateTokenEvidenceRequired": mapped_name_entries > 0,
        "privateTokenEvidenceComplete": mapped_name_entries == len(native_mapped_names) and mapped_name_entries > 0,
        "indexedNameOnly": mapped_name_entries == 0 and indexed_name_entries == len(native_mapped_names) and indexed_name_entries > 0,
        "internalBuilderEvidence": obj.TypeId in BUILDER_STAGE_TYPES and history_entry_count > 0 and native_evidence_complete,
        "reason": None if native_evidence_complete else "One or more subshapes had no mapped or indexed name from the native API.",
    }
    return {
        "name": obj.Name,
        "label": obj.Label,
        "typeId": obj.TypeId,
        "shape": shape_summary(shape),
        "elementMapVersion": getattr(shape, "ElementMapVersion", None),
        "elementMapSize": int(getattr(shape, "ElementMapSize", 0) or 0),
        "names": names,
        "linkedObjects": linked_objects(obj),
        "relations": relations,
        "relationDigest": digest_json(relations),
        "semanticNameDigest": digest_json([{
            "name": entry["name"],
            "mappedName": normalize_mapped_name(entry.get("mappedName")),
            "indexedName": entry.get("indexedName") or "",
        } for entry in names]),
        "nativeEvidence": native_evidence,
    }


def correlation_snapshot(case_id, stages):
    return [{
        "key": "%s:%03d:%s" % (case_id, ordinal, stage["name"]),
        "ordinal": ordinal,
        "name": stage["name"],
        "typeId": stage["typeId"],
        "linkedObjects": stage["linkedObjects"],
        "relationCount": len(stage["relations"]),
        "relationDigest": stage["relationDigest"],
        "semanticNameDigest": stage["semanticNameDigest"],
    } for ordinal, stage in enumerate(stages)]


def parameter_value(value):
    if hasattr(value, "Value"):
        return float(value.Value)
    if hasattr(value, "x") and hasattr(value, "y") and hasattr(value, "z"):
        return [float(value.x), float(value.y), float(value.z)]
    if isinstance(value, (bool, int, float, str)) or value is None:
        return value
    return str(value)


def mutation_stage_snapshot(case_id, stages):
    return [{
        "key": "%s:%03d:%s" % (case_id, ordinal, stage["name"]),
        "ordinal": ordinal,
        "name": stage["name"],
        "typeId": stage["typeId"],
        "shape": stage["shape"],
        "relationDigest": stage["relationDigest"],
        "semanticNameDigest": stage["semanticNameDigest"],
    } for ordinal, stage in enumerate(stages)]


def mutation_geometry_fingerprint(stage):
    return stage["shape"]["geometryDigest"]


def mutation_naming_fingerprint(stage):
    return (stage["relationDigest"], stage["semanticNameDigest"])


def collect_named_stages(document, stage_names):
    stages = []
    for stage_name in stage_names:
        obj = document.getObject(stage_name)
        stage = stage_report(obj) if obj is not None else None
        if stage is None:
            raise RuntimeError("mutation removed or invalidated stage %s" % stage_name)
        stages.append(stage)
    return stages


def capture_mutation(case_id, document, construction, before_stages):
    final = construction["final"]
    target = construction["mutationTarget"]
    property_name = construction["mutationProperty"]
    edited_value = construction["mutationValue"]
    stage_names = [stage["name"] for stage in before_stages]
    stage_ordinals = {stage_name: ordinal for ordinal, stage_name in enumerate(stage_names)}
    if target.Name not in stage_ordinals or final.Name not in stage_ordinals:
        raise RuntimeError("mutation target or final object has no captured Shape stage")
    if property_name not in target.PropertiesList:
        raise RuntimeError("mutation property %s.%s is not registered" % (target.Name, property_name))
    editor_modes = list(target.getEditorMode(property_name))
    property_type = target.getTypeIdOfProperty(property_name)
    native_editable = "ReadOnly" not in editor_modes and "Hidden" not in editor_modes
    original_value = getattr(target, property_name)
    before_value = parameter_value(original_value)
    try:
        setattr(target, property_name, edited_value)
        document.recompute()
        edited_value_readback = parameter_value(getattr(target, property_name))
        edited_stages = collect_named_stages(document, stage_names)
    finally:
        setattr(target, property_name, original_value)
        document.recompute()
    restored_value = parameter_value(getattr(target, property_name))
    restored_stages = collect_named_stages(document, stage_names)
    before_geometry = [mutation_geometry_fingerprint(stage) for stage in before_stages]
    edited_geometry = [mutation_geometry_fingerprint(stage) for stage in edited_stages]
    restored_geometry = [mutation_geometry_fingerprint(stage) for stage in restored_stages]
    before_naming = [mutation_naming_fingerprint(stage) for stage in before_stages]
    edited_naming = [mutation_naming_fingerprint(stage) for stage in edited_stages]
    restored_naming = [mutation_naming_fingerprint(stage) for stage in restored_stages]
    changed_ordinals = [ordinal for ordinal, (before, edited) in enumerate(zip(before_geometry, edited_geometry)) if before != edited]
    restoration_drift_ordinals = [ordinal for ordinal, (before, restored) in enumerate(zip(before_geometry, restored_geometry)) if before != restored]
    naming_changed_ordinals = [ordinal for ordinal, (before, edited) in enumerate(zip(before_naming, edited_naming)) if before != edited]
    naming_restoration_drift_ordinals = [ordinal for ordinal, (before, restored) in enumerate(zip(before_naming, restored_naming)) if before != restored]
    final_ordinal = stage_ordinals[final.Name]
    target_ordinal = stage_ordinals[target.Name]
    property_changed = before_value != edited_value_readback
    final_shape_changed = before_geometry[final_ordinal] != edited_geometry[final_ordinal]
    final_brep_changed = before_stages[final_ordinal]["shape"]["brepSha256"] != edited_stages[final_ordinal]["shape"]["brepSha256"]
    property_restored = before_value == restored_value
    all_stages_restored = len(restoration_drift_ordinals) == 0
    edited_shapes_valid = all(stage["shape"]["valid"] for stage in edited_stages)
    status = "pass" if native_editable and property_changed and final_shape_changed and property_restored and all_stages_restored and edited_shapes_valid else "failed"
    return {
        "schemaVersion": 1,
        "category": construction["category"],
        "contract": {
            "targetObject": target.Name,
            "targetTypeId": target.TypeId,
            "targetStageOrdinal": target_ordinal,
            "propertyPath": property_name,
            "propertyType": property_type,
            "editorModes": editor_modes,
            "nativeEditable": native_editable and property_type.startswith("App::Property"),
            "finalObject": final.Name,
            "finalStageOrdinal": final_ordinal,
            "requiresFinalPropagation": True,
            "requiresAllStageRestore": True,
        },
        "values": {
            "before": before_value,
            "edited": edited_value_readback,
            "restored": restored_value,
        },
        "phases": {
            "before": mutation_stage_snapshot(case_id, before_stages),
            "edited": mutation_stage_snapshot(case_id, edited_stages),
            "restored": mutation_stage_snapshot(case_id, restored_stages),
        },
        "metrics": {
            "propertyChanged": property_changed,
            "finalShapeChanged": final_shape_changed,
            "finalBrepChanged": final_brep_changed,
            "propertyRestored": property_restored,
            "allStagesRestored": all_stages_restored,
            "allStageGeometryRestored": all_stages_restored,
            "allStageNamingRestored": len(naming_restoration_drift_ordinals) == 0,
            "editedShapesValid": edited_shapes_valid,
            "changedStageOrdinals": changed_ordinals,
            "changedStages": len(changed_ordinals),
            "restorationDriftOrdinals": restoration_drift_ordinals,
            "restorationDriftStages": len(restoration_drift_ordinals),
            "namingChangedStageOrdinals": naming_changed_ordinals,
            "namingChangedStages": len(naming_changed_ordinals),
            "namingRestorationDriftOrdinals": naming_restoration_drift_ordinals,
            "namingRestorationDriftStages": len(naming_restoration_drift_ordinals),
            "stageRecords": len(before_stages),
            "phaseStageRecords": len(before_stages) * 3,
        },
        "status": status,
    }


def make_boolean(doc, operation, index):
    base = doc.addObject("Part::Box", "Box%02d" % index)
    base.Length, base.Width, base.Height = 10.0, 10.0, 10.0
    tool = doc.addObject("Part::Cylinder", "Cylinder%02d" % index)
    tool.Radius, tool.Height = 3.0 + (index % 3), 14.0
    tool.Placement.Base = App.Vector(5.0, 5.0, -2.0)
    if operation == "fuse":
        result = doc.addObject("Part::Fuse", "Fuse%02d" % index)
    elif operation == "common":
        result = doc.addObject("Part::Common", "Common%02d" % index)
    else:
        result = doc.addObject("Part::Cut", "Cut%02d" % index)
    result.Base, result.Tool = base, tool
    return {
        "category": "boolean",
        "final": result,
        "base": base,
        "mutationTarget": tool,
        "mutationProperty": "Radius",
        "mutationValue": float(tool.Radius.Value) + 0.5,
    }


def make_partdesign(doc, mode, index):
    body = doc.addObject("PartDesign::Body", "Body%02d" % index)
    sketch = square_sketch(body, "Sketch%02d" % index, 2.0 + (index % 2) * 0.5)
    pad = body.newObject("PartDesign::Pad", "Pad%02d" % index)
    pad.Profile, pad.Length = sketch, 5.0 + (index % 4)
    if mode == "midplane":
        pad.Midplane = True
    elif mode == "reverse":
        pad.Reversed = True
    elif mode == "taper":
        pad.TaperAngle = 4.0
    elif mode == "twoside":
        pad.Length2 = 2.0
        if hasattr(pad, "Type2"):
            pad.Type2 = "Length"
    doc.recompute()
    if mode.startswith("pocket"):
        base = pad
        pocket_sketch = square_sketch(body, "PocketSketch%02d" % index, 0.8, 5.0)
        pocket = body.newObject("PartDesign::Pocket", "Pocket%02d" % index)
        pocket.Profile = pocket_sketch
        if hasattr(pocket, "BaseFeature"):
            pocket.BaseFeature = base
        pocket.Length = 3.0
        if mode == "pocket-through":
            pocket.Type = 1
        elif mode == "pocket-midplane":
            pocket.Midplane = True
        elif mode == "pocket-twoside":
            pocket.Length2 = 1.0
        doc.recompute()
        return {
            "category": "partdesign",
            "final": pocket,
            "mutationTarget": pad,
            "mutationProperty": "Length",
            "mutationValue": float(pad.Length.Value) + 0.75,
        }
    return {
        "category": "partdesign",
        "final": pad,
        "mutationTarget": pad,
        "mutationProperty": "Length",
        "mutationValue": float(pad.Length.Value) + 0.75,
    }


def make_composite(doc, index):
    first_construction = make_boolean(doc, "fuse" if index % 2 else "cut", index)
    first = first_construction["final"]
    second = doc.addObject("Part::Box", "SecondBox%02d" % index)
    second.Length, second.Width, second.Height = 4.0, 4.0, 4.0
    second.Placement.Base = App.Vector(3.0, 3.0, 3.0)
    final = doc.addObject("Part::Cut", "CompositeCut%02d" % index)
    final.Base, final.Tool = first, second
    return {
        "category": "composite-boolean",
        "final": final,
        "mutationTarget": first_construction["base"],
        "mutationProperty": "Length",
        "mutationValue": float(first_construction["base"].Length.Value) + 0.5,
    }


def make_dressup(doc, mode, index):
    body = doc.addObject("PartDesign::Body", "DressupBody%02d" % index)
    box = body.newObject("PartDesign::AdditiveBox", "DressupBox%02d" % index)
    box.Length, box.Width, box.Height = 10.0, 10.0, 10.0
    doc.recompute()
    if mode == "fillet":
        feature = body.newObject("PartDesign::Fillet", "Fillet%02d" % index)
        feature.Base, feature.Radius = (box, ["Edge1"]), 1.0
    else:
        feature = body.newObject("PartDesign::Chamfer", "Chamfer%02d" % index)
        feature.Base, feature.Size = (box, ["Edge1"]), 1.0
    doc.recompute()
    return {
        "category": "dress-up",
        "final": feature,
        "mutationTarget": feature,
        "mutationProperty": "Radius" if mode == "fillet" else "Size",
        "mutationValue": 1.25,
    }


def collect_case(case_id, factory, index, directory):
    name = "ElementMapOracle%02d" % index
    doc = App.newDocument(name)
    try:
        construction = factory(doc, index)
        final = construction["final"]
        doc.recompute()
        stages = [entry for obj in doc.Objects if (entry := stage_report(obj)) is not None]
        mutation = capture_mutation(case_id, doc, construction, stages)
        stages = collect_named_stages(doc, [stage["name"] for stage in stages])
        before_names = {(stage["name"], entry["name"]): normalize_mapped_name(entry.get("mappedName")) for stage in stages for entry in stage["names"]}
        initial_correlations = correlation_snapshot(case_id, stages)
        final_name = getattr(final, "Name", "")
        path = os.path.join(directory, "%s.FCStd" % case_id)
        doc.saveAs(path)
        resources = {}
        string_hasher_resource = None
        with zipfile.ZipFile(path, "r") as archive:
            for resource_path in archive.namelist():
                if resource_path == "StringHasher.Table.txt":
                    raw = archive.read(resource_path)
                    try:
                        text = raw.decode("utf-8")
                    except UnicodeDecodeError:
                        text = None
                    string_hasher_resource = {"sha256": hashlib.sha256(raw).hexdigest(), "text": text} if text is not None else {"sha256": hashlib.sha256(raw).hexdigest(), "text": None}
                    continue
                if not resource_path.endswith(".Map.txt"):
                    continue
                raw = archive.read(resource_path)
                try:
                    text = raw.decode("utf-8")
                except UnicodeDecodeError:
                    continue
                resources[resource_path] = {"sha256": hashlib.sha256(raw).hexdigest(), "text": text}
        stages = [dict(stage, namingEvidenceStatus=stage["nativeEvidence"]["status"], elementMap2ResourcePaths=[path for path in resources if path.startswith(stage["name"] + ".")]) for stage in stages]
        App.closeDocument(doc.Name)
        reopened = App.openDocument(path)
        reopened.recompute()
        after_stages = [entry for obj in reopened.Objects if (entry := stage_report(obj)) is not None]
        after_names = {(stage["name"], entry["name"]): normalize_mapped_name(entry.get("mappedName")) for stage in after_stages for entry in stage["names"]}
        reopened_correlations = correlation_snapshot(case_id, after_stages)
        roundtrip_drift = sum(1 for key, value in before_names.items() if after_names.get(key) != value)
        resaved_path = os.path.join(directory, "%s-resaved.FCStd" % case_id)
        reopened.saveAs(resaved_path)
        App.closeDocument(reopened.Name)
        resaved = App.openDocument(resaved_path)
        resaved.recompute()
        resaved_stages = [entry for obj in resaved.Objects if (entry := stage_report(obj)) is not None]
        resaved_names = {(stage["name"], entry["name"]): normalize_mapped_name(entry.get("mappedName")) for stage in resaved_stages for entry in stage["names"]}
        resaved_correlations = correlation_snapshot(case_id, resaved_stages)
        resave_name_drift = sum(1 for key, value in before_names.items() if resaved_names.get(key) != value)
        App.closeDocument(resaved.Name)
        return {"id": case_id, "category": construction["category"], "finalObject": final_name, "stages": stages, "mutation": mutation, "stageCorrelations": {"initial": initial_correlations, "reopened": reopened_correlations, "resaved": resaved_correlations}, "elementMapResources": resources, "stringHasherResource": string_hasher_resource, "roundtripNameDrift": roundtrip_drift, "resaveNameDrift": resave_name_drift, "nativeDesktopResaveCovered": True, "status": "pass" if mutation["status"] == "pass" else "failed"}
    except Exception as error:
        return {"id": case_id, "finalObject": "", "stages": [], "elementMapResources": {}, "status": "failed", "error": str(error)}
    finally:
        if name in App.listDocuments():
            App.closeDocument(name)


factories = []
for operation in ("fuse", "cut", "common"):
    for variant in range(1, 4):
        factories.append(("boolean-%s-%d" % (operation, variant), lambda doc, index, op=operation: make_boolean(doc, op, index)))
for mode in ("plain", "midplane", "reverse", "taper", "twoside", "pocket", "pocket-through", "pocket-midplane", "pocket-twoside", "pocket-taper", "pocket-up-to-face"):
    factories.append(("partdesign-%s" % mode, lambda doc, index, value=mode: make_partdesign(doc, value, index)))
for variant in range(1, 7):
    factories.append(("composite-%02d" % variant, make_composite))
for mode in ("fillet", "chamfer"):
    for variant in range(1, 3):
        factories.append(("dressup-%s-%d" % (mode, variant), lambda doc, index, value=mode: make_dressup(doc, value, index)))

with tempfile.TemporaryDirectory(prefix="freecad-elementmap-oracle-") as directory:
    cases = [collect_case(case_id, factory, index + 1, directory) for index, (case_id, factory) in enumerate(factories[:30])]

version = App.Version()
report = {
    "schemaVersion": 1,
    "mutationContractVersion": 1,
    "baselineId": "freecad-1.1.1-composite-history-elementmap2",
    "freecadVersion": version_text(),
    "gitCommit": FREECAD_COMMIT,
    "status": "pass" if len(cases) == 30 and all(case["status"] == "pass" for case in cases) else "failed",
    "cases": cases,
    "summary": {
        "cases": len(cases),
        "passed": sum(1 for case in cases if case["status"] == "pass"),
        "failed": sum(1 for case in cases if case["status"] != "pass"),
        "categoryCases": {category: sum(1 for case in cases if case.get("category") == category) for category in ("boolean", "partdesign", "composite-boolean", "dress-up")},
        "stageCorrelations": sum(len(case.get("stages", [])) for case in cases),
        "relationRecords": sum(sum(len(stage.get("relations", [])) for stage in case.get("stages", [])) for case in cases),
        "mutationCases": sum(1 for case in cases if case.get("mutation")),
        "mutationPassed": sum(1 for case in cases if case.get("mutation", {}).get("status") == "pass"),
        "mutationStageRecords": sum(case.get("mutation", {}).get("metrics", {}).get("stageRecords", 0) for case in cases),
        "mutationPhaseStageRecords": sum(case.get("mutation", {}).get("metrics", {}).get("phaseStageRecords", 0) for case in cases),
        "mutationFinalPropagationFailures": sum(1 for case in cases if case.get("mutation", {}).get("metrics", {}).get("finalShapeChanged") is not True),
        "mutationStageRestoreFailures": sum(case.get("mutation", {}).get("metrics", {}).get("restorationDriftStages", 0) for case in cases),
        "mutationNamingRestoreDriftCases": sum(1 for case in cases if case.get("mutation", {}).get("metrics", {}).get("namingRestorationDriftStages", 0) > 0),
        "mutationNamingRestoreDriftStages": sum(case.get("mutation", {}).get("metrics", {}).get("namingRestorationDriftStages", 0) for case in cases),
        "roundtripNameDrift": sum(case.get("roundtripNameDrift", 0) for case in cases),
        "resaveNameDrift": sum(case.get("resaveNameDrift", 0) for case in cases),
        "nativeDesktopResaveCases": sum(1 for case in cases if case.get("nativeDesktopResaveCovered") is True),
    },
}
print("FREECAD_COMPOSITE_HISTORY_ELEMENTMAP_RESULT=" + json.dumps(report, sort_keys=True, separators=(",", ":")))
