"""Rate limiter abstraction.

`InMemoryRateLimiter` is the default and works on cPanel's single-process
workers. When ``REDIS_URL`` is configured, `RedisRateLimiter` takes over so
multi-worker hosts share rate-limit counters across processes.

Call :func:`get_default_limiter` to obtain the process-level singleton.
"""

from __future__ import annotations

import time
from collections import deque
from threading import Lock
from typing import Protocol


class RateLimiter(Protocol):
    def allow(self, key: str, *, limit: int, window_seconds: int) -> bool: ...


class InMemoryRateLimiter:
    """Sliding-window counter per `key`. Thread-safe; not multi-process."""

    def __init__(self) -> None:
        self._buckets: dict[str, deque[float]] = {}
        self._lock = Lock()

    def allow(self, key: str, *, limit: int, window_seconds: int) -> bool:
        now = time.monotonic()
        cutoff = now - window_seconds
        with self._lock:
            bucket = self._buckets.setdefault(key, deque())
            while bucket and bucket[0] < cutoff:
                bucket.popleft()
            if len(bucket) >= limit:
                return False
            bucket.append(now)
            return True


class RedisRateLimiter:
    """Sliding-window counter backed by Redis sorted sets.

    Safe for multi-worker / multi-process deployments.  Requires ``redis-py``
    (``redis[hiredis]`` recommended) installed in the environment.
    """

    def __init__(self, redis_url: str) -> None:
        import redis  # type: ignore[import-untyped]

        self._client = redis.from_url(redis_url, decode_responses=True)

    def allow(self, key: str, *, limit: int, window_seconds: int) -> bool:
        now = time.time()
        cutoff = now - window_seconds
        pipe = self._client.pipeline(transaction=True)
        pipe.zremrangebyscore(key, "-inf", cutoff)
        pipe.zadd(key, {str(now): now})
        pipe.zcard(key)
        pipe.expire(key, window_seconds + 1)
        results = pipe.execute()
        count: int = results[2]
        if count > limit:
            # Undo the zadd we just performed — we exceeded the limit.
            self._client.zrem(key, str(now))
            return False
        return True


def _build_default_limiter() -> RateLimiter:
    from django.conf import settings

    redis_url: str | None = getattr(settings, "REDIS_URL", None)
    if redis_url:
        try:
            limiter = RedisRateLimiter(redis_url)
            # Perform a quick connectivity check so we fall back gracefully if
            # Redis is configured but not reachable at startup.
            limiter._client.ping()  # type: ignore[attr-defined]
            return limiter
        except Exception:
            import logging
            logging.getLogger(__name__).warning(
                "REDIS_URL is set but Redis is not reachable — "
                "falling back to InMemoryRateLimiter (not safe for multi-worker)."
            )
    return InMemoryRateLimiter()


_default: RateLimiter | None = None
_default_lock = Lock()


def get_default_limiter() -> RateLimiter:
    """Return (and lazily initialise) the process-level rate limiter singleton."""
    global _default
    if _default is None:
        with _default_lock:
            if _default is None:
                _default = _build_default_limiter()
    return _default
