"""CuraEngine integration: BAMBOX header parsing and slice statistics.

Parses BAMBOX header comments from G-code to auto-configure packaging.
The header contract lets CuraEngine output carry machine-readable metadata
without coupling the slicer to Bambu Lab specifics.

Printer definitions live in their own repos (e.g. estampo/cura-p1s),
not in bambox.

Header format (emitted as G-code comments by the printer definition)::

    ; BAMBOX_PRINTER=p1s
    ; BAMBOX_EXTRUDERS=4
    ; BAMBOX_BED_TEMP=60
    ; BAMBOX_NOZZLE_TEMP=220
    ; BAMBOX_FILAMENT_SLOT=0
    ; BAMBOX_FILAMENT_TYPE=PLA
    ; BAMBOX_ASSEMBLE=true
    ; BAMBOX_END
"""

from __future__ import annotations

import logging
import re
from dataclasses import dataclass

from bambox.gcode_compat import _FILAMENT_AREA

log = logging.getLogger(__name__)

# ---------------------------------------------------------------------------
# Printer definitions
# ---------------------------------------------------------------------------

# Mapping from BAMBOX_PRINTER machine name to Bambu firmware printer_model_id.
# These IDs appear in the .gcode.3mf metadata and must match what the firmware
# expects — incorrect values may cause the printer to reject the file.
PRINTER_MODEL_IDS: dict[str, str] = {
    "p1s": "C12",
    "p1p": "C11",
    "x1c": "BL-P001",
    "x1": "BL-P002",
    "x1e": "BL-P003",
    "a1": "N2S",
    "a1_mini": "N1",
}


# ---------------------------------------------------------------------------
# BAMBOX header parsing
# ---------------------------------------------------------------------------


def parse_bambox_headers(gcode: str) -> dict[str, str]:
    """Extract ``; BAMBOX_*`` headers from G-code.

    Returns a dict of key→value pairs.  Machine-level headers (PRINTER,
    EXTRUDERS, BED_TEMP, etc.) are emitted at the top of the file by
    ``machine_start_gcode``.  Per-extruder headers (FILAMENT_SLOT,
    FILAMENT_TYPE) are emitted by ``machine_extruder_start_code`` at tool-
    change points throughout the file — so the full file must be scanned.

    Multi-value keys like ``BAMBOX_FILAMENT_TYPE`` appearing multiple times
    are collected into comma-separated values.

    CuraEngine CLI does not substitute ``{variable}`` placeholders in
    machine_start_gcode.  Values containing ``{`` are treated as
    unresolved and discarded.  For machine-level headers (BED_TEMP,
    NOZZLE_TEMP, etc.) the values are inferred from actual G-code
    commands (M140/M104) via :func:`_resolve_unsubstituted_headers`.
    """
    # Per-extruder keys that may appear anywhere in the file
    _FULL_SCAN_KEYS = {"FILAMENT_SLOT", "FILAMENT_TYPE"}

    result: dict[str, str] = {}
    # Collect per-extruder slot/type entries for deduplication.
    # CuraEngine emits paired SLOT+TYPE at each tool change throughout the file.
    # The older header format uses comma-separated values on single lines.
    raw_slots: list[str] = []
    raw_types: list[str] = []

    header_done = False
    for line in gcode.splitlines():
        stripped = line.strip()
        if stripped == "; BAMBOX_END":
            header_done = True
            continue
        if not stripped.startswith("; BAMBOX_"):
            continue
        payload = stripped[9:]  # after "; BAMBOX_"
        if "=" not in payload:
            continue
        key, _, val = payload.partition("=")
        key = key.strip()
        val = val.strip()
        is_template = "{" in val
        # After the header block, only collect per-extruder keys
        if header_done and key not in _FULL_SCAN_KEYS:
            continue
        # Accumulate slot and type entries separately
        if key == "FILAMENT_SLOT" and not is_template:
            raw_slots.append(val)
            continue
        if key == "FILAMENT_TYPE":
            if not is_template:
                raw_types.append(val)
            else:
                raw_types.append("")  # placeholder for unresolved template
            continue
        # Skip unsubstituted CuraEngine templates for regular keys
        if is_template:
            continue
        if key in result:
            result[key] = result[key] + "," + val
        else:
            result[key] = val

    # Flatten comma-separated values (old format: "FILAMENT_SLOT=0,2")
    all_slots = [s for raw in raw_slots for s in raw.split(",") if s]
    all_types = [t for raw in raw_types for t in raw.split(",")]

    # Deduplicate by slot number — keep first occurrence only
    seen: set[str] = set()
    dedup_slots: list[str] = []
    dedup_types: list[str] = []
    for i, slot in enumerate(all_slots):
        if slot not in seen:
            seen.add(slot)
            dedup_slots.append(slot)
            dedup_types.append(all_types[i] if i < len(all_types) else "")
    # If no slots were found but types were, keep types (legacy format)
    if not dedup_slots and all_types:
        result["FILAMENT_TYPE"] = ",".join(all_types)
    elif dedup_slots:
        result["FILAMENT_SLOT"] = ",".join(dedup_slots)
        if dedup_types:
            result["FILAMENT_TYPE"] = ",".join(dedup_types)

    # Only resolve unsubstituted headers if we found BAMBOX markers
    if result:
        _resolve_unsubstituted_headers(gcode, result)

    return result


def _resolve_unsubstituted_headers(gcode: str, headers: dict[str, str]) -> None:
    """Infer missing header values from actual G-code commands.

    CuraEngine CLI does not substitute ``{variable}`` in start gcode,
    so BED_TEMP, NOZZLE_TEMP, and NOZZLE_DIAMETER may be missing.
    This scans the first 50 lines for M140/M104 to fill them in.
    """
    if "BED_TEMP" not in headers:
        m = re.search(r"M140 S(\d+)", gcode[:3000])
        if m:
            headers["BED_TEMP"] = m.group(1)

    if "NOZZLE_TEMP" not in headers:
        m = re.search(r"M104 S(\d+)", gcode[:3000])
        if m:
            headers["NOZZLE_TEMP"] = m.group(1)

    if "NOZZLE_DIAMETER" not in headers:
        headers["NOZZLE_DIAMETER"] = "0.4"

    if "BED_TYPE" not in headers:
        headers["BED_TYPE"] = "Textured PEI Plate"


# ---------------------------------------------------------------------------
# Slice statistics extraction
# ---------------------------------------------------------------------------

_PLA_DENSITY_G_PER_MM3 = 0.00124  # g/mm³  (1.24 g/cm³)


@dataclass
class SliceStats:
    """Statistics extracted from slicer G-code."""

    prediction: int = 0  # estimated print time in seconds
    weight: float = 0.0  # total filament weight in grams
    filament_used_m: list[float] | None = None  # per-extruder metres


def extract_slice_stats(gcode: str, flush_volume_mm3: float = 280.0) -> SliceStats:
    """Extract print time and filament usage from CuraEngine G-code.

    CuraEngine emits:
    * ``;TIME:N`` — total print time in seconds (often default 6666)
    * ``;TIME_ELAPSED:N`` — cumulative elapsed time (last value = total)
    * ``;Filament used: X.XXm, Y.YYm`` — per-extruder filament usage

    We prefer the last ``TIME_ELAPSED`` value (accurate per-layer
    cumulative time) over ``;TIME:`` which CuraEngine often sets to a
    default placeholder (6666).

    For multi-filament prints, CuraEngine does not account for firmware-level
    purge cycles (the M620/M621 AMS flush). This function adds estimated
    purge time and filament waste based on the number of tool changes.

    Args:
        gcode: Raw G-code string.
        flush_volume_mm3: Purge volume per tool change in mm³ (default 280,
            matching BambuStudio's ``flush_volumes_matrix`` default).
    """
    stats = SliceStats()

    # Time: prefer last TIME_ELAPSED (accurate) over ;TIME: (often 6666 default)
    elapsed = re.findall(r";TIME_ELAPSED:([\d.]+)", gcode)
    if elapsed:
        stats.prediction = int(float(elapsed[-1]))
    else:
        m_time = re.search(r";TIME:(\d+)", gcode)
        if m_time:
            stats.prediction = int(m_time.group(1))

    # Filament: ";Filament used: 1.234m, 0.567m" (one entry per extruder)
    m_fil = re.search(r";Filament used:\s*(.+)", gcode)
    if m_fil:
        parts = m_fil.group(1).split(",")
        metres: list[float] = []
        total_mm = 0.0
        for part in parts:
            part = part.strip().rstrip("m")
            try:
                m_val = float(part)
            except ValueError:
                m_val = 0.0
            metres.append(m_val)
            total_mm += m_val * 1000  # convert m → mm
        stats.filament_used_m = metres
        # weight = length_mm × cross-section_mm² × density_g/mm³
        stats.weight = round(total_mm * _FILAMENT_AREA * _PLA_DENSITY_G_PER_MM3, 2)

    # CuraEngine CLI often reports "Filament used: 0m" as a placeholder.
    # Compute from max absolute E position per G92-reset segment, tracked
    # per extruder so we can report per-filament usage.
    if stats.weight == 0.0:
        segment_max = 0.0
        per_ext: dict[int, float] = {}  # extruder -> total extrusion mm
        current_ext = 0
        for line in gcode.splitlines():
            stripped = line.strip()
            if stripped == "G92 E0":
                per_ext[current_ext] = per_ext.get(current_ext, 0.0) + segment_max
                segment_max = 0.0
                continue
            m_t = re.match(r"^T(\d+)$", stripped)
            if m_t:
                ext = int(m_t.group(1))
                if ext < 255:
                    # Flush the current segment to the current extruder before switching
                    per_ext[current_ext] = per_ext.get(current_ext, 0.0) + segment_max
                    segment_max = 0.0
                    current_ext = ext
                continue
            m_e = re.match(r"G[01]\s.*E([\d.]+)", stripped)
            if m_e:
                e_val = float(m_e.group(1))
                if e_val > segment_max:
                    segment_max = e_val
        per_ext[current_ext] = per_ext.get(current_ext, 0.0) + segment_max
        total_length = sum(per_ext.values())
        if total_length > 0:
            stats.weight = round(total_length * _FILAMENT_AREA * _PLA_DENSITY_G_PER_MM3, 2)
            # Build per-extruder metres list
            if per_ext:
                max_ext = max(per_ext.keys())
                stats.filament_used_m = [
                    round(per_ext.get(i, 0.0) / 1000, 2) for i in range(max_ext + 1)
                ]

    # Compensate for firmware-level AMS purge cycles that CuraEngine doesn't
    # know about.  Each T<n> tool change (excluding the initial extruder select
    # before any extrusion and special T255/T1000) triggers an M620/M621 flush
    # on real hardware.
    tool_changes = re.findall(r"^T(\d+)\s*$", gcode, re.MULTILINE)
    if len(tool_changes) > 1:
        # First T command is an extruder select, not a purge-triggering change
        n_purges = len([t for t in tool_changes if int(t) < 255]) - 1
        if n_purges > 0:
            # Flush weight: volume × density (flush_volume is in mm³)
            purge_weight = n_purges * flush_volume_mm3 * _PLA_DENSITY_G_PER_MM3
            stats.weight = round(stats.weight + purge_weight, 2)
            # Flush time: ~95s per purge cycle (retract, travel to purge
            # station, heat, pulsatile flush, wipe, reload, travel back).
            # Derived from BambuStudio P1S: 81min overhead / 51 purges.
            _PURGE_TIME_SECS = 95
            stats.prediction += n_purges * _PURGE_TIME_SECS

    return stats
