"""
Brynq Runtime — Cloud Client.

HTTPS client that talks to api.brynq.ai. The runtime calls the cloud to:
    1. Get execution plans for user requests.
    2. Report execution metadata (timing, tokens — never content).
    3. Browse marketplace agents.
    4. Register the runtime with the user's account.

Design decisions:
    - Uses requests.Session for HTTP connection reuse (keep-alive pooling).
    - Auto-retry with exponential backoff on transient failures.
    - Never sends user content or API keys to the cloud. Only metadata.
"""

from __future__ import annotations

import logging
import time
from dataclasses import dataclass
from typing import Any
from urllib.parse import quote as _url_quote

import requests as _requests

from runtime.config import RuntimeConfig, load_config

logger = logging.getLogger(__name__)

# ── Circuit breaker (module-level) ────────────────────────────────────

_circuit_failures: int = 0            # consecutive failure count
_circuit_open_until: float = 0.0      # time.monotonic() when circuit can close
_CIRCUIT_THRESHOLD: int = 3           # failures before opening
_CIRCUIT_COOLDOWN: float = 60.0       # seconds to stay open


def _circuit_record_success() -> None:
    """Reset the circuit breaker on a successful request."""
    global _circuit_failures, _circuit_open_until
    if _circuit_failures > 0:
        logger.info("Circuit breaker CLOSED — cloud connection restored")
    _circuit_failures = 0
    _circuit_open_until = 0.0


def _circuit_record_failure() -> None:
    """Record a failure; open the circuit after threshold consecutive failures."""
    global _circuit_failures, _circuit_open_until
    _circuit_failures += 1
    if _circuit_failures >= _CIRCUIT_THRESHOLD and _circuit_open_until == 0.0:
        _circuit_open_until = time.monotonic() + _CIRCUIT_COOLDOWN
        logger.warning(
            "Circuit breaker OPEN — %d consecutive failures, "
            "blocking requests for %.0fs",
            _circuit_failures, _CIRCUIT_COOLDOWN,
        )


def _circuit_is_open() -> bool:
    """Return True if the circuit is open (requests should be blocked)."""
    global _circuit_failures, _circuit_open_until
    if _circuit_open_until == 0.0:
        return False
    if time.monotonic() >= _circuit_open_until:
        # Cooldown elapsed — half-open: allow one attempt
        logger.info("Circuit breaker HALF-OPEN — allowing probe request")
        _circuit_open_until = 0.0
        _circuit_failures = 0
        return False
    return True


# ── Exceptions ─────────────────────────────────────────────────────────


class CloudError(Exception):
    """Base exception for cloud communication failures."""

    def __init__(self, message: str, status_code: int | None = None) -> None:
        super().__init__(message)
        self.status_code = status_code


class CloudUnavailable(CloudError):
    """Cloud server is unreachable or returned a 5xx error."""


class CloudRejected(CloudError):
    """Cloud returned a 4xx error (bad request, unauthorized, etc.)."""


# ── Data types ─────────────────────────────────────────────────────────


@dataclass
class PlanResponse:
    """Parsed response from POST /v1/plan.

    Attributes:
        plan:     The raw execution plan dict (deserialized by the executor).
        plan_id:  Plan identifier for reporting back.
        raw:      Full response payload for debugging.
    """

    plan: dict[str, Any]
    plan_id: str
    raw: dict[str, Any]


@dataclass
class AgentInfo:
    """Marketplace agent metadata."""

    agent_id: str
    name: str
    description: str
    category: str
    type: str  # "local" or "remote"
    version: str = ""
    rating: float = 0.0
    installs: int = 0


# ── Client ─────────────────────────────────────────────────────────────


class CloudClient:
    """HTTPS client for the Brynq Cloud API.

    Usage:
        client = CloudClient(config)
        plan = await client.get_plan(request="Analyze this", models=["llama3"])
        # ... execute plan locally ...
        await client.report_results(plan_id, metadata)
    """

    # Retry configuration
    MAX_RETRIES: int = 3
    BACKOFF_BASE: float = 1.0  # seconds
    BACKOFF_MAX: float = 10.0

    # Timeout configuration (seconds)
    CONNECT_TIMEOUT: float = 5.0   # Max wait for TCP connection
    READ_TIMEOUT: float = 30.0     # Max wait for response data

    def __init__(self, config: RuntimeConfig | None = None) -> None:
        self._config = config or load_config()
        self._base_url = self._config.cloud_url
        self._auth_token: str | None = None
        # Persistent session — reuses TCP connections via urllib3 pool
        self._session = _requests.Session()
        self._session.headers.update({
            "Content-Type": "application/json",
            "Accept": "application/json",
            "User-Agent": "brynq-runtime/0.1.0",
        })

    @property
    def is_authenticated(self) -> bool:
        """Whether the client has a valid auth token."""
        return self._auth_token is not None

    def set_token(self, token: str) -> None:
        """Set the JWT auth token for cloud requests.

        Args:
            token: JWT token from user login or runtime registration.
        """
        self._auth_token = token

    # ── Public API ─────────────────────────────────────────────────────

    def get_plan(
        self,
        request: str,
        available_models: list[str],
        available_agents: list[dict[str, Any]] | None = None,
        session_context: dict[str, Any] | None = None,
    ) -> PlanResponse:
        """Request an execution plan from the cloud.

        The cloud analyzes the request, picks models, optimizes prompts,
        and returns a signed execution plan. NO user content is retained
        on the cloud.

        Args:
            request:          Natural language description of what to do.
            available_models: Model IDs the runtime can reach (e.g., ["ollama:llama3"]).
            available_agents: Installed agents with info (optional).
            session_context:  Session metadata — summary, not raw content (optional).

        Returns:
            PlanResponse containing the execution plan dict.

        Raises:
            CloudUnavailable: Server unreachable or 5xx error.
            CloudRejected:    4xx error (bad request, auth failure).
        """
        body = {
            "request": request,
            "available_models": available_models,
            "available_agents": available_agents or [],
            "context": session_context or {},
        }
        resp = self._post("/v1/plan", body)
        return PlanResponse(
            plan=resp,
            plan_id=resp.get("plan_id", ""),
            raw=resp,
        )

    def report_results(
        self,
        plan_id: str,
        steps: list[dict[str, Any]],
        total_duration_ms: int,
        success: bool,
    ) -> dict[str, Any]:
        """Report execution metadata to the cloud for quality learning.

        IMPORTANT: This sends ONLY timing and token counts. Never user
        content, model outputs, or API keys.

        Args:
            plan_id:           ID of the executed plan.
            steps:             Per-step metadata: [{step_id, duration_ms, token_count, success}].
            total_duration_ms: Total wall-clock duration.
            success:           Whether the full plan completed without errors.

        Returns:
            Acknowledgement dict (may contain routing adjustments for future plans).

        Raises:
            CloudUnavailable: Server unreachable.
            CloudRejected:    Rejected (invalid plan_id, etc.).
        """
        body = {
            "plan_id": plan_id,
            "steps": steps,
            "total_duration_ms": total_duration_ms,
            "success": success,
        }
        return self._post("/v1/report", body)

    def list_marketplace_agents(
        self,
        category: str | None = None,
        search: str | None = None,
    ) -> list[AgentInfo]:
        """Browse the agent marketplace catalog.

        Args:
            category: Filter by category (optional).
            search:   Text search query (optional).

        Returns:
            List of AgentInfo objects.
        """
        params: list[str] = []
        if category:
            params.append(f"category={_url_quote(category)}")
        if search:
            params.append(f"search={_url_quote(search)}")

        query = f"?{'&'.join(params)}" if params else ""
        resp = self._get(f"/v1/agents{query}")

        agents_raw = resp.get("agents", [])
        return [
            AgentInfo(
                agent_id=a.get("agent_id", ""),
                name=a.get("name", ""),
                description=a.get("description", ""),
                category=a.get("category", ""),
                type=a.get("type", "remote"),
                version=a.get("version", ""),
                rating=float(a.get("rating", 0)),
                installs=int(a.get("installs", 0)),
            )
            for a in agents_raw
        ]

    def register_runtime(self, user_token: str) -> dict[str, Any]:
        """Register this runtime instance with the user's cloud account.

        Called once during initial setup. The cloud returns a runtime-specific
        JWT that subsequent requests use.

        Args:
            user_token: The user's login JWT from brynq.ai.

        Returns:
            Dict with "runtime_token" and "runtime_id".

        Raises:
            CloudRejected: Invalid user token.
        """
        # Temporarily use the user token for this one request
        old_token = self._auth_token
        self._auth_token = user_token
        try:
            resp = self._post("/v1/auth/register", {"type": "runtime"})
            # Store the runtime token for future requests
            if "runtime_token" in resp:
                self._auth_token = resp["runtime_token"]
            return resp
        except Exception:
            self._auth_token = old_token
            raise

    def health_check(self) -> bool:
        """Check whether the cloud is reachable.

        Returns:
            True if cloud responds to /health with status "ok".
        """
        try:
            resp = self._get("/health")
            return resp.get("status") == "ok"
        except CloudError:
            return False

    # ── HTTP internals ─────────────────────────────────────────────────

    def _headers(self) -> dict[str, str]:
        """Build per-request headers (auth only; base headers live on the session)."""
        h: dict[str, str] = {}
        if self._auth_token:
            h["Authorization"] = f"Bearer {self._auth_token}"
        return h

    def _post(self, path: str, body: dict[str, Any]) -> dict[str, Any]:
        """HTTP POST with retry and backoff.

        Args:
            path: API path (e.g., "/v1/plan").
            body: JSON-serializable request body.

        Returns:
            Parsed JSON response.
        """
        url = f"{self._base_url}{path}"
        return self._execute("POST", url, json_body=body)

    def _get(self, path: str) -> dict[str, Any]:
        """HTTP GET with retry and backoff.

        Args:
            path: API path (e.g., "/v1/agents").

        Returns:
            Parsed JSON response.
        """
        url = f"{self._base_url}{path}"
        return self._execute("GET", url)

    def _execute(
        self,
        method: str,
        url: str,
        json_body: dict[str, Any] | None = None,
    ) -> dict[str, Any]:
        """Execute an HTTP request with retries and exponential backoff.

        Uses the shared requests.Session for connection reuse (keep-alive).

        Retries on:
            - Connection errors (server down, network issues)
            - 5xx errors (server-side failures)

        Does NOT retry on:
            - 4xx errors (client mistakes — fix the request, don't retry)

        Args:
            method:    HTTP method ("GET", "POST", etc.).
            url:       Full request URL.
            json_body: JSON-serializable request body (optional).

        Returns:
            Parsed JSON response dict.

        Raises:
            CloudUnavailable: After all retries exhausted, or 5xx error.
            CloudRejected: On 4xx response.
        """
        # Circuit breaker — reject immediately if circuit is open
        if _circuit_is_open():
            raise CloudUnavailable(
                "Circuit breaker open — cloud requests blocked after "
                f"{_CIRCUIT_THRESHOLD} consecutive failures",
            )

        last_error: Exception | None = None

        for attempt in range(self.MAX_RETRIES):
            if attempt > 0:
                delay = min(
                    self.BACKOFF_BASE * (2 ** (attempt - 1)),
                    self.BACKOFF_MAX,
                )
                logger.info(
                    "Cloud request retry %d/%d after %.1fs",
                    attempt + 1, self.MAX_RETRIES, delay,
                )
                time.sleep(delay)

            try:
                resp = self._session.request(
                    method,
                    url,
                    json=json_body,
                    headers=self._headers(),
                    timeout=(self.CONNECT_TIMEOUT, self.READ_TIMEOUT),
                )

                status = resp.status_code

                if 400 <= status < 500:
                    # Client error — do not retry, not a cloud failure
                    _circuit_record_success()
                    raise CloudRejected(
                        f"Cloud returned {status}: {resp.text}",
                        status_code=status,
                    )

                if status >= 500:
                    # Server error — retry
                    last_error = CloudUnavailable(
                        f"Cloud returned {status}: {resp.text}",
                        status_code=status,
                    )
                    logger.warning("Cloud 5xx (attempt %d): %s", attempt + 1, last_error)
                    continue

                _circuit_record_success()
                return resp.json() if resp.text else {}

            except (CloudRejected, CloudUnavailable):
                raise

            except _requests.exceptions.ConnectionError as e:
                last_error = CloudUnavailable(f"Cloud unreachable: {e}")
                logger.warning("Cloud connection failed (attempt %d): %s", attempt + 1, e)

            except _requests.exceptions.Timeout as e:
                last_error = CloudUnavailable(f"Cloud timeout: {e}")
                logger.warning("Cloud timeout (attempt %d): %s", attempt + 1, e)

            except Exception as e:
                last_error = CloudUnavailable(f"Unexpected error: {e}")
                logger.warning("Cloud request failed (attempt %d): %s", attempt + 1, e)

        # All retries exhausted — record failure for circuit breaker
        _circuit_record_failure()
        raise last_error or CloudUnavailable("Cloud request failed after retries")
