#!/usr/bin/env python3
"""Run inside mugi-neko: page-level CDP against 127.0.0.1:9223 (Chromium direct; no host socat).

Reads JSON job from path argv[1]: {"target_id": str, "steps": [[method, params], ...], "timeout": float}
Prints one JSON line to stdout: {"ok": true, "result": [...]} or {"ok": false, "error": "..."}
"""
from __future__ import annotations

import base64
import json
import os
import socket
import struct
import sys
import time
from urllib.parse import urlparse
from urllib.request import urlopen

CDP_BASE = "http://127.0.0.1:9223"


def _origin_line(ws_url: str) -> str:
    p = urlparse(ws_url)
    scheme = "https" if (p.scheme or "").lower() == "wss" else "http"
    h = p.hostname or "localhost"
    po = p.port
    origin = f"{scheme}://{h}" + (f":{po}" if po else "")
    return f"Origin: {origin}\r\n"


def _send_json(sock, obj: dict) -> None:
    payload = json.dumps(obj).encode("utf-8")
    mask_key = os.urandom(4)
    ln = len(payload)
    if ln <= 125:
        header = struct.pack("!BB", 0x81, 0x80 | ln) + mask_key
    elif ln <= 65535:
        header = struct.pack("!BBH", 0x81, 0x80 | 126, ln) + mask_key
    else:
        header = struct.pack("!BBQ", 0x81, 0x80 | 127, ln) + mask_key
    masked = bytes(b ^ mask_key[i % 4] for i, b in enumerate(payload))
    sock.sendall(header + masked)


def _read_frame(sock, deadline: float) -> tuple[int, int, bytes] | None:
    if time.monotonic() >= deadline:
        return None
    sock.settimeout(max(0.05, deadline - time.monotonic()))
    try:
        hdr = sock.recv(2)
    except Exception:
        return None
    if len(hdr) < 2:
        return None
    b1, b2 = hdr[0], hdr[1]
    opcode = b1 & 0x0F
    masked = (b2 >> 7) & 1
    plen = b2 & 0x7F
    if plen == 126:
        ext = sock.recv(2)
        if len(ext) < 2:
            return None
        plen = struct.unpack("!H", ext)[0]
    elif plen == 127:
        ext = sock.recv(8)
        if len(ext) < 8:
            return None
        plen = struct.unpack("!Q", ext)[0]
    mask_key = b""
    if masked:
        mask_key = sock.recv(4)
        if len(mask_key) < 4:
            return None
    payload = b""
    while len(payload) < plen:
        sock.settimeout(max(0.05, deadline - time.monotonic()))
        chunk = sock.recv(plen - len(payload))
        if not chunk:
            return None
        payload += chunk
    if masked:
        payload = bytes(payload[i] ^ mask_key[i % 4] for i in range(len(payload)))
    return 1, opcode, payload


def _pong(sock, payload: bytes) -> None:
    mask_key = os.urandom(4)
    ln = len(payload)
    op = 0x8A
    if ln <= 125:
        header = struct.pack("!BB", op, 0x80 | ln) + mask_key
    elif ln <= 65535:
        header = struct.pack("!BBH", op, 0x80 | 126, ln) + mask_key
    else:
        header = struct.pack("!BBQ", op, 0x80 | 127, ln) + mask_key
    if ln == 0:
        sock.sendall(header)
        return
    masked = bytes(payload[i] ^ mask_key[i % 4] for i in range(ln))
    sock.sendall(header + masked)


def _recv_json(sock, deadline: float) -> dict | None:
    while time.monotonic() < deadline:
        fr = _read_frame(sock, deadline)
        if fr is None:
            return None
        _f, opcode, payload = fr
        if opcode == 0x9:
            _pong(sock, payload)
            continue
        if opcode == 0x8:
            return None
        if opcode == 0xA:
            continue
        if opcode == 0x1:
            try:
                return json.loads(payload.decode("utf-8", errors="replace"))
            except json.JSONDecodeError:
                continue
    return None


def _recv_until_id(sock, msg_id: int, deadline: float) -> dict | None:
    while time.monotonic() < deadline:
        msg = _recv_json(sock, deadline)
        if msg is None:
            return None
        if msg.get("id") == msg_id:
            return msg
    return None


def _browser_ws() -> str:
    with urlopen(f"{CDP_BASE}/json/version", timeout=5) as r:
        ver = json.loads(r.read().decode("utf-8"))
    ws = (ver.get("webSocketDebuggerUrl") or "").strip()
    if not ws:
        raise RuntimeError("no browser webSocketDebuggerUrl")
    p = urlparse(ws)
    return f"{p.scheme or 'ws'}://127.0.0.1:9223{p.path}"


def run_session(target_id: str, session_steps: list, timeout: float) -> tuple[bool, list | str]:
    browser_ws = _browser_ws()
    p = urlparse(browser_ws)
    host = p.hostname or "127.0.0.1"
    port = p.port or 9223
    path = p.path or "/"

    sock = socket.create_connection((host, port), timeout=15)
    try:
        key = base64.b64encode(os.urandom(16)).decode()
        req = (
            f"GET {path} HTTP/1.1\r\n"
            f"Host: {host}:{port}\r\n"
            f"{_origin_line(browser_ws)}"
            f"Upgrade: websocket\r\n"
            f"Connection: Upgrade\r\n"
            f"Sec-WebSocket-Key: {key}\r\n"
            f"Sec-WebSocket-Version: 13\r\n"
            f"\r\n"
        )
        sock.sendall(req.encode())
        buf = b""
        while b"\r\n\r\n" not in buf:
            chunk = sock.recv(4096)
            if not chunk:
                return False, "ws upgrade closed"
            buf += chunk
        if b"101" not in buf.split(b"\r\n")[0]:
            return False, "ws upgrade failed"

        deadline = time.monotonic() + timeout
        _send_json(sock, {"id": 1, "method": "Target.attachToTarget", "params": {"targetId": target_id, "flatten": True}})
        event_sid = None
        r1 = None
        while time.monotonic() < deadline:
            msg = _recv_json(sock, deadline)
            if msg is None:
                return False, "attach closed"
            if msg.get("method") == "Target.attachedToTarget":
                pr = msg.get("params") or {}
                es = pr.get("sessionId")
                if isinstance(es, str) and es:
                    event_sid = es
            if msg.get("id") == 1:
                r1 = msg
                break
        if not r1 or "error" in r1:
            return False, str((r1 or {}).get("error", "attach timeout"))
        resp_sid = (r1.get("result") or {}).get("sessionId")
        sid = resp_sid if (isinstance(resp_sid, str) and resp_sid) else event_sid
        if not sid:
            return False, "no sessionId"

        _send_json(sock, {"id": 2, "method": "Runtime.runIfWaitingForDebugger", "params": {}, "sessionId": sid})
        _recv_until_id(sock, 2, deadline)

        out: list = []
        nid = 3
        for step in session_steps:
            method, params = step[0], step[1] if len(step) > 1 else {}
            if not isinstance(params, dict):
                params = {}
            _send_json(sock, {"id": nid, "method": method, "params": params, "sessionId": sid})
            rn = _recv_until_id(sock, nid, deadline)
            if not rn:
                return False, f"timeout: {method}"
            if "error" in rn:
                return False, f"{method}: {rn.get('error')}"
            out.append(rn.get("result") or {})
            nid += 1
        return True, out
    finally:
        try:
            sock.close()
        except Exception:
            pass


def main() -> None:
    try:
        with open(sys.argv[1], encoding="utf-8") as f:
            job = json.load(f)
        tid = job["target_id"]
        steps = job["steps"]
        timeout = float(job.get("timeout") or 45.0)
        ok, res = run_session(tid, steps, timeout)
        if ok:
            print(json.dumps({"ok": True, "result": res}, ensure_ascii=False), flush=True)
        else:
            print(json.dumps({"ok": False, "error": str(res)}, ensure_ascii=False), flush=True)
    except Exception as e:
        print(json.dumps({"ok": False, "error": str(e)}, ensure_ascii=False), flush=True)
        sys.exit(1)


if __name__ == "__main__":
    main()
