# SPDX-License-Identifier: GPL-3.0-or-later
"""Startup capability probe + performance profile.

Instead of reading (misleading) hardware specs, this MEASURES the machine's real 2D
render throughput once at plugin open — QGIS's map canvas is CPU-rendered (QPainter),
so that render is the true bottleneck; the GPU is irrelevant to the 2D map and core
count misleads (rendering is single-threaded). A tiny off-screen render of N dots is
timed and bucketed into a profile (low / medium / high); RAM is a conservative floor
only. The profile is a bundle of tuning knobs the rest of the plugin reads live:

  repaint_s          canvas repaint throttle (s)
  svg_denom          show SVG ships when zoomed in past 1:svg_denom, else dots
  label_denom        show labels when zoomed in past 1:label_denom
  batch_on/batch_off msg/s to switch write-coalescing on/off (dead band)
  default_radius_nm  internet-source area-radius default

The choice is persisted in QgsSettings so we don't re-benchmark every open, and can
be overridden by the user. The live render-timer (elsewhere) can nudge repaint_s at
runtime; everything reads :func:`profile` so a change propagates.
"""
import time

PROFILES = {
    "low":    dict(repaint_s=1.5, svg_denom=15000, label_denom=15000,
                   batch_on=30, batch_off=15, default_radius_nm=15),
    "medium": dict(repaint_s=0.9, svg_denom=25000, label_denom=25000,
                   batch_on=60, batch_off=40, default_radius_nm=20),
    "high":   dict(repaint_s=0.6, svg_denom=40000, label_denom=40000,
                   batch_on=120, batch_off=80, default_radius_nm=40),
}
_DEFAULT = "medium"
_KEY = "ais_nmea_source/perf_profile"
_KEY_MS = "ais_nmea_source/perf_bench_ms"

_current = None            # active profile name (cached for the session)


# --------------------------------------------------------------------------- #
#  Public accessors
# --------------------------------------------------------------------------- #
def name():
    """Active profile name ('low'|'medium'|'high')."""
    return _current or _DEFAULT


def profile():
    """The active profile's knob dict."""
    return PROFILES.get(name(), PROFILES[_DEFAULT])


def get(knob):
    """One knob value from the active profile."""
    return profile().get(knob)


def set_profile(prof, persist=True):
    """Force a profile (user override). Persists unless persist=False."""
    global _current
    if prof not in PROFILES:
        return
    _current = prof
    if persist:
        _settings_set(prof, None)


def detect(force=False):
    """Pick a profile: persisted value if present, else a one-time render
    benchmark (+ a RAM floor). Returns (name, bench_ms|None). Best-effort — any
    failure lands on the medium default."""
    global _current
    if _current is not None and not force:
        return _current, None
    if not force:
        saved = _settings_get()
        if saved in PROFILES:
            _current = saved
            return _current, None
    ms = _bench_render_ms()
    prof = _apply_ram_floor(_bucket(ms))
    _current = prof
    _settings_set(prof, ms)
    return _current, ms


# --------------------------------------------------------------------------- #
#  Benchmark + bucketing
# --------------------------------------------------------------------------- #
def _bucket(ms):
    # thresholds for a 2000-dot off-screen render (ms). A strong workstation lands
    # ~8 ms; a mid laptop ~15-30 ms; a weak box 40 ms+. Tune once we have weak-HW
    # samples (the whole point of measuring rather than reading specs).
    if ms is None:
        return _DEFAULT
    if ms < 12.0:
        return "high"
    if ms < 32.0:
        return "medium"
    return "low"


def _bench_render_ms(n=2000):
    """Render N dots to an 800x600 off-screen image and return the milliseconds.
    Uses QGIS's own renderer so it measures the exact 2D path the map uses."""
    try:
        from qgis.core import (QgsVectorLayer, QgsFeature, QgsGeometry, QgsPointXY,
                               QgsMapSettings, QgsMapRendererSequentialJob,
                               QgsCoordinateReferenceSystem, QgsRectangle,
                               QgsMarkerSymbol, QgsSingleSymbolRenderer)
        from qgis.PyQt.QtCore import QSize
        lyr = QgsVectorLayer("Point?crs=EPSG:3857", "perf_bench", "memory")
        pr = lyr.dataProvider()
        # Deterministic scatter across the 1e5 extent (two co-prime multipliers,
        # golden-ratio-ish) — no PRNG needed, so the benchmark is repeatable and
        # doesn't pull in `random` (keeps the security scan quiet).
        feats = []
        for i in range(n):
            x = (i * 61703) % 100000
            y = (i * 40503) % 100000
            f = QgsFeature()
            f.setGeometry(QgsGeometry.fromPointXY(QgsPointXY(float(x), float(y))))
            feats.append(f)
        pr.addFeatures(feats)
        lyr.updateExtents()
        lyr.setRenderer(QgsSingleSymbolRenderer(
            QgsMarkerSymbol.createSimple({"name": "circle", "size": "1.8"})))
        ms = QgsMapSettings()
        ms.setLayers([lyr])
        ms.setDestinationCrs(QgsCoordinateReferenceSystem("EPSG:3857"))
        ms.setExtent(QgsRectangle(0, 0, 1e5, 1e5))
        ms.setOutputSize(QSize(800, 600))
        # Render a few times and take the MIN — the first render carries a large
        # one-time warm-up (JIT/init) that would otherwise misclassify a fast box.
        best = None
        for _ in range(3):
            t0 = time.perf_counter()
            job = QgsMapRendererSequentialJob(ms)
            job.start()
            job.waitForFinished()
            dt = (time.perf_counter() - t0) * 1000.0
            best = dt if best is None else min(best, dt)
        return best
    except Exception:                                   # noqa: BLE001
        return None


def _apply_ram_floor(prof):
    """A low-RAM machine never gets the 'high' profile."""
    gb = _total_ram_gb()
    if gb is not None and gb < 8.0 and prof == "high":
        return "medium"
    return prof


def _total_ram_gb():
    """Total physical RAM in GB, cross-platform, no external deps (or None)."""
    import os
    try:
        if hasattr(os, "sysconf") and "SC_PHYS_PAGES" in os.sysconf_names:
            return (os.sysconf("SC_PHYS_PAGES") * os.sysconf("SC_PAGE_SIZE")) / 1e9
    except Exception:                                   # noqa: BLE001  # nosec B110
        pass
    try:
        import ctypes

        class _MEMSTATEX(ctypes.Structure):
            _fields_ = [("dwLength", ctypes.c_ulong),
                        ("dwMemoryLoad", ctypes.c_ulong),
                        ("ullTotalPhys", ctypes.c_ulonglong),
                        ("ullAvailPhys", ctypes.c_ulonglong),
                        ("ullTotalPageFile", ctypes.c_ulonglong),
                        ("ullAvailPageFile", ctypes.c_ulonglong),
                        ("ullTotalVirtual", ctypes.c_ulonglong),
                        ("ullAvailVirtual", ctypes.c_ulonglong),
                        ("ullAvailExtendedVirtual", ctypes.c_ulonglong)]
        m = _MEMSTATEX()
        m.dwLength = ctypes.sizeof(_MEMSTATEX)
        if ctypes.windll.kernel32.GlobalMemoryStatusEx(ctypes.byref(m)):
            return m.ullTotalPhys / 1e9
    except Exception:                                   # noqa: BLE001  # nosec B110
        pass
    return None


# --------------------------------------------------------------------------- #
#  Persistence
# --------------------------------------------------------------------------- #
def _settings_get():
    try:
        from qgis.core import QgsSettings
        return QgsSettings().value(_KEY, None)
    except Exception:                                   # noqa: BLE001
        return None


def _settings_set(prof, ms):
    try:
        from qgis.core import QgsSettings
        s = QgsSettings()
        s.setValue(_KEY, prof)
        if ms is not None:
            s.setValue(_KEY_MS, round(ms, 1))
    except Exception:                                   # noqa: BLE001  # nosec B110
        pass
