#!/usr/bin/env python3
"""
SHiRE CoverCanvas Adaptive Relief Engine v0.1.0

This release establishes the native service contract, persistent jobs,
capability detection and hard safety gates.

It does not yet claim printable geometry. Manufacturing export stays blocked
until real mesh wrapping, wall-thickness validation and protected-zone
intersection checks pass.
"""

from __future__ import annotations

import hashlib
import importlib.util
import json
import os
import shutil
import subprocess
import sys
import threading
import time
import traceback
import uuid

from datetime import datetime, timezone
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from typing import Any
from urllib.parse import urlparse


ENGINE_ROOT = Path(__file__).resolve().parent
CONFIG_PATH = ENGINE_ROOT / "config.json"


def utc_now() -> str:
    return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")


def read_json(path: Path) -> dict[str, Any]:
    with path.open("r", encoding="utf-8") as handle:
        value = json.load(handle)
    if not isinstance(value, dict):
        raise ValueError(f"{path} must contain a JSON object")
    return value


CONFIG = read_json(CONFIG_PATH)
RUNTIME_ROOT = Path(CONFIG["runtime_root"]).resolve()
JOBS_ROOT = RUNTIME_ROOT / "jobs"
UPLOADS_ROOT = RUNTIME_ROOT / "uploads"
OUTPUTS_ROOT = RUNTIME_ROOT / "outputs"
LOGS_ROOT = RUNTIME_ROOT / "logs"

for directory in (RUNTIME_ROOT, JOBS_ROOT, UPLOADS_ROOT, OUTPUTS_ROOT, LOGS_ROOT):
    directory.mkdir(parents=True, exist_ok=True)


def command_info(command: str) -> dict[str, Any]:
    path = shutil.which(command)
    if not path:
        return {"available": False, "path": None, "version": None}

    version = None
    commands = {
        "blender": [path, "--version"],
        "openscad": [path, "--version"],
        "meshlabserver": [path, "-v"],
        "assimp": [path, "version"],
    }

    try:
        result = subprocess.run(
            commands.get(command, [path, "--version"]),
            stdout=subprocess.PIPE,
            stderr=subprocess.STDOUT,
            text=True,
            timeout=8,
            check=False,
        )
        version = (result.stdout or "").splitlines()[0][:300] or None
    except Exception:
        version = None

    return {"available": True, "path": path, "version": version}


def module_info(name: str) -> dict[str, Any]:
    spec = importlib.util.find_spec(name)
    return {"available": spec is not None}


def capabilities() -> dict[str, Any]:
    programs = {
        name: command_info(name)
        for name in ("blender", "openscad", "meshlabserver", "assimp")
    }
    modules = {
        name: module_info(name)
        for name in ("numpy", "trimesh", "PIL", "cv2", "open3d", "scipy", "shapely")
    }

    real_mesh_candidate = bool(
        programs["blender"]["available"]
        or (
            modules["numpy"]["available"]
            and modules["trimesh"]["available"]
            and modules["PIL"]["available"]
        )
    )

    return {
        "service": CONFIG["service_name"],
        "service_version": CONFIG["service_version"],
        "real_service": True,
        "simulation": False,
        "programs": programs,
        "python_modules": modules,
        "stages": {
            "request_validation": True,
            "persistent_jobs": True,
            "capability_detection": True,
            "cover_mesh_loading": real_mesh_candidate,
            "flat_heightmap_processing": real_mesh_candidate,
            "adaptive_surface_wrap": False,
            "protected_zone_mesh_intersection": False,
            "wall_thickness_analysis": False,
            "mesh_repair": False,
            "stl_export": False,
            "three_mf_export": False,
            "step_export": False,
            "manufacturing_export": False
        },
        "manufacturing_export_blocked": True,
        "safety": CONFIG["safety"]
    }


def sha256_json(value: Any) -> str:
    encoded = json.dumps(
        value,
        sort_keys=True,
        separators=(",", ":"),
        ensure_ascii=False,
    ).encode("utf-8")
    return hashlib.sha256(encoded).hexdigest()


def validate_number(
    errors: list[str],
    value: Any,
    name: str,
    minimum: float | None = None,
    maximum: float | None = None,
) -> None:
    if value is None:
        return
    if isinstance(value, bool) or not isinstance(value, (int, float)):
        errors.append(f"{name} must be numeric")
        return
    if minimum is not None and value < minimum:
        errors.append(f"{name} must be at least {minimum}")
    if maximum is not None and value > maximum:
        errors.append(f"{name} must be no more than {maximum}")


def validate_geometry_request(payload: Any) -> list[str]:
    errors: list[str] = []

    if not isinstance(payload, dict):
        return ["Request payload must be a JSON object"]

    cover = payload.get("cover_model")
    if not isinstance(cover, dict):
        errors.append("cover_model is required")
        cover = {}

    if CONFIG["safety"]["require_validated_cover"]:
        if not cover.get("id"):
            errors.append("cover_model.id is required")
        if not cover.get("reference"):
            errors.append("cover_model.reference is required")

    layers = payload.get("layers")
    if not isinstance(layers, list) or not layers:
        errors.append("At least one visible artwork layer is required")
        layers = []

    for index, layer in enumerate(layers):
        prefix = f"layers[{index}]"
        if not isinstance(layer, dict):
            errors.append(f"{prefix} must be an object")
            continue

        if not layer.get("id"):
            errors.append(f"{prefix}.id is required")

        transform = layer.get("transform")
        if not isinstance(transform, dict):
            errors.append(f"{prefix}.transform is required")
            continue

        validate_number(errors, transform.get("position_x"), f"{prefix}.transform.position_x", -2, 2)
        validate_number(errors, transform.get("position_y"), f"{prefix}.transform.position_y", -2, 2)
        validate_number(errors, transform.get("scale_x"), f"{prefix}.transform.scale_x", 0.01, 10)
        validate_number(errors, transform.get("scale_y"), f"{prefix}.transform.scale_y", 0.01, 10)
        validate_number(errors, transform.get("rotation"), f"{prefix}.transform.rotation", -360, 360)
        validate_number(errors, transform.get("opacity"), f"{prefix}.transform.opacity", 0, 1)

        if layer.get("projection_mode") not in ("planar", "cylindrical", "surface"):
            errors.append(
                f"{prefix}.projection_mode must be planar, cylindrical or surface"
            )

    mode = payload.get("application_mode")
    valid_modes = {
        "colour_texture",
        "raised_emboss",
        "engraving",
        "sculpted_relief",
        "stencil",
    }
    if mode not in valid_modes:
        errors.append(f"application_mode must be one of {sorted(valid_modes)}")

    limits = CONFIG["safety"]

    emboss = payload.get("emboss")
    if emboss is not None:
        if not isinstance(emboss, dict):
            errors.append("emboss must be an object or null")
        else:
            validate_number(
                errors,
                emboss.get("height"),
                "emboss.height",
                0,
                limits["maximum_relief_height_mm"],
            )
            validate_number(
                errors,
                emboss.get("minimum_feature"),
                "emboss.minimum_feature",
                limits["minimum_raised_feature_mm"],
            )

    engraving = payload.get("engraving")
    if engraving is not None:
        if not isinstance(engraving, dict):
            errors.append("engraving must be an object or null")
        else:
            validate_number(
                errors,
                engraving.get("depth"),
                "engraving.depth",
                0,
                limits["maximum_engraving_depth_mm"],
            )
            validate_number(
                errors,
                engraving.get("minimum_feature"),
                "engraving.minimum_feature",
                limits["minimum_recessed_feature_mm"],
            )

    relief = payload.get("relief")
    if relief is not None:
        if not isinstance(relief, dict):
            errors.append("relief must be an object or null")
        else:
            validate_number(errors, relief.get("strength"), "relief.strength", 0, 1)

    protected = payload.get("protected_zones")
    if CONFIG["safety"]["require_protected_zone_check"]:
        if protected is None:
            errors.append("protected_zones must be supplied, even when empty")
        elif not isinstance(protected, list):
            errors.append("protected_zones must be an array")

    decoration = payload.get("decoration_zones")
    if decoration is not None and not isinstance(decoration, list):
        errors.append("decoration_zones must be an array")

    return errors


def job_path(job_id: str) -> Path:
    if not job_id or any(c not in "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_" for c in job_id):
        raise ValueError("Invalid job id")
    return JOBS_ROOT / f"{job_id}.json"


def write_job(job: dict[str, Any]) -> None:
    destination = job_path(job["id"])
    temporary = destination.with_suffix(".json.tmp")
    with temporary.open("w", encoding="utf-8") as handle:
        json.dump(job, handle, indent=2, ensure_ascii=False)
        handle.write("\n")
    os.replace(temporary, destination)


def read_job(job_id: str) -> dict[str, Any]:
    path = job_path(job_id)
    if not path.is_file():
        raise FileNotFoundError(job_id)
    return read_json(path)


def update_job(job_id: str, **changes: Any) -> dict[str, Any]:
    job = read_job(job_id)
    job.update(changes)
    job["updated_at"] = utc_now()
    write_job(job)
    return job


def process_job(job_id: str) -> None:
    try:
        job = update_job(
            job_id,
            status="processing",
            progress=10,
            stage="validating_request",
        )
        payload = job["request"]

        errors = validate_geometry_request(payload)
        if errors:
            update_job(
                job_id,
                status="blocked",
                progress=100,
                stage="request_rejected",
                errors=errors,
                manufacturing_ready=False,
                simulation=False,
            )
            return

        update_job(
            job_id,
            progress=35,
            stage="capability_check",
            capabilities=capabilities(),
        )

        time.sleep(0.1)

        update_job(
            job_id,
            progress=60,
            stage="request_normalised",
            normalised_request_sha256=sha256_json(payload),
        )

        time.sleep(0.1)

        # V0.1 intentionally stops here. This is a real native job and a real
        # safety decision, but printable geometry is not yet claimed.
        update_job(
            job_id,
            status="blocked",
            progress=100,
            stage="mesh_engine_not_installed",
            result={
                "real_service": True,
                "simulation": False,
                "request_validated": True,
                "geometry_generated": False,
                "manufacturing_ready": False,
                "export_allowed": False,
                "next_required_stage": "adaptive_surface_wrap_v0.2",
                "message": (
                    "The native engine accepted and validated this job. "
                    "Adaptive mesh wrapping is not enabled in v0.1, so export remains blocked."
                ),
            },
            manufacturing_ready=False,
            simulation=False,
        )
    except Exception as exc:
        update_job(
            job_id,
            status="failed",
            progress=100,
            stage="engine_error",
            error=str(exc),
            traceback=traceback.format_exc(),
            manufacturing_ready=False,
            simulation=False,
        )


def create_job(payload: dict[str, Any]) -> dict[str, Any]:
    job_id = f"cc-{datetime.now(timezone.utc).strftime('%Y%m%d%H%M%S')}-{uuid.uuid4().hex[:10]}"
    job = {
        "id": job_id,
        "engine_version": CONFIG["service_version"],
        "created_at": utc_now(),
        "updated_at": utc_now(),
        "status": "queued",
        "progress": 0,
        "stage": "queued",
        "simulation": False,
        "manufacturing_ready": False,
        "request_sha256": sha256_json(payload),
        "request": payload,
    }
    write_job(job)

    worker = threading.Thread(target=process_job, args=(job_id,), daemon=True)
    worker.start()
    return job


class Handler(BaseHTTPRequestHandler):
    server_version = "SHiRECoverCanvasRelief/0.1"

    def log_message(self, fmt: str, *args: Any) -> None:
        message = (
            f"{utc_now()} {self.client_address[0]} "
            f"{fmt % args}\n"
        )
        with (LOGS_ROOT / "http.log").open("a", encoding="utf-8") as handle:
            handle.write(message)

    def send_json(self, status: int, value: Any) -> None:
        encoded = json.dumps(value, ensure_ascii=False).encode("utf-8")
        self.send_response(status)
        self.send_header("Content-Type", "application/json; charset=utf-8")
        self.send_header("Content-Length", str(len(encoded)))
        self.send_header("Cache-Control", "no-store")
        self.send_header("Access-Control-Allow-Origin", "http://127.0.0.1:4174")
        self.send_header("Access-Control-Allow-Headers", "Content-Type")
        self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
        self.end_headers()
        self.wfile.write(encoded)

    def read_body(self) -> dict[str, Any]:
        content_length = int(self.headers.get("Content-Length", "0"))
        maximum = int(CONFIG["max_request_bytes"])
        if content_length <= 0:
            raise ValueError("Request body is required")
        if content_length > maximum:
            raise ValueError(f"Request exceeds maximum size of {maximum} bytes")

        raw = self.rfile.read(content_length)
        value = json.loads(raw.decode("utf-8"))
        if not isinstance(value, dict):
            raise ValueError("Request body must be a JSON object")
        return value

    def do_OPTIONS(self) -> None:
        self.send_json(200, {"ok": True})

    def do_GET(self) -> None:
        path = urlparse(self.path).path

        if path == "/health":
            self.send_json(
                200,
                {
                    "ok": True,
                    "status": "ready",
                    "service": CONFIG["service_name"],
                    "service_version": CONFIG["service_version"],
                    "real_service": True,
                    "simulation": False,
                    "manufacturing_export_blocked": True,
                    "time": utc_now(),
                },
            )
            return

        if path == "/capabilities":
            self.send_json(200, {"ok": True, **capabilities()})
            return

        if path.startswith("/jobs/"):
            job_id = path.split("/", 2)[2]
            try:
                self.send_json(200, {"ok": True, "job": read_job(job_id)})
            except FileNotFoundError:
                self.send_json(404, {"ok": False, "error": "Job not found"})
            except ValueError as exc:
                self.send_json(400, {"ok": False, "error": str(exc)})
            return

        self.send_json(404, {"ok": False, "error": "Not found"})

    def do_POST(self) -> None:
        path = urlparse(self.path).path

        try:
            payload = self.read_body()
        except (ValueError, json.JSONDecodeError) as exc:
            self.send_json(400, {"ok": False, "error": str(exc)})
            return

        if path == "/validate":
            errors = validate_geometry_request(payload)
            self.send_json(
                200,
                {
                    "ok": not errors,
                    "valid": not errors,
                    "errors": errors,
                    "real_service": True,
                    "simulation": False,
                    "manufacturing_ready": False,
                },
            )
            return

        if path == "/jobs":
            job = create_job(payload)
            self.send_json(
                202,
                {
                    "ok": True,
                    "simulation": False,
                    "real_service": True,
                    "job_id": job["id"],
                    "status": job["status"],
                    "progress": job["progress"],
                    "manufacturing_ready": False,
                },
            )
            return

        self.send_json(404, {"ok": False, "error": "Not found"})


def main() -> None:
    host = str(CONFIG["listen_host"])
    port = int(CONFIG["listen_port"])

    server = ThreadingHTTPServer((host, port), Handler)
    print(
        f"{CONFIG['service_name']} v{CONFIG['service_version']} "
        f"listening on http://{host}:{port}",
        flush=True,
    )
    server.serve_forever()


if __name__ == "__main__":
    main()
