"""Brute-force / sensitive-endpoint throttle middleware (D-1, v0.4.0).

Stability tier: **experimental** (AP-11) — added in 0.4.0 to
close the D-1 federation row
(`mgf-common/docs/inprogress/pre_release_additions.md` §6.2).
inthewords is the immediate adopter (auth-heavy Django app).

What ships here
---------------

- :class:`TokenBucket` — pure-Python token-bucket primitive
  (same shape as `mgf.fastapi.TokenBucket` — federation
  consistency, two-consumer signal). Each sibling ships its
  own copy because the runtime shape is "Python in-process,
  thread-safe" and the dependency cost of cross-sibling
  imports outweighs the ~80-LOC duplication.
- :class:`ThrottleMiddleware` — Django middleware (sync) that
  consults a TokenBucket per request. Returns ``429 Too Many
  Requests`` with ``Retry-After`` when the bucket is empty.
- :func:`SENSITIVE_PATHS_DEFAULT` — the canonical list of
  paths that benefit from throttle by default (`/login/`,
  `/password-reset/`, `/api/auth/*`). Consumers extend or
  replace.

Sync-mode middleware
--------------------

This middleware is **sync-mode only** (matches the
`MgfRequestIdMiddleware` / `MgfContextMiddleware` shape per
RA-05). Async-Django apps pay Django's sync-to-async shim
cost per request. An async variant tracks for v0.5+ if a
consumer demands it.

Cross-references
----------------

- **AP-11** — stability tiers.
- **D-1** federation row.
- The FastAPI sibling-equivalent: `mgf.fastapi.RateLimitMiddleware`
  + `mgf.fastapi.TokenBucket` (same shape, two-consumer
  signal).
"""

from __future__ import annotations

import logging
import threading
import time
from dataclasses import dataclass, field
from typing import TYPE_CHECKING

from django.http import HttpResponse, JsonResponse
from mgf.django._errors import ThrottleConfigError

if TYPE_CHECKING:
    from collections.abc import Callable

    from django.http import HttpRequest

_LOGGER = logging.getLogger(__name__)


__all__ = [
    "SENSITIVE_PATHS_DEFAULT",
    "ThrottleMiddleware",
    "TokenBucket",
]


SENSITIVE_PATHS_DEFAULT: tuple[str, ...] = (
    "/login/",
    "/login",
    "/accounts/login/",  # django-allauth default
    "/password-reset/",
    "/api/auth/",  # DRF-style
    "/api/token/",  # JWT-style
)
"""Canonical sensitive-paths list. Consumers extend or replace."""


@dataclass
class _BucketState:
    tokens: float
    last_refill_at: float
    lock: threading.Lock = field(default_factory=threading.Lock)


class TokenBucket:
    """Token-bucket rate limiter — pure-Python, thread-safe.

    Same shape as `mgf.fastapi.TokenBucket`. See that module's
    docstring for the algorithmic rationale.
    """

    def __init__(self, *, capacity: int, refill_per_second: float) -> None:
        if capacity <= 0:
            raise ThrottleConfigError(
                f"TokenBucket capacity must be > 0; got {capacity}. "
                f"Hint: pass a positive integer capacity.",
                field="capacity",
                value=capacity,
            )
        if refill_per_second <= 0:
            raise ThrottleConfigError(
                f"TokenBucket refill_per_second must be > 0; got {refill_per_second}. "
                f"Hint: pass a positive refill_per_second.",
                field="refill_per_second",
                value=refill_per_second,
            )
        self.capacity = capacity
        self.refill_per_second = float(refill_per_second)
        self._states: dict[str, _BucketState] = {}
        self._states_lock = threading.Lock()

    def try_consume(self, key: str) -> tuple[bool, float]:
        """Try to consume one token for ``key``.

        Returns ``(allowed, retry_after_seconds)``: ``(True, 0.0)`` if a token was
        available (request allowed), else ``(False, seconds_until_next_token)``.
        Thread-safe (per-key lock).
        """
        now = time.monotonic()
        state = self._get_or_create(key, now)
        with state.lock:
            elapsed = now - state.last_refill_at
            state.tokens = min(
                self.capacity,
                state.tokens + elapsed * self.refill_per_second,
            )
            state.last_refill_at = now
            if state.tokens >= 1.0:
                state.tokens -= 1.0
                return True, 0.0
            deficit = 1.0 - state.tokens
            return False, deficit / self.refill_per_second

    def _get_or_create(self, key: str, now: float) -> _BucketState:
        with self._states_lock:
            state = self._states.get(key)
            if state is None:
                state = _BucketState(
                    tokens=float(self.capacity),
                    last_refill_at=now,
                )
                self._states[key] = state
            return state

    def reset(self, key: str | None = None) -> None:
        """Reset rate-limit state. ``key=None`` clears EVERY bucket; otherwise clears

        just ``key``. Thread-safe; intended for tests / administrative reset.
        """
        with self._states_lock:
            if key is None:
                self._states.clear()
            else:
                self._states.pop(key, None)


class ThrottleMiddleware:
    """Django middleware: throttle sensitive paths per client IP.

    Install in ``MIDDLEWARE`` *early* — typically right after
    ``MgfRequestIdMiddleware`` + ``MgfContextMiddleware`` so the
    request-id is bound for any 429 audit log line.

    ::

        MIDDLEWARE = [
            "mgf.django.MgfRequestIdMiddleware",
            "mgf.django.MgfContextMiddleware",
            "mgf.django.ThrottleMiddleware",
            # ... rest of stack ...
        ]

    Per-request flow:

    1. Match the request path against ``sensitive_paths``. If no
       match, pass through (no throttle).
    2. Compute the bucket key — default is ``request.META["REMOTE_ADDR"]``.
       Override with the ``key`` callable in :class:`Config`.
    3. Consult the bucket. If allowed: continue. If denied:
       return 429 with ``Retry-After``.

    Configure via ``settings.py``::

        MGF_DJANGO_THROTTLE = {
            "bucket_capacity": 5,           # 5 attempts...
            "bucket_refill_per_second": 0.0167,  # ...refilled at 1/min
            "sensitive_paths": (
                "/login/",
                "/password-reset/",
                "/api/auth/",
            ),
        }

    The default (5 attempts, ~1/min refill) is the django-axes-
    style brute-force-protection shape: an attacker exhausting
    the bucket waits 1 minute per attempt instead of the
    request-rate of typical password-spraying.

    Async support (RA-05)
    ---------------------

    Sync-mode only. ``async_capable = False`` set explicitly.
    Mirrors the other mgf-django middleware classes.
    """

    async_capable = False

    def __init__(
        self,
        get_response: Callable[[HttpRequest], HttpResponse],
    ) -> None:
        from django.conf import settings

        self.get_response = get_response

        config = getattr(settings, "MGF_DJANGO_THROTTLE", {})
        self._bucket = TokenBucket(
            capacity=config.get("bucket_capacity", 5),
            refill_per_second=config.get(
                "bucket_refill_per_second",
                1.0 / 60.0,
            ),
        )
        self._sensitive_paths: tuple[str, ...] = tuple(
            config.get("sensitive_paths", SENSITIVE_PATHS_DEFAULT),
        )
        self._key_fn: Callable[[HttpRequest], str | None] = config.get(
            "key_fn",
            _default_key,
        )

    def __call__(self, request: HttpRequest) -> HttpResponse:
        if not self._is_sensitive(request.path):
            return self.get_response(request)

        key = self._key_fn(request)
        if key is None:
            return self.get_response(request)

        allowed, retry_after = self._bucket.try_consume(key)
        if allowed:
            return self.get_response(request)

        retry_after_secs = max(1, int(retry_after + 0.999))
        _LOGGER.warning(
            "throttle: rate-limit exceeded",
            extra={
                "event": "mgf.django.throttle_exceeded",
                "audit": True,
                "path": request.path,
                "key": key,
                "retry_after_seconds": retry_after_secs,
            },
        )
        return JsonResponse(
            {"error": "rate-limit-exceeded", "retry_after": retry_after_secs},
            status=429,
            headers={"Retry-After": str(retry_after_secs)},
        )

    def _is_sensitive(self, path: str) -> bool:
        return any(path.startswith(prefix) for prefix in self._sensitive_paths)


def _default_key(request: HttpRequest) -> str | None:
    """Default bucket key — ``REMOTE_ADDR`` fallback to None.

    ``REMOTE_ADDR`` is the real TCP peer address set by the server, so
    it is NOT client-spoofable (the default deliberately does NOT read
    ``X-Forwarded-For`` / ``X-Real-IP``, which are attacker-controlled).

    .. warning::
       Behind a reverse proxy / load balancer / CDN, ``REMOTE_ADDR`` is
       the *proxy's* address — so every client collapses into ONE bucket
       and legitimate traffic is throttled as a group. To key per real
       client in that topology, pass a ``key_fn`` that reads a forwarded
       header — but ONLY if your proxy strips the client-supplied copy of
       that header (otherwise the value is attacker-controlled and the
       throttle is bypassable). Prefer an *authenticated* identity
       (user id / API key from ``request``) where available. Same
       proxy-trust footgun as ``mgf.fastapi`` IpAllowlist / RateLimit.

    Returning ``None`` disables throttle for the request (caller can
    override to provide e.g. authenticated-user-id keying).
    """
    return request.META.get("REMOTE_ADDR") or None
