"""Dependency-free Python client for PBX Browser Local API v1.

The PBX Local API is a loopback-only HTTP/JSON service owned by PBXUI.
Current protected operations accept either ``Authorization: Bearer <key>`` or
``X-PBX-API-Key: <key>``.  This client uses Bearer authentication.

Current PBX lifecycle rules reflected here:

* ``GET /v1/health`` is public.
* Every other Local API route is authenticated.
* API profile start is asynchronous and MUST explicitly select ``cdp`` or
  ``selenium``. Manual launch remains UI-only.
* Selenium publishes only its WebDriver endpoint. PBX's internal Chromium CDP
  attachment endpoint is intentionally not a public Local API endpoint.
* Profile create/run requests remain subject to PBX account/server policy and
  can return 403/429 responses.
"""

from __future__ import annotations

import json
import os
import time
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Dict, Iterable, Optional, Sequence
from urllib.error import HTTPError, URLError
from urllib.parse import quote
from urllib.request import Request, urlopen


class PBXAPIError(RuntimeError):
    """Structured PBX Local API failure."""

    def __init__(
        self,
        status: int,
        code: str,
        message: str,
        *,
        response: Optional[Dict[str, Any]] = None,
    ) -> None:
        super().__init__(f"PBX API {status} {code}: {message}")
        self.status = int(status)
        self.code = str(code)
        self.message = str(message)
        self.response = response or {}


@dataclass
class PBXLocalAPI:
    """Small synchronous client for PBX Browser Local API v1."""

    api_key: str
    endpoint: str = "http://127.0.0.1:8787"
    timeout: float = 10.0

    def __post_init__(self) -> None:
        self.api_key = str(self.api_key).strip()
        if not self.api_key:
            raise ValueError("PBX Local API key is required.")

        self.endpoint = str(self.endpoint).strip().rstrip("/")
        if not self.endpoint:
            raise ValueError("PBX Local API endpoint is required.")

        self.timeout = float(self.timeout)
        if self.timeout <= 0:
            raise ValueError("PBX Local API timeout must be greater than zero.")

    @classmethod
    def from_installed_pbx(
        cls,
        *,
        appdata_root: Optional[str | os.PathLike[str]] = None,
        timeout: float = 10.0,
    ) -> "PBXLocalAPI":
        """Load the API key/port from the current Windows PBXUI installation.

        PBXUI stores the Local API key in ``%APPDATA%\\PBXUI\\api_key.txt`` and
        the persistent Local API preference in ``local_api.json``.  A process
        ``PBX_LOCAL_API_PORT`` override wins over the persisted port because the
        PBX listener uses the same override.
        """

        if appdata_root is None:
            appdata = os.environ.get("APPDATA", "").strip()
            if not appdata:
                raise PBXAPIError(
                    0,
                    "appdata_unavailable",
                    "APPDATA is not available; pass appdata_root explicitly.",
                )
            root = Path(appdata) / "PBXUI"
        else:
            root = Path(appdata_root)
            if root.name.lower() != "pbxui":
                root = root / "PBXUI"

        key_path = root / "api_key.txt"
        try:
            api_key = key_path.read_text(encoding="utf-8").strip()
        except OSError as exc:
            raise PBXAPIError(
                0,
                "api_key_unavailable",
                f"PBX Local API key could not be read from {key_path}: {exc}",
            ) from exc

        if not api_key:
            raise PBXAPIError(
                0,
                "api_key_unavailable",
                f"PBX Local API key file is empty: {key_path}",
            )

        port = 8787
        configured_port = os.environ.get("PBX_LOCAL_API_PORT", "").strip()
        if configured_port:
            try:
                parsed_port = int(configured_port)
            except ValueError as exc:
                raise PBXAPIError(
                    0,
                    "invalid_local_api_port",
                    "PBX_LOCAL_API_PORT is not a valid integer.",
                ) from exc
            if not 0 < parsed_port <= 65535:
                raise PBXAPIError(
                    0,
                    "invalid_local_api_port",
                    "PBX_LOCAL_API_PORT must be between 1 and 65535.",
                )
            port = parsed_port
        else:
            settings_path = root / "local_api.json"
            if settings_path.exists():
                try:
                    settings = json.loads(settings_path.read_text(encoding="utf-8"))
                    settings_port = int(settings.get("port", 8787))
                    if 0 < settings_port <= 65535:
                        port = settings_port
                except (OSError, ValueError, TypeError, json.JSONDecodeError):
                    # PBXUI itself falls back to 8787; mirror that safe behavior.
                    port = 8787

        return cls(
            api_key=api_key,
            endpoint=f"http://127.0.0.1:{port}",
            timeout=timeout,
        )

    def _require_api_key(self) -> None:
        if not str(self.api_key).strip():
            raise PBXAPIError(
                0,
                "api_key_required",
                "A valid PBX Local API key is required for this operation.",
            )

    @staticmethod
    def _environment_segment(environment_id: str) -> str:
        value = str(environment_id).strip()
        if not value:
            raise ValueError("environment_id is required.")
        return quote(value, safe="")

    @staticmethod
    def _environment_ids(environment_ids: Iterable[str]) -> list[str]:
        result: list[str] = []
        for value in environment_ids:
            environment_id = str(value).strip()
            if environment_id and environment_id not in result:
                result.append(environment_id)
        if not result:
            raise ValueError("At least one environment ID is required.")
        return result

    @staticmethod
    def _automation_mode(value: str) -> str:
        mode = str(value).strip().lower()
        if mode not in {"cdp", "selenium"}:
            raise ValueError("automation must be 'cdp' or 'selenium'.")
        return mode

    def _request(
        self,
        method: str,
        path: str,
        body: Optional[Dict[str, Any]] = None,
        *,
        authenticated: bool = True,
    ) -> Any:
        payload = None if body is None else json.dumps(body).encode("utf-8")
        headers = {
            "Accept": "application/json",
            "User-Agent": "PBXLocalAPI-Python/1",
        }
        if authenticated:
            self._require_api_key()
            headers["Authorization"] = f"Bearer {self.api_key}"
        if payload is not None:
            headers["Content-Type"] = "application/json"

        request = Request(
            self.endpoint + path,
            data=payload,
            headers=headers,
            method=method.upper(),
        )

        try:
            with urlopen(request, timeout=self.timeout) as response:
                raw = response.read()
                if not raw:
                    return None
                try:
                    return json.loads(raw.decode("utf-8"))
                except (UnicodeDecodeError, json.JSONDecodeError) as exc:
                    raise PBXAPIError(
                        int(getattr(response, "status", 0) or 0),
                        "invalid_json_response",
                        "PBX Local API returned a non-JSON response.",
                    ) from exc
        except HTTPError as exc:
            raw = exc.read()
            parsed: Dict[str, Any] = {}
            try:
                value = json.loads(raw.decode("utf-8")) if raw else {}
                if isinstance(value, dict):
                    parsed = value
            except Exception:
                parsed = {}

            error = parsed.get("error", {})
            if not isinstance(error, dict):
                error = {}

            raise PBXAPIError(
                exc.code,
                str(error.get("code", "http_error")),
                str(error.get("message", exc.reason)),
                response=parsed,
            ) from exc
        except URLError as exc:
            raise PBXAPIError(
                0,
                "connection_failed",
                str(exc.reason),
            ) from exc
        except TimeoutError as exc:
            raise PBXAPIError(0, "timeout", str(exc)) from exc
        except OSError as exc:
            raise PBXAPIError(0, "connection_failed", str(exc)) from exc

    # ------------------------------------------------------------------
    # Service / diagnostics
    # ------------------------------------------------------------------

    def health(self) -> Dict[str, Any]:
        return self._request("GET", "/v1/health", authenticated=False)

    def capabilities(self) -> Dict[str, Any]:
        return self._request("GET", "/v1/capabilities")

    def diagnostics(self) -> Dict[str, Any]:
        return self._request("GET", "/v1/diagnostics")

    def export_diagnostics(self) -> Dict[str, Any]:
        return self._request("POST", "/v1/diagnostics/export", {})

    # ------------------------------------------------------------------
    # Environment CRUD
    # ------------------------------------------------------------------

    def list_environments(self) -> list[Dict[str, Any]]:
        return self._request("GET", "/v1/environments")

    def get_environment(self, environment_id: str) -> Dict[str, Any]:
        env = self._environment_segment(environment_id)
        return self._request("GET", f"/v1/environments/{env}")

    def generate_draft(self, options: Dict[str, Any]) -> Dict[str, Any]:
        return self._request("POST", "/v1/environments/draft", dict(options))

    def create_environment(self, options: Dict[str, Any]) -> Dict[str, Any]:
        return self._request("POST", "/v1/environments", dict(options))

    def create_batch(
        self,
        count: int,
        template: Dict[str, Any],
        *,
        name_prefix: Optional[str] = None,
    ) -> Dict[str, Any]:
        if not 1 <= int(count) <= 100:
            raise ValueError("count must be between 1 and 100.")
        body: Dict[str, Any] = {
            "count": int(count),
            "template": dict(template),
        }
        if name_prefix is not None and str(name_prefix).strip():
            body["name_prefix"] = str(name_prefix).strip()
        return self._request("POST", "/v1/environments/batch", body)

    def update_environment(
        self,
        environment_id: str,
        patch: Dict[str, Any],
    ) -> Dict[str, Any]:
        env = self._environment_segment(environment_id)
        return self._request("PATCH", f"/v1/environments/{env}", dict(patch))

    def delete_environment(self, environment_id: str) -> Dict[str, Any]:
        env = self._environment_segment(environment_id)
        return self._request("DELETE", f"/v1/environments/{env}")

    def clone_environment(
        self,
        environment_id: str,
        overrides: Optional[Dict[str, Any]] = None,
    ) -> Dict[str, Any]:
        env = self._environment_segment(environment_id)
        return self._request(
            "POST",
            f"/v1/environments/{env}/clone",
            dict(overrides or {}),
        )

    def validate_environment(self, environment_id: str) -> Dict[str, Any]:
        env = self._environment_segment(environment_id)
        return self._request("POST", f"/v1/environments/{env}/validate", {})

    # ------------------------------------------------------------------
    # Lifecycle / automation
    # ------------------------------------------------------------------

    def status(self, environment_id: str) -> Dict[str, Any]:
        env = self._environment_segment(environment_id)
        return self._request("GET", f"/v1/environments/{env}/status")

    def start(self, environment_id: str, *, automation: str) -> Dict[str, Any]:
        """Start a profile through the Local API.

        ``automation`` is mandatory because Local API starts are automation
        launches. Valid values are ``cdp`` and ``selenium``. Manual mode is
        intentionally UI-only.
        """

        env = self._environment_segment(environment_id)
        mode = self._automation_mode(automation)
        return self._request(
            "POST",
            f"/v1/environments/{env}/start",
            {"automation": mode},
        )

    def start_cdp(self, environment_id: str) -> Dict[str, Any]:
        return self.start(environment_id, automation="cdp")

    def start_selenium(self, environment_id: str) -> Dict[str, Any]:
        return self.start(environment_id, automation="selenium")

    def stop(self, environment_id: str) -> Dict[str, Any]:
        env = self._environment_segment(environment_id)
        return self._request("POST", f"/v1/environments/{env}/stop", {})

    def wait_for_status(
        self,
        environment_id: str,
        desired: str | Sequence[str],
        *,
        timeout: float = 60.0,
        poll_interval: float = 0.25,
        fail_on_failed: bool = True,
    ) -> Dict[str, Any]:
        if isinstance(desired, str):
            wanted = {desired.strip().lower()}
        else:
            wanted = {str(value).strip().lower() for value in desired}
        wanted.discard("")
        if not wanted:
            raise ValueError("desired must contain at least one lifecycle state.")

        deadline = time.monotonic() + float(timeout)
        last: Dict[str, Any] = {}

        while True:
            last = self.status(environment_id)
            state = str(last.get("status", "")).strip().lower()
            if state in wanted:
                return last

            if fail_on_failed and state == "failed":
                raise PBXAPIError(
                    409,
                    "environment_failed",
                    str(last.get("error") or "PBX environment entered Failed state."),
                    response=last,
                )

            if time.monotonic() >= deadline:
                raise PBXAPIError(
                    0,
                    "lifecycle_timeout",
                    f"Timed out waiting for environment {environment_id!r} "
                    f"to reach {sorted(wanted)}; last state={state or 'unknown'}.",
                    response=last,
                )

            time.sleep(max(0.05, float(poll_interval)))

    def wait_until_running(
        self,
        environment_id: str,
        *,
        timeout: float = 60.0,
        poll_interval: float = 0.25,
    ) -> Dict[str, Any]:
        return self.wait_for_status(
            environment_id,
            "running",
            timeout=timeout,
            poll_interval=poll_interval,
        )

    def wait_until_stopped(
        self,
        environment_id: str,
        *,
        timeout: float = 60.0,
        poll_interval: float = 0.25,
    ) -> Dict[str, Any]:
        return self.wait_for_status(
            environment_id,
            "stopped",
            timeout=timeout,
            poll_interval=poll_interval,
            fail_on_failed=False,
        )

    def automation(self, environment_id: str) -> Dict[str, Any]:
        status = self.status(environment_id)
        automation = status.get("automation", {})
        return automation if isinstance(automation, dict) else {}

    # ------------------------------------------------------------------
    # Proxy
    # ------------------------------------------------------------------

    def check_proxy(self, proxy: Dict[str, Any]) -> Dict[str, Any]:
        return self._request(
            "POST",
            "/v1/proxy/check",
            {"proxy": dict(proxy)},
        )

    def update_proxy(
        self,
        environment_id: str,
        proxy: Dict[str, Any],
    ) -> Dict[str, Any]:
        env = self._environment_segment(environment_id)
        # Current server accepts both wrapped and direct objects. Use the
        # canonical wrapped form so this matches /v1/proxy/check and the
        # environment document structure.
        return self._request(
            "PATCH",
            f"/v1/environments/{env}/proxy",
            {"proxy": dict(proxy)},
        )

    def set_direct_network(self, environment_id: str) -> Dict[str, Any]:
        return self.update_proxy(environment_id, {"enabled": False})

    def check_environment_proxy(self, environment_id: str) -> Dict[str, Any]:
        env = self._environment_segment(environment_id)
        return self._request("POST", f"/v1/environments/{env}/proxy/check", {})

    # ------------------------------------------------------------------
    # Fingerprint
    # ------------------------------------------------------------------

    def update_fingerprint(
        self,
        environment_id: str,
        patch: Dict[str, Any],
    ) -> Dict[str, Any]:
        env = self._environment_segment(environment_id)
        return self._request(
            "PATCH",
            f"/v1/environments/{env}/fingerprint",
            {"fingerprint": dict(patch)},
        )

    def randomize_fingerprint(
        self,
        environment_id: str,
        fields: Optional[Iterable[str]] = None,
    ) -> Dict[str, Any]:
        env = self._environment_segment(environment_id)
        body: Dict[str, Any] = {}
        if fields is not None:
            body["fields"] = [str(value) for value in fields]
        return self._request(
            "POST",
            f"/v1/environments/{env}/fingerprint/randomize",
            body,
        )

    # ------------------------------------------------------------------
    # Batch operations
    # ------------------------------------------------------------------

    def batch_update(
        self,
        environment_ids: Iterable[str],
        patch: Dict[str, Any],
    ) -> Dict[str, Any]:
        return self._request(
            "PATCH",
            "/v1/environments/batch",
            {
                "environment_ids": self._environment_ids(environment_ids),
                "patch": dict(patch),
            },
        )

    def batch_delete(self, environment_ids: Iterable[str]) -> Dict[str, Any]:
        return self._request(
            "POST",
            "/v1/environments/batch/delete",
            {"environment_ids": self._environment_ids(environment_ids)},
        )

    def batch_randomize_fingerprint(
        self,
        environment_ids: Iterable[str],
        fields: Optional[Iterable[str]] = None,
    ) -> Dict[str, Any]:
        body: Dict[str, Any] = {
            "environment_ids": self._environment_ids(environment_ids)
        }
        if fields is not None:
            body["fields"] = [str(value) for value in fields]
        return self._request(
            "POST",
            "/v1/environments/batch/fingerprint/randomize",
            body,
        )

    def batch_start(
        self,
        environment_ids: Iterable[str],
        *,
        automation: str,
    ) -> Dict[str, Any]:
        return self._request(
            "POST",
            "/v1/environments/batch/start",
            {
                "environment_ids": self._environment_ids(environment_ids),
                "automation": self._automation_mode(automation),
            },
        )

    def batch_stop(self, environment_ids: Iterable[str]) -> Dict[str, Any]:
        return self._request(
            "POST",
            "/v1/environments/batch/stop",
            {"environment_ids": self._environment_ids(environment_ids)},
        )
