"""Poll-driven game telemetry.

Most games push: they POST their state to /api/gsi/<game> while you play and
the adapter parses whatever arrives. Some games never push. They serve a
local endpoint and expect to be ASKED — League of Legends is the obvious
one, whose Live Client Data API sits on https://127.0.0.1:2999 and answers
only while a match is running.

An adapter opts in by saying where to look:

    POLL_URL = "https://127.0.0.1:2999/liveclientdata/allgamedata"
    POLL_INTERVAL_S = 1.0      # while it is answering
    POLL_IDLE_S = 5.0          # while it is not (no game running)
    POLL_INSECURE = True       # its certificate is self-signed
    POLL_RESET_AFTER_S = 60    # a longer gap means "different match"

or by defining `poll()` itself and returning a payload (or None for "nothing
there right now"), which is the escape hatch for anything stranger than an
HTTP GET.

Everything that comes back goes through the same `adapter.parse(payload,
state)` and the same writer as a pushed payload, so downstream a polled
game is indistinguishable from a pushed one: same events table, same
markers, same chapters.

Two rules worth stating, because they are what keeps this from being a
generic "fetch a URL the manifest names" primitive:

* POLL_URL must be a loopback address. A plugin is unsandboxed code and can
  obviously open its own sockets in `poll()`, but the declarative path that
  the app itself drives only ever talks to this machine.
* A closed port is the normal state, not a problem. Nothing is reported to
  the user for a refused connection — the game simply is not running.

`state` is handed back to the adapter across app restarts (it is written to
DATA/poll_state/<game>.json, so it must stay JSON-serializable). That is
what stops a restart in the middle of a match from re-reading a cumulative
event log from the top and marking every play in it twice. Deciding whether
the restored state still belongs to what is on screen is the adapter's job —
it is the only one that knows what a "new match" looks like.
"""
import json
import ssl
import threading
import time
import urllib.request
from urllib.parse import urlparse

from clipshelf import problems

LOOPBACK = {"127.0.0.1", "localhost", "::1"}
MAX_BYTES = 4 * 1024 * 1024     # allgamedata is ~100 KB; this is a sanity cap
FETCH_TIMEOUT_S = 3.0

_stop = threading.Event()
_SAVE_EVERY_S = 5.0
_threads: dict = {}      # slug -> Thread
_stats: dict = {}        # slug -> {"last_ok", "events", "errors", "polls"}


def _is_loopback(url: str) -> bool:
    try:
        host = (urlparse(url).hostname or "").lower()
    except ValueError:
        return False
    return host in LOOPBACK


def _context(url: str, insecure: bool):
    """Loopback TLS with verification off, when the adapter asks for it.

    The games that serve these endpoints sign them with their own root (Riot
    publishes riotgames.pem for exactly this), and the connection never
    leaves the machine — there is no network path for anyone to be in the
    middle of. Verification is still ON for anything that is not loopback,
    which combined with the loopback-only rule in start() means this branch
    is unreachable for a remote host rather than merely discouraged.
    """
    if not url.lower().startswith("https"):
        return None
    if not (insecure and _is_loopback(url)):
        return None
    ctx = ssl.create_default_context()
    ctx.check_hostname = False
    ctx.verify_mode = ssl.CERT_NONE
    return ctx


def fetch(url: str, insecure: bool = False, timeout: float = FETCH_TIMEOUT_S):
    """GET url and parse it as JSON. Raises like urllib does."""
    req = urllib.request.Request(url, headers={"Accept": "application/json"})
    with urllib.request.urlopen(req, timeout=timeout,
                                context=_context(url, insecure)) as r:
        raw = r.read(MAX_BYTES + 1)
    if len(raw) > MAX_BYTES:
        raise ValueError(f"{url} returned more than {MAX_BYTES} bytes")
    return json.loads(raw.decode("utf-8", "replace"))


def _state_path(slug: str):
    from clipshelf.config import DATA
    return DATA / "poll_state" / f"{slug}.json"


def load_state(slug: str) -> dict:
    """The adapter's state as of the last time this app ran.

    Deliberately NOT expired by age: the watermark is a position in the
    game's own log, so an adapter that comes back to a match still in
    progress emits exactly what happened while the app was down — no gap,
    no duplicates — and one that comes back to a different match sees a
    clock that has gone backwards and starts over.
    """
    try:
        raw = json.loads(_state_path(slug).read_text(encoding="utf-8"))
        return raw if isinstance(raw, dict) else {}
    except Exception:
        return {}


def save_state(slug: str, state: dict) -> bool:
    try:
        p = _state_path(slug)
        p.parent.mkdir(parents=True, exist_ok=True)
        tmp = p.with_suffix(".json.tmp")
        tmp.write_text(json.dumps(state), encoding="utf-8")
        tmp.replace(p)
        return True
    except Exception:
        # A state that cannot be written costs duplicate markers after a
        # restart, which is not worth taking the poller down for.
        return False


def polled(adapter) -> bool:
    return bool(callable(getattr(adapter, "poll", None))
                or getattr(adapter, "POLL_URL", ""))


def _loop(slug: str, adapter):
    from clipshelf import gsi_api

    poll = getattr(adapter, "poll", None)
    url = str(getattr(adapter, "POLL_URL", "") or "")
    insecure = bool(getattr(adapter, "POLL_INSECURE", False))
    live_s = max(0.2, float(getattr(adapter, "POLL_INTERVAL_S", 1.0) or 1.0))
    idle_s = max(1.0, float(getattr(adapter, "POLL_IDLE_S", 5.0) or 5.0))
    reset_s = max(5.0, float(getattr(adapter, "POLL_RESET_AFTER_S", 60) or 60))
    st = _stats.setdefault(slug, {"last_ok": 0, "events": 0, "errors": 0, "polls": 0})

    state = load_state(slug)
    saved = json.dumps(state, sort_keys=True)
    saved_at = 0.0
    while not _stop.is_set():
        wait = idle_s
        payload = None
        try:
            st["polls"] += 1
            payload = poll() if callable(poll) else fetch(url, insecure)
        except Exception:
            # Refused/timed out/404 — the game is not running. This is the
            # steady state on a machine that owns the pack but is not
            # playing right now, so it is deliberately silent.
            payload = None

        if payload is not None:
            now = time.time()
            # A gap means the endpoint went away and came back: a different
            # match, whose event log starts from zero again. Handing the
            # adapter its old state would make it skip the new match's first
            # events as "already seen".
            if st["last_ok"] and now - st["last_ok"] > reset_s:
                state = {}
            st["last_ok"] = now
            wait = live_s
            events = []
            try:
                events = adapter.parse(payload, state) or []
            except Exception as e:                              # noqa: BLE001
                st["errors"] += 1
                problems.record(f"{slug} adapter", e)
            if events:
                try:
                    gsi_api.record_events(slug, events)
                    st["events"] += len(events)
                except Exception as e:                          # noqa: BLE001
                    st["errors"] += 1
                    problems.record(f"{slug} markers", e)
            # Persist the watermark. Immediately when markers were just
            # emitted — that is precisely the moment a crash would cost
            # duplicates — and otherwise throttled, since this runs once a
            # second and the rest of the state is only a clock reading.
            if events or now - saved_at >= _SAVE_EVERY_S:
                try:
                    now_json = json.dumps(state, sort_keys=True)
                except (TypeError, ValueError):
                    now_json = saved      # adapter kept something unserializable
                if now_json != saved and save_state(slug, state):
                    saved, saved_at = now_json, now
                else:
                    saved_at = now
        _stop.wait(wait)


def start(adapters=None):
    """One thread per polled adapter. Call after plugins are loaded."""
    from clipshelf.plugin_host import registry
    if adapters is None:
        adapters = registry.game_adapters
    _stop.clear()
    started = []
    for slug, adapter in sorted(adapters.items()):
        if slug in _threads and _threads[slug].is_alive():
            continue
        if not polled(adapter):
            continue
        url = str(getattr(adapter, "POLL_URL", "") or "")
        if url and not callable(getattr(adapter, "poll", None)) and not _is_loopback(url):
            print(f"[poll] {slug}: POLL_URL must be a loopback address "
                  f"(got {url!r}) — write poll() if you need the network",
                  flush=True)
            continue
        t = threading.Thread(target=_loop, args=(slug, adapter),
                             daemon=True, name=f"poll-{slug}")
        _threads[slug] = t
        t.start()
        started.append(slug)
    if started:
        print(f"[poll] asking {', '.join(started)} for events", flush=True)
    return started


def stop():
    _stop.set()


def status() -> dict:
    """What each poller has managed so far — for tests and for answering
    'is it even looking?' without reading the log."""
    return {slug: dict(_stats.get(slug, {}), alive=t.is_alive())
            for slug, t in _threads.items()}
