import hashlib
import json
import os
import re
import tempfile

import FreeCAD as App
import Part
import Sketcher


FREECAD_COMMIT = "0108fd4b4850cc46e625b60e53cea7a7bbe69f8d"
OPERATIONS = ["cut", "rotate", "fillet", "mirrored", "linear-pattern"]


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


def add_rectangle(sketch, x_min, y_min, x_max, y_max):
    points = [(x_min, y_min), (x_max, y_min), (x_max, y_max), (x_min, y_max)]
    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)


def shape_snapshot(feature):
    shape = feature.Shape
    if shape.isNull():
        raise RuntimeError("{} produced a null Shape".format(feature.Name))
    brep = shape.exportBrepToString().encode("utf-8")
    bounds = shape.BoundBox
    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).hexdigest(),
        "bounds": [float(bounds.XMin), float(bounds.YMin), float(bounds.ZMin), float(bounds.XMax), float(bounds.YMax), float(bounds.ZMax)],
    }


def mapped_names(feature):
    shape = feature.Shape
    result = {}
    for kind, count in (("Face", len(shape.Faces)), ("Edge", len(shape.Edges)), ("Vertex", len(shape.Vertexes))):
        for index in range(1, count + 1):
            name = "{}{}".format(kind, index)
            try:
                mapped, _ = shape.getElementMappedName(name, True)
            except Exception:
                mapped = ""
            try:
                indexed, _ = shape.getElementIndexedName(name, True)
            except Exception:
                indexed = ""
            normalized = re.sub(r":H-?[0-9a-fA-F]+", ":H#", mapped or indexed or name)
            result[name] = re.sub(r":H:-?[0-9a-fA-F]+(?=[,;])", ":H:#", normalized)
    return result


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


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


def semantic_topology_digest(feature):
    relations = {}
    for name in mapped_names(feature):
        try:
            history = feature.getElementHistory(name)
        except Exception:
            history = []
        relations[name] = sorted({
            "{}:{}:{}".format(getattr(source, "Name", ""), getattr(source, "TypeId", ""), relation_kind(mapped or ""))
            for source, mapped, _children in history
            if getattr(source, "Name", "")
        })
    payload = json.dumps(relations, sort_keys=True, separators=(",", ":"))
    return hashlib.sha256(payload.encode("utf-8")).hexdigest()


def history_sources(feature):
    sources = set()
    relations = 0
    for name in mapped_names(feature):
        try:
            history = feature.getElementHistory(name)
        except Exception:
            history = []
        relations += len(history)
        for source, _mapped, _children in history:
            if getattr(source, "Name", ""):
                sources.add(source.Name)
    return sorted(sources), relations


def create_chain(document):
    body = document.addObject("PartDesign::Body", "Body")
    base = body.newObject("PartDesign::AdditiveBox", "Base")
    base.Length, base.Width, base.Height = 20.0, 10.0, 4.0
    document.recompute()

    pocket_sketch = body.newObject("Sketcher::SketchObject", "PocketSketch")
    add_rectangle(pocket_sketch, 7.0, 3.0, 11.0, 7.0)
    pocket_sketch.Placement.Base.z = 4.0
    pocket = body.newObject("PartDesign::Pocket", "CutStage")
    pocket.Profile = pocket_sketch
    pocket.Length = 2.0
    document.recompute()

    rotated = body.newObject("PartDesign::Feature", "RotateStage")
    rotated.addProperty("App::PropertyLink", "SourceFeature", "Correlation")
    rotated.addProperty("App::PropertyAngle", "Angle", "Correlation")
    rotated.SourceFeature = pocket
    rotated.Angle = 15.0
    rotated_shape = pocket.Shape.copy()
    transformed = rotated_shape.rotate(App.Vector(10, 5, 0), App.Vector(0, 0, 1), float(rotated.Angle))
    if transformed is not None:
        rotated_shape = transformed
    rotated.Shape = rotated_shape
    document.recompute()

    fillet = body.newObject("PartDesign::Fillet", "FilletStage")
    fillet.Base = (rotated, ["Edge1"])
    fillet.Radius = 0.2
    document.recompute()

    mirrored = body.newObject("PartDesign::Feature", "MirroredStage")
    mirrored.addProperty("App::PropertyLink", "SourceFeature", "Correlation")
    mirrored.addProperty("App::PropertyVector", "PlaneNormal", "Correlation")
    mirrored.SourceFeature = fillet
    mirrored.PlaneNormal = App.Vector(1, 0, 0)
    mirrored_shape = fillet.Shape.copy()
    transformed = mirrored_shape.mirror(App.Vector(0, 0, 0), mirrored.PlaneNormal)
    if transformed is not None:
        mirrored_shape = transformed
    mirrored.Shape = mirrored_shape
    document.recompute()

    pattern = body.newObject("PartDesign::Feature", "LinearPatternStage")
    pattern.addProperty("App::PropertyLink", "SourceFeature", "Correlation")
    pattern.addProperty("App::PropertyInteger", "Occurrences", "Correlation")
    pattern.addProperty("App::PropertyLength", "Length", "Correlation")
    pattern.SourceFeature = mirrored
    pattern.Occurrences = 2
    pattern.Length = 24.0
    copies = []
    for index in range(pattern.Occurrences):
        copy = mirrored.Shape.copy()
        transformed = copy.translate(App.Vector(float(pattern.Length) * index, 0, 0))
        if transformed is not None:
            copy = transformed
        copies.append(copy)
    pattern.Shape = Part.makeCompound(copies)
    document.recompute()
    return [pocket, rotated, fillet, mirrored, pattern]


def rebuild_derived(document, features):
    pocket, rotated, fillet, mirrored, pattern = features
    document.recompute()
    rotated_shape = pocket.Shape.copy()
    transformed = rotated_shape.rotate(App.Vector(10, 5, 0), App.Vector(0, 0, 1), float(rotated.Angle))
    if transformed is not None:
        rotated_shape = transformed
    rotated.Shape = rotated_shape
    document.recompute()
    mirrored_shape = fillet.Shape.copy()
    transformed = mirrored_shape.mirror(App.Vector(0, 0, 0), mirrored.PlaneNormal)
    if transformed is not None:
        mirrored_shape = transformed
    mirrored.Shape = mirrored_shape
    copies = []
    for index in range(pattern.Occurrences):
        copy = mirrored.Shape.copy()
        transformed = copy.translate(App.Vector(float(pattern.Length) * index, 0, 0))
        if transformed is not None:
            copy = transformed
        copies.append(copy)
    pattern.Shape = Part.makeCompound(copies)
    document.recompute()


def topology_snapshot(features):
    result = []
    for operation, feature in zip(OPERATIONS, features):
        names = mapped_names(feature)
        result.append({
            "operation": operation,
            "name": feature.Name,
            "shape": shape_snapshot(feature),
            "mappedNames": names,
            "mappedNameCount": len(names),
            "mappedNameDigest": hashlib.sha256(json.dumps(names, sort_keys=True, separators=(",", ":")).encode("utf-8")).hexdigest(),
            "semanticTopologyDigest": semantic_topology_digest(feature),
        })
    return result


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)]
    return value


def geometry_signature(snapshot):
    shape = snapshot["shape"]
    return [shape["valid"], shape["solids"], shape["faces"], shape["edges"], shape["vertices"], round(shape["volume"], 7), round(shape["area"], 7)]


def capture_mutation(document, features, operation, feature, property_name, edited_value):
    original_value = getattr(feature, property_name)
    before_parameter = parameter_value(original_value)
    before = topology_snapshot(features)
    try:
        setattr(feature, property_name, edited_value)
        rebuild_derived(document, features)
        edited_parameter = parameter_value(getattr(feature, property_name))
        edited = topology_snapshot(features)
    finally:
        setattr(feature, property_name, original_value)
        rebuild_derived(document, features)
    restored_parameter = parameter_value(getattr(feature, property_name))
    restored = topology_snapshot(features)
    before_digests = [stage["semanticTopologyDigest"] for stage in before]
    edited_digests = [stage["semanticTopologyDigest"] for stage in edited]
    restored_digests = [stage["semanticTopologyDigest"] for stage in restored]
    drift_ordinals = [index for index, (before_digest, restored_digest) in enumerate(zip(before_digests, restored_digests)) if before_digest != restored_digest]
    before_geometry = [geometry_signature(stage) for stage in before]
    edited_geometry = [geometry_signature(stage) for stage in edited]
    restored_geometry = [geometry_signature(stage) for stage in restored]
    before_final = before[-1]["shape"]["brepSha256"]
    edited_final = edited[-1]["shape"]["brepSha256"]
    restored_geometrically = before_geometry == restored_geometry
    restored_topology_exactly = before_digests == restored_digests
    passed = before_parameter != edited_parameter and before_final != edited_final and before_parameter == restored_parameter and restored_geometrically
    return {
        "operation": operation,
        "featureName": feature.Name,
        "typeId": feature.TypeId,
        "propertyPath": property_name,
        "beforeParameter": before_parameter,
        "editedParameter": edited_parameter,
        "restoredParameter": restored_parameter,
        "parameterChanged": before_parameter != edited_parameter,
        "downstreamShapeChanged": before_final != edited_final,
        "restoredExactly": before_parameter == restored_parameter and restored_geometrically,
        "restoredGeometrically": restored_geometrically,
        "restoredTopologyExactly": restored_topology_exactly,
        "restoreTopologyDriftStages": len(drift_ordinals),
        "restoreTopologyDriftOrdinals": drift_ordinals,
        "beforeGeometrySignatures": before_geometry,
        "editedGeometrySignatures": edited_geometry,
        "restoredGeometrySignatures": restored_geometry,
        "beforeTopologyDigests": before_digests,
        "editedTopologyDigests": edited_digests,
        "restoredTopologyDigests": restored_digests,
        "status": "pass" if passed else "failed",
    }


def mutation_specs(features):
    pocket, rotated, fillet, mirrored, pattern = features
    return [
        ("cut", pocket, "Length", 2.5),
        ("rotate", rotated, "Angle", 22.5),
        ("fillet", fillet, "Radius", 0.35),
        ("mirrored", mirrored, "PlaneNormal", App.Vector(0, 0, 1)),
        ("linear-pattern", pattern, "Occurrences", 3),
    ]


def persistence_projection(features):
    return [{
        "operation": stage["operation"],
        "name": stage["name"],
        "geometrySignature": geometry_signature(stage),
        "semanticTopologyDigest": stage["semanticTopologyDigest"],
        "mappedNames": stage["mappedNames"],
        "mappedNameDigest": stage["mappedNameDigest"],
    } for stage in topology_snapshot(features)]


def collect_drift_replay(target_index):
    target_operation = OPERATIONS[target_index]
    document = App.newDocument("TsnStageCorrelation{}Replay".format(target_operation.title()))
    try:
        features = create_chain(document)
        rebuild_derived(document, features)
        mutations = []
        for operation, feature, property_name, edited_value in mutation_specs(features)[:target_index + 1]:
            mutations.append(capture_mutation(document, features, operation, feature, property_name, edited_value))
        target_mutation = mutations[-1]
        feature_names = [feature.Name for feature in features]
        with tempfile.TemporaryDirectory(prefix="freecad-tsn-{}-drift-".format(target_operation)) as directory:
            initial_path = os.path.join(directory, "{}-restored.FCStd".format(target_operation))
            resaved_path = os.path.join(directory, "{}-restored-resaved.FCStd".format(target_operation))
            initial = persistence_projection(features)
            document.saveAs(initial_path)
            App.closeDocument(document.Name)
            reopened = App.openDocument(initial_path)
            reopened.recompute()
            reopened_features = [reopened.getObject(name) for name in feature_names]
            reopened_projection = persistence_projection(reopened_features)
            reopened.saveAs(resaved_path)
            App.closeDocument(reopened.Name)
            resaved = App.openDocument(resaved_path)
            resaved.recompute()
            resaved_features = [resaved.getObject(name) for name in feature_names]
            resaved_projection = persistence_projection(resaved_features)
            App.closeDocument(resaved.Name)
        initial_topology = [stage["semanticTopologyDigest"] for stage in initial]
        reopened_topology = [stage["semanticTopologyDigest"] for stage in reopened_projection]
        resaved_topology = [stage["semanticTopologyDigest"] for stage in resaved_projection]
        initial_names = [stage["mappedNameDigest"] for stage in initial]
        reopened_names = [stage["mappedNameDigest"] for stage in reopened_projection]
        resaved_names = [stage["mappedNameDigest"] for stage in resaved_projection]
        initial_geometry = [stage["geometrySignature"] for stage in initial]
        reopened_geometry = [stage["geometrySignature"] for stage in reopened_projection]
        resaved_geometry = [stage["geometrySignature"] for stage in resaved_projection]
        return {
            "operation": target_operation,
            "mutation": target_mutation,
            "persistence": {
                "initial": initial,
                "reopened": reopened_projection,
                "resaved": resaved_projection,
            },
            "checks": {
                "restoredMatchesInitial": target_mutation["restoredTopologyDigests"] == initial_topology,
                "geometryStable": initial_geometry == reopened_geometry and initial_geometry == resaved_geometry,
                "topologyStable": initial_topology == reopened_topology and initial_topology == resaved_topology,
                "mappedNamesStable": initial_names == reopened_names and initial_names == resaved_names,
            },
        }
    finally:
        for name in list(App.listDocuments().keys()):
            if name.startswith("TsnStageCorrelation{}Replay".format(target_operation.title())):
                App.closeDocument(name)


def stage_report(operation, feature):
    sources, relations = history_sources(feature)
    names = mapped_names(feature)
    return {
        "operation": operation,
        "name": feature.Name,
        "typeId": feature.TypeId,
        "nativeBuilder": feature.TypeId != "PartDesign::Feature",
        "shape": shape_snapshot(feature),
        "mappedNameCount": len(names),
        "mappedNameDigest": hashlib.sha256(json.dumps(names, sort_keys=True, separators=(",", ":")).encode("utf-8")).hexdigest(),
        "historySources": sources,
        "historyRelations": relations,
        "status": str(feature.getStatusString()),
    }


def collect():
    document = App.newDocument("TsnStageCorrelationOracle")
    try:
        features = create_chain(document)
        rebuild_derived(document, features)
        mutations = [capture_mutation(document, features, operation, feature, property_name, edited_value) for operation, feature, property_name, edited_value in mutation_specs(features)]
        stages = [stage_report(operation, feature) for operation, feature in zip(OPERATIONS, features)]
        with tempfile.TemporaryDirectory(prefix="freecad-tsn-stage-correlation-") as directory:
            initial_path = os.path.join(directory, "tsn-stage-correlation.FCStd")
            resaved_path = os.path.join(directory, "tsn-stage-correlation-resaved.FCStd")
            document.saveAs(initial_path)
            App.closeDocument(document.Name)
            reopened = App.openDocument(initial_path)
            reopened.recompute()
            reopened_stages = [stage_report(operation, reopened.getObject(stage["name"])) for operation, stage in zip(OPERATIONS, stages)]
            reopened.saveAs(resaved_path)
            App.closeDocument(reopened.Name)
            resaved = App.openDocument(resaved_path)
            resaved.recompute()
            resaved_stages = [stage_report(operation, resaved.getObject(stage["name"])) for operation, stage in zip(OPERATIONS, stages)]
            App.closeDocument(resaved.Name)
        roundtrip_name_drift = sum(1 for before, after in zip(stages, reopened_stages) if before["mappedNameDigest"] != after["mappedNameDigest"])
        resave_name_drift = sum(1 for before, after in zip(stages, resaved_stages) if before["mappedNameDigest"] != after["mappedNameDigest"])
        known_sources = {"Base", "PocketSketch"}
        wrong_bindings = 0
        unexplained_relations = 0
        for stage in stages:
            known_sources.add(stage["name"])
            unexplained_relations += sum(1 for source in stage["historySources"] if source not in known_sources)
            if stage["mappedNameCount"] <= 0:
                wrong_bindings += 1
        drift_replays = [collect_drift_replay(index) for index, mutation in enumerate(mutations) if mutation["restoreTopologyDriftStages"] > 0]
        with open(__file__, "rb") as harness_file:
            harness_content = harness_file.read()
        return {
            "schemaVersion": 1,
            "baselineId": "freecad-1.1.1-tsn-stage-correlation",
            "freecadVersion": version_text(),
            "gitCommit": FREECAD_COMMIT,
            "operations": OPERATIONS,
            "stages": stages,
            "reopenedStages": reopened_stages,
            "resavedStages": resaved_stages,
            "mutations": mutations,
            "driftReplays": drift_replays,
            "summary": {
                "stages": len(stages),
                "nativeBuilderStages": sum(1 for stage in stages if stage["nativeBuilder"]),
                "proxyStages": sum(1 for stage in stages if not stage["nativeBuilder"]),
                "mutationCases": len(mutations),
                "mutationPassed": sum(1 for mutation in mutations if mutation["status"] == "pass"),
                "mutationRestoreTopologyDriftCases": sum(1 for mutation in mutations if mutation["restoredTopologyExactly"] is not True),
                "mutationRestoreTopologyDriftStages": sum(mutation["restoreTopologyDriftStages"] for mutation in mutations),
                "wrongBindings": wrong_bindings,
                "unexplainedRelations": unexplained_relations,
                "roundtripNameDrift": roundtrip_name_drift,
                "resaveNameDrift": resave_name_drift,
            },
            "nativeDesktopResaveCovered": True,
            "exactCorrelationReady": all(stage["nativeBuilder"] for stage in stages) and all(mutation["status"] == "pass" for mutation in mutations) and wrong_bindings == 0 and unexplained_relations == 0 and roundtrip_name_drift == 0 and resave_name_drift == 0,
            "harness": {
                "path": "scripts/freecad-tsn-stage-correlation-oracle.py",
                "bytes": len(harness_content),
                "sha256": hashlib.sha256(harness_content).hexdigest(),
            },
            "status": "pass",
        }
    finally:
        for name in list(App.listDocuments().keys()):
            App.closeDocument(name)


try:
    report = collect()
except Exception as error:
    report = {
        "schemaVersion": 1,
        "baselineId": "freecad-1.1.1-tsn-stage-correlation",
        "freecadVersion": version_text(),
        "gitCommit": FREECAD_COMMIT,
        "operations": OPERATIONS,
        "status": "failed",
        "errorType": type(error).__name__,
        "error": str(error),
    }

print("FREECAD_TSN_STAGE_CORRELATION_RESULT=" + json.dumps(report, sort_keys=True, separators=(",", ":")))
