import hashlib
import json
import os
import sys
import time
from datetime import datetime, timezone
from pathlib import Path
from typing import Any

import cadquery as cq


CONVERTER_NAME = "NodeDcBimConverter"
CONVERTER_VERSION = "0.2.0"
UPLOADS_DIR = Path(os.environ.get("NODEDC_BIM_CONVERTER_UPLOADS_DIR", "/beam/uploads")).resolve()
POLL_INTERVAL_SECONDS = float(os.environ.get("NODEDC_BIM_CONVERTER_INTERVAL_SECONDS", "10"))
PROCESS_ONCE = os.environ.get("NODEDC_BIM_CONVERTER_ONCE", "").lower() in {"1", "true", "yes"}
STEP_EXTENSIONS = {".step", ".stp"}


def now_iso() -> str:
    return datetime.now(timezone.utc).isoformat()


def src_from_path(path: Path) -> str:
    return f"uploads/{path.resolve().relative_to(UPLOADS_DIR).as_posix()}"


def manifest_path(source_path: Path) -> Path:
    return source_path.with_name(f"{source_path.name}.beam.json")


def read_json(path: Path) -> dict[str, Any]:
    try:
        return json.loads(path.read_text(encoding="utf-8"))
    except Exception:
        return {}


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 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(k): json_safe(v) for k, v in value.items()}
    if isinstance(value, (list, tuple)):
        return [json_safe(v) for v 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 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 import_step_assembly(source_path: Path) -> tuple[cq.Assembly, str]:
    try:
        return cq.Assembly.importStep(str(source_path)), "assembly"
    except ValueError as exc:
        if "does not contain an assembly" not in str(exc):
            raise

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

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

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


def convert_step_to_glb(source_path: Path, glb_path: Path, metadata_path: Path) -> dict[str, Any]:
    assy, import_strategy = import_step_assembly(source_path)
    tree = node_to_metadata(assy)
    glb_path.parent.mkdir(parents=True, exist_ok=True)
    assy.export(str(glb_path), tolerance=0.1, angularTolerance=0.1)

    metadata = {
        "source": src_from_path(source_path),
        "artifact": src_from_path(glb_path),
        "format": "step",
        "targetFormat": "glb",
        "importStrategy": import_strategy,
        "componentTree": tree,
        "componentCount": count_nodes(tree),
        "generatedAt": now_iso(),
        "converter": {
            "name": CONVERTER_NAME,
            "version": CONVERTER_VERSION,
            "engine": "cadquery",
            "cadqueryVersion": getattr(cq, "__version__", "unknown"),
        },
    }
    write_json_atomic(metadata_path, metadata)
    return metadata


def should_process(source_path: Path, manifest: dict[str, Any], glb_path: Path) -> bool:
    status = manifest.get("status")
    if status == "ready" and glb_path.exists():
        return manifest.get("converterVersion") != CONVERTER_VERSION
    if status == "processing":
        updated_at = manifest.get("updatedAt")
        if updated_at:
            try:
                updated = datetime.fromisoformat(str(updated_at).replace("Z", "+00:00"))
                if (datetime.now(timezone.utc) - updated).total_seconds() < 300:
                    return False
            except Exception:
                pass
    return source_path.exists()


def process_one(source_path: Path) -> bool:
    manifest_file = manifest_path(source_path)
    stem = source_path.stem
    glb_path = source_path.with_name(f"{stem}.glb")
    metadata_path = source_path.with_name(f"{stem}.metadata.json")
    manifest = read_json(manifest_file)

    if not should_process(source_path, manifest, glb_path):
        return False

    base_manifest = {
        "createdAt": manifest.get("createdAt") or now_iso(),
        "componentTreeRequired": True,
        "converterName": CONVERTER_NAME,
        "converterVersion": CONVERTER_VERSION,
        "sourceFormat": "step",
        "sourceSrc": src_from_path(source_path),
        "targetFormat": "glb",
    }

    write_json_atomic(
        manifest_file,
        {
            **base_manifest,
            "message": "Preparing GLB and component tree.",
            "status": "processing",
            "updatedAt": now_iso(),
        },
    )
    print(f"[{CONVERTER_NAME}] converting {source_path}", flush=True)

    try:
        metadata = convert_step_to_glb(source_path, glb_path, metadata_path)
        write_json_atomic(
            manifest_file,
            {
                **base_manifest,
                "artifactSrc": src_from_path(glb_path),
                "artifactType": "gltf",
                "componentCount": metadata.get("componentCount"),
                "metadataSrc": src_from_path(metadata_path),
                "message": "GLB and component tree are ready.",
                "status": "ready",
                "updatedAt": now_iso(),
            },
        )
        print(f"[{CONVERTER_NAME}] ready {glb_path}", flush=True)
        return True
    except Exception as exc:
        write_json_atomic(
            manifest_file,
            {
                **base_manifest,
                "error": str(exc),
                "message": "Failed to prepare GLB and component tree.",
                "status": "failed",
                "updatedAt": now_iso(),
            },
        )
        print(f"[{CONVERTER_NAME}] failed {source_path}: {exc}", file=sys.stderr, flush=True)
        return False


def scan_once() -> int:
    if not UPLOADS_DIR.exists():
        print(f"[{CONVERTER_NAME}] uploads dir does not exist: {UPLOADS_DIR}", flush=True)
        return 0

    processed = 0
    for source_path in sorted(UPLOADS_DIR.rglob("*")):
        if not source_path.is_file():
            continue
        if source_path.suffix.lower() not in STEP_EXTENSIONS:
            continue
        if process_one(source_path):
            processed += 1
    return processed


def main() -> None:
    print(f"[{CONVERTER_NAME}] watching {UPLOADS_DIR}", flush=True)
    while True:
        scan_once()
        if PROCESS_ONCE:
            return
        time.sleep(POLL_INTERVAL_SECONDS)


if __name__ == "__main__":
    main()
