# SPDX-License-Identifier: GPL-3.0-or-later
"""Live map-render timer.

The QGIS 2D canvas renders on the CPU (QPainter), and under a firehose feed that
render pass — not our decode/bind pipeline — is what pins a core. The startup
benchmark (perf_profile) sizes the machine on a synthetic scene; this measures
the *real* one: it brackets each canvas render between ``renderStarting`` and
``mapCanvasRefreshed`` and keeps a rolling median.

It is deliberately NOT a control loop (an adaptive governor felt less nimble in
testing and could itself be part of the load). It measures, and — once, one-way
— if the real render is clearly too slow for the current profile on this box, it
asks for a single safety downgrade (high -> medium -> low), which loosens the
repaint throttle and pushes the dot/label scale out. No upgrades, no oscillation.
"""
import time
from collections import deque

from qgis.PyQt.QtCore import QObject


class RenderTimer(QObject):
    # A render median past this (ms), sustained, means the canvas can't keep up
    # on this box — well above a healthy scene (tens of ms) so normal operation
    # never trips it.
    SLOW_MS = 300.0
    MIN_SAMPLES = 20                 # need a real run before judging
    REARM_AFTER = 40                 # samples to wait before flagging again

    def __init__(self, canvas, on_slow=None, parent=None):
        """canvas: the QgsMapCanvas to watch. on_slow(median_ms): called at most
        once per arming when the render is sustained-slow (best-effort)."""
        super().__init__(parent)
        self._canvas = canvas
        self._on_slow = on_slow
        self._t0 = None
        self._samples = deque(maxlen=30)
        self._since_flag = 0
        self._armed = True
        self._connected = False
        try:
            canvas.renderStarting.connect(self._on_start)
            canvas.mapCanvasRefreshed.connect(self._on_done)
            self._connected = True
        except Exception:                              # noqa: BLE001  # nosec B110
            pass

    def _on_start(self):
        self._t0 = time.monotonic()

    def _on_done(self):
        if self._t0 is None:
            return
        dt = (time.monotonic() - self._t0) * 1000.0
        self._t0 = None
        # Ignore absurd gaps (canvas idle between refreshes can stretch the bracket).
        if dt <= 0 or dt > 60000.0:
            return
        self._samples.append(dt)
        if not self._armed:
            self._since_flag += 1
            if self._since_flag >= self.REARM_AFTER:
                self._armed = True
                self._since_flag = 0
            return
        self._maybe_flag()

    def median_ms(self):
        """Median of the recent render samples, or None if too few yet."""
        n = len(self._samples)
        if n == 0:
            return None
        s = sorted(self._samples)
        return s[n // 2] if n % 2 else (s[n // 2 - 1] + s[n // 2]) / 2.0

    def _maybe_flag(self):
        if not self._armed:
            return
        if len(self._samples) < self.MIN_SAMPLES:
            return
        med = self.median_ms()
        if med is not None and med >= self.SLOW_MS:
            self._armed = False
            self._since_flag = 0
            if self._on_slow is not None:
                try:
                    self._on_slow(med)
                except Exception:                      # noqa: BLE001  # nosec B110
                    pass

    def disconnect_canvas(self):
        """Detach from the canvas signals (call on shutdown)."""
        if not self._connected:
            return
        for sig, slot in ((getattr(self._canvas, "renderStarting", None), self._on_start),
                          (getattr(self._canvas, "mapCanvasRefreshed", None), self._on_done)):
            try:
                sig.disconnect(slot)
            except Exception:                          # noqa: BLE001  # nosec B110
                pass
        self._connected = False
