"""Interactive 3D viewer for Beckhoff 5-axis metal DED NC toolpaths.

Reads an NC file via the existing ``nc_parser`` (imported, never rewritten) and
renders a single self-contained, offline-capable HTML file with:

  * a 3D toolpath view (rapid moves, per-layer weld paths),
  * an A/B tilt-angle chart with welding-active shading,
  * a statistics table,
  * a layer-isolation dropdown.

Mouse controls are Plotly's built-in scatter3d defaults:
  LEFT-DRAG rotate, SCROLL zoom, RIGHT-DRAG pan, HOVER tooltip.

Units: X/Y/Z in millimetres, A/B in degrees, path lengths in mm, time in
seconds (feed rate F = 800 mm/min).

Run:
  python nc_viewer.py <path_to_nc_file>

Dependencies: plotly, numpy. ``nc_parser.py`` must sit in the same folder.
The output HTML embeds plotly.js from CDN (``include_plotlyjs="cdn"``).
"""

import math
from pathlib import Path

import numpy as np
import plotly.graph_objects as go
import plotly.io as pio
from plotly.subplots import make_subplots

from nc_parser import parse_nc, compute_tool_vector


# Machine feed rate used for the print-time estimate (mm/min).
FEED_RATE = 800.0

# Number of animation frames. Each frame reveals the toolpath up to one move
# (the trail itself is full-resolution — every move is drawn), so this only
# controls how chunky the growth looks and the HTML size, not completeness.
ANIM_TARGET_FRAMES = 160

# Default 3D camera: ORTHOGRAPHIC projection from an elevated isometric angle.
# Plotly normalises the scene to a unit cube before placing the camera, so this
# single fixed eye fits the whole part regardless of its real size — a "full
# view" of any part. Orthographic (no perspective foreshortening) gives a true-
# to-scale, CAD-style view; the eye is pulled back so nothing is clipped.
# Used as the initial view and the view that Reset snaps back to.
DEFAULT_CAMERA = dict(
    projection=dict(type="orthographic"),
    eye=dict(x=1.6, y=1.6, z=1.2),
    center=dict(x=0.0, y=0.0, z=0.0),
    up=dict(x=0.0, y=0.0, z=1.0),
)

# Dark theme palette.
SCENE_BG = "#1a1a2e"
FIG_BG = "#0f0f1a"
RAPID_COLOUR = "#888888"
ANGLE_A_COLOUR = "#00bfff"
ANGLE_B_COLOUR = "#ff4500"


def _slice_order(moves: list[dict]) -> list[int]:
    """Return unique slice_no values in first-seen order.

    Inputs:
        moves -- list of move dicts from ``parse_nc``.
    Output:
        list[int] of distinct ``slice_no`` values, ordered by appearance.
    """
    seen = []
    for m in moves:
        if m["slice_no"] not in seen:
            seen.append(m["slice_no"])
    return seen


def _layer_colour(i: int, n: int) -> str:
    """Map a layer index to a distinct rainbow colour.

    Inputs:
        i -- zero-based layer index.
        n -- total number of layers.
    Output:
        an "rgb(r,g,b)" string sampled across the hue wheel so each layer
        (Z level) is visually separable.
    """
    hue = (i / max(n, 1)) * 0.85  # 0..0.85 avoids wrapping red back to red
    r, g, b = _hsv_to_rgb(hue, 0.85, 1.0)
    return f"rgb({int(r * 255)},{int(g * 255)},{int(b * 255)})"


def _hsv_to_rgb(h: float, s: float, v: float) -> tuple[float, float, float]:
    """Convert HSV (each 0..1) to RGB (each 0..1). Pure helper, no deps."""
    i = int(h * 6.0)
    f = h * 6.0 - i
    p = v * (1.0 - s)
    q = v * (1.0 - f * s)
    t = v * (1.0 - (1.0 - f) * s)
    i %= 6
    return [
        (v, t, p), (q, v, p), (p, v, t),
        (p, q, v), (t, p, v), (v, p, q),
    ][i]


def _path_length(pts: list[tuple[float, float, float]]) -> float:
    """Sum Euclidean distances between consecutive 3D points (mm).

    Inputs:
        pts -- ordered list of (x, y, z) tuples in mm.
    Output:
        total path length in mm (0.0 for fewer than two points).
    """
    total = 0.0
    for (x0, y0, z0), (x1, y1, z1) in zip(pts, pts[1:]):
        total += math.dist((x0, y0, z0), (x1, y1, z1))
    return total


def compute_stats(moves: list[dict]) -> dict:
    """Compute all statistics shown in the stats table.

    Inputs:
        moves -- list of move dicts from ``parse_nc`` (x/y/z in mm, a/b in
                 degrees, plus move_type/weld_state/slice_no/fraction_no).

    Output:
        dict with these keys (values pre-formatted as strings for display
        except where noted):
          "part_name"        -- set by caller, placeholder "" here
          "total_layers"     -- int count of unique slice_no
          "total_fractions"  -- int count of unique (slice_no, fraction_no)
          "total_points"     -- int len(moves)
          "weld_points"      -- int count where weld_state == "on"
          "rapid_points"     -- int count where move_type == "rapid"
          "weld_length_mm"   -- float, summed distance between consecutive
                                weld (cut) points, mm
          "rapid_length_mm"  -- float, summed distance between consecutive
                                rapid points, mm
          "z_min"/"z_max"    -- float, mm
          "a_min"/"a_max"    -- float, degrees
          "b_min"/"b_max"    -- float, degrees
          "print_time_s"     -- float, weld_length / FEED_RATE * 60, seconds

        All numeric values are returned as floats/ints; formatting for the
        table happens in ``build_stats_table``.
    """
    total_points = len(moves)
    layers = {m["slice_no"] for m in moves}
    fractions = {(m["slice_no"], m["fraction_no"]) for m in moves}
    weld_points = sum(1 for m in moves if m["weld_state"] == "on")
    rapid_points = sum(1 for m in moves if m["move_type"] == "rapid")

    weld_pts = [(m["x"], m["y"], m["z"]) for m in moves if m["move_type"] == "cut"]
    rapid_pts = [(m["x"], m["y"], m["z"]) for m in moves if m["move_type"] == "rapid"]
    weld_len = _path_length(weld_pts)
    rapid_len = _path_length(rapid_pts)

    zs = [m["z"] for m in moves] or [0.0]
    as_ = [m["a"] for m in moves] or [0.0]
    bs = [m["b"] for m in moves] or [0.0]

    return {
        "part_name": "",
        "total_layers": len(layers),
        "total_fractions": len(fractions),
        "total_points": total_points,
        "weld_points": weld_points,
        "rapid_points": rapid_points,
        "weld_length_mm": weld_len,
        "rapid_length_mm": rapid_len,
        "z_min": min(zs),
        "z_max": max(zs),
        "a_min": min(as_),
        "a_max": max(as_),
        "b_min": min(bs),
        "b_max": max(bs),
        "print_time_s": weld_len / FEED_RATE * 60.0,
    }


def _customdata_row(m: dict) -> list:
    """Build the per-point customdata array used by the hover template.

    Inputs:
        m -- one move dict.
    Output:
        [x, y, z, a, b, weld_state, slice_no, fraction_no, u, v, w] where
        (u, v, w) is the unit tool vector from ``compute_tool_vector``.
    """
    u, v, w = compute_tool_vector(m["a"], m["b"])
    return [
        m["x"], m["y"], m["z"], m["a"], m["b"],
        m["weld_state"], m["slice_no"], m["fraction_no"],
        u, v, w,
    ]


# Shared hover template (units in the labels).
_HOVER = (
    "<b>Position</b><br>"
    "X: %{customdata[0]:.2f} mm<br>"
    "Y: %{customdata[1]:.2f} mm<br>"
    "Z: %{customdata[2]:.2f} mm<br>"
    "<b>Extruder</b><br>"
    "A: %{customdata[3]:.2f}°<br>"
    "B: %{customdata[4]:.2f}°<br>"
    "Vector: (%{customdata[8]:.3f}, %{customdata[9]:.3f}, %{customdata[10]:.3f})<br>"
    "<b>Status:</b> %{customdata[5]}<br>"
    "Slice: %{customdata[6]}  Fraction: %{customdata[7]}"
    "<extra></extra>"
)


def build_rapid_trace(moves: list[dict]) -> go.Scatter3d:
    """Build a single 3D trace for all rapid (non-welding) moves.

    Inputs:
        moves -- list of move dicts (mm for coords).
    Output:
        a ``go.Scatter3d`` line trace (grey dotted) covering every
        ``move_type == "rapid"`` point, with full hover customdata.
    """
    rapids = [m for m in moves if m["move_type"] == "rapid"]
    return go.Scatter3d(
        x=[m["x"] for m in rapids],
        y=[m["y"] for m in rapids],
        z=[m["z"] for m in rapids],
        mode="lines",
        line=dict(color=RAPID_COLOUR, dash="dot", width=1),
        name="Rapid moves",
        customdata=[_customdata_row(m) for m in rapids],
        hovertemplate=_HOVER,
        showlegend=True,
    )


def build_weld_traces(moves: list[dict]) -> list[go.Scatter3d]:
    """Build one 3D weld-path trace per slice (Z layer), colour-coded.

    Inputs:
        moves -- list of move dicts (mm for coords).
    Output:
        list of ``go.Scatter3d`` "lines+markers" traces, one per unique
        ``slice_no`` (in appearance order), each a distinct rainbow colour and
        named ``"Layer N  Z=..mm"``. Only ``move_type == "cut"`` points are
        included. Each trace carries full hover customdata.
    """
    order = _slice_order(moves)
    n = len(order)
    traces = []
    for i, sno in enumerate(order):
        cut = [m for m in moves if m["slice_no"] == sno and m["move_type"] == "cut"]
        if not cut:
            continue
        z = cut[0]["z"]
        colour = _layer_colour(i, n)
        traces.append(go.Scatter3d(
            x=[m["x"] for m in cut],
            y=[m["y"] for m in cut],
            z=[m["z"] for m in cut],
            mode="lines+markers",
            marker=dict(size=3, color=colour),
            line=dict(color=colour, width=3),
            name=f"Layer {sno}  Z={z:.1f}mm",
            customdata=[_customdata_row(m) for m in cut],
            hovertemplate=_HOVER,
            showlegend=True,
        ))
    return traces


def build_angle_chart(moves: list[dict]):
    """Build the A/B tilt-angle line traces plus welding-active shading.

    Inputs:
        moves -- list of move dicts (angles in degrees).
    Output:
        (traces, y_range):
          traces  -- list of four ``go.Scatter`` (2D) traces over move index
                     0..N-1: a low baseline + a "Welding active" band that
                     together shade (green, semi-transparent) the FULL chart
                     height wherever ``weld_state == "on"``, then the A axis
                     (blue) and B axis (red) angle lines on top.
          y_range -- [ymin, ymax] for the subplot's y-axis (padded angle range,
                     with a sensible minimum so a flat 0° trace still shows a
                     band). Returned so ``build_figure`` can pin the axis — the
                     band is drawn to these exact bounds.
        These live in the 2D subplot, not the 3D scene.
    """
    idx = list(range(len(moves)))
    a_vals = [m["a"] for m in moves]
    b_vals = [m["b"] for m in moves]

    # Pinned y-range: padded angle extent, but never degenerate (so an all-0°
    # part still gets a visible band). The band fills exactly these bounds.
    lo = min(a_vals + b_vals + [0.0])
    hi = max(a_vals + b_vals + [0.0])
    pad = max((hi - lo) * 0.1, 1.0)
    ymin, ymax = lo - pad, hi + pad

    welding = [m["weld_state"] == "on" for m in moves]
    band_low = [ymin if on else None for on in welding]
    band_high = [ymax if on else None for on in welding]

    # Two traces forming a filled band: invisible baseline at ymin, then the
    # top edge at ymax filling down to it. connectgaps=False keeps the band
    # broken across travel (non-welding) gaps.
    base = go.Scatter(
        x=idx, y=band_low, mode="lines",
        line=dict(width=0), hoverinfo="skip",
        showlegend=False, connectgaps=False,
    )
    band = go.Scatter(
        x=idx, y=band_high, mode="lines",
        line=dict(width=0), fill="tonexty",
        fillcolor="rgba(0,255,128,0.18)",
        name="Welding active", hoverinfo="skip",
        connectgaps=False,
    )
    a_trace = go.Scatter(
        x=idx, y=a_vals, mode="lines",
        line=dict(color=ANGLE_A_COLOUR, width=2),
        name="A axis (°)",
        hovertemplate="move %{x}<br>A: %{y:.2f}°<extra></extra>",
    )
    b_trace = go.Scatter(
        x=idx, y=b_vals, mode="lines",
        line=dict(color=ANGLE_B_COLOUR, width=2),
        name="B axis (°)",
        hovertemplate="move %{x}<br>B: %{y:.2f}°<extra></extra>",
    )
    # Band first (base then fill) so the angle lines draw on top.
    return [base, band, a_trace, b_trace], [ymin, ymax]


def build_stats_table(stats: dict) -> go.Table:
    """Build the formatted statistics table trace.

    Inputs:
        stats -- dict from ``compute_stats`` (with "part_name" filled in).
    Output:
        a ``go.Table`` trace with "Property"/"Value" columns, dark-blue header,
        alternating dark row backgrounds, white 12pt text. Lengths shown in
        mm, angles in degrees, time in seconds.
    """
    rows = [
        ("Part name", stats["part_name"]),
        ("Total layers", f"{stats['total_layers']}"),
        ("Total fractions", f"{stats['total_fractions']}"),
        ("Total points", f"{stats['total_points']}"),
        ("Weld ON points", f"{stats['weld_points']}"),
        ("Rapid points", f"{stats['rapid_points']}"),
        ("Weld path length", f"{stats['weld_length_mm']:.1f} mm"),
        ("Rapid path length", f"{stats['rapid_length_mm']:.1f} mm"),
        ("Z range", f"{stats['z_min']:.2f} mm → {stats['z_max']:.2f} mm"),
        ("A angle range", f"{stats['a_min']:.2f}° → {stats['a_max']:.2f}°"),
        ("B angle range", f"{stats['b_min']:.2f}° → {stats['b_max']:.2f}°"),
        ("Est. print time", f"{stats['print_time_s']:.1f} s"),
    ]
    props = [r[0] for r in rows]
    vals = [r[1] for r in rows]

    # Alternating row colours.
    fill = [
        ["#1a1a2e" if i % 2 == 0 else "#16213e" for i in range(len(rows))]
    ] * 2

    return go.Table(
        header=dict(
            values=["<b>Property</b>", "<b>Value</b>"],
            fill_color="#0d47a1",
            font=dict(color="white", size=12),
            align="left",
        ),
        cells=dict(
            values=[props, vals],
            fill_color=fill,
            font=dict(color="white", size=12),
            align="left",
            height=24,
        ),
    )


def build_layer_dropdown(weld_traces: list[go.Scatter3d], n_static_before: int,
                         total_traces: int) -> dict:
    """Build the updatemenus dropdown that isolates a single layer.

    Inputs:
        weld_traces     -- the per-layer weld traces (to read names/order).
        n_static_before -- number of traces placed BEFORE the weld traces in
                           the figure's trace list (e.g. the rapid trace),
                           so weld-trace indices can be computed.
        total_traces    -- total number of traces in the figure (for building
                           full-length opacity arrays via restyle).
    Output:
        a single ``updatemenus`` dict. "All layers" shows every weld trace at
        full opacity; each "Layer N" option sets that layer's weld trace to
        opacity 1.0 and all OTHER weld traces to 0.1. Non-weld traces (rapid
        path, animated head cursor) are left untouched.

    Note: opacity is restyled only on the weld-trace indices, so the rapid
    path and the animated head cursor always stay fully visible.
    """
    weld_indices = list(range(n_static_before, n_static_before + len(weld_traces)))

    buttons = [dict(
        label="All layers",
        method="restyle",
        args=[{"opacity": [1.0] * len(weld_indices)}, weld_indices],
    )]

    for i, tr in enumerate(weld_traces):
        opac = [0.1] * len(weld_indices)
        opac[i] = 1.0
        buttons.append(dict(
            label=tr.name,
            method="restyle",
            args=[{"opacity": opac}, weld_indices],
        ))

    return dict(
        buttons=buttons,
        direction="down",
        showactive=True,
        x=0.01, xanchor="left",
        y=1.12, yanchor="top",
        bgcolor="#16213e",
        font=dict(color="white", size=12),
        bordercolor="#0d47a1",
    )


def _stick_length(moves: list[dict]) -> float:
    """Pick a sensible length (mm) for the head 'stick' from the part size.

    Inputs:
        moves -- list of move dicts (mm coords).
    Output:
        10% of the largest bounding-box extent (min 5 mm), so the stick is
        visible but not overwhelming regardless of part scale.
    """
    if not moves:
        return 5.0
    xs = [m["x"] for m in moves]
    ys = [m["y"] for m in moves]
    zs = [m["z"] for m in moves]
    ext = max(max(xs) - min(xs), max(ys) - min(ys), max(zs) - min(zs), 1.0)
    return max(ext * 0.10, 5.0)


def _stick_points(m: dict, length: float):
    """Return the two endpoints of the head stick for one move.

    Inputs:
        m      -- a move dict (x/y/z mm, a/b degrees).
        length -- stick length in mm.
    Output:
        (xs, ys, zs) each a 2-element list: [contact_point, tip], where the tip
        is the contact point offset by ``length`` along ``compute_tool_vector``.
        Straight up (+Z) when A=B=0; tilts as A/B grow, so the inclination is
        visible in 3D.
    """
    u, v, w = compute_tool_vector(m["a"], m["b"])
    bx, by, bz = m["x"], m["y"], m["z"]
    return ([bx, bx + u * length],
            [by, by + v * length],
            [bz, bz + w * length])


def build_cursor_trace(moves: list[dict]) -> go.Scatter3d:
    """Build the animated head 'stick' that moves along the toolpath.

    Inputs:
        moves -- list of move dicts (mm coords, deg angles).
    Output:
        a two-point ``go.Scatter3d`` line+marker trace drawn as a short yellow
        stick: a fat dot at the contact point and a thin line pointing along
        the tool vector (straight up at A=B=0, tilted when the head is
        inclined). Updated frame by frame during the play/pause animation.
    """
    first = moves[0] if moves else {"x": 0.0, "y": 0.0, "z": 0.0,
                                    "a": 0.0, "b": 0.0}
    xs, ys, zs = _stick_points(first, _stick_length(moves))
    return go.Scatter3d(
        x=xs, y=ys, z=zs,
        mode="lines+markers",
        line=dict(color="#ffff00", width=6),
        marker=dict(size=[7, 3], color="#ffff00",
                    line=dict(color="black", width=1)),
        name="Head position",
        showlegend=True,
        hoverinfo="skip",
    )


def build_trail_trace() -> go.Scatter3d:
    """Build the (initially empty) trail that is drawn progressively on Play.

    Inputs:
        none.
    Output:
        an empty ``go.Scatter3d`` line+markers trace. During the animation each
        frame replaces it with EVERY coordinate visited so far (full resolution,
        not sub-sampled), drawing a cyan line AND a dot at every point the head
        has passed. Empty on load so nothing is drawn until Play is pressed.
    """
    return go.Scatter3d(
        x=[], y=[], z=[],
        mode="lines+markers",
        # Dim grey line so the layer-coloured dots (set per-frame) stand out.
        line=dict(color="#5a6072", width=2),
        marker=dict(size=2),
        name="Printed trail",
        showlegend=True,
        hoverinfo="skip",
    )


def _anim_indices(n_moves: int) -> list[int]:
    """Pick the move indices used as animation frames.

    Inputs:
        n_moves -- total number of moves.
    Output:
        ascending list of move indices sub-sampled to about
        ``ANIM_TARGET_FRAMES`` frames (always includes the final move), so the
        animation stays smooth regardless of file size.
    """
    if n_moves == 0:
        return []
    step = max(1, math.ceil(n_moves / ANIM_TARGET_FRAMES))
    idxs = list(range(0, n_moves, step))
    if idxs[-1] != n_moves - 1:
        idxs.append(n_moves - 1)
    return idxs


def build_animation(moves: list[dict], trail_index: int, cursor_index: int,
                    static_indices: list[int]):
    """Build the progressive-reveal animation, control buttons, and slider.

    Inputs:
        moves          -- list of move dicts (mm coords, weld_state).
        trail_index    -- trace index of the growing "Printed trail".
        cursor_index   -- trace index of the head-position marker.
        static_indices -- trace indices of the full static toolpath (rapid +
                          weld traces) that are hidden during playback and
                          restored on reset.

    Output:
        (frames, play_menu, slider):
          frames    -- list[go.Frame]. The animation frames (one per sub-sampled
                       move, named by move index) grow the trail up to that move
                       and advance the head cursor; the first frame also hides
                       every static toolpath trace so only the trail is drawn
                       while playing. A trailing "reset" frame clears the trail,
                       returns the head to the start, and makes the static
                       toolpath visible again.
          play_menu -- an ``updatemenus`` dict with Play (~25 fps, restarts from
                       the beginning so the path always re-reveals), Pause
                       (halts immediately), and Reset buttons.
          slider    -- a ``sliders`` dict to scrub to any frame by move index.
        Returns ([], None, None) when there are no moves.

    Coordinates: mm. The trail/cursor positions are exact NC move coordinates;
    only which moves get their own frame is sub-sampled (see ``_anim_indices``).
    """
    idxs = _anim_indices(len(moves))
    if not idxs:
        return [], None, None

    first = moves[idxs[0]]
    stick_len = _stick_length(moves)
    # Full-resolution coordinate arrays; each frame reveals a prefix of these
    # so the real toolpath (every move, not just frame points) is drawn.
    all_x = [m["x"] for m in moves]
    all_y = [m["y"] for m in moves]
    all_z = [m["z"] for m in moves]
    # Per-point trail colour keyed to the move's layer, using the SAME mapping
    # as the weld traces so each revealed dot matches its layer's rainbow hue.
    order = _slice_order(moves)
    n_layers = len(order)
    layer_pos = {sno: i for i, sno in enumerate(order)}
    all_colours = [_layer_colour(layer_pos[m["slice_no"]], n_layers) for m in moves]

    # Hiding/showing the static toolpath = a tiny visibility update per static
    # trace (re-used as frame data alongside the trail + cursor updates).
    hide_static = [go.Scatter3d(visible=False) for _ in static_indices]
    show_static = [go.Scatter3d(visible=True) for _ in static_indices]

    frames = []
    slider_steps = []
    for k, i in enumerate(idxs):
        # Reveal every move up to and including this frame's move index.
        upto = i + 1
        m = moves[i]

        trail = go.Scatter3d(
            x=all_x[:upto], y=all_y[:upto], z=all_z[:upto],
            marker=dict(size=2, color=all_colours[:upto]),
        )
        cx, cy, cz = _stick_points(m, stick_len)
        cursor = go.Scatter3d(x=cx, y=cy, z=cz)

        if k == 0:
            # First frame also hides the full static path so playback reveals
            # it, and flips scene.uirevision to "play" so the next Reset (which
            # uses a different value) is guaranteed to re-apply the camera.
            data = [trail, cursor, *hide_static]
            traces = [trail_index, cursor_index, *static_indices]
            layout = dict(scene=dict(uirevision="play", camera=DEFAULT_CAMERA))
        else:
            data = [trail, cursor]
            traces = [trail_index, cursor_index]
            layout = None

        frame_kwargs = dict(data=data, traces=traces, name=str(i))
        if layout is not None:
            frame_kwargs["layout"] = layout
        frames.append(go.Frame(**frame_kwargs))
        slider_steps.append(dict(
            method="animate",
            label=str(i),
            args=[[str(i)], dict(
                mode="immediate",
                frame=dict(duration=0, redraw=True),
                transition=dict(duration=0),
            )],
        ))

    anim_names = [str(i) for i in idxs]

    # Reset frame: empty trail, head stick back at the start, static path
    # visible, and the camera snapped back to the default zoomed-out view.
    # Flipping scene.uirevision to "reset" (different from Play's "play") forces
    # Plotly to re-apply DEFAULT_CAMERA even after the user has rotated.
    rx, ry, rz = _stick_points(first, stick_len)
    frames.append(go.Frame(
        name="reset",
        data=[
            go.Scatter3d(x=[], y=[], z=[]),
            go.Scatter3d(x=rx, y=ry, z=rz),
            *show_static,
        ],
        traces=[trail_index, cursor_index, *static_indices],
        layout=dict(scene=dict(uirevision="reset", camera=DEFAULT_CAMERA)),
    ))

    play_menu = dict(
        type="buttons",
        direction="left",
        showactive=False,
        x=0.01, xanchor="left",
        y=0.05, yanchor="bottom",
        bgcolor="#16213e",
        bordercolor="#0d47a1",
        font=dict(color="white", size=12),
        pad=dict(l=4, r=4, t=4, b=4),
        buttons=[
            dict(
                label="▶ Play",
                method="animate",
                # Explicit frame list (not None) so the trailing "reset" frame
                # is excluded; fromcurrent=False restarts the reveal each time.
                args=[anim_names, dict(
                    fromcurrent=False,
                    mode="immediate",
                    frame=dict(duration=40, redraw=True),
                    transition=dict(duration=0),
                )],
            ),
            dict(
                label="❚❚ Pause",
                method="animate",
                args=[[None], dict(
                    mode="immediate",
                    frame=dict(duration=0, redraw=False),
                    transition=dict(duration=0),
                )],
            ),
            dict(
                label="⟲ Reset",
                method="animate",
                args=[["reset"], dict(
                    mode="immediate",
                    frame=dict(duration=0, redraw=True),
                    transition=dict(duration=0),
                )],
            ),
        ],
    )

    slider = dict(
        active=0,
        x=0.05, len=0.55,
        y=0.0, yanchor="top",
        pad=dict(t=10, b=10),
        currentvalue=dict(prefix="Move ", font=dict(color="white", size=12)),
        font=dict(color="white", size=10),
        bgcolor="#16213e",
        bordercolor="#0d47a1",
        steps=slider_steps,
    )

    return frames, play_menu, slider


def _axis_ranges(moves: list[dict]):
    """Compute fixed [min, max] ranges for the X/Y/Z scene axes (mm).

    Inputs:
        moves -- list of move dicts (mm coords).
    Output:
        (x_range, y_range, z_range), each a 2-element [lo, hi] list padded by
        5% of the largest extent so the part never touches the scene walls.
        These are pinned on the scene so the view stays FIXED and fully framed
        regardless of which traces are visible — without them the animation
        (which hides the static toolpath and grows a 1-point trail) makes
        ``aspectmode="data"`` rescale the scene, causing the zoom to jump.
    """
    if not moves:
        return [-1, 1], [-1, 1], [-1, 1]
    xs = [m["x"] for m in moves]
    ys = [m["y"] for m in moves]
    zs = [m["z"] for m in moves]
    pad = max(max(xs) - min(xs), max(ys) - min(ys), max(zs) - min(zs), 1.0) * 0.05
    return (
        [min(xs) - pad, max(xs) + pad],
        [min(ys) - pad, max(ys) + pad],
        [min(zs) - pad, max(zs) + pad],
    )


def build_figure(parsed: dict, source_file: str) -> go.Figure:
    """Assemble the complete two-column Plotly figure.

    Inputs:
        parsed      -- dict from ``parse_nc`` ("part_name", "moves").
        source_file -- path/name of the NC file (shown in the title).
    Output:
        a ``go.Figure`` with: a large 3D toolpath scene (rapid + per-layer weld
        + an animated head cursor) spanning both left rows, an A/B angle chart
        top-right, a stats table bottom-right, a layer-isolation dropdown,
        play/pause animation controls with a scrub slider, and a dark theme.
        Calls every ``build_*`` helper.
    """
    moves = parsed["moves"]
    part_name = parsed["part_name"]

    stats = compute_stats(moves)
    stats["part_name"] = part_name
    n_layers = stats["total_layers"]
    n_points = stats["total_points"]

    # Fixed scene bounds from the part's bounding box (see _axis_ranges).
    x_range, y_range, z_range = _axis_ranges(moves)

    fig = make_subplots(
        rows=2, cols=2,
        column_widths=[0.66, 0.34],
        row_heights=[0.5, 0.5],
        specs=[
            [{"type": "scene", "rowspan": 2}, {"type": "xy"}],
            [None, {"type": "table"}],
        ],
        subplot_titles=("", "Extruder Tilt — A and B Angles", ""),
        horizontal_spacing=0.06,
        vertical_spacing=0.10,
    )

    # --- 3D scene traces (col 1) ---
    rapid_trace = build_rapid_trace(moves)
    weld_traces = build_weld_traces(moves)

    fig.add_trace(rapid_trace, row=1, col=1)            # index 0
    for wt in weld_traces:                               # indices 1..n
        fig.add_trace(wt, row=1, col=1)

    # Head cursor (index 1+n) and growing trail (index 2+n) sit right after the
    # weld traces so the layer dropdown (restyles only weld indices) never
    # touches them. The static toolpath = rapid + weld traces (indices 0..n).
    cursor_index = 1 + len(weld_traces)
    trail_index = 2 + len(weld_traces)
    static_indices = list(range(0, 1 + len(weld_traces)))
    fig.add_trace(build_cursor_trace(moves), row=1, col=1)
    fig.add_trace(build_trail_trace(), row=1, col=1)

    # --- angle chart (row 1, col 2) ---
    angle_traces, angle_yrange = build_angle_chart(moves)
    for at in angle_traces:
        fig.add_trace(at, row=1, col=2)

    # --- stats table (row 2, col 2) ---
    fig.add_trace(build_stats_table(stats), row=2, col=2)

    # Total trace count (for dropdown bookkeeping): rapid + welds + cursor +
    # trail + 4 angle traces + table.
    total_traces = 1 + len(weld_traces) + 2 + 4 + 1

    dropdown = build_layer_dropdown(
        weld_traces, n_static_before=1, total_traces=total_traces,
    )

    # --- animation: frames + play/pause/reset + slider ---
    frames, play_menu, slider = build_animation(
        moves, trail_index, cursor_index, static_indices,
    )
    fig.frames = frames
    menus = [dropdown] + ([play_menu] if play_menu else [])
    sliders = [slider] if slider else []

    title = (
        f"NC Viewer — {part_name}  |  {n_layers} layers  "
        f"|  {n_points} points  |  {Path(source_file).name}"
    )

    fig.update_layout(
        title=dict(text=title, font=dict(color="white", size=18), x=0.5),
        autosize=True,
        paper_bgcolor=FIG_BG,
        plot_bgcolor=FIG_BG,
        font=dict(color="white"),
        updatemenus=menus,
        sliders=sliders,
        # Constant uirevision keeps the user's camera across frame redraws, so
        # the 3D scene can be rotated/zoomed/panned WHILE the animation plays.
        uirevision="keep",
        legend=dict(
            x=0.62, y=0.99, xanchor="right", yanchor="top",
            bgcolor="rgba(22,33,62,0.6)", font=dict(color="white", size=10),
        ),
        scene=dict(
            bgcolor=SCENE_BG,
            # Pinned axis ranges (full part bounding box) keep the view fixed
            # and fully framed — without them aspectmode="data" rescales the
            # scene as the animation hides/reveals traces, making the zoom jump.
            xaxis=dict(title="X (mm)", showgrid=True, gridcolor="#444",
                       color="white", range=x_range),
            yaxis=dict(title="Y (mm)", showgrid=True, gridcolor="#444",
                       color="white", range=y_range),
            zaxis=dict(title="Z (mm)", showgrid=True, gridcolor="#444",
                       color="white", range=z_range),
            aspectmode="data",
            camera=DEFAULT_CAMERA,
            uirevision="keep",
        ),
    )

    # Angle chart axis labels (top-right subplot is xaxis2/yaxis2).
    fig.update_xaxes(title_text="Move index", row=1, col=2,
                     color="white", gridcolor="#333")
    # Pin the y-range so the welding band fills the full chart height exactly.
    fig.update_yaxes(title_text="Angle (°)", row=1, col=2,
                     color="white", gridcolor="#333",
                     range=angle_yrange)

    return fig


def main(nc_path: str) -> None:
    """Parse an NC file and write the interactive viewer HTML.

    Inputs:
        nc_path -- path to the .nc file to visualise.
    Output:
        None. Writes ``<stem>_viewer.html`` (self-contained, plotly.js from
        CDN) next to where the script runs and prints "Saved: <filename>".
    """
    parsed = parse_nc(nc_path)
    fig = build_figure(parsed, nc_path)

    out_name = f"{Path(nc_path).stem}_viewer.html"
    pio.write_html(
        fig, file=out_name, include_plotlyjs="cdn", full_html=True,
        default_width="100%", default_height="100vh",
        config={"responsive": True},
    )
    print(f"Saved: {out_name}")


if __name__ == "__main__":
    import sys
    if len(sys.argv) < 2:
        print("Usage: python nc_viewer.py <path_to_nc_file>")
        sys.exit(1)
    main(sys.argv[1])
