"""Cross-process token-bucket rate limiter for the Claude Code compat suite.

The compat matrix runs 75 live `claude` CLI invocations (25 cells × 3
Claude tiers per cell). When pytest-xdist fans these out across worker
processes, each worker would maintain its own in-memory rate limiter
and the *aggregate* request rate hitting any one upstream provider
would be `workers × per-worker rate` — exactly the situation that
trips Anthropic / Azure / Bedrock / Vertex 429s in the middle of a
matrix run and silently flips green cells red.

The fix is a **shared, cross-process** token bucket per provider,
backed by a small JSON state file and an OS-level `flock`. Each
`run_claude` invocation acquires one token (sleeping if the bucket is
empty) before launching the CLI; refills happen lazily based on wall
time, so workers can be killed and restarted without losing or
double-spending budget.

Configuration is driven entirely by environment variables so a
binary-search workflow can shift per-provider rates without code
edits:

    LITELLM_COMPAT_RATE_ANTHROPIC          (req/s, default 5.0)
    LITELLM_COMPAT_RATE_AZURE              (req/s, default 5.0)
    LITELLM_COMPAT_RATE_VERTEX_AI          (req/s, default 5.0)
    LITELLM_COMPAT_RATE_BEDROCK_CONVERSE   (req/s, default 5.0)
    LITELLM_COMPAT_RATE_BEDROCK_INVOKE     (req/s, default 5.0)
    LITELLM_COMPAT_RATE_OPENAI             (req/s, default 5.0)
    LITELLM_COMPAT_RATE_AZURE_OPENAI       (req/s, default 5.0)
    LITELLM_COMPAT_RATE_BEDROCK_MANTLE     (req/s, default 5.0)
    LITELLM_COMPAT_RATE_BURST              (per-bucket burst override;
                                            default = rate)
    LITELLM_COMPAT_RATE_STATE_DIR          (state file directory;
                                            default = $TMPDIR/litellm-claude-compat-ratelimit)

A rate of 0 (or any non-positive value) disables throttling for that
provider — useful when you trust the upstream to handle the burst or
when running unit-test-shaped workloads that never actually hit the
network.

The provider id is inferred from the model id by `infer_provider`,
mirroring the matrix's column layout (`anthropic`, `azure`,
`vertex_ai`, `bedrock_converse`, `bedrock_invoke`, `openai`,
`azure_openai`, `bedrock_mantle`). The `vertex_ai_gpt` matrix column
has no bucket: its cells are static not_applicable stubs that never
reach the network.
"""

from __future__ import annotations

import contextlib
import json
import math
import os
import tempfile
import time
from dataclasses import dataclass
from pathlib import Path
from typing import Dict, Iterator, Mapping, Optional

# `fcntl` is POSIX-only; the suite is Linux/macOS only, so we don't
# attempt a Windows fallback. Importing at module load fails fast on
# the (currently non-existent) Windows runner so we don't silently
# degrade to no-locking behavior.
import fcntl

PROVIDER_ANTHROPIC = "anthropic"
PROVIDER_AZURE = "azure"
PROVIDER_VERTEX_AI = "vertex_ai"
PROVIDER_BEDROCK_CONVERSE = "bedrock_converse"
PROVIDER_BEDROCK_INVOKE = "bedrock_invoke"
PROVIDER_OPENAI = "openai"
PROVIDER_AZURE_OPENAI = "azure_openai"
PROVIDER_BEDROCK_MANTLE = "bedrock_mantle"

ALL_PROVIDERS = (
    PROVIDER_ANTHROPIC,
    PROVIDER_AZURE,
    PROVIDER_VERTEX_AI,
    PROVIDER_BEDROCK_CONVERSE,
    PROVIDER_BEDROCK_INVOKE,
    PROVIDER_OPENAI,
    PROVIDER_AZURE_OPENAI,
    PROVIDER_BEDROCK_MANTLE,
)

DEFAULT_RATE = 5.0  # req/s per provider, conservative starting point
RATE_ENV_PREFIX = "LITELLM_COMPAT_RATE_"
BURST_ENV = "LITELLM_COMPAT_RATE_BURST"
STATE_DIR_ENV = "LITELLM_COMPAT_RATE_STATE_DIR"
DEFAULT_STATE_DIR_NAME = "litellm-claude-compat-ratelimit"


def infer_provider(model: str) -> str:
    """Map a model alias to its compat-matrix provider id.

    The matrix column layout is fixed; aliases registered in the proxy
    encode the provider via a suffix (`-bedrock-converse`,
    `-bedrock-invoke`, `-azure`, `-vertex`, `-openai`, `-azure-openai`,
    `-bedrock-mantle`) or its absence (Anthropic). Order matters:
    `-azure-openai` also ends with `-openai`, and the bedrock suffixes
    all contain `bedrock`, so the more-specific suffixes are tested
    first.
    """
    if not model:
        raise ValueError("model must be a non-empty string")
    lower = model.lower()
    if lower.endswith("-azure-openai"):
        return PROVIDER_AZURE_OPENAI
    if lower.endswith("-openai"):
        return PROVIDER_OPENAI
    if lower.endswith("-bedrock-mantle"):
        return PROVIDER_BEDROCK_MANTLE
    if lower.endswith("-bedrock-converse"):
        return PROVIDER_BEDROCK_CONVERSE
    if lower.endswith("-bedrock-invoke"):
        return PROVIDER_BEDROCK_INVOKE
    if lower.endswith("-azure"):
        return PROVIDER_AZURE
    if lower.endswith("-vertex"):
        return PROVIDER_VERTEX_AI
    return PROVIDER_ANTHROPIC


@dataclass(frozen=True)
class ProviderConfig:
    """Static config snapshot for one provider's bucket.

    Captured up front (rather than re-read per acquire) so the limiter's
    behavior in a single process is stable even if env vars are mutated
    mid-run. A fresh `RateLimiter` picks up env changes on construction.
    """

    rate_per_sec: float
    burst: float

    @property
    def enabled(self) -> bool:
        return self.rate_per_sec > 0 and self.burst > 0


def load_config(
    env: Optional[Mapping[str, str]] = None,
) -> Dict[str, ProviderConfig]:
    """Build a {provider: ProviderConfig} from env, applying defaults.

    Parsing failures fall back to the default rate rather than
    crashing the test session — a typo in `LITELLM_COMPAT_RATE_AZURE`
    should not silently disable throttling, but it also shouldn't
    abort 75 live tests with a `ValueError` ten minutes in.
    """
    src = env if env is not None else os.environ

    def _as_float(value: Optional[str], default: float) -> float:
        if value is None or value == "":
            return default
        try:
            return float(value)
        except (TypeError, ValueError):
            return default

    burst_override = _as_float(src.get(BURST_ENV), -1.0)

    out: Dict[str, ProviderConfig] = {}
    for provider in ALL_PROVIDERS:
        env_key = RATE_ENV_PREFIX + provider.upper()
        rate = _as_float(src.get(env_key), DEFAULT_RATE)
        burst = burst_override if burst_override > 0 else max(rate, 1.0)
        out[provider] = ProviderConfig(rate_per_sec=rate, burst=burst)
    return out


def _state_dir(env: Optional[Mapping[str, str]] = None) -> Path:
    """Resolve the directory holding per-provider state files.

    A user-supplied `LITELLM_COMPAT_RATE_STATE_DIR` wins for tests and
    container environments where `$TMPDIR` may be ephemeral or shared
    in surprising ways. The directory is created lazily with
    `parents=True, exist_ok=True` so first-run setup needs no
    fixture wiring.
    """
    src = env if env is not None else os.environ
    explicit = src.get(STATE_DIR_ENV)
    if explicit:
        return Path(explicit)
    return Path(tempfile.gettempdir()) / DEFAULT_STATE_DIR_NAME


class RateLimiter:
    """Cross-process token bucket per provider.

    Each `acquire(provider)` call:
      1. Opens (creating if needed) `<state_dir>/<provider>.json`.
      2. Holds an exclusive `flock` while reading + updating the
         {tokens, last_refill} state.
      3. Refills tokens based on `now - last_refill`, capped at burst.
      4. If tokens >= 1, subtracts one and returns immediately.
      5. Otherwise, computes the wall-time delay needed to earn a
         single token at the configured rate, releases the lock, and
         sleeps. After sleeping it retries — staying under the lock
         while sleeping would serialize all workers behind whichever
         one held it longest.

    This bounds the *aggregate* req/s seen by the upstream, regardless
    of how many xdist workers, threads, or processes are concurrently
    running tests against the same provider.

    A `_clock` / `_sleep` injection seam keeps the unit tests fast and
    deterministic; production callers should never override either.
    """

    def __init__(
        self,
        config: Optional[Mapping[str, ProviderConfig]] = None,
        state_dir: Optional[Path] = None,
        clock: Optional[callable] = None,
        sleep: Optional[callable] = None,
    ) -> None:
        self._config = dict(config) if config is not None else load_config()
        self._state_dir = Path(state_dir) if state_dir is not None else _state_dir()
        self._clock = clock or time.monotonic
        self._sleep = sleep or time.sleep
        # `_state_dir.mkdir` once on construction is fine; concurrent
        # workers all racing to create the same directory is benign.
        self._state_dir.mkdir(parents=True, exist_ok=True)

    def acquire(self, provider: str) -> float:
        """Block until one token is available for `provider`.

        Returns the cumulative wall-time spent waiting (0.0 when the
        bucket had budget and we returned immediately). Callers can
        log this to attribute slow cells to throttling vs. upstream
        latency — same role `DriverResult.duration_ms` plays for the
        actual CLI invocation.
        """
        cfg = self._config.get(provider)
        if cfg is None or not cfg.enabled:
            return 0.0

        path = self._state_path(provider)
        total_waited = 0.0
        while True:
            now = self._clock()
            sleep_for = self._try_consume(path, cfg, now)
            if sleep_for <= 0:
                return total_waited
            self._sleep(sleep_for)
            total_waited += sleep_for

    def _state_path(self, provider: str) -> Path:
        return self._state_dir / f"{provider}.json"

    def _try_consume(self, path: Path, cfg: ProviderConfig, now: float) -> float:
        """Atomically refill and try to take one token.

        Returns 0.0 if a token was consumed, or a positive sleep
        duration (seconds) if the caller must wait before retrying.

        We hold an exclusive `flock` only across the read-modify-write
        of the JSON state — never across a `sleep` — so workers don't
        serialize while one of them is parked.
        """
        # `os.open` + `os.O_CREAT | os.O_RDWR` gives us a fd we can
        # both lock and read/write through. Opening with `"a+"` then
        # seeking is equivalent but uglier; this version is closer to
        # the canonical flock recipe.
        fd = os.open(path, os.O_CREAT | os.O_RDWR, 0o600)
        try:
            fcntl.flock(fd, fcntl.LOCK_EX)
            try:
                tokens, last_refill = self._read_state(fd, cfg, now)
                tokens, last_refill = self._refill(tokens, last_refill, now, cfg)
                if tokens >= 1.0:
                    tokens -= 1.0
                    self._write_state(fd, tokens, last_refill)
                    return 0.0
                # Not enough budget. Persist the refilled state so a
                # subsequent caller doesn't have to redo the math, then
                # release the lock and tell the caller how long to
                # sleep before retrying.
                self._write_state(fd, tokens, last_refill)
                deficit = 1.0 - tokens
                # `deficit / rate` seconds will earn exactly enough
                # for one token. Add a tiny safety margin so we don't
                # wake up nanoseconds early and spin.
                return (deficit / cfg.rate_per_sec) + 1e-3
            finally:
                fcntl.flock(fd, fcntl.LOCK_UN)
        finally:
            os.close(fd)

    @staticmethod
    def _read_state(fd: int, cfg: ProviderConfig, now: float) -> tuple:
        """Read {tokens, last_refill} from `fd`, defaulting to a full
        bucket on a missing/empty/corrupt file.

        New files start full so the very first request never waits;
        corrupt files are treated like new files because the
        alternative — refusing to run — is worse than briefly
        over-spending one bucket's worth of budget.
        """
        os.lseek(fd, 0, os.SEEK_SET)
        raw = os.read(fd, 4096).decode("utf-8")
        if not raw.strip():
            return cfg.burst, now
        try:
            obj = json.loads(raw)
            tokens = float(obj.get("tokens", cfg.burst))
            last_refill = float(obj.get("last_refill", now))
            return tokens, last_refill
        except (ValueError, TypeError):
            return cfg.burst, now

    @staticmethod
    def _refill(
        tokens: float, last_refill: float, now: float, cfg: ProviderConfig
    ) -> tuple:
        """Apply elapsed time to the bucket, capped at burst.

        Negative elapsed (clock went backward, e.g. across host
        sleep/resume or a manually-tweaked monotonic mock) is clamped
        to zero so we never *remove* tokens.
        """
        elapsed = max(0.0, now - last_refill)
        tokens = min(cfg.burst, tokens + elapsed * cfg.rate_per_sec)
        return tokens, now

    @staticmethod
    def _write_state(fd: int, tokens: float, last_refill: float) -> None:
        payload = json.dumps({"tokens": tokens, "last_refill": last_refill}).encode(
            "utf-8"
        )
        os.lseek(fd, 0, os.SEEK_SET)
        os.ftruncate(fd, 0)
        os.write(fd, payload)


# A single process-wide instance is all we need: provider-keyed state
# is stored in files, so multiple `RateLimiter` instances would just
# duplicate the in-process bookkeeping. We expose a getter rather than
# the instance directly so unit tests can install a custom limiter
# scoped to a tmp directory without monkeypatching globals.
_default: Optional[RateLimiter] = None


def get_default_limiter() -> RateLimiter:
    global _default
    if _default is None:
        _default = RateLimiter()
    return _default


def reset_default_limiter() -> None:
    """Drop the cached default limiter; the next `get_default_limiter`
    call rebuilds it from the current environment.

    Useful between unit tests that patch env vars: without this they'd
    keep reading the stale config snapshot from the first call.
    """
    global _default
    _default = None


@contextlib.contextmanager
def use_limiter(limiter: RateLimiter) -> Iterator[RateLimiter]:
    """Temporarily install `limiter` as the process default.

    The driver's `run_claude` calls `get_default_limiter()`; tests that
    want a controlled tmp-dir-backed limiter use this contextmanager
    to swap one in without touching env vars or the on-disk default
    state.
    """
    global _default
    previous = _default
    _default = limiter
    try:
        yield limiter
    finally:
        _default = previous
