"""Slicer integration for Kiln — headless slicing via PrusaSlicer / OrcaSlicer CLI.

Wraps the command-line interface of PrusaSlicer and OrcaSlicer so that STL
(or 3MF/STEP) files can be sliced to G-code without opening the GUI.  The
slicer binary is auto-detected on PATH or can be specified explicitly.

Supported slicers:
    * PrusaSlicer (``prusa-slicer`` / ``PrusaSlicer``)
    * OrcaSlicer  (``orca-slicer`` / ``OrcaSlicer``)

Both expose the same ``--export-gcode`` flag for headless slicing.

Example::

    from kiln.slicer import slice_file, find_slicer

    slicer = find_slicer()                # auto-detect
    result = slice_file("model.stl")      # -> SliceResult
    print(result.output_path)             # <tempdir>/kiln_sliced/model.gcode
"""

from __future__ import annotations

import contextlib
import logging
import os
import re
import shutil
import subprocess
import sys
import tempfile
from dataclasses import dataclass
from pathlib import Path
from typing import Any

logger = logging.getLogger(__name__)

# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------

# Kiln attribution header for generated gcode files.
_KILN_GCODE_HEADER = (
    "; Generated by Kiln — 3D printing infrastructure for AI agents\n"
    "; https://kiln3d.com\n"
    ";\n"
)

# Names to probe on PATH, in preference order.
_SLICER_NAMES: list[str] = [
    "prusa-slicer",
    "PrusaSlicer",
    "prusaslicer",
    "orca-slicer",
    "OrcaSlicer",
    "orcaslicer",
]

# Common install locations on macOS (app bundles).
# Only populated on macOS to avoid useless stat() calls on Linux/WSL.
_MACOS_PATHS: list[str] = (
    [
        "/Applications/PrusaSlicer.app/Contents/MacOS/PrusaSlicer",
        "/Applications/Original Prusa Drivers/PrusaSlicer.app/Contents/MacOS/PrusaSlicer",
        "/Applications/OrcaSlicer.app/Contents/MacOS/OrcaSlicer",
    ]
    if sys.platform == "darwin"
    else []
)

# Extensions the slicer can accept as input.
_INPUT_EXTENSIONS = {".stl", ".3mf", ".step", ".stp", ".obj", ".amf"}

# Default output directory.
_DEFAULT_OUTPUT_DIR = os.path.join(tempfile.gettempdir(), "kiln_sliced")


# ---------------------------------------------------------------------------
# Exceptions
# ---------------------------------------------------------------------------


class SlicerError(Exception):
    """Raised when slicing fails."""


class SlicerNotFoundError(SlicerError):
    """Raised when no slicer binary is found on the system."""


# ---------------------------------------------------------------------------
# Dataclasses
# ---------------------------------------------------------------------------


@dataclass
class SlicerInfo:
    """Information about a discovered slicer binary."""

    path: str
    name: str
    version: str | None = None

    def to_dict(self) -> dict:
        return {
            "path": self.path,
            "name": self.name,
            "version": self.version,
        }


@dataclass
class SliceResult:
    """Outcome of a slicing operation."""

    success: bool
    output_path: str | None = None
    slicer: str | None = None
    message: str = ""
    stdout: str = ""
    stderr: str = ""

    def to_dict(self) -> dict:
        d = {
            "success": self.success,
            "output_path": self.output_path,
            "slicer": self.slicer,
            "message": self.message,
        }
        if self.stderr:
            d["stderr"] = self.stderr[:500]
        return d


# ---------------------------------------------------------------------------
# Slicer discovery
# ---------------------------------------------------------------------------


def find_slicer(slicer_path: str | None = None) -> SlicerInfo:
    """Locate a slicer binary on the system.

    Args:
        slicer_path: Explicit path to a slicer binary.  If provided,
            this is used directly (validated for existence).  If ``None``,
            auto-detection is performed.

    Returns:
        A :class:`SlicerInfo` with the resolved path and name.

    Raises:
        SlicerNotFoundError: If no slicer binary can be found.
    """
    if slicer_path:
        if os.path.isfile(slicer_path) and os.access(slicer_path, os.X_OK):
            name = Path(slicer_path).stem.lower()
            version = _get_version(slicer_path)
            return SlicerInfo(path=slicer_path, name=name, version=version)
        raise SlicerNotFoundError(f"Slicer binary not found or not executable: {slicer_path}")

    # Check PATH
    for name in _SLICER_NAMES:
        found = shutil.which(name)
        if found:
            version = _get_version(found)
            return SlicerInfo(path=found, name=name.lower(), version=version)

    # Check macOS app bundles
    for path in _MACOS_PATHS:
        if os.path.isfile(path) and os.access(path, os.X_OK):
            name = Path(path).stem.lower()
            version = _get_version(path)
            return SlicerInfo(path=path, name=name, version=version)

    # Check env var
    env_path = os.environ.get("KILN_SLICER_PATH")
    if env_path and os.path.isfile(env_path) and os.access(env_path, os.X_OK):
        name = Path(env_path).stem.lower()
        version = _get_version(env_path)
        return SlicerInfo(path=env_path, name=name, version=version)

    raise SlicerNotFoundError(
        "No slicer found. Install PrusaSlicer or OrcaSlicer:\n"
        "  Linux/WSL: apt install prusa-slicer  (or download from prusaslicer.org)\n"
        "  macOS: brew install --cask prusaslicer\n"
        "Or set KILN_SLICER_PATH to the binary location."
    )


def _get_version(slicer_path: str) -> str | None:
    """Try to get the slicer version string."""
    try:
        result = subprocess.run(
            [slicer_path, "--version"],
            capture_output=True,
            text=True,
            timeout=10,
        )
        output = (result.stdout or result.stderr or "").strip()
        # PrusaSlicer outputs "PrusaSlicer-2.7.1+linux-..."
        # OrcaSlicer outputs "OrcaSlicer 2.0.0"
        if output:
            return output.split("\n")[0].rstrip("\r")[:100]
    except Exception as exc:
        logger.debug("Failed to get slicer version from %s: %s", slicer_path, exc)
    return None


# ---------------------------------------------------------------------------
# Slicing
# ---------------------------------------------------------------------------


def slice_file(
    input_path: str,
    *,
    output_dir: str | None = None,
    output_name: str | None = None,
    profile: str | None = None,
    slicer_path: str | None = None,
    extra_args: list[str] | None = None,
    timeout: int = 300,
) -> SliceResult:
    """Slice a 3D model file to G-code.

    Args:
        input_path: Path to the input file (STL, 3MF, STEP, OBJ, AMF).
        output_dir: Directory for the output G-code file.  Defaults to
            the system temp directory.
        output_name: Override the output file name.  Defaults to the
            input file's stem with ``.gcode`` extension.
        profile: Path to a slicer profile/config file (.ini or .json).
        slicer_path: Explicit slicer binary path.  Auto-detected if omitted.
        extra_args: Additional CLI arguments to pass to the slicer.
        timeout: Maximum slicing time in seconds (default 300).

    Returns:
        A :class:`SliceResult` with the path to the generated G-code.

    Raises:
        SlicerError: If slicing fails.
        SlicerNotFoundError: If no slicer binary is found.
        FileNotFoundError: If the input file does not exist.
    """
    # Validate input
    input_abs = os.path.abspath(input_path)
    if not os.path.isfile(input_abs):
        raise FileNotFoundError(f"Input file not found: {os.path.basename(input_abs)}")

    ext = Path(input_abs).suffix.lower()
    if ext not in _INPUT_EXTENSIONS:
        raise SlicerError(f"Unsupported input format '{ext}'. Supported: {', '.join(sorted(_INPUT_EXTENSIONS))}")

    # Find slicer
    slicer = find_slicer(slicer_path)

    # Prepare output
    out_dir = output_dir or _DEFAULT_OUTPUT_DIR
    os.makedirs(out_dir, mode=0o700, exist_ok=True)

    if output_name:
        # Strict filename sanitisation: basename, null bytes, length, reserveds
        safe_name = output_name.replace("\x00", "")
        safe_name = os.path.basename(safe_name)
        if not safe_name or safe_name != output_name.replace("\x00", "") or safe_name in {".", ".."}:
            raise ValueError(
                f"output_name must be a plain filename without path separators "
                f"or traversal sequences, got: {output_name!r}"
            )
        if len(safe_name) > 255:
            raise ValueError(f"output_name too long ({len(safe_name)} chars, max 255)")
        out_file = os.path.join(out_dir, safe_name)
    else:
        stem = Path(input_abs).stem
        out_file = os.path.join(out_dir, f"{stem}.gcode")

    # Build command
    cmd: list[str] = [
        slicer.path,
        "--export-gcode",
        input_abs,
        "--output",
        out_file,
    ]

    if profile:
        if not os.path.isfile(profile):
            raise SlicerError(f"Profile file not found: {os.path.basename(profile)}")
        cmd.extend(["--load", profile])

    if extra_args:
        cmd.extend(extra_args)

    logger.info("Slicing: %s", " ".join(cmd))

    # Run
    try:
        result = subprocess.run(
            cmd,
            capture_output=True,
            text=True,
            timeout=timeout,
        )
    except subprocess.TimeoutExpired:
        # Clean up partial output on timeout
        with contextlib.suppress(OSError):
            os.unlink(out_file)
        raise SlicerError(
            f"Slicing timed out after {timeout}s. The model may be too complex or the slicer is hanging."
        ) from None
    except OSError as exc:
        raise SlicerError(f"Failed to run slicer: {exc}") from exc

    if result.returncode != 0:
        stderr_snippet = (result.stderr or "").strip()[:500]
        raise SlicerError(f"Slicer exited with code {result.returncode}. stderr: {stderr_snippet}")

    # Verify output exists
    if not os.path.isfile(out_file):
        raise SlicerError(
            f"Slicer completed but output file was not created. "
            f"stdout: {(result.stdout or '').strip()[:200]}"
        )

    return SliceResult(
        success=True,
        output_path=out_file,
        slicer=slicer.name,
        message=f"Sliced {Path(input_abs).name} -> {Path(out_file).name}",
        stdout=(result.stdout or "").strip(),
        stderr=(result.stderr or "").strip(),
    )


def estimate_print(
    file_path: str,
    *,
    profile: str | None = None,
    slicer_path: str | None = None,
) -> dict[str, Any]:
    """Estimate print time and filament usage by slicing to a temp file.

    Slices the model and parses the G-code comments for estimated
    print time, filament length, and filament weight.

    Args:
        file_path: Path to STL/3MF/OBJ file.
        profile: Optional slicer profile path.
        slicer_path: Optional explicit slicer binary path.

    Returns:
        Dict with ``estimated_time_seconds``, ``filament_length_mm``,
        ``filament_weight_g``, ``layer_count``, and ``gcode_path``.

    Raises:
        SlicerError: If slicing fails.
    """
    result = slice_file(file_path, profile=profile, slicer_path=slicer_path)
    if not result.success or not result.output_path:
        raise SlicerError("Slicing failed — cannot estimate print.")

    return _parse_gcode_estimates(result.output_path)


def _parse_gcode_estimates(gcode_path: str) -> dict[str, Any]:
    """Parse G-code file for slicer-generated estimates.

    Reads the first 200 and last 500 lines where PrusaSlicer/OrcaSlicer
    place their estimate comments.  PrusaSlicer puts estimates ~350 lines
    from EOF (after slicer config comments), so 200 was too small.
    """
    import re as _re

    estimates: dict[str, Any] = {"gcode_path": gcode_path}
    lines: list[str] = []

    try:
        with open(gcode_path, errors="replace") as fh:
            head = []
            for i, line in enumerate(fh):
                if i < 200:
                    head.append(line)
                lines.append(line)
            # Keep last 500 lines — PrusaSlicer puts estimates ~350 from EOF
            tail = lines[-500:] if len(lines) > 500 else []
            search_lines = head + tail
    except OSError:
        return estimates

    for line in search_lines:
        line = line.strip()

        # PrusaSlicer: ; estimated printing time (normal mode) = 1h 23m 45s
        time_match = _re.search(
            r"estimated printing time.*?=\s*(?:(\d+)d\s*)?(?:(\d+)h\s*)?(?:(\d+)m\s*)?(?:(\d+)s)?",
            line,
            _re.IGNORECASE,
        )
        if time_match:
            d = int(time_match.group(1) or 0)
            h = int(time_match.group(2) or 0)
            m = int(time_match.group(3) or 0)
            s = int(time_match.group(4) or 0)
            estimates["estimated_time_seconds"] = d * 86400 + h * 3600 + m * 60 + s
            estimates["estimated_time_human"] = line.split("=", 1)[-1].strip()

        # ; filament used [mm] = 1234.56
        fil_mm = _re.search(r"filament used \[mm\]\s*=\s*([\d.]+)", line, _re.IGNORECASE)
        if fil_mm:
            estimates["filament_length_mm"] = float(fil_mm.group(1))

        # ; filament used [g] = 12.34 or total filament used [g] = 12.34
        fil_g = _re.search(r"filament used \[g\]\s*=\s*([\d.]+)", line, _re.IGNORECASE)
        if fil_g:
            estimates["filament_weight_g"] = float(fil_g.group(1))

        # ; filament used [cm3] = 12.34
        fil_cm3 = _re.search(r"filament used \[cm3\]\s*=\s*([\d.]+)", line, _re.IGNORECASE)
        if fil_cm3:
            estimates["filament_volume_cm3"] = float(fil_cm3.group(1))

        # ; total layers count = 123
        layers = _re.search(r"total layers count\s*=\s*(\d+)", line, _re.IGNORECASE)
        if layers:
            estimates["layer_count"] = int(layers.group(1))

        # ; filament cost = 1.23
        cost = _re.search(r"filament cost\s*=\s*([\d.]+)", line, _re.IGNORECASE)
        if cost:
            estimates["filament_cost"] = float(cost.group(1))

    return estimates


# ---------------------------------------------------------------------------
# Multi-color multi-copy slicing
# ---------------------------------------------------------------------------


def slice_multicolor_copies(
    input_path: str,
    count: int,
    *,
    spacing_mm: float = 10.0,
    bed_width_mm: float = 256.0,
    bed_depth_mm: float = 256.0,
    printer_id: str | None = None,
    slicer_path: str | None = None,
    profile: str | None = None,
    extra_args: list[str] | None = None,
    output_dir: str | None = None,
    timeout: int = 300,
) -> SliceResult:
    """Slice an STL into *count* copies, each assigned a different tool (T0, T1, ...).

    The approach: position each copy on a grid, slice each individually, then
    merge the gcode bodies with ``T`` commands between copies.  This avoids
    the need for a multi-extruder PrusaSlicer printer profile.

    The merged gcode will contain ``T0`` before copy 1's layers, ``T1`` before
    copy 2's layers, etc.  Downstream Bambu 3MF wrapping will convert these
    to M620/M621 AMS load sequences.

    :param input_path: Path to the source STL file.
    :param count: Number of copies (each gets a different tool).
    :param spacing_mm: Gap between copies in mm.
    :param bed_width_mm: Build plate width (X) in mm.
    :param bed_depth_mm: Build plate depth (Y) in mm.
    :param printer_id: Optional supported printer model id.  When provided,
        the bundled printer-intelligence build volume overrides the generic
        bed dimensions.
    :param slicer_path: Explicit slicer binary path.
    :param profile: Path to a slicer profile/config file.
    :param extra_args: Additional CLI arguments for the slicer.
    :param output_dir: Directory for output files.
    :param timeout: Slicing timeout per copy in seconds.
    :returns: A :class:`SliceResult` with the merged gcode path.
    :raises SlicerError: If slicing any copy fails.
    :raises ValueError: If copies don't fit on the bed.
    """
    import math
    import tempfile as _tempfile

    from kiln.auto_orient import _parse_mesh, _translate_to_bed, _write_binary_stl

    if count < 2:
        raise ValueError(f"count must be >= 2, got {count}")
    if printer_id:
        from kiln.printers.bed_fit import resolve_build_volume

        resolved = resolve_build_volume(printer_id)
        if resolved is None:
            raise ValueError(
                f"Unknown printer_id {printer_id!r}; omit printer_id and "
                "pass bed_width_mm/bed_depth_mm explicitly, or use a "
                "supported printer model id."
            )
        _model_id, build_volume = resolved
        bed_width_mm, bed_depth_mm = build_volume[0], build_volume[1]

    # Parse mesh and compute bounding box
    triangles, _vertices = _parse_mesh(input_path)
    triangles = _translate_to_bed(triangles)

    all_verts = [v for tri in triangles for v in tri]
    xs = [v[0] for v in all_verts]
    ys = [v[1] for v in all_verts]
    x_min, x_max = min(xs), max(xs)
    y_min, y_max = min(ys), max(ys)
    model_width = x_max - x_min
    model_depth = y_max - y_min

    if model_width <= 0 or model_depth <= 0:
        raise ValueError("Model has zero width or depth.")

    # Center mesh at origin
    center_x = (x_min + x_max) / 2.0
    center_y = (y_min + y_max) / 2.0
    centered = []
    for tri in triangles:
        tv = tuple((v[0] - center_x, v[1] - center_y, v[2]) for v in tri)
        centered.append(tv)

    # Grid layout
    cell_w = model_width + spacing_mm
    cell_d = model_depth + spacing_mm
    cols = int(bed_width_mm // cell_w)
    rows = int(bed_depth_mm // cell_d)

    if cols < 1 or rows < 1:
        raise ValueError(
            f"Model ({model_width:.1f} x {model_depth:.1f} mm) is too large "
            f"for the build plate ({bed_width_mm} x {bed_depth_mm} mm)."
        )
    if count > cols * rows:
        raise ValueError(
            f"Cannot fit {count} copies on {bed_width_mm}x{bed_depth_mm} mm bed."
        )

    actual_cols = min(count, cols)
    actual_rows = math.ceil(count / actual_cols)
    grid_width = actual_cols * cell_w - spacing_mm
    grid_depth = actual_rows * cell_d - spacing_mm
    start_x = (bed_width_mm - grid_width) / 2.0 + model_width / 2.0
    start_y = (bed_depth_mm - grid_depth) / 2.0 + model_depth / 2.0

    # Create positioned STL copies and slice each
    work_dir = _tempfile.mkdtemp(prefix="kiln_multicolor_")
    gcode_bodies: list[str] = []
    slicer_name: str | None = None

    placed = 0
    for row in range(actual_rows):
        for col in range(actual_cols):
            if placed >= count:
                break
            offset_x = start_x + col * cell_w
            offset_y = start_y + row * cell_d

            # Translate centered mesh to this grid position
            positioned = []
            for tri in centered:
                tv = tuple((v[0] + offset_x, v[1] + offset_y, v[2]) for v in tri)
                positioned.append(tv)

            # Write positioned copy as STL
            copy_stl = os.path.join(work_dir, f"copy_{placed}.stl")
            _write_binary_stl(positioned, copy_stl)

            # Slice it
            copy_out_dir = os.path.join(work_dir, f"slice_{placed}")
            os.makedirs(copy_out_dir, exist_ok=True)

            result = slice_file(
                copy_stl,
                output_dir=copy_out_dir,
                profile=profile,
                slicer_path=slicer_path,
                extra_args=list(extra_args) if extra_args else None,
                timeout=timeout,
            )
            if not result.success or not result.output_path:
                raise SlicerError(f"Slicing copy {placed} failed: {result.message}")

            slicer_name = result.slicer

            # Read the gcode body
            with open(result.output_path, errors="replace") as fh:
                gcode_bodies.append(fh.read())

            placed += 1

    # Merge gcode bodies with T commands between copies
    merged = _merge_multicolor_gcode(gcode_bodies)

    # Write merged gcode
    out_dir = output_dir or _DEFAULT_OUTPUT_DIR
    os.makedirs(out_dir, mode=0o700, exist_ok=True)
    stem = Path(input_path).stem
    merged_path = os.path.join(out_dir, f"{stem}_multicolor.gcode")
    with open(merged_path, "w") as fh:
        fh.write(_KILN_GCODE_HEADER + merged)

    return SliceResult(
        success=True,
        output_path=merged_path,
        slicer=slicer_name,
        message=f"Sliced {count} multi-color copies of {Path(input_path).name}",
    )


def _merge_multicolor_gcode(gcode_bodies: list[str]) -> str:
    """Merge multiple single-filament gcode bodies into one with T commands.

    Takes the first copy's gcode as the base (with headers/comments intact),
    then for subsequent copies, extracts only the layer gcode (stripping
    slicer headers and init commands) and inserts T commands before each.

    The merged gcode uses the first copy's header, all layer bodies merged
    sequentially, with tool changes at the transition between copies.
    """
    if not gcode_bodies:
        raise ValueError("No gcode bodies to merge")
    if len(gcode_bodies) == 1:
        return gcode_bodies[0]

    def _extract_body(gcode: str) -> str:
        """Extract gcode body starting from the first layer change marker."""
        lines = gcode.split("\n")
        body_start = 0
        for i, line in enumerate(lines):
            stripped = line.strip()
            if stripped.startswith((";BEFORE_LAYER_CHANGE", ";LAYER_CHANGE")):
                body_start = i
                break
        return "\n".join(lines[body_start:])

    def _extract_header(gcode: str) -> str:
        """Extract gcode header before the first layer change."""
        lines = gcode.split("\n")
        header_lines = []
        for line in lines:
            stripped = line.strip()
            if stripped.startswith((";BEFORE_LAYER_CHANGE", ";LAYER_CHANGE")):
                break
            header_lines.append(line)
        return "\n".join(header_lines)

    # Build merged gcode:
    # 1. Header from copy 0
    # 2. T0 + body from copy 0
    # 3. T1 + body from copy 1
    # ...
    parts: list[str] = []

    # Header from first copy
    parts.append(_extract_header(gcode_bodies[0]))
    parts.append("")

    for i, gcode in enumerate(gcode_bodies):
        body = _extract_body(gcode)
        # Insert tool change command before each copy's layers
        parts.append(f"; --- Copy {i + 1} (Tool {i}) ---")
        parts.append(f"T{i}")
        parts.append(body)
        parts.append("")

    return "\n".join(parts)


def merge_multipart_gcode(
    parts: list[dict[str, Any]],
    *,
    output_path: str | None = None,
) -> dict[str, Any]:
    """Merge separately-sliced gcode files into one multi-tool gcode.

    Uses a **batched** strategy that minimises tool changes: parts are
    grouped by Z-height overlap so the printer completes all layers of
    one tool in the overlap zone before switching, then continues with
    whichever tool has layers remaining.

    .. note::

        Parts **must be XY-disjoint** — their printed footprints must not
        overlap on the build plate.  The batched merge prints each tool's
        layers independently in the overlap zone; if parts share XY space,
        the later tool will collide with the earlier tool's deposited
        material.

    Each entry in *parts* must have:

    - ``gcode_path``: Path to the ``.gcode`` file.
    - ``tool_index``: Tool number (T0, T1, ...) for AMS mapping.
    - ``name``: Human-readable name (e.g. ``"body_grey"``).

    :param parts: List of part dicts.
    :param output_path: Output file path.  Defaults to a temp file.
    :returns: Dict with ``output_path``, ``total_layers``, ``phases``,
        and ``estimated_time_sec``.
    :raises ValueError: If parts list is empty or files not found.
    """
    if not parts:
        raise ValueError("No parts to merge.")

    # Parse each gcode into header + [(z, block), ...]
    parsed: list[tuple[str, list[tuple[float, str]], dict[str, Any]]] = []
    for p in parts:
        gcode_path = p["gcode_path"]
        if not os.path.isfile(gcode_path):
            raise ValueError(f"Gcode file not found: {gcode_path}")

        with open(gcode_path) as f:
            lines = f.readlines()

        header_lines: list[str] = []
        layers: list[tuple[float, str]] = []
        current_block: list[str] = []
        current_z: float | None = None
        found_first = False

        for line in lines:
            if ";LAYER_CHANGE" in line and not found_first:
                found_first = True
            if not found_first:
                header_lines.append(line)
                continue
            if ";LAYER_CHANGE" in line and current_block and current_z is not None:
                layers.append((current_z, "".join(current_block)))
                current_block = []
                current_z = None
            current_block.append(line)
            m = re.match(r"^;Z:([\d.]+)", line)
            if m:
                current_z = float(m.group(1))

        if current_block and current_z is not None:
            layers.append((current_z, "".join(current_block)))

        if not layers:
            raise ValueError(
                f"Part {p['name']!r} ({gcode_path}) has zero parsed layers. "
                "Ensure the gcode contains ;LAYER_CHANGE markers."
            )

        parsed.append(("".join(header_lines), layers, p))

    # Find the Z overlap range — the max Z of the shortest part
    # layers is guaranteed non-empty per the zero-layer check above.
    z_maxes = [max(z for z, _ in layers) for _, layers, _ in parsed]
    overlap_ceiling = min(z_maxes)

    # Use the first part's header as the merged header
    merged_header = parsed[0][0]

    # Build tool names for the merge comment
    tool_names = ", ".join(
        f"T{p['tool_index']}={p['name']}" for p in parts
    )
    total_layers = max(len(layers) for _, layers, _ in parsed)

    # Build merged output using batched strategy:
    # Phase 1: For each part (in tool_index order), emit its overlap layers
    # Phase 2: For each part with layers above the overlap, emit those
    sorted_parts = sorted(enumerate(parsed), key=lambda x: x[1][2]["tool_index"])
    phases: list[dict[str, Any]] = []
    merged_blocks: list[str] = []

    merged_blocks.append(merged_header)
    merged_blocks.append(
        f"; Kiln multi-color merge: {len(parts)} parts, "
        f"{total_layers} layers, tools: {tool_names}\n"
    )

    for _idx, (_header, layers, part_info) in sorted_parts:
        tool = part_info["tool_index"]
        name = part_info["name"]
        overlap = [(z, blk) for z, blk in layers if z <= overlap_ceiling]
        above = [(z, blk) for z, blk in layers if z > overlap_ceiling]

        if overlap:
            merged_blocks.append(f"; --- Tool change: T{tool} ({name}) ---\n")
            merged_blocks.append(f"T{tool}\n")
            merged_blocks.append("G92 E0\n")
            for _z, blk in overlap:
                merged_blocks.append(blk)
            phases.append({
                "tool": tool,
                "name": name,
                "zone": "overlap",
                "layers": len(overlap),
                "z_range": f"{overlap[0][0]}-{overlap[-1][0]}mm",
            })

    for _idx, (_header, layers, part_info) in sorted_parts:
        tool = part_info["tool_index"]
        name = part_info["name"]
        above = [(z, blk) for z, blk in layers if z > overlap_ceiling]

        if above:
            merged_blocks.append(f"; --- Tool change: T{tool} ({name}) ---\n")
            merged_blocks.append(f"T{tool}\n")
            merged_blocks.append("G92 E0\n")
            for _z, blk in above:
                merged_blocks.append(blk)
            phases.append({
                "tool": tool,
                "name": name,
                "zone": "upper",
                "layers": len(above),
                "z_range": f"{above[0][0]}-{above[-1][0]}mm",
            })

    merged_body = "".join(merged_blocks)

    # Sum slicer time estimates from each part's original gcode file.
    # PrusaSlicer puts estimates at the END of the file (after all layers),
    # so we scan the full file, not just the pre-layer header.
    _TIME_RE = re.compile(
        r"estimated printing time \(normal mode\).*?=\s*"
        r"(?:(\d+)d\s*)?(?:(\d+)h\s*)?(?:(\d+)m\s*)?(?:(\d+)s)?",
        re.IGNORECASE,
    )
    total_time = 0
    for _header, _layers, part_info in parsed:
        gcode_path = part_info.get("gcode_path", "")
        if gcode_path and os.path.isfile(gcode_path):
            with open(gcode_path, errors="replace") as _f:
                for _line in _f:
                    m = _TIME_RE.search(_line)
                    if m:
                        total_time += (
                            int(m.group(1) or 0) * 86400
                            + int(m.group(2) or 0) * 3600
                            + int(m.group(3) or 0) * 60
                            + int(m.group(4) or 0)
                        )
                        break  # one estimate per file

    if not output_path:
        output_path = os.path.join(
            tempfile.mkdtemp(prefix="kiln_merge_"),
            "merged_multicolor.gcode",
        )

    with open(output_path, "w") as f:
        f.write(_KILN_GCODE_HEADER + merged_body)

    return {
        "output_path": output_path,
        "total_layers": total_layers,
        "phases": phases,
        "estimated_time_sec": total_time,
        "parts_merged": len(parts),
    }
