#!/usr/bin/env python3
"""
ocweb_server.py — render server for OCBrowser (OpenComputers).

A real Chromium (Playwright) loads the page with JavaScript and CSS; the server
turns the visible part into a grid of character cells the OC screen can show:
  * background, pictures and boxes -> two square "semi-pixels" per cell
    (upper/lower half), dithered to the 240-colour palette of a tier-3 GPU
  * text -> real characters at their positions, in their colours
  * links and input fields -> clickable boxes
The browser in OpenOS sends clicks, typed text, keys and scrolling back, so
interactive sites (logins, search, Gmail…) work like a very low-resolution
remote browser (the Browsh / Opera Mini idea).

Install (once):   py -m pip install playwright pillow numpy
                  py -m playwright install chromium
Run:              py ocweb_server.py --port 8765
In OCBrowser:     menu → Render server → http://<this PC>:8765

Security: pages, cookies and everything typed go through this server in plain
HTTP. Run it on your own PC/LAN (or put HTTPS in front of it).

Protocol (all GET, binary answer "OCF2" frame or text "ERR ..."):
  /open?url=&cols=&rows=[&sid=]   new or existing session, navigate
  /frame?sid=[&row=N]             current view, optionally scrolled to row N
  /click?sid=&x=&y=               click at cell (x, y) of the view (1-based)
  /type?sid=&text=                type text into the focused element
  /key?sid=&key=Enter|Tab|Backspace|Escape|ArrowDown…
  /back?sid= /forward?sid= /reload?sid= /close?sid=
"""
from __future__ import annotations

import argparse
import io
import struct
import sys
import threading
import time
import uuid
from http.server import BaseHTTPRequestHandler, HTTPServer
from urllib.parse import parse_qs, urlparse

import numpy as np
from PIL import Image

CW, CH = 8, 16                     # css pixels per character cell
UA = ("Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) "
      "Chrome/124.0 Safari/537.36 OCBrowser-render/1.0")

# ---------------------------------------------------------------- palette
# tier-3 GPU fixed colours: 6 red x 8 green x 5 blue levels (indices 16..255)
R_LV = np.array([round(i * 255 / 5) for i in range(6)])
G_LV = np.array([round(i * 255 / 7) for i in range(8)])
B_LV = np.array([round(i * 255 / 4) for i in range(5)])


def cube_index(rgb: np.ndarray) -> np.ndarray:
    """nearest cube colour for an (..., 3) float array -> index 16..255"""
    r = np.clip(np.rint(rgb[..., 0] * 5 / 255), 0, 5).astype(int)
    g = np.clip(np.rint(rgb[..., 1] * 7 / 255), 0, 7).astype(int)
    b = np.clip(np.rint(rgb[..., 2] * 4 / 255), 0, 4).astype(int)
    return 16 + r * 40 + g * 5 + b


def cube_rgb(idx: np.ndarray) -> np.ndarray:
    i = idx - 16
    return np.stack([R_LV[i // 40], G_LV[(i // 5) % 8], B_LV[i % 5]], axis=-1).astype(float)


def dither(img: np.ndarray) -> np.ndarray:
    """Floyd-Steinberg to the cube (img: H x W x 3 float) -> H x W indices"""
    a = img.astype(float).copy()
    h, w, _ = a.shape
    out = np.zeros((h, w), int)
    for y in range(h):
        for x in range(w):
            old = a[y, x]
            idx = int(cube_index(old))
            new = cube_rgb(np.array(idx))
            out[y, x] = idx
            err = old - new
            if np.abs(err).max() < 24:      # flat colour close enough: no noise
                continue
            err = err * 0.6                  # damped: smoother photos, less speckle
            if x + 1 < w:
                a[y, x + 1] += err * 7 / 16
            if y + 1 < h:
                if x > 0:
                    a[y + 1, x - 1] += err * 3 / 16
                a[y + 1, x] += err * 5 / 16
                if x + 1 < w:
                    a[y + 1, x + 1] += err * 1 / 16
    return out


# ---------------------------------------------------------------- page JS
# collects visible characters, links and inputs of the viewport (css pixels)
COLLECT_JS = r"""
([CW, CH]) => {
  const vw = innerWidth, vh = innerHeight;
  const chars = [], links = [], inputs = [];
  const seen = new Set();
  const visible = (el) => {
    const s = getComputedStyle(el);
    return s.visibility !== 'hidden' && s.display !== 'none' && parseFloat(s.opacity) > 0.05;
  };
  const walker = document.createTreeWalker(document.body || document.documentElement, NodeFilter.SHOW_TEXT);
  const range = document.createRange();
  let n, budget = 60000;
  while ((n = walker.nextNode()) && budget > 0) {
    const t = n.nodeValue.replace(/\s+/g, ' ');
    if (!t || !t.trim()) continue;
    const el = n.parentElement;
    if (!el || !visible(el)) continue;
    range.selectNodeContents(n);
    const br = range.getBoundingClientRect();
    if (br.bottom < 0 || br.top > vh || br.right < 0 || br.left > vw || br.width === 0) continue;
    const color = getComputedStyle(el).color;
    // split the node into visual lines: a run = consecutive characters with the same top
    let run = null;
    for (let i = 0; i < t.length && budget > 0; i++) {
      const c = t[i];
      range.setStart(n, i); range.setEnd(n, i + 1);
      const r = range.getBoundingClientRect();
      budget--;
      if (r.width === 0 && r.height === 0) continue;
      if (!run || Math.abs(r.top - run.top) > r.height * 0.5) {
        if (run) chars.push([run.left, run.top + run.h / 2, run.text, color]);
        if (/\s/.test(c)) { run = null; continue; }
        run = {left: r.left, top: r.top, h: r.height, text: ''};
      }
      run.text += /\s/.test(c) ? ' ' : c;
    }
    if (run) chars.push([run.left, run.top + run.h / 2, run.text, color]);
  }
  for (const a of document.querySelectorAll('a[href], button, [role=button], [onclick], summary, label[for]')) {
    if (!visible(a)) continue;
    const r = a.getBoundingClientRect();
    if (r.width < 2 || r.height < 2 || r.bottom < 0 || r.top > vh) continue;
    links.push([r.left, r.top, r.width, r.height, a.href || '', (a.innerText || a.getAttribute('aria-label') || '').trim().slice(0, 60)]);
  }
  for (const e of document.querySelectorAll('input, textarea, select, [contenteditable=true]')) {
    if (!visible(e) || e.type === 'hidden') continue;
    const r = e.getBoundingClientRect();
    if (r.width < 2 || r.height < 2 || r.bottom < 0 || r.top > vh) continue;
    inputs.push([r.left, r.top, r.width, r.height, (e.type || e.tagName).toLowerCase(), e.value || '', e.placeholder || '']);
  }
  const de = document.documentElement;
  return {chars, links, inputs, scrollY, height: Math.max(de.scrollHeight, document.body ? document.body.scrollHeight : 0),
          title: document.title, url: location.href};
}
"""
HIDE_TEXT_CSS = ("*{color:transparent!important;text-shadow:none!important;caret-color:transparent!important;"
                 "-webkit-text-fill-color:transparent!important}input::placeholder,textarea::placeholder{color:transparent!important}")


def parse_css_color(s: str):
    s = s.strip()
    if s.startswith("rgb"):
        nums = s[s.index("(") + 1:s.index(")")].replace("/", " ").replace(",", " ").split()
        r, g, b = (float(x) for x in nums[:3])
        a = float(nums[3]) if len(nums) > 3 else 1.0
        return (r, g, b, a)
    return (255.0, 255.0, 255.0, 1.0)


# ---------------------------------------------------------------- sessions
class Session:
    def __init__(self, browser, cols: int, rows: int):
        self.cols, self.rows = cols, rows
        self.ctx = browser.new_context(viewport={"width": cols * CW, "height": rows * CH}, user_agent=UA,
                                       locale="ru-RU", device_scale_factor=1)
        self.page = self.ctx.new_page()
        self.last = time.time()
        self.hide = None

    def resize(self, cols, rows):
        if (cols, rows) != (self.cols, self.rows):
            self.cols, self.rows = cols, rows
            self.page.set_viewport_size({"width": cols * CW, "height": rows * CH})

    def settle(self, timeout=6000):
        try:
            self.page.wait_for_load_state("networkidle", timeout=timeout)
        except Exception:  # noqa: BLE001 - slow pages: show what is there
            pass

    def frame(self) -> bytes:
        cols, rows = self.cols, self.rows
        info = self.page.evaluate(COLLECT_JS, [CW, CH])
        # background without text
        handle = self.page.add_style_tag(content=HIDE_TEXT_CSS)
        png = self.page.screenshot(type="png")
        handle.evaluate("e => e.remove()")
        img = Image.open(io.BytesIO(png)).convert("RGB")
        small = np.asarray(img.resize((cols, rows * 2), Image.BOX), float)
        idx = dither(small)                                   # (rows*2, cols)
        top, bot = idx[0::2], idx[1::2]
        cell_rgb = (small[0::2] + small[1::2]) / 2
        # text: first (largest) character per cell wins
        # text runs: placed one character per cell from the run's left edge, so
        # words stay whole (proportional fonts are narrower or wider than a cell)
        chars = {}
        for x, y, text, color in info["chars"]:
            col, row = int(round(x / CW)), int(y // CH)
            if not (0 <= row < rows):
                continue
            for k, c in enumerate(text.rstrip()):
                cc = col + k
                if cc >= cols:
                    break
                if cc >= 0 and (cc, row) not in chars and c != " ":
                    chars[(cc, row)] = (c, color)
        out = io.BytesIO()
        def s16(v):
            out.write(struct.pack(">H", max(0, min(65535, int(v)))))
        def sstr(t):
            b = t.encode("utf-8")[:65535]
            s16(len(b)); out.write(b)
        out.write(b"OCF2")
        total_rows = int(info["height"] // CH) + 1
        scroll_row = int(round(info["scrollY"] / CH))
        s16(cols); s16(rows); s16(total_rows); s16(scroll_row)
        s16(len(info["links"])); s16(len(info["inputs"]))
        sstr(info["url"]); sstr(info["title"] or "")
        for r in range(rows):
            for c in range(cols):
                ch = chars.get((c, r))
                if ch:
                    bg = cell_rgb[r, c]
                    tr, tg, tb, ta = parse_css_color(ch[1])
                    fg = np.array([tr, tg, tb]) * ta + bg * (1 - ta)
                    # keep text readable on its background
                    lum = lambda v: 0.2126 * v[0] + 0.7152 * v[1] + 0.0722 * v[2]
                    if abs(lum(fg) - lum(bg)) < 60:
                        fg = np.array([255.0, 255, 255]) if lum(bg) < 128 else np.array([0.0, 0, 0])
                    b = int(cube_index(bg)); f = int(cube_index(fg))
                    data = ch[0].encode("utf-8")
                    out.write(bytes([b, b, f, len(data)])); out.write(data)
                else:
                    out.write(bytes([top[r, c], bot[r, c], 0, 0]))
        for x, y, w, h, href, text in info["links"]:
            s16(x // CW + 1); s16(y // CH + 1); s16(max(1, -(-w // CW))); s16(max(1, -(-h // CH)))
            sstr(href); sstr(text)
        for x, y, w, h, typ, val, ph in info["inputs"]:
            s16(x // CW + 1); s16(y // CH + 1); s16(max(1, -(-w // CW))); s16(max(1, -(-h // CH)))
            sstr(typ); sstr(val); sstr(ph)
        return out.getvalue()


class Server:
    def __init__(self, headless=True, chrome=None):
        from playwright.sync_api import sync_playwright
        self.pw = sync_playwright().start()
        opts = {"headless": headless}
        if chrome:
            opts["executable_path"] = chrome
        self.browser = self.pw.chromium.launch(**opts)
        self.sessions: dict[str, Session] = {}
        self.lock = threading.Lock()

    def session(self, q, create=False) -> Session:
        sid = q.get("sid", [""])[0]
        s = self.sessions.get(sid)
        if s is None:
            if not create:
                raise KeyError("unknown session (it expired?) - open the page again")
            cols = max(20, min(320, int(q.get("cols", ["150"])[0])))
            rows = max(5, min(100, int(q.get("rows", ["44"])[0])))
            s = Session(self.browser, cols, rows)
            sid = uuid.uuid4().hex[:12]
            self.sessions[sid] = s
        s.sid = sid
        s.last = time.time()
        if "cols" in q and "rows" in q:
            s.resize(max(20, min(320, int(q["cols"][0]))), max(5, min(100, int(q["rows"][0]))))
        return s

    def gc(self):
        for sid, s in list(self.sessions.items()):
            if time.time() - s.last > 1800:
                try:
                    s.ctx.close()
                except Exception:  # noqa: BLE001
                    pass
                del self.sessions[sid]

    def handle(self, path: str, q: dict) -> bytes:
        with self.lock:
            self.gc()
            if path == "/open":
                s = self.session(q, create=True)
                url = q.get("url", ["about:blank"])[0]
                try:
                    s.page.goto(url, timeout=20000, wait_until="domcontentloaded")
                except Exception as e:  # noqa: BLE001
                    if "Timeout" not in str(e):
                        return f"ERR {e}".encode()
                s.settle()
            else:
                s = self.session(q)
                if path == "/frame":
                    if "row" in q:
                        s.page.evaluate("y => scrollTo(0, y)", int(q["row"][0]) * CH)
                        time.sleep(0.15)
                elif path == "/click":
                    x, y = float(q["x"][0]) - 0.5, float(q["y"][0]) - 0.5
                    s.page.mouse.click(x * CW, y * CH)
                    time.sleep(0.3)
                    s.settle(4000)
                elif path == "/type":
                    s.page.keyboard.type(q.get("text", [""])[0], delay=5)
                    time.sleep(0.2)
                elif path == "/key":
                    s.page.keyboard.press(q.get("key", ["Enter"])[0])
                    time.sleep(0.3)
                    s.settle(4000)
                elif path in ("/back", "/forward", "/reload"):
                    getattr(s.page, {"/back": "go_back", "/forward": "go_forward", "/reload": "reload"}[path])()
                    s.settle()
                elif path == "/close":
                    s.ctx.close()
                    del self.sessions[s.sid]
                    return b"OK"
                else:
                    return b"ERR unknown command"
            body = s.frame()
            return b"SID " + s.sid.encode() + b"\n" + body


def main():
    ap = argparse.ArgumentParser(description="render server for OCBrowser")
    ap.add_argument("--port", type=int, default=8765)
    ap.add_argument("--host", default="0.0.0.0")
    ap.add_argument("--show", action="store_true", help="show the Chromium window (debugging)")
    ap.add_argument("--chrome", help="path to a Chromium/Chrome executable (default: Playwright's)")
    args = ap.parse_args()
    srv = Server(headless=not args.show, chrome=args.chrome)

    class H(BaseHTTPRequestHandler):
        def do_GET(self):
            u = urlparse(self.path)
            t0 = time.time()
            try:
                body = srv.handle(u.path, parse_qs(u.query))
                code = 200
            except KeyError as e:
                body, code = f"ERR {e.args[0]}".encode(), 200
            except Exception as e:  # noqa: BLE001
                body, code = f"ERR {type(e).__name__}: {e}".encode(), 200
            self.send_response(code)
            self.send_header("Content-Type", "application/octet-stream")
            self.send_header("Content-Length", str(len(body)))
            self.end_headers()
            self.wfile.write(body)
            print(f"{u.path:8} {len(body):7} B  {time.time() - t0:5.2f} s")

        def log_message(self, *a):
            pass

    print(f"OCBrowser render server on http://{args.host}:{args.port}  (Ctrl+C to stop)")
    HTTPServer((args.host, args.port), H).serve_forever()


if __name__ == "__main__":
    main()
