import hashlib
import json
import sys
import time
from pathlib import Path
from typing import Any

import cadquery as cq


def hash_id(value: str) -> str:
    return hashlib.sha1(value.encode("utf-8")).hexdigest()[:16]


def json_safe(value: Any) -> Any:
    if value is None or isinstance(value, (str, int, float, bool)):
        return value
    if isinstance(value, dict):
        return {str(key): json_safe(item) for key, item in value.items()}
    if isinstance(value, (list, tuple)):
        return [json_safe(item) for item in value]
    return str(value)


def node_to_metadata(node: cq.Assembly, parent_path: str = "") -> dict[str, Any]:
    name = node.name or "root"
    node_path = f"{parent_path}/{name}" if parent_path else name
    children = [node_to_metadata(child, node_path) for child in node.children]
    return {
        "id": hash_id(node_path),
        "name": name,
        "path": node_path,
        "hasShape": bool(node.obj),
        "metadata": json_safe(node.metadata or {}),
        "children": children,
    }


def count_nodes(node: dict[str, Any]) -> int:
    return 1 + sum(count_nodes(child) for child in node.get("children", []))


def metadata_dict(value: Any) -> dict[str, Any]:
    safe_value = json_safe(value or {})
    return safe_value if isinstance(safe_value, dict) else {"value": safe_value}


def get_shape_type(value: Any) -> str | None:
    try:
        shape_type = value.ShapeType()
        return str(shape_type) if shape_type else None
    except Exception:
        return None


def collect_solids(workplane: cq.Workplane) -> list[Any]:
    solids: list[Any] = []
    seen: set[int] = set()

    for value in workplane.vals():
        candidates = []
        try:
            candidates = list(value.Solids())
        except Exception:
            candidates = []
        if not candidates and get_shape_type(value) == "Solid":
            candidates = [value]
        for solid in candidates:
            key = hash(solid)
            if key in seen:
                continue
            seen.add(key)
            solids.append(solid)

    return solids


def unique_assembly_name(parent: Any, requested_name: Any) -> str:
    base_name = str(requested_name or "component")
    if base_name not in parent.objects:
        return base_name

    index = 2
    while True:
        candidate = f"{base_name}__{index:03d}"
        if candidate not in parent.objects:
            return candidate
        index += 1


def import_step_with_unique_component_names(source_path: Path) -> cq.Assembly:
    original_add = cq.Assembly.add

    def add_with_unique_names(self: cq.Assembly, arg: Any, *args: Any, **kwargs: Any) -> Any:
        if isinstance(arg, cq.Assembly):
            positional_name = args[0] if args else None
            requested_name = kwargs.get("name", positional_name) or arg.name
            unique_name = unique_assembly_name(self, requested_name)

            if unique_name != requested_name:
                arg.metadata = {
                    **metadata_dict(arg.metadata),
                    "originalName": str(requested_name),
                }
                if args:
                    args = (unique_name, *args[1:])
                else:
                    kwargs = {**kwargs, "name": unique_name}

        return original_add(self, arg, *args, **kwargs)

    cq.Assembly.add = add_with_unique_names
    try:
        return cq.Assembly.importStep(str(source_path))
    finally:
        cq.Assembly.add = original_add


def import_step_assembly(source_path: Path) -> tuple[cq.Assembly, str]:
    try:
        return cq.Assembly.importStep(str(source_path)), "assembly"
    except ValueError as exc:
        error_message = str(exc)
        if "Unique name is required" in error_message and "already in the assembly" in error_message:
            return import_step_with_unique_component_names(source_path), "assembly-unique-names"
        if "does not contain an assembly" not in error_message:
            raise

    step_model = cq.importers.importStep(str(source_path))
    solids = collect_solids(step_model)
    assembly = cq.Assembly(name=f"{source_path.stem}_root")

    if len(solids) > 1:
        for index, solid in enumerate(solids, start=1):
            assembly.add(solid, name=f"{source_path.stem}_solid_{index:03d}")
        return assembly, "split-solids"

    assembly.add(step_model, name=source_path.stem)
    return assembly, "single-shape"


def write_json_atomic(path: Path, payload: dict[str, Any]) -> None:
    path.parent.mkdir(parents=True, exist_ok=True)
    tmp_path = path.with_name(f"{path.name}.tmp")
    tmp_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
    tmp_path.replace(path)


def main() -> int:
    source_path = Path(sys.argv[1])
    glb_path = Path(sys.argv[2])
    result_path = Path(sys.argv[3])
    linear_deflection = float(sys.argv[4])
    angular_deflection = float(sys.argv[5])
    started_at = time.monotonic()

    try:
        assembly, import_strategy = import_step_assembly(source_path)
        tree = node_to_metadata(assembly)
        glb_path.parent.mkdir(parents=True, exist_ok=True)
        assembly.export(str(glb_path), tolerance=linear_deflection, angularTolerance=angular_deflection)
        stats = {
            "fallback": True,
            "fileSizeMb": round(source_path.stat().st_size / 1024 / 1024, 2),
            "importStrategy": import_strategy,
            "cadqueryVersion": getattr(cq, "__version__", "unknown"),
            "linearDeflection": linear_deflection,
            "angularDeflection": angular_deflection,
            "durationSeconds": round(time.monotonic() - started_at, 2),
        }
        write_json_atomic(result_path, {"ok": True, "tree": tree, "stats": stats})
        return 0
    except Exception as exc:
        write_json_atomic(result_path, {"ok": False, "error": str(exc)})
        return 1


if __name__ == "__main__":
    raise SystemExit(main())
