"""QQuickPaintedItem wafer map — ported from the replay app's ReplayWidget.

Draws:
  • Radial ring template (concentric guides + crosshair axes + top notch)
  • RBF heatmap layer (blended under markers via `blend` 0→1)
  • Sensor marker circles colored by band (low/in_range/high)
  • Numbered labels (toggle via `showLabels`)

All sensor coordinates are center-origin mm (from wafer_layouts or a loaded CSV).
"""
from __future__ import annotations

import logging
import math

import numpy as np
from PySide6.QtCore import Property, QPoint, QRectF, Qt, Signal, Slot
from PySide6.QtGui import (
    QBrush,
    QColor,
    QFont,
    QImage,
    QPainter,
    QPen,
    QPolygon,
)  # fmt: skip
from PySide6.QtQml import QmlElement
from PySide6.QtQuick import QQuickPaintedItem

from pygui.backend.models.frame_stats import compute_stats
from pygui.backend.visualization.radial_metrics import ring_boundaries
from pygui.backend.visualization.rbf_heatmap import (
    ellipse_alpha,
    interpolate_field,
    refine_field,
)
from pygui.backend.wafer.zwafer_models import Sensor

log = logging.getLogger(__name__)


def parse_thickness_csv(file_path: str) -> tuple[list[list[float]], str]:
    """Parse a customer thickness CSV into [x, y, t2] points (mm from center).

    Expected columns (C# parity): Lot Start Time, RC, Site#, Slot#, T2,
    NGOF, Wafer X, Wafer Y. Only the first wafer (first Slot# value) is read.
    Returns (points, error) — error is "" on success.
    """
    import csv

    points: list[list[float]] = []
    slot: str | None = None
    try:
        with open(file_path, newline="") as fh:
            reader = csv.reader(fh)
            header = next(reader, None)
            if header is None:
                return [], "Unable to read header row from CSV file."
            if header and header[-1] == "":  # trailing comma
                header = header[:-1]
            nfields = len(header)
            if nfields != 8:
                return [], "Incorrect field count in CSV header row."
            for lno, row in enumerate(reader, start=2):
                if row and row[-1] == "":
                    row = row[:-1]
                if len(row) != nfields:
                    return [], f"Incorrect field count in CSV row {lno}"
                if slot is None:
                    slot = row[3]
                elif row[3] != slot:
                    break
                try:
                    t2, x, y = float(row[4]), float(row[6]), float(row[7])
                except ValueError:
                    return [], f"Malformed value in CSV row {lno}"
                points.append([x, y, t2])
    except OSError as exc:
        return [], f"Unable to read CSV file: {exc}"
    if not points:
        return [], "No data found in CSV file."
    return points, ""


def readout_text(values: list[float]) -> str:
    """Full READOUT stats line for the export footer; '' when no data."""
    if not values:
        return ""
    s = compute_stats(values)
    return (f"Sensors: {len(values)}   Min: {s.min:.2f}°C (#{s.min_index + 1})   "
            f"Max: {s.max:.2f}°C (#{s.max_index + 1})   Diff: {s.diff:.2f}   "
            f"Avg: {s.avg:.2f}   σ: {s.sigma:.2f}   3σ: {s.three_sigma:.2f}")

QML_IMPORT_NAME = "ISC.Wafer"
QML_IMPORT_MAJOR_VERSION = 1


@QmlElement
class WaferMapItem(QQuickPaintedItem):
    """Painted wafer map; driven by SessionController via QML property bindings."""

    sensorsChanged  = Signal()
    valuesChanged   = Signal()
    bandsChanged    = Signal()
    targetChanged   = Signal()
    marginChanged   = Signal()
    blendChanged    = Signal()
    showLabelsChanged = Signal()
    showExtremesChanged = Signal()
    colorsChanged   = Signal()
    shapeChanged    = Signal()
    sizeChanged     = Signal()
    thicknessChanged = Signal()
    showThicknessChanged = Signal()
    hoveredIndexChanged = Signal()
    hoverScaleChanged = Signal()


    def __init__(self, parent=None):
        super().__init__(parent)
        self._sensors:  list[Sensor] = []
        self._values:   list[float]  = []
        self._bands:    list[str]    = []
        self._target:   float = 149.0
        self._margin:   float = 1.0
        self._blend:    float = 0.0
        self._show_labels: bool = True
        self._show_extremes: bool = True
        self._shape: str = "round"
        self._size: float = 300.0
        self._thickness_data: list[list[float]] = []  # [x_mm, y_mm, t2] triples
        self._show_thickness: bool = False
        self._thickness_heatmap:QImage | None = None
        # Hover highlight: index of the marker under the pointer (-1 = none)
        # and a 1.0->~1.5 grow factor driven by a QML Behavior for a smooth animation.
        self._hovered_index: int = -1
        self._hover_scale: float = 1.0

        # Dark-theme color defaults (match Theme.qml tokens)
        self._ring_color     = QColor("#2A3441")   # waferRingColor  (toneBorder)
        self._axis_color     = QColor("#3A4D5C")   # waferAxisColor  (softBorder)
        self._low_color      = QColor("#5B9DF5")   # sensorLow
        self._in_range_color = QColor("#22C55E")   # sensorInRange
        self._high_color     = QColor("#EF4444")   # sensorHigh
        self._text_color     = QColor("#CBD5E1")   # bodyColor

        # Internal draw state
        self._markers: dict[int, tuple[int, int]] = {}  # sensor index → (px, py)
        self._marker_r: int = 4
        self._heatmap:  QImage | None = None
        # Size the markers/heatmap were last computed against. On first activation
        # (e.g. a freshly-created Loader item) width()/height() can still be 0/stale
        # when `sensors` is first set, computing degenerate marker positions that
        # never get retried. paint() re-checks this every frame so geometry that
        # settles after property assignment still gets picked up.
        self._last_draw_size: int = -1

        self.widthChanged.connect(self._on_resize)
        self.heightChanged.connect(self._on_resize)

    # ── Qt properties ────────────────────────────────────────────────────

    @Property("QVariantList", notify=sensorsChanged)
    def sensors(self) -> list:
        return [{"label": s.label, "x": s.x, "y": s.y} for s in self._sensors]

    @sensors.setter  # type: ignore[no-redef]
    def sensors(self, val: list) -> None:
        self._sensors = [
            Sensor(
                label=d["label"],
                x=float(d["x"]),
                y=float(d["y"]),
                side=d.get("side", "right"),
                offset_x=float(d.get("offset_x", 0.0)),
                offset_y=float(d.get("offset_y", 0.0))
            )
            for d in (val or [])
        ]
        self._rebuild()
        self.sensorsChanged.emit()

    @Property("QVariantList", notify=valuesChanged)
    def values(self) -> list:
        return self._values

    @values.setter  # type: ignore[no-redef]
    def values(self, val: list) -> None:
        self._values = list(val or [])
        self._rebuild_heatmap()
        self.valuesChanged.emit()
        self.update()

    @Property("QVariantList", notify=bandsChanged)
    def bands(self) -> list:
        return self._bands

    @bands.setter  # type: ignore[no-redef]
    def bands(self, val: list) -> None:
        self._bands = list(val or [])
        self.bandsChanged.emit()
        self.update()

    @Property(float, notify=targetChanged)
    def target(self) -> float:
        return self._target

    @target.setter  # type: ignore[no-redef]
    def target(self, val: float) -> None:
        self._target = float(val)
        self._rebuild_heatmap()
        self.targetChanged.emit()
        self.update()

    @Property(float, notify=marginChanged)
    def margin(self) -> float:
        return self._margin

    @margin.setter  # type: ignore[no-redef]
    def margin(self, val: float) -> None:
        self._margin = float(val)
        self._rebuild_heatmap()
        self.marginChanged.emit()
        self.update()

    @Property(float, notify=blendChanged)
    def blend(self) -> float:
        return self._blend

    @blend.setter  # type: ignore[no-redef]
    def blend(self, val: float) -> None:
        self._blend = max(0.0, min(1.0, float(val)))
        if self._blend > 0 and self._heatmap is None:
            self._rebuild_heatmap()
        self.blendChanged.emit()
        self.update()

    @Property(bool, notify=showLabelsChanged)
    def showLabels(self) -> bool:
        return self._show_labels

    @showLabels.setter  # type: ignore[no-redef]
    def showLabels(self, val: bool) -> None:
        self._show_labels = bool(val)
        self.showLabelsChanged.emit()
        self.update()

    @Property(bool, notify=showExtremesChanged)
    def showExtremes(self) -> bool:
        return self._show_extremes

    @showExtremes.setter  # type: ignore[no-redef]
    def showExtremes(self, val: bool) -> None:
        self._show_extremes = bool(val)
        self.showExtremesChanged.emit()
        self.update()

    @Property(str, notify=shapeChanged)
    def shape(self) -> str:
        return self._shape

    @shape.setter  # type: ignore[no-redef]
    def shape(self, val: str) -> None:
        self._shape = str(val).lower()
        self._rebuild()
        self.shapeChanged.emit()

    @Property(float, notify=sizeChanged)
    def size(self) -> float:
        return self._size

    @size.setter  # type: ignore[no-redef]
    def size(self, val: float) -> None:
        self._size = float(val)
        self._rebuild()
        self.sizeChanged.emit()

    @Property("QVariantList", notify=thicknessChanged)
    def thicknessData(self) -> list:
        """Thickness measurement points as [x_mm, y_mm, t2] triples."""
        return self._thickness_data

    @thicknessData.setter  # type: ignore[no-redef]
    def thicknessData(self, val:list) -> None:
        self._thickness_data = [list(p) for p in (val or [])]
        self._rebuild_thickness()
        self.thicknessChanged.emit()
        self.update()

    @Property(bool, notify=thicknessChanged)
    def hasThickness(self) -> bool:
        return bool(self._thickness_data)

    @Slot(str, result=str)
    def loadThickness(self, file_path: str) -> str:
        """Load a customer thickness CSV; returns error message, '' on success."""
        points, err = parse_thickness_csv(file_path)
        if err:
            return err
        self._thickness_data = points
        self._rebuild_thickness()
        self.thicknessChanged.emit()
        self.update()
        return ""
        self.update

    @Property(bool, notify=showThicknessChanged)
    def showThickness(self) -> bool:
        return self._show_thickness

    @showThickness.setter  # type: ignore[no-redef]
    def showThickness(self, val: bool) -> None:
        self._show_thickness = bool(val)
        self.showThicknessChanged.emit()
        self.update()

    @Property(int, notify=hoveredIndexChanged)
    def hoveredIndex(self) -> int:
        return self._hovered_index

    @hoveredIndex.setter  # type: ignore[no-redef]
    def hoveredIndex(self, val: int) -> None:
        val = int(val)
        if val == self._hovered_index:
            return
        self._hovered_index = val
        self.hoveredIndexChanged.emit()
        self.update()

    @Property(float, notify=hoverScaleChanged)
    def hoverScale(self) -> float:
        return self._hover_scale

    @hoverScale.setter  # type: ignore[no-redef]
    def hoverScale(self, val: float) -> None:
        self._hover_scale = float(val)
        self.hoverScaleChanged.emit()
        self.update()

    # Colour properties — QML can bind these to Theme tokens
    @Property(QColor, notify=colorsChanged)
    def ringColor(self) -> QColor: return self._ring_color
    @ringColor.setter  # type: ignore[no-redef]
    def ringColor(self, c: QColor) -> None: self._ring_color = c; self.update()

    @Property(QColor, notify=colorsChanged)
    def axisColor(self) -> QColor: return self._axis_color
    @axisColor.setter  # type: ignore[no-redef]
    def axisColor(self, c: QColor) -> None: self._axis_color = c; self.update()

    @Property(QColor, notify=colorsChanged)
    def lowColor(self) -> QColor: return self._low_color
    @lowColor.setter  # type: ignore[no-redef]
    def lowColor(self, c: QColor) -> None: self._low_color = c; self.update()

    @Property(QColor, notify=colorsChanged)
    def inRangeColor(self) -> QColor: return self._in_range_color
    @inRangeColor.setter  # type: ignore[no-redef]
    def inRangeColor(self, c: QColor) -> None: self._in_range_color = c; self.update()

    @Property(QColor, notify=colorsChanged)
    def highColor(self) -> QColor: return self._high_color
    @highColor.setter  # type: ignore[no-redef]
    def highColor(self, c: QColor) -> None: self._high_color = c; self.update()

    @Property(QColor, notify=colorsChanged)
    def textColor(self) -> QColor: return self._text_color
    @textColor.setter  # type: ignore[no-redef]
    def textColor(self, c: QColor) -> None: self._text_color = c; self.update()

    # ── slots ─────────────────────────────────────────────────────────────

    @Slot(float, float, result=int)
    def which_marker(self, x: float, y: float) -> int:
        """Return the sensor index nearest to (x, y) within marker radius, else -1."""
        r = max(self._marker_r, 6)
        for idx, (mx, my) in self._markers.items():
            if abs(mx - x) <= r and abs(my - y) <= r:
                return idx
        return -1


    @Slot(str, result=bool)
    @Slot(str, str, result=bool)
    def export_image(self, file_path: str, extra: str = "") -> bool:
        """Export the current wafer map rendering to a PNG file

        Args:
            file_path: Absolute path for the output PNG.
            extra: Optional second footer line (live stream metrics).

        Returns:
        True on success, False on failure
        """
        if not file_path or not self._values:
            # No file loaded / no stream played yet — only the empty template
            # would render, which isn't a meaningful export.
            return False
        try:
            # Render synchronously via paint() into our own image —
            # grabToImage() resolves on a later render frame, so its result
            # is null when read immediately (the old silent failure).
            w, h = int(self.width()), int(self.height())
            if w <= 0 or h <= 0:
                return False
            lines = [ln for ln in (readout_text(self._values), extra) if ln]
            footer_h = 28 * len(lines)
            img = QImage(w, h + footer_h, QImage.Format.Format_ARGB32)
            img.fill(0)  # transparent background
            painter = QPainter(img)
            self.paint(painter)
            if lines:
                painter.fillRect(QRectF(0, h, w, footer_h), QColor("#101014"))
                painter.setPen(QColor("#CBD5E1"))
                for i, line in enumerate(lines):
                    painter.drawText(QRectF(0, h + 28 * i, w, 28),
                                     Qt.AlignmentFlag.AlignCenter, line)
            painter.end()
            return bool(img.save(file_path))
        except Exception as e:
            log.error("Export failed: %s", e)
            return False


    # ── internal ─────────────────────────────────────────────────────────

    def _paint_extremes(self, painter: QPainter) -> None:
        """Ring the hottest (high color) and coldest (low color) markers."""
        if not self._show_extremes or not self._values or not self._markers:
            return
        s = compute_stats(self._values)
        ring_r = self._marker_r + 5
        painter.setBrush(Qt.BrushStyle.NoBrush)
        for idx, color in ((s.max_index, self._high_color),
                           (s.min_index, self._low_color)):
            if idx not in self._markers:
                continue
            px, py = self._markers[idx]
            painter.setPen(QPen(color, 2))
            painter.drawEllipse(px - ring_r, py - ring_r, 2 * ring_r, 2 * ring_r)

    def _rebuild_thickness(self) -> None:
        """Interpolate thickness points into a gray/orange heatmap QImage."""
        if not self._thickness_data:
            self._thickness_heatmap = None
            return
        ds = self._draw_size()
        r_mm = self._wafer_radius_mm()
        pts = np.array(self._thickness_data, dtype=float)
        xs, ys, vs = pts[:, 0], pts[:, 1], pts[:, 2]
        try:
            # No round_clip — see _rebuild_heatmap for why the mask is analytic.
            field = interpolate_field(
                xs, ys, vs,
                width=ds, height=ds,
                extent=(-r_mm, r_mm, -r_mm, r_mm),
            )
        except Exception:
            self._thickness_heatmap = None
            return
        field = refine_field(field)
        # ponytail: RBF overshoots around outlier sensors, inventing values no
        # sensor produced (e.g. negative diffs when all diffs are positive).
        # Clamp to the real data range so color only reflects measured values.
        field = np.clip(field, vs.min(), vs.max())
        if self._shape == "round":
            alpha = ellipse_alpha(*field.shape)
        else:
            alpha = np.ones(field.shape)
        # Color-range bounds from inside the wafer only — the unclipped field
        # extrapolates wildly toward the square corners, which alpha hides but
        # a corner-driven vmin/vmax would flatten the visible gradient.
        inside = field[alpha >= 0.5]
        vmin, vmax = inside.min(), inside.max()
        span = vmax - vmin or 1.0
        # Gray/orange colormap: map field range 0→1 to gray→orange
        t = np.clip((field - vmin) / span, 0.0, 1.0)

        gh, gw = field.shape
        # Gray (0.5, 0.5, 0.5) → orange (1.0, 0.65, 0.0)
        rgb = np.zeros((gh, gw, 3), dtype=np.float32)
        rgb[:, :, 0] = 0.5 + 0.5 * t
        rgb[:, :, 1] = 0.5 + 0.15 * t
        rgb[:, :, 2] = 0.5 - 0.5 * t

        rgb = np.nan_to_num(rgb, nan=0.0)
        rgba = np.zeros((gh, gw, 4), dtype=np.uint8)
        rgba[:, :, :3] = (rgb * 255).astype(np.uint8)
        rgba[:, :, 3] = (alpha * 180).astype(np.uint8)

        grid_img = QImage(rgba.tobytes(), gw, gh, QImage.Format.Format_RGBA8888).copy()
        self._thickness_heatmap = grid_img.scaled(
            ds, ds,
            Qt.AspectRatioMode.IgnoreAspectRatio,
            Qt.TransformationMode.SmoothTransformation,
        )

    def _on_resize(self) -> None:
        self._rebuild()

    def _rebuild(self) -> None:
        self._compute_markers()
        self._rebuild_heatmap()
        self.update()

    def _draw_size(self) -> int:
        return max(1, int(min(self.width(), self.height())))

    def _center(self) -> tuple[int, int]:
        return int(self.width() / 2), int(self.height() / 2)

    def _wafer_radius_mm(self) -> float:
        """Radius of the wafer bounding circle in mm (5% padding beyond outermost sensor)."""
        if not self._sensors:
            return 150.0
        r = max(math.hypot(s.x, s.y) for s in self._sensors)
        return r * 1.05

    def _sensor_ring_radii_mm(self) -> list[float]:
        """Distinct radial distances of sensor groups, sorted ascending, plus the outer boundary."""
        return ring_boundaries(self._sensors)

    def _scale(self, ds: int, r_mm: float) -> float:
        """Pixels per mm. The wafer radius maps to ds//2 - 24 px."""
        return (ds / 2 - 24) / r_mm

    def _to_px(self, x_mm: float, y_mm: float, cx: int, cy: int, scale: float) -> tuple[int, int]:
        """Center-origin mm → pixel (top-left origin). Y is flipped."""
        return cx + int(x_mm * scale), cy - int(y_mm * scale)

    def _compute_markers(self) -> None:
        ds = self._draw_size()
        r_mm = self._wafer_radius_mm()
        sc = self._scale(ds, r_mm)
        cx, cy = self._center()
        self._marker_r = max(3, ds // 70)
        self._markers = {i: self._to_px(s.x, s.y, cx, cy, sc)
                         for i, s in enumerate(self._sensors)}

    def _rebuild_heatmap(self) -> None:
        if not self._sensors or not self._values or self._blend == 0.0:
            self._heatmap = None
            return
        ds = self._draw_size()
        r_mm = self._wafer_radius_mm()
        xs = np.array([s.x for s in self._sensors])
        ys = np.array([s.y for s in self._sensors])
        vs = np.array(self._values[:len(self._sensors)], dtype=float)
        if len(vs) < len(self._sensors):
            self._heatmap = None
            return
        try:
            # No round_clip: refine the smooth unclipped field, then mask with an
            # analytic anti-aliased ellipse (a coarse-grid clip mask upscales jagged).
            field = interpolate_field(
                xs, ys, vs,
                width=ds, height=ds,
                extent=(-r_mm, r_mm, -r_mm, r_mm),
            )
        except Exception:
            self._heatmap = None
            return
        field = refine_field(field)
        # ponytail: RBF overshoots around outlier sensors, inventing values no
        # sensor produced (e.g. negative diffs when all diffs are positive).
        # Clamp to the real data range so color only reflects measured values.
        field = np.clip(field, vs.min(), vs.max())
        if self._shape == "round":
            alpha = ellipse_alpha(*field.shape)
        else:
            alpha = np.ones(field.shape)
        grid_img = self._field_to_qimage(field, alpha)
        self._heatmap = grid_img.scaled(
            ds, ds,
            Qt.AspectRatioMode.IgnoreAspectRatio,
            Qt.TransformationMode.SmoothTransformation,
        )

    def _field_to_qimage(self, field: np.ndarray, alpha: np.ndarray) -> QImage:
        """Apply a band-aware tri-color gradient → RGBA QImage at the field's own resolution."""
        lo_b = self._target - self._margin
        hi_b = self._target + self._margin
        span = hi_b - lo_b or 1.0
        # t: 0 = lo_b, 1 = hi_b (clipped)
        t = np.clip((field - lo_b) / span, 0.0, 1.0)

        def c(q: QColor) -> np.ndarray:
            return np.array([q.redF(), q.greenF(), q.blueF()], dtype=np.float32)

        lo_c = c(self._low_color)
        mid_c = c(self._in_range_color)
        hi_c = c(self._high_color)

        t2 = t * 2  # 0→2 across full range

        lower = t <= 0.5
        t_lo = np.clip(t2,       0.0, 1.0)[:, :, np.newaxis]  # 0→1 in lower half
        t_hi = np.clip(t2 - 1.0, 0.0, 1.0)[:, :, np.newaxis]  # 0→1 in upper half

        rgb = np.where(
            lower[:, :, np.newaxis],
            lo_c * (1 - t_lo) + mid_c * t_lo,
            mid_c * (1 - t_hi) + hi_c * t_hi,
        )
        rgb = np.nan_to_num(rgb, nan=0.0)
        gh, gw = field.shape
        rgba = np.zeros((gh, gw, 4), dtype=np.uint8)
        rgba[:, :, :3] = (rgb * 255).astype(np.uint8)
        # alpha is a continuous 0..1 coverage mask (not a hard isfinite cutoff) so the
        # round-clip boundary anti-aliases instead of showing coarse-grid steps.
        rgba[:, :, 3]  = (alpha * 210).astype(np.uint8)

        return QImage(rgba.tobytes(), gw, gh, QImage.Format.Format_RGBA8888).copy()

    # ── paint ─────────────────────────────────────────────────────────────

    def paint(self, painter: QPainter) -> None:
        ds = self._draw_size()
        if ds != self._last_draw_size:
            # Geometry settled (or changed) since markers/heatmap were last built
            # against a stale size — recompute now, at actual paint time.
            self._last_draw_size = ds
            self._compute_markers()
            self._rebuild_heatmap()
            self._rebuild_thickness()

        r_px = int(ds / 2 - 4)
        cx, cy = self._center()

        painter.setRenderHint(QPainter.RenderHint.Antialiasing)

        self._paint_template(painter, cx, cy, r_px)

        if self._heatmap and self._blend > 0.0:
            painter.setOpacity(self._blend)
            painter.drawImage(cx - self._heatmap.width() // 2,
                              cy - self._heatmap.height() // 2, self._heatmap)
            painter.setOpacity(1.0)

        if self._show_thickness and self._thickness_heatmap:
            painter.setOpacity(0.4)
            painter.drawImage(cx - self._thickness_heatmap.width() // 2,
            cy - self._thickness_heatmap.height()//2, self._thickness_heatmap)
            painter.setOpacity(1.0)

        self._paint_markers(painter)
        self._paint_extremes(painter)

    def _paint_template(self, painter: QPainter, cx: int, cy: int, r_px: int) -> None:
        ds = self._draw_size()
        r_mm = self._wafer_radius_mm()
        sc = self._scale(ds, r_mm)

        if self._shape == "square":
            # Draw square boundary (thick pen)
            border_pen = QPen(self._ring_color, 2, Qt.PenStyle.SolidLine)
            painter.setPen(border_pen)
            half_size_px = int(self._size / 2 * sc)
            painter.drawRect(cx - half_size_px, cy - half_size_px, 2 * half_size_px, 2 * half_size_px)

            # Crosshair axes
            axis_pen = QPen(self._axis_color, 1, Qt.PenStyle.DashLine)
            painter.setPen(axis_pen)
            painter.drawLine(cx, cy - half_size_px, cx, cy + half_size_px)
            painter.drawLine(cx - half_size_px, cy, cx + half_size_px, cy)

            # Draw concentric square guide lines at the distinct radii of sensor rings
            grid_pen = QPen(self._ring_color, 1, Qt.PenStyle.SolidLine)
            painter.setPen(grid_pen)
            for ring_r_mm in self._sensor_ring_radii_mm()[:-1]:  # exclude the outermost border
                rr = max(1, int(ring_r_mm * sc))
                painter.drawRect(cx - rr, cy - rr, 2 * rr, 2 * rr)
        else:
            # Concentric rings
            ring_pen = QPen(self._ring_color, 1, Qt.PenStyle.SolidLine)
            painter.setPen(ring_pen)
            for ring_r_mm in self._sensor_ring_radii_mm():
                rr = max(1, int(ring_r_mm * sc))
                painter.drawEllipse(cx - rr, cy - rr, 2 * rr, 2 * rr)

            # Crosshair axes
            axis_pen = QPen(self._axis_color, 1, Qt.PenStyle.DashLine)
            painter.setPen(axis_pen)
            painter.drawLine(cx, cy - r_px, cx, cy + r_px)
            painter.drawLine(cx - r_px, cy, cx + r_px, cy)

            # Top notch triangle (wafer orientation marker)
            nw = max(6, ds // 25)
            nh = max(4, ds // 35)
            notch = QPolygon([
                QPoint(cx,           cy - r_px),
                QPoint(cx - nw // 2, cy - r_px + nh),
                QPoint(cx + nw // 2, cy - r_px + nh),
            ])
            painter.setPen(Qt.PenStyle.NoPen)
            painter.setBrush(QBrush(self._axis_color))
            painter.drawPolygon(notch)

    def _paint_markers(self, painter: QPainter) -> None:
        r = self._marker_r

        # Scale font size based on the number of sensors to prevent overlap on dense wafers
        num_sensors = len(self._sensors)
        font_scale = 1.0
        if num_sensors > 60:
            font_scale = 0.7  # reduce font size by 30% for dense wafers
        elif num_sensors > 40:
            font_scale = 0.85

        id_font = QFont()
        id_font.setPointSize(max(9, int(r * font_scale)))
        id_font.setBold(True)

        temp_font = QFont()
        temp_font.setPointSize(max(9, int(r * font_scale)))

        band_color = {
            "in_range": self._in_range_color,
            "high":     self._high_color,
            "low":      self._low_color,
        }

        painter.setFont(id_font)
        id_fm = painter.fontMetrics()
        id_line_h = id_fm.height()
        id_ascent = id_fm.ascent()

        painter.setFont(temp_font)
        temp_fm = painter.fontMetrics()
        temp_line_h = temp_fm.height()
        temp_ascent = temp_fm.ascent()

        for i, s in enumerate(self._sensors):
            if i not in self._markers:
                continue
            px, py = self._markers[i]
            color = band_color.get(
                self._bands[i] if i < len(self._bands) else "in_range",
                self._in_range_color,
            )
            is_hovered = i == self._hovered_index
            marker_r = int(round(r * self._hover_scale)) if is_hovered else r

            if is_hovered:
                # Soft glow ring behind the marker, growing with hoverScale.
                glow_r = int(round(marker_r * 1.9))
                glow_color = QColor(color)
                glow_color.setAlpha(70)
                painter.setPen(Qt.PenStyle.NoPen)
                painter.setBrush(QBrush(glow_color))
                painter.drawEllipse(px - glow_r, py - glow_r, 2 * glow_r, 2 * glow_r)

            # Filled circle with thin dark outline for contrast over heatmap
            painter.setPen(QPen(QColor(0, 0, 0, 100), 1))
            painter.setBrush(QBrush(color))
            painter.drawEllipse(px - marker_r, py - marker_r, 2 * marker_r, 2 * marker_r)

            if self._show_labels:
                has_temp = i < len(self._values)

                # Fetch text alignment side and offsets
                side = getattr(s, "side", "right").lower()
                ox = getattr(s, "offset_x", 0.0) * r
                oy = getattr(s, "offset_y", 0.0) * r

                id_text = s.label
                temp_text = f"{self._values[i]:.2f}" if has_temp else ""

                id_w = id_fm.horizontalAdvance(id_text)
                temp_w = temp_fm.horizontalAdvance(temp_text) if has_temp else 0
                text_w = max(id_w, temp_w)

                # Height of the 1 or 2-line text block
                text_h = (id_line_h + temp_line_h) if has_temp else id_line_h

                # Calculate box top-left (lx, ly) relative to dot center (px, py)
                gap = 3
                if side == "left":
                    lx = px - r - gap - text_w - ox
                    ly = py - text_h // 2 + oy
                elif side == "top":
                    lx = px - text_w // 2 + ox
                    ly = py - r - gap - text_h - oy
                elif side == "bottom":
                    lx = px - text_w // 2 + ox
                    ly = py + r + gap + oy
                else: # "right" or default
                    lx = px + r + gap + ox
                    ly = py - text_h // 2 + oy

                # Draw Sensor ID (first line)
                painter.setFont(id_font)
                painter.setPen(QPen(self._text_color))
                y1 = ly + id_ascent
                if side in ("top", "bottom"):
                    painter.drawText(lx + (text_w - id_w) // 2, y1, id_text)  # type: ignore[arg-type]
                elif side == "left":
                    painter.drawText(lx + (text_w - id_w), y1, id_text)  # type: ignore[arg-type]
                else:
                    painter.drawText(lx, y1, id_text)  # type: ignore[arg-type]

                # Draw Temperature (second line)
                if has_temp:
                    painter.setFont(temp_font)
                    painter.setPen(QPen(color))
                    y2 = ly + id_line_h + temp_ascent
                    if side in ("top", "bottom"):
                        painter.drawText(lx + (text_w - temp_w) // 2, y2, temp_text)  # type: ignore[arg-type]
                    elif side == "left":
                        painter.drawText(lx + (text_w - temp_w), y2, temp_text)  # type: ignore[arg-type]
                    else:
                        painter.drawText(lx, y2, temp_text)  # type: ignore[arg-type]
