"""QML-facing controller for the live/review wafer dashboard."""
from __future__ import annotations

import json
import logging
import time
from typing import Optional

from PySide6.QtCore import Property, QObject, Qt, QTimer, Signal, Slot

from pygui.backend.cluster_average import average_clusters, group_sensors_by_radius
from pygui.backend.data.csv_recorder import CsvRecorder
from pygui.backend.models.frame import Frame
from pygui.backend.models.frame_player import (
    FramePlayer,
    all_sensor_series,
    frames_from_wafer_data,
)
from pygui.backend.models.frame_stats import compute_edge_center
from pygui.backend.models.sensor_editor import SensorEditor
from pygui.backend.models.session_model import SessionModel, SessionUpdate
from pygui.backend.models.threshold_classifier import ThresholdConfig
from pygui.backend.utils import slot_error_boundary
from pygui.backend.wafer.family_spec import sensor_count_for
from pygui.backend.wafer.wafer_layouts import ec_pairs_for_wafer_id
from pygui.backend.wafer.zwafer_models import Sensor
from pygui.backend.wafer.zwafer_parser import ZWaferParser
from pygui.serialcomm.stream_reader import StreamReader

log = logging.getLogger(__name__)

MODE_LIVE = "live"
MODE_REVIEW = "review"


class SessionController(QObject):
    # public signals
    frameUpdated = Signal()
    modeChanged = Signal()
    stateChanged = Signal()
    recordingChanged = Signal()
    sensorsChanged = Signal()
    loadedFileChanged = Signal()
    loadFileError = Signal(str)
    clusterAveragingEnabledChanged = Signal()
    liveStatsChanged = Signal()  # emitted when receivedCount or errorCount change
    liveError = Signal(str)  # fatal live-stream error message, for Activity Log
    # trend: per-frame avg for live graph
    trendDelta = Signal(str)  # JSON [[elapsed_s, avg]] — one new point per live frame
    trendReset = Signal()  # live trend buffer cleared (new stream started)
    # NOTE: dict (QVariantMap), not object — Signal(object) delivers Python
    # dicts to QML as an opaque empty wrapper with no accessible fields.
    comparisonResult = Signal(dict)  # dict with success, distance, warping_path, max_sensor_deviation, series_a, series_b
    splitResult = Signal(dict)  # dict with success, segments
    segmentExported = Signal(dict)  # dict with success, path | error
    # private: marshal a worker-thread frame onto the main thread
    _liveFrame = Signal(object)    # Frame
    _liveError = Signal(str)       # worker-thread error ping, carries exception text

    def __init__(self, parent: QObject | None = None) -> None:
        super().__init__(parent)
        self._mode = MODE_REVIEW
        self._model = SessionModel()
        self._player = FramePlayer()
        self._reader: Optional[StreamReader] = None
        self._recorder = CsvRecorder()
        self._sensors: list[Sensor] = []
        self._last: Optional[SessionUpdate] = None
        self._elapsed = 0.0
        self._stream_start_time: float = 0.0

        self._play_timer = QTimer(self)
        self._play_timer.timeout.connect(self._advance)
        self._speed = 1.0

        # Q1: coalesce live repaints to ~20 Hz; data is still processed every frame.
        self._repaint_timer = QTimer(self)
        self._repaint_timer.setInterval(50)            # ~20 Hz
        self._repaint_timer.timeout.connect(self._flush_repaint)
        self._dirty = False

        self._liveFrame.connect(self._on_live_frame, Qt.ConnectionType.QueuedConnection)
        self._liveError.connect(self._on_live_error, Qt.ConnectionType.QueuedConnection)

        self._sensor_editor = SensorEditor()
        self._ec_pairs: tuple[tuple[int, int], ...] = ()
        self._last_raw_frame: Frame | None = None
        self._loaded_file: str = ""
        self._cluster_averaging_enabled = False
        self._active_clusters: list[list[int]] = []
        self._received_count: int = 0
        self._error_count: int = 0

        # Comparison cache for dynamic overlap mapping
        self._compare_recs_a: list = []
        self._compare_recs_b: list = []
        self._compare_alignment_map: dict[int, int] = {}

    # ---- properties QML binds to ----
    @Property(str, notify=modeChanged)
    def mode(self) -> str: return self._mode

    @Property(int, notify=frameUpdated)
    def frameIndex(self) -> int: return self._player.index

    @Property(int, notify=frameUpdated)
    def frameTotal(self) -> int: return self._player.total

    @Property(bool, notify=stateChanged)
    def playing(self) -> bool: return self._play_timer.isActive()

    @Property(str, notify=stateChanged)
    def state(self) -> str:
        if self._last:
            return self._last.state
        # IF we dont have data yet, but the reader is running -> Streaming
        if self._mode == MODE_LIVE and self._reader is not None:
            return "streaming"

        return "idle"

    @Property(bool, notify=recordingChanged)
    def recording(self) -> bool: return self._recorder.is_recording

    @Property(list, notify=frameUpdated)
    def sensorDots(self) -> list[dict]:
        """Per-sensor render data for the radial map."""
        if not self._last:
            return []
        out = []
        for i, s in enumerate(self._sensors):
            v = self._last.values[i] if i < len(self._last.values) else 0.0
            band = self._last.bands[i] if i < len(self._last.bands) else "in_range"
            out.append({"label": s.label, "x": s.x, "y": s.y,
                        "value": round(v, 2), "band": band, "index": i})
        return out

    @Property("QVariantList", notify=sensorsChanged)  # type: ignore[arg-type]
    def sensorLayout(self) -> list:
        """[{label, x, y, side, offset_x, offset_y}] for WaferMapItem.sensors."""
        return [
            {
                "label": s.label,
                "x": s.x,
                "y": s.y,
                "side": getattr(s, "side", "right"),
                "offset_x": getattr(s, "offset_x", 0.0),
                "offset_y": getattr(s, "offset_y", 0.0),
            }
            for s in self._sensors
        ]

    @Property(str, notify=sensorsChanged)
    def waferShape(self) -> str:
        """Wafer shape: 'round' or 'square'."""
        return getattr(self._sensors, "shape", "round")

    @Property(float, notify=sensorsChanged)
    def waferSize(self) -> float:
        """Wafer size in mm."""
        return getattr(self._sensors, "size", 300.0)

    @Property("QVariantList", notify=frameUpdated)  # type: ignore[arg-type]
    def sensorValues(self) -> list:
        """[float] in sensor order."""
        if not self._last:
            return []
        return [round(v, 3) for v in self._last.values]

    @Property("QVariantList", notify=frameUpdated)  # type: ignore[arg-type]
    def sensorBands(self) -> list:
        """['in_range'|'high'|'low'] in sensor order."""
        if not self._last:
            return []
        return list(self._last.bands)

    @Property(float, notify=frameUpdated)
    def target(self) -> float:
        """Resolved band center (frame mean in auto mode, else set_point)."""
        return self._last.target if self._last else 149.0

    @Property(float, notify=frameUpdated)
    def margin(self) -> float:
        """Resolved band half-width (frame 1σ in auto mode, else margin)."""
        return self._last.margin if self._last else 1.0

    @Property(bool, notify=frameUpdated)
    def thresholdAuto(self) -> bool:
        """Whether the current band is auto-derived (frame mean ± 1σ) or manual."""
        return self._model.auto

    @Property(dict, notify=frameUpdated)
    def stats(self) -> dict:
        if not self._last:
            return {}
        s = self._last.stats
        out = {"min": round(s.min, 2), "minIndex": s.min_index + 1,
               "max": round(s.max, 2), "maxIndex": s.max_index + 1,
               "diff": round(s.diff, 2), "avg": round(s.avg, 2),
               "sigma": round(s.sigma, 2), "threeSigma": round(s.three_sigma, 2)}
        ec = compute_edge_center(self._last.values, self._ec_pairs,
                                 self._sensor_editor.replaced_indices())
        if ec is not None:
            out.update({
                "ecMinEdgeIndex": ec.min_edge_index + 1,
                "ecMinCenterIndex": ec.min_center_index + 1,
                "ecMinEdgeValue": round(ec.min_edge, 2),
                "ecMinCenterValue": round(ec.min_center, 2),
                "ecMinDelta": round(ec.min_delta, 2),
                "ecMaxEdgeIndex": ec.max_edge_index + 1,
                "ecMaxCenterIndex": ec.max_center_index + 1,
                "ecMaxEdgeValue": round(ec.max_edge, 2),
                "ecMaxCenterValue": round(ec.max_center, 2),
                "ecMaxDelta": round(ec.max_delta, 2),
            })
        return out

    @Property(str, notify=loadedFileChanged)
    def loadedFile(self) -> str: return self._loaded_file

    @Property(str, notify=loadedFileChanged)
    def graphSensorNames(self) -> str:
        """Comma-joined sensor names for the whole-run Graph tab."""
        names, _ = all_sensor_series(list(self._player.frames))
        return ",".join(names)

    @Property(str, notify=loadedFileChanged)
    def graphSeriesJson(self) -> str:
        """JSON per-sensor value series (one list per sensor) for the Graph tab."""
        _, series = all_sensor_series(list(self._player.frames))
        return json.dumps(series)

    @Property("QVariantList", notify=frameUpdated)  # type: ignore[arg-type]
    def overriddenSensors(self) -> list[int]:
        """Indices of sensors that currently have a replacement or offset."""
        return self._sensor_editor.active_indices()

    @Property(bool, notify=clusterAveragingEnabledChanged)
    def clusterAveragingEnabled(self) -> bool:
        return self._cluster_averaging_enabled

    @clusterAveragingEnabled.setter  # type: ignore[no-redef]
    def clusterAveragingEnabled(self, value: bool) -> None:
        if self._cluster_averaging_enabled == value:
            return
        self._cluster_averaging_enabled = value
        self.clusterAveragingEnabledChanged.emit()
        self._reprocess_current()

    @Property(int, notify=liveStatsChanged)
    def receivedCount(self) -> int:
        return self._received_count

    @Property(int, notify=liveStatsChanged)
    def errorCount(self) -> int:
        return self._error_count

    @Property(int, notify=liveStatsChanged)
    def resyncCount(self) -> int:
        return self._reader.resync_count if self._reader else 0

    # ---- mode + thresholds ----
    @Slot(str)
    @slot_error_boundary
    def setMode(self, mode: str) -> None:
        if mode == self._mode:
            return
        if mode != "live":
            self.stopStream()
            if self._mode == "live":
                # Leaving a live session: blank the wafer map instead of
                # freezing the last streamed frame on screen.
                self._last = None
                self._last_raw_frame = None
                self.frameUpdated.emit()
        self._play_timer.stop()
        self._mode = mode
        self.modeChanged.emit()

    @Slot(float, float, bool)
    @slot_error_boundary
    def setThresholds(self, set_point: float, margin: float, auto: bool) -> None:
        self._model.set_thresholds(ThresholdConfig(set_point, margin, auto))
        if self._last:                     # re-band current frame in place
            self._reprocess_current()

    # ---- review: file load + playback ----
    @Slot(str)
    @slot_error_boundary
    def loadFile(self, file_path: str) -> None:
        from pathlib import Path

        from pygui.backend.data.data_records import (
            is_official_csv,
            read_data_records,
            read_official_csv,
        )
        from pygui.backend.wafer.wafer_layouts import WaferLayout, resolve_shape_and_size

        frames = []

        try:
            if is_official_csv(file_path):
                sensors, records = read_official_csv(file_path)
                frames = frames_from_wafer_data(None, records)
            else:
                data, _ = ZWaferParser().parse(file_path)
                if data is None or not data.sensors:
                    self.loadFileError.emit("Invalid layout or missing sensors in custom CSV.")
                    return
                records = read_data_records(file_path)
                sensors = data.sensors
                frames = frames_from_wafer_data(data, records)

            if not sensors or not frames:
                self.loadFileError.emit("No sensors or frames found in data file.")
                return
        except Exception as exc:
            log.warning("Could not parse file %s: %s", file_path, exc)
            self.loadFileError.emit(f"Load error: {exc}")
            return

        wafer_id = ""
        if not is_official_csv(file_path):
            wafer_id = data.serial if (data and data.serial) else ""
        else:
            stem = Path(file_path).stem
            wafer_id = stem.split("-")[0] if "-" in stem else stem

        shape, size = resolve_shape_and_size(sensors, wafer_id)
        self._ec_pairs = ec_pairs_for_wafer_id(wafer_id)
        self._sensors = WaferLayout(sensors, shape=shape, size=size)
        self._active_clusters = getattr(self._sensors, 'clusters', [])
        if not self._active_clusters:
            self._active_clusters = group_sensors_by_radius(self._sensors)
        self._player.load(frames)
        self._model.reset()
        self._loaded_file = file_path
        self.loadedFileChanged.emit()
        self.sensorsChanged.emit()
        self._emit_current()

    @Slot()
    @slot_error_boundary
    def unloadFile(self) -> None:
        """Clear the loaded file, resetting the player and frame states."""
        self._player.load([])
        self._model.reset()
        self._ec_pairs = ()
        self._loaded_file = ""
        self.loadedFileChanged.emit()
        self.sensorsChanged.emit()

    # ---- comparison: DTW between two CSV files ----
    @Slot(str, str)
    @slot_error_boundary
    def compareFiles(self, file_a: str, file_b: str) -> None:
        """Run DTW comparison between two CSV files and emit result."""
        from pathlib import Path

        from pygui.backend.comparison import compare_runs
        from pygui.backend.data.data_records import is_official_csv, read_data_records, read_official_csv

        if Path(file_a).resolve() == Path(file_b).resolve():
            self.comparisonResult.emit({
                "success": False,
                "error": "Cannot compare a file to itself. Choose two different runs."
            })
            return

        try:
            if is_official_csv(file_a):
                _, recs_a = read_official_csv(file_a)
            else:
                recs_a = read_data_records(file_a)

            if is_official_csv(file_b):
                _, recs_b = read_official_csv(file_b)
            else:
                recs_b = read_data_records(file_b)
            if not recs_a or not recs_b:
                self.comparisonResult.emit({
                    "success": False,
                    "error": "No data in one or both files"
                })
                return

            self._compare_recs_a = recs_a
            self._compare_recs_b = recs_b

            len_a, len_b = len(recs_a), len(recs_b)
            # DTW can only align frames both runs actually have; the longer
            # run's extra tail is still returned for display, just unaligned.
            overlap_frames = min(len_a, len_b)
            series_a = [recs_a[i].values[0] if recs_a[i].values else 0.0 for i in range(len_a)]
            series_b = [recs_b[i].values[0] if recs_b[i].values else 0.0 for i in range(len_b)]
            time_a = [round(recs_a[i].time, 3) for i in range(len_a)]
            time_b = [round(recs_b[i].time, 3) for i in range(len_b)]

            result = compare_runs(series_a[:overlap_frames], series_b[:overlap_frames])
            self._compare_alignment_map = {i: j for i, j in result["warping_path"]}

            # Mean temporal shift along the DTW path: positive means run B
            # reaches the same profile features later than run A.
            path = result["warping_path"]
            frame_offset = round(sum(j - i for i, j in path) / len(path)) if path else 0
            frame_offset_seconds = round(
                sum(time_b[j] - time_a[i] for i, j in path) / len(path), 2
            ) if path else 0.0

            # Max sensor deviation: walk the DTW-aligned frame pairs (bounded to
            # the overlap both runs share) and take the largest per-sensor abs
            # difference across all sensors, not just the sensor[0] series used
            # for alignment.
            num_sensors = min(len(recs_a[0].values), len(recs_b[0].values)) if recs_a[0].values and recs_b[0].values else 0
            max_sensor_deviation = 0.0
            for i, j in zip(result["index_a"], result["index_b"]):
                values_a = recs_a[i].values
                values_b = recs_b[j].values
                for s in range(min(num_sensors, len(values_a), len(values_b))):
                    diff = abs(values_a[s] - values_b[s])
                    if diff > max_sensor_deviation:
                        max_sensor_deviation = diff

            # Sensor layout + per-sensor diff for the wafer overlap view. Reuses the
            # same sensor-parsing branch as loadFile() (file_a is assumed representative
            # of both files' wafer type).
            from pygui.backend.wafer.wafer_layouts import resolve_shape_and_size
            from pygui.backend.wafer.zwafer_parser import ZWaferParser

            sensor_layout: list[dict] = []
            sensor_diff: list[float] = []
            wafer_shape = "round"
            wafer_size = 300.0
            try:
                if is_official_csv(file_a):
                    sensors, _ = read_official_csv(file_a)
                    stem = Path(file_a).stem
                    wafer_id = stem.split("-")[0] if "-" in stem else stem
                else:
                    parsed, _ = ZWaferParser().parse(file_a)
                    sensors = parsed.sensors if parsed else []
                    wafer_id = parsed.serial if (parsed and parsed.serial) else ""

                if sensors:
                    wafer_shape, wafer_size = resolve_shape_and_size(sensors, wafer_id)
                    last_a = recs_a[overlap_frames - 1].values
                    last_b = recs_b[overlap_frames - 1].values
                    n = min(len(sensors), len(last_a), len(last_b))
                    sensor_layout = [
                        {
                            "label": s.label,
                            "x": s.x,
                            "y": s.y,
                            "side": getattr(s, "side", "right"),
                            "offset_x": getattr(s, "offset_x", 0.0),
                            "offset_y": getattr(s, "offset_y", 0.0),
                        }
                        for s in sensors[:n]
                    ]
                    sensor_diff = [round(last_b[i] - last_a[i], 3) for i in range(n)]
            except Exception as layout_exc:
                log.warning("Could not resolve sensor layout for overlap view: %s", layout_exc)

            self.comparisonResult.emit({
                "success": True,
                "distance": result["distance"],
                # Lists, not tuples — tuples don't survive QVariant conversion
                "warping_path": [list(p) for p in result["warping_path"][:50]],
                "frame_offset": frame_offset,
                "frame_offset_seconds": frame_offset_seconds,
                "max_sensor_deviation": max_sensor_deviation,
                "series_a": series_a,
                "series_b": series_b,
                "time_a": time_a,
                "time_b": time_b,
                "frame_count_a": len_a,
                "frame_count_b": len_b,
                "sensor_layout": sensor_layout,
                "sensor_diff": sensor_diff,
                "wafer_shape": wafer_shape,
                "wafer_size": wafer_size,
            })
        except Exception as exc:
            log.warning("Comparison failed: %s", exc)
            self.comparisonResult.emit({
                "success": False,
                "error": str(exc)
            })

    @Slot(int, result="QVariantList")
    def getSensorDiffAt(self, scrub_idx: int) -> list[float]:
        """Return the per-sensor temp diff (Run B - Run A) at the given scrub frame index of Run A, using DTW alignment."""
        if not self._compare_recs_a or not self._compare_recs_b:
            return []

        idx_a = min(max(0, scrub_idx), len(self._compare_recs_a) - 1)
        if idx_a not in self._compare_alignment_map:
            # DTW path only covers indices up to the shorter run's length —
            # past that, Run B has no data at this frame, not a clampable one.
            return []
        idx_b = self._compare_alignment_map[idx_a]

        values_a = self._compare_recs_a[idx_a].values
        values_b = self._compare_recs_b[idx_b].values

        n = min(len(values_a), len(values_b))
        return [round(values_b[i] - values_a[i], 3) for i in range(n)]

    # ---- splitting: threshold-based segmentation ----
    @Slot(str, float)
    @slot_error_boundary
    def splitData(self, file_path: str, threshold: float) -> None:
        """Segment temperature profile into Ramp/Soak/Cool phases."""
        from pygui.backend.data.data_records import is_official_csv, read_data_records, read_official_csv
        from pygui.backend.splitter import segment_profile

        try:
            if is_official_csv(file_path):
                _, records = read_official_csv(file_path)
            else:
                records = read_data_records(file_path)
            if not records:
                self.splitResult.emit({
                    "success": False,
                    "error": "No data in file"
                })
                return

            # Extract average temperatures (use first sensor as proxy)
            avg_temps = [rec.values[0] if rec.values else 0.0 for rec in records]
            segments = segment_profile(avg_temps, threshold)

            # cache for exportSegment (per-segment Export in SplitDialog)
            self._last_split_file = file_path
            self._last_split_segments = segments

            self.splitResult.emit({
                "success": True,
                "segments": [{
                    "label": seg.label,
                    "start_frame": seg.start_frame,
                    "end_frame": seg.end_frame,
                    "avg_temp": seg.avg_temp
                } for seg in segments]
            })
        except Exception as exc:
            log.warning("Split failed: %s", exc)
            self.splitResult.emit({
                "success": False,
                "error": str(exc)
            })

    @Slot(int)
    @slot_error_boundary
    def exportSegment(self, index: int) -> None:
        """Write one segment from the last split as a standalone CSV.

        Output lands next to the source file as
        ``<stem>_<label><index>.csv`` — the stem keeps its wafer-id prefix
        so read_official_csv still resolves the layout on re-import.
        Emits segmentExported with success + path (or error).
        """
        from pathlib import Path

        from pygui.backend.data.csv_recorder import CsvRecorder

        source = getattr(self, "_last_split_file", "")
        segments = getattr(self, "_last_split_segments", [])
        if not source or not (0 <= index < len(segments)):
            self.segmentExported.emit({
                "success": False,
                "error": "No split result to export"
            })
            return

        seg = segments[index]
        src = Path(source)
        dest = src.with_name(f"{src.stem}_{seg.label.lower()}{index + 1}.csv")
        try:
            ok = CsvRecorder.write_segment(
                str(dest), str(src), seg.start_frame, seg.end_frame)
        except Exception as exc:
            log.warning("Segment export failed: %s", exc)
            self.segmentExported.emit({"success": False, "error": str(exc)})
            return

        if ok:
            self.segmentExported.emit({"success": True, "path": str(dest)})
        else:
            self.segmentExported.emit({
                "success": False,
                "error": "No frames in range"
            })

    @Slot()
    @slot_error_boundary
    def play(self) -> None:
        self._play_timer.start(self._next_interval_ms())
        self.stateChanged.emit()

    @Slot()
    @slot_error_boundary
    def pause(self) -> None:
        self._play_timer.stop()
        self.stateChanged.emit()

    @Slot()
    @slot_error_boundary
    def stop(self) -> None:
        self._play_timer.stop()
        self._player.seek(0)
        self._emit_current()
        self.stateChanged.emit()

    @Slot(int)
    @slot_error_boundary
    def step(self, delta: int) -> None:
        self._play_timer.stop()
        self._player.step(delta)
        self._emit_current()
        self.stateChanged.emit()

    @Slot(int)
    @slot_error_boundary
    def seek(self, i: int) -> None:
        self._player.seek(i)
        self._emit_current()

    @Slot(float)
    @slot_error_boundary
    def setSpeed(self, x: float) -> None:
        self._speed = max(0.1, x)
        if self._play_timer.isActive():
            self._play_timer.start(self._next_interval_ms())

    def _next_interval_ms(self) -> int:
        """Timer interval in ms for the next frame, scaled by playback speed. Falls back to 500ms."""
        ms = self._player.next_frame_ms()
        if ms <= 0:
            ms = 500.0
        return max(1, int(ms / self._speed))

    def _advance(self) -> None:
        if self._player.at_end:
            self._play_timer.stop()
            self.stateChanged.emit()
            return
        self._player.step(1)
        self._emit_current()
        self._play_timer.start(self._next_interval_ms())

    def _apply_pipeline(self, raw_values: list[float]) -> list[float]:
        """Apply override first, then optional cluster averaging."""
        edited = self._sensor_editor.apply(raw_values)
        if getattr(self, "_cluster_averaging_enabled", False):
            return average_clusters(edited, getattr(self, "_active_clusters", []))
        return edited

    def _emit_current(self) -> None:
        frame = self._player.current()
        if frame is None:
            return
        self._last_raw_frame = frame
        edited = Frame(seq=frame.seq, time=frame.time,
                    values=self._apply_pipeline(frame.values))
        self._last = self._model.process(edited)
        self.frameUpdated.emit()
        self.stateChanged.emit()

    def _reprocess_current(self) -> None:
        """Re-run the model on the last raw frame"""
        frame = self._last_raw_frame
        if frame is None:
            return
        edited = Frame(seq=frame.seq, time=frame.time,
                    values=self._apply_pipeline(frame.values))
        self._last = self._model.process(edited)
        self.frameUpdated.emit()
        self.stateChanged.emit()

    # ---- live: stream start/stop ----
    @Slot(str, str)
    @slot_error_boundary
    def startStream(self, port: str, family_code: str = "") -> None:
        if self._reader is not None:
            log.warning("startStream: StreamReader is already running.")
            return

        # C# parity (Form1.cs btnConnect_Click: "currently only X wafers
        # support streaming"). Other families' firmware has no live-stream
        # protocol — a D2 send on family A produced a burst of unrelated
        # ASCII output for ~10s then went quiet, not real sensor telemetry.
        if family_code and family_code != "X":
            message = f"Live stream is only supported on X-family wafers (detected {family_code})"
            log.warning(message)
            self.liveError.emit(message)
            return

        from pygui.backend.wafer.wafer_layouts import load_layout_for_wafer_id
        from pygui.serialcomm.serial_port import SerialPort  # transport open

        if family_code:
            try:
                self._sensors = load_layout_for_wafer_id(family_code)
                self.sensorsChanged.emit()
            except Exception as e:
                log.warning("Could not load layout for %s: %s", family_code, e)
        self._ec_pairs = ec_pairs_for_wafer_id(family_code)

        self._active_clusters = getattr(self._sensors, 'clusters', [])
        if not self._active_clusters:
            self._active_clusters = group_sensors_by_radius(self._sensors)

        # The binary protocol's payload carries a fixed-size channel array
        # (sensorCount * 2 bytes, big-endian Sign-Magnitude each) that's wider
        # than any one wafer family's real sensor count — trailing channels
        # are unpopulated hardware slots, not real readings. Bound decoding to
        # the just-loaded layout's actual sensor count so those float/noise
        # channels never reach values (and therefore never reach MIN/MAX/AVG
        # in compute_stats, which has no sensor-count knowledge of its own).
        # Fall back to the family YAML lookup only if no layout loaded yet
        # (e.g. startStream called with an unknown family_code).
        expected_sensors = len(self._sensors) or sensor_count_for(family_code or "")

        def parse_binary_frame(payload: bytes, seq: int) -> Frame:
            values = []
            num_sensors = min(expected_sensors, len(payload) // 2)
            for i in range(num_sensors):
                high_byte = payload[i * 2]
                low_byte = payload[i * 2 + 1]
                val16 = (high_byte << 8) | low_byte

                is_negative = (val16 & 0x8000) != 0
                integer_part = (val16 >> 7) & 0xFF
                fractional_bits = val16 & 0x7F
                fractional_part = fractional_bits / 128.0
                result = integer_part + fractional_part
                if is_negative:
                    result = -result
                values.append(result)

            return Frame(seq=seq, time=time.monotonic(), values=values)

        # Clear out any old data from the prev sessions
        self._model.reset()
        self._reset_live_trend()
        self._last = None
        self._last_raw_frame = None
        self._received_count = 0
        self._error_count = 0
        self.liveStatsChanged.emit()

        transport = SerialPort.open_port(port, timeout=1)
        # Discard anything left over from a prior session (e.g. the device
        # kept transmitting after our last D2S landed) — stale bytes here
        # misalign the very first packet, corrupting every reading in it.
        try:
            transport.reset_input_buffer()
        except Exception as exc:
            log.warning("Could not reset input buffer before start: %s", exc)
        # Send 'D2' command padded to 512 bytes to start the stream
        cmd = b"D2" + SerialPort.COMMAND_PAD.encode()
        transport.write(cmd)

        import weakref
        weak_self = weakref.ref(self)

        def on_error(exc: Exception):
            log.error("Live stream error: %s", exc)
            ref = weak_self()
            if ref is not None:
                # Marshal onto the main thread via the queued signal — this
                # callback runs on StreamReader's own worker thread, and
                # stopStream() joins that same thread, so calling it directly
                # here would deadlock/raise ("cannot join current thread").
                ref._liveError.emit(str(exc))

        def on_frame(frame: Frame):
            ref = weak_self()
            if ref is not None:
                ref._liveFrame.emit(frame)

        self._reader = StreamReader(
            transport, parse_binary_frame,
            on_frame=on_frame,
            on_error=on_error,
            family_code=family_code or "A")
        self._reader.start()
        self._repaint_timer.start()

        self.setMode("live")
        self.stateChanged.emit()

    @Slot()
    @slot_error_boundary
    def stopStream(self) -> None:
        self._repaint_timer.stop()
        self._received_count = 0
        self._error_count = 0
        self.liveStatsChanged.emit()
        if self._reader:
            transport = self._reader._transport
            reader_thread = self._reader._thread
            # If the worker thread already exited on its own (e.g. a fatal
            # stream error), it already closed the transport in its finally
            # block — writing here races that close and raises EBADF. Nothing
            # is listening for D2S anyway once the reader has given up.
            reader_still_running = reader_thread is not None and reader_thread.is_alive()

            # Send 'D2S' command padded to 512 bytes to stop the stream
            # Send BEFORE stopping the reader which will close the port on thread exit
            if reader_still_running and transport and transport.is_open:
                try:
                    cmd = b"D2S" + (b"F" * 509)
                    transport.write(cmd)
                    transport.flush()
                    # Device may keep transmitting a few more bytes after D2S
                    # lands — discard them so they don't survive into the
                    # next Live session and misalign its first packet.
                    transport.reset_input_buffer()
                except Exception as exc:
                    log.error("Error sending stop command: %s", exc)

            self._reader.stop()
            self._reader = None

        self.stateChanged.emit()
        self.stopRecording()

    @Slot(object)
    @slot_error_boundary
    def _on_live_frame(self, frame: Frame) -> None:
        self._received_count += 1
        self.liveStatsChanged.emit()
        # Main thread (queued). Record raw, process edited; mark dirty for repaint.
        self._last_raw_frame = frame
        edited = Frame(seq=frame.seq, time=frame.time,
                    values=self._apply_pipeline(frame.values))
        self._last = self._model.process(edited)
        if self._recorder.is_recording:
            self._recorder.write(frame)   # always record raw values, never edited
        self._dirty = True

        if self._last and self._last.stats:
            avg = self._last.stats.avg
            elapsed = time.monotonic() - self._stream_start_time
            self.trendDelta.emit(json.dumps([[elapsed, avg]]))

    def _flush_repaint(self) -> None:
        if not self._dirty:
            return
        self._dirty = False
        self.frameUpdated.emit()
        self.stateChanged.emit()

    def _on_live_error(self, message: str) -> None:
        """Main-thread callback for worker-thread stream errors. StreamReader
        only calls its on_error for resync exhaustion or an unhandled parse
        exception — both unrecoverable for the current stream — so this ends
        the session and surfaces the reason (Activity Log wiring in QML)."""
        self._error_count += 1
        self.liveStatsChanged.emit()
        self.liveError.emit(message)
        self.stopStream()

    # ---- recording ----
    @Slot(str, str)
    @slot_error_boundary
    def startRecording(self, path: str, serial: str = "") -> None:
        self._recorder.start(path, self._sensors, serial)
        self.recordingChanged.emit()

    @Slot()
    @slot_error_boundary
    def stopRecording(self) -> None:
        if self._recorder.is_recording:
            self._recorder.stop()
            self.recordingChanged.emit()

    @Slot(int, float)
    @slot_error_boundary
    def replaceSensor(self, index: int, value: float) -> None:
        """Override sensor `index` to display `value` every frame."""
        self._sensor_editor.set_replacement(index, value)
        self._reprocess_current()

    @Slot(int, float)
    @slot_error_boundary
    def offsetSensor(self, index: int, delta: float) -> None:
        """Shift sensor `index` by `delta` every frame."""
        self._sensor_editor.set_offset(index, delta)
        self._reprocess_current()

    @Slot(int)
    @slot_error_boundary
    def clearSensorEdit(self, index: int) -> None:
        """Remove all overrides for sensor `index`."""
        self._sensor_editor.clear(index)
        self._reprocess_current()

    @Slot()
    @slot_error_boundary
    def clearSensorEdits(self) -> None:
        """Remove all sensor overrides."""
        self._sensor_editor.clear()
        self._reprocess_current()

    def _reset_live_trend(self) -> None:
        """Mark a fresh stream start for elapsed-time trend deltas."""
        self._stream_start_time = time.monotonic()
        self.trendReset.emit()

