"""``mgf.fastapi.security`` — request-side security primitives.

Two primitives ship from this module:

  - :class:`IpAllowlist` — FastAPI dependency that gates routes on a
    CIDR allowlist. Concentrates the proxy-trust footgun
    (``X-Forwarded-For`` honouring) in one place with a clearly-named
    opt-in (``trust_proxy=False`` default). Closes PAPER-27 (v0.27).
    Use case: ops-only / staging-only / pre-auth-shipping admin
    endpoints that need a network gate even before SaaS auth is wired.

  - :func:`bearer_session` — factory that returns a FastAPI dependency
    resolving the ``Authorization: Bearer <token>`` header (and, optionally,
    a session cookie for browser clients) to a typed principal via an
    :class:`AuthVerifier` Protocol stashed on ``app.state``. Closes
    PAPER-29 (v0.3 of mgf-fastapi). Use case: every authenticated route
    in a SaaS app runs the same "bearer header → typed session" gate; this
    factory codifies it once, with the constant-time token handling and
    the JSON-401 translation done in one place.

Both raise :class:`mgf.common.exceptions.HttpForbiddenError` (IpAllowlist) or
:class:`mgf.common.exceptions.HttpUnauthorizedError` (bearer_session) on miss.
Pair with :class:`mgf.fastapi.ExceptionTranslationMiddleware` for the
JSON 4xx response shape.

The dependency reads ``request.client.host`` directly by default
(IpAllowlist). If the consumer's deployment is behind a reverse proxy,
a forwarded-headers middleware MUST be installed AND ``trust_proxy=True``
set explicitly — otherwise every request appears to originate from the
proxy.
"""

from __future__ import annotations

import ipaddress
import logging
from functools import lru_cache
from typing import TYPE_CHECKING, Any, Protocol, TypeVar

from mgf.common.exceptions import HttpForbiddenError, HttpUnauthorizedError

# ``Request`` looks type-only here, but FastAPI evaluates the
# dependency's annotations at registration time via ``get_type_hints``
# to bind the parameter — moving this into ``TYPE_CHECKING`` would
# crash dep wiring at app-startup time.
from fastapi import (
    Request,  # noqa: TC001 — see comment above; FastAPI introspects annotations at registration time
)

if TYPE_CHECKING:
    from collections.abc import Awaitable, Callable, Iterable

__all__ = [
    "LOOPBACK_CIDRS",
    "AuthVerifier",
    "IpAllowlist",
    "bearer_session",
]

PrincipalT_co = TypeVar("PrincipalT_co", covariant=True)

LOG = logging.getLogger(__name__)

#: Loopback CIDRs that match local-only traffic (IPv4 + IPv6).
#: The default :class:`IpAllowlist` includes these so tests work
#: out-of-the-box; consumers OVERRIDE ``cidrs=`` for production.
LOOPBACK_CIDRS: tuple[str, ...] = ("127.0.0.0/8", "::1/128")


@lru_cache(maxsize=128)
def _normalize_client_host(raw: str) -> str | None:
    """Return ``raw`` stripped of IPv6 brackets and port suffix.

    RA-03: reverse proxies + ASGI servers occasionally hand the
    `IpAllowlist` strings shapes that `ipaddress.ip_address()` rejects:

      - ``"203.0.113.5:54321"`` — IPv4 with port suffix.
      - ``"[2001:db8::1]"`` — IPv6 with RFC 3986 URL-style brackets.
      - ``"[::1]:8080"`` — IPv6 with brackets + port.

    Without this normalisation those become 403 `client IP not in
    allowlist` because the parser fails before the membership check —
    operators see a misleading message; the real failure is parsing.

    Returns ``None`` if the input is empty or obviously not an
    address (the caller still tries to parse the result; this helper
    is purely the strip-then-handoff stage).
    """
    if not raw:
        return None
    s = raw.strip()
    # IPv6 in brackets — RFC 3986 URL-style. Strip the brackets;
    # an optional `:<port>` after the closing bracket gets dropped too.
    if s.startswith("["):
        end = s.find("]")
        if end != -1:
            return s[1:end]
        # Malformed (open bracket, no close). Hand off raw — let the
        # caller's ip_address() return the typed ValueError.
        return s
    # IPv4 with `:port` — exactly ONE colon, and it's not an IPv6
    # address. IPv6 plain notation (no brackets) has multiple colons;
    # don't strip those.
    if s.count(":") == 1:
        return s.split(":", 1)[0]
    return s


def _parse_cidr(cidr: str) -> ipaddress.IPv4Network | ipaddress.IPv6Network:
    """Cache-coerce a CIDR string. The allowlist is short and stable."""
    return ipaddress.ip_network(cidr, strict=False)


class IpAllowlist:
    """FastAPI dependency that 403s requests whose client IP isn't in ``cidrs``.

    Use as a route ``Depends`` annotation::

        from fastapi import Depends, FastAPI
        from mgf.fastapi.security import IpAllowlist

        admin_only = IpAllowlist(cidrs=["10.0.0.0/8", "192.168.1.0/24"])

        app = FastAPI()

        @app.post("/admin/trackers", dependencies=[Depends(admin_only)])
        async def create_tracker(): ...

    :param cidrs: Allowed CIDRs (IPv4 and/or IPv6). Empty iterable
        denies every request — useful as a "this endpoint exists
        but is currently disabled" signal.
    :param trust_proxy: When ``True``, the dependency honours the
        first entry in ``X-Forwarded-For`` instead of
        ``request.client.host``. Default ``False`` — your deployment
        MUST explicitly opt in once a forwarded-headers middleware
        is in place. Honouring forwarded headers without proxy trust
        is the canonical IP-spoofing footgun.

    Raises :class:`HttpForbiddenError` (403) on miss. Pair with
    :class:`mgf.fastapi.ExceptionTranslationMiddleware` for
    the JSON 403 response shape.
    """

    __slots__ = ("_networks", "_trust_proxy")

    def __init__(
        self,
        cidrs: Iterable[str] = LOOPBACK_CIDRS,
        *,
        trust_proxy: bool = False,
    ) -> None:
        # Parse + validate CIDRs eagerly so a misconfiguration
        # surfaces at construction, not at first request.
        self._networks: list[ipaddress.IPv4Network | ipaddress.IPv6Network] = []
        for cidr in cidrs:
            try:
                self._networks.append(_parse_cidr(cidr))
            except ValueError as exc:
                msg = f"IpAllowlist: invalid CIDR {cidr!r}"
                raise ValueError(msg) from exc
        self._trust_proxy = trust_proxy

        # SH-03: surface a startup-time audit log so operators see
        # the trust_proxy flag at app boot. trust_proxy=True is the
        # canonical IP-spoofing footgun — correct ONLY behind a
        # reverse proxy that strips client-supplied X-Forwarded-For.
        # Logging here means a misconfiguration is visible in
        # structured logs at the moment it's introduced; PAPER-27
        # has the threat model.
        if trust_proxy:
            LOG.warning(
                "IpAllowlist: constructed with trust_proxy=True. "
                "Confirm an upstream reverse proxy strips client-"
                "supplied X-Forwarded-For; without that, "
                "X-Forwarded-For is attacker-controlled and the "
                "allowlist is bypassable. (PAPER-27.)",
                extra={"event": "mgf.fastapi.ip_allowlist.trust_proxy_enabled", "audit": True},
            )

    def __call__(self, request: Request) -> None:
        """FastAPI dependency form. 403 on miss; returns ``None`` on hit."""
        client_host = self._extract_client_host(request)
        if client_host is None:
            LOG.warning(
                "IpAllowlist: request had no client IP — denying",
                extra={
                    "event": "mgf.fastapi.ip_allowlist.denied",
                    "reason": "no_client_ip",
                    "audit": True,
                },
            )
            raise HttpForbiddenError("client IP not in allowlist")

        try:
            client_ip = ipaddress.ip_address(client_host)
        except ValueError as exc:
            LOG.warning(
                "IpAllowlist: unparseable client IP %r — denying",
                client_host,
                extra={
                    "event": "mgf.fastapi.ip_allowlist.denied",
                    "reason": "unparseable_ip",
                    "client_host": client_host,
                    "audit": True,
                },
            )
            raise HttpForbiddenError("client IP not in allowlist") from exc

        for network in self._networks:
            # IPv4 in IPv4 network OR IPv6 in IPv6 network — version mismatch
            # always means "not a match" so we skip the cross-version pair.
            if client_ip.version == network.version and client_ip in network:
                return

        LOG.warning(
            "IpAllowlist: client IP %s not in allowlist (%d networks)",
            client_host,
            len(self._networks),
            extra={
                "event": "mgf.fastapi.ip_allowlist.denied",
                "reason": "not_in_allowlist",
                "client_host": client_host,
                "networks": len(self._networks),
                "audit": True,
            },
        )
        raise HttpForbiddenError("client IP not in allowlist")

    def _extract_client_host(self, request: Request) -> str | None:
        """Return the source IP per ``trust_proxy`` policy."""
        raw: str | None = None
        if self._trust_proxy:
            forwarded = request.headers.get("x-forwarded-for")
            if forwarded:
                # Take the first entry — the originating client.
                # Subsequent entries are intermediate proxies.
                raw = forwarded.split(",", 1)[0].strip()
        if raw is None and request.client is not None:
            raw = request.client.host
        if raw is None:
            return None
        return _normalize_client_host(raw)


# ---------------------------------------------------------------------------
# Bearer-token session resolution (PAPER-29)
# ---------------------------------------------------------------------------


class AuthVerifier(Protocol[PrincipalT_co]):
    """Protocol consumers implement to verify a bearer token.

    The verifier accepts a bare token (no ``Bearer `` prefix) and
    returns the typed principal on success. On failure it can raise
    ``HttpUnauthorizedError`` directly (passed through unchanged) or any
    other exception (translated to ``HttpUnauthorizedError`` by the
    dependency factory). The exact principal type is consumer-defined —
    a dataclass, a pydantic model, a tenant-membership tuple, whatever
    the app's auth flow needs downstream.

    Tests swap providers by assigning a fake to ``app.state`` (see the
    :func:`bearer_session` factory's ``verifier_attr`` parameter for
    the attribute name).
    """

    async def verify_session(self, token: str) -> PrincipalT_co: ...


def bearer_session(
    verifier_attr: str = "auth_provider",
    *,
    cookie_name: str | None = None,
) -> Callable[[Request], Awaitable[Any]]:
    """Build a FastAPI dependency that resolves a bearer token to a principal.

    The returned dependency:

      - Reads the :class:`AuthVerifier` from
        ``request.app.state.<verifier_attr>``. Tests swap providers by
        mutating ``app.state``.
      - Reads the bearer token from the ``Authorization`` header. If
        ``cookie_name`` is set, it also falls back to the named cookie
        when the header is missing (useful for browser clients that
        store the session in an ``HttpOnly`` cookie).
      - Calls ``await verifier.verify_session(token)`` and returns the
        principal.
      - On a missing header, malformed scheme, missing verifier on
        ``app.state``, or any exception from the verifier, raises
        :class:`mgf.common.exceptions.HttpUnauthorizedError` — which
        :class:`mgf.fastapi.ExceptionTranslationMiddleware` resolves
        to a 401 JSON response.

    Use the typing pattern at the consumer side::

        from typing import Annotated
        from fastapi import Depends, FastAPI
        from mgf.fastapi.security import bearer_session

        from my_app.auth import SessionPrincipal, my_verifier

        app = FastAPI()
        app.state.auth_provider = my_verifier  # any AuthVerifier impl

        Session = Annotated[SessionPrincipal, Depends(bearer_session())]

        @app.get("/me")
        async def me(session: Session) -> dict[str, str]:
            return {"user": session.email}

    The factory returns ``Callable[[Request], Awaitable[Any]]`` because
    Python's typing can't carry the principal type back across the
    ``Depends(...)`` boundary. Consumers recover precise typing via
    ``Annotated[MyPrincipal, Depends(bearer_session())]`` as shown above.

    :param verifier_attr: Attribute name on ``app.state`` where the
        :class:`AuthVerifier` is stored. Default ``"auth_provider"``.
        Useful when a single app has multiple verifiers (admin vs.
        end-user, different tenants, etc.) — call ``bearer_session``
        once per attribute name.
    :param cookie_name: Optional cookie name. When set, the dependency
        falls back to reading the token from this cookie if the
        ``Authorization`` header is missing. ``None`` (default) means
        bearer-only.

    :raises HttpUnauthorizedError: on missing/malformed token, missing
        verifier, or any exception from ``verify_session``.
    """

    async def _dep(request: Request) -> Any:
        token = _extract_bearer_token(request, cookie_name)
        if token is None:
            raise HttpUnauthorizedError("missing bearer token")

        verifier: AuthVerifier[Any] | None = getattr(request.app.state, verifier_attr, None)
        if verifier is None:
            # This is a deployment bug (verifier was never wired up), not
            # a user auth failure. Log noisily but return 401 to the
            # caller — exposing the deployment misconfiguration would
            # leak internal state to anyone who hit an auth-gated route.
            LOG.error(
                "bearer_session: app.state.%s not set; cannot verify token",
                verifier_attr,
                extra={"event": "mgf.fastapi.auth.misconfigured", "audit": True},
            )
            raise HttpUnauthorizedError("authentication not configured")

        try:
            return await verifier.verify_session(token)
        except HttpUnauthorizedError:
            # Verifier already produced the right shape — let it through
            # untouched so its message / cause stays intact.
            raise
        except Exception as exc:
            # Translate every other failure mode (provider 500, expired
            # token, malformed JWT, network blip, …) to 401. The verifier
            # type name lands in the log so consumers can debug without
            # exposing detail to the caller.
            LOG.info(
                "bearer_session: verifier rejected token (%s)",
                type(exc).__name__,
                extra={
                    "event": "mgf.fastapi.auth.rejected",
                    "verifier_error": type(exc).__name__,
                    "audit": True,
                },
            )
            raise HttpUnauthorizedError("invalid or expired session") from exc

    return _dep


def _extract_bearer_token(request: Request, cookie_name: str | None) -> str | None:
    """Pull the bare token from the Authorization header (or fallback cookie).

    The Authorization header is parsed with a case-insensitive scheme
    check; the ``Bearer`` prefix is stripped before returning. When
    ``cookie_name`` is provided AND the header is missing or doesn't
    use the Bearer scheme, the named cookie is consulted as a fallback.
    Returns ``None`` when no token could be found.

    **RA-08 / SH-05 defense:** tokens containing control characters
    (CR / LF / NUL / other C0 controls) are rejected — return ``None``
    even if the raw value was present. Browsers usually disallow these
    in cookies/headers, but a non-browser client (a Worker, a misconfigured
    proxy) could pass them. Forwarding to ``verify_session(token)`` with
    embedded controls risks the verifier mis-parsing or the value
    appearing un-redacted in logs.
    """
    auth = request.headers.get("authorization")
    if auth:
        scheme, _, value = auth.partition(" ")
        if scheme.lower() == "bearer":
            token = value.strip()
            if token and _is_clean_token(token):
                return token
    if cookie_name:
        cookie = request.cookies.get(cookie_name)
        if cookie:
            token = cookie.strip()
            if token and _is_clean_token(token):
                return token
    return None


def _is_clean_token(token: str) -> bool:
    """Reject tokens carrying control characters (RA-08 / SH-05).

    A clean token has no characters in the C0 control range (``\\x00``
    through ``\\x1F``) or DEL (``\\x7F``). Real OAuth / JWT bearer
    tokens use base64url / hex / opaque-ascii — none contain controls.
    """
    return not any(c < " " or c == "\x7f" for c in token)
