import hashlib
import json
import os
import sys
import gc

import FreeCAD as App
import Path


MARKER = "FREECAD_CAM_PATH_RESULT="


def emit_marker(value, status=0):
    result_file = os.environ.get("FREECAD_CAM_PATH_RESULT_FILE", "")
    marker_value = value
    if result_file:
        parent = os.path.dirname(result_file)
        if parent:
            os.makedirs(parent, exist_ok=True)
        with open(result_file, "w", encoding="utf-8") as stream:
            json.dump(value, stream, sort_keys=True, separators=(",", ":"))
            stream.write("\n")
        os._exit(status)
    marker_value = value
    payload = (MARKER + json.dumps(marker_value, sort_keys=True, separators=(",", ":")) + "\n").encode("utf-8")
    offset = 0
    while offset < len(payload):
        offset += os.write(sys.stdout.fileno(), payload[offset:])
    os._exit(status)


def command_record(command, precision=9):
    threshold = 0.5 * (10 ** -precision)
    parameters = {}
    for name, value in sorted(dict(command.Parameters).items()):
        rounded = round(float(value), precision)
        parameters[str(name)] = 0.0 if abs(rounded) < threshold else rounded
    parameter_text = " ".join(
        "{}{:0.{}f}".format(name, value, precision)
        for name, value in parameters.items()
    )
    return {
        "name": str(command.Name),
        "parameters": parameters,
        "gcode": (str(command.Name) + (" " + parameter_text if parameter_text else "")).strip(),
    }


def path_record(obj):
    path = obj.Path
    bounds = path.BoundBox
    return {
        "name": obj.Name,
        "label": obj.Label,
        "typeId": obj.TypeId,
        "pathPropertyType": obj.getTypeIdOfProperty("Path"),
        "commandCount": len(path.Commands),
        "commands": [command_record(command) for command in path.Commands],
        "length": round(float(path.Length), 9),
        "boundingBox": {
            "min": [round(float(bounds.XMin), 9), round(float(bounds.YMin), 9), round(float(bounds.ZMin), 9)],
            "max": [round(float(bounds.XMax), 9), round(float(bounds.YMax), 9), round(float(bounds.ZMax), 9)],
        },
        "oracleTag": str(getattr(obj, "OracleTag", "")),
    }


def archive_record(path):
    with open(path, "rb") as stream:
        payload = stream.read()
    return {
        "path": path,
        "byteLength": len(payload),
        "sha256": hashlib.sha256(payload).hexdigest(),
    }


def load_path_objects(path):
    document = App.openDocument(path)
    try:
        records = [path_record(document.getObject(name)) for name in ("NativePath", "PythonPath")]
        return records
    finally:
        App.closeDocument(document.Name)


def verify_preserved_archive(path):
    records = load_path_objects(path)
    expected_native_count = int(os.environ.get("FREECAD_CAM_PATH_EXPECT_NATIVE_COUNT", "5"))
    expected_python_count = int(os.environ.get("FREECAD_CAM_PATH_EXPECT_PYTHON_COUNT", "5"))
    expected_native_last = os.environ.get("FREECAD_CAM_PATH_EXPECT_NATIVE_LAST", "")
    expected_python_last = os.environ.get("FREECAD_CAM_PATH_EXPECT_PYTHON_LAST", "")
    expected_counts = {"NativePath": expected_native_count, "PythonPath": expected_python_count}
    result = {
        "schemaVersion": 1,
        "baselineId": "freecad-1.1.1",
        "mode": "web-preserved-native-reopen",
        "archive": archive_record(path),
        "objects": records,
        "verified": all(
            record["pathPropertyType"] == "Path::PropertyPath"
            and record["commandCount"] == expected_counts[record["name"]]
            and (record["name"] != "NativePath" or not expected_native_last or record["commands"][-1]["name"] == expected_native_last)
            and (record["name"] != "PythonPath" or not expected_python_last or record["commands"][-1]["name"] == expected_python_last)
            for record in records
        ),
    }
    emit_marker(result, 0 if result["verified"] else 2)


def qt_command_oracle(document, selected_object):
    import FreeCADGui as Gui
    from PySide import QtCore, QtWidgets

    workbenches = Gui.listWorkbenches()
    workbench_name = "CAMWorkbench" if "CAMWorkbench" in workbenches else "PathWorkbench"
    if workbench_name not in workbenches:
        raise RuntimeError("FreeCAD CAM workbench is unavailable in the desktop oracle")
    Gui.activateWorkbench(workbench_name)
    Gui.updateGui()
    QtWidgets.QApplication.processEvents()

    all_commands = sorted(set(Gui.listCommands()))
    cam_commands = [command_id for command_id in all_commands if command_id.startswith("CAM_")]
    required = [
        "CAM_Job",
        "CAM_Profile",
        "CAM_Pocket_Shape",
        "CAM_Drilling",
        "CAM_Adaptive",
        "CAM_Pocket3D",
        "CAM_Post",
        "CAM_Simulator",
        "CAM_Sanity",
        "CAM_ToolBitDock",
    ]

    def observe(command_id):
        command = Gui.Command.get(command_id)
        actions = [] if command is None else list(command.getAction())
        if not actions:
            return {"registered": command is not None, "actionCount": 0, "enabled": None}
        action = actions[0]
        return {
            "registered": True,
            "actionCount": len(actions),
            "objectName": str(action.objectName()),
            "text": str(action.text()).replace("&", ""),
            "toolTip": str(action.toolTip()),
            "shortcut": str(action.shortcut().toString()),
            "enabled": bool(command.isActive()),
            "visible": bool(action.isVisible()),
            "iconPresent": not action.icon().isNull(),
        }

    Gui.Selection.clearSelection()
    Gui.Command.update()
    Gui.updateGui()
    QtWidgets.QApplication.processEvents()
    empty = {command_id: observe(command_id) for command_id in required}
    Gui.Selection.addSelection(document.Name, selected_object.Name)
    Gui.Command.update()
    Gui.updateGui()
    QtWidgets.QApplication.processEvents()
    selected = {command_id: observe(command_id) for command_id in required}
    Gui.Selection.clearSelection()
    selected_job_object = document.getObject("OracleJob")
    Gui.Selection.addSelection(document.Name, selected_job_object.Name)
    Gui.Command.update()
    Gui.updateGui()
    QtWidgets.QApplication.processEvents()
    selected_job = {command_id: observe(command_id) for command_id in required}
    Gui.Selection.clearSelection()
    selected_operation_object = document.getObject("OracleProfile")
    Gui.Selection.addSelection(document.Name, selected_operation_object.Name)
    Gui.Command.update()
    Gui.updateGui()
    QtWidgets.QApplication.processEvents()
    selected_operation = {command_id: observe(command_id) for command_id in required}
    Gui.Selection.clearSelection()
    empty_document = App.newDocument("CamQtEmptyContext")
    Gui.Command.update()
    Gui.updateGui()
    QtWidgets.QApplication.processEvents()
    empty_document_state = {command_id: observe(command_id) for command_id in required}
    App.closeDocument(empty_document.Name)
    App.setActiveDocument(document.Name)
    Gui.Command.update()
    Gui.updateGui()

    main_window = Gui.getMainWindow()
    toolbars = []
    for toolbar in main_window.findChildren(QtWidgets.QToolBar):
        command_ids = [str(action.objectName()) for action in toolbar.actions() if str(action.objectName()).startswith("CAM_")]
        if command_ids:
            toolbars.append({
                "objectName": str(toolbar.objectName()),
                "title": str(toolbar.windowTitle()).replace("&", ""),
                "visible": bool(toolbar.isVisible()),
                "commands": command_ids,
            })

    menus = []
    for menu_action in main_window.menuBar().actions():
        menu = menu_action.menu()
        if menu is None:
            continue
        command_ids = []
        for action in menu.actions():
            action_name = str(action.objectName())
            if action_name.startswith("CAM_"):
                command_ids.append(action_name)
            submenu = action.menu()
            if submenu is not None:
                command_ids.extend(
                    str(child.objectName())
                    for child in submenu.actions()
                    if str(child.objectName()).startswith("CAM_")
                )
        if command_ids:
            menus.append({
                "title": str(menu_action.text()).replace("&", ""),
                "commands": sorted(set(command_ids)),
            })

    def flush_qt():
        Gui.updateGui()
        QtWidgets.QApplication.processEvents()

    def task_panel_state(phase):
        focus = QtWidgets.QApplication.focusWidget()
        button_boxes = [box for box in main_window.findChildren(QtWidgets.QDialogButtonBox) if box.isVisible()]
        buttons = []
        for box in button_boxes:
            for button in box.buttons():
                buttons.append({
                    "text": str(button.text()).replace("&", ""),
                    "objectName": str(button.objectName()),
                    "enabled": bool(button.isEnabled()),
                    "visible": bool(button.isVisible()),
                    "default": bool(button.isDefault()),
                    "role": str(box.buttonRole(button)),
                })
        fields = []
        field_types = (QtWidgets.QLineEdit, QtWidgets.QComboBox, QtWidgets.QSpinBox, QtWidgets.QDoubleSpinBox, QtWidgets.QCheckBox)
        widgets = []
        seen_widgets = set()
        for field_type in field_types:
            for widget in main_window.findChildren(field_type):
                identity = id(widget)
                if identity not in seen_widgets:
                    widgets.append(widget)
                    seen_widgets.add(identity)
        for widget in widgets:
            if not widget.isVisible():
                continue
            fields.append({
                "className": str(widget.metaObject().className()),
                "objectName": str(widget.objectName()),
                "enabled": bool(widget.isEnabled()),
                "focus": widget is focus,
            })
        field_classes = sorted({field["className"] for field in fields})
        in_edit = Gui.activeDocument().getInEdit() if Gui.activeDocument() else None
        in_edit_object = getattr(in_edit, "Object", None) if in_edit else None
        return {
            "phase": phase,
            "activeDialog": bool(Gui.Control.activeDialog()),
            "inEdit": str(getattr(in_edit_object, "Name", "")) if in_edit else "",
            "focus": None if focus is None else {"className": str(focus.metaObject().className()), "objectName": str(focus.objectName())},
            "buttonBoxCount": len(button_boxes),
            "buttonTexts": [{"text": button["text"], "enabled": button["enabled"], "default": button["default"], "role": button["role"]} for button in buttons],
            "fieldCount": len(fields),
            "fieldClasses": field_classes,
        }

    def close_task_panel(action):
        candidates = []
        for box in main_window.findChildren(QtWidgets.QDialogButtonBox):
            if not box.isVisible():
                continue
            for button in box.buttons():
                role = box.buttonRole(button)
                if action == "accept" and role in (QtWidgets.QDialogButtonBox.AcceptRole, QtWidgets.QDialogButtonBox.ApplyRole):
                    candidates.append(button)
                if action == "cancel" and role in (QtWidgets.QDialogButtonBox.RejectRole, QtWidgets.QDialogButtonBox.DestructiveRole):
                    candidates.append(button)
        clicked = False
        if candidates:
            candidates[0].click()
            clicked = True
            flush_qt()
        if Gui.Control.activeDialog():
            Gui.activeDocument().resetEdit()
            flush_qt()
        return clicked

    def edit_lifecycle(object_name, action):
        before = task_panel_state("before")
        entered = bool(Gui.activeDocument().setEdit(object_name))
        flush_qt()
        active = task_panel_state("active")
        clicked = close_task_panel(action)
        closed = task_panel_state("closed")
        return {
            "objectName": object_name,
            "action": action,
            "setEditAccepted": entered,
            "buttonClicked": clicked,
            "before": before,
            "active": active,
            "closed": closed,
            "dialogOpened": active["activeDialog"] and bool(active["inEdit"]),
            "dialogClosed": not closed["activeDialog"] and not closed["inEdit"],
        }

    task_lifecycles = [
        edit_lifecycle(selected_job_object.Name, "cancel"),
        edit_lifecycle(selected_operation_object.Name, "accept"),
        edit_lifecycle(selected_operation_object.Name, "cancel"),
    ]
    gc.collect()
    QtWidgets.QApplication.processEvents()

    observations = []
    for command_id in required:
        observations.append({
            "id": command_id,
            "emptySelection": empty[command_id],
            "selectedModel": selected[command_id],
            "selectedJob": selected_job[command_id],
            "selectedOperation": selected_operation[command_id],
            "emptyDocument": empty_document_state[command_id],
            "selectionChangesEnabledState": len({
                (empty[command_id].get("enabled"), empty[command_id].get("isActive")),
                (selected[command_id].get("enabled"), selected[command_id].get("isActive")),
                (selected_job[command_id].get("enabled"), selected_job[command_id].get("isActive")),
                (selected_operation[command_id].get("enabled"), selected_operation[command_id].get("isActive")),
                (empty_document_state[command_id].get("enabled"), empty_document_state[command_id].get("isActive")),
            }) > 1,
        })
    return {
        "guiUp": bool(App.GuiUp),
        "qtVersion": str(QtCore.qVersion()),
        "workbench": workbench_name,
        "activeWorkbench": str(Gui.activeWorkbench().name()),
        "registeredCommandCount": len(all_commands),
        "camCommandCount": len(cam_commands),
        "camCommands": cam_commands,
        "requiredCommands": observations,
        "toolbarCount": len(toolbars),
        "toolbars": sorted(toolbars, key=lambda item: (item["title"], item["objectName"])),
        "menuCount": len(menus),
        "menus": menus,
        "taskLifecycles": task_lifecycles,
        "taskLifecycleVerified": all(item["dialogOpened"] and item["dialogClosed"] for item in task_lifecycles),
    }


def profile_algorithm_oracle():
    import Path.Main.Job as PathJob
    import Path.Op.Profile as PathProfile

    fixture = os.path.join(App.getHomePath(), "Mod", "CAM", "CAMTests", "test_profile.fcstd")
    App.ConfigSet("SuppressRecomputeRequiredDialog", "True")
    document = App.openDocument(fixture)
    App.ConfigSet("SuppressRecomputeRequiredDialog", "")
    job = PathJob.Create("OracleJob", [document.Body], None)
    job.GeometryTolerance.Value = 0.001
    profile = PathProfile.Create("OracleProfile", parentJob=job)
    profile.Base = [(document.Body, ["Face18"])]
    profile.processCircles = True
    profile.processHoles = True
    profile.UseComp = True
    profile.Direction = "CW"
    document.recompute()

    def summarize(operation, scenario_id):
        commands = [command_record(command, 2) for command in operation.Path.Commands]
        cutting = [command for command in commands if command["name"] in ("G1", "G2", "G3")]
        canonical = json.dumps(commands, sort_keys=True, separators=(",", ":"))
        return {
            "id": scenario_id,
            "useComp": bool(operation.UseComp),
            "direction": str(operation.Direction),
            "commandCount": len(commands),
            "cuttingCommandCount": len(cutting),
            "commandSha256": hashlib.sha256(canonical.encode("utf-8")).hexdigest(),
            "firstCuttingCommand": cutting[0] if cutting else None,
            "lastCuttingCommand": cutting[-1] if cutting else None,
            "pathLength": round(float(operation.Path.Length), 2),
            "commands": commands,
        }

    compensated = summarize(profile, "outside-cw-tool-comp")
    no_comp = PathProfile.Create("OracleProfileNoComp", parentJob=job)
    no_comp.Base = [(document.Body, ["Face18"])]
    no_comp.processCircles = True
    no_comp.processHoles = True
    no_comp.UseComp = False
    no_comp.Direction = "CW"
    document.recompute()
    uncompensated = summarize(no_comp, "outside-cw-no-comp")
    result = {
        "fixture": "Mod/CAM/CAMTests/test_profile.fcstd",
        "fixtureSha256": archive_record(fixture)["sha256"],
        "modelTypeId": document.Body.TypeId,
        "jobTypeId": job.TypeId,
        "operationTypeId": profile.TypeId,
        "pathPropertyType": profile.getTypeIdOfProperty("Path"),
        "commandCount": compensated["commandCount"],
        "cuttingCommandCount": compensated["cuttingCommandCount"],
        "commandSha256": compensated["commandSha256"],
        "normalizationToleranceMm": 0.01,
        "firstCuttingCommand": compensated["firstCuttingCommand"],
        "lastCuttingCommand": compensated["lastCuttingCommand"],
        "pathLength": compensated["pathLength"],
        "commands": compensated["commands"],
        "variants": [compensated, uncompensated],
    }
    return document, result


def helix_algorithm_oracle():
    import Path.Main.Job as PathJob
    import Path.Op.Helix as PathHelix

    fixture = os.path.join(App.getHomePath(), "Mod", "CAM", "CAMTests", "test_holes00.fcstd")
    App.ConfigSet("SuppressRecomputeRequiredDialog", "True")
    document = App.openDocument(fixture)
    App.ConfigSet("SuppressRecomputeRequiredDialog", "")
    try:
        job = PathJob.Create("HelixOracleJob", [document.Body], None)
        job.Tools.Group[0].Tool.Diameter = 0.9
        operation = PathHelix.Create("OracleHelix", parentJob=job)
        scenarios = []
        expected = [
            ("inside-conventional", "Inside", "Conventional", "CW", "G2"),
            ("outside-climb", "Outside", "Climb", "CW", "G2"),
            ("inside-climb", "Inside", "Climb", "CCW", "G3"),
            ("outside-conventional", "Outside", "Conventional", "CCW", "G3"),
        ]
        for scenario_id, start_side, cut_mode, expected_direction, expected_arc in expected:
            operation.StartSide = start_side
            operation.CutMode = cut_mode
            operation.enforceRecompute()
            document.recompute()
            commands = [command_record(command, 2) for command in operation.Path.Commands]
            arc_commands = [command for command in commands if command["name"] in ("G2", "G3")]
            arc_names = sorted({command["name"] for command in arc_commands})
            command_histogram = {
                name: sum(1 for command in commands if command["name"] == name)
                for name in sorted({command["name"] for command in commands})
            }
            canonical = json.dumps(commands, sort_keys=True, separators=(",", ":"))
            scenarios.append({
                "id": scenario_id,
                "startSide": str(operation.StartSide),
                "cutMode": str(operation.CutMode),
                "direction": str(operation.Direction),
                "expectedDirection": expected_direction,
                "expectedArc": expected_arc,
                "arcNames": arc_names,
                "arcCommandCount": len(arc_commands),
                "firstArcCommand": arc_commands[0] if arc_commands else None,
                "lastArcCommand": arc_commands[-1] if arc_commands else None,
                "commandCount": len(commands),
                "commandHistogram": command_histogram,
                "commandSha256": hashlib.sha256(canonical.encode("utf-8")).hexdigest(),
                "pathLength": round(float(operation.Path.Length), 2),
            })
        base_sub_elements = sum(len(base[1]) for base in operation.Base)
        return {
            "fixture": "Mod/CAM/CAMTests/test_holes00.fcstd",
            "fixtureSha256": archive_record(fixture)["sha256"],
            "modelTypeId": document.Body.TypeId,
            "jobTypeId": job.TypeId,
            "operationTypeId": operation.TypeId,
            "pathPropertyType": operation.getTypeIdOfProperty("Path"),
            "toolDiameterMm": round(float(job.Tools.Group[0].Tool.Diameter), 3),
            "baseSubElementCount": base_sub_elements,
            "normalizationToleranceMm": 0.01,
            "scenarioCount": len(scenarios),
            "scenarios": scenarios,
        }
    finally:
        App.closeDocument(document.Name)


def create_roundtrip_archives(source_path, mutated_path):
    document = App.newDocument("NativeCamPathRoundTrip")
    native = document.addObject("Path::Feature", "NativePath")
    native.Label = "Native Path Original"
    native.addProperty("App::PropertyString", "OracleTag", "Oracle")
    native.OracleTag = "native-v1"
    python_path = document.addObject("Path::FeaturePython", "PythonPath")
    python_path.Label = "Python Path Original"
    python_path.addProperty("App::PropertyString", "OracleTag", "Oracle")
    python_path.OracleTag = "python-v1"

    commands = [
        Path.Command("G0", {"X": 0.0, "Y": 0.0, "Z": 5.0}),
        Path.Command("G1", {"X": 0.0, "Y": 0.0, "Z": 0.0, "F": 120.0}),
        Path.Command("G1", {"X": 20.0, "Y": 0.0, "Z": 0.0, "F": 300.0}),
        Path.Command("G2", {"X": 20.0, "Y": 20.0, "I": 0.0, "J": 10.0, "F": 300.0}),
        Path.Command("G0", {"X": 20.0, "Y": 20.0, "Z": 5.0}),
    ]
    native.Path = Path.Path(commands)
    python_path.Path = Path.Path([command for command in commands])
    document.recompute()
    document.saveAs(source_path)
    source_before_close = [path_record(native), path_record(python_path)]
    App.closeDocument(document.Name)

    source_reopened = load_path_objects(source_path)
    mutated = App.openDocument(source_path)
    native_mutated = mutated.getObject("NativePath")
    python_mutated = mutated.getObject("PythonPath")
    native_mutated.Label = "Native Path Mutated"
    native_mutated.OracleTag = "native-v2"
    native_mutated.Path = Path.Path(native_mutated.Path.Commands + [Path.Command("M5")])
    python_mutated.Label = "Python Path Mutated"
    python_mutated.OracleTag = "python-v2"
    python_mutated.Path = Path.Path(python_mutated.Path.Commands + [Path.Command("M2")])
    mutated.recompute()
    mutated.saveAs(mutated_path)
    App.closeDocument(mutated.Name)
    mutated_reopened = load_path_objects(mutated_path)

    return {
        "source": archive_record(source_path),
        "mutated": archive_record(mutated_path),
        "sourceBeforeClose": source_before_close,
        "sourceReopened": source_reopened,
        "mutatedReopened": mutated_reopened,
    }


preserved_path = os.environ.get("FREECAD_CAM_PATH_VERIFY_ARCHIVE", "")
if os.environ.get("FREECAD_CAM_PATH_VERIFY_ONLY") == "1":
    if not preserved_path:
        raise RuntimeError("FREECAD_CAM_PATH_VERIFY_ARCHIVE is required in verify-only mode")
    verify_preserved_archive(preserved_path)

source_path = os.environ.get("FREECAD_CAM_PATH_SOURCE", "")
mutated_path = os.environ.get("FREECAD_CAM_PATH_MUTATED", "")
if not source_path or not mutated_path:
    raise RuntimeError("FREECAD_CAM_PATH_SOURCE and FREECAD_CAM_PATH_MUTATED are required")

profile_document, profile_result = profile_algorithm_oracle()
helix_result = helix_algorithm_oracle()
App.setActiveDocument(profile_document.Name)
try:
    qt_result = qt_command_oracle(profile_document, profile_document.Body)
finally:
    App.closeDocument(profile_document.Name)

fcstd_result = create_roundtrip_archives(source_path, mutated_path)
version = App.Version()
result = {
    "schemaVersion": 1,
    "baselineId": "freecad-1.1.1",
    "freecadVersion": ".".join(str(value) for value in version[:3]),
    "revision": str(version[3]),
    "gitCommit": str(version[7]) if len(version) > 7 else "",
    "nativeRuntime": {
        "guiUp": bool(App.GuiUp),
        "pathModule": str(getattr(Path, "__file__", "built-in")),
    },
    "profileAlgorithm": profile_result,
    "helixAlgorithm": helix_result,
    "qtDynamicOracle": qt_result,
    "fcstdRoundTrip": fcstd_result,
}
emit_marker(result)
