#!/usr/bin/env python
"""
ff.py - drive Firefox over Playwright, the Firefox sibling of cdp.py.

Firefox dropped most of its CDP support, so the stateless "new connection per
command" trick cdp.py uses against Chrome's debug port doesn't port cleanly.
Instead `launch` spawns a small background daemon that holds ONE Playwright
Firefox page (on a persistent profile, so logins survive); every other
subcommand is a thin HTTP client to that daemon. The page persists between
calls (nav now, shot later) and the daemon accumulates console + network logs
for retrieval -- the "collect the logs" use case.

Usage:
  py ff.py launch [url] [--headless]   # start the background Firefox daemon
  py ff.py status                      # daemon health + current url/title
  py ff.py nav   <url>                 # navigate the page
  py ff.py shot  <out.png>             # screenshot the page to a PNG file
  py ff.py click <x> <y>               # left-click at viewport coords
  py ff.py type  <text>                # insert text into the focused element
  py ff.py key   <Key>                 # press a key (Enter/Tab/Escape/...)
  py ff.py eval  <js>                  # page.evaluate(js), prints JSON result
  py ff.py console [--clear]           # dump collected console messages (JSON)
  py ff.py network [--clear]           # dump collected network requests (JSON)
  py ff.py stop                        # shut the daemon down

Env: FF_PORT (control port, default 9333)
     FF_PROFILE (default %USERPROFILE%\\.claude\\ff-profile)
"""
import sys, os, json, time, subprocess, urllib.request, urllib.error

PORT = int(os.environ.get("FF_PORT", "9333"))
BASE = f"http://localhost:{PORT}"
PROFILE = os.environ.get("FF_PROFILE", os.path.join(os.path.expanduser("~"), ".claude", "ff-profile"))


# --------------------------------------------------------------------------- #
# client side (the CLI you actually type)
# --------------------------------------------------------------------------- #
def _req(path, method="GET", body=None, timeout=30):
    data = json.dumps(body).encode() if body is not None else None
    r = urllib.request.Request(BASE + path, data=data, method=method,
                               headers={"Content-Type": "application/json"})
    with urllib.request.urlopen(r, timeout=timeout) as resp:
        raw = resp.read().decode()
        return json.loads(raw) if raw else {}


def _alive():
    try:
        _req("/status", timeout=2)
        return True
    except Exception:
        return False


def cmd_launch(args):
    headless = "--headless" in args
    url = next((a for a in args if not a.startswith("--")), None)
    if _alive():
        print(f"[ff] daemon already running on {BASE}")
        if url:
            _req("/nav", "POST", {"url": _fix(url)})
            print(f"[ff] navigated -> {_fix(url)}")
        return
    os.makedirs(PROFILE, exist_ok=True)
    flags = subprocess.CREATE_NEW_PROCESS_GROUP | 0x00000008  # DETACHED_PROCESS
    env = dict(os.environ, FF_DAEMON="1", FF_HEADLESS="1" if headless else "0",
               FF_START_URL=_fix(url) if url else "about:blank")
    # Redirect the detached child's stdio to a logfile -- otherwise it inherits
    # the parent's stdout pipe (caller never gets control back) and any startup
    # crash is invisible.
    log = open(os.path.join(os.path.dirname(PROFILE), "ff-daemon.log"), "w")
    subprocess.Popen([sys.executable, os.path.abspath(__file__), "_serve"],
                     env=env, creationflags=flags, close_fds=True,
                     stdin=subprocess.DEVNULL, stdout=log, stderr=log)
    for _ in range(60):
        if _alive():
            print(f"[ff] daemon up on {BASE}  (headless={headless})  profile={PROFILE}")
            if url:
                print(f"[ff] start url -> {_fix(url)}")
            return
        time.sleep(0.5)
    raise SystemExit("[ff] daemon failed to start (check that 'py -m playwright install firefox' ran)")


def _fix(url):
    if url and "://" not in url and url != "about:blank":
        return "https://" + url
    return url


def _need(args, n, what):
    if len(args) < n:
        raise SystemExit(f"[ff] {what}")


def cmd_status(a):
    print(json.dumps(_req("/status"), indent=2))


def cmd_nav(a):
    _need(a, 1, "usage: ff.py nav <url>")
    _req("/nav", "POST", {"url": _fix(a[0])})
    print(f"[ff] navigated -> {_fix(a[0])}")


def cmd_shot(a):
    _need(a, 1, "usage: ff.py shot <out.png>")
    out = os.path.abspath(a[0])
    _req("/shot", "POST", {"path": out})
    print(f"[ff] screenshot -> {out} ({os.path.getsize(out)} bytes)")


def cmd_click(a):
    _need(a, 2, "usage: ff.py click <x> <y>")
    _req("/click", "POST", {"x": float(a[0]), "y": float(a[1])})
    print(f"[ff] click ({a[0]},{a[1]})")


def cmd_type(a):
    _need(a, 1, "usage: ff.py type <text>")
    _req("/type", "POST", {"text": a[0]})
    print(f"[ff] typed {len(a[0])} chars")


def cmd_key(a):
    _need(a, 1, "usage: ff.py key <Key>")
    _req("/key", "POST", {"key": a[0]})
    print(f"[ff] key {a[0]}")


def cmd_eval(a):
    _need(a, 1, "usage: ff.py eval <js>")
    print(json.dumps(_req("/eval", "POST", {"js": a[0]}).get("value"), indent=2, default=str))


def cmd_console(a):
    res = _req("/console" + ("?clear=1" if "--clear" in a else ""))
    print(json.dumps(res.get("messages", []), indent=2, default=str))


def cmd_network(a):
    res = _req("/network" + ("?clear=1" if "--clear" in a else ""))
    print(json.dumps(res.get("requests", []), indent=2, default=str))


def cmd_stop(a):
    if not _alive():
        print("[ff] daemon not running")
        return
    try:
        _req("/stop", "POST", {}, timeout=5)
    except Exception:
        pass
    print("[ff] daemon stopped")


# --------------------------------------------------------------------------- #
# daemon side (py ff.py _serve) -- holds the live Firefox page
# --------------------------------------------------------------------------- #
def serve():
    from http.server import BaseHTTPRequestHandler, HTTPServer
    from urllib.parse import urlparse, parse_qs
    import threading
    from playwright.sync_api import sync_playwright

    headless = os.environ.get("FF_HEADLESS") == "1"
    start_url = os.environ.get("FF_START_URL", "about:blank")

    pw = sync_playwright().start()
    ctx = pw.firefox.launch_persistent_context(PROFILE, headless=headless,
                                               viewport={"width": 1280, "height": 800})
    page = ctx.pages[0] if ctx.pages else ctx.new_page()

    console_log, network_log = [], []
    page.on("console", lambda m: console_log.append(
        {"type": m.type, "text": m.text, "location": m.location}))
    page.on("response", lambda r: network_log.append(
        {"status": r.status, "method": r.request.method, "url": r.url,
         "type": r.request.resource_type}))
    page.on("pageerror", lambda e: console_log.append(
        {"type": "pageerror", "text": str(e), "location": {}}))
    if start_url and start_url != "about:blank":
        try:
            page.goto(start_url, wait_until="load", timeout=30000)
        except Exception:
            pass

    class H(BaseHTTPRequestHandler):
        def log_message(self, *a):  # silence
            pass

        def _reply(self, obj, code=200):
            b = json.dumps(obj, default=str).encode()
            self.send_response(code)
            self.send_header("Content-Type", "application/json")
            self.send_header("Content-Length", str(len(b)))
            self.end_headers()
            self.wfile.write(b)

        def _body(self):
            n = int(self.headers.get("Content-Length", 0))
            return json.loads(self.rfile.read(n)) if n else {}

        def do_GET(self):
            u = urlparse(self.path)
            q = parse_qs(u.query)
            try:
                if u.path == "/status":
                    self._reply({"ok": True, "url": page.url, "title": page.title(),
                                 "headless": headless, "console": len(console_log),
                                 "network": len(network_log)})
                elif u.path == "/console":
                    msgs = list(console_log)
                    if q.get("clear"):
                        console_log.clear()
                    self._reply({"messages": msgs})
                elif u.path == "/network":
                    reqs = list(network_log)
                    if q.get("clear"):
                        network_log.clear()
                    self._reply({"requests": reqs})
                else:
                    self._reply({"error": "not found"}, 404)
            except Exception as e:
                self._reply({"error": str(e)}, 500)

        def do_POST(self):
            u = urlparse(self.path)
            try:
                b = self._body()
                if u.path == "/nav":
                    page.goto(b["url"], wait_until="load", timeout=30000)
                    self._reply({"ok": True, "url": page.url})
                elif u.path == "/shot":
                    page.screenshot(path=b["path"], full_page=b.get("full", False))
                    self._reply({"ok": True})
                elif u.path == "/click":
                    page.mouse.click(b["x"], b["y"])
                    self._reply({"ok": True})
                elif u.path == "/type":
                    page.keyboard.insert_text(b["text"])
                    self._reply({"ok": True})
                elif u.path == "/key":
                    page.keyboard.press(b["key"])
                    self._reply({"ok": True})
                elif u.path == "/eval":
                    self._reply({"value": page.evaluate(b["js"])})
                elif u.path == "/stop":
                    self._reply({"ok": True})
                    threading.Thread(target=httpd.shutdown, daemon=True).start()
                else:
                    self._reply({"error": "not found"}, 404)
            except Exception as e:
                self._reply({"error": str(e)}, 500)

    httpd = HTTPServer(("127.0.0.1", PORT), H)
    try:
        httpd.serve_forever()
    finally:
        try:
            ctx.close()
        except Exception:
            pass
        pw.stop()


CMDS = {"launch": cmd_launch, "status": cmd_status, "nav": cmd_nav, "shot": cmd_shot,
        "click": cmd_click, "type": cmd_type, "key": cmd_key, "eval": cmd_eval,
        "console": cmd_console, "network": cmd_network, "stop": cmd_stop}

if __name__ == "__main__":
    if len(sys.argv) >= 2 and sys.argv[1] == "_serve":
        serve()
    elif len(sys.argv) < 2 or sys.argv[1] not in CMDS:
        print(__doc__)
        raise SystemExit(1)
    else:
        CMDS[sys.argv[1]](sys.argv[2:])
