# @File: routers/oauth.py
# @Desc: Social-login endpoints that bolt Google / Microsoft / LinkedIn OAuth
#        on top of the existing WHMCS-backed auth system. The /start endpoint
#        hands out a provider authorize URL (with CSRF state + PKCE); the
#        /callback endpoint validates the return, verifies the provider's ID
#        token or userinfo, links (or creates) a WHMCS client, mints an
#        application JWT, and 302-redirects the browser back to the frontend
#        /auth/oauth/callback page with the token embedded in the URL fragment
#        (so it never hits any server log).
#
#        Security guarantees:
#          - State is server-persisted, single-use, expires in 10 minutes.
#          - PKCE (S256) is enforced for Google + Microsoft.
#          - Google ID tokens are verified against Google's JWKS server-side
#            (signature, issuer, audience, expiry, email_verified).
#          - Client secrets are never exposed to the browser — they live only
#            in server-side env vars and are only sent to the provider's token
#            endpoint over TLS.
#          - Per-IP rate limiting on /start (10 flows / minute).
#
#        This integrates cleanly with the rest of the portal by reusing
#        `_build_app_token` from routers/whmcs.py, so admin-role resolution,
#        JWT claim shape, and client-id prefixing are identical to the
#        email/password flow.
from __future__ import annotations

import logging
import secrets
from datetime import datetime, timedelta, timezone
from typing import Optional
from urllib.parse import urlencode

from core.database import get_db
from dependencies.auth import get_current_user
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import RedirectResponse
from models.oauth_identity import OAuthIdentity
from models.oauth_state import OAuthState
from pydantic import BaseModel
from schemas.auth import UserResponse
from services.oauth import (
    OAuthError,
    build_authorize_url,
    build_redirect_uri,
    check_rate_limit,
    exchange_code_for_tokens,
    get_provider,
    is_configured,
    list_public_providers,
    new_state_token,
    verify_and_extract_profile,
)
from services.whmcs import call_whmcs, get_client_by_email
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession

# Routers/whmcs._build_app_token already encapsulates admin resolution + JWT
# minting exactly the way the email/password flow needs it. Reusing it keeps
# the two login paths bit-for-bit compatible.
from routers.whmcs import _build_app_token  # noqa: E402  (intentional late import)

logger = logging.getLogger(__name__)

router = APIRouter(prefix="/api/v1/auth/oauth", tags=["oauth"])

# How long a single OAuth attempt's state row is valid for.
_STATE_TTL_MINUTES = 10


# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------


def _frontend_base_url(request: Request) -> str:
    """Return the best guess for the frontend origin to redirect the user to.

    Priority:
      1. ``FRONTEND_BASE_URL`` env var (explicit production setting).
      2. ``Origin`` / ``Referer`` request headers (from /start call).
      3. Fall back to the request's own scheme+host (works when frontend and
         backend are served from the same origin in dev).
    """
    import os

    explicit = (os.environ.get("FRONTEND_BASE_URL") or "").strip().rstrip("/")
    if explicit:
        return explicit
    origin = (request.headers.get("origin") or "").strip().rstrip("/")
    if origin:
        return origin
    referer = (request.headers.get("referer") or "").strip()
    if referer:
        # Keep only scheme://host from the referer.
        try:
            from urllib.parse import urlparse

            u = urlparse(referer)
            if u.scheme and u.netloc:
                return f"{u.scheme}://{u.netloc}"
        except Exception:  # pragma: no cover - defensive
            pass
    return str(request.base_url).rstrip("/")


def _client_ip(request: Request) -> str:
    # Respect common proxy headers, fall back to the direct client host.
    fwd = request.headers.get("x-forwarded-for") or ""
    if fwd:
        return fwd.split(",")[0].strip()
    return request.client.host if request.client else ""


def _build_frontend_redirect(
    base_url: str,
    *,
    token: Optional[str] = None,
    needs_profile: bool = False,
    error: Optional[str] = None,
) -> str:
    """Build the URL we redirect the browser to after the OAuth round-trip.

    On success we embed the token in the URL **fragment** (never the query
    string) so it is not captured in server access logs, proxies, or Referer
    headers. The frontend /auth/oauth/callback page parses the fragment,
    stores the token in localStorage, and redirects onward.
    """
    target = f"{base_url.rstrip('/')}/auth/oauth/callback"
    if error:
        return f"{target}?{urlencode({'error': error})}"
    if not token:
        return f"{target}?{urlencode({'error': 'missing_token'})}"
    fragment = urlencode(
        {
            "token": token,
            "needs_profile": "1" if needs_profile else "0",
        }
    )
    return f"{target}#{fragment}"


# ---------------------------------------------------------------------------
# Public providers list
# ---------------------------------------------------------------------------


class ProviderInfo(BaseModel):
    slug: str
    display_name: str
    configured: bool


@router.get("/providers", response_model=list[ProviderInfo])
async def oauth_providers():
    """Return the list of social-login providers and whether each is enabled.

    Safe to call unauthenticated — returns only boolean ``configured`` flags,
    no client IDs or secrets.
    """
    return list_public_providers()


# ---------------------------------------------------------------------------
# /start — redirect to provider's authorize URL
# ---------------------------------------------------------------------------


@router.get("/{provider}/start")
async def oauth_start(
    provider: str,
    request: Request,
    mode: str = "login",
    db: AsyncSession = Depends(get_db),
):
    """Begin an OAuth flow by redirecting to the provider's authorize URL.

    Query parameters:
      - ``mode``: ``login`` or ``register`` (cosmetic — influences where the
        user lands on the frontend after completion).
    """
    slug = (provider or "").strip().lower()
    mode_norm = "register" if mode == "register" else "login"

    # Validate provider.
    try:
        cfg = get_provider(slug)
    except KeyError:
        raise HTTPException(status_code=404, detail=f"Unknown OAuth provider: {provider}")

    if not is_configured(slug):
        raise HTTPException(
            status_code=503,
            detail=f"{cfg.display_name} sign-in is not configured on this server yet.",
        )

    # Rate limit.
    if not check_rate_limit(_client_ip(request)):
        raise HTTPException(
            status_code=429,
            detail="Too many sign-in attempts. Please wait a minute and try again.",
        )

    # Create state + PKCE.
    state = new_state_token()
    try:
        redirect_uri = build_redirect_uri(slug, request_base_url=str(request.base_url).rstrip("/"))
    except ValueError as exc:
        logger.error("[oauth] cannot build redirect_uri: %s", exc)
        raise HTTPException(
            status_code=500,
            detail="Server is missing OAUTH_REDIRECT_BASE_URL configuration.",
        ) from exc

    authorize_url, code_verifier = build_authorize_url(
        slug,
        state=state,
        redirect_uri=redirect_uri,
    )

    # Persist the state row. We store a frontend return_to (origin only) so we
    # can redirect back to the correct origin in /callback even if headers are
    # stripped on the callback request.
    return_to = _frontend_base_url(request)
    state_row = OAuthState(
        state=state,
        provider=slug,
        code_verifier=code_verifier or secrets.token_urlsafe(8),  # schema NOT NULL
        mode=mode_norm,
        return_to=return_to,
        expires_at=datetime.now(timezone.utc) + timedelta(minutes=_STATE_TTL_MINUTES),
    )
    db.add(state_row)
    await db.commit()

    logger.info(
        "[oauth] start provider=%s mode=%s ip=%s return_to=%s",
        slug,
        mode_norm,
        _client_ip(request),
        return_to,
    )
    return RedirectResponse(url=authorize_url, status_code=302)


# ---------------------------------------------------------------------------
# /callback — exchange code, verify, link/create WHMCS client, issue JWT
# ---------------------------------------------------------------------------


async def _resolve_or_create_whmcs_client(
    *,
    email: str,
    first_name: str,
    last_name: str,
) -> tuple[int, bool]:
    """Return (whmcs_client_id, needs_profile).

    - If a WHMCS client with this email already exists → reuse its id, no
      profile completion required (their existing WHMCS profile may already
      be complete).
    - Otherwise create a brand-new WHMCS client with a random password and
      placeholder address fields. WHMCS requires non-empty address/country/
      phone fields on AddClient, so we send legally-minimal placeholders
      and flag ``needs_profile=True`` so the frontend sends the user to
      /complete-profile to fill the real details before their dashboard.
    """
    existing = await get_client_by_email(email)
    if existing:
        client_id = int(existing.get("id") or existing.get("userid") or existing.get("client_id") or 0)
        if client_id > 0:
            return client_id, False

    # Brand-new WHMCS client. Use a cryptographically-random password since
    # the user will sign in via OAuth; they can set a real password later via
    # WHMCS's own reset flow if they ever want to use email/password login.
    random_password = secrets.token_urlsafe(24)
    result = await call_whmcs(
        "AddClient",
        {
            "firstname": first_name or "User",
            "lastname": last_name or "Account",
            "email": email,
            "password2": random_password,
            "companyname": "",
            "address1": "Not provided",
            "address2": "",
            "city": "Not provided",
            "state": "Not provided",
            "postcode": "00000",
            "country": "SA",
            "phonenumber": "00000000",
            "noemail": True,
        },
    )
    client_id_raw = result.get("clientid") or result.get("userid") or 0
    try:
        client_id = int(client_id_raw)
    except (TypeError, ValueError):
        client_id = 0
    if client_id <= 0:
        raise OAuthError(
            "Could not provision an account for your social profile. Please try again.",
            code="whmcs_add_client_failed",
        )
    return client_id, True


async def _link_identity(
    db: AsyncSession,
    *,
    provider: str,
    sub: str,
    email: str,
    whmcs_client_id: int,
) -> None:
    """Upsert (provider, sub) -> (email, whmcs_client_id) in oauth_identities."""
    result = await db.execute(
        select(OAuthIdentity).where(
            OAuthIdentity.provider == provider,
            OAuthIdentity.provider_sub == sub,
        )
    )
    row = result.scalar_one_or_none()
    if row is None:
        db.add(
            OAuthIdentity(
                provider=provider,
                provider_sub=sub,
                email=email,
                whmcs_client_id=whmcs_client_id,
            )
        )
    else:
        row.email = email
        row.whmcs_client_id = whmcs_client_id
    await db.commit()


@router.get("/{provider}/callback")
async def oauth_callback(
    provider: str,
    request: Request,
    code: Optional[str] = None,
    state: Optional[str] = None,
    error: Optional[str] = None,
    error_description: Optional[str] = None,
    db: AsyncSession = Depends(get_db),
):
    """Handle the provider redirect, mint an app JWT, and bounce to the frontend."""
    slug = (provider or "").strip().lower()

    # Resolve the state row FIRST so we know where to redirect even on error.
    return_to: str = _frontend_base_url(request)
    if state:
        state_row = (
            await db.execute(select(OAuthState).where(OAuthState.state == state))
        ).scalar_one_or_none()
    else:
        state_row = None

    if state_row and state_row.return_to:
        return_to = state_row.return_to

    # Provider reported an error (user cancelled, consent denied, etc.).
    if error:
        logger.info("[oauth] provider error provider=%s error=%s desc=%s", slug, error, error_description)
        # Best-effort cleanup.
        if state_row is not None:
            await db.delete(state_row)
            await db.commit()
        return RedirectResponse(
            _build_frontend_redirect(return_to, error=error),
            status_code=302,
        )

    # Basic sanity checks.
    if not code or not state:
        return RedirectResponse(
            _build_frontend_redirect(return_to, error="missing_code_or_state"),
            status_code=302,
        )
    if state_row is None:
        return RedirectResponse(
            _build_frontend_redirect(return_to, error="invalid_state"),
            status_code=302,
        )
    if state_row.provider != slug:
        await db.delete(state_row)
        await db.commit()
        return RedirectResponse(
            _build_frontend_redirect(return_to, error="state_provider_mismatch"),
            status_code=302,
        )
    if state_row.is_expired():
        await db.delete(state_row)
        await db.commit()
        return RedirectResponse(
            _build_frontend_redirect(return_to, error="state_expired"),
            status_code=302,
        )

    # Single-use: consume the state row now so it cannot be replayed.
    code_verifier = state_row.code_verifier or ""
    mode = state_row.mode
    await db.delete(state_row)
    await db.commit()

    try:
        redirect_uri = build_redirect_uri(slug, request_base_url=str(request.base_url).rstrip("/"))
        token_resp = await exchange_code_for_tokens(
            slug,
            code=code,
            redirect_uri=redirect_uri,
            code_verifier=code_verifier if code_verifier and not code_verifier.startswith("pad:") else code_verifier,
        )
        profile = await verify_and_extract_profile(slug, token_resp)
    except OAuthError as exc:
        logger.warning("[oauth] flow failed provider=%s code=%s message=%s", slug, exc.code, exc.message)
        return RedirectResponse(
            _build_frontend_redirect(return_to, error=exc.code),
            status_code=302,
        )
    except Exception:
        logger.exception("[oauth] unexpected failure provider=%s", slug)
        return RedirectResponse(
            _build_frontend_redirect(return_to, error="unexpected_error"),
            status_code=302,
        )

    # Prefer an existing link by (provider, sub) over an email match; this
    # handles the corner case where a user changed their email at the provider.
    linked = (
        await db.execute(
            select(OAuthIdentity).where(
                OAuthIdentity.provider == profile.provider,
                OAuthIdentity.provider_sub == profile.sub,
            )
        )
    ).scalar_one_or_none()

    needs_profile = False
    try:
        if linked and linked.whmcs_client_id > 0:
            client_id = int(linked.whmcs_client_id)
        else:
            client_id, needs_profile = await _resolve_or_create_whmcs_client(
                email=profile.email,
                first_name=profile.first_name,
                last_name=profile.last_name,
            )
    except OAuthError as exc:
        logger.error("[oauth] client resolution failed provider=%s: %s", slug, exc.message)
        return RedirectResponse(
            _build_frontend_redirect(return_to, error=exc.code),
            status_code=302,
        )
    except HTTPException as exc:
        logger.error("[oauth] WHMCS error during client resolution: %s", exc.detail)
        return RedirectResponse(
            _build_frontend_redirect(return_to, error="whmcs_unavailable"),
            status_code=302,
        )
    except Exception:
        logger.exception("[oauth] unexpected error resolving WHMCS client")
        return RedirectResponse(
            _build_frontend_redirect(return_to, error="unexpected_error"),
            status_code=302,
        )

    # Persist/refresh the identity link.
    await _link_identity(
        db,
        provider=profile.provider,
        sub=profile.sub,
        email=profile.email,
        whmcs_client_id=client_id,
    )

    full_name = f"{profile.first_name} {profile.last_name}".strip() or profile.email
    token = await _build_app_token(client_id=client_id, email=profile.email, name=full_name)

    logger.info(
        "[oauth] success provider=%s mode=%s email=%s client_id=%s new_client=%s",
        profile.provider,
        mode,
        profile.email,
        client_id,
        needs_profile,
    )
    return RedirectResponse(
        _build_frontend_redirect(return_to, token=token, needs_profile=needs_profile),
        status_code=302,
    )


# ---------------------------------------------------------------------------
# Authenticated introspection (useful for the frontend to debug state)
# ---------------------------------------------------------------------------


class LinkedIdentity(BaseModel):
    provider: str
    email: str


@router.get("/linked", response_model=list[LinkedIdentity])
async def oauth_linked(
    current_user: UserResponse = Depends(get_current_user),
    db: AsyncSession = Depends(get_db),
):
    """Return the social identities currently linked to the authenticated user.

    We match on the user's WHMCS client id (which is encoded in the JWT ``sub``
    as ``whmcs:<id>``).
    """
    sub = getattr(current_user, "id", "") or ""
    if not sub.startswith("whmcs:"):
        return []
    try:
        whmcs_id = int(sub.split(":", 1)[1])
    except (IndexError, ValueError):
        return []
    rows = (
        await db.execute(select(OAuthIdentity).where(OAuthIdentity.whmcs_client_id == whmcs_id))
    ).scalars().all()
    return [LinkedIdentity(provider=r.provider, email=r.email) for r in rows]