"""Who may ask for a place's forecast, and how often (AV-062).

Before the app is on a public server (AV-054) an anonymous bot must not be
able to spend the shared Open-Meteo quota by asking for forecasts in a loop.
Two switches, read from the environment at start (local_dev.env or the
server's own):

* ``PLACE_WEATHER_ACCESS`` -- ``open`` (the default: this local server, one
  user) or ``signed_in`` (the beta: only a request carrying a signed-in
  user; until AV-054 brings accounts, nothing carries one, so the place
  endpoints answer 401 -- the switch is ready, the accounts are not);
* ``PLACE_WEATHER_SEARCHES_PER_MINUTE`` / ``PLACE_WEATHER_FORECASTS_PER_MINUTE``
  -- a sliding one-minute limit per client (the signed-in user, else the
  IP address); over it, 429 with Retry-After.

Answers come from the shared forecast cache whenever it can serve them, so a
popular place costs one fetch per model run, however many people ask.
"""

from __future__ import annotations

import os
import threading
import time
from collections import defaultdict, deque

ACCESS_ENVIRONMENT = "PLACE_WEATHER_ACCESS"
ACCESS_MODES = ("open", "signed_in")
DEFAULT_SEARCHES_PER_MINUTE = 30
DEFAULT_FORECASTS_PER_MINUTE = 20


def access_mode() -> str:
    value = (os.environ.get(ACCESS_ENVIRONMENT) or "open").strip().lower()
    return value if value in ACCESS_MODES else "open"


def _limit(name: str, default: int) -> int:
    try:
        return max(1, int(os.environ.get(name) or default))
    except ValueError:
        return default


class RateLimiter:
    """At most ``per_minute`` events per client in any 60 s; ``clock`` is
    injectable for tests."""

    def __init__(self, per_minute: int, *, clock=time.monotonic):
        self.per_minute, self._clock = per_minute, clock
        self._events: dict[str, deque[float]] = defaultdict(deque)
        self._lock = threading.Lock()

    def allow(self, client: str) -> float | None:
        """None when allowed (and counted); else the seconds to wait."""
        now = self._clock()
        with self._lock:
            events = self._events[client]
            while events and now - events[0] >= 60:
                events.popleft()
            if len(events) >= self.per_minute:
                return max(1.0, 60 - (now - events[0]))
            events.append(now)
            return None


class PlaceAccess:
    def __init__(self, *, clock=time.monotonic):
        self.mode = access_mode()
        self.searches = RateLimiter(
            _limit("PLACE_WEATHER_SEARCHES_PER_MINUTE", DEFAULT_SEARCHES_PER_MINUTE), clock=clock
        )
        self.forecasts = RateLimiter(
            _limit("PLACE_WEATHER_FORECASTS_PER_MINUTE", DEFAULT_FORECASTS_PER_MINUTE), clock=clock
        )

    def check(
        self, kind: str, client: str, signed_in: bool
    ) -> tuple[int, str, float | None] | None:
        """None: go ahead. Else (HTTP status, error code, retry-after s)."""
        if self.mode == "signed_in" and not signed_in:
            return 401, "SIGN_IN_REQUIRED", None
        limiter = self.searches if kind == "search" else self.forecasts
        wait = limiter.allow(client)
        if wait is not None:
            return 429, "PLACE_WEATHER_RATE_LIMITED", wait
        return None
