"""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 shutil
import subprocess
import sys
import tempfile
from dataclasses import dataclass
from pathlib import Path
from typing import Any

logger = logging.getLogger(__name__)

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

# 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 and last 200 lines (where PrusaSlicer/OrcaSlicer
    place their estimate comments) to avoid reading the entire file.
    """
    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 200 lines
            tail = lines[-200:] if len(lines) > 200 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,
    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 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}")

    # 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(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)
