"""
Browser-based OAuth PKCE authentication for AiCippy.

Implements the Authorization Code flow with PKCE (Proof Key for Code Exchange)
against the Cognito hosted UI at auth.aivibe.cloud. Opens the user's default
browser for login and catches the callback on a lightweight localhost HTTP server.

This module provides:
- PKCE code_verifier / code_challenge generation (S256)
- Authorization URL construction for Cognito hosted UI
- Localhost HTTP callback server with port fallback
- Auth code exchange for tokens via Cognito /oauth2/token endpoint
- Graceful timeout, port-busy, and browser-open failure handling

Security notes:
- Uses S256 code_challenge_method (never plain)
- code_verifier is 128 bytes of OS randomness, base64url-encoded
- State parameter prevents CSRF
- Tokens are stored in OS keychain via KeychainStorage (same as password login)
"""

from __future__ import annotations

import base64
import hashlib
import html
import os
import secrets
import socket
import webbrowser
from datetime import datetime, timedelta, timezone
from http.server import BaseHTTPRequestHandler, HTTPServer
from threading import Event, Thread
from typing import Any, Final
from urllib.parse import parse_qs, urlencode, urlparse

import httpx
from jose import jwt

from aicippy.auth.keychain import KeychainStorage
from aicippy.auth.models import AuthResult, TokenInfo
from aicippy.config import get_settings
from aicippy.utils.correlation import get_correlation_id
from aicippy.utils.logging import get_logger

logger = get_logger(__name__)

# ============================================================================
# Constants
# ============================================================================

HTTP_TIMEOUT_SECONDS: Final[float] = 30.0
CALLBACK_TIMEOUT_SECONDS: Final[int] = 120
CALLBACK_PORT_START: Final[int] = 9876
CALLBACK_PORT_RETRIES: Final[int] = 3
CALLBACK_PATH: Final[str] = "/callback"
PKCE_VERIFIER_LENGTH: Final[int] = 96  # bytes before base64url encoding
SCOPES: Final[str] = "openid email profile"

# HTML responses for the callback server
_SUCCESS_HTML: Final[str] = """<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>AiCippy - Authentication Successful</title>
<style>
  body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
         display: flex; align-items: center; justify-content: center; min-height: 100vh;
         margin: 0; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: #fff; }}
  .card {{ background: rgba(255,255,255,0.15); backdrop-filter: blur(10px);
           border-radius: 16px; padding: 48px; text-align: center; max-width: 420px; }}
  h1 {{ margin: 0 0 12px; font-size: 28px; }}
  p  {{ margin: 0; opacity: 0.9; line-height: 1.6; }}
</style>
</head>
<body>
<div class="card">
  <h1>Authenticated</h1>
  <p>You can close this tab and return to your terminal.</p>
</div>
</body>
</html>"""

_ERROR_HTML: Final[str] = """<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>AiCippy - Authentication Failed</title>
<style>
  body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
         display: flex; align-items: center; justify-content: center; min-height: 100vh;
         margin: 0; background: linear-gradient(135deg, #ef4444 0%, #b91c1c 100%); color: #fff; }}
  .card {{ background: rgba(255,255,255,0.15); backdrop-filter: blur(10px);
           border-radius: 16px; padding: 48px; text-align: center; max-width: 420px; }}
  h1 {{ margin: 0 0 12px; font-size: 28px; }}
  p  {{ margin: 0; opacity: 0.9; line-height: 1.6; }}
</style>
</head>
<body>
<div class="card">
  <h1>Authentication Failed</h1>
  <p>{error_message}</p>
  <p style="margin-top:16px;font-size:14px;opacity:0.7;">Please return to the terminal and try again.</p>
</div>
</body>
</html>"""


# ============================================================================
# PKCE Helpers
# ============================================================================


def _generate_pkce_pair() -> tuple[str, str]:
    """Generate PKCE code_verifier and code_challenge (S256).

    Returns:
        Tuple of (code_verifier, code_challenge) both base64url-encoded
        without padding.
    """
    # Generate high-entropy random bytes, then base64url encode
    random_bytes = os.urandom(PKCE_VERIFIER_LENGTH)
    code_verifier = base64.urlsafe_b64encode(random_bytes).rstrip(b"=").decode("ascii")

    # S256: code_challenge = BASE64URL(SHA256(code_verifier))
    digest = hashlib.sha256(code_verifier.encode("ascii")).digest()
    code_challenge = base64.urlsafe_b64encode(digest).rstrip(b"=").decode("ascii")

    return code_verifier, code_challenge


def _generate_state() -> str:
    """Generate a cryptographic random state parameter for CSRF protection."""
    return secrets.token_urlsafe(32)


# ============================================================================
# Callback HTTP Server
# ============================================================================


class _CallbackResult:
    """Thread-safe container for the OAuth callback result."""

    __slots__ = ("code", "error", "error_description", "state", "_event")

    def __init__(self) -> None:
        self.code: str | None = None
        self.error: str | None = None
        self.error_description: str | None = None
        self.state: str | None = None
        self._event = Event()

    def set_success(self, code: str, state: str | None) -> None:
        self.code = code
        self.state = state
        self._event.set()

    def set_error(self, error: str, description: str | None = None) -> None:
        self.error = error
        self.error_description = description
        self._event.set()

    def wait(self, timeout: float) -> bool:
        """Wait for the callback. Returns True if received, False on timeout."""
        return self._event.wait(timeout=timeout)


def _make_handler(result: _CallbackResult, expected_state: str) -> type:
    """Create a request handler class that captures the callback result."""

    class CallbackHandler(BaseHTTPRequestHandler):
        """HTTP handler for the OAuth callback."""

        def do_GET(self) -> None:  # noqa: N802 - required by BaseHTTPRequestHandler
            """Handle GET request on callback path."""
            parsed = urlparse(self.path)

            if parsed.path != CALLBACK_PATH:
                self.send_response(404)
                self.send_header("Content-Type", "text/plain")
                self.end_headers()
                self.wfile.write(b"Not found")
                return

            params = parse_qs(parsed.query)

            # Check for error response from Cognito
            if "error" in params:
                error = params["error"][0]
                description = params.get("error_description", [None])[0]
                result.set_error(error, description)
                self.send_response(200)
                self.send_header("Content-Type", "text/html; charset=utf-8")
                self.end_headers()
                safe_msg = html.escape(description or error)
                self.wfile.write(
                    _ERROR_HTML.format(error_message=safe_msg).encode("utf-8")
                )
                return

            # Extract authorization code
            code_list = params.get("code")
            if not code_list:
                result.set_error("missing_code", "No authorization code in callback")
                self.send_response(400)
                self.send_header("Content-Type", "text/html; charset=utf-8")
                self.end_headers()
                self.wfile.write(
                    _ERROR_HTML.format(
                        error_message="No authorization code received."
                    ).encode("utf-8")
                )
                return

            # Validate state to prevent CSRF
            received_state = params.get("state", [None])[0]
            if received_state != expected_state:
                result.set_error(
                    "state_mismatch",
                    "State parameter mismatch. Possible CSRF attack.",
                )
                self.send_response(400)
                self.send_header("Content-Type", "text/html; charset=utf-8")
                self.end_headers()
                self.wfile.write(
                    _ERROR_HTML.format(
                        error_message="Security validation failed. Please try again."
                    ).encode("utf-8")
                )
                return

            # Success
            result.set_success(code_list[0], received_state)
            self.send_response(200)
            self.send_header("Content-Type", "text/html; charset=utf-8")
            self.end_headers()
            self.wfile.write(_SUCCESS_HTML.encode("utf-8"))

        def log_message(self, format: str, *args: Any) -> None:
            """Suppress default HTTP server logging to stderr."""
            # Route through structured logging instead
            logger.debug("callback_server_request", message=format % args)

    return CallbackHandler


def _find_available_port(start_port: int, retries: int) -> int:
    """Find an available TCP port starting from start_port.

    Args:
        start_port: First port to try.
        retries: Number of consecutive ports to try.

    Returns:
        An available port number.

    Raises:
        OSError: If no available port is found within the retry range.
    """
    for offset in range(retries):
        port = start_port + offset
        try:
            with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
                sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
                sock.bind(("127.0.0.1", port))
                return port
        except OSError:
            logger.debug("port_in_use", port=port)
            continue

    raise OSError(
        f"No available port found in range {start_port}-{start_port + retries - 1}"
    )


# ============================================================================
# BrowserAuth
# ============================================================================


class BrowserAuth:
    """Browser-based OAuth PKCE authentication for AiCippy.

    Opens the Cognito hosted UI in the user's default browser, catches the
    callback on a localhost HTTP server, exchanges the auth code for tokens,
    and stores them in the OS keychain.

    Example:
        >>> auth = BrowserAuth()
        >>> result = await auth.login()
        >>> if result.success:
        ...     print(f"Logged in as {result.user_email}")
    """

    __slots__ = (
        "_settings",
        "_storage",
        "_client_id",
        "_cognito_domain",
        "_keyring_available",
        "_last_auth_url",
        "_browser_opened",
    )

    def __init__(self) -> None:
        """Initialize browser auth handler."""
        self._settings = get_settings()
        self._storage = KeychainStorage()
        self._client_id = self._settings.cognito_client_id
        self._cognito_domain = self._settings.cognito_domain
        self._keyring_available: bool = True
        self._last_auth_url: str | None = None
        self._browser_opened: bool = False

        logger.debug(
            "browser_auth_initialized",
            cognito_domain=self._cognito_domain,
        )

    def _build_auth_url(
        self,
        code_challenge: str,
        state: str,
        redirect_uri: str,
        identity_provider: str | None = None,
    ) -> str:
        """Build the Cognito hosted UI authorization URL.

        Args:
            code_challenge: PKCE S256 code challenge.
            state: CSRF state parameter.
            redirect_uri: Localhost callback URI.
            identity_provider: Optional Cognito identity provider name
                (e.g. "Google", "SignInWithGitHub") to skip the hosted UI
                and redirect directly to the provider.

        Returns:
            Full authorization URL string.
        """
        params: dict[str, str] = {
            "client_id": self._client_id,
            "response_type": "code",
            "scope": SCOPES,
            "redirect_uri": redirect_uri,
            "code_challenge": code_challenge,
            "code_challenge_method": "S256",
            "state": state,
        }
        if identity_provider:
            params["identity_provider"] = identity_provider
        return f"{self._cognito_domain}/oauth2/authorize?{urlencode(params)}"

    async def _exchange_code(
        self,
        code: str,
        code_verifier: str,
        redirect_uri: str,
    ) -> dict[str, Any]:
        """Exchange authorization code for tokens via Cognito token endpoint.

        Args:
            code: Authorization code from callback.
            code_verifier: PKCE code verifier.
            redirect_uri: The redirect URI used in the authorization request.

        Returns:
            Token response dictionary with access_token, id_token, etc.

        Raises:
            httpx.HTTPStatusError: If the token endpoint returns an error.
        """
        token_url = f"{self._cognito_domain}/oauth2/token"
        correlation_id = get_correlation_id()

        async with httpx.AsyncClient(
            timeout=HTTP_TIMEOUT_SECONDS,
            headers={"User-Agent": "AiCippy-CLI/1.0"},
        ) as client:
            response = await client.post(
                token_url,
                data={
                    "grant_type": "authorization_code",
                    "client_id": self._client_id,
                    "code": code,
                    "code_verifier": code_verifier,
                    "redirect_uri": redirect_uri,
                },
                headers={"Content-Type": "application/x-www-form-urlencoded"},
            )

            if response.status_code != 200:
                body = response.text
                logger.warning(
                    "token_exchange_failed",
                    status=response.status_code,
                    body=body[:200],
                    correlation_id=correlation_id,
                )
                response.raise_for_status()

            return response.json()

    async def login(
        self,
        timeout: int = CALLBACK_TIMEOUT_SECONDS,
        identity_provider: str | None = None,
    ) -> AuthResult:
        """Start browser-based login flow.

        1. Generate PKCE code verifier/challenge
        2. Find available port and build redirect URI
        3. Build authorization URL (with optional identity_provider for direct redirect)
        4. Auto-open browser (with manual URL fallback)
        5. Start localhost callback server
        6. Wait for callback with auth code
        7. Exchange code for tokens
        8. Store tokens and return AuthResult

        Args:
            timeout: Maximum seconds to wait for the browser callback.
            identity_provider: Optional Cognito identity provider name
                (e.g. "Google", "SignInWithGitHub") to skip the hosted UI
                and redirect directly to the provider.

        Returns:
            AuthResult with success/failure status.
        """
        correlation_id = get_correlation_id()
        logger.info("browser_login_started", correlation_id=correlation_id)

        # Step 1: Generate PKCE pair and state
        code_verifier, code_challenge = _generate_pkce_pair()
        state = _generate_state()

        # Step 2: Find available port
        try:
            port = _find_available_port(CALLBACK_PORT_START, CALLBACK_PORT_RETRIES)
        except OSError as e:
            logger.warning("no_available_port", error=str(e), correlation_id=correlation_id)
            return AuthResult.failure(
                f"Could not find an available port for callback server: {e}"
            )

        redirect_uri = f"http://localhost:{port}{CALLBACK_PATH}"

        # Step 3: Build authorization URL
        auth_url = self._build_auth_url(
            code_challenge, state, redirect_uri, identity_provider=identity_provider
        )

        # Step 4: Start callback server FIRST, then open browser
        callback_result = _CallbackResult()
        handler_class = _make_handler(callback_result, state)

        try:
            server = HTTPServer(("127.0.0.1", port), handler_class)
        except OSError as e:
            logger.warning("server_start_failed", port=port, error=str(e))
            return AuthResult.failure(f"Could not start callback server on port {port}: {e}")

        server_thread = Thread(target=server.serve_forever, daemon=True)
        server_thread.start()

        try:
            # Step 5: Open browser
            browser_opened = False
            try:
                browser_opened = webbrowser.open(auth_url)
            except Exception as e:
                logger.debug("browser_open_failed", error=str(e))

            # Yield control so the caller can display the URL
            # The URL and status are returned via the auth_url property
            # but the caller (CLI) handles display. We log it here.
            if not browser_opened:
                logger.info("browser_not_opened", auth_url=auth_url)

            # Store URL for caller access
            self._last_auth_url = auth_url
            self._browser_opened = browser_opened

            # Step 6: Wait for callback
            received = callback_result.wait(timeout=timeout)

            if not received:
                logger.warning("callback_timeout", timeout=timeout, correlation_id=correlation_id)
                return AuthResult.failure(
                    f"Authentication timed out after {timeout} seconds. "
                    "Please try again."
                )

            # Check for errors from callback
            if callback_result.error:
                error_detail = callback_result.error_description or callback_result.error
                logger.warning(
                    "callback_error",
                    error=callback_result.error,
                    description=callback_result.error_description,
                    correlation_id=correlation_id,
                )
                return AuthResult.failure(f"Authentication failed: {error_detail}")

            if not callback_result.code:
                return AuthResult.failure("No authorization code received from callback")

            # Step 7: Exchange code for tokens
            try:
                token_data = await self._exchange_code(
                    callback_result.code, code_verifier, redirect_uri
                )
            except httpx.HTTPStatusError as e:
                logger.warning(
                    "token_exchange_http_error",
                    status=e.response.status_code,
                    correlation_id=correlation_id,
                )
                return AuthResult.failure(
                    f"Token exchange failed (HTTP {e.response.status_code}). Please try again."
                )
            except httpx.HTTPError as e:
                logger.warning(
                    "token_exchange_network_error",
                    error=str(e),
                    correlation_id=correlation_id,
                )
                return AuthResult.failure(f"Network error during token exchange: {e}")

            # Step 8: Parse tokens and create AuthResult
            access_token = token_data.get("access_token")
            id_token = token_data.get("id_token")
            refresh_token = token_data.get("refresh_token", "")
            expires_in = token_data.get("expires_in", 3600)

            if not access_token or not id_token:
                return AuthResult.failure("Missing tokens in exchange response")

            expires_at = datetime.now(timezone.utc) + timedelta(seconds=expires_in)

            tokens = TokenInfo(
                access_token=access_token,
                id_token=id_token,
                refresh_token=refresh_token,
                expires_at=expires_at,
                token_type=token_data.get("token_type", "Bearer"),
            )

            # Extract user info from ID token claims
            user_email = "unknown"
            user_id = "unknown"
            try:
                claims = jwt.get_unverified_claims(id_token)
                user_id = claims.get("sub", "unknown")
                user_email = claims.get("email", "unknown")
            except Exception as e:
                logger.warning("id_token_decode_failed", error=str(e))

            # Store credentials in OS keychain
            if self._keyring_available:
                try:
                    self._storage.store_tokens(tokens)
                    self._storage.store_user_info(user_id, user_email)
                except Exception as e:
                    logger.warning(
                        "credential_storage_failed_keyring_unavailable",
                        error=str(e),
                        correlation_id=correlation_id,
                    )
                    self._keyring_available = False

            logger.info(
                "browser_login_success",
                user_email=user_email,
                correlation_id=correlation_id,
            )

            return AuthResult.success_result(
                user_email=user_email,
                user_id=user_id,
                expires_at=expires_at,
                tokens=tokens,
            )

        finally:
            # Always shut down the server
            server.shutdown()
            server_thread.join(timeout=5)

    @property
    def last_auth_url(self) -> str | None:
        """Get the last authorization URL that was generated (for manual copy)."""
        return self._last_auth_url

    @property
    def browser_opened(self) -> bool:
        """Whether the browser was successfully opened."""
        return self._browser_opened
