import importlib
import importlib.util
import faulthandler
import hashlib
import json
import os
import re
import sys

import FreeCAD as App


PROBE_SCOPE = os.environ.get("FREECAD_REFERENCE_SCOPE", "full")
if PROBE_SCOPE not in {"full", "gui-commands"}:
    raise RuntimeError("Unknown FreeCAD reference probe scope: " + PROBE_SCOPE)
TRACEBACK_SECONDS = int(os.environ.get("FREECAD_REFERENCE_TRACEBACK_SECONDS", "0"))
if TRACEBACK_SECONDS > 0:
    faulthandler.dump_traceback_later(TRACEBACK_SECONDS, repeat=True, file=2)
ISOLATED_GUI_CONFIG = os.environ.get("FREECAD_ORACLE_ISOLATED_CONFIG") == "1"
BIM_FIRST_TIME_WELCOME_SUPPRESSED = False
if ISOLATED_GUI_CONFIG:
    bim_preferences = App.ParamGet("User parameter:BaseApp/Preferences/Mod/BIM")
    bim_preferences.SetBool("FirstTime", False)
    BIM_FIRST_TIME_WELCOME_SUPPRESSED = not bim_preferences.GetBool("FirstTime", True)


def probe_progress(message):
    print("FREECAD_REFERENCE_PROGRESS=" + message, file=sys.stderr, flush=True)


def property_metadata(obj):
    def stable_text(value):
        text = re.sub(r"0x[0-9a-fA-F]+", "<address>", str(value))
        text = re.sub(r"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}", "<uuid>", text)
        return re.sub(r"(FreeCAD_Doc_<uuid>_[^/_]+_)\d+", r"\1<run>", text)

    def json_value(value):
        if value is None or isinstance(value, (bool, int, float)):
            return value
        if isinstance(value, str):
            return stable_text(value)
        if isinstance(value, (list, tuple)):
            return [json_value(item) for item in value]
        if isinstance(value, dict):
            return {str(key): json_value(item) for key, item in value.items()}
        # FreeCAD quantities and placements keep a stable textual form while
        # the report remains valid JSON for every registered property type.
        try:
            return stable_text(value)
        except Exception:
            return "<unserializable>"

    properties = []
    for name in obj.PropertiesList:
        try:
            type_id = obj.getTypeIdOfProperty(name)
        except Exception:
            type_id = "unknown"
        try:
            group = obj.getGroupOfProperty(name)
        except Exception:
            group = ""
        try:
            status = list(obj.getPropertyStatus(name))
        except Exception:
            status = []
        try:
            default = json_value(getattr(obj, name))
        except Exception:
            default = "<unreadable>"
        properties.append({"name": name, "typeId": type_id, "group": group, "status": status, "default": default})
    return properties


def probe_object(document, type_id):
    object_name = "Reference" + type_id.replace(":", "_")
    try:
        obj = document.addObject(type_id, object_name)
    except Exception as error:
        return {"typeId": type_id, "available": False, "probeStatus": "unavailable", "error": str(error)}
    return {
        "typeId": type_id,
        "available": True,
        "probeStatus": "available",
        "name": obj.Name,
        "runtimeTypeId": obj.TypeId,
        "properties": property_metadata(obj),
    }


def probe_registered_objects(document):
    try:
        registered = sorted(set(str(type_id) for type_id in document.supportedTypes()))
    except Exception as error:
        return {"available": False, "types": [], "error": str(error)}

    records = []
    for index, type_id in enumerate(registered):
        record = probe_object(document, type_id)
        record["candidateIndex"] = index
        record["candidateSource"] = "Document.supportedTypes"
        records.append(record)
        # A few GUI-only TechDraw providers dereference their view during
        # removeObject(). Keep those instances alive until FreeCAD exits so a
        # probe cannot turn a valid metadata observation into a process crash.
        if not bool(getattr(App, "GuiUp", False)) and record.get("available") and record.get("name"):
            try:
                document.removeObject(record["name"])
            except Exception as error:
                record["cleanupError"] = str(error)

    return {
        "available": True,
        "candidateSource": "Document.supportedTypes",
        "candidateCount": len(records),
        "availableCount": sum(1 for record in records if record.get("available")),
        "unavailableCount": sum(1 for record in records if not record.get("available")),
        "types": records,
    }


ALL_REFERENCE_MODULES = [
    "AddonManager", "Assembly", "BIM", "CAM", "Cloud", "Draft", "Fem", "Help",
    "Idf", "Import", "Inspection", "JtReader", "Material", "Measure", "Mesh",
    "MeshPart", "OpenSCAD", "Part", "PartDesign", "Plot", "Points",
    "ReverseEngineering", "Robot", "Sandbox", "Show", "Sketcher", "Spreadsheet",
    "Start", "Surface", "TechDraw", "TemplatePyMod", "Test", "Tux", "Web",
]

GUI_REQUIRED_MODULES = {
    "AddonManager", "Assembly", "BIM", "CAM", "Draft", "Fem", "Help", "Idf",
    "Import", "Inspection", "JtReader", "Material", "Measure", "Mesh", "MeshPart",
    "OpenSCAD", "Part", "PartDesign", "Plot", "Points", "ReverseEngineering", "Robot",
    "Sandbox", "Show", "Sketcher", "Spreadsheet", "Start", "Surface", "TechDraw", "Tux",
    "Web",
}


def module_is_installed(name):
    suffixes = ("", ".py", ".so", ".pyd", ".dll", ".dylib")
    roots = [path for path in sys.path if path]
    try:
        resource_dir = App.getResourceDir()
        roots.extend([resource_dir, os.path.join(resource_dir, "Mod")])
    except Exception:
        pass
    for root in roots:
        for suffix in suffixes:
            if os.path.exists(os.path.join(root, name + suffix)):
                return True
            if os.path.exists(os.path.join(root, name, "__init__" + suffix)):
                return True
            if os.path.exists(os.path.join(root, "Mod", name + suffix)):
                return True
    return False


def probe_module(name):
    gui_required = name in GUI_REQUIRED_MODULES
    installed = module_is_installed(name)
    spec = None
    try:
        spec = importlib.util.find_spec(name)
    except Exception:
        spec = None
    try:
        module = importlib.import_module(name)
        importable = True
        import_error = None
        file = getattr(module, "__file__", None)
    except Exception as caught_error:
        importable = False
        import_error = caught_error
        file = getattr(spec, "origin", None) if spec else None
    compiled = bool(installed or spec)
    if importable and gui_required and not bool(getattr(App, "GuiUp", False)):
        runtime_status = "gui-only-unprobeable"
    elif importable:
        runtime_status = "compiled-importable"
    elif compiled:
        runtime_status = "compiled-import-failure"
    else:
        runtime_status = "not-built"
    result = {
        "name": name,
        "available": importable,
        "compiled": compiled,
        "importable": importable,
        "guiRequired": gui_required,
        "guiAvailable": bool(getattr(App, "GuiUp", False)),
        "runtimeStatus": runtime_status,
        "file": file,
    }
    if import_error is not None:
        result["error"] = str(import_error)
    return result


def probe_gui_commands():
    if not bool(getattr(App, "GuiUp", False)):
        return {"available": False, "workbenches": [], "commands": []}
    try:
        import FreeCADGui as Gui
    except Exception as error:
        return {"available": False, "workbenches": [], "commands": [], "error": str(error)}

    try:
        workbench_names = sorted(Gui.listWorkbenches().keys())
    except Exception:
        workbench_names = []
    workbench_filter = os.environ.get("FREECAD_GUI_WORKBENCH_FILTER")
    if workbench_filter:
        if workbench_filter not in workbench_names:
            raise RuntimeError("Unknown GUI workbench filter: " + workbench_filter)
        workbench_names = [workbench_filter]
    command_registrations = {}

    def command_active(command_id):
        try:
            command = Gui.Command.get(command_id)
            if command is None:
                return None
            # StdCmdExpression dereferences its private QAction in isActive().
            # Other commands must use their native predicate; QAction enabled
            # state is only a cached presentation value and misses context changes.
            if command_id == "Std_Expressions":
                actions = command.getAction()
                return bool(actions[0].isEnabled()) if actions else None
            return bool(command.isActive())
        except Exception as error:
            return {"error": str(error)}

    workbenches = []
    for workbench_name in workbench_names:
        probe_progress("workbench:start:" + workbench_name)
        try:
            Gui.activateWorkbench(workbench_name)
            Gui.updateGui()
            command_ids = sorted(set(Gui.listCommands()))
            for command_id in command_ids:
                command_registrations.setdefault(command_id, []).append(workbench_name)
            workbenches.append({"name": workbench_name, "commandCount": len(command_ids), "status": "probed"})
            probe_progress("workbench:done:" + workbench_name)
        except Exception as error:
            try:
                Gui.Selection.clearSelection()
            except Exception:
                pass
            workbenches.append({"name": workbench_name, "commandCount": 0, "status": "probe-failure", "error": str(error)})
            probe_progress("workbench:failed:" + workbench_name)

    # Command registration is workbench-scoped, but document/selection state is
    # intrinsic to each command. Probe the state matrix once after all providers
    # are loaded instead of repeating the cumulative command set per workbench.
    for document_name in list(App.listDocuments().keys()):
        App.closeDocument(document_name)
    command_ids = sorted(command_registrations.keys())
    command_universe_sha256 = hashlib.sha256("\n".join(command_ids).encode("utf-8")).hexdigest()
    if os.environ.get("FREECAD_GUI_REGISTRATION_ONLY") == "1":
        return {
            "available": True,
            "probeMode": "registration-only",
            "stateProbe": "not-probed",
            "stateCases": [],
            "directIsActiveBoundary": "not-applicable",
            "commandUniverseCount": len(command_ids),
            "commandUniverseSha256": command_universe_sha256,
            "stateCommandCount": 0,
            "shard": None,
            "workbenches": workbenches,
            "commands": [
                {
                    "id": command_id,
                    "registeredWorkbenches": command_registrations[command_id],
                }
                for command_id in command_ids
            ],
        }
    shard = None
    state_command_ids = command_ids
    shard_spec = os.environ.get("FREECAD_GUI_COMMAND_SHARD")
    if shard_spec:
        try:
            shard_index, shard_count = [int(value) for value in shard_spec.split("/", 1)]
        except Exception:
            raise RuntimeError("FREECAD_GUI_COMMAND_SHARD must use zero-based INDEX/COUNT syntax")
        if shard_count < 1 or shard_index < 0 or shard_index >= shard_count:
            raise RuntimeError("FREECAD_GUI_COMMAND_SHARD is outside its declared range")
        shard_start = len(command_ids) * shard_index // shard_count
        shard_end = len(command_ids) * (shard_index + 1) // shard_count
        state_command_ids = command_ids[shard_start:shard_end]
        shard = {
            "index": shard_index,
            "count": shard_count,
            "start": shard_start,
            "end": shard_end,
        }
    Gui.Selection.clearSelection()
    Gui.updateGui()
    probe_progress("state:start:noDocument")
    no_document = {command_id: command_active(command_id) for command_id in state_command_ids}
    probe_progress("state:done:noDocument")
    context_document = App.newDocument("CommandContext")
    selected_object = context_document.addObject("Part::Box", "CommandProbeBox")
    Gui.updateGui()
    probe_progress("state:start:documentNoSelection")
    document_no_selection = {command_id: command_active(command_id) for command_id in state_command_ids}
    probe_progress("state:done:documentNoSelection")
    Gui.Selection.addSelection(context_document.Name, selected_object.Name)
    Gui.updateGui()
    probe_progress("state:start:selectedPartBox")
    selected_selection = {command_id: command_active(command_id) for command_id in state_command_ids}
    probe_progress("state:done:selectedPartBox")
    Gui.Selection.clearSelection()
    App.closeDocument(context_document.Name)
    return {
        "available": True,
        "probeMode": "state-matrix",
        "stateProbe": "native-is-active-after-workbench-registration-with-Std_Expressions-qaction-guard",
        "stateCases": ["noDocument", "documentNoSelection", "selectedPartBox"],
        "directIsActiveBoundary": "Std_Expressions-only-qaction-guard-for-locked-1.1.1-null-qaction-crash",
        "commandUniverseCount": len(command_ids),
        "commandUniverseSha256": command_universe_sha256,
        "stateCommandCount": len(state_command_ids),
        "shard": shard,
        "workbenches": workbenches,
        "commands": [
            {
                "id": command_id,
                "registeredWorkbenches": command_registrations[command_id],
                "observations": [{
                    "workbench": "all-registered-runtime-context",
                    "noDocument": no_document.get(command_id),
                    "documentNoSelection": document_no_selection.get(command_id),
                    "selectedPartBox": selected_selection.get(command_id),
                }],
            }
            for command_id in state_command_ids
        ],
    }


modules = []
if PROBE_SCOPE == "full":
    document = App.newDocument("ReferenceProbe")
    # Creating one native Part object loads the same runtime type registry used
    # by the locked desktop baseline before supportedTypes() is enumerated.
    document.addObject("Part::Box", "TypeRegistryProbeBox")
    modules = [probe_module(name) for name in ALL_REFERENCE_MODULES]
    App.closeDocument(document.Name)
gui_commands = probe_gui_commands()
version = App.Version()
result = {
    "schemaVersion": 1,
    "baselineId": "freecad-1.1.1",
    "freecadVersion": ".".join(str(value) for value in version[:3]),
    "revision": str(version[3]),
    "gitBranch": str(version[6]) if len(version) > 6 else "",
    "gitCommit": str(version[7]) if len(version) > 7 else "",
    "guiUp": bool(getattr(App, "GuiUp", False)),
    "buildPurpose": "desktop-reference-oracle" if bool(getattr(App, "GuiUp", False)) else "headless-reference-oracle",
    "probeScope": PROBE_SCOPE,
    "oracleSetup": {
        "isolatedConfig": ISOLATED_GUI_CONFIG,
        "bimFirstTimeWelcome": "suppressed-before-workbench-activation" if BIM_FIRST_TIME_WELCOME_SUPPRESSED else "native-user-config",
    },
    "determinism": {
        "schemaVersion": 1,
        "normalizedProcessFields": ["pointer-address", "uuid", "freecad-document-cache-run"],
    },
    "moduleCount": len(modules),
    "modules": modules,
    "guiCommands": gui_commands,
}
if PROBE_SCOPE == "full":
    # Workbench activation registers additional GUI-owned document types.
    # Enumerate them only in the independent object/property scope.
    document = App.newDocument("RuntimeObjectProbe")
    document.addObject("Part::Box", "TypeRegistryProbeBox")
    result["runtimeObjects"] = probe_registered_objects(document)
    result["objects"] = [probe_object(document, type_id) for type_id in [
        "Part::Box", "Part::Cylinder", "Part::Sphere", "Part::Cone",
        "Part::Feature", "PartDesign::Feature", "Sketcher::SketchObject",
    ]]
    App.closeDocument(document.Name)
result["moduleStatusSummary"] = {
    status: sum(1 for module in result["modules"] if module["runtimeStatus"] == status)
    for status in sorted({module["runtimeStatus"] for module in result["modules"]})
}
print("FREECAD_REFERENCE_RESULT=" + json.dumps(result, sort_keys=True, separators=(",", ":")))
sys.stdout.flush()
sys.exit(0)
