#!/usr/bin/env python3
"""tty000 static host + optional serial bridge.

Works on Debian, macOS, and Windows. No pip packages required on Unix.
Serial is off unless --serial / --helper / TTY000_SERIAL=1.

  python3 server.py                  # static site on :8000
  python3 server.py --helper         # local USB bridge on 127.0.0.1:7682
  TTY000_SERIAL=1 python3 server.py  # Debian lab: site + host UARTs
"""

import argparse
import base64
import glob
import json
import os
import posixpath
import queue
import re
import secrets
import sys
import threading
import time
from http import HTTPStatus
from http.server import HTTPServer, SimpleHTTPRequestHandler
from socketserver import ThreadingMixIn
from urllib.parse import parse_qs, urlparse

try:
    from http.server import ThreadingHTTPServer
except ImportError:  # Python 3.6
    class ThreadingHTTPServer(ThreadingMixIn, HTTPServer):
        daemon_threads = True

ROOT = os.path.dirname(os.path.abspath(__file__))
if ROOT not in sys.path:
    sys.path.insert(0, ROOT)
try:
    import api_engine
except ImportError:
    api_engine = None
APP_ROUTES = {
    "/",
    "/serial",
    "/view",
    "/lab",
    "/explain",
    "/rtt",
    "/museum",
    "/helper",
    "/diff",
    "/cast",
    "/term",
    "/baud",
    "/detect",
    "/record",
    "/workbench",
    "/docs",
    "/faq",
}
# Help is its own HTML page (helpers.php). Do not fold it into APP_ROUTES
# or the helper would serve index.html instead of the guides.
HELP_ROUTES = {"/helpers", "/helpers.php"}
PAGE_SEO = {
    "/": ("tty000: free open-source TTY in the browser", "Free MIT-licensed serial TTY, Device Detective, session recorder, workbench, ANSI log viewer, escape lab, dump explainer, accessibility RTT, and Teletype museum. Local by default."),
    "/serial": ("Serial TTY: tty000", "Free open-source browser UART for ESP32, Arduino, GPS, and consoles. Web Serial, local helper, Debian host ports, or Demo UART."),
    "/view": ("ANSI log viewer: tty000", "Paste a CI or TUI dump and see the terminal the author saw. Colors, cursor, replay. Nothing uploaded."),
    "/lab": ("Escape sequence lab: tty000", "Compose CSI, watch a color leak, flip a simulated termios board."),
    "/explain": ("Terminal dump explainer: tty000", "Local diagnosis of rustc, gcc, npm, Kubernetes, SSH, and kernel oops. No API key."),
    "/rtt": ("Accessibility RTT / TTY: tty000", "GA / SK typed conversation. Large type. Optional WebRTC peer. Not a 711 relay."),
    "/museum": ("Teletype museum: tty000", "Ten characters a second, paper, bell. Feel 110 baud."),
    "/helper": ("Set up a USB port: tty000", "Download the local helper and follow the Windows, macOS, or Linux steps to open a real serial port from Firefox, Safari, or any browser."),
    "/diff": ("Diff two dumps: tty000", "Compare two terminal dumps and highlight extra CSI."),
    "/cast": ("asciinema player: tty000", "Drop a .cast file and replay it in the glass."),
    "/term": ("TERM lab: tty000", "What xterm 256color and friends actually promise."),
    "/baud": ("Baud calculator: tty000", "Bit time, character time, and a cable length rule of thumb."),
    "/detect": ("Device Detective: tty000", "Identify baud, line endings, protocol, and device family from a live serial capture. Local only."),
    "/record": ("Session recorder: tty000", "Record TX and RX with timestamps, save a .tty000 file, and replay the investigation."),
    "/workbench": ("Workbench: tty000", "Live terminal, Device Detective, record, explain, and diagnostic commands in one glass."),
    "/docs": ("tty000 tools and FAQ", "Serial, Detect, Record, Workbench, ANSI viewer, lab, explainer, RTT, museum. Machine catalogs at /product.json and /llms.txt."),
    "/faq": ("tty000 FAQ", "Does data leave the machine? Firefox? Hosting? Bots should read /llms.txt and /product.json."),
    "/helpers": ("Help: tty000", "How each tty000 tool works: Workbench, Serial, Detect, Record, View, Lab, Explain, RTT, Museum."),
}
MIME_EXTRA = {
    ".webmanifest": "application/manifest+json; charset=utf-8",
    ".json": "application/json; charset=utf-8",
    ".xml": "application/xml; charset=utf-8",
    ".svg": "image/svg+xml",
    ".txt": "text/plain; charset=utf-8",
    ".woff2": "font/woff2",
    ".woff": "font/woff",
    ".tty000": "application/json; charset=utf-8",
    ".js": "text/javascript; charset=utf-8",
    ".md": "text/markdown; charset=utf-8",
    ".cff": "text/plain; charset=utf-8",
}
SESSIONS = {}
LOCK = threading.Lock()
CONFIG = {
    "serial": False,
    "token": os.environ.get("TTY000_TOKEN", ""),
}


def list_ports():
    names = []
    if os.name == "nt":
        names = [f"COM{i}" for i in range(1, 33)]
        found = []
        for name in names:
            try:
                import serial  # type: ignore

                s = serial.Serial(name)
                s.close()
                found.append(name)
            except Exception:
                continue
        return [{"id": n, "path": n, "label": n, "busy": n in {s.get("path") for s in SESSIONS.values()}} for n in found]
    patterns = [
        "/dev/ttyUSB*",
        "/dev/ttyACM*",
        "/dev/ttyAMA*",
        "/dev/tty.usb*",
        "/dev/cu.usb*",
        "/dev/tty.usbserial*",
        "/dev/cu.usbserial*",
        "/dev/serial/by-id/*",
    ]
    for pat in patterns:
        names.extend(glob.glob(pat))
    # Prefer cu.* on macOS so open() does not block for carrier.
    cleaned = []
    seen = set()
    for path in sorted(set(names)):
        if not os.path.exists(path):
            continue
        key = os.path.realpath(path)
        if key in seen:
            continue
        seen.add(key)
        cleaned.append({
            "id": path,
            "path": path,
            "label": os.path.basename(path),
            "busy": port_is_busy(path),
        })
    return cleaned


def port_is_busy(path: str) -> bool:
    if any(s.get("path") == path for s in SESSIONS.values()):
        return True
    if os.name == "nt":
        try:
            import serial  # type: ignore

            s = serial.Serial(path)
            s.close()
            return False
        except Exception:
            return True
    try:
        fd = os.open(path, os.O_RDWR | os.O_NOCTTY | os.O_NONBLOCK)
        try:
            import fcntl

            fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
            fcntl.flock(fd, fcntl.LOCK_UN)
        except (OSError, BlockingIOError):
            os.close(fd)
            return True
        os.close(fd)
        return False
    except OSError:
        return True


def open_port(path: str, baud: int, data_bits: int, parity: str, stop_bits: int):
    if os.name == "nt":
        import serial  # type: ignore

        ser = serial.Serial(
            path,
            baudrate=baud,
            bytesize=7 if data_bits == 7 else 8,
            parity={"even": "E", "odd": "O"}.get(parity, "N"),
            stopbits=2 if stop_bits == 2 else 1,
            timeout=0.1,
        )
        return {"kind": "pyserial", "io": ser}

    import termios

    fd = os.open(path, os.O_RDWR | os.O_NOCTTY | os.O_NONBLOCK)
    attrs = termios.tcgetattr(fd)
    attrs[0] = 0
    attrs[1] = 0
    attrs[3] = 0
    cflag = termios.CREAD | termios.CLOCAL
    cflag |= termios.CS7 if data_bits == 7 else termios.CS8
    if stop_bits == 2:
        cflag |= termios.CSTOPB
    if parity == "even":
        cflag |= termios.PARENB
    elif parity == "odd":
        cflag |= termios.PARENB | termios.PARODD
    baud_map = {}
    for rate in (300, 600, 1200, 2400, 4800, 9600, 14400, 19200, 38400, 57600, 115200, 230400, 250000, 460800, 921600):
        const = getattr(termios, f"B{rate}", None)
        if const is not None:
            baud_map[rate] = const
    speed = baud_map.get(baud)
    if speed is None:
        raise OSError(f"unsupported baud {baud} on this host")
    attrs[2] = cflag
    attrs[4] = speed
    attrs[5] = speed
    attrs[6][termios.VMIN] = 0
    attrs[6][termios.VTIME] = 1
    termios.tcsetattr(fd, termios.TCSANOW, attrs)
    try:
        termios.tcflush(fd, termios.TCIOFLUSH)
    except termios.error:
        pass
    return {"kind": "fd", "io": fd}


def port_read(handle) -> bytes:
    if handle["kind"] == "virtual":
        return handle["io"].read() or b""
    if handle["kind"] == "pyserial":
        return handle["io"].read(4096) or b""
    try:
        return os.read(handle["io"], 4096)
    except BlockingIOError:
        return b""
    except OSError:
        return b""


def port_write(handle, data: bytes) -> None:
    if handle["kind"] == "virtual":
        handle["io"].write(data)
        return
    if handle["kind"] == "pyserial":
        handle["io"].write(data)
        return
    os.write(handle["io"], data)


def port_close(handle) -> None:
    try:
        if handle["kind"] == "virtual":
            handle["io"].close()
        elif handle["kind"] == "pyserial":
            handle["io"].close()
        else:
            os.close(handle["io"])
    except OSError:
        pass


def port_signal(handle, dtr=None, rts=None, brk=None) -> None:
    if handle.get("kind") == "virtual":
        if brk:
            handle["io"].write(b"\r\n[break]\r\nready > ")
        return
    if handle["kind"] == "pyserial":
        if dtr is not None:
            handle["io"].dtr = dtr
        if rts is not None:
            handle["io"].rts = rts
        if brk:
            handle["io"].send_break(0.2)
        return
    import fcntl

    fd = handle["io"]
    bits = {"dtr": 0x002, "rts": 0x004}
    if sys.platform == "linux":
        bis, bic = 0x5416, 0x5417
    else:
        bis, bic = 0x8004746B, 0x8004746C
    for name, mask, val in (("dtr", bits["dtr"], dtr), ("rts", bits["rts"], rts)):
        if val is None:
            continue
        try:
            fcntl.ioctl(fd, bis if val else bic, mask.to_bytes(4, sys.byteorder))
        except OSError:
            pass
    if brk:
        import termios

        termios.tcsendbreak(fd, 0)


def reader_loop(sid: str) -> None:
    sess = SESSIONS.get(sid)
    if not sess:
        return
    while not sess["stop"].is_set():
        chunk = port_read(sess["handle"])
        if chunk:
            sess["queue"].put(chunk)
            logf = sess.get("log")
            if logf:
                try:
                    logf.write(chunk)
                    logf.flush()
                except OSError:
                    pass
        else:
            time.sleep(0.03)


def json_bytes(obj) -> bytes:
    return json.dumps(obj).encode("utf-8")


def crc16(data: bytes) -> int:
    crc = 0
    for b in data:
        crc ^= b << 8
        for _ in range(8):
            crc = ((crc << 1) ^ 0x1021) if crc & 0x8000 else (crc << 1)
            crc &= 0xFFFF
    return crc


def wait_byte(sess, wanted, timeout=8.0):
    end = time.time() + timeout
    buf = b""
    while time.time() < end:
        try:
            buf += sess["queue"].get(timeout=0.2)
        except queue.Empty:
            continue
        for w in wanted:
            if bytes([w]) in buf:
                return w
    return None


def xmodem_send(sess, data: bytes, crc: bool = True, name: str = "file.bin") -> None:
    handle = sess["handle"]
    mark = wait_byte(sess, (0x15, 0x43), timeout=6)
    use_crc = crc or mark == 0x43
    blk = 1
    off = 0
    while off < len(data) or off == 0:
        chunk = data[off:off + 128].ljust(128, b"\x1a")
        frame = bytes([0x01, blk & 0xFF, (255 - (blk & 0xFF))]) + chunk
        if use_crc:
            c = crc16(chunk)
            frame += bytes([c >> 8, c & 0xFF])
        else:
            frame += bytes([sum(chunk) & 0xFF])
        port_write(handle, frame)
        ack = wait_byte(sess, (0x06, 0x15, 0x18), timeout=5)
        if ack == 0x18:
            raise RuntimeError("receiver cancelled")
        if ack != 0x06:
            port_write(handle, frame)
            if wait_byte(sess, (0x06,), timeout=5) != 0x06:
                raise RuntimeError("no ACK")
        blk = (blk + 1) & 0xFF
        off += 128
        if off >= len(data):
            break
    port_write(handle, b"\x04")
    wait_byte(sess, (0x06,), timeout=3)


def zmodem_send(sess, data: bytes, name: str = "file.bin") -> None:
    # Best effort: ZMODEM hex header then data. Receivers that do not speak ZMODEM
    # still get the payload as raw bytes after the header.
    handle = sess["handle"]
    header = b"**\x18B00000000000000\x0d\x0a"
    port_write(handle, header)
    time.sleep(0.05)
    for chunk in (data[i:i + 256] for i in range(0, len(data), 256)):
        port_write(handle, chunk)
        time.sleep(0.01)
    port_write(handle, b"\x18")


class VirtualBoard(object):
    """In-process demo UART for scripts using the helper. Not a host USB port."""

    def __init__(self):
        self.q = queue.Queue()
        self.buf = b""
        self.closed = False
        boot = (
            b"\r\n\x1b[32mTTY000 virtual UART\x1b[0m. Helper-only, no hardware.\r\n"
            b"rst:0x1 (POWERON),boot:0x13 (SPI_FAST_FLASH_BOOT)\r\n"
            b"ESP-ROM:esp32s3-20210327\r\n"
            b"chip revision: v0.2\r\n"
            b"Type AT, HELP, PING, I, or I2C.\r\n"
            b"ready > "
        )
        self.q.put(boot)

    def write(self, data):
        if self.closed:
            return
        self.buf += data
        while True:
            nl = self.buf.find(b"\n")
            cr = self.buf.find(b"\r")
            cuts = [n for n in (nl, cr) if n >= 0]
            if not cuts:
                break
            i = min(cuts)
            line = self.buf[:i].decode("utf-8", "replace").strip()
            self.buf = self.buf[i + 1:]
            if self.buf[:1] in (b"\n", b"\r"):
                self.buf = self.buf[1:]
            self._reply(line)

    def _reply(self, line):
        cmd = line.upper()
        if not cmd:
            self.q.put(b"ready > ")
            return
        if cmd == "AT":
            self.q.put(b"OK\r\nready > ")
        elif cmd == "HELP":
            self.q.put(b"AT  ATI  AT+GMR  HELP  PING  I  I2C  BAUD\r\nready > ")
        elif cmd == "PING":
            self.q.put(b"PONG\r\nready > ")
        elif cmd in ("I", "ATI"):
            self.q.put(b"TTY000 VIRTUAL UART\r\nESP32-S3\r\nready > ")
        elif cmd == "AT+GMR":
            self.q.put(b"AT version:2.2.0.0\r\nSDK version:v4.4\r\nOK\r\nready > ")
        elif cmd in ("I2C", "I2CDETECT"):
            self.q.put(b"     0  1  2  3  4  5  6  7  8  9  a  b  c  d  e  f\r\n00:          -- -- -- -- -- -- -- -- -- -- -- -- --\r\n30: -- -- -- -- -- -- -- -- -- -- -- -- 3c -- -- --\r\nready > ")
        elif cmd == "BAUD":
            self.q.put(b"115200 8N1\r\nready > ")
        else:
            self.q.put(("ERR unknown: %s\r\nready > " % line[:40]).encode("utf-8", "replace"))

    def read(self):
        chunks = []
        try:
            while True:
                chunks.append(self.q.get_nowait())
        except queue.Empty:
            pass
        return b"".join(chunks)

    def close(self):
        self.closed = True


def port_flush(handle):
    if handle.get("kind") == "virtual":
        return
    if handle["kind"] == "pyserial":
        try:
            handle["io"].reset_input_buffer()
            handle["io"].reset_output_buffer()
        except Exception:
            pass
        return
    try:
        import termios
        termios.tcflush(handle["io"], termios.TCIOFLUSH)
    except Exception:
        pass


class Handler(SimpleHTTPRequestHandler):
    def __init__(self, *args, **kwargs):
        kwargs.pop("directory", None)
        if sys.version_info >= (3, 7):
            super().__init__(*args, directory=ROOT, **kwargs)
        else:
            super().__init__(*args, **kwargs)

    def log_message(self, fmt, *args):
        sys.stderr.write("%s - %s\n" % (self.address_string(), fmt % args))

    def end_headers(self):
        origin = self.headers.get("Origin", "")
        if origin and self._origin_ok(origin):
            self.send_header("Access-Control-Allow-Origin", origin)
            self.send_header("Access-Control-Allow-Credentials", "true")
            self.send_header("Access-Control-Allow-Private-Network", "true")
        elif not origin:
            self.send_header("Access-Control-Allow-Origin", "*")
        self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
        self.send_header("Access-Control-Allow-Headers", "Authorization, Content-Type")
        self.send_header("Cache-Control", "no-store")
        super().end_headers()

    def _origin_ok(self, origin: str) -> bool:
        if not origin:
            return True
        try:
            host = urlparse(origin).hostname or ""
        except ValueError:
            return False
        return host in {"127.0.0.1", "localhost", "tty000.com", "www.tty000.com", "local.tty000.com"} or host.endswith(".tty000.com")

    def do_OPTIONS(self):
        self.send_response(HTTPStatus.NO_CONTENT)
        self.end_headers()

    def do_GET(self):
        parsed = urlparse(self.path)
        if parsed.path == "/api/health":
            return self._json(200, {
                "ok": True,
                "serial": CONFIG["serial"],
                "helper": CONFIG.get("helper", False),
                "platform": sys.platform,
                "version": getattr(api_engine, "API_VERSION", "1.1.0") if api_engine else "1.1.0",
                "catalog": "/product.json",
                "llms": "/llms.txt",
                "features": [
                    "log", "sweep", "send", "recipe", "read", "flush", "line",
                    "sessions", "virtual", "baud", "diff", "strip", "stats",
                    "hex", "nmea", "at", "crc", "profiles", "batch",
                ],
                "app": ["serial", "detect", "record", "workbench", "view", "lab", "explain", "rtt", "museum", "diff", "cast", "term", "baud"],
            })
        if parsed.path == "/api/version":
            return self._json(200, {
                "ok": True,
                "version": getattr(api_engine, "API_VERSION", "1.1.0") if api_engine else "1.1.0",
                "serial": CONFIG["serial"],
                "helper": CONFIG.get("helper", False),
                "python": sys.version.split()[0],
            })
        if parsed.path == "/api/profiles":
            if api_engine:
                return self._json(200, {"ok": True, "profiles": api_engine.PROFILES})
            return self._json(200, {"ok": True, "profiles": []})
        if parsed.path == "/api/families":
            if api_engine:
                return self._json(200, api_engine.catalog())
            return self._json(500, {"ok": False, "error": "api_engine missing"})
        if parsed.path == "/api/ports":
            if not self._auth(parsed) or not CONFIG["serial"]:
                return self._json(401 if CONFIG["serial"] else 200, {"ports": [], "serial": CONFIG["serial"]})
            return self._json(200, {"ports": list_ports(), "serial": True})
        if parsed.path == "/api/serial/stream":
            return self._stream(parsed)
        if parsed.path in {"/api/catalog", "/api/product"}:
            return self._send_file("product.json", "application/json; charset=utf-8")
        if parsed.path == "/api/serial/sessions":
            return self._sessions()
        if parsed.path == "/api/serial/status":
            return self._status(parsed)
        if parsed.path in {"/api", "/api/"}:
            return self._api_docs()
        if parsed.path == "/api/openapi.json":
            return self._send_file("api/openapi.json", "application/json; charset=utf-8")
        if parsed.path == "/api/baud":
            qs = parse_qs(parsed.query)
            if qs.get("baud"):
                if api_engine is None:
                    return self._json(500, {"ok": False, "error": "api_engine missing"})
                return self._json(200, api_engine.baud_info(
                    (qs.get("baud") or [None])[0],
                    (qs.get("dataBits") or [8])[0],
                    (qs.get("parity") or ["none"])[0],
                    (qs.get("stopBits") or [1])[0],
                    (qs.get("cableM") or [None])[0],
                ))
            return self._json(200, {"ok": True, "method": "GET or POST", "query": "baud=115200&dataBits=8&parity=none&stopBits=1"})
        if parsed.path == "/api/detect":
            return self._json(200, {
                "ok": True,
                "method": "POST",
                "body": {"text": "serial dump", "baud": None},
                "docs": "/api",
            })
        if parsed.path == "/api/explain":
            return self._json(200, {
                "ok": True,
                "method": "POST",
                "body": {"text": "log or serial dump"},
                "docs": "/api",
            })
        route = posixpath.normpath(parsed.path)
        if route != "/" and route.endswith("/"):
            route = route[:-1]
        if route in HELP_ROUTES:
            return self._help_page()
        if route in APP_ROUTES:
            return self._spa(route)
        return super().do_GET()

    def do_POST(self):
        parsed = urlparse(self.path)
        length = int(self.headers.get("Content-Length") or 0)
        raw = self.rfile.read(length) if length else b""
        body = {}
        if raw:
            try:
                body = json.loads(raw.decode("utf-8") or "{}")
            except json.JSONDecodeError:
                body = {}
        if not self._auth(parsed) and parsed.path.startswith("/api/"):
            return self._json(401, {"error": "token required"})
        if parsed.path == "/api/serial/open":
            return self._open(parsed, body)
        if parsed.path == "/api/serial/write":
            return self._write(body)
        if parsed.path == "/api/serial/signal":
            return self._signal(body)
        if parsed.path == "/api/serial/close":
            return self._close(body)
        if parsed.path == "/api/serial/log":
            return self._log(body)
        if parsed.path == "/api/serial/sweep":
            return self._sweep(body)
        if parsed.path == "/api/serial/send":
            return self._send(body)
        if parsed.path == "/api/serial/recipe":
            return self._recipe(body)
        if parsed.path == "/api/serial/read":
            return self._read(body)
        if parsed.path == "/api/serial/flush":
            return self._flush(body)
        if parsed.path == "/api/serial/line":
            return self._line(body)
        if parsed.path in {"/api/serial/config", "/api/serial/reopen"}:
            return self._reopen(body)
        if parsed.path in {"/api/virtual/open", "/api/serial/virtual"}:
            return self._virtual_open(body)
        if parsed.path == "/api/detect":
            return self._detect(body)
        if parsed.path == "/api/explain":
            return self._explain(body)
        if parsed.path == "/api/batch":
            return self._batch(body)
        if parsed.path == "/api/baud":
            return self._baud(body)
        if parsed.path in {"/api/strip", "/api/ansi/strip"}:
            return self._strip(body)
        if parsed.path in {"/api/stats", "/api/ansi/stats"}:
            return self._stats(body)
        if parsed.path == "/api/hex":
            return self._hex(body)
        if parsed.path == "/api/nmea":
            return self._nmea(body)
        if parsed.path == "/api/at":
            return self._at(body)
        if parsed.path == "/api/crc":
            return self._crc(body)
        if parsed.path == "/api/diff":
            return self._diff(body)
        return self._json(404, {"error": "not found"})

    def _auth(self, parsed) -> bool:
        token = CONFIG["token"]
        if not token:
            return True
        qs = parse_qs(parsed.query)
        got = (qs.get("token") or [""])[0]
        header = self.headers.get("Authorization", "")
        if header.startswith("Bearer "):
            got = got or header[7:]
        return secrets.compare_digest(got, token)

    def _json(self, status: int, obj) -> None:
        payload = json_bytes(obj)
        self.send_response(status)
        self.send_header("Content-Type", "application/json; charset=utf-8")
        self.send_header("Content-Length", str(len(payload)))
        self.end_headers()
        self.wfile.write(payload)

    def _open(self, parsed, body) -> None:
        if not CONFIG["serial"]:
            return self._json(403, {"error": "serial disabled on this host"})
        qs = parse_qs(parsed.query)
        path = body.get("path") or (qs.get("path") or [""])[0]
        if path in {"virtual://demo", "virtual", "demo"}:
            return self._virtual_open(body)
        allowed = {p["path"] for p in list_ports()}
        if not path or path not in allowed:
            return self._json(400, {"error": "unknown port", "ports": list_ports()})
        baud = int(body.get("baud") or qs.get("baud", ["115200"])[0])
        data_bits = int(body.get("dataBits") or 8)
        parity = str(body.get("parity") or "none")
        stop_bits = int(body.get("stopBits") or 1)
        try:
            handle = open_port(path, baud, data_bits, parity, stop_bits)
        except Exception as exc:
            return self._json(500, {"error": str(exc)})
        sid = secrets.token_hex(12)
        stop = threading.Event()
        sess = {"handle": handle, "queue": queue.Queue(), "stop": stop, "path": path, "bytes_in": 0, "bytes_out": 0, "opened": time.time()}
        with LOCK:
            SESSIONS[sid] = sess
        threading.Thread(target=reader_loop, args=(sid,), daemon=True).start()
        return self._json(200, {"id": sid, "path": path})

    def _write(self, body) -> None:
        sess = SESSIONS.get(body.get("id", ""))
        if not sess:
            return self._json(404, {"error": "no session"})
        data = body.get("data") or ""
        raw = base64.b64decode(data) if body.get("b64") else data.encode("utf-8")
        try:
            port_write(sess["handle"], raw)
            sess["bytes_out"] = int(sess.get("bytes_out") or 0) + len(raw)
        except Exception as exc:
            return self._json(500, {"error": str(exc)})
        return self._json(200, {"ok": True})

    def _signal(self, body) -> None:
        sess = SESSIONS.get(body.get("id", ""))
        if not sess:
            return self._json(404, {"error": "no session"})
        try:
            port_signal(
                sess["handle"],
                dtr=body.get("dtr"),
                rts=body.get("rts"),
                brk=bool(body.get("break") if "break" in body else body.get("brk")),
            )
        except Exception as exc:
            return self._json(500, {"error": str(exc)})
        return self._json(200, {"ok": True})

    def _close(self, body) -> None:
        sid = body.get("id", "")
        sess = SESSIONS.pop(sid, None)
        if sess:
            sess["stop"].set()
            if sess.get("log"):
                try:
                    sess["log"].close()
                except OSError:
                    pass
            port_close(sess["handle"])
        return self._json(200, {"ok": True})

    def _log(self, body) -> None:
        sess = SESSIONS.get(body.get("id", ""))
        if not sess:
            return self._json(404, {"error": "no session"})
        enable = bool(body.get("enable"))
        if not enable:
            if sess.get("log"):
                sess["log"].close()
                sess["log"] = None
            return self._json(200, {"ok": True, "path": None})
        logdir = os.path.join(ROOT, "cache", "logs")
        os.makedirs(logdir, exist_ok=True)
        path = os.path.join(logdir, f"session-{sess.get('path', 'port').replace('/', '_')}-{int(time.time())}.log")
        sess["log"] = open(path, "ab")
        return self._json(200, {"ok": True, "path": path})

    def _sweep(self, body) -> None:
        if not CONFIG["serial"]:
            return self._json(403, {"error": "serial disabled"})
        path = body.get("path") or ""
        allowed = {p["path"] for p in list_ports()}
        if path not in allowed:
            return self._json(400, {"error": "unknown port"})
        bauds = [300, 1200, 2400, 4800, 9600, 19200, 38400, 57600, 115200, 230400, 250000, 460800, 921600]
        scores = []
        for baud in bauds:
            try:
                handle = open_port(path, baud, 8, "none", 1)
            except Exception:
                continue
            time.sleep(0.35)
            buf = b""
            deadline = time.time() + 0.45
            while time.time() < deadline:
                buf += port_read(handle)
                time.sleep(0.04)
            port_close(handle)
            printable = sum(1 for b in buf if 32 <= b < 127 or b in (10, 13))
            scores.append({"baud": baud, "score": (printable / len(buf)) if buf else 0, "n": len(buf)})
        scores.sort(key=lambda s: (s["score"], s["n"]), reverse=True)
        return self._json(200, {"ok": True, "scores": scores, "best": scores[0] if scores else None})

    def _send(self, body) -> None:
        sess = SESSIONS.get(body.get("id", ""))
        if not sess:
            return self._json(404, {"error": "no session"})
        try:
            raw = base64.b64decode(body.get("b64") or "")
        except Exception:
            return self._json(400, {"error": "bad payload"})
        mode = (body.get("mode") or "raw").lower()
        try:
            if mode in {"xmodem", "ymodem"}:
                xmodem_send(sess, raw, crc=True, name=body.get("name") or "file.bin")
            elif mode == "zmodem":
                zmodem_send(sess, raw, name=body.get("name") or "file.bin")
            else:
                for chunk in (raw[i:i + 64] for i in range(0, len(raw), 64)):
                    port_write(sess["handle"], chunk)
                    time.sleep(0.02)
        except Exception as exc:
            return self._json(500, {"error": str(exc)})
        return self._json(200, {"ok": True, "mode": mode, "bytes": len(raw)})

    def _recipe(self, body) -> None:
        sess = SESSIONS.get(body.get("id", ""))
        if not sess:
            return self._json(404, {"error": "no session"})
        name = body.get("name") or ""
        h = sess["handle"]
        try:
            if name in {"arduino", "avr", "rp2040", "pico"}:
                port_signal(h, dtr=False)
                time.sleep(0.1)
                port_signal(h, dtr=True)
            elif name in {"esp32", "esp8266"}:
                port_signal(h, dtr=True, rts=False)
                time.sleep(0.08)
                port_signal(h, dtr=False, rts=True)
                time.sleep(0.08)
                port_signal(h, dtr=False, rts=False)
            elif name in {"stm32", "nrf", "reset"}:
                port_signal(h, brk=True)
                time.sleep(0.05)
                port_signal(h, dtr=False)
                time.sleep(0.05)
                port_signal(h, dtr=True)
            else:
                return self._json(400, {"error": "unknown recipe", "known": ["arduino", "esp32", "esp8266", "stm32", "rp2040", "reset"]})
        except Exception as exc:
            return self._json(500, {"error": str(exc)})
        return self._json(200, {"ok": True, "name": name})

    def _stream(self, parsed) -> None:
        if not self._auth(parsed) or not CONFIG["serial"]:
            return self._json(401, {"error": "no stream"})
        qs = parse_qs(parsed.query)
        sid = (qs.get("id") or [""])[0]
        sess = SESSIONS.get(sid)
        if not sess:
            return self._json(404, {"error": "no session"})
        self.send_response(HTTPStatus.OK)
        self.send_header("Content-Type", "text/event-stream")
        self.send_header("X-Accel-Buffering", "no")
        self.send_header("Connection", "keep-alive")
        self.end_headers()
        try:
            self.wfile.write(b": ok\n\n")
            self.wfile.flush()
            while not sess["stop"].is_set():
                try:
                    chunk = sess["queue"].get(timeout=1.0)
                except queue.Empty:
                    self.wfile.write(b": ping\n\n")
                    self.wfile.flush()
                    continue
                payload = json_bytes({"b64": base64.b64encode(chunk).decode("ascii")})
                self.wfile.write(b"data: " + payload + b"\n\n")
                self.wfile.flush()
        except BrokenPipeError:
            pass

    def translate_path(self, path):
        raw = urlparse(path).path
        route = posixpath.normpath(raw)
        if route != "/" and route.endswith("/"):
            route = route[:-1]
        if route in HELP_ROUTES:
            return os.path.join(ROOT, "helpers.php")
        if route in APP_ROUTES:
            return os.path.join(ROOT, "index.html")
        parsed = posixpath.normpath(raw).lstrip("/")
        full = os.path.normpath(os.path.join(ROOT, parsed or "index.html"))
        if not full.startswith(ROOT):
            return os.path.join(ROOT, "index.html")
        if os.path.isdir(full):
            return os.path.join(full, "index.html")
        return full

    def guess_type(self, path):
        ext = os.path.splitext(path)[1].lower()
        if ext in MIME_EXTRA:
            return MIME_EXTRA[ext]
        return SimpleHTTPRequestHandler.guess_type(self, path)

    def _spa(self, route: str) -> None:
        title, desc = PAGE_SEO.get(route, PAGE_SEO["/"])
        with open(os.path.join(ROOT, "index.html"), encoding="utf-8") as fh:
            html = fh.read()
        abs_url = "https://tty000.com" if route == "/" else f"https://tty000.com{route}"
        html = re.sub(r"<title>.*?</title>", f"<title>{title}</title>", html, count=1)
        html = re.sub(
            r'<meta name="description" content=".*?"',
            f'<meta name="description" content="{desc}"',
            html,
            count=1,
        )
        html = re.sub(
            r'<link rel="canonical" href=".*?"',
            f'<link rel="canonical" href="{abs_url}"',
            html,
            count=1,
        )
        html = re.sub(
            r'<meta property="og:title" content=".*?"',
            f'<meta property="og:title" content="{title}"',
            html,
            count=1,
        )
        html = re.sub(
            r'<meta property="og:description" content=".*?"',
            f'<meta property="og:description" content="{desc}"',
            html,
            count=1,
        )
        html = re.sub(
            r'<meta property="og:url" content=".*?"',
            f'<meta property="og:url" content="{abs_url}"',
            html,
            count=1,
        )
        payload = html.encode("utf-8")
        self.send_response(200)
        self.send_header("Content-Type", "text/html; charset=utf-8")
        self.send_header("Content-Length", str(len(payload)))
        self.end_headers()
        self.wfile.write(payload)

    def _sess(self, body):
        return SESSIONS.get((body or {}).get("id") or "")

    def _virtual_open(self, body):
        if not CONFIG["serial"]:
            return self._json(403, {"error": "serial disabled on this host"})
        board = VirtualBoard()
        sid = secrets.token_hex(12)
        stop = threading.Event()
        sess = {
            "handle": {"kind": "virtual", "io": board},
            "queue": queue.Queue(),
            "stop": stop,
            "path": "virtual://demo",
            "virtual": True,
            "bytes_in": 0,
            "bytes_out": 0,
            "opened": time.time(),
        }
        with LOCK:
            SESSIONS[sid] = sess
        threading.Thread(target=reader_loop, args=(sid,), daemon=True).start()
        return self._json(200, {"id": sid, "path": "virtual://demo", "virtual": True})

    def _sessions(self):
        if not CONFIG["serial"]:
            return self._json(200, {"ok": True, "sessions": []})
        out = []
        for sid, sess in list(SESSIONS.items()):
            out.append({
                "id": sid,
                "path": sess.get("path"),
                "virtual": bool(sess.get("virtual")),
                "bytesIn": int(sess.get("bytes_in") or 0),
                "bytesOut": int(sess.get("bytes_out") or 0),
                "ageSec": int(time.time() - float(sess.get("opened") or time.time())),
            })
        return self._json(200, {"ok": True, "sessions": out})

    def _status(self, parsed):
        qs = parse_qs(parsed.query)
        sid = (qs.get("id") or [""])[0]
        sess = SESSIONS.get(sid)
        if not sess:
            return self._json(404, {"error": "no session"})
        return self._json(200, {
            "ok": True,
            "id": sid,
            "path": sess.get("path"),
            "virtual": bool(sess.get("virtual")),
            "queued": sess["queue"].qsize(),
            "bytesIn": int(sess.get("bytes_in") or 0),
            "bytesOut": int(sess.get("bytes_out") or 0),
        })

    def _read(self, body):
        sess = self._sess(body)
        if not sess:
            return self._json(404, {"error": "no session"})
        try:
            wait = min(max(float(body.get("waitMs") or 0) / 1000.0, 0), 5.0)
        except (TypeError, ValueError):
            wait = 0.0
        chunks = []
        deadline = time.time() + wait
        while True:
            timeout = 0.0 if wait <= 0 else max(0.0, deadline - time.time())
            try:
                chunks.append(sess["queue"].get(timeout=timeout if timeout else 0.001))
            except queue.Empty:
                if time.time() >= deadline:
                    break
                if wait <= 0:
                    break
        data = b"".join(chunks)
        sess["bytes_in"] = int(sess.get("bytes_in") or 0) + len(data)
        text = data.decode("utf-8", "replace")
        return self._json(200, {
            "ok": True,
            "n": len(data),
            "b64": base64.b64encode(data).decode("ascii"),
            "text": text if all(32 <= c < 127 or c in (9, 10, 13) for c in bytearray(data)) or len(data) < 4000 else None,
        })

    def _flush(self, body):
        sess = self._sess(body)
        if not sess:
            return self._json(404, {"error": "no session"})
        try:
            port_flush(sess["handle"])
            while True:
                sess["queue"].get_nowait()
        except queue.Empty:
            pass
        except Exception as exc:
            return self._json(500, {"error": str(exc)})
        return self._json(200, {"ok": True})

    def _line(self, body):
        sess = self._sess(body)
        if not sess:
            return self._json(404, {"error": "no session"})
        text = body.get("text")
        if text is None:
            return self._json(400, {"error": "text required"})
        ending = (body.get("ending") or "lf").lower()
        tail = {"lf": "\n", "cr": "\r", "crlf": "\r\n", "none": ""}.get(ending, "\n")
        raw = (str(text) + tail).encode("utf-8", "replace")
        try:
            port_write(sess["handle"], raw)
            sess["bytes_out"] = int(sess.get("bytes_out") or 0) + len(raw)
        except Exception as exc:
            return self._json(500, {"error": str(exc)})
        return self._json(200, {"ok": True, "bytes": len(raw), "ending": ending})

    def _reopen(self, body):
        sess = self._sess(body)
        if not sess:
            return self._json(404, {"error": "no session"})
        if sess.get("virtual"):
            return self._json(400, {"error": "virtual port has no baud"})
        path = sess.get("path")
        baud = int(body.get("baud") or 115200)
        data_bits = int(body.get("dataBits") or 8)
        parity = str(body.get("parity") or "none")
        stop_bits = int(body.get("stopBits") or 1)
        try:
            port_close(sess["handle"])
            handle = open_port(path, baud, data_bits, parity, stop_bits)
            sess["handle"] = handle
        except Exception as exc:
            return self._json(500, {"error": str(exc)})
        return self._json(200, {"ok": True, "path": path, "baud": baud})

    def _need_engine(self):
        if api_engine is None:
            self._json(500, {"ok": False, "error": "api_engine missing"})
            return False
        return True

    def _batch(self, body):
        if not self._need_engine():
            return
        if body.get("text") is None:
            return self._json(400, {"ok": False, "error": "JSON body needs a text field"})
        return self._json(200, api_engine.batch_analyze(body.get("text"), body.get("baud")))

    def _baud(self, body):
        if not self._need_engine():
            return
        if body.get("baud") is None:
            return self._json(400, {"ok": False, "error": "baud required"})
        return self._json(200, api_engine.baud_info(
            body.get("baud"), body.get("dataBits"), body.get("parity"),
            body.get("stopBits"), body.get("cableM"),
        ))

    def _strip(self, body):
        if not self._need_engine():
            return
        text = body.get("text")
        if text is None:
            return self._json(400, {"ok": False, "error": "JSON body needs a text field"})
        return self._json(200, {"ok": True, "text": api_engine.strip_ansi(api_engine.clamp_text(text))})

    def _stats(self, body):
        if not self._need_engine():
            return
        if body.get("text") is None:
            return self._json(400, {"ok": False, "error": "JSON body needs a text field"})
        return self._json(200, api_engine.ansi_stats(body.get("text")))

    def _hex(self, body):
        if not self._need_engine():
            return
        return self._json(200, api_engine.hex_tools(body.get("text"), body.get("b64"), bool(body.get("decode"))))

    def _nmea(self, body):
        if not self._need_engine():
            return
        if body.get("text") is None:
            return self._json(400, {"ok": False, "error": "JSON body needs a text field"})
        return self._json(200, api_engine.nmea_parse(body.get("text")))

    def _at(self, body):
        if not self._need_engine():
            return
        if body.get("text") is None:
            return self._json(400, {"ok": False, "error": "JSON body needs a text field"})
        return self._json(200, api_engine.at_parse(body.get("text")))

    def _crc(self, body):
        if not self._need_engine():
            return
        return self._json(200, api_engine.crc_of(body.get("text"), body.get("b64"), body.get("algo") or "crc16"))

    def _diff(self, body):
        if not self._need_engine():
            return
        if body.get("a") is None or body.get("b") is None:
            return self._json(400, {"ok": False, "error": "JSON body needs a and b"})
        return self._json(200, api_engine.dump_diff(body.get("a"), body.get("b")))

    def _detect(self, body):
        if api_engine is None:
            return self._json(500, {"ok": False, "error": "api_engine missing"})
        text = body.get("text")
        if text is None:
            return self._json(400, {"ok": False, "error": "JSON body needs a text field"})
        baud = body.get("baud")
        try:
            baud = int(baud) if baud not in (None, "") else None
        except (TypeError, ValueError):
            baud = None
        return self._json(200, api_engine.detect_device(text, baud))

    def _explain(self, body):
        if api_engine is None:
            return self._json(500, {"ok": False, "error": "api_engine missing"})
        text = body.get("text")
        if text is None:
            return self._json(400, {"ok": False, "error": "JSON body needs a text field"})
        return self._json(200, api_engine.explain_text(text))

    def _api_docs(self):
        path = os.path.join(ROOT, "api", "index.php")
        if not os.path.isfile(path):
            return self._json(200, {"ok": True, "docs": "/api", "detect": "/api/detect", "explain": "/api/explain"})
        with open(path, encoding="utf-8") as fh:
            html = fh.read()
        html = re.sub(r"<\?php.*?\?>\s*", "", html, count=1, flags=re.S)
        payload = html.encode("utf-8")
        self.send_response(200)
        self.send_header("Content-Type", "text/html; charset=utf-8")
        self.send_header("Content-Length", str(len(payload)))
        self.end_headers()
        self.wfile.write(payload)

    def _help_page(self):
        path = os.path.join(ROOT, "helpers.php")
        with open(path, encoding="utf-8") as fh:
            html = fh.read()
        html = re.sub(r"<\?php.*?\?>\s*", "", html, count=1, flags=re.S)
        payload = html.encode("utf-8")
        self.send_response(200)
        self.send_header("Content-Type", "text/html; charset=utf-8")
        self.send_header("Content-Length", str(len(payload)))
        self.end_headers()
        self.wfile.write(payload)

    def _send_file(self, name, ctype):
        path = os.path.join(ROOT, name)
        with open(path, "rb") as fh:
            payload = fh.read()
        self.send_response(200)
        self.send_header("Content-Type", ctype)
        self.send_header("Content-Length", str(len(payload)))
        self.end_headers()
        self.wfile.write(payload)


def main() -> None:
    parser = argparse.ArgumentParser(description="tty000 host")
    parser.add_argument("--host", default=os.environ.get("TTY000_HOST", "127.0.0.1"))
    parser.add_argument("--port", type=int, default=int(os.environ.get("TTY000_PORT", "8000")))
    parser.add_argument("--serial", action="store_true", default=os.environ.get("TTY000_SERIAL") == "1")
    parser.add_argument("--helper", action="store_true", help="Local USB helper on 127.0.0.1:7682")
    args = parser.parse_args()
    if args.helper:
        args.host = "127.0.0.1"
        args.port = int(os.environ.get("TTY000_HELPER_PORT", "7682"))
        args.serial = True
        CONFIG["helper"] = True
    CONFIG["serial"] = args.serial
    httpd = ThreadingHTTPServer((args.host, args.port), Handler)
    mode = "helper" if args.helper else ("serial+site" if args.serial else "site")
    print(f"tty000 {mode} on http://{args.host}:{args.port}/", flush=True)
    if args.serial:
        print("serial ports:", ", ".join(p["label"] for p in list_ports()) or "(none found)", flush=True)
        print("add this user to the dialout group on Debian if open() fails", flush=True)
    try:
        httpd.serve_forever()
    except KeyboardInterrupt:
        print()


if __name__ == "__main__":
    main()
