from __future__ import annotations

import argparse
import json
import os
import signal
import subprocess
import sys
import time
from typing import Any

from browseable.client import ClientError, request_json
from browseable.config import (
    DEFAULT_BROWSER,
    DEFAULT_HOST,
    DEFAULT_PORT,
    ensure_data_dir,
    get_daemon_url,
    log_file,
    make_daemon_url,
    pid_file,
    url_file,
)


def _print_error(message: str) -> None:
    print(f"error: {message}", file=sys.stderr)


def _print_json(payload: dict[str, Any] | list[dict[str, Any]]) -> None:
    print(json.dumps(payload, indent=2))


def _daemon_health(base_url: str) -> dict[str, Any] | None:
    try:
        return request_json("GET", "/health", base_url=base_url, timeout=2.0)
    except ClientError:
        return None


def _write_daemon_state(pid: int | None, daemon_url: str) -> None:
    ensure_data_dir()
    pid_path = pid_file()
    if pid is not None and pid > 0:
        pid_path.write_text(str(pid), encoding="utf-8")
    elif pid_path.exists():
        pid_path.unlink()
    url_file().write_text(daemon_url, encoding="utf-8")


def _clear_daemon_state() -> None:
    for path in [pid_file(), url_file()]:
        if path.exists():
            path.unlink()


def _read_pid() -> int | None:
    path = pid_file()
    if not path.exists():
        return None
    raw = path.read_text(encoding="utf-8").strip()
    if not raw:
        return None
    try:
        value = int(raw)
        if value <= 0:
            return None
        return value
    except ValueError:
        return None


def cmd_daemon_run(args: argparse.Namespace) -> int:
    try:
        from browseable.daemon_server import run_daemon
    except ModuleNotFoundError as exc:
        if exc.name == "playwright":
            _print_error(
                "playwright is not installed. Run: uv sync && uv run playwright install chromium"
            )
            return 1
        raise

    run_daemon(host=args.host, port=args.port, browser=args.browser, headless=not args.headed)
    return 0


def cmd_daemon_start(args: argparse.Namespace) -> int:
    daemon_url = make_daemon_url(args.host, args.port)
    health = _daemon_health(daemon_url)
    if health and health.get("ok"):
        print(f"browseable daemon already running at {daemon_url}")
        _write_daemon_state(_read_pid(), daemon_url)
        return 0

    ensure_data_dir()
    command = [
        sys.executable,
        "-m",
        "browseable",
        "daemon",
        "run",
        "--host",
        args.host,
        "--port",
        str(args.port),
        "--browser",
        args.browser,
    ]
    if args.headed:
        command.append("--headed")

    with log_file().open("a", encoding="utf-8") as log_handle:
        proc = subprocess.Popen(
            command,
            stdout=log_handle,
            stderr=subprocess.STDOUT,
            start_new_session=True,
        )

    _write_daemon_state(proc.pid, daemon_url)
    deadline = time.time() + 30.0
    started = False
    while time.time() < deadline:
        health = _daemon_health(daemon_url)
        if health and health.get("ok"):
            started = True
            break
        if proc.poll() is not None:
            break
        time.sleep(0.2)

    if not started:
        if proc.returncode is None:
            proc.terminate()
        _clear_daemon_state()
        _print_error(
            "daemon failed to start. Check "
            f"{log_file()} for details (often missing browser install)."
        )
        return 1

    print(f"browseable daemon started (pid {proc.pid}) at {daemon_url}")
    return 0


def cmd_daemon_stop(args: argparse.Namespace) -> int:
    daemon_url = args.url or get_daemon_url()
    running = _daemon_health(daemon_url)

    if running and running.get("ok"):
        try:
            request_json("POST", "/shutdown", base_url=daemon_url, timeout=3.0)
        except ClientError:
            pass
        _wait_for_daemon_shutdown(daemon_url, timeout_s=4.0)

    pid = _read_pid()
    if pid:
        if _pid_exists(pid):
            try:
                os.kill(pid, signal.SIGTERM)
            except ProcessLookupError:
                pass
        _wait_for_pid_exit(pid, timeout_s=4.0)

    _clear_daemon_state()
    print("browseable daemon stopped")
    return 0


def _wait_for_daemon_shutdown(base_url: str, *, timeout_s: float) -> bool:
    deadline = time.time() + timeout_s
    while time.time() < deadline:
        if _daemon_health(base_url) is None:
            return True
        time.sleep(0.2)
    return False


def _pid_exists(pid: int) -> bool:
    try:
        os.kill(pid, 0)
    except ProcessLookupError:
        return False
    except PermissionError:
        return True
    return True


def _wait_for_pid_exit(pid: int, *, timeout_s: float) -> bool:
    deadline = time.time() + timeout_s
    while time.time() < deadline:
        if not _pid_exists(pid):
            return True
        time.sleep(0.2)
    return False


def cmd_daemon_status(args: argparse.Namespace) -> int:
    daemon_url = args.url or get_daemon_url()
    health = _daemon_health(daemon_url)
    pid = _read_pid()
    if health and health.get("ok"):
        pid_info = f", pid {pid}" if pid else ""
        print(f"browseable daemon: running at {daemon_url}{pid_info}")
        print(f"sessions: {health.get('sessions', 0)}")
        return 0

    print(f"browseable daemon: not running ({daemon_url})")
    if pid:
        print(f"stale pid file detected: {pid_file()}")
    return 1


def cmd_session_create(args: argparse.Namespace) -> int:
    try:
        result = request_json(
            "POST",
            "/sessions",
            payload={"url": args.url, "timeout_ms": args.timeout_ms},
            timeout=max(5, args.timeout_ms / 1000 + 2),
        )
    except ClientError as exc:
        return _handle_client_error(exc)
    if args.json:
        _print_json(result)
    else:
        print(result["session_id"])
        print(f"url: {result['url']}")
    return 0


def cmd_session_close(args: argparse.Namespace) -> int:
    try:
        result = request_json("DELETE", f"/sessions/{args.session_id}")
    except ClientError as exc:
        return _handle_client_error(exc)
    if args.json:
        _print_json(result)
    else:
        print(f'closed session {result["session_id"]}')
    return 0


def cmd_elements(args: argparse.Namespace) -> int:
    try:
        result = request_json("GET", f"/sessions/{args.session_id}/elements")
    except ClientError as exc:
        return _handle_client_error(exc)
    elements = result.get("elements", [])
    if args.json:
        _print_json(result)
    else:
        _print_elements(elements)
    return 0


def _print_elements(elements: list[dict[str, Any]]) -> None:
    if not elements:
        print("no interactable elements found")
        return
    rows = []
    for item in elements:
        label = item.get("text") or item.get("placeholder") or item.get("name") or ""
        label = " ".join(str(label).split())
        if len(label) > 52:
            label = f"{label[:49]}..."
        rows.append(
            [
                str(item.get("index", "")),
                str(item.get("element_id", "")),
                str(item.get("tag", "")),
                str(item.get("type", "")),
                label,
            ]
        )
    headers = ["INDEX", "ID", "TAG", "TYPE", "LABEL/TEXT"]
    widths = [len(h) for h in headers]
    for row in rows:
        for idx, cell in enumerate(row):
            widths[idx] = max(widths[idx], len(cell))

    print("  ".join(headers[i].ljust(widths[i]) for i in range(len(headers))))
    print("  ".join("-" * widths[i] for i in range(len(headers))))
    for row in rows:
        print("  ".join(row[i].ljust(widths[i]) for i in range(len(row))))
    print("")
    print("Use INDEX or ID with actions, e.g. `browseable action <session> click 2`")


def cmd_action_click(args: argparse.Namespace) -> int:
    return _run_action(
        args,
        action="click",
        element=args.element,
        text=None,
    )


def cmd_action_type(args: argparse.Namespace) -> int:
    return _run_action(
        args,
        action="type",
        element=args.element,
        text=args.text,
    )


def cmd_action_interact(args: argparse.Namespace) -> int:
    return _run_action(
        args,
        action="interact",
        element=None,
        text=None,
    )


def _run_action(
    args: argparse.Namespace,
    *,
    action: str,
    element: str | None,
    text: str | None,
) -> int:
    payload: dict[str, Any] = {
        "action": action,
        "timeout_ms": args.timeout_ms,
    }
    if element is not None:
        payload["element_id"] = element
    if text is not None:
        payload["text"] = text
    try:
        result = request_json(
            "POST",
            f"/sessions/{args.session_id}/actions",
            payload=payload,
            timeout=max(5, args.timeout_ms / 1000 + 2),
        )
    except ClientError as exc:
        return _handle_client_error(exc)

    if args.json:
        _print_json(result)
    else:
        action_name = result.get("action")
        element_id = result.get("element_id")
        if element_id:
            print(f"{action_name} ok on {element_id} (url: {result.get('url')})")
        else:
            print(f"{action_name} ok (url: {result.get('url')})")
    return 0


def _handle_client_error(exc: ClientError) -> int:
    message = str(exc)
    if "Cannot connect to browseable daemon" in message:
        _print_error(f"{message}. Start it with: browseable daemon start")
    else:
        _print_error(message)
    return 1


def build_parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(
        prog="browseable",
        description="CLI for persistent browser sessions and interactable element actions",
    )
    subparsers = parser.add_subparsers(dest="command", required=True)

    daemon_parser = subparsers.add_parser("daemon", help="manage background daemon")
    daemon_subparsers = daemon_parser.add_subparsers(dest="daemon_command", required=True)

    daemon_run = daemon_subparsers.add_parser("run", help="run daemon in foreground")
    _add_daemon_options(daemon_run)
    daemon_run.set_defaults(func=cmd_daemon_run)

    daemon_start = daemon_subparsers.add_parser("start", help="start daemon in background")
    _add_daemon_options(daemon_start)
    daemon_start.set_defaults(func=cmd_daemon_start)

    daemon_stop = daemon_subparsers.add_parser("stop", help="stop running daemon")
    daemon_stop.add_argument("--url", help="override daemon URL")
    daemon_stop.set_defaults(func=cmd_daemon_stop)

    daemon_status = daemon_subparsers.add_parser("status", help="check daemon health")
    daemon_status.add_argument("--url", help="override daemon URL")
    daemon_status.set_defaults(func=cmd_daemon_status)

    session_parser = subparsers.add_parser("session", help="manage browser sessions")
    session_subparsers = session_parser.add_subparsers(dest="session_command", required=True)

    session_create = session_subparsers.add_parser("create", help="create a session for a URL")
    session_create.add_argument("url", help="URL to open in new browser context")
    session_create.add_argument("--timeout-ms", type=int, default=15000)
    session_create.add_argument("--json", action="store_true", help="print JSON output")
    session_create.set_defaults(func=cmd_session_create)

    session_close = session_subparsers.add_parser("close", help="close a session")
    session_close.add_argument("session_id")
    session_close.add_argument("--json", action="store_true", help="print JSON output")
    session_close.set_defaults(func=cmd_session_close)

    elements_parser = subparsers.add_parser(
        "elements", help="list interactable elements for a session"
    )
    elements_parser.add_argument("session_id")
    elements_parser.add_argument("--json", action="store_true", help="print JSON output")
    elements_parser.set_defaults(func=cmd_elements)

    action_parser = subparsers.add_parser(
        "action", aliases=["act"], help="run an action in a session"
    )
    action_parser.add_argument("session_id")
    action_subparsers = action_parser.add_subparsers(dest="action_command", required=True)

    click_parser = action_subparsers.add_parser("click", help="click an element")
    click_parser.add_argument("element", help="element index from list or element ID")
    click_parser.add_argument("--timeout-ms", type=int, default=10000)
    click_parser.add_argument("--json", action="store_true", help="print JSON output")
    click_parser.set_defaults(func=cmd_action_click)

    type_parser = action_subparsers.add_parser("type", help="type text into an element")
    type_parser.add_argument("element", help="element index from list or element ID")
    type_parser.add_argument("text", help="text to type")
    type_parser.add_argument("--timeout-ms", type=int, default=10000)
    type_parser.add_argument("--json", action="store_true", help="print JSON output")
    type_parser.set_defaults(func=cmd_action_type)

    interact_parser = action_subparsers.add_parser(
        "interact", help="open/focus this session in an interactive browser window"
    )
    interact_parser.add_argument("--timeout-ms", type=int, default=15000)
    interact_parser.add_argument("--json", action="store_true", help="print JSON output")
    interact_parser.set_defaults(func=cmd_action_interact)

    return parser


def _add_daemon_options(parser: argparse.ArgumentParser) -> None:
    parser.add_argument("--host", default=DEFAULT_HOST)
    parser.add_argument("--port", type=int, default=DEFAULT_PORT)
    parser.add_argument("--browser", default=DEFAULT_BROWSER, choices=["chromium", "firefox", "webkit"])
    parser.add_argument("--headed", action="store_true", help="run with visible browser window")


def main(argv: list[str] | None = None) -> int:
    parser = build_parser()
    args = parser.parse_args(argv)
    return args.func(args)


if __name__ == "__main__":
    raise SystemExit(main())
