"""NC file parser for the Beckhoff 5-axis metal DED slicer.

Parses the machine NC dialect emitted by ``stl_slicer.py`` (and the real
Beckhoff controller) into structured Python data. No visualisation — this
module only reads NC text and extracts motion + weld information.

The NC dialect understood here:

  COMMENTS
    (Part name: Hemisphere-v1)            -> part name (header)
    (New Slice of body X No.: 3, Z15.00)  -> slice (Z layer) number + Z
    (X, Slice 3, Fraction 2 Outer ...)    -> fraction (contour) number

  MOTION
    G00 X.. Y.. Z.. [A..] [B..]           -> rapid move (no welding)
    G01 X.. Y.. Z.. [A..] [B..] [M61]     -> cut move (M61 = laser ON)

  WELD CONTROL
    M60=1 / M60=11   -> weld start markers (tracked, not moves)
    M61              -> laser ON  (inline on a G01 line)
    M62              -> laser OFF (standalone line)
    M01              -> optional stop between slices (ignored)

  IGNORED
    #TRAFO ON, G54, F800.0, M63, M71, blank lines, ``;`` comments.

Units: X/Y/Z in millimetres, A/B in degrees.

Standard library only (re, math, pathlib) — importable with zero installs.
"""

import re
import math
from pathlib import Path


# Regexes compiled once at import time.
_PART_NAME_RE = re.compile(r"\(Part name:\s*(.+?)\)")
_SLICE_RE = re.compile(r"\(New Slice of body .*?No\.:\s*(\d+)")
_FRACTION_RE = re.compile(r"Fraction\s+(\d+)")
# Axis word followed by an optional-sign float (incl. scientific notation).
_AXIS_RE = re.compile(r"\b([XYZAB])(-?\d+\.?\d*(?:[eE][-+]?\d+)?)")


def _extract_axes(line: str) -> dict:
    """Pull X/Y/Z/A/B axis words out of a motion line.

    Inputs:
        line -- a single G00/G01 NC line, e.g. "G01 X-1.2 Y3.4 Z2.5 A0.0 B1.0".

    Output:
        dict mapping the upper-case axis letter ("X".."B") to its float value
        (mm for X/Y/Z, degrees for A/B). Only axes present in the line appear.
    """
    return {m.group(1): float(m.group(2)) for m in _AXIS_RE.finditer(line)}


def parse_nc(filepath: str) -> dict:
    """Parse a Beckhoff DED NC file into structured move data.

    Inputs:
        filepath -- path to the .nc file to read.

    Output:
        dict with two keys:
          "part_name" : str  -- from the "(Part name: ...)" header comment,
                                 or the filename stem if that comment is absent.
          "moves"     : list[dict] -- one dict per G00/G01 motion line, each:
              {
                "line_no"     : int,   # 1-based line number in the source file
                "x"           : float, # mm
                "y"           : float, # mm
                "z"           : float, # mm
                "a"           : float, # degrees (0.0 if absent)
                "b"           : float, # degrees (0.0 if absent)
                "move_type"   : str,   # "rapid" (G00) or "cut" (G01)
                "weld_state"  : str,   # "on" or "off"
                "slice_no"    : int,   # Z layer this move belongs to
                "fraction_no" : int,   # contour within the layer
              }

    Behaviour / error handling:
        * Weld state starts "off"; M61 (inline) turns it "on", M62 turns it
          "off". The state carries forward across G01 lines until M62.
        * A G00/G01 line missing X, Y or Z prints a warning with the line
          number and is skipped (never crashes).
        * Missing A or B silently default to 0.0.
        * The whole parse is wrapped in try/except; on error the file path is
          reported and a dict with an empty move list is returned.
    """
    part_name = None
    moves = []

    weld_state = "off"
    slice_no = 0
    fraction_no = 0

    try:
        text = Path(filepath).read_text()
        lines = text.splitlines()

        for idx, raw in enumerate(lines, start=1):
            line = raw.strip()

            # Skip blanks and ';' comments outright.
            if not line or line.startswith(";"):
                continue

            # Parenthesised comments: extract part name / slice / fraction.
            if line.startswith("("):
                if part_name is None:
                    m = _PART_NAME_RE.search(line)
                    if m:
                        part_name = m.group(1).strip()
                        continue
                m = _SLICE_RE.search(line)
                if m:
                    slice_no = int(m.group(1))
                    continue
                m = _FRACTION_RE.search(line)
                if m:
                    fraction_no = int(m.group(1))
                continue

            # Weld control / stop markers.
            if line.startswith("M62"):
                weld_state = "off"
                continue
            if line.startswith("M60") or line.startswith("M01"):
                # M60=1 / M60=11 = weld start markers, M01 = optional stop.
                # Tracked by context only; not moves themselves.
                continue

            # Motion lines.
            if line.startswith("G00") or line.startswith("G01"):
                move_type = "rapid" if line.startswith("G00") else "cut"
                axes = _extract_axes(line)

                missing = [ax for ax in ("X", "Y", "Z") if ax not in axes]
                if missing:
                    print(
                        f"Warning: line {idx} missing {','.join(missing)} "
                        f"-> skipped: {line!r}"
                    )
                    continue

                # M61 appears inline on a G01 line and turns the laser ON for
                # this move and the ones that follow.
                if "M61" in line:
                    weld_state = "on"

                moves.append({
                    "line_no": idx,
                    "x": axes["X"],
                    "y": axes["Y"],
                    "z": axes["Z"],
                    "a": axes.get("A", 0.0),
                    "b": axes.get("B", 0.0),
                    "move_type": move_type,
                    "weld_state": weld_state,
                    "slice_no": slice_no,
                    "fraction_no": fraction_no,
                })
                continue

            # Everything else (#TRAFO ON, G54, F800.0, M63, M71, ...) ignored.

    except Exception as exc:  # noqa: BLE001 - report and degrade gracefully
        print(f"Error parsing NC file {filepath!r}: {exc}")
        return {"part_name": Path(filepath).stem, "moves": moves}

    if part_name is None:
        part_name = Path(filepath).stem

    return {"part_name": part_name, "moves": moves}


def compute_tool_vector(a_deg: float, b_deg: float) -> tuple[float, float, float]:
    """Convert A/B axis angles to a unit tool-direction vector.

    Inputs:
        a_deg -- A-axis angle in degrees.
        b_deg -- B-axis angle in degrees.

    Output:
        (u, v, w) -- a unit direction vector for where the extruder/welding
        head is pointing, where:
            u = X component
            v = Y component
            w = Z component

    Formula:
        u =  sin(B)
        v = -sin(A) * cos(B)
        w =  cos(A) * cos(B)

    With A=0 and B=0 the result is (0.0, 0.0, 1.0) — the head points straight
    up along +Z. The vector is already unit length for any A/B.
    """
    a = math.radians(a_deg)
    b = math.radians(b_deg)
    u = math.sin(b)
    v = -math.sin(a) * math.cos(b)
    w = math.cos(a) * math.cos(b)
    return (u, v, w)


def summarise(parsed: dict) -> None:
    """Print a clean human-readable summary of parsed NC data.

    Inputs:
        parsed -- the dict returned by ``parse_nc`` (keys "part_name",
                  "moves").

    Output:
        None. Prints to the terminal: part name, total move count, number of
        distinct Z layers (slices) and fractions, weld-ON vs rapid point
        counts, and the Z (mm) / A (deg) / B (deg) ranges seen across moves.
    """
    moves = parsed.get("moves", [])
    part_name = parsed.get("part_name", "?")

    total = len(moves)
    layers = len({m["slice_no"] for m in moves})
    fractions = len({(m["slice_no"], m["fraction_no"]) for m in moves})
    weld_on = sum(1 for m in moves if m["weld_state"] == "on")
    rapid = sum(1 for m in moves if m["move_type"] == "rapid")

    print(f"Part name   : {part_name}")
    print(f"Total moves : {total}")
    print(f"Layers      : {layers}")
    print(f"Fractions   : {fractions}")
    print(f"Weld ON pts : {weld_on}")
    print(f"Rapid pts   : {rapid}")

    if moves:
        zs = [m["z"] for m in moves]
        as_ = [m["a"] for m in moves]
        bs = [m["b"] for m in moves]
        print(f"Z range     : {min(zs):.2f} mm → {max(zs):.2f} mm")
        print(f"A range     : {min(as_):.2f}° → {max(as_):.2f}°")
        print(f"B range     : {min(bs):.2f}° → {max(bs):.2f}°")
    else:
        print("Z range     : (no moves)")
        print("A range     : (no moves)")
        print("B range     : (no moves)")


if __name__ == "__main__":
    import tempfile

    TEST_NC = """(Part name: Hemisphere-v1)
#TRAFO ON
G54
M63
M71
(New Slice of body Hemisphere-v1 No.: 1, Z2.50)
(Change material to Steel)
(Hemisphere-v1, Slice 1, Fraction 1 Outer Perimeter 1)
F800.0
G00 X-48.09 Y-13.29 Z2.50 A0.00 B0.00
M60=1
G01 X-47.29 Y-15.96 Z2.50 A0.00 B0.00 M61
G01 X-46.78 Y-17.40 Z2.50 A0.00 B0.00
G01 X-45.71 Y-20.01 Z2.50 A2.50 B-1.20
M62
(Hemisphere-v1, Slice 1, Fraction 2 Outer Perimeter 2)
G00 X-31.23 Y-24.83 Z2.50 A0.00 B0.00
M60=11
G01 X-31.90 Y-23.97 Z2.50 A0.00 B0.00 M61
G01 X-32.64 Y-22.94 Z2.50 A1.80 B0.50
M62
M01
(New Slice of body Hemisphere-v1 No.: 2, Z7.50)
(Hemisphere-v1, Slice 2, Fraction 1 Outer Perimeter 1)
F800.0
G00 X-45.12 Y-10.55 Z7.50 A5.10 B-2.30
M60=11
G01 X-44.50 Y-12.80 Z7.50 A5.10 B-2.30 M61
G01 X-43.90 Y-15.10 Z7.50 A6.20 B-1.80
M62
M01
"""

    with tempfile.NamedTemporaryFile(
        "w", suffix=".nc", delete=False
    ) as fh:
        fh.write(TEST_NC)
        tmp_path = fh.name

    parsed = parse_nc(tmp_path)
    summarise(parsed)

    # Quick sanity check on the tool-vector helper.
    print()
    print(f"tool vector @ A0 B0  : {compute_tool_vector(0.0, 0.0)}")
    print(f"tool vector @ A5 B-2 : {compute_tool_vector(5.0, -2.0)}")
