"""Generic OAuth API routes."""

from __future__ import annotations

import json
from html import escape
from typing import TYPE_CHECKING, Any
from urllib.parse import urlparse

from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import HTMLResponse, RedirectResponse
from pydantic import BaseModel, Field

from mindroom.api import config_lifecycle
from mindroom.api.auth import login_redirect_for_request, verify_user
from mindroom.api.credentials_oauth_flows import consume_pending_oauth_request, issue_pending_oauth_state
from mindroom.api.credentials_target import resolve_request_credentials_target, worker_target_for_credentials_target
from mindroom.api.dashboard_credential_scope import build_dashboard_execution_identity
from mindroom.credentials import delete_scoped_credentials, load_scoped_credentials, save_scoped_credentials
from mindroom.logging_config import get_logger
from mindroom.mcp.oauth import disconnect_mcp_oauth_request_session
from mindroom.oauth import (
    OAuthClaimValidationError,
    OAuthClientConfigResolution,
    OAuthProvider,
    OAuthProviderError,
    is_oauth_loopback_hostname,
)
from mindroom.oauth.registry import load_oauth_providers_for_snapshot
from mindroom.oauth.service import (
    OAuthConnectTarget,
    consume_oauth_connect_token,
    lookup_oauth_connect_token,
    oauth_credential_target_payload,
    oauth_credentials_usable,
    oauth_provider_service_account_configured,
    oauth_success_redirect_url,
    refresh_scoped_oauth_credentials,
    sanitized_oauth_token_result,
)

if TYPE_CHECKING:
    from mindroom.api.credentials_target import RequestCredentialsTarget
    from mindroom.constants import RuntimePaths

router = APIRouter(prefix="/api/oauth", tags=["oauth"])
logger = get_logger(__name__)
_OAUTH_COMPLETE_MESSAGE_TYPE = "mindroom:oauth-complete"
# OAuth callbacks intentionally verify the browser user inline instead of relying on
# standalone-public-path bypasses, because callbacks write scoped credentials.


class OAuthConnectResponse(BaseModel):
    """Authorization URL for an OAuth provider."""

    provider: str
    auth_url: str
    completion_origin: str


class OAuthStatusResponse(BaseModel):
    """Credential status for an OAuth provider."""

    provider: str
    display_name: str
    credential_service: str
    tool_config_service: str | None = None
    client_config_service: str | None = None
    client_config_redirect_uri_supported: bool = False
    connected: bool
    has_client_config: bool
    has_custom_client_config: bool = False
    has_service_account_config: bool = False
    email: str | None = None
    hosted_domain: str | None = None
    capabilities: list[str] = Field(default_factory=list)


def _load_provider(request: Request, provider_id: str) -> tuple[OAuthProvider, RuntimePaths]:
    snapshot = config_lifecycle.bind_current_request_snapshot(request)
    providers = load_oauth_providers_for_snapshot(snapshot)
    provider = providers.get(provider_id)
    if provider is None:
        raise HTTPException(status_code=404, detail=f"Unknown OAuth provider: {provider_id}")
    return provider, snapshot.runtime_paths


async def _require_oauth_api_user(request: Request) -> None:
    await verify_user(request, request.headers.get("authorization"), allow_public_paths=False)


async def _require_oauth_browser_user(request: Request) -> RedirectResponse | None:
    try:
        await _require_oauth_api_user(request)
    except HTTPException as exc:
        if exc.status_code == 401:
            login_redirect = login_redirect_for_request(request)
            if login_redirect is not None:
                return login_redirect
        raise
    return None


async def _client_config_resolution_for_request(
    request: Request,
    provider: OAuthProvider,
    runtime_paths: RuntimePaths,
    *,
    reject_remote_provisioned: bool,
) -> OAuthClientConfigResolution | None:
    """Resolve an OAuth client that can return to the requesting browser host."""
    try:
        resolution = await provider.client_config_resolution_async(runtime_paths)
    except OAuthProviderError as exc:
        raise HTTPException(status_code=503, detail=str(exc)) from exc
    if resolution is None or resolution.custom or is_oauth_loopback_hostname(request.url.hostname):
        return resolution
    if reject_remote_provisioned:
        detail = (
            "The provisioned OAuth client is available only when MindRoom is opened on localhost. "
            "Set MINDROOM_PUBLIC_URL (or MINDROOM_BASE_URL) and configure a custom OAuth client for remote access."
        )
        raise HTTPException(status_code=503, detail=detail)
    return None


async def _issue_authorization_url(
    request: Request,
    provider: OAuthProvider,
    runtime_paths: RuntimePaths,
    *,
    agent_name: str | None,
    connect_token: str | None = None,
) -> OAuthConnectResponse:
    await _client_config_resolution_for_request(
        request,
        provider,
        runtime_paths,
        reject_remote_provisioned=True,
    )
    if connect_token:
        try:
            connect_target = lookup_oauth_connect_token(provider, runtime_paths, connect_token)
        except OAuthProviderError as exc:
            raise HTTPException(status_code=400, detail=str(exc)) from exc
        _verify_connect_target_authorized(request, connect_target, runtime_paths)
        _verify_connect_target_query(connect_target, agent_name, request.query_params.get("execution_scope"))
        target = resolve_request_credentials_target(
            request,
            agent_name=agent_name,
            service_names=(provider.credential_service,),
            allow_private_scopes=True,
        )
        _verify_connect_target_binding(provider, connect_target, target)
        code_verifier = provider.issue_pkce_code_verifier()
        state = issue_pending_oauth_state(
            request,
            provider.id,
            agent_name,
            payload=_target_binding_payload(provider, target),
            code_verifier=code_verifier,
        )
        try:
            auth_url = await provider.authorization_uri_async(
                target.runtime_paths,
                state=state,
                code_verifier=code_verifier,
            )
        except OAuthProviderError as exc:
            raise HTTPException(status_code=503, detail=str(exc)) from exc
        try:
            consume_oauth_connect_token(provider, runtime_paths, connect_token, expected_target=connect_target)
        except OAuthProviderError as exc:
            raise HTTPException(status_code=400, detail=str(exc)) from exc
        return OAuthConnectResponse(
            provider=provider.id,
            auth_url=auth_url,
            completion_origin=_oauth_success_origin(provider, runtime_paths),
        )

    target = resolve_request_credentials_target(
        request,
        agent_name=agent_name,
        service_names=(provider.credential_service,),
        allow_private_scopes=True,
    )
    try:
        code_verifier = provider.issue_pkce_code_verifier()
        state = issue_pending_oauth_state(
            request,
            provider.id,
            agent_name,
            payload=_target_binding_payload(provider, target),
            code_verifier=code_verifier,
        )
        auth_url = await provider.authorization_uri_async(
            target.runtime_paths,
            state=state,
            code_verifier=code_verifier,
        )
    except OAuthProviderError as exc:
        raise HTTPException(status_code=503, detail=str(exc)) from exc
    return OAuthConnectResponse(
        provider=provider.id,
        auth_url=auth_url,
        completion_origin=_oauth_success_origin(provider, runtime_paths),
    )


def _target_binding_payload(provider: OAuthProvider, target: RequestCredentialsTarget) -> dict[str, str]:
    return oauth_credential_target_payload(provider, worker_target_for_credentials_target(target))


def _verify_connect_target_authorized(
    request: Request,
    connect_target: OAuthConnectTarget,
    runtime_paths: RuntimePaths,
) -> None:
    dashboard_identity = build_dashboard_execution_identity(
        request,
        "oauth",
        runtime_paths=runtime_paths,
    )
    if connect_target.requester_id and connect_target.requester_id != dashboard_identity.requester_id:
        raise HTTPException(status_code=403, detail="OAuth connect link does not belong to the current user")


def _verify_connect_target_query(
    connect_target: OAuthConnectTarget,
    agent_name: str | None,
    execution_scope: str | None,
) -> None:
    expected_scope = "" if connect_target.worker_scope == "unscoped" else connect_target.worker_scope
    if (agent_name or "") != (connect_target.agent_name or "") or (execution_scope or "") != expected_scope:
        raise HTTPException(status_code=400, detail="OAuth connect link target does not match this request")


def _verify_connect_target_binding(
    provider: OAuthProvider,
    connect_target: OAuthConnectTarget,
    target: RequestCredentialsTarget,
) -> None:
    expected = _target_binding_payload(provider, target)
    if (
        connect_target.agent_name != (expected["agent_name"] or None)
        or connect_target.worker_scope != expected["worker_scope"]
        or connect_target.worker_key != expected["worker_key"]
    ):
        raise HTTPException(status_code=400, detail="OAuth connect link target does not match this request")


def _verify_pending_target_binding(
    provider: OAuthProvider,
    pending_payload: dict[str, str] | None,
    target: RequestCredentialsTarget,
) -> None:
    if pending_payload != _target_binding_payload(provider, target):
        raise HTTPException(status_code=409, detail="OAuth state no longer matches the requested credential target")


def _claim_str(credentials: dict[str, Any], key: str) -> str | None:
    if credentials.get("_oauth_claims_verified") is not True:
        return None
    claims = credentials.get("_oauth_claims")
    if not isinstance(claims, dict):
        return None
    value = claims.get(key)
    return value if isinstance(value, str) and value else None


def _same_external_identity(existing_credentials: dict[str, Any] | None, token_data: dict[str, Any]) -> bool:
    existing_sub = _claim_str(existing_credentials or {}, "sub")
    new_sub = _claim_str(token_data, "sub")
    if existing_sub is not None or new_sub is not None:
        return existing_sub == new_sub

    existing_email = _claim_str(existing_credentials or {}, "email")
    new_email = _claim_str(token_data, "email")
    return existing_email is not None and existing_email == new_email


def _same_oauth_client(existing_credentials: dict[str, Any] | None, token_data: dict[str, Any]) -> bool:
    existing_client_id = (existing_credentials or {}).get("client_id")
    if not isinstance(existing_client_id, str) or not existing_client_id.strip():
        return False
    token_client_id = token_data.get("client_id")
    return isinstance(token_client_id, str) and token_client_id.strip() == existing_client_id.strip()


def _token_data_preserving_refresh_token(
    existing_credentials: dict[str, Any] | None,
    safe_token_data: dict[str, Any],
) -> dict[str, Any]:
    token_data = dict(safe_token_data)
    existing_refresh_token = (existing_credentials or {}).get("refresh_token")
    if (
        "refresh_token" not in token_data
        and isinstance(existing_refresh_token, str)
        and existing_refresh_token
        and _same_external_identity(existing_credentials, token_data)
        and _same_oauth_client(existing_credentials, token_data)
    ):
        token_data["refresh_token"] = existing_refresh_token
    return token_data


def _script_json(value: object) -> str:
    return json.dumps(value).replace("</", "<\\/")


def _oauth_success_origin(provider: OAuthProvider, runtime_paths: RuntimePaths) -> str:
    success_url = oauth_success_redirect_url(provider, runtime_paths)
    parsed = urlparse(success_url)
    return f"{parsed.scheme}://{parsed.netloc}"


@router.post("/{provider_id}/connect")
async def connect(provider_id: str, request: Request, agent_name: str | None = None) -> OAuthConnectResponse:
    """Start a provider OAuth flow and return the external authorization URL."""
    await _require_oauth_api_user(request)
    provider, runtime_paths = _load_provider(request, provider_id)
    return await _issue_authorization_url(request, provider, runtime_paths, agent_name=agent_name)


@router.get("/{provider_id}/authorize")
async def authorize(
    provider_id: str,
    request: Request,
    agent_name: str | None = None,
    connect_token: str | None = None,
) -> RedirectResponse:
    """Start a provider OAuth flow from a browser-openable MindRoom URL."""
    login_redirect = await _require_oauth_browser_user(request)
    if login_redirect is not None:
        return login_redirect
    provider, runtime_paths = _load_provider(request, provider_id)
    response = await _issue_authorization_url(
        request,
        provider,
        runtime_paths,
        agent_name=agent_name,
        connect_token=connect_token,
    )
    return RedirectResponse(url=response.auth_url)


@router.get("/{provider_id}/success", response_class=HTMLResponse)
async def success(provider_id: str, request: Request) -> HTMLResponse:
    """Signal OAuth completion to the dashboard popup opener."""
    await _require_oauth_api_user(request)
    provider, _runtime_paths = _load_provider(request, provider_id)
    message = {
        "type": _OAUTH_COMPLETE_MESSAGE_TYPE,
        "provider": provider.id,
        "status": "connected",
    }
    escaped_display_name = escape(provider.display_name)
    html = f"""<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <title>{escaped_display_name} connected</title>
  </head>
  <body>
    <p>{escaped_display_name} is connected. You can close this window.</p>
    <script>
      const message = {_script_json(message)};
      if (window.opener && !window.opener.closed) {{
        window.opener.postMessage(message, "*");
      }}
      window.close();
    </script>
  </body>
</html>"""
    return HTMLResponse(html)


@router.get("/{provider_id}/callback")
async def callback(provider_id: str, request: Request) -> RedirectResponse:
    """Handle a provider OAuth callback and store scoped credentials."""
    error = request.query_params.get("error")
    if error:
        raise HTTPException(status_code=400, detail=f"OAuth provider returned an error: {error}")

    code = request.query_params.get("code")
    if not code:
        raise HTTPException(status_code=400, detail="No authorization code received")

    state = request.query_params.get("state")
    if not state:
        raise HTTPException(status_code=400, detail="No OAuth state received")

    await _require_oauth_api_user(request)
    provider, runtime_paths = _load_provider(request, provider_id)
    pending = consume_pending_oauth_request(request, provider.id, state)
    target = resolve_request_credentials_target(
        request,
        agent_name=pending.agent_name,
        service_names=(provider.credential_service,),
        execution_scope_override_provided=pending.execution_scope_override_provided,
        execution_scope_override=pending.execution_scope_override,
        allow_private_scopes=True,
    )
    _verify_pending_target_binding(provider, pending.payload, target)

    try:
        token_result = await provider.exchange_code(
            code,
            runtime_paths,
            code_verifier=pending.code_verifier,
        )
        provider.validate_claims(token_result, runtime_paths)
        safe_result = sanitized_oauth_token_result(provider, token_result)
        worker_target = worker_target_for_credentials_target(target)
        credentials_manager = target.base_manager
        existing_credentials = load_scoped_credentials(
            provider.credential_service,
            credentials_manager=credentials_manager,
            worker_target=worker_target,
            allowed_shared_services=target.allowed_shared_services,
        )
        token_data = _token_data_preserving_refresh_token(existing_credentials, safe_result.token_data)
        save_scoped_credentials(
            provider.credential_service,
            token_data,
            credentials_manager=credentials_manager,
            worker_target=worker_target,
        )
    except OAuthClaimValidationError as exc:
        raise HTTPException(status_code=400, detail=str(exc)) from exc
    except OAuthProviderError as exc:
        raise HTTPException(status_code=400, detail=str(exc)) from exc
    except Exception as exc:
        logger.exception(
            "oauth_callback_failed",
            provider_id=provider.id,
            error_type=type(exc).__name__,
        )
        raise HTTPException(status_code=500, detail="OAuth callback failed") from exc

    return RedirectResponse(url=oauth_success_redirect_url(provider, runtime_paths))


@router.get("/{provider_id}/status")
async def status(provider_id: str, request: Request, agent_name: str | None = None) -> OAuthStatusResponse:
    """Return scoped connection status for one provider."""
    await _require_oauth_api_user(request)
    provider, runtime_paths = _load_provider(request, provider_id)
    target = resolve_request_credentials_target(
        request,
        agent_name=agent_name,
        service_names=(provider.credential_service,),
        allow_private_scopes=True,
    )
    worker_target = worker_target_for_credentials_target(target)
    credentials = (
        load_scoped_credentials(
            provider.credential_service,
            credentials_manager=target.base_manager,
            worker_target=worker_target,
            allowed_shared_services=target.allowed_shared_services,
        )
        or {}
    )
    has_service_account_config = oauth_provider_service_account_configured(provider, runtime_paths)
    client_config_resolution = (
        provider.client_config_resolution(runtime_paths)
        if has_service_account_config
        else await _client_config_resolution_for_request(
            request,
            provider,
            runtime_paths,
            reject_remote_provisioned=False,
        )
    )
    has_client_config = client_config_resolution is not None
    credentials_usable = oauth_credentials_usable(provider, runtime_paths, credentials)
    if credentials_usable and has_client_config and not has_service_account_config:
        try:
            refreshed_credentials = await refresh_scoped_oauth_credentials(
                provider,
                runtime_paths,
                credentials_manager=target.base_manager,
                worker_target=worker_target,
                allowed_shared_services=target.allowed_shared_services,
            )
        except OAuthProviderError as exc:
            logger.warning(
                "oauth_token_refresh_failed",
                provider_id=provider.id,
                error_type=type(exc).__name__,
            )
        else:
            credentials = refreshed_credentials or {}
            credentials_usable = oauth_credentials_usable(provider, runtime_paths, credentials)
    connected = has_service_account_config or credentials_usable
    if client_config_resolution is not None:
        client_config_service = client_config_resolution.service
    elif provider.all_client_config_services:
        client_config_service = provider.all_client_config_services[0]
    else:
        client_config_service = None
    client_config_redirect_uri_supported = (
        client_config_service is not None and client_config_service in provider.client_config_services
    )
    return OAuthStatusResponse(
        provider=provider.id,
        display_name=provider.display_name,
        credential_service=provider.credential_service,
        tool_config_service=provider.tool_config_service,
        client_config_service=client_config_service,
        client_config_redirect_uri_supported=client_config_redirect_uri_supported,
        connected=connected,
        has_client_config=has_client_config,
        has_custom_client_config=(client_config_resolution is not None and client_config_resolution.custom),
        has_service_account_config=has_service_account_config,
        email=_claim_str(credentials, "email"),
        hosted_domain=_claim_str(credentials, "hd"),
        capabilities=list(provider.status_capabilities),
    )


@router.post("/{provider_id}/disconnect")
async def disconnect(provider_id: str, request: Request, agent_name: str | None = None) -> dict[str, str]:
    """Remove scoped OAuth credentials for one provider while preserving tool settings."""
    await _require_oauth_api_user(request)
    provider, _runtime_paths = _load_provider(request, provider_id)
    target = resolve_request_credentials_target(
        request,
        agent_name=agent_name,
        service_names=(provider.credential_service,),
        allow_private_scopes=True,
    )
    worker_target = worker_target_for_credentials_target(target)
    delete_scoped_credentials(
        provider.credential_service,
        credentials_manager=target.base_manager,
        worker_target=worker_target,
    )
    snapshot = config_lifecycle.bind_current_request_snapshot(request)
    config = snapshot.runtime_config
    if config is not None:
        await disconnect_mcp_oauth_request_session(
            config.mcp_servers,
            provider.id,
            worker_target=worker_target,
        )
    return {"status": "disconnected", "provider": provider.id}
