"""OAuth 2.0 Authorization Code flow with PKCE for the Sika CLI.

Implements the browser-based authentication flow:

1. Generate PKCE parameters (code verifier, challenge, state).
2. Start a local HTTP server to receive the OAuth callback.
3. Open the browser to the Cognito authorization endpoint.
4. Wait for the callback with the authorization code.
5. Exchange the code for tokens using the code verifier.
6. Extract the user's email from the ID token.
7. Create a long-lived API token via ``POST /platform/v1/tokens``.

The OAuth tokens are discarded after login — all subsequent
operations use the API token stored in ``~/.sika/credentials``.

This module is CLI-only — the SDK has no knowledge of OAuth flows.
"""

from __future__ import annotations

import base64
import hashlib
import http.server
import json
import secrets
import threading
import urllib.parse
from dataclasses import dataclass, field
from typing import Any

import httpx

# ---------------------------------------------------------------------------
# Cognito configuration per environment
# ---------------------------------------------------------------------------

_COGNITO_CONFIG: dict[str, dict[str, str]] = {
    "production": {
        "domain": "auth.sikasecurity.com",
        "client_id": "5fao498sigi5nvkflf860fun07",
        "platform_api_url": "https://api.sikasecurity.com",
    },
    "dev": {
        "domain": "dev.auth.sikasecurity.com",
        "client_id": "1smbui959cm931g47gn09reeug",
        "platform_api_url": "https://dev.api.sikasecurity.com",
    },
}

_SCOPES = "openid email profile"
_TOKEN_CREATION_TIMEOUT = 30.0


class AuthFlowError(Exception):
    """Raised when the browser authentication flow fails.

    Wraps HTTP errors to prevent leaking sensitive parameters
    (e.g. code verifier) in tracebacks.
    """
_CALLBACK_PATH = "/callback"
_TOKEN_EXCHANGE_TIMEOUT = 30.0


def get_cognito_config(environment: str) -> dict[str, str]:
    """Return Cognito configuration for the given environment.

    Args:
        environment: ``"production"`` or ``"dev"``.

    Returns:
        Dict with ``domain`` and ``client_id`` keys.
    """
    return _COGNITO_CONFIG.get(environment, _COGNITO_CONFIG["production"])


# ---------------------------------------------------------------------------
# PKCE helpers
# ---------------------------------------------------------------------------


@dataclass(frozen=True)
class PKCEParams:
    """PKCE parameters for the authorization code flow."""

    code_verifier: str
    code_challenge: str
    state: str

    def __repr__(self) -> str:
        return "PKCEParams(code_verifier='***', code_challenge='***', state='***')"


def _base64url_encode(data: bytes) -> str:
    """Base64url-encode *data* without padding (RFC 4648 Section 5)."""
    return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")


def generate_pkce_params() -> PKCEParams:
    """Generate cryptographically secure PKCE parameters.

    - **Code verifier:** 32 random bytes, base64url-encoded (43 chars).
    - **Code challenge:** SHA-256 of the verifier, base64url-encoded.
    - **State:** 16 random bytes, base64url-encoded (CSRF protection).
    """
    verifier_bytes = secrets.token_bytes(32)
    code_verifier = _base64url_encode(verifier_bytes)
    code_challenge = _base64url_encode(
        hashlib.sha256(code_verifier.encode("ascii")).digest()
    )
    state = _base64url_encode(secrets.token_bytes(16))
    return PKCEParams(
        code_verifier=code_verifier,
        code_challenge=code_challenge,
        state=state,
    )


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


def build_authorize_url(
    cognito_domain: str,
    client_id: str,
    redirect_uri: str,
    pkce: PKCEParams,
) -> str:
    """Build the Cognito authorization URL with PKCE parameters.

    Args:
        cognito_domain: Cognito custom domain (e.g. ``auth.sikasecurity.com``).
        client_id: OAuth client ID for the CLI.
        redirect_uri: Local callback URI (e.g. ``http://localhost:PORT/callback``).
        pkce: PKCE parameters from :func:`generate_pkce_params`.

    Returns:
        The full authorization URL to open in the browser.
    """
    params = {
        "response_type": "code",
        "client_id": client_id,
        "redirect_uri": redirect_uri,
        "scope": _SCOPES,
        "code_challenge_method": "S256",
        "code_challenge": pkce.code_challenge,
        "state": pkce.state,
    }
    return f"https://{cognito_domain}/oauth2/authorize?{urllib.parse.urlencode(params)}"


# ---------------------------------------------------------------------------
# Token exchange
# ---------------------------------------------------------------------------


@dataclass(frozen=True)
class TokenResponse:
    """Tokens returned from the Cognito token endpoint."""

    access_token: str
    id_token: str
    refresh_token: str
    expires_in: int

    def __repr__(self) -> str:
        return f"TokenResponse(expires_in={self.expires_in}, ...)"


def exchange_code_for_tokens(
    cognito_domain: str,
    client_id: str,
    redirect_uri: str,
    code: str,
    code_verifier: str,
) -> TokenResponse:
    """Exchange an authorization code for tokens.

    Args:
        cognito_domain: Cognito custom domain.
        client_id: OAuth client ID.
        redirect_uri: The same redirect URI used in the authorization request.
        code: The authorization code from the callback.
        code_verifier: The original PKCE code verifier.

    Returns:
        A :class:`TokenResponse` with access, ID, and refresh tokens.

    Raises:
        AuthFlowError: If the token exchange fails.
    """
    token_url = f"https://{cognito_domain}/oauth2/token"
    try:
        response = httpx.post(
            token_url,
            data={
                "grant_type": "authorization_code",
                "client_id": client_id,
                "redirect_uri": redirect_uri,
                "code": code,
                "code_verifier": code_verifier,
            },
            headers={"Content-Type": "application/x-www-form-urlencoded"},
            timeout=_TOKEN_EXCHANGE_TIMEOUT,
        )
        response.raise_for_status()
    except httpx.HTTPStatusError as exc:
        raise AuthFlowError(
            f"Token exchange failed (HTTP {exc.response.status_code})"
        ) from None
    except httpx.RequestError as exc:
        raise AuthFlowError(
            f"Token exchange failed: {type(exc).__name__}"
        ) from None
    data = response.json()
    return TokenResponse(
        access_token=data["access_token"],
        id_token=data["id_token"],
        refresh_token=data["refresh_token"],
        expires_in=data["expires_in"],
    )


# ---------------------------------------------------------------------------
# ID token decoding
# ---------------------------------------------------------------------------


def extract_email_from_id_token(id_token: str) -> str | None:
    """Extract the email claim from a Cognito ID token.

    Decodes the JWT payload without cryptographic verification.  This
    is safe because we just received the token directly from Cognito
    over HTTPS — there is no untrusted intermediary.

    Args:
        id_token: A JWT ID token from Cognito.

    Returns:
        The ``email`` claim, or ``None`` if not present.
    """
    try:
        # JWT format: header.payload.signature
        payload_b64 = id_token.split(".")[1]
        # Add padding for base64 decoding
        padding = 4 - len(payload_b64) % 4
        if padding != 4:
            payload_b64 += "=" * padding
        payload_bytes = base64.urlsafe_b64decode(payload_b64)
        claims: dict[str, Any] = json.loads(payload_bytes)
        return claims.get("email")
    except (IndexError, ValueError, json.JSONDecodeError):
        return None


# ---------------------------------------------------------------------------
# API token creation
# ---------------------------------------------------------------------------


def create_api_token(
    platform_api_url: str,
    id_token: str,
    *,
    product: str = "recon",
    label: str = "sika-cli",
    expires_in_days: int = 90,
) -> str:
    """Create a Sika API token via ``POST /platform/v1/tokens``.

    Exchanges a Cognito ID token for a long-lived API token that
    the SDK uses for ``X-Api-Key`` authentication.

    Args:
        platform_api_url: Platform API base URL
            (e.g. ``https://api.sikasecurity.com``).
        id_token: Cognito ID token (JWT) from the PKCE flow.
        product: Product identifier (default ``"recon"``).
        label: Human-readable label for the token.
        expires_in_days: Token lifetime in days (1--365, default 90).

    Returns:
        The plaintext API token (``ti_<product>_...``).

    Raises:
        AuthFlowError: If token creation fails.
    """
    url = f"{platform_api_url}/platform/v1/tokens"
    try:
        response = httpx.post(
            url,
            json={
                "product": product,
                "label": label,
                "expiresInDays": expires_in_days,
            },
            headers={"Authorization": f"Bearer {id_token}"},
            timeout=_TOKEN_CREATION_TIMEOUT,
        )
        response.raise_for_status()
    except httpx.HTTPStatusError as exc:
        raise AuthFlowError(
            f"API token creation failed (HTTP {exc.response.status_code})"
        ) from None
    except httpx.RequestError as exc:
        raise AuthFlowError(
            f"API token creation failed: {type(exc).__name__}"
        ) from None

    data = response.json()
    plaintext_token: str = data["plaintextToken"]
    return plaintext_token


# ---------------------------------------------------------------------------
# Local callback server
# ---------------------------------------------------------------------------


@dataclass
class CallbackResult:
    """Result captured from the OAuth callback."""

    code: str | None = None
    state: str | None = None
    error: str | None = None


@dataclass
class CallbackServer:
    """Threaded local HTTP server that receives the OAuth callback.

    Starts on an OS-assigned port on localhost.  The server handles
    exactly one request (the callback) and then shuts down.

    Usage::

        server = CallbackServer()
        server.start()
        redirect_uri = server.redirect_uri   # e.g. http://localhost:54321/callback
        # ... open browser, wait ...
        server.wait(timeout=300)
        result = server.result
        server.stop()
    """

    result: CallbackResult = field(default_factory=CallbackResult)
    _port: int = field(default=0, init=False)
    _httpd: http.server.HTTPServer | None = field(default=None, init=False)
    _thread: threading.Thread | None = field(default=None, init=False)
    _ready: threading.Event = field(default_factory=threading.Event, init=False)
    _done: threading.Event = field(default_factory=threading.Event, init=False)

    @property
    def port(self) -> int:
        """The port the server is listening on (0 before :meth:`start`)."""
        return self._port

    @property
    def redirect_uri(self) -> str:
        """The full callback URI (e.g. ``http://localhost:54321/callback``)."""
        return f"http://localhost:{self._port}{_CALLBACK_PATH}"

    def start(self) -> None:
        """Start the callback server in a background thread."""
        server = self
        result = self.result

        class _Handler(http.server.BaseHTTPRequestHandler):
            def do_GET(self) -> None:
                parsed = urllib.parse.urlparse(self.path)
                if parsed.path != _CALLBACK_PATH:
                    self.send_response(404)
                    self.end_headers()
                    return

                params = urllib.parse.parse_qs(parsed.query)

                if "error" in params:
                    result.error = params["error"][0]
                else:
                    result.code = params.get("code", [None])[0]
                    result.state = params.get("state", [None])[0]

                self.send_response(200)
                self.send_header("Content-Type", "text/html")
                self.end_headers()
                self.wfile.write(
                    b"<html><body>"
                    b"<h2>Authentication successful</h2>"
                    b"<p>You can close this tab and return to the terminal.</p>"
                    b"</body></html>"
                )
                server._done.set()

            def log_message(self, format: str, *args: Any) -> None:
                # Suppress default HTTP server logging
                pass

        self._httpd = http.server.HTTPServer(("127.0.0.1", 0), _Handler)
        self._port = self._httpd.server_address[1]
        self._ready.set()

        self._thread = threading.Thread(target=self._httpd.serve_forever, daemon=True)
        self._thread.start()

    def wait(self, timeout: float = 300.0) -> bool:
        """Wait for the callback to arrive.

        Args:
            timeout: Maximum seconds to wait.

        Returns:
            ``True`` if the callback was received, ``False`` on timeout.
        """
        return self._done.wait(timeout=timeout)

    def stop(self) -> None:
        """Shut down the server and join the background thread."""
        if self._httpd is not None:
            self._httpd.shutdown()
        if self._thread is not None:
            self._thread.join(timeout=5.0)
