import hashlib
import json
import math
import os

import FreeCAD as App
import Import
import Part


FREECAD_COMMIT = "0108fd4b4850cc46e625b60e53cea7a7bbe69f8d"


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


def shape_snapshot_from_shape(shape, include_brep=False):
    if shape.isNull():
        raise RuntimeError("ordered pair produced a null Shape")
    bounds = shape.BoundBox
    snapshot = {
        "valid": bool(shape.isValid()),
        "solids": len(shape.Solids),
        "faces": len(shape.Faces),
        "edges": len(shape.Edges),
        "vertices": len(shape.Vertexes),
        "volume": round(float(shape.Volume), 7),
        "area": round(float(shape.Area), 7),
        "bounds": [round(float(value), 7) for value in (bounds.XMin, bounds.YMin, bounds.ZMin, bounds.XMax, bounds.YMax, bounds.ZMax)],
    }
    if include_brep:
        brep = shape.exportBrepToString()
        brep_bytes = brep.encode("utf-8") if isinstance(brep, str) else bytes(brep)
        snapshot["brepSha256"] = hashlib.sha256(brep_bytes).hexdigest()
    return snapshot


def shape_snapshot(feature):
    return shape_snapshot_from_shape(feature.Shape)


def imported_solid_shapes(document):
    shapes = []
    for obj in document.Objects:
        if obj.TypeId != "Part::Feature" or not hasattr(obj, "Shape") or obj.Shape.isNull() or not obj.Shape.isValid() or not obj.Shape.Solids:
            continue
        bounds = obj.Shape.BoundBox
        if not all(math.isfinite(float(value)) for value in (bounds.XMin, bounds.YMin, bounds.ZMin, bounds.XMax, bounds.YMax, bounds.ZMax)):
            continue
        shapes.append(obj.Shape)
    return shapes


def phase_snapshot(document):
    feature = document.getObject("PairResult")
    if feature is None:
        raise RuntimeError("PairResult is absent after FCStd persistence")
    evidence = str(feature.NamingEvidence)
    return {
        "pair": str(feature.OrderedPair),
        "namingEvidenceBytes": len(evidence.encode("utf-8")),
        "namingEvidenceSha256": hashlib.sha256(evidence.encode("utf-8")).hexdigest(),
        "shape": shape_snapshot(feature),
    }


def document_snapshot(document):
    datum_types = {"App::Line", "App::Plane", "App::Point"}
    return {
        "objects": [{
            "name": obj.Name,
            "typeId": obj.TypeId,
            **({"shape": shape_snapshot(obj)} if obj.TypeId not in datum_types and hasattr(obj, "Shape") and not obj.Shape.isNull() else {}),
        } for obj in document.Objects],
    }


def object_set_snapshot(document):
    return [{"name": obj.Name, "typeId": obj.TypeId} for obj in document.Objects]


def link_snapshot(value):
    linked = value
    subelements = []
    if isinstance(value, tuple):
        linked = value[0]
        if len(value) > 1:
            subelements = [str(item) for item in value[1]]
    return {
        "name": linked.Name if linked is not None else None,
        "typeId": linked.TypeId if linked is not None else None,
        "subelements": subelements,
    }


def collect_accepted():
    step_path = os.environ["FREECAD_PAIR_STEP_PATH"]
    evidence_path = os.environ["FREECAD_PAIR_EVIDENCE_PATH"]
    pair = os.environ["FREECAD_ORDERED_PAIR"]
    output_directory = os.environ["FREECAD_PAIR_OUTPUT_DIRECTORY"]
    with open(evidence_path, "r", encoding="utf-8") as evidence_file:
        evidence = evidence_file.read()
    document = App.newDocument("FreeCadOrderedPairResave")
    try:
        Import.insert(step_path, document.Name)
        document.recompute()
        source_shapes = imported_solid_shapes(document)
        if not source_shapes:
            raise RuntimeError("STEP import produced no Shape")
        result = document.addObject("Part::Feature", "PairResult")
        result.Shape = source_shapes[0].copy() if len(source_shapes) == 1 else Part.makeCompound([shape.copy() for shape in source_shapes])
        result.addProperty("App::PropertyString", "OrderedPair", "Parity")
        result.addProperty("App::PropertyString", "NamingEvidence", "Parity")
        result.OrderedPair = pair
        result.NamingEvidence = evidence
        document.recompute()
        initial = phase_snapshot(document)
        initial_path = os.path.join(output_directory, "ordered-pair.FCStd")
        resaved_path = os.path.join(output_directory, "ordered-pair-resaved.FCStd")
        document.saveAs(initial_path)
        App.closeDocument(document.Name)
        reopened = App.openDocument(initial_path)
        reopened.recompute()
        reopened_snapshot = phase_snapshot(reopened)
        reopened.saveAs(resaved_path)
        App.closeDocument(reopened.Name)
        resaved = App.openDocument(resaved_path)
        resaved.recompute()
        resaved_snapshot = phase_snapshot(resaved)
        App.closeDocument(resaved.Name)
        return {
            "schemaVersion": 1,
            "baselineId": "freecad-1.1.1-ordered-operation-pair-resave",
            "freecadVersion": version_text(),
            "gitCommit": FREECAD_COMMIT,
            "pair": pair,
            "decision": "accepted",
            "phases": {"initial": initial, "reopened": reopened_snapshot, "resaved": resaved_snapshot},
            "checks": {
                "shapeStable": initial["shape"] == reopened_snapshot["shape"] and initial["shape"] == resaved_snapshot["shape"],
                "namingEvidenceStable": initial["namingEvidenceSha256"] == reopened_snapshot["namingEvidenceSha256"] and initial["namingEvidenceSha256"] == resaved_snapshot["namingEvidenceSha256"],
            },
            "status": "pass",
        }
    finally:
        for name in list(App.listDocuments().keys()):
            App.closeDocument(name)


def collect_rejected():
    step_path = os.environ["FREECAD_PAIR_STEP_PATH"]
    pair = os.environ["FREECAD_ORDERED_PAIR"]
    operation = pair.split("->", 1)[1]
    output_directory = os.environ["FREECAD_PAIR_OUTPUT_DIRECTORY"]
    document = App.newDocument("FreeCadOrderedPairRejection")
    try:
        Import.insert(step_path, document.Name)
        document.recompute()
        source_objects = [obj for obj in document.Objects if hasattr(obj, "Shape") and not obj.Shape.isNull()]
        solid_sources = [obj for obj in source_objects if obj.TypeId == "Part::Feature" and obj.Shape.isValid() and len(obj.Shape.Solids) == 1]
        if len(solid_sources) != 1:
            candidates = [{"name": obj.Name, "typeId": obj.TypeId, "shape": shape_snapshot_from_shape(obj.Shape)} for obj in solid_sources]
            raise RuntimeError("Rejected-pair STEP import must produce exactly one valid single-Solid source: " + json.dumps(candidates, sort_keys=True))
        source = solid_sources[0]
        source_name = source.Name
        source_type_id = source.TypeId
        document.UndoMode = 1
        baseline = document_snapshot(document)
        baseline_objects = object_set_snapshot(document)
        baseline_source_shape_object = source.Shape.copy()
        baseline_source_shape = shape_snapshot_from_shape(source.Shape, include_brep=True)
        diagnostic = {
            "exceptionType": None,
            "exception": None,
            "recomputeResult": None,
            "statusString": None,
            "state": [],
            "shapeNull": True,
            "shapeValid": False,
        }
        document.openTransaction("ordered-pair-rejection")
        try:
            if operation == "pad":
                body = document.addObject("PartDesign::Body", "Body")
                feature = body.newObject("PartDesign::Pad", "PairResult")
                feature.Profile = source
                feature.Length = 5.0
                diagnostic["parameter"] = {"name": "length", "value": round(float(feature.Length), 7)}
                diagnostic["input"] = link_snapshot(feature.Profile)
            elif operation == "loft":
                section = document.addObject("Part::Feature", "Section")
                section.Shape = Part.makePolygon([
                    App.Vector(0.0, 0.0, 15.0),
                    App.Vector(2.0, 0.0, 15.0),
                    App.Vector(2.0, 3.0, 15.0),
                    App.Vector(0.0, 3.0, 15.0),
                    App.Vector(0.0, 0.0, 15.0),
                ])
                feature = document.addObject("Part::Loft", "PairResult")
                feature.Sections = [source, section]
                feature.Solid = True
                feature.Ruled = False
                diagnostic["parameter"] = {"name": "ruled", "value": bool(feature.Ruled)}
                diagnostic["input"] = {
                    "sections": [{"name": item.Name, "typeId": item.TypeId} for item in feature.Sections],
                }
            elif operation == "pipe":
                spine = document.addObject("Part::Feature", "Spine")
                spine.Shape = Part.makePolygon([App.Vector(0.0, 0.0, 0.0), App.Vector(0.0, 0.0, 15.0)])
                feature = document.addObject("Part::Sweep", "PairResult")
                feature.Sections = [source]
                feature.Spine = spine
                feature.Solid = True
                feature.Frenet = False
                diagnostic["parameter"] = {"name": "spineLength", "value": 15.0}
                diagnostic["input"] = {
                    "sections": [{"name": item.Name, "typeId": item.TypeId} for item in feature.Sections],
                    "spine": link_snapshot(feature.Spine),
                }
            elif operation == "revolution":
                feature = document.addObject("Part::Revolution", "PairResult")
                feature.Source = source
                feature.Axis = App.Vector(0.0, 1.0, 0.0)
                feature.Base = App.Vector(-1.0, 0.0, 0.0)
                feature.Angle = 360.0
                feature.Solid = True
                diagnostic["parameter"] = {"name": "angle", "value": round(float(feature.Angle), 7)}
                diagnostic["input"] = link_snapshot(feature.Source)
            elif operation == "fuse":
                tool = document.addObject("Part::Feature", "Tool")
                tool.Shape = Part.makeBox(5.0, 5.0, 5.0, App.Vector(8.0, 0.0, 0.0))
                feature = document.addObject("Part::Fuse", "PairResult")
                feature.Base = source
                feature.Tool = tool
                diagnostic["parameter"] = {"name": "toolOffsetX", "value": 8.0}
                diagnostic["input"] = {"base": link_snapshot(feature.Base), "tool": link_snapshot(feature.Tool)}
            elif operation == "fillet":
                feature = document.addObject("Part::Fillet", "PairResult")
                feature.Base = source
                edge_indices = [1] if pair == "thickness->fillet" else range(1, len(source.Shape.Edges) + 1)
                feature.Edges = [(index, 0.4, 0.4) for index in edge_indices]
                diagnostic["parameter"] = {"name": "radius", "value": 0.4}
                diagnostic["input"] = link_snapshot(feature.Base)
                diagnostic["input"]["selectedEdges"] = [entry[0] for entry in feature.Edges]
            elif operation == "chamfer":
                feature = document.addObject("Part::Chamfer", "PairResult")
                feature.Base = source
                edge_indices = [1] if pair == "thickness->chamfer" else range(1, len(source.Shape.Edges) + 1)
                feature.Edges = [(index, 0.4, 0.4) for index in edge_indices]
                diagnostic["parameter"] = {"name": "distance", "value": 0.4}
                diagnostic["input"] = link_snapshot(feature.Base)
                diagnostic["input"]["selectedEdges"] = [entry[0] for entry in feature.Edges]
            elif operation == "draft":
                body = document.addObject("PartDesign::Body", "Body")
                draft_base = body.newObject("PartDesign::Feature", "DraftBase")
                draft_base.Shape = source.Shape.copy()
                datum_plane = body.newObject("PartDesign::Plane", "DatumPlane")
                datum_plane.AttachmentSupport = [(document.XY_Plane, "")]
                datum_plane.MapMode = "FlatFace"
                datum_line = body.newObject("PartDesign::Line", "DatumLine")
                datum_line.AttachmentSupport = [(document.Z_Axis, "")]
                datum_line.MapMode = "TwoPointLine"
                feature = body.newObject("PartDesign::Draft", "PairResult")
                feature.Base = (draft_base, ["Face1"])
                feature.NeutralPlane = (datum_plane, [""])
                feature.PullDirection = (datum_line, [""])
                feature.Angle = 5.0
                feature.Reversed = False
                diagnostic["parameter"] = {"name": "angle", "value": round(float(feature.Angle), 7)}
                diagnostic["input"] = link_snapshot(feature.Base)
                diagnostic["input"]["sourceName"] = source.Name
                draft_base_shape = shape_snapshot_from_shape(draft_base.Shape, include_brep=True)
                diagnostic["input"]["shapeSummaryTransferredExactly"] = {key: value for key, value in draft_base_shape.items() if key != "brepSha256"} == {key: value for key, value in baseline_source_shape.items() if key != "brepSha256"}
                diagnostic["input"]["sourceBrepSha256"] = baseline_source_shape["brepSha256"]
                diagnostic["input"]["draftBaseBrepSha256"] = draft_base_shape["brepSha256"]
            elif operation == "thickness":
                feature = document.addObject("Part::Thickness", "PairResult")
                feature.Faces = (source, ["Face2"])
                feature.Value = -0.4
                feature.Mode = 0
                feature.Join = 0
                diagnostic["parameter"] = {"name": "offset", "value": round(float(feature.Value), 7)}
                diagnostic["input"] = link_snapshot(feature.Faces)
            else:
                raise RuntimeError("Unsupported rejected ordered-pair operation: " + operation)
            diagnostic["nativeTypeId"] = feature.TypeId
            diagnostic["inputShape"] = shape_snapshot_from_shape(source.Shape)
            diagnostic["objects"] = object_set_snapshot(document)
            try:
                diagnostic["recomputeResult"] = bool(document.recompute())
            except Exception as error:
                diagnostic["exceptionType"] = type(error).__name__
                diagnostic["exception"] = str(error)
            diagnostic["statusString"] = str(feature.getStatusString())
            diagnostic["state"] = [str(value) for value in feature.State]
            diagnostic["shapeNull"] = bool(feature.Shape.isNull())
            diagnostic["shapeValid"] = False if feature.Shape.isNull() else bool(feature.Shape.isValid())
            if not feature.Shape.isNull():
                diagnostic["resultShape"] = shape_snapshot_from_shape(feature.Shape)
        finally:
            document.abortTransaction()
        document.recompute()
        initial = document_snapshot(document)
        restored_objects = object_set_snapshot(document)
        restored_source = document.getObject(source_name)
        if restored_source is None or not hasattr(restored_source, "Shape") or restored_source.Shape.isNull():
            raise RuntimeError("Fuse source Shape is absent after transaction abort")
        restored_source_shape = shape_snapshot_from_shape(restored_source.Shape, include_brep=True)
        source_shape_summary_restored = {
            key: value for key, value in baseline_source_shape.items() if key != "brepSha256"
        } == {
            key: value for key, value in restored_source_shape.items() if key != "brepSha256"
        }
        source_shape_topologically_equal = bool(restored_source.Shape.isEqual(baseline_source_shape_object))
        source_shape_symmetric_difference = {
            "beforeMinusAfterVolume": round(float(baseline_source_shape_object.cut(restored_source.Shape).Volume), 7),
            "afterMinusBeforeVolume": round(float(restored_source.Shape.cut(baseline_source_shape_object).Volume), 7),
        }
        source_shape_geometrically_restored = source_shape_summary_restored and all(
            value == 0 for value in source_shape_symmetric_difference.values()
        )
        source_brep_reserialized = baseline_source_shape["brepSha256"] != restored_source_shape["brepSha256"]
        initial_path = os.path.join(output_directory, "ordered-pair-rejection.FCStd")
        resaved_path = os.path.join(output_directory, "ordered-pair-rejection-resaved.FCStd")
        document.saveAs(initial_path)
        App.closeDocument(document.Name)
        reopened = App.openDocument(initial_path)
        reopened.recompute()
        reopened_snapshot = document_snapshot(reopened)
        reopened.saveAs(resaved_path)
        App.closeDocument(reopened.Name)
        resaved = App.openDocument(resaved_path)
        resaved.recompute()
        resaved_snapshot = document_snapshot(resaved)
        App.closeDocument(resaved.Name)
        feature_rejected = diagnostic["shapeNull"] and (
            diagnostic["exceptionType"] is not None
            or diagnostic["recomputeResult"] is False
            or diagnostic["statusString"] != "Valid"
            or "Error" in diagnostic["state"]
            or "Invalid" in diagnostic["state"]
        )
        freecad_profile_no_op = operation == "thickness" and not feature_rejected and not diagnostic["shapeNull"] and diagnostic["shapeValid"] and diagnostic["statusString"] == "Valid" and diagnostic.get("resultShape") == diagnostic.get("inputShape")
        freecad_profile_accepted = operation in ("fillet", "chamfer", "thickness") and not freecad_profile_no_op and not feature_rejected and not diagnostic["shapeNull"] and diagnostic["shapeValid"] and diagnostic["statusString"] == "Valid"
        expected_type_id = {"pad": "PartDesign::Pad", "loft": "Part::Loft", "pipe": "Part::Sweep", "revolution": "Part::Revolution", "fuse": "Part::Fuse", "fillet": "Part::Fillet", "chamfer": "Part::Chamfer", "draft": "PartDesign::Draft", "thickness": "Part::Thickness"}[operation]
        input_names = [diagnostic.get("input", {}).get("sourceName", diagnostic.get("input", {}).get("name"))] if operation in ("pad", "revolution", "fillet", "chamfer", "draft", "thickness") else ([diagnostic.get("input", {}).get(key, {}).get("name") for key in ("base", "tool")] if operation == "fuse" else [item["name"] for item in diagnostic.get("input", {}).get("sections", [])])
        checks = {
            "nativeFeatureConstructed": diagnostic.get("nativeTypeId") == expected_type_id,
            "fuseSolidUsedAsInput": source_name in input_names and diagnostic.get("inputShape", {}).get("solids") == 1 and (operation != "draft" or diagnostic.get("input", {}).get("shapeSummaryTransferredExactly") is True),
            "recomputeStatusCaptured": diagnostic["recomputeResult"] is not None or diagnostic["exceptionType"] is not None,
            "nativeFeatureRejected": feature_rejected,
            "freecadProfileAccepted": freecad_profile_accepted,
            "objectSetRestored": baseline_objects == restored_objects,
            "sourceShapeRestored": baseline_source_shape == restored_source_shape,
            "sourceShapeGeometricallyRestored": source_shape_geometrically_restored,
            "sourceShapeSummaryRestored": source_shape_summary_restored,
            "documentUnpolluted": baseline == initial,
            "documentStable": initial == reopened_snapshot and initial == resaved_snapshot,
        }
        if freecad_profile_no_op:
            checks["freecadProfileNoOp"] = True
        return {
            "schemaVersion": 1,
            "baselineId": "freecad-1.1.1-ordered-operation-pair-resave",
            "freecadVersion": version_text(),
            "gitCommit": FREECAD_COMMIT,
            "pair": pair,
            "decision": "rejected",
            "operation": operation,
            "nativeTypeId": expected_type_id,
            "source": {
                "name": source_name,
                "typeId": source_type_id,
                "shape": baseline_source_shape,
            },
            "diagnostic": diagnostic,
            "rollback": {
                "before": {"objects": baseline_objects, "sourceShape": baseline_source_shape},
                "after": {"objects": restored_objects, "sourceShape": restored_source_shape},
                "sourceBrepReserialized": source_brep_reserialized,
                "sourceShapeSymmetricDifference": source_shape_symmetric_difference,
                "sourceShapeTopologicallyEqual": source_shape_topologically_equal,
            },
            "phases": {"initial": initial, "reopened": reopened_snapshot, "resaved": resaved_snapshot},
            "checks": checks,
            "status": "pass" if all(checks.values()) or (operation in ("fillet", "chamfer", "thickness") and freecad_profile_accepted and all(value for key, value in checks.items() if key != "nativeFeatureRejected")) or (freecad_profile_no_op and source_shape_geometrically_restored and all(value for key, value in checks.items() if key not in ("nativeFeatureRejected", "freecadProfileAccepted", "sourceShapeRestored"))) or (not freecad_profile_accepted and feature_rejected and all(value for key, value in checks.items() if key != "freecadProfileAccepted")) else "failed",
        }
    finally:
        for name in list(App.listDocuments().keys()):
            App.closeDocument(name)


try:
    report = collect_rejected() if os.environ.get("FREECAD_PAIR_DECISION") == "rejected" else collect_accepted()
except Exception as error:
    report = {
        "schemaVersion": 1,
        "baselineId": "freecad-1.1.1-ordered-operation-pair-resave",
        "freecadVersion": version_text(),
        "gitCommit": FREECAD_COMMIT,
        "status": "failed",
        "errorType": type(error).__name__,
        "error": str(error),
    }

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