"""G-code metadata extraction for the Kiln project.

Parses G-code file headers to extract print metadata (material, time,
temperatures, slicer info) so the agent can reason about files even when
filenames are meaningless like ``test5112.gcode``.

Supports comment formats from:
    - PrusaSlicer / OrcaSlicer / BambuStudio
    - Cura / CuraEngine
    - Simplify3D

Usage::

    from kiln.gcode_metadata import extract_metadata, enrich_printer_file

    meta = extract_metadata("/path/to/file.gcode")
    print(meta.material, meta.estimated_time_seconds)

    enrich_printer_file(printer_file, file_content=gcode_string)
"""

from __future__ import annotations

import contextlib
import logging
import re
from dataclasses import asdict, dataclass
from typing import Any

logger = logging.getLogger(__name__)

_MAX_HEADER_LINES: int = 200  # Scan more than gcode.py's 100 to catch Cura footers


# ---------------------------------------------------------------------------
# Result dataclass
# ---------------------------------------------------------------------------


@dataclass
class GCodeMetadata:
    """Structured metadata extracted from a G-code file header."""

    material: str | None = None
    estimated_time_seconds: int | None = None
    tool_temp: float | None = None
    bed_temp: float | None = None
    slicer: str | None = None
    layer_height: float | None = None
    filament_used_mm: float | None = None
    printer_model: str | None = None

    def to_dict(self) -> dict[str, Any]:
        """Return a JSON-serialisable dict, omitting ``None`` values."""
        return {k: v for k, v in asdict(self).items() if v is not None}


# ---------------------------------------------------------------------------
# Comment-based metadata patterns
# ---------------------------------------------------------------------------

# PrusaSlicer / OrcaSlicer / BambuStudio patterns
_RE_PRUSA_MATERIAL = re.compile(
    r";\s*filament_type\s*=\s*(.+)",
    re.IGNORECASE,
)
_RE_PRUSA_TIME = re.compile(
    r";\s*estimated printing time.*?=\s*(.+)",
    re.IGNORECASE,
)
_RE_PRUSA_TOOL_TEMP = re.compile(
    r";\s*(?:temperature|nozzle_temperature)\s*=\s*(\d+\.?\d*)",
    re.IGNORECASE,
)
_RE_PRUSA_BED_TEMP = re.compile(
    r";\s*bed_temperature\s*=\s*(\d+\.?\d*)",
    re.IGNORECASE,
)
_RE_PRUSA_LAYER_HEIGHT = re.compile(
    r";\s*layer_height\s*=\s*(\d+\.?\d*)",
    re.IGNORECASE,
)
_RE_PRUSA_FILAMENT_USED = re.compile(
    r";\s*filament used \[mm\]\s*=\s*(\d+\.?\d*)",
    re.IGNORECASE,
)
_RE_PRUSA_SLICER = re.compile(
    r";\s*generated by\s+(.+)",
    re.IGNORECASE,
)
_RE_PRUSA_PRINTER_MODEL = re.compile(
    r";\s*printer_model\s*=\s*(.+)",
    re.IGNORECASE,
)

# Cura patterns
_RE_CURA_MATERIAL = re.compile(
    r";\s*MATERIAL\s*[:=]\s*(.+)",
    re.IGNORECASE,
)
_RE_CURA_TIME = re.compile(
    r";\s*TIME\s*[:=]\s*(\d+)",
    re.IGNORECASE,
)
_RE_CURA_FILAMENT_USED = re.compile(
    r";\s*Filament used\s*[:=]\s*(\d+\.?\d*)\s*(mm|m)?",
    re.IGNORECASE,
)
_RE_CURA_LAYER_HEIGHT = re.compile(
    r";\s*Layer height\s*[:=]\s*(\d+\.?\d*)",
    re.IGNORECASE,
)
_RE_CURA_SLICER = re.compile(
    r";\s*Generated with\s+(.+)",
    re.IGNORECASE,
)
_RE_CURA_MACHINE = re.compile(
    r";\s*MACHINE_TYPE\s*[:=]\s*(.+)",
    re.IGNORECASE,
)

# Simplify3D patterns
_RE_S3D_TOOL_TEMP = re.compile(
    r";\s*extruder\d*Temp\s*,\s*(\d+\.?\d*)",
    re.IGNORECASE,
)
_RE_S3D_BED_TEMP = re.compile(
    r";\s*platformTemp\s*,\s*(\d+\.?\d*)",
    re.IGNORECASE,
)
_RE_S3D_TIME = re.compile(
    r";\s*Build time\s*[:=]?\s*(.+)",
    re.IGNORECASE,
)
_RE_S3D_FILAMENT_USED = re.compile(
    r";\s*Filament length\s*[:=]?\s*(\d+\.?\d*)\s*(mm|m)?",
    re.IGNORECASE,
)
_RE_S3D_LAYER_HEIGHT = re.compile(
    r";\s*layerHeight\s*,\s*(\d+\.?\d*)",
    re.IGNORECASE,
)
_RE_S3D_SLICER = re.compile(
    r";\s*Simplify3D\(R\)\s*Version\s+(.+)",
    re.IGNORECASE,
)

# M-command temperature patterns (fallback)
_RE_M_COMMAND = re.compile(r"^[Mm](104|109|140|190)\s")
_RE_S_PARAM = re.compile(r"[Ss]\s*(\d+\.?\d*)")


# ---------------------------------------------------------------------------
# Time string parsing
# ---------------------------------------------------------------------------


def _parse_time_string(raw: str) -> int | None:
    """Parse various time string formats into total seconds.

    Supported formats:
        - ``1h 42m 30s`` (PrusaSlicer)
        - ``6150`` (Cura, raw seconds)
        - ``1 hours 42 minutes`` (Simplify3D)
        - ``1d 2h 30m 15s`` (extended PrusaSlicer)
        - ``42m 30s`` (no hours)
        - ``30s`` (seconds only)

    Returns ``None`` if the string cannot be parsed.
    """
    raw = raw.strip()
    if not raw:
        return None

    # Pure integer seconds (Cura style)
    if re.fullmatch(r"\d+", raw):
        try:
            return int(raw)
        except ValueError:
            return None

    total = 0
    found = False

    # Days
    m = re.search(r"(\d+)\s*d(?:ays?)?", raw, re.IGNORECASE)
    if m:
        total += int(m.group(1)) * 86400
        found = True

    # Hours
    m = re.search(r"(\d+)\s*h(?:ours?)?", raw, re.IGNORECASE)
    if m:
        total += int(m.group(1)) * 3600
        found = True

    # Minutes
    m = re.search(r"(\d+)\s*m(?:inutes?|in)?(?:\b|$)", raw, re.IGNORECASE)
    if m:
        total += int(m.group(1)) * 60
        found = True

    # Seconds
    m = re.search(r"(\d+)\s*s(?:econds?|ec)?(?:\b|$)", raw, re.IGNORECASE)
    if m:
        total += int(m.group(1))
        found = True

    return total if found else None


# ---------------------------------------------------------------------------
# Material normalisation
# ---------------------------------------------------------------------------


def _normalize_material(raw: str) -> str:
    """Normalise a material name: uppercase, stripped, common aliases unified."""
    normalised = raw.strip().upper()
    # Unify common aliases
    _ALIASES: dict[str, str] = {
        "POLYLACTIC ACID": "PLA",
        "POLYETHYLENE TEREPHTHALATE": "PETG",
        "ACRYLONITRILE BUTADIENE STYRENE": "ABS",
        "THERMOPLASTIC POLYURETHANE": "TPU",
    }
    return _ALIASES.get(normalised, normalised)


# ---------------------------------------------------------------------------
# Core extraction from lines
# ---------------------------------------------------------------------------


def _extract_from_lines(lines: list[str]) -> GCodeMetadata:
    """Parse metadata from a list of G-code lines (first N header lines)."""
    meta = GCodeMetadata()

    # Track whether temps came from comments (preferred) vs M-commands (fallback)
    _tool_temp_from_comment = False
    _bed_temp_from_comment = False

    for line in lines:
        stripped = line.strip()
        if not stripped:
            continue

        # --- Comment-based patterns (highest priority) ---
        if stripped.startswith(";"):
            # Material
            if meta.material is None:
                for pat in (_RE_PRUSA_MATERIAL, _RE_CURA_MATERIAL):
                    m = pat.match(stripped)
                    if m:
                        meta.material = _normalize_material(m.group(1))
                        break

            # Estimated time
            if meta.estimated_time_seconds is None:
                for pat in (_RE_PRUSA_TIME, _RE_CURA_TIME, _RE_S3D_TIME):
                    m = pat.match(stripped)
                    if m:
                        parsed = _parse_time_string(m.group(1))
                        if parsed is not None:
                            meta.estimated_time_seconds = parsed
                            break

            # Tool temperature (from comment)
            if meta.tool_temp is None or not _tool_temp_from_comment:
                for pat in (_RE_PRUSA_TOOL_TEMP, _RE_S3D_TOOL_TEMP):
                    m = pat.match(stripped)
                    if m:
                        try:
                            meta.tool_temp = float(m.group(1))
                            _tool_temp_from_comment = True
                        except ValueError:
                            pass
                        break

            # Bed temperature (from comment)
            if meta.bed_temp is None or not _bed_temp_from_comment:
                for pat in (_RE_PRUSA_BED_TEMP, _RE_S3D_BED_TEMP):
                    m = pat.match(stripped)
                    if m:
                        try:
                            meta.bed_temp = float(m.group(1))
                            _bed_temp_from_comment = True
                        except ValueError:
                            pass
                        break

            # Layer height
            if meta.layer_height is None:
                for pat in (_RE_PRUSA_LAYER_HEIGHT, _RE_CURA_LAYER_HEIGHT, _RE_S3D_LAYER_HEIGHT):
                    m = pat.match(stripped)
                    if m:
                        with contextlib.suppress(ValueError):
                            meta.layer_height = float(m.group(1))
                        break

            # Filament used
            if meta.filament_used_mm is None:
                for pat in (_RE_PRUSA_FILAMENT_USED, _RE_CURA_FILAMENT_USED, _RE_S3D_FILAMENT_USED):
                    m = pat.match(stripped)
                    if m:
                        try:
                            val = float(m.group(1))
                            unit = m.group(2) if m.lastindex and m.lastindex >= 2 else None
                            # Convert metres to mm if needed
                            if unit and unit.lower() == "m":
                                val *= 1000.0
                            meta.filament_used_mm = val
                        except (ValueError, IndexError):
                            pass
                        break

            # Slicer identification
            if meta.slicer is None:
                for pat in (_RE_PRUSA_SLICER, _RE_CURA_SLICER, _RE_S3D_SLICER):
                    m = pat.match(stripped)
                    if m:
                        meta.slicer = m.group(1).strip()
                        break

            # Printer model
            if meta.printer_model is None:
                for pat in (_RE_PRUSA_PRINTER_MODEL, _RE_CURA_MACHINE):
                    m = pat.match(stripped)
                    if m:
                        meta.printer_model = m.group(1).strip()
                        break

            continue

        # --- M-command temperature fallback ---
        # Only use if no comment-based temp was found
        if not _tool_temp_from_comment and meta.tool_temp is None:
            cmd_match = re.match(r"^[Mm](104|109)\b", stripped)
            if cmd_match:
                s_match = _RE_S_PARAM.search(stripped)
                if s_match:
                    try:
                        temp = float(s_match.group(1))
                        if temp > 0:  # Ignore M104 S0 (heater off)
                            meta.tool_temp = temp
                    except ValueError:
                        pass

        if not _bed_temp_from_comment and meta.bed_temp is None:
            cmd_match = re.match(r"^[Mm](140|190)\b", stripped)
            if cmd_match:
                s_match = _RE_S_PARAM.search(stripped)
                if s_match:
                    try:
                        temp = float(s_match.group(1))
                        if temp > 0:  # Ignore M140 S0 (heater off)
                            meta.bed_temp = temp
                    except ValueError:
                        pass

    return meta


# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------


def extract_metadata(file_path: str) -> GCodeMetadata:
    """Extract metadata from a G-code file's header comments.

    Scans the first :data:`_MAX_HEADER_LINES` lines for slicer-embedded
    metadata.  Also scans for the first temperature commands
    (M104/M109/M140/M190) as fallback for tool/bed temps.

    :param file_path: Path to a G-code file.
    :returns: A :class:`GCodeMetadata` with whatever fields could be parsed.
        Never raises on parse errors -- returns empty metadata on failure.
    """
    try:
        lines: list[str] = []
        with open(file_path, errors="replace") as fh:
            for i, line in enumerate(fh):
                if i >= _MAX_HEADER_LINES:
                    break
                lines.append(line)
        return _extract_from_lines(lines)
    except (FileNotFoundError, PermissionError, OSError) as exc:
        logger.warning("Could not read G-code file for metadata: %s", exc)
        return GCodeMetadata()
    except Exception as exc:
        logger.warning("Unexpected error extracting G-code metadata: %s", exc)
        return GCodeMetadata()


def extract_metadata_from_content(content: str) -> GCodeMetadata:
    """Extract metadata from G-code content string (for in-memory use).

    :param content: G-code file content as a string.
    :returns: A :class:`GCodeMetadata` with whatever fields could be parsed.
    """
    try:
        lines = content.splitlines()[:_MAX_HEADER_LINES]
        return _extract_from_lines(lines)
    except Exception as exc:
        logger.warning("Unexpected error extracting metadata from content: %s", exc)
        return GCodeMetadata()


def enrich_printer_file(
    printer_file: Any,
    file_content: str | None = None,
) -> None:
    """Enrich a PrinterFile with metadata from G-code content.

    Mutates *printer_file* in-place, filling in metadata fields.
    If *file_content* is ``None``, this is a no-op.

    :param printer_file: A :class:`~kiln.printers.base.PrinterFile` instance.
    :param file_content: Optional G-code file content string.
    """
    if file_content is None:
        return

    try:
        meta = extract_metadata_from_content(file_content)
        if meta.material is not None and printer_file.material is None:
            printer_file.material = meta.material
        if meta.estimated_time_seconds is not None and printer_file.estimated_time_seconds is None:
            printer_file.estimated_time_seconds = meta.estimated_time_seconds
        if meta.tool_temp is not None and printer_file.tool_temp is None:
            printer_file.tool_temp = meta.tool_temp
        if meta.bed_temp is not None and printer_file.bed_temp is None:
            printer_file.bed_temp = meta.bed_temp
        if meta.slicer is not None and printer_file.slicer is None:
            printer_file.slicer = meta.slicer
        if meta.layer_height is not None and printer_file.layer_height is None:
            printer_file.layer_height = meta.layer_height
        if meta.filament_used_mm is not None and printer_file.filament_used_mm is None:
            printer_file.filament_used_mm = meta.filament_used_mm
    except Exception as exc:
        logger.warning("Failed to enrich PrinterFile with metadata: %s", exc)
