"""OpenSCAD local model generation provider.

Compiles OpenSCAD scripts into STL files using the ``openscad`` CLI.
The agent writes the ``.scad`` code; Kiln compiles it locally.  This
is deterministic, parametric, free, and ideal for geometric or
mechanical parts.

Requires OpenSCAD installed on the system.  Auto-detects the binary
from ``PATH`` or macOS application bundle.
"""

from __future__ import annotations

import contextlib
import logging
import os
import re
import shutil
import subprocess
import sys
import tempfile
import time
import uuid
from pathlib import Path
from typing import Any

from kiln.generation.base import (
    GenerationError,
    GenerationJob,
    GenerationProvider,
    GenerationResult,
    GenerationStatus,
)

logger = logging.getLogger(__name__)

# Bundled OpenSCAD library whitelist — only these prefixes are allowed
# in include/use statements for security.
_SAFE_LIBRARY_PREFIXES = ("BOSL2/", "MCAD/")

# Dangerous patterns that can read arbitrary files from disk.
_DANGEROUS_PATTERNS = [
    r"\bimport\s*\(",  # import() can read arbitrary files
    r"\bsurface\s*\(",  # surface() reads files from disk
]


def _check_scad_security(code: str) -> None:
    """Validate OpenSCAD code against security rules.

    Raises :class:`ValueError` if the code contains dangerous operations
    (``import()``, ``surface()``) or references non-bundled libraries.
    This MUST be called before any code path that compiles OpenSCAD.
    """
    if len(code) > 100_000:
        raise ValueError("OpenSCAD code too large (max 100KB).")

    for pattern in _DANGEROUS_PATTERNS:
        if re.search(pattern, code, re.IGNORECASE):
            raise ValueError(
                f"OpenSCAD code contains blocked operation matching {pattern}. "
                f"File I/O operations are not allowed for security."
            )

    if not _has_only_safe_includes(code):
        raise ValueError(
            "OpenSCAD code contains include/use statements referencing "
            "non-bundled libraries. Only BOSL2 and MCAD are allowed."
        )


def _get_bundled_library_path() -> str:
    """Return the absolute path to Kiln's bundled OpenSCAD libraries.

    The library directory lives at ``kiln/src/kiln/data/scad_libraries/``
    and may contain BOSL2, MCAD, or other vetted libraries.
    """
    return str(
        Path(__file__).resolve().parent.parent / "data" / "scad_libraries"
    )


def _has_only_safe_includes(code: str) -> bool:
    """Check that all ``include``/``use`` statements reference bundled libraries.

    Returns ``True`` if the code contains no ``include``/``use`` at all,
    or if every such statement references a path starting with one of the
    allowed library prefixes (BOSL2/, MCAD/).

    Security: rejects path traversal (``..``), absolute paths, null bytes,
    and non-ASCII characters that could be used to bypass filtering.
    """
    for match in re.finditer(r"\b(?:include|use)\s*<([^>]+)>", code, re.IGNORECASE):
        path = match.group(1).strip()
        # Reject null bytes and non-ASCII (Unicode tricks)
        if "\x00" in path or not path.isascii():
            return False
        # Reject path traversal
        if ".." in path:
            return False
        # Reject absolute paths
        if path.startswith("/") or path.startswith("\\"):
            return False
        if not any(path.startswith(prefix) for prefix in _SAFE_LIBRARY_PREFIXES):
            return False
    return True


_MACOS_APP_PATH = "/Applications/OpenSCAD.app/Contents/MacOS/OpenSCAD" if sys.platform == "darwin" else ""
# Versioned installs (e.g., OpenSCAD-2021.01.app)
_MACOS_VERSIONED_PATTERN = "/Applications/OpenSCAD-*.app/Contents/MacOS/OpenSCAD" if sys.platform == "darwin" else ""


def _find_openscad(explicit_path: str | None = None) -> str:
    """Locate the OpenSCAD binary.

    Args:
        explicit_path: If provided, verify it exists and is executable.

    Returns:
        Absolute path to the OpenSCAD binary.

    Raises:
        GenerationError: If no binary is found.
    """
    if explicit_path:
        if os.path.isfile(explicit_path) and os.access(explicit_path, os.X_OK):
            return explicit_path
        raise GenerationError(
            f"OpenSCAD binary not found at {explicit_path}",
            code="OPENSCAD_NOT_FOUND",
        )

    # Check PATH.
    which = shutil.which("openscad")
    if which:
        return which

    # Check macOS application bundle.
    if _MACOS_APP_PATH and os.path.isfile(_MACOS_APP_PATH) and os.access(_MACOS_APP_PATH, os.X_OK):
        return _MACOS_APP_PATH

    # Check versioned macOS app bundles (e.g., OpenSCAD-2021.01.app).
    if _MACOS_VERSIONED_PATTERN:
        import glob as _glob

        matches = sorted(_glob.glob(_MACOS_VERSIONED_PATTERN), reverse=True)
        for match in matches:
            if os.path.isfile(match) and os.access(match, os.X_OK):
                return match
        # If versioned app exists but isn't executable, likely Gatekeeper
        if matches:
            app_path = matches[0].split("/Contents/")[0]
            raise GenerationError(
                f"OpenSCAD found at {app_path} but cannot execute.\n"
                f"macOS Gatekeeper may be blocking it. Fix with:\n"
                f'  xattr -dr com.apple.quarantine "{app_path}"\n'
                f"Then retry the operation.",
                code="OPENSCAD_QUARANTINED",
            )

    raise GenerationError(
        "OpenSCAD not found. Install it:\n"
        "  Linux/WSL: apt install openscad\n"
        "  macOS: brew install openscad\n"
        "  Or download from https://openscad.org\n"
        "Or set the binary path explicitly.",
        code="OPENSCAD_NOT_FOUND",
    )


class OpenSCADProvider(GenerationProvider):
    """Local OpenSCAD model generation.

    The ``prompt`` argument to :meth:`generate` should contain valid
    OpenSCAD code.  The provider writes it to a temporary ``.scad``
    file, compiles it to STL, and returns immediately (synchronous).

    Args:
        binary_path: Explicit path to the ``openscad`` binary.
            Auto-detected if omitted.
        timeout: Maximum compilation time in seconds.
    """

    def __init__(
        self,
        binary_path: str | None = None,
        *,
        timeout: int = 120,
    ) -> None:
        self._binary = _find_openscad(binary_path)
        # _find_openscad() raises GenerationError on failure, so
        # self._binary is always a valid path after this line.
        self._timeout = timeout
        self._jobs: dict[str, GenerationJob] = {}
        self._paths: dict[str, str] = {}

    @property
    def name(self) -> str:
        return "openscad"

    @property
    def display_name(self) -> str:
        return "OpenSCAD"

    def generate(
        self,
        prompt: str,
        *,
        format: str = "stl",
        style: str | None = None,
        **kwargs: Any,
    ) -> GenerationJob:
        """Compile OpenSCAD code to an STL file.

        Args:
            prompt: Valid OpenSCAD code.
            format: Output format (only ``"stl"`` supported).
            style: Ignored for OpenSCAD.

        Returns:
            :class:`GenerationJob` with ``SUCCEEDED`` or ``FAILED`` status.
        """
        if format != "stl":
            raise GenerationError(
                f"OpenSCAD only supports STL output, got {format!r}.",
                code="UNSUPPORTED_FORMAT",
            )

        job_id = f"openscad-{uuid.uuid4().hex[:12]}"
        output_dir = kwargs.get("output_dir", os.path.join(tempfile.gettempdir(), "kiln_generated"))
        os.makedirs(output_dir, exist_ok=True)
        out_path = os.path.join(output_dir, f"{job_id}.stl")

        # Centralised security checks (size, dangerous ops, include whitelist).
        _check_scad_security(prompt)

        # Write .scad source to a temp file.
        scad_fd, scad_path = tempfile.mkstemp(suffix=".scad", prefix="kiln_")
        try:
            with os.fdopen(scad_fd, "w") as fh:
                fh.write(prompt)

            binary = self._require_binary()
            lib_path = _get_bundled_library_path()
            cmd = [binary, "-o", out_path]
            if os.path.isdir(lib_path):
                cmd.extend(["-L", lib_path])
            cmd.append(scad_path)
            logger.info("OpenSCAD: %s", " ".join(cmd))

            work_dir = tempfile.mkdtemp(prefix="kiln_scad_")
            try:
                try:
                    result = subprocess.run(
                        cmd,
                        capture_output=True,
                        text=True,
                        timeout=self._timeout,
                        cwd=work_dir,
                    )
                except subprocess.TimeoutExpired:
                    job = GenerationJob(
                        id=job_id,
                        provider=self.name,
                        prompt=prompt[:200],
                        status=GenerationStatus.FAILED,
                        progress=0,
                        created_at=time.time(),
                        format=format,
                        error=f"OpenSCAD compilation timed out after {self._timeout}s.",
                    )
                    self._jobs[job_id] = job
                    return job
                except OSError as exc:
                    raise GenerationError(
                        f"Failed to run OpenSCAD: {exc}",
                        code="OPENSCAD_EXEC_ERROR",
                    ) from exc
            finally:
                shutil.rmtree(work_dir, ignore_errors=True)

            if result.returncode != 0:
                stderr = (result.stderr or "").strip()[:500]
                job = GenerationJob(
                    id=job_id,
                    provider=self.name,
                    prompt=prompt[:200],
                    status=GenerationStatus.FAILED,
                    progress=0,
                    created_at=time.time(),
                    format=format,
                    error=f"OpenSCAD exited with code {result.returncode}: {stderr}",
                )
                self._jobs[job_id] = job
                return job

            if not os.path.isfile(out_path) or os.path.getsize(out_path) == 0:
                job = GenerationJob(
                    id=job_id,
                    provider=self.name,
                    prompt=prompt[:200],
                    status=GenerationStatus.FAILED,
                    progress=0,
                    created_at=time.time(),
                    format=format,
                    error="OpenSCAD produced no output file.",
                )
                self._jobs[job_id] = job
                return job

            self._paths[job_id] = out_path
            job = GenerationJob(
                id=job_id,
                provider=self.name,
                prompt=prompt[:200],
                status=GenerationStatus.SUCCEEDED,
                progress=100,
                created_at=time.time(),
                format=format,
            )
            self._jobs[job_id] = job
            return job

        finally:
            # Clean up temp .scad file.
            with contextlib.suppress(OSError):
                os.unlink(scad_path)

    def get_job_status(self, job_id: str) -> GenerationJob:
        """Return the stored job state.

        OpenSCAD jobs are synchronous, so this simply returns the
        result from :meth:`generate`.
        """
        job = self._jobs.get(job_id)
        if not job:
            raise GenerationError(f"Job {job_id!r} not found.", code="JOB_NOT_FOUND")
        return job

    def download_result(
        self,
        job_id: str,
        output_dir: str = os.path.join(tempfile.gettempdir(), "kiln_generated"),
    ) -> GenerationResult:
        """Return the path to the already-generated STL.

        For OpenSCAD, the file is generated synchronously during
        :meth:`generate`, so this just verifies the file exists.
        """
        path = self._paths.get(job_id)
        if not path or not os.path.isfile(path):
            raise GenerationError(f"No generated file for job {job_id!r}.", code="NO_RESULT")

        job = self._jobs.get(job_id)
        prompt = job.prompt if job else ""

        return GenerationResult(
            job_id=job_id,
            provider=self.name,
            local_path=path,
            format="stl",
            file_size_bytes=os.path.getsize(path),
            prompt=prompt,
        )

    def render_preview(
        self,
        file_path: str,
        *,
        output_path: str | None = None,
        width: int = 800,
        height: int = 600,
        camera: str | None = None,
    ) -> str:
        """Render a PNG preview of an STL or OpenSCAD file.

        Uses OpenSCAD's rendering pipeline to produce a visual preview
        image.  Agents can use this to visually inspect generated geometry
        before sending it to the printer.

        Args:
            file_path: Path to ``.stl`` or ``.scad`` file to render.
            output_path: Path for the output PNG.  Defaults to a temp file.
            width: Image width in pixels.
            height: Image height in pixels.
            camera: OpenSCAD camera spec.  Defaults to auto-centering.

        Returns:
            Path to the rendered PNG file.

        Raises:
            GenerationError: If rendering fails.
        """
        src = Path(file_path).resolve()
        ext = src.suffix.lower()

        # Security: only allow 3D model files for preview rendering.
        if ext not in (".stl", ".scad", ".3mf", ".obj", ".amf"):
            raise ValueError(
                f"render_preview only accepts 3D model files, got {ext!r}"
            )

        if ext == ".stl":
            # Wrap the STL in an OpenSCAD import statement.
            # Path is validated above — only model file extensions allowed.
            scad_code = f'import("{src}");'
            scad_fd, scad_path = tempfile.mkstemp(suffix=".scad", prefix="kiln_preview_")
            try:
                with os.fdopen(scad_fd, "w") as fh:
                    fh.write(scad_code)
                return self._render_scad_to_png(
                    scad_path, output_path=output_path,
                    width=width, height=height, camera=camera,
                )
            finally:
                with contextlib.suppress(OSError):
                    os.unlink(scad_path)
        elif ext == ".scad":
            return self._render_scad_to_png(
                file_path, output_path=output_path,
                width=width, height=height, camera=camera,
            )
        else:
            raise GenerationError(
                f"Cannot render preview for {ext!r} files. Supported: .stl, .scad",
                code="UNSUPPORTED_FORMAT",
            )

    def _require_binary(self) -> str:
        """Return the OpenSCAD binary path or raise if unavailable."""
        if not self._binary:
            raise GenerationError(
                "OpenSCAD not found. Install it:\n"
                "  Linux/WSL: apt install openscad\n"
                "  macOS: brew install openscad\n"
                "  Or download from https://openscad.org",
                code="OPENSCAD_NOT_FOUND",
            )
        return self._binary

    def _render_scad_to_png(
        self,
        scad_path: str,
        *,
        output_path: str | None = None,
        width: int = 800,
        height: int = 600,
        camera: str | None = None,
    ) -> str:
        """Render a .scad file to PNG using the OpenSCAD CLI."""
        if output_path is None:
            out_fd, output_path = tempfile.mkstemp(suffix=".png", prefix="kiln_preview_")
            os.close(out_fd)

        binary = self._require_binary()
        cmd = [
            binary,
            "-o", output_path,
            f"--imgsize={width},{height}",
            "--render",
        ]
        if camera:
            cmd.extend(["--camera", camera])
        else:
            cmd.append("--autocenter")
            cmd.append("--viewall")
        cmd.append(scad_path)

        work_dir = tempfile.mkdtemp(prefix="kiln_preview_")
        try:
            result = subprocess.run(
                cmd,
                capture_output=True,
                text=True,
                timeout=60,
                cwd=work_dir,
            )
        except subprocess.TimeoutExpired:
            raise GenerationError(
                "OpenSCAD preview render timed out after 60s.",
                code="RENDER_TIMEOUT",
            ) from None
        except OSError as exc:
            raise GenerationError(
                f"Failed to run OpenSCAD for preview: {exc}",
                code="OPENSCAD_EXEC_ERROR",
            ) from exc
        finally:
            shutil.rmtree(work_dir, ignore_errors=True)

        if result.returncode != 0:
            stderr = (result.stderr or "").strip()[:500]
            raise GenerationError(
                f"OpenSCAD preview failed (exit {result.returncode}): {stderr}",
                code="RENDER_FAILED",
            )

        if not os.path.isfile(output_path) or os.path.getsize(output_path) == 0:
            raise GenerationError(
                "OpenSCAD preview produced no output image.",
                code="RENDER_EMPTY",
            )

        return output_path

    def validate_scad(self, code: str) -> dict[str, Any]:
        """Validate OpenSCAD code without generating output.

        Compiles the code with ``--export-format=echo`` to check for
        syntax/semantic errors without producing geometry.

        Args:
            code: OpenSCAD source code to validate.

        Returns:
            Dict with ``valid``, ``errors``, and ``warnings`` keys.
        """
        # Apply the same security checks as generate() — block import(),
        # surface(), and non-bundled includes.
        _check_scad_security(code)

        scad_fd, scad_path = tempfile.mkstemp(suffix=".scad", prefix="kiln_validate_")
        try:
            with os.fdopen(scad_fd, "w") as fh:
                fh.write(code)

            # Use /dev/null as output — we only care about stderr
            null_out = os.path.join(tempfile.gettempdir(), f"kiln_null_{uuid.uuid4().hex[:8]}.stl")
            binary = self._require_binary()
            lib_path = _get_bundled_library_path()
            cmd = [binary, "-o", null_out]
            if os.path.isdir(lib_path):
                cmd.extend(["-L", lib_path])
            cmd.append(scad_path)

            try:
                result = subprocess.run(
                    cmd,
                    capture_output=True,
                    text=True,
                    timeout=30,
                    cwd=tempfile.gettempdir(),
                )
            except subprocess.TimeoutExpired:
                return {
                    "valid": False,
                    "errors": [{"message": "Validation timed out after 30s"}],
                    "warnings": [],
                }
            finally:
                with contextlib.suppress(OSError):
                    os.unlink(null_out)

            return _parse_openscad_output(result.stderr or "", result.returncode)
        finally:
            with contextlib.suppress(OSError):
                os.unlink(scad_path)

    def boolean_operation(
        self,
        operation: str,
        file_paths: list[str],
        *,
        output_path: str | None = None,
    ) -> str:
        """Perform a CSG boolean operation on two or more STL files.

        Uses OpenSCAD's boolean engine to compute the result of
        ``union()``, ``difference()``, or ``intersection()`` on
        the given meshes.

        Args:
            operation: One of ``"union"``, ``"difference"``, or
                ``"intersection"``.
            file_paths: List of STL file paths (minimum 2).
            output_path: Output STL path.  Defaults to a temp file.

        Returns:
            Path to the resulting STL file.

        Raises:
            GenerationError: If the operation fails.
            ValueError: If arguments are invalid.
        """
        operation = operation.lower()
        if operation not in ("union", "difference", "intersection"):
            raise ValueError(
                f"operation must be 'union', 'difference', or 'intersection', "
                f"got {operation!r}"
            )
        if len(file_paths) < 2:
            raise ValueError("boolean_operation requires at least 2 file paths")

        for fp in file_paths:
            if not os.path.isfile(fp):
                raise FileNotFoundError(f"STL file not found: {fp}")
            # Security: restrict file access to STL/3MF files only.
            # Prevents agents from importing arbitrary files (e.g. /etc/passwd).
            ext = os.path.splitext(fp)[1].lower()
            if ext not in (".stl", ".3mf", ".obj", ".amf", ".off", ".dxf", ".svg"):
                raise ValueError(
                    f"boolean_operation only accepts 3D model files, "
                    f"got unsupported extension {ext!r} for {fp!r}"
                )

        # Build OpenSCAD code that imports and booleans the meshes.
        # We use absolute resolved paths to avoid path confusion.
        imports = []
        for fp in file_paths:
            resolved = os.path.abspath(fp)
            imports.append(f'  import("{resolved}");')

        scad_code = f"{operation}() {{\n" + "\n".join(imports) + "\n}"

        if output_path is None:
            out_fd, output_path = tempfile.mkstemp(
                suffix=".stl", prefix=f"kiln_{operation}_"
            )
            os.close(out_fd)

        scad_fd, scad_path = tempfile.mkstemp(suffix=".scad", prefix="kiln_bool_")
        work_dir = tempfile.mkdtemp(prefix="kiln_bool_")
        try:
            with os.fdopen(scad_fd, "w") as fh:
                fh.write(scad_code)

            binary = self._require_binary()
            cmd = [binary, "-o", output_path, scad_path]
            logger.info("OpenSCAD boolean: %s", " ".join(cmd))

            try:
                result = subprocess.run(
                    cmd,
                    capture_output=True,
                    text=True,
                    timeout=self._timeout,
                    cwd=work_dir,
                )
            except subprocess.TimeoutExpired:
                raise GenerationError(
                    f"Boolean {operation} timed out after {self._timeout}s.",
                    code="BOOL_TIMEOUT",
                ) from None
            except OSError as exc:
                raise GenerationError(
                    f"Failed to run OpenSCAD for boolean: {exc}",
                    code="OPENSCAD_EXEC_ERROR",
                ) from exc

            if result.returncode != 0:
                stderr = (result.stderr or "").strip()[:500]
                raise GenerationError(
                    f"Boolean {operation} failed (exit {result.returncode}): {stderr}",
                    code="BOOL_FAILED",
                )

            if not os.path.isfile(output_path) or os.path.getsize(output_path) == 0:
                raise GenerationError(
                    f"Boolean {operation} produced no output.",
                    code="BOOL_EMPTY",
                )

            return output_path
        finally:
            with contextlib.suppress(OSError):
                os.unlink(scad_path)
            shutil.rmtree(work_dir, ignore_errors=True)

    def list_styles(self) -> list[str]:
        return []


def boolean_mesh_operation(
    operation: str,
    file_paths: list[str],
    *,
    output_path: str | None = None,
) -> dict[str, Any]:
    """Perform a CSG boolean operation on STL meshes via OpenSCAD.

    Convenience function that auto-discovers OpenSCAD and delegates
    to :meth:`OpenSCADProvider.boolean_operation`.

    Args:
        operation: ``"union"``, ``"difference"``, or ``"intersection"``.
        file_paths: List of STL file paths (minimum 2).
        output_path: Output STL path.

    Returns:
        Dict with result path, operation, and triangle count.

    Raises:
        GenerationError: If OpenSCAD is not found or the operation fails.
    """
    # Validate inputs before expensive OpenSCAD discovery
    valid_ops = ("union", "difference", "intersection")
    if operation not in valid_ops:
        raise ValueError(
            f"Unknown operation '{operation}'. Must be one of: "
            f"union, difference, intersection"
        )
    if len(file_paths) < 2:
        raise ValueError("boolean_mesh_operation requires at least 2 file paths")
    for fp in file_paths:
        if not os.path.isfile(fp):
            raise FileNotFoundError(f"STL file not found: {fp}")

    binary = _find_openscad()  # raises GenerationError if not found
    if not binary:
        raise GenerationError(
            "OpenSCAD not found.",
            code="OPENSCAD_NOT_FOUND",
        )

    provider = OpenSCADProvider(binary_path=binary)
    result_path = provider.boolean_operation(
        operation, file_paths, output_path=output_path,
    )

    # Count triangles in result
    tri_count = 0
    try:
        import struct as _struct

        with open(result_path, "rb") as fh:
            fh.seek(80)
            data = fh.read(4)
            if len(data) == 4:
                tri_count = _struct.unpack("<I", data)[0]
    except Exception:
        pass

    return {
        "path": result_path,
        "operation": operation,
        "input_files": file_paths,
        "triangle_count": tri_count,
    }


def compose_from_primitives(
    operations: list[dict[str, Any]],
    *,
    output_path: str | None = None,
) -> dict[str, Any]:
    """Build a functional part by composing geometric primitives with booleans.

    This is the **CAD-aware generation path** — instead of asking a text-to-mesh
    AI to guess at geometry, agents describe parts as a tree of primitives
    (cubes, cylinders, spheres) combined with boolean operations.

    Each operation in the list is a dict with:
        - ``type``: ``"primitive"`` or ``"boolean"``
        - For primitives:
            - ``shape``: ``"cube"``, ``"cylinder"``, ``"sphere"``, ``"cone"``
            - ``params``: shape-specific parameters (see below)
            - ``translate``: optional ``[x, y, z]`` translation
            - ``rotate``: optional ``[rx, ry, rz]`` rotation in degrees
            - ``id``: optional string ID to reference later
        - For booleans:
            - ``operation``: ``"union"``, ``"difference"``, ``"intersection"``
            - ``children``: list of operation dicts (recursive)

    Primitive params:
        - cube: ``{"size": [x,y,z]}`` or ``{"size": scalar}``
        - cylinder: ``{"h": height, "r": radius}`` or ``{"h", "r1", "r2"}``
        - sphere: ``{"r": radius}``
        - cone: ``{"h": height, "r1": bottom_radius, "r2": top_radius}``

    Example — bracket with mounting hole::

        compose_from_primitives([
            {"type": "boolean", "operation": "difference", "children": [
                {"type": "boolean", "operation": "union", "children": [
                    {"type": "primitive", "shape": "cube",
                     "params": {"size": [40, 5, 30]}},
                    {"type": "primitive", "shape": "cube",
                     "params": {"size": [5, 30, 30]}},
                ]},
                {"type": "primitive", "shape": "cylinder",
                 "params": {"h": 10, "r": 3},
                 "translate": [20, -1, 15], "rotate": [-90, 0, 0]},
            ]},
        ])

    :param operations: List of operation dicts (primitive or boolean tree).
    :param output_path: Output STL path (defaults to temp file).
    :returns: Dict with path, triangle_count, and the generated SCAD code.
    :raises GenerationError: If OpenSCAD is not found or compilation fails.
    """
    if not operations:
        raise ValueError("Operations list cannot be empty")

    binary = _find_openscad()  # raises GenerationError if not found
    if binary is None:
        raise GenerationError(
            "OpenSCAD not found.",
            code="OPENSCAD_NOT_FOUND",
        )

    # Generate OpenSCAD code from the operation tree
    scad_lines: list[str] = ["// Generated by Kiln compose_from_primitives"]
    scad_lines.append("$fn = 50;")
    scad_lines.append("")

    for op in operations:
        scad_lines.append(_op_to_scad(op, indent=0))

    scad_code = "\n".join(scad_lines)

    # Even though we generated this code ourselves, validate it to catch
    # any injection via user-controlled fields (e.g. text content).
    _check_scad_security(scad_code)

    # Compile with OpenSCAD
    if output_path is None:
        output_path = os.path.join(tempfile.gettempdir(), f"kiln_composed_{uuid.uuid4().hex[:8]}.stl")

    # Compile directly — the generated code is validated above via _check_scad_security().

    scad_fd, scad_path = tempfile.mkstemp(suffix=".scad", prefix="kiln_composed_")
    with os.fdopen(scad_fd, "w") as fh:
        fh.write(scad_code)

    try:
        result = subprocess.run(
            [binary, "-o", output_path, scad_path],
            capture_output=True,
            text=True,
            timeout=120,
        )
        if result.returncode != 0:
            raise GenerationError(
                f"OpenSCAD compilation failed: {result.stderr[:500]}",
                code="OPENSCAD_COMPILE_ERROR",
            )
    except subprocess.TimeoutExpired as exc:
        raise GenerationError(
            "OpenSCAD compilation timed out after 120s",
            code="OPENSCAD_TIMEOUT",
        ) from exc
    finally:
        # Clean up SCAD file
        with contextlib.suppress(OSError):
            os.unlink(scad_path)

    if not os.path.isfile(output_path):
        raise GenerationError(
            "OpenSCAD produced no output file",
            code="OPENSCAD_NO_OUTPUT",
        )

    # Count triangles
    tri_count = 0
    try:
        import struct as _struct

        with open(output_path, "rb") as fh:
            fh.seek(80)
            data = fh.read(4)
            if len(data) == 4:
                tri_count = _struct.unpack("<I", data)[0]
    except Exception:
        pass

    return {
        "path": output_path,
        "scad_code": scad_code,
        "triangle_count": tri_count,
        "operation_count": len(operations),
    }


def _op_to_scad(op: dict[str, Any], *, indent: int = 0) -> str:
    """Convert a single operation dict to OpenSCAD code."""
    op_type = op.get("type", "")

    if op_type == "primitive":
        return _primitive_to_scad(op, indent=indent)
    elif op_type == "boolean":
        return _boolean_to_scad(op, indent=indent)
    else:
        raise ValueError(f"Unknown operation type: {op_type!r}. Must be 'primitive' or 'boolean'.")


def _primitive_to_scad(op: dict[str, Any], *, indent: int = 0) -> str:
    """Convert a primitive operation to OpenSCAD code."""
    pad = "    " * indent
    shape = op.get("shape", "")
    params = op.get("params", {})
    translate = op.get("translate")
    rotate = op.get("rotate")

    # Build the shape call
    if shape == "cube":
        size = params.get("size", 10)
        if isinstance(size, (list, tuple)):
            shape_code = f"cube([{size[0]}, {size[1]}, {size[2]}]);"
        else:
            shape_code = f"cube({size});"
    elif shape == "cylinder":
        h = params.get("h", 10)
        r = params.get("r")
        r1 = params.get("r1")
        r2 = params.get("r2")
        d = params.get("d")
        if r is not None:
            shape_code = f"cylinder(h={h}, r={r});"
        elif r1 is not None and r2 is not None:
            shape_code = f"cylinder(h={h}, r1={r1}, r2={r2});"
        elif d is not None:
            shape_code = f"cylinder(h={h}, d={d});"
        else:
            shape_code = f"cylinder(h={h}, r=5);"
    elif shape == "sphere":
        r = params.get("r", 5)
        shape_code = f"sphere(r={r});"
    elif shape == "cone":
        h = params.get("h", 10)
        r1 = params.get("r1", 5)
        r2 = params.get("r2", 0)
        shape_code = f"cylinder(h={h}, r1={r1}, r2={r2});"
    elif shape == "torus":
        # Torus via rotate_extrude of a circle
        major_r = params.get("major_r", 10)
        minor_r = params.get("minor_r", 3)
        shape_code = (
            f"rotate_extrude($fn=64) "
            f"translate([{major_r}, 0, 0]) "
            f"circle(r={minor_r}, $fn=32);"
        )
    elif shape == "wedge":
        # Right-triangle prism (wedge/ramp shape)
        w = params.get("width", 10)
        d = params.get("depth", 10)
        h = params.get("height", 10)
        shape_code = (
            f"linear_extrude(height={d}) "
            f"polygon(points=[[0,0],[{w},0],[0,{h}]]);"
        )
    elif shape == "hex_prism":
        # Regular hexagonal prism (for nuts, bolts, hex stock)
        r = params.get("r", 5)
        h = params.get("h", 5)
        shape_code = f"cylinder(h={h}, r={r}, $fn=6);"
    elif shape == "text":
        # Embossed text via linear_extrude + text()
        content = params.get("text", "Kiln")
        size = params.get("size", 10)
        depth = params.get("depth", 2)
        font = params.get("font", "Liberation Sans")
        # Escape quotes and backslashes to prevent OpenSCAD code injection
        safe_content = str(content).replace("\\", "\\\\").replace('"', '\\"')
        safe_font = str(font).replace("\\", "\\\\").replace('"', '\\"')
        shape_code = (
            f"linear_extrude(height={depth}) "
            f'text("{safe_content}", size={size}, font="{safe_font}", halign="center", valign="center");'
        )
    elif shape == "rounded_cube":
        # Cube with rounded edges via minkowski (cube + sphere)
        size = params.get("size", [10, 10, 10])
        r = params.get("radius", 1)
        if isinstance(size, (list, tuple)):
            inner = [max(0.1, s - 2 * r) for s in size]
            shape_code = (
                f"minkowski() {{ cube([{inner[0]}, {inner[1]}, {inner[2]}]); "
                f"sphere(r={r}, $fn=16); }}"
            )
        else:
            inner = max(0.1, size - 2 * r)
            shape_code = (
                f"minkowski() {{ cube({inner}); "
                f"sphere(r={r}, $fn=16); }}"
            )
    elif shape == "pipe":
        # Hollow cylinder (pipe/tube)
        h = params.get("h", 20)
        outer_r = params.get("outer_r", 10)
        inner_r = params.get("inner_r", 8)
        shape_code = (
            f"difference() {{ cylinder(h={h}, r={outer_r}); "
            f"translate([0, 0, -0.1]) cylinder(h={h + 0.2}, r={inner_r}); }}"
        )
    else:
        raise ValueError(
            f"Unknown primitive shape: {shape!r}. Use 'cube', 'cylinder', "
            f"'sphere', 'cone', 'torus', 'wedge', 'hex_prism', 'text', "
            f"'rounded_cube', or 'pipe'."
        )

    # Wrap with transformations
    if translate and rotate:
        return "\n".join([
            f"{pad}translate([{translate[0]}, {translate[1]}, {translate[2]}])",
            f"{pad}    rotate([{rotate[0]}, {rotate[1]}, {rotate[2]}])",
            f"{pad}        {shape_code}",
        ])
    elif translate:
        return "\n".join([
            f"{pad}translate([{translate[0]}, {translate[1]}, {translate[2]}])",
            f"{pad}    {shape_code}",
        ])
    elif rotate:
        return "\n".join([
            f"{pad}rotate([{rotate[0]}, {rotate[1]}, {rotate[2]}])",
            f"{pad}    {shape_code}",
        ])
    else:
        return f"{pad}{shape_code}"


def _boolean_to_scad(op: dict[str, Any], *, indent: int = 0) -> str:
    """Convert a boolean operation to OpenSCAD code."""
    pad = "    " * indent
    operation = op.get("operation", "union")
    children = op.get("children", [])

    if operation not in ("union", "difference", "intersection"):
        raise ValueError(f"Unknown boolean operation: {operation!r}")

    if len(children) < 2:
        raise ValueError(f"Boolean '{operation}' requires at least 2 children, got {len(children)}")

    lines = [f"{pad}{operation}() {{"]
    for child in children:
        lines.append(_op_to_scad(child, indent=indent + 1))
    lines.append(f"{pad}}}")

    return "\n".join(lines)


def _parse_openscad_output(stderr: str, return_code: int) -> dict[str, Any]:
    """Parse OpenSCAD stderr into structured error/warning lists.

    Args:
        stderr: Raw stderr output from OpenSCAD.
        return_code: Process exit code.

    Returns:
        Dict with ``valid``, ``errors``, and ``warnings``.
    """
    errors: list[dict[str, Any]] = []
    warnings: list[dict[str, Any]] = []

    for line in stderr.splitlines():
        line = line.strip()
        if not line:
            continue

        entry: dict[str, Any] = {"message": line}

        # Extract file:line info
        loc_match = re.match(r"(?:ERROR|WARNING|TRACE):\s*(.+?),\s*line\s+(\d+)", line, re.IGNORECASE)
        if loc_match:
            entry["line"] = int(loc_match.group(2))

        # Also handle "Parser error in line N:" format
        parser_match = re.search(r"in line (\d+)", line, re.IGNORECASE)
        if parser_match and "line" not in entry:
            entry["line"] = int(parser_match.group(1))

        lower = line.lower()
        if "error" in lower or "parser error" in lower:
            errors.append(entry)
        elif "warning" in lower or "deprecated" in lower:
            warnings.append(entry)
        elif return_code != 0:
            errors.append(entry)

    return {
        "valid": return_code == 0 and len(errors) == 0,
        "errors": errors,
        "warnings": warnings,
    }
