import json
import os
import tempfile
import zipfile
import xml.etree.ElementTree as ET

import FreeCAD as App


FREECAD_COMMIT = "0108fd4b4850cc46e625b60e53cea7a7bbe69f8d"


class PropertyObserver:
    def __init__(self, document_name):
        self.document_name = document_name
        self.events = []

    def clear(self):
        self.events = []

    def slotOpenTransaction(self, document, name):
        if document.Name == self.document_name:
            self.events.append({"type": "transaction.opened", "name": str(name)})

    def slotCommitTransaction(self, document):
        if document.Name == self.document_name:
            self.events.append({"type": "transaction.committed"})

    def slotAbortTransaction(self, document):
        if document.Name == self.document_name:
            self.events.append({"type": "transaction.aborted"})

    def slotBeforeChangeObject(self, obj, prop):
        if obj.Document and obj.Document.Name == self.document_name:
            self.events.append({"type": "property.before-change", "object": obj.Name, "property": str(prop)})

    def slotChangedObject(self, obj, prop):
        if obj.Document and obj.Document.Name == self.document_name:
            self.events.append({"type": "property.changed", "object": obj.Name, "property": str(prop)})

    def slotAppendDynamicProperty(self, obj, prop):
        if getattr(obj, "Document", None) and obj.Document.Name == self.document_name:
            self.events.append({"type": "property.added", "object": obj.Name, "property": str(prop)})

    def slotRemoveDynamicProperty(self, obj, prop):
        if getattr(obj, "Document", None) and obj.Document.Name == self.document_name:
            self.events.append({"type": "property.removed", "object": obj.Name, "property": str(prop)})


def object_state(obj):
    return {
        "state": [str(value) for value in obj.State],
        "mustExecute": bool(obj.MustExecute),
        "documentTouched": bool(obj.Document.isTouched()),
    }


def change_case(case_id, attr=0, runtime_status=None):
    document = App.newDocument("PropertyStatus_" + case_id)
    try:
        obj = document.addObject("App::FeatureTest", "Feature")
        obj.addProperty("App::PropertyInteger", "Value", "Oracle", "Status probe", attr)
        if runtime_status:
            obj.setPropertyStatus("Value", runtime_status)
        document.recompute()
        document.purgeTouched()
        before = object_state(obj)
        obj.Value = 7
        changed = object_state(obj)
        recomputed = int(document.recompute())
        after_recompute = object_state(obj)
        return {
            "id": case_id,
            "attr": int(attr),
            "runtimeStatus": runtime_status,
            "reportedStatus": [str(value) for value in obj.getPropertyStatus("Value")],
            "reportedType": [str(value) for value in obj.getTypeOfProperty("Value")],
            "before": before,
            "changed": changed,
            "recomputeCount": recomputed,
            "afterRecompute": after_recompute,
        }
    finally:
        App.closeDocument(document.Name)


def locked_dynamic_case():
    document = App.newDocument("PropertyStatus_LockDynamic")
    observer = None
    try:
        document.UndoMode = 1
        obj = document.addObject("App::FeaturePython", "Feature")
        obj.addProperty("App::PropertyInteger", "LockedValue", "Oracle")
        obj.setPropertyStatus("LockedValue", "LockDynamic")
        observer = PropertyObserver(document.Name)
        App.addDocumentObserver(observer)
        document.openTransaction("dynamic-property-mutation")
        obj.addProperty("App::PropertyInteger", "MutableValue", "Oracle")
        obj.MutableValue = 3
        failures = {}
        for operation, callback in {
            "remove": lambda: obj.removeProperty("LockedValue"),
            "rename": lambda: obj.renameProperty("LockedValue", "RenamedValue"),
        }.items():
            try:
                callback()
                failures[operation] = None
            except Exception as error:
                failures[operation] = {"type": type(error).__name__, "message": str(error)}
        obj.renameProperty("MutableValue", "RenamedValue")
        removed_mutable = bool(obj.removeProperty("RenamedValue"))
        document.commitTransaction()
        return {
            "reportedStatus": [str(value) for value in obj.getPropertyStatus("LockedValue")],
            "propertyStillPresent": "LockedValue" in obj.PropertiesList,
            "renamedPropertyPresent": "RenamedValue" in obj.PropertiesList,
            "failures": failures,
            "mutableRenameAndRemove": removed_mutable and "MutableValue" not in obj.PropertiesList and "RenamedValue" not in obj.PropertiesList,
            "observerEvents": observer.events,
            "undoAvailable": document.UndoCount == 1,
        }
    finally:
        if observer:
            App.removeDocumentObserver(observer)
        App.closeDocument(document.Name)


def partial_trigger_case():
    document = App.newDocument("PropertyStatus_PartialTrigger")
    observer = PropertyObserver(document.Name)
    App.addDocumentObserver(observer)
    try:
        document.UndoMode = 1
        obj = document.addObject("PartDesign::SubShapeBinder", "Binder")
        document.recompute()
        document.purgeTouched()
        observer.clear()
        document.openTransaction("partial-trigger-mutation")
        obj.PartialLoad = True
        recomputed = int(document.recompute())
        document.commitTransaction()
        return {
            "reportedStatus": [str(value) for value in obj.getPropertyStatus("PartialLoad")],
            "value": bool(obj.PartialLoad),
            "recomputeCount": recomputed,
            "state": object_state(obj),
            "observerEvents": observer.events,
            "undoAvailable": document.UndoCount == 1,
        }
    finally:
        App.removeDocumentObserver(observer)
        App.closeDocument(document.Name)


def archive_case():
    with tempfile.TemporaryDirectory(prefix="freecad-property-status-") as temp_dir:
        path = os.path.join(temp_dir, "PropertyStatus.FCStd")
        document = App.newDocument("PropertyStatusArchive")
        try:
            obj = document.addObject("App::FeaturePython", "Feature")
            target = document.addObject("App::FeaturePython", "Target")
            obj.addProperty("App::PropertyInteger", "Persisted", "Oracle")
            obj.Persisted = 11
            obj.setPropertyStatus("Persisted", "Output")
            obj.addProperty("App::PropertyInteger", "RuntimeTransient", "Oracle")
            obj.RuntimeTransient = 12
            obj.setPropertyStatus("RuntimeTransient", "Transient")
            obj.addProperty("App::PropertyInteger", "TypeTransient", "Oracle", "", int(App.PropertyType.Prop_Transient))
            obj.TypeTransient = 13
            obj.addProperty("App::PropertyInteger", "NoPersist", "Oracle", "", int(App.PropertyType.Prop_NoPersist))
            obj.NoPersist = 14
            obj.addProperty("App::PropertyFloatConstraint", "FloatConstraint", "Oracle")
            obj.FloatConstraint = 0.25
            obj.addProperty("App::PropertyQuantityConstraint", "QuantityConstraint", "Oracle")
            obj.QuantityConstraint = 12.5
            obj.addProperty("App::PropertyPrecision", "Precision", "Oracle")
            obj.Precision = 0.001
            obj.addProperty("App::PropertyLinkHidden", "HiddenLink", "Oracle")
            obj.HiddenLink = target
            document.recompute()
            document.saveAs(path)
        finally:
            App.closeDocument(document.Name)

        with zipfile.ZipFile(path, "r") as archive:
            document_xml = archive.read("Document.xml").decode("utf-8")
        xml_root = ET.fromstring(document_xml)
        xml_properties = {
            element.attrib.get("name"): element
            for element in xml_root.findall("./ObjectData/Object/Properties/Property")
        }

        def native_float_property(name, expected):
            element = xml_properties.get(name)
            child = element.find("Float") if element is not None else None
            return bool(element is not None and element.attrib.get("type") == "App::Property" + name and child is not None and abs(float(child.attrib["value"]) - expected) < 1e-12)

        reopened = App.openDocument(path)
        try:
            obj = reopened.getObject("Feature")
            return {
                "xml": {
                    "persistedProperty": '<Property name="Persisted"' in document_xml,
                    "runtimeTransientProperty": '<Property name="RuntimeTransient"' in document_xml,
                    "typeTransientProperty": '<Property name="TypeTransient"' in document_xml,
                    "noPersistProperty": '<Property name="NoPersist"' in document_xml,
                    "runtimeTransientHasValue": '<Integer value="12"' in document_xml,
                    "typeTransientHasValue": '<Integer value="13"' in document_xml,
                    "floatConstraintNative": native_float_property("FloatConstraint", 0.25),
                    "quantityConstraintNative": native_float_property("QuantityConstraint", 12.5),
                    "precisionNative": native_float_property("Precision", 0.001),
                    "hiddenLinkNative": xml_properties.get("HiddenLink") is not None and xml_properties["HiddenLink"].attrib.get("type") == "App::PropertyLinkHidden" and xml_properties["HiddenLink"].find("Link") is not None and xml_properties["HiddenLink"].find("Link").attrib.get("value") == "Target",
                },
                "reopened": {
                    "properties": sorted(str(value) for value in obj.PropertiesList if value in {"Persisted", "RuntimeTransient", "TypeTransient", "NoPersist"}),
                    "values": {
                        "Persisted": int(obj.Persisted),
                        "RuntimeTransient": int(obj.RuntimeTransient),
                        "TypeTransient": int(obj.TypeTransient),
                    },
                    "statuses": {
                        name: [str(value) for value in obj.getPropertyStatus(name)]
                        for name in ["Persisted", "RuntimeTransient", "TypeTransient"]
                    },
                    "codecValues": {
                        "FloatConstraint": float(obj.FloatConstraint),
                        "QuantityConstraint": float(obj.QuantityConstraint.Value),
                        "Precision": float(obj.Precision),
                        "HiddenLink": obj.HiddenLink.Name,
                    },
                },
            }
        finally:
            App.closeDocument(reopened.Name)


cases = [
    change_case("ordinary"),
    change_case("runtime-output", runtime_status="Output"),
    change_case("type-output", attr=int(App.PropertyType.Prop_Output)),
    change_case("runtime-no-recompute", runtime_status="NoRecompute"),
    change_case("type-no-recompute", attr=int(App.PropertyType.Prop_NoRecompute)),
]
report = {
    "schemaVersion": 2,
    "baselineId": "freecad-1.1.1-property-status-oracle",
    "freecadVersion": ".".join(str(value) for value in App.Version()[:3]),
    "gitCommit": FREECAD_COMMIT,
    "status": "pass",
    "cases": cases,
    "lockDynamic": locked_dynamic_case(),
    "partialTrigger": partial_trigger_case(),
    "fcstd": archive_case(),
}
print("FREECAD_PROPERTY_STATUS_ORACLE_RESULT=" + json.dumps(report, sort_keys=True, separators=(",", ":")))
