import time
import logging
from django.conf import settings
from django.http import HttpResponseForbidden, JsonResponse
from django.core.cache import cache

logger = logging.getLogger(__name__)


class RateLimitMiddleware:
    """
    IP-based rate limiting, applied only to the prefixes in DEFAULT_RULES.

    Each rule maps a URL prefix to (max_requests, window_seconds).

    THREE THINGS THIS IS NOT, each of which has to be covered elsewhere:

    - It is not per-user. The middleware runs above AuthenticationMiddleware
      (see MIDDLEWARE in settings/base.py), so `request.user` does not exist
      here. Per-user throttling belongs in DRF's throttle classes.
    - It is not a brute-force defence. `_check_rate` is a read-modify-write
      with no lock, so concurrent requests can both pass. Sign-in additionally
      keeps a per-email failure counter in users/forms.py, keyed on the thing
      an attacker cannot rotate.
    - It is not reliable without a shared cache. LocMemCache is per process,
      so the effective limit becomes `workers x max_requests` and resets on
      restart. Production uses DatabaseCache for exactly this reason, and
      `manage.py createcachetable` has to have been run.
    """

    # ORDER IS LOAD-BEARING. `_match_rule` returns the FIRST matching prefix,
    # not the longest, so a narrower rule must sit above the broader one it
    # lives under — '/auth/desktop/register/' before '/auth/desktop/'.
    DEFAULT_RULES = {
        '/api/waitlist/submit/': (5, 300),        # 5 per 5 min
        '/api/waitlist/verify/': (10, 300),       # 10 per 5 min (user retries code)
        '/api/waitlist/resend/': (3, 360),        # 3 resends per 5 min
        '/api/newsletter/subscribe/': (3, 300),   # 3 per 5 min

        '/auth/desktop/register/': (6, 900),      # 6 sign-ups per 15 min
        '/auth/desktop/': (20, 300),              # covers sign-in and done
        # The website minting a code, then the app spending it. Both are
        # loose enough for a user who presses the hand-back button again
        # because the deep link did nothing visible on their machine.
        '/api/auth/desktop/authorize/': (10, 300),
        '/api/auth/desktop/token/': (10, 300),    # the code exchange
        '/api/auth/handoff/': (10, 300),          # the PWA's sibling of desktop/authorize/
        '/api/auth/login/': (20, 300),            # the website's JSON login
        '/api/auth/register/': (6, 900),          # 6 sign-ups per 15 min
        # Both `request` rules send an email, so the limit is on the mailbox
        # being filled, not on the endpoint being read. `confirm` is looser
        # because a link can be opened twice by a mail client prefetching it.
        '/api/auth/password-reset/request/': (4, 900),
        '/api/auth/password-reset/confirm/': (10, 900),
        '/api/auth/verify-email/request/': (4, 900),
        '/api/auth/verify-email/confirm/': (10, 900),
        '/api/auth/oauth/exchange/': (10, 300),   # the social handoff exchange
        '/api/auth/oauth/': (20, 300),            # start and callback redirects
        '/api/waitlist/join/': (5, 300),          # signed-in waitlist join
        '/api/token/refresh/': (60, 300),         # a desktop client refreshes often
    }

    # How many proxies sit in front of Django. On cPanel that is Apache in
    # front of Passenger: one hop. VERIFY THIS ON THE BOX before trusting it —
    # log X-Forwarded-For and REMOTE_ADDR for one real request and count.
    TRUSTED_PROXY_HOPS = 1

    def __init__(self, get_response):
        self.get_response = get_response

    def __call__(self, request):
        rule = self._match_rule(request.path)

        if rule:
            max_requests, window = rule
            ip = self._get_client_ip(request)
            cache_key = f'rl:{ip}:{request.path}'

            is_limited, remaining = self._check_rate(cache_key, max_requests, window)

            if is_limited:
                logger.warning(
                    'Rate limit exceeded',
                    extra={
                        'ip': ip,
                        'path': request.path,
                    }
                )
                return JsonResponse(
                    {
                        'status': 'error',
                        'message': 'Too many requests. Please wait before trying again.',
                    },
                    status=429,
                )

        return self.get_response(request)

    def _match_rule(self, path):
        """Return (max_requests, window) for the first matching rule, or None."""
        for prefix, rule in self.DEFAULT_RULES.items():
            if path.startswith(prefix):
                return rule
        return None

    def _get_client_ip(self, request):
        """
        Extract the client IP from the right-hand end of X-Forwarded-For.

        TAKING THE LEFTMOST HOP — which this used to do — is spoofable by
        anybody: a client simply sends its own X-Forwarded-For and every rule
        above becomes decorative, because each forged value is a fresh bucket.
        A client can PREPEND entries to that header; it cannot remove the one
        our own proxy appends. So the last TRUSTED_PROXY_HOPS entries are the
        only part of the chain worth reading, and we take the first of those.

        Tolerable for a newsletter form. Not tolerable now that the same
        function guards sign-in and the token exchange.
        """
        forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR', '')
        hops = [hop.strip() for hop in forwarded_for.split(',') if hop.strip()]

        if len(hops) >= self.TRUSTED_PROXY_HOPS > 0:
            return hops[-self.TRUSTED_PROXY_HOPS]

        return request.META.get('REMOTE_ADDR', '0.0.0.0')

    def _check_rate(self, cache_key, max_requests, window):
        """
        Sliding window counter using Django cache.
        Returns (is_limited: bool, remaining: int).
        """
        now = time.time()
        window_key = f'{cache_key}:window'

        # Get existing request timestamps
        timestamps = cache.get(cache_key, [])

        # Drop timestamps outside the current window
        timestamps = [t for t in timestamps if now - t < window]

        if len(timestamps) >= max_requests:
            return True, 0

        timestamps.append(now)
        cache.set(cache_key, timestamps, timeout=window)

        remaining = max_requests - len(timestamps)
        return False, remaining


class AdminIPRestrictMiddleware:
    """
    Refuses `/admin/` to any IP not in `settings.ADMIN_ALLOWED_IPS`.

    The one cost of serving the PWA from this host (docs/pwa-boundary.md §2):
    `/admin/` is now same-origin with a client that renders arbitrary
    user-authored text on every screen, so an XSS there could act as a
    signed-in staff user against Django admin — something the desktop client
    never exposed. `CSRF_COOKIE_HTTPONLY` (settings/base.py) is the other half
    of the mitigation; this is the IP-restriction half, and it is meant to be
    REDUNDANT with the same restriction at Apache, not a replacement for it —
    a request refused at the web server never reaches Passenger, let alone
    this middleware, which is the stronger guarantee.

    AN EMPTY `ADMIN_ALLOWED_IPS` DISABLES THE CHECK rather than blocking
    everyone, so local development and a box that has not set the variable yet
    are not locked out by default. Set it before real users are on the PWA.
    """

    # Same reasoning as `RateLimitMiddleware.TRUSTED_PROXY_HOPS` — cPanel's
    # Apache-in-front-of-Passenger is one hop. Kept as a separate constant
    # rather than shared: the two middlewares guard different things and a
    # deploy that fronts admin differently from the API should be free to
    # change one without the other.
    TRUSTED_PROXY_HOPS = 1

    def __init__(self, get_response):
        self.get_response = get_response

    def __call__(self, request):
        if request.path.startswith('/admin/') and settings.ADMIN_ALLOWED_IPS:
            ip = self._client_ip(request)
            if ip not in settings.ADMIN_ALLOWED_IPS:
                logger.warning('Blocked /admin/ from disallowed IP', extra={'ip': ip})
                return HttpResponseForbidden('Forbidden')

        return self.get_response(request)

    def _client_ip(self, request):
        forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR', '')
        hops = [hop.strip() for hop in forwarded_for.split(',') if hop.strip()]

        if len(hops) >= self.TRUSTED_PROXY_HOPS > 0:
            return hops[-self.TRUSTED_PROXY_HOPS]

        return request.META.get('REMOTE_ADDR', '0.0.0.0')