"""Shared Playwright venv helpers and subprocess runner for web-act scripts."""
from __future__ import annotations

import os
import selectors
import signal
import subprocess
import sys
import time
from dataclasses import dataclass

_MUGI_DIR = os.path.expanduser("~/.mugi")
_PLAYWRIGHT_VENV_DIR = os.path.join(_MUGI_DIR, "playwright-venv")
_PLAYWRIGHT_VENV_PYTHON = os.path.join(_PLAYWRIGHT_VENV_DIR, "bin", "python")

_BRAVE_EXECUTABLE_PATHS = (
    "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser",
)


def playwright_venv_python() -> str:
    return _PLAYWRIGHT_VENV_PYTHON


def playwright_available() -> bool:
    """Return True only if the Playwright venv exists and has playwright installed."""
    if not os.path.isfile(_PLAYWRIGHT_VENV_PYTHON):
        return False
    try:
        r = subprocess.run(
            [_PLAYWRIGHT_VENV_PYTHON, "-c", "import playwright; print('ok')"],
            capture_output=True,
            text=True,
            timeout=10,
        )
        return r.returncode == 0 and "ok" in (r.stdout or "")
    except Exception:
        return False


_BROWSER_PROBE = (
    "from playwright.sync_api import sync_playwright\n"
    "with sync_playwright() as p:\n"
    "    b = p.chromium.launch(headless=True)\n"
    "    b.close()\n"
    "print('ok')\n"
)
_BROWSER_PROBE_CACHE: tuple[float, bool, str] | None = None
_BROWSER_PROBE_TTL_S = 60.0


def playwright_browsers_ready(*, use_cache: bool = True) -> tuple[bool, str]:
    """Return (ready, detail). Verifies bundled Chromium can launch, not just import."""
    global _BROWSER_PROBE_CACHE
    if not playwright_available():
        return False, "Playwright package is not installed."
    now = time.monotonic()
    if use_cache and _BROWSER_PROBE_CACHE is not None:
        cached_at, ready, detail = _BROWSER_PROBE_CACHE
        if now - cached_at < _BROWSER_PROBE_TTL_S:
            return ready, detail
    try:
        r = subprocess.run(
            [_PLAYWRIGHT_VENV_PYTHON, "-c", _BROWSER_PROBE],
            capture_output=True,
            text=True,
            timeout=30,
        )
    except subprocess.TimeoutExpired:
        ready, detail = False, "Chromium launch timed out during Playwright preflight."
    except Exception as exc:
        ready, detail = False, str(exc)
    else:
        if r.returncode == 0 and "ok" in (r.stdout or ""):
            ready, detail = True, ""
        else:
            err = (r.stderr or r.stdout or "").strip()
            if "Executable doesn't exist" in err or "playwright install" in err.lower():
                detail = (
                    "Playwright browser binaries are missing or out of date for the installed "
                    "package. Open Settings → Advanced and click Install Playwright to download "
                    "Chromium, then retry."
                )
            elif err:
                detail = err[:800]
            else:
                detail = "Chromium launch failed during Playwright preflight."
            ready = False
    _BROWSER_PROBE_CACHE = (now, ready, detail)
    return ready, detail


def _load_prefs() -> dict:
    try:
        from utils.app_prefs_store import load_app_prefs_cached
        return load_app_prefs_cached()
    except Exception:
        return {}


def playwright_launch_kwargs(*, stealth: bool = False) -> dict:
    """Build chromium.launch() kwargs from app prefs."""
    prefs = _load_prefs()
    channel = (prefs.get("playwright_browser") or "chromium").strip().lower()
    custom_path = (prefs.get("playwright_browser_path") or "").strip()
    kwargs: dict = {"headless": True}
    if custom_path:
        kwargs["executable_path"] = os.path.expanduser(custom_path)
    elif channel == "brave":
        for path in _BRAVE_EXECUTABLE_PATHS:
            if os.path.isfile(path):
                kwargs["executable_path"] = path
                break
        if "executable_path" not in kwargs:
            kwargs["executable_path"] = _BRAVE_EXECUTABLE_PATHS[0]
    elif channel in ("chrome", "msedge"):
        kwargs["channel"] = channel
    if stealth:
        kwargs.setdefault("args", []).append("--disable-blink-features=AutomationControlled")
    return kwargs


def _kill_process_group(proc: subprocess.Popen, sig: int = signal.SIGTERM) -> None:
    if proc.poll() is not None:
        return
    try:
        pgid = os.getpgid(proc.pid)
        os.killpg(pgid, sig)
    except (ProcessLookupError, PermissionError, OSError):
        try:
            proc.kill()
        except Exception:
            pass


@dataclass
class ScriptRunResult:
    exit_code: int
    stdout: str
    stderr: str
    duration_ms: int
    timed_out: bool
    cancelled: bool = False


class _OutputCollector:
    def __init__(self, max_output_chars: int, *, label: str) -> None:
        self._max = max_output_chars
        self._label = label
        self._parts: list[str] = []
        self.truncated = False

    def append(self, chunk: str) -> None:
        if not chunk or self.truncated:
            return
        current = sum(len(p) for p in self._parts)
        if current >= self._max:
            self.truncated = True
            return
        remaining = self._max - current
        if len(chunk) > remaining:
            self._parts.append(chunk[:remaining])
            self.truncated = True
        else:
            self._parts.append(chunk)

    def text(self) -> str:
        body = "".join(self._parts)
        if self.truncated:
            return body + f"\n…[{self._label} truncated]"
        return body


def run_script_subprocess(
    argv: list[str],
    *,
    cwd: str,
    timeout_s: float,
    env: dict[str, str] | None = None,
    max_output_chars: int = 8000,
    stop_check=None,
) -> ScriptRunResult:
    """Run *argv* in a new process group; kill the group on timeout or stop."""
    start = time.monotonic()
    proc: subprocess.Popen | None = None
    timed_out = False
    cancelled = False
    try:
        proc = subprocess.Popen(
            argv,
            cwd=cwd,
            env=env,
            stdout=subprocess.PIPE,
            stderr=subprocess.PIPE,
            text=True,
            start_new_session=True,
        )
        deadline = start + max(1.0, float(timeout_s))
        stdout_col = _OutputCollector(max_output_chars, label="stdout")
        stderr_col = _OutputCollector(max_output_chars, label="stderr")
        sel = selectors.DefaultSelector()
        try:
            if proc.stdout:
                sel.register(proc.stdout, selectors.EVENT_READ, data="stdout")
            if proc.stderr:
                sel.register(proc.stderr, selectors.EVENT_READ, data="stderr")
            while proc.poll() is None:
                if stop_check and stop_check():
                    cancelled = True
                    _kill_process_group(proc)
                    break
                if time.monotonic() >= deadline:
                    timed_out = True
                    _kill_process_group(proc)
                    break
                events = sel.select(timeout=0.05)
                for key, _ in events:
                    chunk = key.fileobj.read(4096)
                    if not chunk:
                        continue
                    if key.data == "stdout":
                        stdout_col.append(chunk)
                    else:
                        stderr_col.append(chunk)
        finally:
            sel.close()
        assert proc is not None
        if proc.stdout:
            stdout_col.append(proc.stdout.read() or "")
        if proc.stderr:
            stderr_col.append(proc.stderr.read() or "")
        try:
            proc.wait(timeout=5)
        except subprocess.TimeoutExpired:
            _kill_process_group(proc, signal.SIGKILL)
            proc.wait(timeout=2)
        stdout = stdout_col.text()
        stderr = stderr_col.text()
        exit_code = proc.returncode if proc.returncode is not None else -1
        if timed_out:
            exit_code = -9
        elif cancelled:
            exit_code = -15
        return ScriptRunResult(
            exit_code=exit_code,
            stdout=stdout,
            stderr=stderr,
            duration_ms=int((time.monotonic() - start) * 1000),
            timed_out=timed_out,
            cancelled=cancelled,
        )
    except Exception as exc:
        if proc is not None and proc.poll() is None:
            _kill_process_group(proc, signal.SIGKILL)
        return ScriptRunResult(
            exit_code=-1,
            stdout="",
            stderr=str(exc),
            duration_ms=int((time.monotonic() - start) * 1000),
            timed_out=False,
            cancelled=cancelled,
        )


def run_python_module(
    module: str,
    args: list[str],
    *,
    cwd: str,
    timeout_s: float,
    env: dict[str, str] | None = None,
    max_output_chars: int = 8000,
    stop_check=None,
) -> ScriptRunResult:
    """Run ``python -m module`` using the Playwright venv interpreter."""
    py = playwright_venv_python()
    if not os.path.isfile(py):
        return ScriptRunResult(
            exit_code=-1,
            stdout="",
            stderr="Playwright venv python not found.",
            duration_ms=0,
            timed_out=False,
        )
    return run_script_subprocess(
        [py, "-m", module, *args],
        cwd=cwd,
        timeout_s=timeout_s,
        env=env,
        max_output_chars=max_output_chars,
        stop_check=stop_check,
    )
