"""Token lifecycle for one Control Room account.

Three Control Room behaviours drive everything here:

1. **A refresh invalidates the token that authorised it.** So refreshes are
   serialized — in-process by a mutex, across processes by a file lock. Two
   concurrent refreshes would leave one caller holding a token that is already
   dead.
2. **An account may hold only five sessions.** So the token is cached on disk
   and adopted by sibling processes instead of each one logging in, and a slot
   is booked in the session ledger.
3. **Tokens last ~20 minutes.** So renewal happens ahead of expiry rather than
   in response to a 401, which would waste a round trip on every request that
   happened to straddle the boundary.

`get_headers()` is the only entry point callers need; nothing outside this class
should read the token.
"""

import logging
import threading
import time

import requests

from ..utils.config import ServerConfig
from . import _locks
from ._http_session import build_session
from ._paths import account_key, lock_path
from ._session_budget import SessionBudget, SessionBudgetExceeded
from ._token_store import Token, TokenStore

logger = logging.getLogger(__name__)

TRANSIENT_STATUS_CODES = frozenset({502, 503, 504})
MAX_TRANSIENT_RETRIES = 2
TRANSIENT_BACKOFF_SECONDS = 1.0
AUTH_COOLDOWN_BASE_SECONDS = 15.0
AUTH_COOLDOWN_MAX_SECONDS = 60.0
SIBLING_WAIT_SECONDS = 5.0
SIBLING_POLL_INTERVAL_SECONDS = 0.2
ERROR_BODY_CAP = 500


class A360AuthError(RuntimeError):
    """Authentication failed in a way the caller must surface, not retry blindly."""


class AuthManager:
    """Owns the token, the session slot, and the HTTP session for one account."""

    def __init__(self, config: ServerConfig, *, owner: str | None = None) -> None:
        self._config = config
        self._key = account_key(config.control_room_url, config.auth.username)
        self._owner = owner or f"a360-mcp:{_process_id()}"
        self._store = TokenStore(self._key)
        self._budget = SessionBudget(self._key)
        self._session = build_session(config.control_room_url, verify=config.verify)
        self._token: Token | None = None
        # True only when *this* manager authenticated for the current token.
        # A token adopted from the shared disk cache belongs to a sibling, and
        # ending its Control Room session on our exit would 401 that sibling.
        self._token_is_ours = False
        self._token_lock = threading.Lock()
        self._failure_count = 0
        self._cooldown_until = 0.0

    # ------------------------------------------------------------------ public

    def get_headers(self) -> dict[str, str]:
        """Headers for a Control Room call, renewing the token when needed.

        `X-Authorization` carries the bare token — no `Bearer` prefix, and never
        alongside an `Authorization` header. The Control Room rejects both.
        """
        token = self._ensure_token()
        return {
            "X-Authorization": token.value,
            "Content-Type": "application/json",
            "Accept": "application/json",
        }

    def make_request(self, method: str, path: str, **kwargs) -> requests.Response:
        """Call the Control Room, retrying only what is safe to retry."""
        url = f"{self._config.api_url}{path}"
        timeout = kwargs.pop("timeout", self._config.request_timeout)
        response = self._request_with_transient_retry(method, url, timeout=timeout, **kwargs)
        if response.status_code != 401:
            return response

        # The token died early (evicted by a sixth session, or revoked). Re-auth
        # once — never in a loop, or a rejected credential becomes a lockout.
        logger.debug("401 from %s; re-authenticating once", path)
        self.invalidate()
        return self._request_with_transient_retry(method, url, timeout=timeout, **kwargs)

    def invalidate(self) -> None:
        """Drop the cached token so the next call authenticates afresh.

        Only evicts the *shared* disk cache when it still holds the same dead
        token. A sibling that re-authenticated in the meantime published a
        working token; deleting that costs the account another login.
        """
        with self._token_lock:
            dead = self._token
            self._token = None
            if dead is not None:
                self._store.clear(only_if=dead)

    def logout(self) -> None:
        """End the Control Room session and give back the ledger slot.

        The Control Room logout is only issued for a session **we** opened.
        A token adopted from the shared cache belongs to whichever process
        authenticated for it; logging it out here would 401 every sibling
        mid-call. Our ledger slot is released either way.
        """
        token = self._token
        if token is not None and self._token_is_ours:
            try:
                self._session.post(
                    f"{self._config.api_url}/v1/authentication/logout",
                    headers={"X-Authorization": token.value},
                    timeout=self._config.request_timeout,
                )
            except requests.RequestException as exc:
                logger.debug("logout call failed, releasing slot anyway: %s", exc)
            self._store.clear(only_if=token)
        self._token = None
        self._token_is_ours = False
        self._budget.release(self._owner)

    def session_summary(self) -> dict:
        """Session accounting for health output — no network calls."""
        return self._budget.summary()

    # ----------------------------------------------------------------- private

    def _ensure_token(self) -> Token:
        cached = self._token
        if cached is not None and not cached.is_expired():
            return cached

        with self._token_lock:
            # Re-check: another thread may have renewed while we waited.
            cached = self._token
            if cached is not None and not cached.is_expired():
                return cached

            adopted = self._store.load()
            if adopted is not None and not adopted.is_expired():
                self._token = adopted
                self._token_is_ours = False
                return adopted

            self._raise_if_cooling_down()
            token, is_ours = self._obtain_token_serialized(previous=adopted or cached)
            self._token = token
            self._token_is_ours = is_ours
            return token

    def _obtain_token_serialized(self, *, previous: Token | None) -> tuple[Token, bool]:
        """Authenticate or refresh, letting one process at a time do it.

        Returns ``(token, is_ours)``. ``is_ours`` is False for a token a
        sibling process published — we may use it, but we may not end it.
        """
        with _locks.hold(lock_path(self._key, "auth")) as acquired:
            if not acquired:
                sibling = self._await_sibling_token()
                if sibling is not None:
                    return sibling, False
                # Sibling never published one; fall through and do it ourselves
                # rather than deadlock behind a lock we do not hold.
            return self._obtain_token(previous=previous), True

    def _await_sibling_token(self) -> Token | None:
        """Poll the shared cache for a token another process is fetching."""
        deadline = time.monotonic() + SIBLING_WAIT_SECONDS
        while time.monotonic() < deadline:
            time.sleep(SIBLING_POLL_INTERVAL_SECONDS)
            candidate = self._store.load()
            if candidate is not None and not candidate.is_expired():
                logger.debug("adopted token published by a sibling process")
                return candidate
        return None

    def _obtain_token(self, *, previous: Token | None) -> Token:
        """Book a session slot, then authenticate (or refresh) for real."""
        self._claim_session_slot()
        try:
            token = self._refresh(previous) if previous is not None else self._authenticate()
        except A360AuthError:
            self._register_failure()
            raise
        self._register_success()
        self._store.save(token)
        return token

    def _authenticate(self) -> Token:
        body = self._post_auth(self._config.auth_url, json=self._config.auth.to_auth_request())
        return Token.from_auth_response(body, username=self._config.auth.username)

    def _refresh(self, previous: Token) -> Token:
        """Exchange a token for a new one; the old one dies in the process."""
        try:
            body = self._post_auth(
                self._config.token_refresh_url, headers={"X-Authorization": previous.value}
            )
        except A360AuthError:
            # An expired or already-invalidated token cannot be refreshed —
            # fall back to a full authentication rather than surfacing a
            # confusing "refresh failed".
            logger.debug("refresh rejected; falling back to full authentication")
            return self._authenticate()
        return Token.from_auth_response(body, username=self._config.auth.username)

    def _post_auth(self, url: str, **kwargs) -> dict:
        try:
            response = self._session.post(url, timeout=self._config.request_timeout, **kwargs)
        except requests.RequestException as exc:
            raise A360AuthError(f"Could not reach the Control Room at {url}: {exc}") from exc
        if response.status_code >= 400:
            raise A360AuthError(
                f"Control Room rejected authentication for "
                f"'{self._config.auth.username}' (HTTP {response.status_code}): "
                f"{_error_body(response)}"
            )
        try:
            return response.json()
        except ValueError as exc:
            raise A360AuthError("Authentication response was not JSON") from exc

    def _claim_session_slot(self) -> None:
        try:
            self._budget.claim(self._owner)
        except SessionBudgetExceeded as exc:
            raise A360AuthError(str(exc)) from exc

    def _request_with_transient_retry(self, method: str, url: str, **kwargs) -> requests.Response:
        """Retry connection failures and gateway errors — nothing else.

        500 is excluded on purpose: a genuine server-side bug should return its
        body to the caller, not be retried into a timeout.
        """
        last_error: Exception | None = None
        for attempt in range(1 + MAX_TRANSIENT_RETRIES):
            headers = {**self.get_headers(), **kwargs.pop("headers", {})}
            try:
                response = self._session.request(method, url, headers=headers, **kwargs)
            except (requests.ConnectionError, requests.Timeout) as exc:
                last_error = exc
                if attempt == MAX_TRANSIENT_RETRIES:
                    break
                time.sleep(TRANSIENT_BACKOFF_SECONDS * (attempt + 1))
                continue
            if response.status_code in TRANSIENT_STATUS_CODES and attempt < MAX_TRANSIENT_RETRIES:
                time.sleep(TRANSIENT_BACKOFF_SECONDS * (attempt + 1))
                continue
            return response
        raise A360AuthError(f"Control Room unreachable after retries: {last_error}")

    def _raise_if_cooling_down(self) -> None:
        remaining = self._cooldown_until - time.monotonic()
        if remaining <= 0:
            return
        raise A360AuthError(
            f"Authentication is cooling down for {remaining:.0f}s after "
            f"{self._failure_count} consecutive failures. Verify the API key and "
            f"username, then retry — do not call repeatedly, the Control Room "
            f"locks accounts on repeated failures."
        )

    def _register_failure(self) -> None:
        self._failure_count += 1
        backoff = min(
            AUTH_COOLDOWN_BASE_SECONDS * (2 ** (self._failure_count - 1)),
            AUTH_COOLDOWN_MAX_SECONDS,
        )
        self._cooldown_until = time.monotonic() + backoff

    def _register_success(self) -> None:
        self._failure_count = 0
        self._cooldown_until = 0.0


def _error_body(response: requests.Response) -> str:
    try:
        return response.text[:ERROR_BODY_CAP]
    except Exception:  # pragma: no cover - defensive
        return "<unreadable response body>"


def _process_id() -> int:
    import os

    return os.getpid()
