"""CAS (eRegistrations custom OAuth2) authentication implementation.

This module handles CAS authentication for legacy BPA systems that don't use Keycloak.
Key differences from Keycloak OIDC:
- No PKCE support (uses client_secret with Basic Auth)
- No OIDC discovery endpoint (endpoints must be configured)
- Authorization endpoint is SPA-based (/cas/spa.html#/)
- User roles fetched from separate PARTC service
"""

from __future__ import annotations

import asyncio
import base64
import logging
import secrets
from typing import TYPE_CHECKING, Any
from urllib.parse import urlencode

import httpx

from mcp_eregistrations_bpa.auth.token_manager import TokenResponse
from mcp_eregistrations_bpa.exceptions import AuthenticationError

if TYPE_CHECKING:
    from mcp_eregistrations_bpa.config import Config

logger = logging.getLogger(__name__)


def generate_state() -> str:
    """Generate a cryptographically secure state parameter.

    Returns:
        A random state string for CSRF protection.
    """
    return secrets.token_urlsafe(16)


def build_cas_authorization_url(
    cas_authorization_base: str,
    client_id: str,
    redirect_uri: str,
    state: str,
    scope: str = "any",
    lang: str = "en",
) -> str:
    """Build CAS authorization URL (no PKCE).

    CAS uses a SPA-based authorization endpoint with hash fragment parameters.

    Args:
        cas_authorization_base: The CAS authorization base URL
            (e.g., {CAS_URL}/cas/spa.html)
        client_id: The OAuth2 client ID.
        redirect_uri: The local callback URL.
        state: The state parameter for CSRF protection.
        scope: OAuth scope (CAS uses "any").
        lang: Language code for the login page.

    Returns:
        The complete authorization URL to open in browser.
    """
    params = {
        "response_type": "code",
        "client_id": client_id,
        "redirect_uri": redirect_uri,
        "scope": scope,
        "state": state,
        "lang": lang,
    }
    # CAS uses hash fragment (#/) for SPA routing
    return f"{cas_authorization_base}#/?{urlencode(params)}"


def _build_basic_auth_header(client_id: str, client_secret: str) -> str:
    """Build HTTP Basic Auth header value.

    Args:
        client_id: The OAuth2 client ID.
        client_secret: The OAuth2 client secret.

    Returns:
        The Authorization header value (e.g., "Basic base64(client_id:secret)").
    """
    credentials = f"{client_id}:{client_secret}"
    encoded = base64.b64encode(credentials.encode()).decode()
    return f"Basic {encoded}"


async def exchange_code_for_tokens_cas(
    token_endpoint: str,
    code: str,
    redirect_uri: str,
    client_id: str,
    client_secret: str,
) -> TokenResponse:
    """Exchange authorization code for tokens using CAS (Basic Auth).

    Unlike Keycloak PKCE flow, CAS requires:
    - Authorization: Basic base64(client_id:client_secret) header
    - No code_verifier (no PKCE)

    Args:
        token_endpoint: The CAS token endpoint (e.g., {CAS_URL}/access_token).
        code: The authorization code from callback.
        redirect_uri: The redirect URI used in authorization.
        client_id: The OAuth2 client ID.
        client_secret: The OAuth2 client secret.

    Returns:
        TokenResponse with access and refresh tokens.

    Raises:
        AuthenticationError: If token exchange fails.
    """
    auth_header = _build_basic_auth_header(client_id, client_secret)

    async with httpx.AsyncClient() as client:
        try:
            response = await client.post(
                token_endpoint,
                headers={
                    "Authorization": auth_header,
                    "Content-Type": "application/x-www-form-urlencoded",
                },
                data={
                    "grant_type": "authorization_code",
                    "code": code,
                    "redirect_uri": redirect_uri,
                },
                timeout=10.0,
            )
            response.raise_for_status()
        except httpx.HTTPStatusError as e:
            error_detail = ""
            try:
                error_data = e.response.json()
                error_detail = error_data.get(
                    "error_description", error_data.get("error", "")
                )
            except Exception:
                pass
            raise AuthenticationError(
                f"CAS auth failed: {error_detail or 'Token exchange failed'}. "
                "Verify CAS_CLIENT_ID and CAS_CLIENT_SECRET."
            ) from e
        except httpx.RequestError as e:
            raise AuthenticationError(
                f"Cannot connect to CAS: {e}. "
                "Verify CAS_URL is correct and the server is accessible."
            ) from e

        try:
            data = response.json()
        except Exception as e:
            raise AuthenticationError(
                f"CAS returned invalid response: {e}. Contact administrator."
            ) from e

        if "access_token" not in data:
            raise AuthenticationError(
                "CAS response missing access_token. "
                "Verify CAS_CLIENT_ID and CAS_CLIENT_SECRET."
            )

        return TokenResponse(
            access_token=data["access_token"],
            refresh_token=data.get("refresh_token"),
            expires_in=data.get(
                "expires_in", 28800
            ),  # Default 8 hours if not specified
            token_type=data.get("token_type", "Bearer"),
        )


async def refresh_tokens_cas(
    token_endpoint: str,
    refresh_token: str,
    client_id: str,
    client_secret: str,
) -> TokenResponse:
    """Refresh access token using CAS (Basic Auth).

    Args:
        token_endpoint: The CAS token endpoint.
        refresh_token: The refresh token.
        client_id: The OAuth2 client ID.
        client_secret: The OAuth2 client secret.

    Returns:
        TokenResponse with new access and refresh tokens.

    Raises:
        AuthenticationError: If refresh fails.
    """
    auth_header = _build_basic_auth_header(client_id, client_secret)

    async with httpx.AsyncClient() as client:
        try:
            response = await client.post(
                token_endpoint,
                headers={
                    "Authorization": auth_header,
                    "Content-Type": "application/x-www-form-urlencoded",
                },
                data={
                    "grant_type": "refresh_token",
                    "refresh_token": refresh_token,
                    "redirect_uri": "http://127.0.0.1:9876/callback",
                },
                timeout=10.0,
            )
            response.raise_for_status()
        except httpx.HTTPStatusError as e:
            raise AuthenticationError(
                "CAS session expired. Please run auth_login again."
            ) from e
        except httpx.RequestError as e:
            raise AuthenticationError(
                f"Cannot refresh CAS session: {e}. Please try again."
            ) from e

        data = response.json()
        return TokenResponse(
            access_token=data["access_token"],
            refresh_token=data.get("refresh_token", refresh_token),
            expires_in=data.get("expires_in", 28800),  # Default 8 hours
            token_type=data.get("token_type", "Bearer"),
        )


async def fetch_user_roles_from_partc(
    partc_url: str,
    access_token: str,
) -> list[str]:
    """Fetch user roles from PARTC service.

    CAS doesn't include all roles in the JWT, so we need to fetch them
    from the PARTC user attributes endpoint.

    Args:
        partc_url: The PARTC user attributes URL (e.g., {PARTC_URL}/user/attributes).
        access_token: The access token for authorization.

    Returns:
        List of role strings.

    Raises:
        AuthenticationError: If role fetching fails.
    """
    async with httpx.AsyncClient() as client:
        try:
            response = await client.get(
                partc_url,
                headers={
                    "Authorization": f"Bearer {access_token}",
                },
                timeout=10.0,
            )
            response.raise_for_status()
        except httpx.HTTPStatusError as e:
            logger.warning(
                "Failed to fetch PARTC roles: HTTP %d", e.response.status_code
            )
            return []  # Return empty roles rather than failing auth
        except httpx.RequestError as e:
            logger.warning("Failed to connect to PARTC: %s", e)
            return []  # Return empty roles rather than failing auth

        data = response.json()

        # PARTC returns array of role strings
        if isinstance(data, list):
            return [str(role) for role in data if role]

        # Handle potential wrapper object
        if isinstance(data, dict):
            roles = data.get("roles") or data.get("attributes") or []
            if isinstance(roles, list):
                return [str(role) for role in roles if role]

        return []


async def _merge_partc_roles(
    token_manager: Any,
    config: Any,
    access_token: str,
) -> None:
    """Fetch and merge PARTC roles into the token manager.

    Args:
        token_manager: The global TokenManager instance.
        config: Loaded BPA config with partc_user_attributes_url.
        access_token: The access token for PARTC API call.
    """
    if not config.partc_user_attributes_url:
        return
    logger.info("Fetching user roles from PARTC...")
    partc_roles = await fetch_user_roles_from_partc(
        partc_url=config.partc_user_attributes_url,
        access_token=access_token,
    )
    if partc_roles:
        existing_roles = set(token_manager.permissions)
        for role in partc_roles:
            if role not in existing_roles:
                token_manager._permissions.append(role)
        logger.info("Added %d roles from PARTC", len(partc_roles))


async def perform_cas_browser_login(
    config: Config | None = None,
) -> dict[str, object]:
    """Perform browser-based CAS login flow.

    This is the CAS equivalent of perform_browser_login() in oidc.py.

    Args:
        config: Pre-resolved Config object. Falls back to load_config().

    Returns:
        dict: Authentication result with user email and session duration.

    Raises:
        AuthenticationError: If authentication fails.
    """
    import webbrowser

    from mcp_eregistrations_bpa.auth.callback import CallbackServer

    logger.info("Starting CAS browser login flow...")

    if config is None:
        from mcp_eregistrations_bpa.config import load_config

        config = load_config()

    if not config.cas_url or not config.cas_client_id or not config.cas_client_secret:
        raise AuthenticationError(
            "CAS configuration incomplete. "
            "Set CAS_URL, CAS_CLIENT_ID, and CAS_CLIENT_SECRET."
        )

    logger.debug("CAS URL: %s", config.cas_url)

    # Generate state (no PKCE for CAS)
    state = generate_state()
    logger.debug("Generated state parameter")

    # Start callback server on fixed port (CAS requires exact redirect_uri match)
    callback_server = CallbackServer(
        port=config.cas_callback_port,
        instance_info={
            "name": config.cas_client_id or "BPA",
            "url": config.bpa_instance_url,
        },
    )
    callback_server.start()
    logger.info("Callback server started on port %d", callback_server.port)

    try:
        # Build CAS authorization URL (no PKCE)
        auth_url = build_cas_authorization_url(
            cas_authorization_base=config.cas_authorization_url or "",
            client_id=config.cas_client_id,
            redirect_uri=callback_server.redirect_uri,
            state=state,
        )
        logger.debug("CAS auth URL built: %s...", auth_url[:80])

        # Open browser
        logger.info("Opening browser for CAS authentication...")
        if not webbrowser.open(auth_url):
            logger.error("Failed to open browser")
            return {
                "error": True,
                "message": (
                    "Cannot open browser for authentication. "
                    f"Please open this URL manually: {auth_url}"
                ),
            }

        # Wait for callback
        logger.info("Waiting for CAS OAuth callback...")
        code = await callback_server.wait_for_callback(expected_state=state)
        logger.info("Received authorization code from CAS")

        # Exchange code for tokens (using Basic Auth, not PKCE)
        logger.info("Exchanging code for tokens via CAS...")
        token_response = await exchange_code_for_tokens_cas(
            token_endpoint=config.cas_token_url or "",
            code=code,
            redirect_uri=callback_server.redirect_uri,
            client_id=config.cas_client_id,
            client_secret=config.cas_client_secret.get_secret_value(),
        )
        logger.info("CAS token exchange successful")

        # Get token manager and store tokens
        from mcp_eregistrations_bpa.server import get_token_manager

        token_manager = get_token_manager(config.instance_id)
        token_manager.store_tokens(
            access_token=token_response.access_token,
            refresh_token=token_response.refresh_token,
            expires_in=token_response.expires_in,
            token_endpoint=config.cas_token_url,
            client_id=config.cas_client_id,
        )

        # Store client_secret for CAS token refresh (needed because CAS uses Basic Auth)
        token_manager._cas_client_secret = config.cas_client_secret.get_secret_value()

        # Fetch additional roles from PARTC if configured
        await _merge_partc_roles(token_manager, config, token_response.access_token)

        logger.info("Tokens stored for user: %s", token_manager.user_email)

        # Update the browser success page with user email
        callback_server.set_user_info(token_manager.user_email or "")
        await asyncio.sleep(1.5)

        result = {
            "success": True,
            "message": (
                f"Authenticated as {token_manager.user_email} via CAS. "
                f"Session valid for {token_manager.expires_in_minutes} minutes."
            ),
            "user_email": token_manager.user_email,
            "session_expires_in_minutes": token_manager.expires_in_minutes,
            "auth_provider": "cas",
        }
        logger.info("CAS browser login complete")
        return result

    except AuthenticationError:
        logger.exception("CAS authentication error")
        raise
    except Exception as e:
        logger.exception("Unexpected error during CAS authentication")
        raise AuthenticationError(
            f"CAS authentication failed: {e}. Please try again."
        ) from e
    finally:
        logger.debug("Stopping callback server")
        callback_server.stop()


async def perform_cas_password_login(
    username: str,
    password: str,
    config: Config | None = None,
) -> dict[str, object]:
    """Authenticate via CAS password grant (Basic Auth header).

    Args:
        username: BPA username (email).
        password: BPA password.
        config: Pre-resolved Config object. Falls back to load_config().

    Returns:
        dict with success, message, user_email, session_expires_in_minutes,
        auth_method, auth_provider.

    Raises:
        AuthenticationError: On invalid credentials or server errors.
    """
    logger.info("Attempting CAS password grant for user: %s", username)

    if config is None:
        from mcp_eregistrations_bpa.config import load_config

        config = load_config()

    if not config.cas_url or not config.cas_client_id or not config.cas_client_secret:
        raise AuthenticationError(
            "CAS configuration incomplete. "
            "Set CAS_URL, CAS_CLIENT_ID, and CAS_CLIENT_SECRET."
        )

    token_url = config.cas_token_url
    if not token_url:
        raise AuthenticationError("CAS token URL could not be determined.")

    client_secret = config.cas_client_secret.get_secret_value()
    auth_header = _build_basic_auth_header(config.cas_client_id, client_secret)

    async with httpx.AsyncClient() as client:
        try:
            response = await client.post(
                token_url,
                headers={
                    "Authorization": auth_header,
                    "Content-Type": "application/x-www-form-urlencoded",
                },
                data={
                    "grant_type": "password",
                    "username": username,
                    "password": password,
                    "scope": "any",
                },
                timeout=10.0,
            )
        except httpx.RequestError as e:
            raise AuthenticationError(
                f"Cannot reach CAS server: {e}. Check connectivity."
            ) from e

        if response.status_code == 401 or (
            response.status_code == 400 and "invalid_grant" in response.text
        ):
            raise AuthenticationError(
                "Invalid credentials. Verify your CAS username and password. "
                "Do not retry automatically -- repeated failures may lock "
                "the account."
            )

        if response.status_code == 403:
            raise AuthenticationError(
                "CAS account disabled or not authorized for this client."
            )

        if response.status_code == 405:
            raise AuthenticationError(
                f"CAS token endpoint returned 405 Method Not Allowed "
                f"at {token_url}. The CAS URL is likely incorrect. "
                f"It should include the /cback/v1.0 path, e.g.: "
                f"https://eid.example.org/cback/v1.0"
            )

        if response.status_code >= 400:
            detail = ""
            try:
                err = response.json()
                detail = err.get("error_description", err.get("error", ""))
            except Exception:
                pass
            raise AuthenticationError(
                f"CAS authentication failed (HTTP {response.status_code}): "
                f"{detail or 'Unknown error'}. Please try again later."
            )

        data = response.json()

    if "access_token" not in data:
        raise AuthenticationError(
            "CAS response missing access_token. "
            "Verify CAS_CLIENT_ID and CAS_CLIENT_SECRET."
        )

    token_response = TokenResponse(
        access_token=data["access_token"],
        refresh_token=data.get("refresh_token"),
        expires_in=data.get("expires_in", 28800),
        token_type=data.get("token_type", "Bearer"),
    )

    # Store tokens via TokenManager
    from mcp_eregistrations_bpa.server import get_token_manager

    token_manager = get_token_manager(config.instance_id)
    token_manager.store_tokens(
        access_token=token_response.access_token,
        refresh_token=token_response.refresh_token,
        expires_in=token_response.expires_in,
        token_endpoint=token_url,
        client_id=config.cas_client_id,
    )

    # Store client_secret for CAS token refresh (needed because CAS uses Basic Auth)
    token_manager._cas_client_secret = client_secret

    # Fetch additional roles from PARTC if configured
    await _merge_partc_roles(token_manager, config, token_response.access_token)

    logger.info("CAS password grant successful for user: %s", token_manager.user_email)

    return {
        "success": True,
        "message": (
            f"Authenticated as {token_manager.user_email} via CAS. "
            f"Session valid for {token_manager.expires_in_minutes} minutes."
        ),
        "user_email": token_manager.user_email,
        "session_expires_in_minutes": token_manager.expires_in_minutes,
        "auth_method": "password_grant",
        "auth_provider": "cas",
    }
