#!/usr/bin/env python3

"""
CSRF Protection Middleware for DocuElevate.

This middleware implements Cross-Site Request Forgery (CSRF) protection for all
state-changing HTTP operations (POST, PUT, DELETE, PATCH).

How it works:
- A cryptographically secure token is generated per session and stored in the session.
- The token is attached to ``request.state.csrf_token`` so Jinja2 templates can render it.
- For every state-changing request the middleware validates the submitted token by
  checking (in order):
    1. The ``X-CSRF-Token`` HTTP request header  (used by AJAX / fetch calls).
    2. The ``csrf_token`` field in ``application/x-www-form-urlencoded`` bodies
       (used by traditional HTML forms such as the login form).
  Multipart file-upload requests must always supply the token via the header.
- Validation is only enforced when ``AUTH_ENABLED=True``.  When authentication is
  disabled (development / single-user mode) the middleware is a no-op.

Exempt paths (CSRF is not checked even for state-changing methods):
- ``/oauth-callback`` – OAuth 2.0 callback; protected by the ``state`` parameter.
- ``/api/qr-auth/claim`` – Called by the unauthenticated mobile app; the
  cryptographically-random, single-use challenge token provides equivalent
  protection.
"""

import logging
import secrets
from typing import Callable

from fastapi import Request, Response
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.responses import JSONResponse, RedirectResponse

logger = logging.getLogger(__name__)

# HTTP methods that change server state and therefore require a valid CSRF token.
CSRF_PROTECTED_METHODS = {"POST", "PUT", "DELETE", "PATCH"}

# Paths that must never be CSRF-checked (e.g. OAuth flow endpoints that carry
# their own replay-protection mechanism).
CSRF_EXEMPT_PATHS = {
    "/oauth-callback",
    # The mobile app calls this endpoint without a browser session/CSRF token.
    # The cryptographically-random, single-use challenge token already provides
    # equivalent protection against cross-site request forgery.
    "/api/qr-auth/claim",
}


class CSRFMiddleware(BaseHTTPMiddleware):
    """
    Middleware that generates and validates CSRF tokens for state-changing requests.

    Token lifecycle
    ---------------
    1. On the first request for a session a 64-character hex token is created with
       ``secrets.token_hex(32)`` and stored in ``request.session["csrf_token"]``.
    2. On every subsequent request the existing token is read from the session.
    3. The token is always attached to ``request.state.csrf_token`` so that
       Jinja2 templates (and response processors) can embed it.

    Validation
    ----------
    For ``POST``, ``PUT``, ``DELETE``, and ``PATCH`` requests the middleware
    checks whether the submitted token matches the session token using a
    constant-time comparison (``secrets.compare_digest``) to prevent timing
    attacks.

    Failure response
    ----------------
    - API routes (``/api/*``):  HTTP 403 JSON response.
    - All other routes:         HTTP 302 redirect to ``/login?error=…``.
    """

    def __init__(self, app, config):
        """
        Initialise the middleware.

        Args:
            app:    The ASGI application.
            config: Application settings object (``app.config.Settings``).
                    ``config.auth_enabled`` controls whether CSRF enforcement is active.
        """
        super().__init__(app)
        self.config = config
        self.enabled = config.auth_enabled
        if self.enabled:
            logger.info("CSRF protection middleware enabled")
        else:
            logger.info("CSRF protection middleware disabled (AUTH_ENABLED=False)")

    async def dispatch(self, request: Request, call_next: Callable) -> Response:
        """
        Process the request: generate/attach the token and validate it when required.

        Args:
            request:   Incoming HTTP request.
            call_next: Next middleware or route handler in the ASGI chain.

        Returns:
            HTTP response, or an error response when CSRF validation fails.
        """
        if not self.enabled:
            return await call_next(request)

        # Generate or retrieve the per-session CSRF token.
        csrf_token = request.session.get("csrf_token")
        if not csrf_token:
            csrf_token = secrets.token_hex(32)
            request.session["csrf_token"] = csrf_token

        # Attach token to request state so templates and route handlers can access it.
        request.state.csrf_token = csrf_token

        # Validate for state-changing methods on non-exempt paths.
        if request.method in CSRF_PROTECTED_METHODS and request.url.path not in CSRF_EXEMPT_PATHS:
            # Bearer-authenticated requests (API tokens) are exempt from CSRF
            # because the token itself acts as proof of intent — it cannot be
            # injected by a cross-site request from a browser.
            auth_header = request.headers.get("authorization", "")
            if auth_header.startswith("Bearer "):
                return await call_next(request)

            submitted_token = await self._get_submitted_token(request)
            if not submitted_token or not secrets.compare_digest(csrf_token, submitted_token):
                logger.warning(f"[SECURITY] CSRF_VALIDATION_FAILED method={request.method} path={request.url.path}")
                if request.url.path.startswith("/api/"):
                    return JSONResponse(
                        status_code=403,
                        content={"detail": "CSRF token missing or invalid"},
                    )
                return RedirectResponse(url="/login?error=Invalid+request", status_code=302)

        return await call_next(request)

    @staticmethod
    async def _get_submitted_token(request: Request) -> str | None:
        """
        Extract the CSRF token submitted by the client.

        Checks (in priority order):
        1. ``X-CSRF-Token`` request header – preferred for AJAX / fetch requests.
        2. ``csrf_token`` form field in ``application/x-www-form-urlencoded`` bodies –
           used by plain HTML forms (e.g. the login form).

        Multipart bodies (file uploads) are intentionally not parsed here to avoid
        buffering large uploads in middleware; those endpoints must send the token
        via the header instead.

        Args:
            request: The incoming HTTP request.

        Returns:
            The submitted CSRF token string, or ``None`` if not found.
        """
        # 1. Check the request header (AJAX / fetch).
        token = request.headers.get("X-CSRF-Token")
        if token:
            return token

        # 2. For URL-encoded form bodies only (plain HTML form submissions).
        content_type = request.headers.get("content-type", "")
        logger.debug("CSRF: content_type=%r method=%s path=%s", content_type, request.method, request.url.path)
        if "application/x-www-form-urlencoded" in content_type:
            try:
                # Cache the raw body bytes before parsing the form.  Starlette's
                # BaseHTTPMiddleware uses _CachedRequest.wrapped_receive to relay
                # the body to downstream handlers.  When form() is called it
                # internally uses stream() which sets _stream_consumed=True but
                # does NOT populate _body.  wrapped_receive then sees a consumed
                # stream and forwards an empty body, so the auth endpoint gets
                # form_keys=[].  Calling body() first stores the bytes in _body;
                # wrapped_receive detects this and replays the real body to any
                # downstream handler (e.g. the /auth endpoint).
                await request.body()
                form = await request.form()
                token = form.get("csrf_token")
                logger.debug(
                    "CSRF: form_keys=%s csrf_token_present=%s",
                    list(form.keys()),
                    bool(token),
                )
                if token:
                    return str(token)
            except Exception as exc:
                logger.debug(f"CSRF: could not parse form body: {exc}")

        return None
