#!/usr/bin/env python3
"""REPL driver for the espresso_frame server's web UI.

Playwright-based since chromium-cli isn't available in this container.
Reads one command per line from stdin, prints a result line -- built
for tmux send-keys/capture-pane use by an agent. Vocabulary mirrors
chromium-cli where it overlaps (nav/wait-for/click/fill/screenshot/
eval/console-errors).

Requires setup.sh to have run first (Python venv + Playwright Chromium
+ the non-root shared-lib/font extraction). Run via:

    .claude/skills/run-server/env.sh sourced, then
    server/.venv/bin/python .claude/skills/run-server/driver.py

See SKILL.md for the full agent-path invocation (tmux wrapping etc).
"""
import glob
import json
import os
import sys

from playwright.sync_api import sync_playwright

SHOT_DIR = os.environ.get("SCREENSHOT_DIR", "/tmp/run-server-shots")
os.makedirs(SHOT_DIR, exist_ok=True)
BASE = os.environ.get("RUN_SERVER_BASE_URL", "http://127.0.0.1:8420")


def find_chrome() -> str:
    override = os.environ.get("RUN_SERVER_CHROME_BIN")
    if override and os.path.exists(override):
        return override
    matches = glob.glob(os.path.expanduser("~/.cache/ms-playwright/chromium-*/chrome-linux64/chrome"))
    if not matches:
        sys.exit("chrome binary not found -- run setup.sh first")
    return matches[0]


pw = sync_playwright().start()
# The FULL `chrome` binary, not chrome-headless-shell (Playwright's
# default headless target): chrome-headless-shell crashed on basic
# calls like set_content() in this container even once every
# ldd-reported missing lib was resolved. Full chrome + --no-sandbox is
# stable here.
browser = pw.chromium.launch(executable_path=find_chrome(), args=["--no-sandbox"])
page = browser.new_page(viewport={"width": 1280, "height": 900})
console_errors: list[str] = []
page.on("console", lambda msg: console_errors.append(msg.text) if msg.type == "error" else None)
page.on("pageerror", lambda exc: console_errors.append(str(exc)))
# Playwright auto-DISMISSES native confirm()/alert() dialogs by default
# (returns false) -- several destructive actions in this app (remove
# widget, clear all widgets) gate on `confirm()`, so without this a
# `click` on one of those buttons would silently no-op. Auto-accept
# instead, since a driver testing a "yes, do the destructive thing"
# flow needs the confirm to actually go through.
page.on("dialog", lambda dialog: dialog.accept())


def cmd_nav(arg):
    url = arg if arg.startswith("http") else BASE + arg
    page.goto(url)
    print("nav ->", page.url)


def cmd_wait_for(arg):
    page.wait_for_selector(arg, timeout=10000)
    print("found:", arg)


def cmd_click(arg):
    page.click(arg)
    print("clicked:", arg)


def cmd_fill(arg):
    sel, _, value = arg.partition(" ")
    page.fill(sel, value)
    print("filled:", sel, "=", value)


def cmd_press(arg):
    page.keyboard.press(arg)
    print("pressed:", arg)


def cmd_screenshot(arg):
    name = arg or f"ss-{len(os.listdir(SHOT_DIR))}"
    path = os.path.join(SHOT_DIR, name + ".png")
    page.screenshot(path=path)
    print("screenshot:", path)


def cmd_eval(arg):
    try:
        print(json.dumps(page.evaluate(arg)))
    except Exception as e:
        print("ERROR:", e)


def cmd_console_errors(_arg):
    print(json.dumps(console_errors))


def cmd_viewport(arg):
    """Resize the viewport. No args -> 390x844 (iPhone-ish mobile
    width); the page itself starts at 1280x900 (desktop) on launch, so
    `viewport 1280 900` gets back to that. The app's mobile breakpoint
    is 860px (see theme.css) -- anything under that exercises the
    off-canvas sidebar/mobile-bar layout."""
    parts = arg.split()
    width = int(parts[0]) if len(parts) > 0 else 390
    height = int(parts[1]) if len(parts) > 1 else 844
    page.set_viewport_size({"width": width, "height": height})
    print("viewport:", width, "x", height)


def cmd_is_open(arg):
    """App-specific: print whether a <dialog> element is open (true/false)."""
    print(json.dumps(page.eval_on_selector(arg, "el => el.open")))


def cmd_bootstrap_admin(arg):
    """App-specific: complete first-run /setup (username/password args,
    default admin/testpassword123). Every scratch DB starts with no
    users, and /setup is the only way in -- it also auto-links the
    pre-existing frame #1 (created by migrations) to the new admin, so
    /frames/1/... is reachable right after this."""
    parts = arg.split()
    username = parts[0] if len(parts) > 0 else "admin"
    password = parts[1] if len(parts) > 1 else "testpassword123"
    page.goto(BASE + "/setup")
    page.fill("input[name=username]", username)
    page.fill("input[name=password]", password)
    page.click("button[type=submit]")
    page.wait_for_load_state("networkidle")
    print("bootstrapped admin, now at:", page.url)


def cmd_quit(_arg):
    browser.close()
    pw.stop()
    sys.exit(0)


COMMANDS = {
    "nav": cmd_nav,
    "wait-for": cmd_wait_for,
    "click": cmd_click,
    "fill": cmd_fill,
    "press": cmd_press,
    "screenshot": cmd_screenshot,
    "eval": cmd_eval,
    "console-errors": cmd_console_errors,
    "viewport": cmd_viewport,
    "is-open": cmd_is_open,
    "bootstrap-admin": cmd_bootstrap_admin,
    "quit": cmd_quit,
}

print("run-server driver -- commands:", ", ".join(COMMANDS), flush=True)
print("driver>", end=" ", flush=True)
for line in sys.stdin:
    line = line.strip()
    if not line:
        print("driver>", end=" ", flush=True)
        continue
    cmd, _, rest = line.partition(" ")
    fn = COMMANDS.get(cmd)
    if fn is None:
        print("unknown command:", cmd, "-- try one of:", ", ".join(COMMANDS))
    else:
        try:
            fn(rest)
        except Exception as e:
            print("ERROR:", e)
    print("driver>", end=" ", flush=True)
