"""QQuickPaintedItem line chart for embedding in QML.

Draws multiple sensor-data line series with axis labels, grid lines,
auto-scaled Y range, legend, and a dark-theme default palette that
complements Theme.qml.

Usage from QML::

    import ISC.Wafer

    GraphQuickItem {
        id: graph
        anchors.fill: parent
        seriesData: [...]
        sensorNames: ["Sensor1", "Sensor2"]
        title: "Sensor Temperature Over Time"
        yLabel: "Temperature (°C)"
        xLabel: "Measurement"
    }
"""

from __future__ import annotations

import math

from PySide6.QtCore import Property, QRectF, Qt, Signal, Slot
from PySide6.QtGui import QColor, QFont, QFontMetrics, QPainter, QPen
from PySide6.QtQml import QmlElement
from PySide6.QtQuick import QQuickPaintedItem

from pygui.backend.visualization.chart_geometry import index_to_pixel, value_to_pixel

QML_IMPORT_NAME = "ISC.Wafer"
QML_IMPORT_MAJOR_VERSION = 1


def viewport_stats(
    series: list[list[float]],
    names: list[str],
    start: int,
    end: int,
) -> tuple[float, float, float, str, str]:
    """Aggregate min/max/avg across every sensor's points in `[start, end]`.

    Mirrors PopupChartForm.cs's `recalc_stats`: one running min/max/avg over
    every visible point from every series, plus which sensor achieved each
    extreme. Non-numeric values are skipped. No points in range -> zeros and
    empty sensor names.
    """
    vmin = math.inf
    vmax = -math.inf
    total = 0.0
    count = 0
    min_sensor = ""
    max_sensor = ""
    for i, s in enumerate(series):
        name = names[i] if i < len(names) else ""
        lo = max(0, start)
        hi = min(len(s) - 1, end)
        for j in range(lo, hi + 1):
            try:
                v = float(s[j])
            except (TypeError, ValueError):
                continue
            if v < vmin:
                vmin = v
                min_sensor = name
            if v > vmax:
                vmax = v
                max_sensor = name
            total += v
            count += 1
    if count == 0:
        return (0.0, 0.0, 0.0, "", "")
    return (vmin, vmax, total / count, min_sensor, max_sensor)


@QmlElement
class GraphQuickItem(QQuickPaintedItem):
    """Painted line chart; driven by series data passed via QML property bindings."""

    seriesDataChanged = Signal()
    sensorNamesChanged = Signal()
    titleChanged = Signal()
    xLabelChanged = Signal()
    yLabelChanged = Signal()
    colorsChanged = Signal()
    viewportChanged = Signal()
    viewStatsChanged = Signal()

    def __init__(self, parent=None) -> None:
        super().__init__(parent)
        self._series_data: list[list[float]] = []    # [[sensor1_vals...], [sensor2_vals...]]
        self._sensor_names: list[str] = []            # parallel to series_data
        self._title: str = ""
        self._x_label: str = "Measurement"
        self._y_label: str = "Temperature (°C)"
        self._show_legend: bool = True

        # Dark-theme colour defaults (match Theme.qml where possible)
        self._bg_color = QColor("#1A1A1A")           # tone200
        self._grid_color = QColor("#2A2A2A")         # toneBorder
        self._axis_color = QColor("#A8A8A8")         # toneMute
        self._text_color = QColor("#F2F2F2")         # toneText
        self._series_colors: list[QColor] = [
            QColor("#FF5757"),   # Red
            QColor("#42A5F5"),   # Blue
            QColor("#66BB6A"),   # Green
            QColor("#FFA726"),   # Orange
            QColor("#AB47BC"),   # Purple
            QColor("#00BCD4"),   # Cyan
            QColor("#FF7043"),   # Deep Orange
            QColor("#795548"),   # Brown
            QColor("#5C6BC0"),   # Indigo
            QColor("#307D75"),   # Teal
        ]

        self._min_y: float = 0.0
        self._max_y: float = 150.0
        self._padding: dict[str, int] = {"left": 60, "right": 20, "top": 44, "bottom": 50}

        # Viewport (replay chart zoom/pan): defaults reproduce "whole series",
        # so the Graph tab (which never sets these) is unaffected. See
        # docs/adr/0004-graphquickitem-viewport-for-replay-chart.md.
        self._view_start: int = 0
        self._view_end: int = -1
        self._show_min_max_markers: bool = False
        self._view_min: float = 0.0
        self._view_max: float = 0.0
        self._view_avg: float = 0.0
        self._view_min_sensor: str = ""
        self._view_max_sensor: str = ""

    # ── Qt properties (QML-bindable) ──────────────────────────────────────────

    @Property("QVariantList", notify=seriesDataChanged)
    def seriesData(self) -> list:
        return self._series_data

    @seriesData.setter  # type: ignore[no-redef]
    def seriesData(self, val: list) -> None:
        self._series_data = [list(s) for s in (val or [])]
        # New data resets the viewport to the full range -- matches
        # PopupChartForm.cs's SetData(), which resets AxisX.Minimum/Maximum
        # before RecalculateAxesScale(). Without this, a stale viewport left
        # over from a previous (longer) series can point entirely past the
        # new data -- every per-series slice becomes empty and the chart
        # silently renders nothing instead of the new data.
        self._view_start = 0
        self._view_end = -1
        self._auto_range()
        self._recompute_view_stats()
        self.seriesDataChanged.emit()
        self.update()

    @Property("QStringList", notify=sensorNamesChanged)
    def sensorNames(self) -> list:
        return self._sensor_names

    @sensorNames.setter  # type: ignore[no-redef]
    def sensorNames(self, val: list) -> None:
        self._sensor_names = list(val or [])
        self._recompute_view_stats()
        self.sensorNamesChanged.emit()
        self.update()

    # ── Viewport properties (replay chart zoom/pan) ────────────────────────

    @Property(int, notify=viewportChanged)
    def viewStartIndex(self) -> int:
        return self._view_start

    @viewStartIndex.setter  # type: ignore[no-redef]
    def viewStartIndex(self, val: int) -> None:
        v = max(0, int(val))
        if v == self._view_start:
            return
        self._view_start = v
        self._on_viewport_changed()

    @Property(int, notify=viewportChanged)
    def viewEndIndex(self) -> int:
        return self._view_end

    @viewEndIndex.setter  # type: ignore[no-redef]
    def viewEndIndex(self, val: int) -> None:
        v = int(val)
        if v == self._view_end:
            return
        self._view_end = v
        self._on_viewport_changed()

    @Property(bool, notify=viewportChanged)
    def showMinMaxMarkers(self) -> bool:
        return self._show_min_max_markers

    @showMinMaxMarkers.setter  # type: ignore[no-redef]
    def showMinMaxMarkers(self, val: bool) -> None:
        v = bool(val)
        if v == self._show_min_max_markers:
            return
        self._show_min_max_markers = v
        self.viewportChanged.emit()
        self.update()

    @Property(float, notify=viewStatsChanged)
    def viewMin(self) -> float:
        return self._view_min

    @Property(float, notify=viewStatsChanged)
    def viewMax(self) -> float:
        return self._view_max

    @Property(float, notify=viewStatsChanged)
    def viewAvg(self) -> float:
        return self._view_avg

    @Property(str, notify=viewStatsChanged)
    def viewMinSensor(self) -> str:
        return self._view_min_sensor

    @Property(str, notify=viewStatsChanged)
    def viewMaxSensor(self) -> str:
        return self._view_max_sensor

    # ── Zoom/pan slots (replay chart interaction; QML MouseArea calls these
    #    with fractions derived from wheel/drag pixel deltas) ───────────────

    @Slot(float, float)
    def zoomAtFraction(self, frac: float, factor: float) -> None:
        """Zoom the viewport around a fractional X position within it.

        `frac` in [0, 1] is where within the current viewport to zoom around;
        `factor < 1` narrows the window (zoom in), `factor > 1` widens it
        (zoom out). Clamped to data bounds and a minimum 1-index window.
        """
        max_len = max((len(s) for s in self._series_data), default=0)
        if max_len < 2:
            return
        start = float(self._view_start)
        end = float(self._resolved_view_end())
        width = max(1.0, end - start)
        f = max(0.0, min(1.0, frac))
        center = start + f * width
        new_width = max(1.0, min(float(max_len - 1), width * max(0.01, factor)))
        new_start = center - f * new_width
        new_end = new_start + new_width
        self._set_viewport_clamped(new_start, new_end, max_len)

    @Slot(float)
    def panByFraction(self, delta_frac: float) -> None:
        """Shift the viewport by `delta_frac * window_width` indices."""
        max_len = max((len(s) for s in self._series_data), default=0)
        if max_len < 2:
            return
        start = float(self._view_start)
        end = float(self._resolved_view_end())
        width = max(1.0, end - start)
        shift = delta_frac * width
        self._set_viewport_clamped(start + shift, end + shift, max_len)

    @Slot()
    def resetZoom(self) -> None:
        """Restore the full-range viewport sentinel (0, -1)."""
        self._set_viewport(0, -1)

    def _set_viewport_clamped(self, new_start: float, new_end: float, max_len: int) -> None:
        if new_start < 0.0:
            new_end -= new_start
            new_start = 0.0
        if new_end > max_len - 1:
            new_start -= new_end - (max_len - 1)
            new_end = float(max_len - 1)
        new_start = max(0.0, new_start)
        self._set_viewport(int(round(new_start)), int(round(new_end)))

    def _set_viewport(self, start: int, end: int) -> None:
        """Set the viewport bounds directly (not through the QML-facing
        property setters, so this stays a plain attribute write for mypy)."""
        new_start = max(0, start)
        if new_start == self._view_start and end == self._view_end:
            return
        self._view_start = new_start
        self._view_end = end
        self._on_viewport_changed()

    @Property(str, notify=titleChanged)
    def title(self) -> str:
        return self._title

    @title.setter  # type: ignore[no-redef]
    def title(self, val: str) -> None:
        self._title = str(val or "")
        self.titleChanged.emit()
        self.update()

    @Property(str, notify=xLabelChanged)
    def xLabel(self) -> str:
        return self._x_label

    @xLabel.setter  # type: ignore[no-redef]
    def xLabel(self, val: str) -> None:
        self._x_label = str(val or "")
        self.xLabelChanged.emit()
        self.update()

    @Property(str, notify=yLabelChanged)
    def yLabel(self) -> str:
        return self._y_label

    @yLabel.setter  # type: ignore[no-redef]
    def yLabel(self, val: str) -> None:
        self._y_label = str(val or "")
        self.yLabelChanged.emit()
        self.update()

    @Property(bool, notify=colorsChanged)
    def showLegend(self) -> bool:
        return self._show_legend

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

    # ── Colour properties (QML can bind Theme tokens here) ────────────────────

    @Property(QColor, notify=colorsChanged)
    def backgroundColor(self) -> QColor:
        return self._bg_color

    @backgroundColor.setter  # type: ignore[no-redef]
    def backgroundColor(self, c: QColor) -> None:
        self._bg_color = c
        self.update()

    @Property(QColor, notify=colorsChanged)
    def gridColor(self) -> QColor:
        return self._grid_color

    @gridColor.setter  # type: ignore[no-redef]
    def gridColor(self, c: QColor) -> None:
        self._grid_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 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()

    @Property("QVariantList", notify=colorsChanged)
    def seriesColors(self) -> list:
        """Return hex strings of the current series color palette."""
        return [c.name() for c in self._series_colors]

    @seriesColors.setter  # type: ignore[no-redef]
    def seriesColors(self, hex_list: list) -> None:
        colors = []
        for h in (hex_list or []):
            try:
                colors.append(QColor(str(h)))
            except Exception:
                colors.append(QColor("#FFFFFF"))
        if colors:
            self._series_colors = colors
            self.update()

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

    def _resolved_view_end(self) -> int:
        """`viewEndIndex` with the -1 ("last index") sentinel resolved."""
        max_len = max((len(s) for s in self._series_data), default=0)
        if self._view_end < 0 or self._view_end > max_len - 1:
            return max_len - 1
        return self._view_end

    def _on_viewport_changed(self) -> None:
        self._auto_range()
        self._recompute_view_stats()
        self.viewportChanged.emit()
        self.update()

    def _recompute_view_stats(self) -> None:
        end = self._resolved_view_end()
        vmin, vmax, avg, min_sensor, max_sensor = viewport_stats(
            self._series_data, self._sensor_names, self._view_start, end
        )
        self._view_min, self._view_max, self._view_avg = vmin, vmax, avg
        self._view_min_sensor, self._view_max_sensor = min_sensor, max_sensor
        self.viewStatsChanged.emit()

    def _auto_range(self) -> None:
        """Set Y range to cover the current viewport's data with 10% padding."""
        start = max(0, self._view_start)
        end = self._resolved_view_end()
        all_vals: list[float] = []
        for series in self._series_data:
            hi = min(len(series) - 1, end)
            for i in range(start, hi + 1):
                try:
                    all_vals.append(float(series[i]))
                except (ValueError, TypeError):
                    pass
        if not all_vals:
            return
        mn = min(all_vals)
        mx = max(all_vals)
        if mx <= mn:
            mn -= 5.0
            mx += 5.0
        pad = (mx - mn) * 0.1
        self._min_y = mn - pad
        self._max_y = mx + pad

    def _plot_rect(self) -> QRectF:
        w = self.width()
        h = self.height()
        pl = self._padding.get("left", 60)
        pr = self._padding.get("right", 20)
        pt = self._padding.get("top", 30)
        pb = self._padding.get("bottom", 50)
        return QRectF(pl, pt, max(10, w - pl - pr), max(10, h - pt - pb))

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

    def paint(self, painter: QPainter) -> None:
        painter.setRenderHint(QPainter.RenderHint.Antialiasing)

        w = self.width()
        h = self.height()
        if w < 50 or h < 50:
            return

        pr = self._plot_rect()
        plot_left = pr.left()
        plot_top = pr.top()
        plot_w = pr.width()
        plot_h = pr.height()

        # --- Background fill ---
        painter.fillRect(0, 0, int(w), int(h), self._bg_color)

        # --- Title ---
        if self._title:
            title_font = QFont()
            title_font.setPixelSize(14)
            title_font.setBold(True)
            painter.setFont(title_font)
            painter.setPen(QPen(self._text_color))
            painter.drawText(
                QRectF(8, 4, w - 8, 24),
                Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter,
                self._title,
            )

        if not self._series_data:
            self._paint_empty(painter, pr)
            return

        # --- Determine Y tick count and values ---
        n_y_ticks = max(2, min(8, int(plot_h // 40)))
        y_range = self._max_y - self._min_y
        if y_range <= 0:
            y_range = 10.0
        y_step = y_range / (n_y_ticks - 1) if n_y_ticks > 1 else y_range

        # --- Grid & Y-axis labels ---
        grid_pen = QPen(self._grid_color, 1)
        axis_pen = QPen(self._axis_color, 1)
        label_font = QFont()
        label_font.setPixelSize(10)

        painter.setFont(label_font)
        fm = QFontMetrics(label_font)

        for i in range(n_y_ticks):
            y_val = self._min_y + i * y_step
            y_px = value_to_pixel(i, 0, n_y_ticks - 1, plot_top, plot_h)

            # Grid line
            painter.setPen(grid_pen)
            painter.drawLine(
                int(plot_left), int(y_px),
                int(plot_left + plot_w), int(y_px),
            )

            # Label
            label_str = f"{y_val:.1f}"
            tw = fm.horizontalAdvance(label_str)
            painter.setPen(axis_pen)
            painter.drawText(
                int(plot_left - tw - 6), int(y_px + fm.height() // 2 - 3),
                label_str,
            )

        # --- Y axis label (rotated) ---
        if self._y_label:
            painter.save()
            painter.setPen(QPen(self._axis_color))
            label_font_b = QFont()
            label_font_b.setPixelSize(11)
            painter.setFont(label_font_b)
            painter.translate(14, plot_top + plot_h / 2)
            painter.rotate(-90)
            painter.drawText(0, 0, self._y_label)
            painter.restore()

        # --- X axis label ---
        if self._x_label:
            painter.setPen(QPen(self._axis_color))
            label_font_b = QFont()
            label_font_b.setPixelSize(11)
            painter.setFont(label_font_b)
            painter.drawText(
                QRectF(plot_left, h - 18, plot_w, 16),
                Qt.AlignmentFlag.AlignHCenter,
                self._x_label,
            )

        # --- Viewport slice (replay chart zoom/pan; full range by default,
        #     so unset viewport reproduces the pre-viewport rendering) ---
        view_start = max(0, self._view_start)
        view_end = self._resolved_view_end()
        view_len = max(1, view_end - view_start + 1)

        # --- X-axis ticks (labelled in original, non-sliced index space) ---
        if view_len > 1:
            n_x_ticks = min(view_len, max(2, int(plot_w // 80)))
            x_tick_step = (view_len - 1) / max(1, n_x_ticks - 1)
            for i in range(n_x_ticks):
                rel_idx = int(round(i * x_tick_step))
                x_px = index_to_pixel(rel_idx, view_len, plot_left, plot_w)
                painter.setPen(axis_pen)
                painter.drawText(
                    int(x_px - 12), int(plot_top + plot_h + 14),
                    str(view_start + rel_idx + 1),
                )

        # --- Axes boundary ---
        border_pen = QPen(self._axis_color, 1)
        painter.setPen(border_pen)
        painter.drawLine(int(plot_left), int(plot_top), int(plot_left), int(plot_top + plot_h))
        painter.drawLine(int(plot_left), int(plot_top + plot_h), int(plot_left + plot_w), int(plot_top + plot_h))

        # --- Draw each series (sliced to the current viewport) ---
        min_pt: tuple[float, float] | None = None
        max_pt: tuple[float, float] | None = None
        min_seen = math.inf
        max_seen = -math.inf
        for si, series in enumerate(self._series_data):
            hi = min(len(series) - 1, view_end)
            sliced = series[view_start:hi + 1] if view_start <= hi else []
            if not sliced:
                continue
            color = self._series_colors[si % len(self._series_colors)]
            pen = QPen(color, 2)
            painter.setPen(pen)

            pts: list[tuple[float, float]] = []
            for i, val in enumerate(sliced):
                try:
                    v = float(val)
                except (ValueError, TypeError):
                    continue
                x_px = index_to_pixel(i, view_len, plot_left, plot_w)
                y_px = value_to_pixel(v, self._min_y, self._max_y, plot_top, plot_h)
                pts.append((x_px, y_px))
                if self._show_min_max_markers:
                    if v < min_seen:
                        min_seen = v
                        min_pt = (x_px, y_px)
                    if v > max_seen:
                        max_seen = v
                        max_pt = (x_px, y_px)

            if len(pts) < 2:
                if len(pts) == 1:
                    painter.drawPoint(int(pts[0][0]), int(pts[0][1]))
                continue

            for i in range(len(pts) - 1):
                painter.drawLine(
                    int(pts[i][0]), int(pts[i][1]),
                    int(pts[i + 1][0]), int(pts[i + 1][1]),
                )

        # --- Min/max markers (replay chart only; PopupChartForm.cs parity) ---
        if self._show_min_max_markers:
            marker_font = QFont()
            marker_font.setPixelSize(14)
            marker_font.setBold(True)
            painter.setFont(marker_font)
            if max_pt is not None:
                painter.setPen(QPen(QColor("#FF5757")))
                painter.drawText(int(max_pt[0] - 6), int(max_pt[1] - 8), "▼")
            if min_pt is not None:
                painter.setPen(QPen(QColor("#42A5F5")))
                painter.drawText(int(min_pt[0] - 6), int(min_pt[1] + 18), "▲")

        # --- Legend ---
        if self._show_legend and self._sensor_names:
            legend_font = QFont()
            legend_font.setPixelSize(10)
            painter.setFont(legend_font)
            lfm = QFontMetrics(legend_font)

            # Compute legend dimensions
            lh = 16
            max_name_w = max(lfm.horizontalAdvance(n) for n in self._sensor_names)
            lw = max_name_w + 24
            legend_count = min(len(self._sensor_names), len(self._series_data))
            lh_total = legend_count * lh + 4

            legend_x = int(plot_left + plot_w - lw - 8)
            legend_y = int(plot_top + 8)

            # Background
            painter.fillRect(legend_x, legend_y, lw, lh_total, QColor(0, 0, 0, 140))

            for i in range(legend_count):
                y_pos = legend_y + 4 + i * lh
                color = self._series_colors[i % len(self._series_colors)]
                # Color swatch
                painter.fillRect(legend_x + 4, y_pos + 2, 12, 10, color)
                # Label
                painter.setPen(QPen(self._text_color))
                painter.drawText(legend_x + 20, y_pos + 11, self._sensor_names[i])

    def _paint_empty(self, painter: QPainter, pr: QRectF) -> None:
        """Draw placeholder when no data is available."""
        painter.setPen(QPen(self._axis_color))
        empty_font = QFont()
        empty_font.setPixelSize(14)
        painter.setFont(empty_font)
        painter.drawText(
            pr,
            Qt.AlignmentFlag.AlignCenter,
            "No data — select a CSV file to view the graph",
        )
