#!/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 ein
                 Runtime.evaluate und misst, ob/wie schnell eine Antwort
                 kommt. Ein haengender JS-Hauptthread antwortet nicht,
                 auch wenn das zuletzt gerenderte Bild noch "lebendig"
                 aussieht (Verdachtsfall Player 664/Villars, 01.10.2026).

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.
"""

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"

CDP_HOST = "127.0.0.1"
CDP_PORT = 9222
EVAL_TIMEOUT_S = 4
FAIL_THRESHOLD = 3
BRIGHTNESS_LOW = 0.03
BRIGHTNESS_HIGH = 0.985
VISUAL_CHECK_ACTIVE = False  # nur Logging - Begruendung siehe Projektchat 2026-10-03


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 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 check_responsiveness():
    """Verbindet, schickt Runtime.evaluate, misst Antwortzeit.
    Gibt (responsive, elapsed_ms, crashed) zurueck."""
    ws_url = get_page_ws_url()
    if ws_url is None:
        return False, None, False
    conn = CDPConnection(ws_url, timeout=EVAL_TIMEOUT_S)
    try:
        conn.send("Inspector.enable")
        t0 = time.time()
        msg_id = conn.send("Runtime.evaluate", {"expression": "1+1", "returnByValue": True})
        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)
        if resp is None or "error" in resp:
            return False, elapsed_ms, crashed
        return True, elapsed_ms, crashed
    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

    try:
        responsive, elapsed_ms, crashed = check_responsiveness()
    except Exception as e:
        log(f"WARN CDP-Verbindung fehlgeschlagen: {e}")
        responsive, elapsed_ms, crashed = False, None, False

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

    if responsive and not visual_fail and not crashed:
        if read_failstreak():
            log(f"OK (eval_ms={elapsed_ms}, brightness={brightness}) - Fehlstreak zurueckgesetzt")
        write_failstreak(0)
        return

    streak = read_failstreak() + 1
    write_failstreak(streak)
    log(f"FAIL responsive={responsive} eval_ms={elapsed_ms} brightness={brightness} "
        f"crashed={crashed} streak={streak}/{FAIL_THRESHOLD}")

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


if __name__ == "__main__":
    main()
