# @File: services/oauth.py
# @Desc: Pure-logic helpers for the social-login OAuth flow. The router layer
#        in routers/oauth.py calls these helpers; this file stays free of
#        FastAPI types so it can be unit-tested in isolation.
#
#        Implemented providers:
#          - "google"    : fully wired (Authorization Code + PKCE, OIDC).
#          - "microsoft" : configured but inactive unless both MICROSOFT_CLIENT_ID
#                          and MICROSOFT_CLIENT_SECRET env vars are present.
#          - "linkedin"  : same — inactive until LINKEDIN_CLIENT_ID/SECRET are set.
#
#        All client secrets are read from environment variables at call time;
#        they are NEVER returned to the frontend. The public
#        `list_public_providers()` helper surfaces only boolean "configured"
#        flags so the UI can decide whether to enable each button.
from __future__ import annotations

import logging
import os
import secrets
import time
from dataclasses import dataclass
from typing import Any, Dict, List, Optional, Tuple
from urllib.parse import urlencode

import httpx
from core.auth import generate_code_challenge, generate_code_verifier
from jose import jwt
from jose.exceptions import JWTError

logger = logging.getLogger(__name__)

# ---------------------------------------------------------------------------
# Provider registry
# ---------------------------------------------------------------------------


@dataclass(frozen=True)
class ProviderConfig:
    """Static metadata for a supported OAuth provider."""

    slug: str                      # "google" | "microsoft" | "linkedin"
    display_name: str              # "Google" | "Microsoft" | "LinkedIn"
    authorize_url: str
    token_url: str
    jwks_url: Optional[str]        # None = no ID token / verify via userinfo
    userinfo_url: Optional[str]
    issuer: Optional[str]          # None means skip iss verification
    scope: str
    uses_pkce: bool
    client_id_env: str
    client_secret_env: str


_PROVIDERS: Dict[str, ProviderConfig] = {
    "google": ProviderConfig(
        slug="google",
        display_name="Google",
        authorize_url="https://accounts.google.com/o/oauth2/v2/auth",
        token_url="https://oauth2.googleapis.com/token",
        jwks_url="https://www.googleapis.com/oauth2/v3/certs",
        userinfo_url="https://openidconnect.googleapis.com/v1/userinfo",
        issuer="https://accounts.google.com",
        scope="openid email profile",
        uses_pkce=True,
        client_id_env="GOOGLE_CLIENT_ID",
        client_secret_env="GOOGLE_CLIENT_SECRET",
    ),
    "microsoft": ProviderConfig(
        slug="microsoft",
        display_name="Microsoft",
        authorize_url="https://login.microsoftonline.com/common/oauth2/v2.0/authorize",
        token_url="https://login.microsoftonline.com/common/oauth2/v2.0/token",
        jwks_url="https://login.microsoftonline.com/common/discovery/v2.0/keys",
        userinfo_url="https://graph.microsoft.com/oidc/userinfo",
        issuer=None,  # Microsoft's iss varies by tenant; validated via aud+signature.
        scope="openid email profile User.Read",
        uses_pkce=True,
        client_id_env="MICROSOFT_CLIENT_ID",
        client_secret_env="MICROSOFT_CLIENT_SECRET",
    ),
    "linkedin": ProviderConfig(
        slug="linkedin",
        display_name="LinkedIn",
        authorize_url="https://www.linkedin.com/oauth/v2/authorization",
        token_url="https://www.linkedin.com/oauth/v2/accessToken",
        jwks_url=None,  # Use /userinfo instead of ID-token JWKS verification.
        userinfo_url="https://api.linkedin.com/v2/userinfo",
        issuer=None,
        scope="openid email profile",
        uses_pkce=False,
        client_id_env="LINKEDIN_CLIENT_ID",
        client_secret_env="LINKEDIN_CLIENT_SECRET",
    ),
}


def get_provider(slug: str) -> ProviderConfig:
    """Return the ProviderConfig for a slug, or raise KeyError."""
    key = (slug or "").strip().lower()
    if key not in _PROVIDERS:
        raise KeyError(f"Unknown OAuth provider: {slug!r}")
    return _PROVIDERS[key]


def _creds(cfg: ProviderConfig) -> Tuple[Optional[str], Optional[str]]:
    """Return (client_id, client_secret) from the environment, or (None, None)."""
    cid = os.environ.get(cfg.client_id_env) or None
    sec = os.environ.get(cfg.client_secret_env) or None
    # Treat empty strings as unconfigured.
    if cid is not None and not cid.strip():
        cid = None
    if sec is not None and not sec.strip():
        sec = None
    return cid, sec


def is_configured(slug: str) -> bool:
    """Return True iff both client ID and secret are present for this provider."""
    try:
        cfg = get_provider(slug)
    except KeyError:
        return False
    cid, sec = _creds(cfg)
    return bool(cid and sec)


def list_public_providers() -> List[Dict[str, Any]]:
    """Return the public-safe provider list the frontend uses to render buttons."""
    return [
        {
            "slug": cfg.slug,
            "display_name": cfg.display_name,
            "configured": is_configured(cfg.slug),
        }
        for cfg in _PROVIDERS.values()
    ]


# ---------------------------------------------------------------------------
# Redirect URI construction
# ---------------------------------------------------------------------------


def build_redirect_uri(provider_slug: str, request_base_url: Optional[str] = None) -> str:
    """Compute the absolute OAuth redirect URI for a provider.

    Priority:
      1. ``OAUTH_REDIRECT_BASE_URL`` env var (production override — must match
         what you registered in the provider console verbatim).
      2. ``request_base_url`` argument (dynamically derived from the incoming
         FastAPI Request, so local dev "http://127.0.0.1:8000" works too).
    """
    base = (os.environ.get("OAUTH_REDIRECT_BASE_URL") or request_base_url or "").rstrip("/")
    if not base:
        raise ValueError(
            "OAUTH_REDIRECT_BASE_URL is not set and no request base URL was available"
        )
    return f"{base}/api/v1/auth/oauth/{provider_slug}/callback"


# ---------------------------------------------------------------------------
# Authorization-URL construction
# ---------------------------------------------------------------------------


def new_state_token() -> str:
    """Generate a cryptographically-random state parameter."""
    return secrets.token_urlsafe(32)


def build_authorize_url(
    provider_slug: str,
    *,
    state: str,
    redirect_uri: str,
    code_verifier: Optional[str] = None,
) -> Tuple[str, str]:
    """Build the provider's authorization URL.

    Returns:
        (authorize_url, code_verifier) — the verifier is returned so the caller
        can persist it alongside the state row. For providers that don't use
        PKCE the returned verifier is an empty string.
    """
    cfg = get_provider(provider_slug)
    cid, _sec = _creds(cfg)
    if not cid:
        raise RuntimeError(f"{cfg.display_name} OAuth is not configured on the server")

    params: Dict[str, str] = {
        "response_type": "code",
        "client_id": cid,
        "redirect_uri": redirect_uri,
        "scope": cfg.scope,
        "state": state,
    }

    # Google + Microsoft both accept and recommend PKCE.
    verifier = ""
    if cfg.uses_pkce:
        verifier = code_verifier or generate_code_verifier()
        params["code_challenge"] = generate_code_challenge(verifier)
        params["code_challenge_method"] = "S256"

    # Google-specific niceties.
    if cfg.slug == "google":
        params["access_type"] = "online"
        params["prompt"] = "select_account"
        # Hint Google to return email_verified=true for real accounts.
        params["include_granted_scopes"] = "true"

    return f"{cfg.authorize_url}?{urlencode(params)}", verifier


# ---------------------------------------------------------------------------
# Code exchange + ID token / userinfo verification
# ---------------------------------------------------------------------------


class OAuthError(Exception):
    """Raised on any recoverable OAuth flow failure with a user-safe message."""

    def __init__(self, message: str, code: str = "oauth_error"):
        super().__init__(message)
        self.message = message
        self.code = code


@dataclass
class VerifiedProfile:
    """Verified profile extracted from a provider after a successful callback."""

    provider: str
    sub: str                     # stable per-user identifier at the provider
    email: str                   # lowercased, verified
    email_verified: bool
    first_name: str
    last_name: str
    raw_claims: Dict[str, Any]   # for logging / debugging only; never returned to frontend


async def exchange_code_for_tokens(
    provider_slug: str,
    *,
    code: str,
    redirect_uri: str,
    code_verifier: Optional[str],
    timeout: float = 15.0,
) -> Dict[str, Any]:
    """Exchange an authorization code for tokens at the provider's token endpoint."""
    cfg = get_provider(provider_slug)
    cid, sec = _creds(cfg)
    if not cid or not sec:
        raise OAuthError(
            f"{cfg.display_name} is not configured on the server",
            code="provider_not_configured",
        )

    data: Dict[str, str] = {
        "grant_type": "authorization_code",
        "code": code,
        "redirect_uri": redirect_uri,
        "client_id": cid,
        "client_secret": sec,
    }
    if cfg.uses_pkce and code_verifier:
        data["code_verifier"] = code_verifier

    try:
        async with httpx.AsyncClient(timeout=timeout) as client:
            resp = await client.post(
                cfg.token_url,
                data=data,
                headers={"Accept": "application/json"},
            )
    except httpx.HTTPError as exc:
        logger.error("[oauth] token exchange HTTP error for %s: %s", provider_slug, exc)
        raise OAuthError(
            "Could not reach the identity provider. Please try again.",
            code="upstream_network_error",
        ) from exc

    if resp.status_code != 200:
        logger.warning(
            "[oauth] token exchange failed for %s: status=%s body=%s",
            provider_slug,
            resp.status_code,
            resp.text[:500],
        )
        raise OAuthError(
            "The identity provider refused the authorization code. Please try signing in again.",
            code="token_exchange_failed",
        )

    try:
        return resp.json()
    except ValueError as exc:
        raise OAuthError(
            "Invalid response from the identity provider.",
            code="token_exchange_bad_body",
        ) from exc


async def _fetch_json(url: str, *, headers: Dict[str, str], timeout: float = 15.0) -> Dict[str, Any]:
    try:
        async with httpx.AsyncClient(timeout=timeout) as client:
            resp = await client.get(url, headers=headers)
    except httpx.HTTPError as exc:
        logger.error("[oauth] userinfo HTTP error %s: %s", url, exc)
        raise OAuthError("Could not reach the identity provider.", code="upstream_network_error") from exc
    if resp.status_code != 200:
        logger.warning("[oauth] userinfo non-200 from %s: %s %s", url, resp.status_code, resp.text[:500])
        raise OAuthError("The identity provider rejected our request.", code="userinfo_failed")
    try:
        return resp.json()
    except ValueError as exc:
        raise OAuthError("Invalid userinfo response.", code="userinfo_bad_body") from exc


async def _verify_google_id_token(id_token: str, *, audience: str) -> Dict[str, Any]:
    """Validate a Google ID token's signature, issuer, audience and expiry."""
    try:
        async with httpx.AsyncClient(timeout=15.0) as client:
            jwks_resp = await client.get("https://www.googleapis.com/oauth2/v3/certs")
            jwks_resp.raise_for_status()
            jwks = jwks_resp.json()
    except httpx.HTTPError as exc:
        raise OAuthError("Could not retrieve Google signing keys.", code="jwks_fetch_failed") from exc

    try:
        unverified_header = jwt.get_unverified_header(id_token)
    except JWTError as exc:
        raise OAuthError("Malformed Google ID token.", code="id_token_malformed") from exc

    kid = unverified_header.get("kid")
    key = next((k for k in jwks.get("keys", []) if k.get("kid") == kid), None)
    if key is None:
        raise OAuthError("Google ID token signed with an unknown key.", code="id_token_unknown_kid")

    try:
        claims = jwt.decode(
            id_token,
            key,
            algorithms=[key.get("alg", "RS256")],
            audience=audience,
            issuer=["https://accounts.google.com", "accounts.google.com"],
            options={"verify_at_hash": False},
        )
    except JWTError as exc:
        raise OAuthError("Google ID token is invalid or expired.", code="id_token_invalid") from exc

    # Extra: enforce clock-skew-tolerant expiry and email_verified.
    if claims.get("exp", 0) < int(time.time()) - 60:
        raise OAuthError("Google ID token has expired.", code="id_token_expired")
    return claims


async def verify_and_extract_profile(
    provider_slug: str,
    token_response: Dict[str, Any],
) -> VerifiedProfile:
    """Convert a provider's token response into a VerifiedProfile, or raise."""
    cfg = get_provider(provider_slug)
    cid, _ = _creds(cfg)

    if cfg.slug == "google":
        id_token = token_response.get("id_token")
        if not id_token:
            raise OAuthError("Google did not return an ID token.", code="id_token_missing")
        claims = await _verify_google_id_token(id_token, audience=str(cid))
        email = (claims.get("email") or "").strip().lower()
        email_verified = bool(claims.get("email_verified"))
        if not email:
            raise OAuthError("Google did not provide an email address.", code="email_missing")
        if not email_verified:
            raise OAuthError(
                "Your Google account email is not verified. Please verify it and try again.",
                code="email_not_verified",
            )
        first = (claims.get("given_name") or "").strip()
        last = (claims.get("family_name") or "").strip()
        if not first and not last:
            name = (claims.get("name") or "").strip()
            if name:
                parts = name.split(" ", 1)
                first = parts[0]
                last = parts[1] if len(parts) > 1 else ""
        sub = str(claims.get("sub") or "")
        if not sub:
            raise OAuthError("Google profile missing subject identifier.", code="sub_missing")
        return VerifiedProfile(
            provider="google",
            sub=sub,
            email=email,
            email_verified=True,
            first_name=first or "User",
            last_name=last or "",
            raw_claims=claims,
        )

    # Microsoft + LinkedIn paths are reachable only if the corresponding
    # secrets are set. They use /userinfo for a cross-provider-uniform shape.
    access_token = token_response.get("access_token")
    if not access_token:
        raise OAuthError("Provider did not return an access token.", code="access_token_missing")
    if not cfg.userinfo_url:
        raise OAuthError("Provider userinfo endpoint not configured.", code="userinfo_not_configured")

    userinfo = await _fetch_json(
        cfg.userinfo_url,
        headers={"Authorization": f"Bearer {access_token}", "Accept": "application/json"},
    )
    email = (userinfo.get("email") or "").strip().lower()
    if not email:
        raise OAuthError("Provider did not return an email address.", code="email_missing")

    # Microsoft may not provide email_verified; their tenant/aud validation on the
    # ID token is the moral equivalent. For LinkedIn, OIDC userinfo emails are
    # verified by policy. We still trust userinfo because it's fetched server-side
    # using a freshly-minted access token we just obtained via PKCE / client_secret.
    email_verified = bool(userinfo.get("email_verified", True))

    first = (userinfo.get("given_name") or "").strip()
    last = (userinfo.get("family_name") or "").strip()
    if not first and not last:
        name = (userinfo.get("name") or "").strip()
        if name:
            parts = name.split(" ", 1)
            first = parts[0]
            last = parts[1] if len(parts) > 1 else ""
    sub = str(userinfo.get("sub") or userinfo.get("id") or "")
    if not sub:
        raise OAuthError("Provider profile missing subject identifier.", code="sub_missing")

    return VerifiedProfile(
        provider=cfg.slug,
        sub=sub,
        email=email,
        email_verified=email_verified,
        first_name=first or "User",
        last_name=last or "",
        raw_claims=userinfo,
    )


# ---------------------------------------------------------------------------
# Simple in-memory per-IP token-bucket rate limiter
# ---------------------------------------------------------------------------


_RATE_BUCKETS: Dict[str, List[float]] = {}
_RATE_LIMIT = 10          # max 10 OAuth starts
_RATE_WINDOW = 60.0       # per rolling 60 seconds


def check_rate_limit(ip: str) -> bool:
    """Return True if the IP may start a new OAuth flow, False if throttled."""
    if not ip:
        return True
    now = time.time()
    bucket = _RATE_BUCKETS.setdefault(ip, [])
    cutoff = now - _RATE_WINDOW
    # Drop expired entries in place.
    i = 0
    while i < len(bucket) and bucket[i] < cutoff:
        i += 1
    if i > 0:
        del bucket[:i]
    if len(bucket) >= _RATE_LIMIT:
        return False
    bucket.append(now)
    return True