# @File: backend/services/bizmail/client.py
# @Desc: Async HTTP client for the Bizmail360 SmarterMail API.
#
# Responsibilities:
#   * Resolve the admin credentials via the API Hub
#     (provider_id="bizmail360_smartermail") with env-var fallback.
#   * Authenticate via POST /api/v1/auth/authenticate-user — SmarterMail
#     returns an `accessToken` and a `refreshToken`. We cache both
#     in-process and transparently refresh when the access token expires.
#   * Provide a thin `request()` helper that adds the bearer token,
#     parses JSON, and maps non-success responses to :class:`BizmailApiError`.
#
# The client is intentionally stateless at the call site: `get_client()`
# returns a singleton that holds the cached tokens. Every public method
# either succeeds or raises :class:`BizmailApiError` — callers map the
# exception to HTTP responses. The client NEVER raises bare httpx errors.
from __future__ import annotations

import asyncio
import logging
import os
import time
from typing import Any, Dict, Optional

import httpx

from services.api_hub_credentials import resolve_credential

logger = logging.getLogger(__name__)

# Provider id used in the API Hub credential registry.
BIZMAIL_PROVIDER_ID = "bizmail360_smartermail"

# Credential names stored in API Hub + env.
_CRED_BASE_URL = "BIZMAIL360_API_BASE_URL"
_CRED_USERNAME = "BIZMAIL360_ADMIN_USERNAME"
_CRED_PASSWORD = "BIZMAIL360_ADMIN_PASSWORD"

# Default base URL if the operator has not configured one explicitly.
_DEFAULT_BASE_URL = "https://webmail.bizmail360.com"

# How many seconds before `accessTokenExpiration` we consider the token
# "about to expire" and refresh proactively.
_TOKEN_REFRESH_BUFFER_SEC = 60


class BizmailApiError(Exception):
    """Raised whenever the upstream SmarterMail API returns a non-success.

    Attributes:
        message:     Human-readable error message suitable for UI display.
        status_code: Upstream HTTP status (or 0 for transport errors).
        payload:     Raw response body (may be dict or str).
    """

    def __init__(
        self,
        message: str,
        *,
        status_code: int = 0,
        payload: Any = None,
    ) -> None:
        super().__init__(message)
        self.message = message
        self.status_code = status_code
        self.payload = payload


async def _resolve(name: str) -> Optional[str]:
    """Resolve a Bizmail credential with API Hub > env precedence."""
    val = await resolve_credential(BIZMAIL_PROVIDER_ID, name)
    if val:
        return val
    return os.getenv(name) or None


class BizmailClient:
    """Process-wide singleton SmarterMail client.

    Holds the cached access/refresh tokens and serializes refresh attempts
    with an asyncio lock so a burst of concurrent calls cannot hammer the
    login endpoint.
    """

    def __init__(self) -> None:
        self._base_url: Optional[str] = None
        self._username: Optional[str] = None
        self._password: Optional[str] = None
        self._access_token: Optional[str] = None
        self._refresh_token: Optional[str] = None
        self._token_exp: float = 0.0
        self._lock = asyncio.Lock()

    async def _load_credentials(self) -> None:
        """Refresh cached credentials from the API Hub on every login."""
        self._base_url = (await _resolve(_CRED_BASE_URL)) or _DEFAULT_BASE_URL
        self._username = await _resolve(_CRED_USERNAME)
        self._password = await _resolve(_CRED_PASSWORD)
        # Normalise base URL — strip trailing slash so endpoint paths are clean.
        if self._base_url:
            self._base_url = self._base_url.rstrip("/")

    def invalidate(self) -> None:
        """Drop cached tokens. Called when admin rotates credentials."""
        self._access_token = None
        self._refresh_token = None
        self._token_exp = 0.0

    async def _login(self) -> None:
        """Perform a fresh password login and cache the returned tokens."""
        await self._load_credentials()
        if not (self._base_url and self._username and self._password):
            raise BizmailApiError(
                "Bizmail360 admin credentials are not configured. "
                "Set BIZMAIL360_ADMIN_USERNAME / BIZMAIL360_ADMIN_PASSWORD "
                "in the API Hub (provider 'bizmail360_smartermail')."
            )
        url = f"{self._base_url}/api/v1/auth/authenticate-user"
        try:
            async with httpx.AsyncClient(timeout=15.0) as client:
                resp = await client.post(
                    url,
                    json={
                        "username": self._username,
                        "password": self._password,
                        "language": "en-US",
                    },
                    headers={"Accept": "application/json"},
                )
        except httpx.RequestError as exc:  # noqa: BLE001
            raise BizmailApiError(
                f"Transport error contacting SmarterMail: {exc}",
                status_code=0,
            ) from exc

        try:
            body = resp.json()
        except ValueError as exc:
            raise BizmailApiError(
                f"SmarterMail returned non-JSON response (HTTP {resp.status_code}).",
                status_code=resp.status_code,
                payload=resp.text[:500],
            ) from exc

        # SmarterMail responses wrap status in `result` ("success"/"error") on
        # older builds and in `success` (bool) on newer ones. Handle both.
        ok = False
        if isinstance(body, dict):
            if str(body.get("result", "")).lower() == "success":
                ok = True
            elif body.get("success") is True:
                ok = True
        if resp.status_code != 200 or not ok:
            err = ""
            if isinstance(body, dict):
                err = str(body.get("message") or body.get("error") or "")
            raise BizmailApiError(
                f"Login failed: {err or f'HTTP {resp.status_code}'}",
                status_code=resp.status_code,
                payload=body,
            )

        access = body.get("accessToken") or body.get("access_token")
        refresh = body.get("refreshToken") or body.get("refresh_token")
        # SmarterMail returns an ISO-8601 expiration string; when absent we
        # default to 30 minutes from now (the SmarterMail default).
        exp_raw = body.get("accessTokenExpiration") or body.get("expires_at")
        exp_ts = time.time() + 30 * 60
        if isinstance(exp_raw, str):
            try:
                from datetime import datetime

                exp_ts = datetime.fromisoformat(exp_raw.replace("Z", "+00:00")).timestamp()
            except Exception:  # noqa: BLE001
                exp_ts = time.time() + 30 * 60
        if not access:
            raise BizmailApiError(
                "SmarterMail login did not return an accessToken.",
                status_code=resp.status_code,
                payload=body,
            )
        self._access_token = access
        self._refresh_token = refresh
        self._token_exp = exp_ts

    async def _ensure_token(self) -> str:
        """Return a valid access token, logging in or refreshing as needed."""
        # Fast path: token is valid and not about to expire.
        if self._access_token and time.time() < self._token_exp - _TOKEN_REFRESH_BUFFER_SEC:
            return self._access_token
        async with self._lock:
            # Re-check after acquiring the lock — another coroutine may
            # have refreshed the token while we were waiting.
            if self._access_token and time.time() < self._token_exp - _TOKEN_REFRESH_BUFFER_SEC:
                return self._access_token
            await self._login()
            assert self._access_token is not None  # for type-checkers
            return self._access_token

    async def request(
        self,
        method: str,
        path: str,
        *,
        json_body: Optional[Dict[str, Any]] = None,
        params: Optional[Dict[str, Any]] = None,
        retry_on_401: bool = True,
        timeout: float = 15.0,
    ) -> Dict[str, Any]:
        """Perform an authenticated SmarterMail API request.

        On HTTP 401 we transparently re-login once and retry; all other
        non-success responses are raised as :class:`BizmailApiError`.
        """
        token = await self._ensure_token()
        # All SmarterMail REST endpoints live under /api/v1.
        url = f"{self._base_url}{path if path.startswith('/') else '/' + path}"
        headers = {
            "Authorization": f"Bearer {token}",
            "Accept": "application/json",
        }
        try:
            async with httpx.AsyncClient(timeout=timeout) as client:
                resp = await client.request(
                    method.upper(),
                    url,
                    headers=headers,
                    json=json_body,
                    params=params,
                )
        except httpx.RequestError as exc:  # noqa: BLE001
            raise BizmailApiError(
                f"Transport error contacting SmarterMail ({method} {path}): {exc}",
                status_code=0,
            ) from exc

        if resp.status_code == 401 and retry_on_401:
            # Access token was rejected — wipe cache and retry exactly once.
            logger.info("SmarterMail returned 401 for %s %s; refreshing token.", method, path)
            self.invalidate()
            return await self.request(
                method,
                path,
                json_body=json_body,
                params=params,
                retry_on_401=False,
                timeout=timeout,
            )

        try:
            body = resp.json()
        except ValueError:
            body = {"raw": resp.text[:500]}

        if resp.status_code >= 400:
            err = ""
            if isinstance(body, dict):
                err = str(body.get("message") or body.get("error") or "")
            raise BizmailApiError(
                f"SmarterMail {method} {path} failed: {err or f'HTTP {resp.status_code}'}",
                status_code=resp.status_code,
                payload=body,
            )
        # Some SmarterMail endpoints return success=false with HTTP 200.
        if isinstance(body, dict) and body.get("success") is False:
            err = str(body.get("message") or body.get("error") or "Upstream reported failure.")
            raise BizmailApiError(
                f"SmarterMail {method} {path} failed: {err}",
                status_code=resp.status_code,
                payload=body,
            )
        return body if isinstance(body, dict) else {"data": body}


# Process-wide singleton.
_CLIENT: Optional[BizmailClient] = None


def get_client() -> BizmailClient:
    """Return the process-wide :class:`BizmailClient` singleton."""
    global _CLIENT
    if _CLIENT is None:
        _CLIENT = BizmailClient()
    return _CLIENT


async def probe_connection() -> Dict[str, Any]:
    """Lightweight probe used by the API Hub test button.

    Attempts a fresh login and returns a dict shaped like every other API
    Hub probe: ``{"ok": bool, "message": str, "latency_ms": int|None}``.
    Never raises — failures are reported as ``ok=False``.
    """
    start = time.time()
    client = get_client()
    try:
        # Force a fresh login so admins can verify the saved credentials.
        client.invalidate()
        await client._ensure_token()  # noqa: SLF001 — intentional internal call
    except BizmailApiError as exc:
        return {
            "ok": False,
            "message": exc.message,
            "latency_ms": int((time.time() - start) * 1000),
        }
    except Exception as exc:  # noqa: BLE001
        return {
            "ok": False,
            "message": f"Unexpected error: {exc}",
            "latency_ms": int((time.time() - start) * 1000),
        }
    return {
        "ok": True,
        "message": "SmarterMail login successful.",
        "latency_ms": int((time.time() - start) * 1000),
    }