from __future__ import annotations

import json
import signal
import threading
import time
import uuid
from dataclasses import dataclass, field
from http.server import BaseHTTPRequestHandler, HTTPServer
from typing import Any
from urllib.parse import urlparse

from playwright.sync_api import Error as PlaywrightError
from playwright.sync_api import TimeoutError as PlaywrightTimeoutError
from playwright.sync_api import sync_playwright


COLLECT_ELEMENTS_SCRIPT = """
() => {
  const unsupportedInputTypes = new Set([
    'hidden', 'file', 'radio', 'checkbox', 'submit', 'reset', 'button',
    'image', 'color', 'range', 'date', 'datetime-local', 'month', 'time', 'week'
  ]);
  const selectors = [
    'a[href]',
    'button',
    'input',
    'textarea',
    'select',
    'summary',
    'label',
    '[role="button"]',
    '[role="link"]',
    '[onclick]',
    '[tabindex]'
  ];
  const seen = new Set();
  const out = [];

  const isVisible = (el) => {
    if (!el || !el.ownerDocument || !el.ownerDocument.documentElement.contains(el)) {
      return false;
    }
    const style = window.getComputedStyle(el);
    if (style.display === 'none' || style.visibility === 'hidden') {
      return false;
    }
    const rect = el.getBoundingClientRect();
    return rect.width > 0 && rect.height > 0;
  };

  if (!window.__browseableElementCounter) {
    window.__browseableElementCounter = 1;
  }

  for (const selector of selectors) {
    for (const el of document.querySelectorAll(selector)) {
      if (seen.has(el) || !isVisible(el)) {
        continue;
      }
      seen.add(el);

      if (el.disabled || el.getAttribute('aria-disabled') === 'true') {
        continue;
      }

      const tag = el.tagName.toLowerCase();
      const role = (el.getAttribute('role') || '').toLowerCase();
      const type = (el.getAttribute('type') || '').toLowerCase();
      const isTextInput = tag === 'textarea' || (tag === 'input' && !unsupportedInputTypes.has(type));
      const isInteractable = (
        tag === 'a' ||
        tag === 'button' ||
        tag === 'summary' ||
        tag === 'select' ||
        tag === 'label' ||
        isTextInput ||
        role === 'button' ||
        role === 'link' ||
        el.hasAttribute('onclick') ||
        el.tabIndex >= 0
      );
      if (!isInteractable) {
        continue;
      }

      if (!el.dataset.browseableId) {
        el.dataset.browseableId = `be-${window.__browseableElementCounter++}`;
      }
      const rect = el.getBoundingClientRect();
      const textCandidate = [
        (el.innerText || '').trim(),
        (el.value || '').trim(),
        (el.getAttribute('aria-label') || '').trim(),
        (el.getAttribute('placeholder') || '').trim()
      ].find((value) => value && value.length > 0) || '';
      out.push({
        element_id: el.dataset.browseableId,
        tag,
        role,
        type,
        text: textCandidate.slice(0, 160),
        name: (el.getAttribute('name') || '').slice(0, 120),
        placeholder: (el.getAttribute('placeholder') || '').slice(0, 120),
        href: (el.getAttribute('href') || '').slice(0, 240),
        x: Math.round(rect.x),
        y: Math.round(rect.y),
        width: Math.round(rect.width),
        height: Math.round(rect.height)
      });
    }
  }
  return out;
}
"""

CAPTURE_FORM_STATE_SCRIPT = """
() => {
  const out = [];
  for (const el of document.querySelectorAll('[data-browseable-id]')) {
    const tag = el.tagName.toLowerCase();
    const isEditable = !!el.isContentEditable;
    if (!isEditable && tag !== 'input' && tag !== 'textarea' && tag !== 'select') {
      continue;
    }
    out.push({
      element_id: el.dataset.browseableId || '',
      tag,
      type: (el.getAttribute('type') || '').toLowerCase(),
      value: isEditable ? (el.innerText || '') : (el.value || ''),
      checked: !!el.checked,
      selected_index: typeof el.selectedIndex === 'number' ? el.selectedIndex : null,
      is_editable: isEditable
    });
  }
  return out;
}
"""

APPLY_FORM_STATE_SCRIPT = """
(records) => {
  if (!Array.isArray(records)) {
    return;
  }
  for (const record of records) {
    if (!record || !record.element_id) {
      continue;
    }
    const el = document.querySelector(`[data-browseable-id="${record.element_id}"]`);
    if (!el) {
      continue;
    }
    const tag = (el.tagName || '').toLowerCase();
    if (record.is_editable && el.isContentEditable) {
      el.innerText = record.value || '';
      el.dispatchEvent(new Event('input', { bubbles: true }));
      continue;
    }
    if (tag === 'input' || tag === 'textarea') {
      if (record.type === 'checkbox' || record.type === 'radio') {
        el.checked = !!record.checked;
      } else {
        el.value = record.value || '';
      }
      el.dispatchEvent(new Event('input', { bubbles: true }));
      el.dispatchEvent(new Event('change', { bubbles: true }));
      continue;
    }
    if (tag === 'select') {
      if (typeof record.selected_index === 'number' && record.selected_index >= 0) {
        el.selectedIndex = record.selected_index;
      }
      if (record.value) {
        el.value = record.value;
      }
      el.dispatchEvent(new Event('change', { bubbles: true }));
    }
  }
}
"""


@dataclass
class SessionState:
    session_id: str
    context: Any
    page: Any
    created_at: float
    updated_at: float
    last_elements: list[dict[str, Any]] = field(default_factory=list)


class BrowseableEngine:
    def __init__(self, *, browser_name: str = "chromium", headless: bool = True):
        self._browser_name = browser_name
        self._headless = headless
        self._lock = threading.RLock()
        self._playwright = None
        self._browser = None
        self._interactive_browser = None
        self._sessions: dict[str, SessionState] = {}

    def start(self) -> None:
        with self._lock:
            if self._browser is not None:
                return
            self._playwright = sync_playwright().start()
            browser_type = getattr(self._playwright, self._browser_name, None)
            if browser_type is None:
                raise ValueError(f"Unsupported browser: {self._browser_name}")
            self._browser = browser_type.launch(headless=self._headless)

    def stop(self) -> None:
        with self._lock:
            for session in list(self._sessions.values()):
                try:
                    session.context.close()
                except Exception:
                    pass
            self._sessions.clear()
            if self._browser is not None:
                try:
                    self._browser.close()
                finally:
                    self._browser = None
            if self._interactive_browser is not None:
                try:
                    self._interactive_browser.close()
                finally:
                    self._interactive_browser = None
            if self._playwright is not None:
                try:
                    self._playwright.stop()
                finally:
                    self._playwright = None

    def session_count(self) -> int:
        with self._lock:
            return len(self._sessions)

    def create_session(self, url: str, *, timeout_ms: int = 15000) -> dict[str, Any]:
        with self._lock:
            self._assert_running()
            if not url:
                raise ValueError("Missing required field: url")
            session_id = uuid.uuid4().hex[:12]
            context = self._browser.new_context()
            page = context.new_page()
            try:
                page.goto(url, wait_until="domcontentloaded", timeout=timeout_ms)
            except PlaywrightTimeoutError as exc:
                context.close()
                raise RuntimeError(f"Navigation timeout after {timeout_ms}ms: {url}") from exc
            except PlaywrightError as exc:
                context.close()
                raise RuntimeError(f"Failed to navigate to {url}: {exc}") from exc
            now = time.time()
            state = SessionState(
                session_id=session_id,
                context=context,
                page=page,
                created_at=now,
                updated_at=now,
            )
            self._sessions[session_id] = state
            return {
                "session_id": session_id,
                "url": page.url,
                "created_at": now,
            }

    def list_elements(self, session_id: str) -> list[dict[str, Any]]:
        with self._lock:
            session = self._get_session(session_id)
            try:
                elements = session.page.evaluate(COLLECT_ELEMENTS_SCRIPT)
            except PlaywrightError as exc:
                raise RuntimeError(f"Failed to read page elements: {exc}") from exc
            normalized: list[dict[str, Any]] = []
            for index, element in enumerate(elements, start=1):
                item = dict(element)
                item["index"] = index
                normalized.append(item)
            session.last_elements = normalized
            session.updated_at = time.time()
            return normalized

    def perform_action(
        self,
        session_id: str,
        *,
        action: str,
        element_token: str | None = None,
        text: str | None = None,
        timeout_ms: int = 10000,
    ) -> dict[str, Any]:
        with self._lock:
            session = self._get_session(session_id)
            action_name = action.lower()
            try:
                if action_name == "interact":
                    return self._open_interactive_session(session, timeout_ms=timeout_ms)

                if not element_token:
                    raise ValueError('Missing "element_id" for this action')
                element_id = self._resolve_element_id(session, element_token)
                locator = session.page.locator(f'[data-browseable-id="{element_id}"]').first
                if locator.count() == 0:
                    raise KeyError(
                        f'Element "{element_token}" was not found. Run "browseable elements {session_id}" again.'
                    )

                if action_name == "click":
                    locator.scroll_into_view_if_needed(timeout=timeout_ms)
                    locator.click(timeout=timeout_ms)
                elif action_name == "type":
                    if text is None:
                        raise ValueError('Missing "text" for type action')
                    self._type_into(locator, session, text=text, timeout_ms=timeout_ms)
                else:
                    raise ValueError(f'Unsupported action "{action}"')
            except PlaywrightTimeoutError as exc:
                raise RuntimeError(f'Action "{action_name}" timed out after {timeout_ms}ms') from exc
            except PlaywrightError as exc:
                raise RuntimeError(f'Action "{action_name}" failed: {exc}') from exc

            session.updated_at = time.time()
            return {
                "ok": True,
                "session_id": session_id,
                "action": action_name,
                "element_id": element_id,
                "url": session.page.url,
            }

    def close_session(self, session_id: str) -> dict[str, Any]:
        with self._lock:
            session = self._get_session(session_id)
            try:
                session.context.close()
            finally:
                self._sessions.pop(session_id, None)
            return {"ok": True, "session_id": session_id}

    def _assert_running(self) -> None:
        if self._browser is None:
            raise RuntimeError("Browser is not initialized")

    def _get_session(self, session_id: str) -> SessionState:
        session = self._sessions.get(session_id)
        if session is None:
            raise KeyError(f'Session "{session_id}" not found')
        return session

    def _resolve_element_id(self, session: SessionState, token: str) -> str:
        if token.isdigit():
            idx = int(token)
            if idx <= 0:
                raise ValueError(f"Element index must be positive: {idx}")
            if not session.last_elements:
                raise KeyError(
                    f'Session "{session.session_id}" has no cached element list. '
                    f'Run "browseable elements {session.session_id}" first.'
                )
            if idx > len(session.last_elements):
                raise KeyError(
                    f"Element index out of range: {idx}. "
                    f"Latest list has {len(session.last_elements)} elements."
                )
            return str(session.last_elements[idx - 1]["element_id"])
        return token

    def _type_into(self, locator: Any, session: SessionState, *, text: str, timeout_ms: int) -> None:
        tag = locator.evaluate("el => el.tagName.toLowerCase()")
        is_contenteditable = locator.evaluate("el => !!el.isContentEditable")
        locator.scroll_into_view_if_needed(timeout=timeout_ms)
        if tag in {"input", "textarea"}:
            locator.fill(text, timeout=timeout_ms)
            return
        if is_contenteditable:
            locator.click(timeout=timeout_ms)
            session.page.keyboard.type(text)
            return
        # Fallback for custom inputs that still support filling.
        locator.fill(text, timeout=timeout_ms)

    def _open_interactive_session(
        self,
        session: SessionState,
        *,
        timeout_ms: int,
    ) -> dict[str, Any]:
        # If daemon already runs headed, just focus the page.
        if not self._headless:
            session.page.bring_to_front()
            session.updated_at = time.time()
            return {
                "ok": True,
                "session_id": session.session_id,
                "action": "interact",
                "url": session.page.url,
                "promoted": False,
                "headed": True,
            }

        if self._interactive_browser is None:
            browser_type = getattr(self._playwright, self._browser_name, None)
            if browser_type is None:
                raise ValueError(f"Unsupported browser: {self._browser_name}")
            self._interactive_browser = browser_type.launch(headless=False)

        # Ensure stable element IDs before capturing state.
        session.page.evaluate(COLLECT_ELEMENTS_SCRIPT)
        form_state = session.page.evaluate(CAPTURE_FORM_STATE_SCRIPT)
        storage_state = session.context.storage_state()
        interactive_context = self._interactive_browser.new_context(storage_state=storage_state)
        interactive_page = interactive_context.new_page()
        interactive_page.goto(session.page.url, wait_until="domcontentloaded", timeout=timeout_ms)
        interactive_page.evaluate(COLLECT_ELEMENTS_SCRIPT)
        interactive_page.evaluate(APPLY_FORM_STATE_SCRIPT, form_state)
        interactive_page.bring_to_front()

        # Replace the old headless context so future actions keep using this interactive session.
        previous_context = session.context
        session.context = interactive_context
        session.page = interactive_page
        session.last_elements = []
        session.updated_at = time.time()
        try:
            previous_context.close()
        except Exception:
            pass

        return {
            "ok": True,
            "session_id": session.session_id,
            "action": "interact",
            "url": interactive_page.url,
            "promoted": True,
            "headed": True,
        }


class APIError(Exception):
    def __init__(self, status_code: int, message: str):
        super().__init__(message)
        self.status_code = status_code
        self.message = message


class BrowseableHTTPServer(HTTPServer):
    allow_reuse_address = True

    def __init__(
        self,
        server_address: tuple[str, int],
        request_handler_class: type[BaseHTTPRequestHandler],
        *,
        engine: BrowseableEngine,
    ):
        super().__init__(server_address, request_handler_class)
        self.engine = engine


class BrowseableRequestHandler(BaseHTTPRequestHandler):
    server: BrowseableHTTPServer

    def log_message(self, format: str, *args: Any) -> None:  # noqa: A003
        # Keep daemon output quiet unless the process itself fails.
        return

    def do_GET(self) -> None:  # noqa: N802
        try:
            parts = self._parts()
            if parts == ["health"]:
                self._send_json(200, {"ok": True, "sessions": self.server.engine.session_count()})
                return
            if len(parts) == 3 and parts[0] == "sessions" and parts[2] == "elements":
                session_id = parts[1]
                elements = self.server.engine.list_elements(session_id)
                self._send_json(200, {"session_id": session_id, "elements": elements})
                return
            raise APIError(404, f"Unknown route: {self.path}")
        except Exception as exc:
            self._handle_error(exc)

    def do_POST(self) -> None:  # noqa: N802
        try:
            parts = self._parts()
            if parts == ["sessions"]:
                payload = self._read_json()
                timeout_ms = int(payload.get("timeout_ms", 15000))
                result = self.server.engine.create_session(payload.get("url", ""), timeout_ms=timeout_ms)
                self._send_json(201, result)
                return

            if len(parts) == 3 and parts[0] == "sessions" and parts[2] == "actions":
                payload = self._read_json()
                timeout_ms = int(payload.get("timeout_ms", 10000))
                result = self.server.engine.perform_action(
                    parts[1],
                    action=str(payload.get("action", "")),
                    element_token=str(payload.get("element_id", "")),
                    text=payload.get("text"),
                    timeout_ms=timeout_ms,
                )
                self._send_json(200, result)
                return

            if parts == ["shutdown"]:
                self._send_json(200, {"ok": True})
                threading.Thread(target=self.server.shutdown, daemon=True).start()
                return

            raise APIError(404, f"Unknown route: {self.path}")
        except Exception as exc:
            self._handle_error(exc)

    def do_DELETE(self) -> None:  # noqa: N802
        try:
            parts = self._parts()
            if len(parts) == 2 and parts[0] == "sessions":
                result = self.server.engine.close_session(parts[1])
                self._send_json(200, result)
                return
            raise APIError(404, f"Unknown route: {self.path}")
        except Exception as exc:
            self._handle_error(exc)

    def _parts(self) -> list[str]:
        path = urlparse(self.path).path.strip("/")
        if not path:
            return []
        return [segment for segment in path.split("/") if segment]

    def _read_json(self) -> dict[str, Any]:
        length = int(self.headers.get("Content-Length", "0"))
        if length <= 0:
            return {}
        raw = self.rfile.read(length).decode("utf-8")
        if not raw.strip():
            return {}
        try:
            return json.loads(raw)
        except json.JSONDecodeError as exc:
            raise APIError(400, f"Invalid JSON payload: {exc}") from exc

    def _send_json(self, status: int, payload: dict[str, Any]) -> None:
        body = json.dumps(payload).encode("utf-8")
        self.send_response(status)
        self.send_header("Content-Type", "application/json")
        self.send_header("Content-Length", str(len(body)))
        self.end_headers()
        self.wfile.write(body)

    def _handle_error(self, exc: Exception) -> None:
        if isinstance(exc, APIError):
            self._send_json(exc.status_code, {"error": exc.message})
            return
        if isinstance(exc, KeyError):
            self._send_json(404, {"error": str(exc).strip("'")})
            return
        if isinstance(exc, ValueError):
            self._send_json(400, {"error": str(exc)})
            return
        self._send_json(500, {"error": str(exc)})


def run_daemon(*, host: str, port: int, browser: str, headless: bool) -> None:
    engine = BrowseableEngine(browser_name=browser, headless=headless)
    engine.start()
    server = BrowseableHTTPServer((host, port), BrowseableRequestHandler, engine=engine)

    def _shutdown(_signum: int, _frame: Any) -> None:
        server.shutdown()

    signal.signal(signal.SIGTERM, _shutdown)
    signal.signal(signal.SIGINT, _shutdown)
    try:
        server.serve_forever(poll_interval=0.2)
    finally:
        server.server_close()
        engine.stop()
