#!/usr/bin/env python3
"""
cdp_check.py - Einmaliger Reaktionsfaehigkeits-Check des Chromium-Renderers.

Wird von 304.sh bei jedem Poll-Zyklus aufgerufen, wenn Airtime ist UND
Chromium bereits laeuft (gleiche Stelle wie chromon.sh). Ergaenzt
chromon.sh, deckt aber eine andere Luecke ab:

  chromon.sh  -> erkennt echte Chromium-Crashes ueber Crash-Dump-Dateien
                 (.dmp). Kann einen Renderer, der haengt OHNE zu crashen,
                 strukturell nicht erkennen - es gibt keinen Dump.
  cdp_check.py -> schickt ueber das Chrome DevTools Protocol zwei
                 gestaffelte Checks:
                 1) Runtime.evaluate("1+1") - prueft den Renderer-
                    Hauptthread. Haengt er, kommt keine Antwort.
                 2) Nur falls (1) erfolgreich: ein doppeltes
                    requestAnimationFrame via Runtime.evaluate -
                    prueft, ob der Compositor noch echte Frames
                    produziert. rAF-Callbacks sind an den tatsaechlichen
                    Display-/Compositor-Zyklus gekoppelt (in moderner
                    Chromium-Architektur im GPU-Prozess) - haengt der
                    GPU-/Compositor-Prozess OHNE dass der Renderer
                    betroffen ist, bleibt (1) erfolgreich, aber (2)
                    timet aus. Genau dieser Fall (Renderer lebt, Bild
                    friert ein) war der Verdacht bei Player 664/Villars,
                    01.10.2026 - verifiziert am 05.10.2026 auf Player 103
                    per SIGSTOP auf den echten GPU-Prozess: plain eval
                    blieb bei ~2ms, rAF lief zuverlaessig in den Timeout.

Opt-in AUSSCHLIESSLICH ueber CDF=on in mc.cfg (gleiches Muster wie
DISPLAYCTL/PANELTYPE). Fehlt das Feld (Default auf allen Feldgeraeten),
ist dieses Skript ein stiller No-Op und chrom.sh oeffnet keinen Debug-Port.

Hintergrund der Opt-in-Pflicht: --remote-debugging-port wurde am 12.03.2026
bereits einmal flottenweit aktiviert und am 16.03.2026 wieder bewusst
entfernt (siehe CONTEXT.md, dokumente/player_doku_techie.md:
"Remote-Debugging-Port wird bewusst nicht verwendet (Sicherheit)"). Dieses
Skript darf diese Entscheidung nicht unterlaufen - der Port bleibt auf
allen Geraeten ohne explizites CDF=on geschlossen.

Bei anhaltendem Fehler (FAIL_THRESHOLD Zyklen in Folge, persistiert in
FAILSTREAK_FILE) oder einem expliziten Crash-Event wird Chromium beendet.
Den Neustart selbst uebernimmt NICHT dieses Skript, sondern 304.sh beim
naechsten Poll-Zyklus (CHROMIUMSTAT == down -> bestehende setx()+chrom.sh-
Logik) - kein doppelter Restart-Pfad.

Jeder nennenswerte Fund (FAIL-Klassifikation oder JS-Exception, NICHT
jeder gesunde Zyklus) wird zusaetzlich als "cdpEvent" an den Server
hochgeladen - selbes Endpunkt-/Auth-Muster wie selfheal.sh. ACHTUNG:
das ist bisher nur die Player-seitige Haelfte - der Server muss den
neuen Root-Key "cdpEvent" noch erkennen und persistieren, siehe
player_doku_techie.md Abschnitt 2.5/7 (serverseitig NICHT Teil dieses
Repos).
"""

import base64
import json
import os
import socket
import struct
import subprocess
import sys
import time
import urllib.request

BIN = os.path.expanduser("~/bin")
MC_CFG = f"{BIN}/mc.cfg"
LOG_FILE = "/var/log/stv.log"
FAILSTREAK_FILE = f"{BIN}/cdp_failstreak"
SCREENSHOT_PATH = "/tmp/cdp_check_shot.png"
CHANNEL_MARKER = "/tmp/channel.txt"
STARTUP_GRACE_S = 8  # chrom.sh schreibt CHANNEL_MARKER als letzten Schritt

CDP_HOST = "127.0.0.1"
CDP_PORT = 9222
EVAL_TIMEOUT_S = 4
EXTRA_LISTEN_S = 2  # zusaetzliches Lauschfenster fuer Runtime.exceptionThrown nach den Checks
FAIL_THRESHOLD = 3
BRIGHTNESS_LOW = 0.03
BRIGHTNESS_HIGH = 0.985
VISUAL_CHECK_ACTIVE = False  # nur Logging - Begruendung siehe Projektchat 2026-10-03

RAF_EXPR = (
    "new Promise(resolve => "
    "requestAnimationFrame(() => requestAnimationFrame(() => resolve(Date.now()))))"
)


def log(msg):
    line = f"{time.strftime('%Y-%m-%d %H:%M:%S')} cdp_check: {msg}"
    try:
        with open(LOG_FILE, "a") as f:
            f.write(line + "\n")
    except OSError:
        pass
    print(line)


def cdf_enabled():
    try:
        with open(MC_CFG) as f:
            for line in f:
                if line.strip() == "CDF=on":
                    return True
    except FileNotFoundError:
        pass
    return False


def recently_restarted():
    """chrom.sh schreibt CHANNEL_MARKER als letzten Schritt des Starts.
    Ist das frisch, startet Chromium gerade erst (z.B. hat chromon.sh im
    selben 304.sh-Zyklus kurz zuvor neu gestartet) - der Debug-Port
    braucht noch einen Moment zum Binden. Ohne diese Gnadenfrist wuerde
    jeder Neustart durch chromon.sh (Crash/Prozess-Tod/Zoom) hier einen
    spurious 'CDP-Verbindung fehlgeschlagen'-Fehlzyklus erzeugen, obwohl
    Chromium nur gerade erst hochfaehrt."""
    try:
        age = time.time() - os.path.getmtime(CHANNEL_MARKER)
        return 0 <= age < STARTUP_GRACE_S
    except OSError:
        return False


def upload_event(event_type, detail, streak=None, action="logged"):
    """Laedt ein nennenswertes Ereignis an den Server - selbes Endpunkt-/
    Auth-/curl-Muster wie selfheal.sh (_upload dort). Best effort: ein
    Upload-Fehler (kein Netz, Server down) wird nicht weiter behandelt,
    es gibt keine Retry-/Queue-Logik - der naechste Fund versucht es
    einfach erneut. Serverseitig muss der Root-Key "cdpEvent" noch
    implementiert werden (Erkennung + Persistierung), siehe
    player_doku_techie.md."""
    try:
        with open(f"{BIN}/token") as f:
            tok = f.read().strip()
        if not tok:
            return
        id_ = subprocess.run([f"{BIN}/getid.sh"], capture_output=True,
                              text=True, timeout=5).stdout.strip()
        domain = subprocess.run([f"{BIN}/getdomain.sh"], capture_output=True,
                                 text=True, timeout=5).stdout.strip().strip("'")
        payload = json.dumps({"cdpEvent": {
            "ts": time.strftime("%Y-%m-%dT%H:%M:%S"),
            "type": event_type,
            "detail": detail[:500],
            "streak": streak,
            "action": action,
        }})
        subprocess.run(
            ["curl", "-k", "--connect-timeout", "10", "--max-time", "20", "-s", "-o", "/dev/null",
             "--data", payload,
             "-H", f"APG-Authorization: {tok}",
             f"{domain}/apg-mp?device={id_}",
             "--user-agent", "apg-mp"],
            check=False, timeout=25,
        )
    except Exception:
        pass


def read_failstreak():
    try:
        with open(FAILSTREAK_FILE) as f:
            return int(f.read().strip())
    except (FileNotFoundError, ValueError):
        return 0


def write_failstreak(n):
    with open(FAILSTREAK_FILE, "w") as f:
        f.write(str(n))


def get_page_ws_url():
    with urllib.request.urlopen(f"http://{CDP_HOST}:{CDP_PORT}/json", timeout=3) as r:
        targets = json.loads(r.read())
    for t in targets:
        if t.get("type") == "page":
            return t["webSocketDebuggerUrl"]
    return None


class CDPConnection:
    """Minimaler WebSocket-Client fuer CDP, bewusst ohne externe
    Abhaengigkeiten (kein pip/websocket-client auf den Playern)."""

    def __init__(self, ws_url, timeout):
        self.sock = self._handshake(ws_url, timeout)
        self.sock.settimeout(timeout)
        self._next_id = 1

    @staticmethod
    def _handshake(ws_url, timeout):
        assert ws_url.startswith("ws://")
        rest = ws_url[len("ws://"):]
        hostport, _, path = rest.partition("/")
        path = "/" + path
        host, _, port = hostport.partition(":")
        port = int(port) if port else 80

        key = base64.b64encode(os.urandom(16)).decode()
        req = (
            f"GET {path} HTTP/1.1\r\n"
            f"Host: {host}:{port}\r\n"
            "Upgrade: websocket\r\n"
            "Connection: Upgrade\r\n"
            f"Sec-WebSocket-Key: {key}\r\n"
            "Sec-WebSocket-Version: 13\r\n"
            "\r\n"
        ).encode()

        s = socket.create_connection((host, port), timeout=timeout)
        s.sendall(req)
        resp = b""
        while b"\r\n\r\n" not in resp:
            chunk = s.recv(4096)
            if not chunk:
                raise ConnectionError("CDP handshake: Verbindung geschlossen")
            resp += chunk
        if b" 101 " not in resp.split(b"\r\n", 1)[0]:
            raise ConnectionError(f"CDP handshake fehlgeschlagen: {resp[:200]!r}")
        return s

    def send(self, method, params=None):
        msg_id = self._next_id
        self._next_id += 1
        payload = json.dumps({"id": msg_id, "method": method, "params": params or {}}).encode()
        self._send_frame(payload)
        return msg_id

    def _send_frame(self, payload):
        mask = os.urandom(4)
        masked = bytes(b ^ mask[i % 4] for i, b in enumerate(payload))
        length = len(payload)
        header = bytearray([0x81])
        if length < 126:
            header.append(0x80 | length)
        elif length < 65536:
            header.append(0x80 | 126)
            header += struct.pack(">H", length)
        else:
            header.append(0x80 | 127)
            header += struct.pack(">Q", length)
        self.sock.sendall(bytes(header) + mask + masked)

    def _read_frame(self, timeout):
        self.sock.settimeout(timeout)
        header = self._recv_exact(2)
        b1, b2 = header[0], header[1]
        opcode = b1 & 0x0F
        masked = b2 & 0x80
        length = b2 & 0x7F
        if length == 126:
            length = struct.unpack(">H", self._recv_exact(2))[0]
        elif length == 127:
            length = struct.unpack(">Q", self._recv_exact(8))[0]
        if masked:
            mask = self._recv_exact(4)
            data = self._recv_exact(length)
            data = bytes(b ^ mask[i % 4] for i, b in enumerate(data))
        else:
            data = self._recv_exact(length)
        return opcode, data

    def _recv_exact(self, n):
        chunks = []
        remaining = n
        while remaining > 0:
            chunk = self.sock.recv(remaining)
            if not chunk:
                raise ConnectionError("CDP: Verbindung waehrend Empfang geschlossen")
            chunks.append(chunk)
            remaining -= len(chunk)
        return b"".join(chunks)

    def recv_until(self, want_id, overall_timeout):
        deadline = time.time() + overall_timeout
        events = []
        while True:
            remaining = deadline - time.time()
            if remaining <= 0:
                return None, events
            try:
                opcode, data = self._read_frame(remaining)
            except socket.timeout:
                return None, events
            if opcode == 0x8:
                raise ConnectionError("CDP: Server hat Verbindung geschlossen")
            if opcode != 0x1:
                continue
            try:
                msg = json.loads(data.decode())
            except ValueError:
                continue
            if msg.get("id") == want_id:
                return msg, events
            if "method" in msg:
                events.append(msg)

    def close(self):
        try:
            self.sock.close()
        except OSError:
            pass


def _extract_exceptions(events):
    """Filtert Runtime.exceptionThrown-Events heraus (unbehandelte
    Exceptions UND unbehandelte Promise-Rejections - Chromium meldet
    beide ueber dasselbe Event) und extrahiert eine kompakte
    Beschreibung je Fund."""
    out = []
    for e in events:
        if e.get("method") != "Runtime.exceptionThrown":
            continue
        details = e.get("params", {}).get("exceptionDetails", {})
        text = details.get("exception", {}).get("description") or details.get("text", "?")
        url = details.get("url", "?")
        line = details.get("lineNumber")
        out.append(f"{text.splitlines()[0]} ({url}:{line})")
    return out


def _evaluate(conn, expression, await_promise=False):
    """Schickt ein Runtime.evaluate, misst Antwortzeit, sammelt
    nebenbei eintreffende Events (Inspector.targetCrashed,
    Runtime.exceptionThrown). Gibt (ok, elapsed_ms, crashed, exceptions)
    zurueck."""
    t0 = time.time()
    msg_id = conn.send("Runtime.evaluate", {
        "expression": expression, "returnByValue": True, "awaitPromise": await_promise,
    })
    resp, events = conn.recv_until(msg_id, EVAL_TIMEOUT_S)
    elapsed_ms = int((time.time() - t0) * 1000)
    crashed = any(e.get("method") == "Inspector.targetCrashed" for e in events)
    exceptions = _extract_exceptions(events)
    ok = resp is not None and "error" not in resp
    return ok, elapsed_ms, crashed, exceptions


def check_responsiveness():
    """Zwei gestaffelte CDP-Checks, siehe Modul-Docstring fuer die
    Begruendung. Gibt (renderer_ok, renderer_ms, raf_ok, raf_ms, crashed,
    exceptions) zurueck. raf_ok/raf_ms sind None, wenn schon der
    Renderer-Check fehlschlug (rAF wuerde dann ohnehin nie ausgeloest -
    kein zusaetzlicher Zeitverlust durch einen zweiten Timeout).

    exceptions ist eine Liste unbehandelter JS-Exceptions/Promise-
    Rejections, die waehrend der Checks auf der Seite auftraten (z.B.
    ein Fehler in preloadPage()'s Fetch-Kette in tv-5.js) - rein
    diagnostisch, geht NICHT in die Fehlstreak-/Remediation-Logik ein
    (ein einzelner Exception-Fund ist kein verlaessliches Hang-Signal,
    siehe Begruendung im Projektchat 05.10.2026)."""
    ws_url = get_page_ws_url()
    if ws_url is None:
        return False, None, None, None, False, []
    conn = CDPConnection(ws_url, timeout=EVAL_TIMEOUT_S)
    try:
        conn.send("Inspector.enable")
        conn.send("Runtime.enable")
        # Runtime.enable liefert gepufferte, bereits vergangene Exceptions
        # erneut aus (fuer die Konsolen-Ansicht gedacht, falls DevTools erst
        # NACH dem Fehler geoeffnet wird) - ohne discardConsoleEntries wuerde
        # jede alte Exception bei jedem der ~4 Zyklen/Minute erneut geloggt,
        # bis die Seite neu laedt. Gefunden + behoben 05.10.2026 auf Player 103.
        conn.send("Runtime.discardConsoleEntries")
        renderer_ok, renderer_ms, crashed1, exc1 = _evaluate(conn, "1+1")
        if not renderer_ok:
            return False, renderer_ms, None, None, crashed1, exc1
        raf_ok, raf_ms, crashed2, exc2 = _evaluate(conn, RAF_EXPR, await_promise=True)

        # Zusaetzliches Lauschfenster: bei gesundem System sind die beiden
        # Checks oben zusammen nur ~20-30ms offen - eine Exception, die
        # knapp daneben feuert, waere sonst bis zum naechsten Zyklus (~15s)
        # unsichtbar. EXTRA_LISTEN_S verbreitert das Fenster deutlich, ohne
        # die ~15s-Taktung spuerbar zu belasten.
        _, extra_events = conn.recv_until(-999, EXTRA_LISTEN_S)
        exc3 = _extract_exceptions(extra_events)
        crashed3 = any(e.get("method") == "Inspector.targetCrashed" for e in extra_events)

        return True, renderer_ms, raf_ok, raf_ms, (crashed1 or crashed2 or crashed3), exc1 + exc2 + exc3
    finally:
        conn.close()


def check_visual_brightness():
    try:
        subprocess.run(
            ["import", "-window", "root", SCREENSHOT_PATH],
            env={**os.environ, "DISPLAY": ":0.0"},
            check=True, timeout=5,
        )
        out = subprocess.run(
            ["identify", "-format", "%[fx:mean]", SCREENSHOT_PATH],
            check=True, timeout=5, capture_output=True, text=True,
        )
        return float(out.stdout.strip())
    except Exception:
        return None


def main():
    if not cdf_enabled():
        return  # stiller No-Op - kein CDF=on in mc.cfg

    if recently_restarted():
        return  # stiller Skip - Chromium startet gerade erst (Gnadenfrist)

    try:
        renderer_ok, renderer_ms, raf_ok, raf_ms, crashed, exceptions = check_responsiveness()
    except Exception as e:
        log(f"WARN CDP-Verbindung fehlgeschlagen: {e}")
        renderer_ok, renderer_ms, raf_ok, raf_ms, crashed, exceptions = False, None, None, None, False, []

    for exc in exceptions:
        # Rein diagnostisch - unbehandelte JS-Exception/Promise-Rejection auf
        # der Seite (z.B. Fehler in tv-5.js), beeinflusst Fehlstreak/
        # Remediation bewusst NICHT (ein einzelner Fund ist kein
        # verlaessliches Hang-Signal, siehe BACKLOG.md)
        log(f"JS-EXCEPTION {exc}")
        upload_event("exception", exc)

    brightness = check_visual_brightness()
    visual_ok = brightness is None or (BRIGHTNESS_LOW < brightness < BRIGHTNESS_HIGH)
    visual_fail = VISUAL_CHECK_ACTIVE and not visual_ok

    healthy = renderer_ok and raf_ok is not False and not visual_fail and not crashed

    if healthy:
        suffix = " - Fehlstreak zurueckgesetzt" if read_failstreak() else ""
        log(f"OK (renderer_ms={renderer_ms}, raf_ms={raf_ms}, brightness={brightness}){suffix}")
        write_failstreak(0)
        return

    streak = read_failstreak() + 1
    write_failstreak(streak)

    if not renderer_ok:
        klass = "RENDERER-HANG"
    elif raf_ok is False:
        klass = "GPU/COMPOSITOR-HANG"
    elif visual_fail:
        klass = "VISUAL"
    else:
        klass = "UNBEKANNT"

    log(f"FAIL [{klass}] renderer_ok={renderer_ok} renderer_ms={renderer_ms} "
        f"raf_ok={raf_ok} raf_ms={raf_ms} brightness={brightness} crashed={crashed} "
        f"streak={streak}/{FAIL_THRESHOLD}")

    will_restart = crashed or streak >= FAIL_THRESHOLD
    upload_event(klass.lower().replace("/", "_"),
                 f"renderer_ms={renderer_ms} raf_ms={raf_ms} crashed={crashed}",
                 streak=streak, action="restart" if will_restart else "logged")

    if will_restart:
        log("Neustart ausgeloest (killall chromium) - naechster 304.sh-Zyklus startet neu")
        subprocess.run(["killall", "chromium"], check=False)
        write_failstreak(0)


if __name__ == "__main__":
    main()
