"""
Aggressive Caching Shield - Token Bucket Rate Limiter
Redis-backed rate limiting to stay under free tier RPC limits.

Free tier limits (per second):
  Helius:     25 RPS (free), 50 RPS (starter)
  QuickNode:  25 RPS (free)
  Alchemy:    25 RPS (free), 330 RPS (growth)
  dRPC:       15 RPS (free, shared)
  PublicNode: 10 RPS (implicit fair use)

Strategy:
  - Default bucket: 15 tokens/sec, burst of 25 (safe for all free tiers)
  - Per-method buckets for expensive calls (getProgramAccounts: 5/s)
  - Blocks requests when bucket is empty instead of queueing
  - Rate limit headers returned to callers for backpressure
  - Falls back to in-memory if Redis unavailable
"""

import asyncio
import logging
import os
import time
from dataclasses import dataclass, field

import redis.asyncio as aioredis

logger = logging.getLogger("rpc_rate_limiter")

# ── Provider Rate Limits ───────────────────────────────────────────────────


@dataclass
class ProviderLimit:
    name: str
    tokens_per_sec: float
    burst_size: int
    # Additional per-method constraints
    method_limits: dict[str, tuple[float, int]] = field(default_factory=dict)


PROVIDER_LIMITS: dict[str, ProviderLimit] = {
    "helius": ProviderLimit(
        "helius",
        20.0,
        25,
        {
            "getProgramAccounts": (5.0, 5),
            "getSignaturesForAddress": (10.0, 15),
        },
    ),
    "quicknode": ProviderLimit("quicknode", 20.0, 25),
    "alchemy": ProviderLimit(
        "alchemy",
        20.0,
        25,
        {
            "getProgramAccounts": (5.0, 5),
        },
    ),
    "drpc": ProviderLimit("drpc", 12.0, 15),
    "publicnode": ProviderLimit("publicnode", 8.0, 10),
    "anvil": ProviderLimit("anvil", 5.0, 8),
    "1rpc": ProviderLimit("1rpc", 15.0, 20),
    "llama_rpc": ProviderLimit("llama_rpc", 15.0, 20),
    "blastapi": ProviderLimit("blastapi", 15.0, 20),
    "_default": ProviderLimit("_default", 10.0, 15),
}

# Bucket prefix for Redis keys
BUCKET_PREFIX = "rmi:ratelimit:"
BURST_PREFIX = "rmi:ratelimit_burst:"

# In-memory fallback
MEM_BUCKET_CLEANUP_INTERVAL = 60  # seconds


class RpcRateLimiter:
    """Token bucket rate limiter using Redis (with in-memory fallback).

    Usage:
        limiter = RpcRateLimiter()
        allowed, wait_time = await limiter.acquire("helius", "getBalance")
        if not allowed:
            raise RateLimitExceeded(f"Try again in {wait_time:.1f}s")
    """

    def __init__(self, redis_url=None, redis_password=None):
        self._redis = None
        self._redis_url = redis_url
        self._redis_password = redis_password
        self._redis_failed = False
        self._init_lock = asyncio.Lock()
        # In-memory fallback: provider -> (tokens, last_refill_ts, burst_used)
        self._mem_buckets: dict[str, tuple[float, float, int]] = {}
        self._mem_lock = asyncio.Lock()

    async def _get_redis(self):
        if self._redis is not None:
            return self._redis
        if self._redis_failed:
            return None
        async with self._init_lock:
            if self._redis is not None:
                return self._redis
            if self._redis_failed:
                return None
            try:
                host = self._redis_url or os.getenv("REDIS_HOST", "rmi-redis")
                port = int(os.getenv("REDIS_PORT", "6379"))
                password = self._redis_password or os.getenv("REDIS_PASSWORD", "")
                url = f"redis://:{password}@{host}:{port}" if password else f"redis://{host}:{port}"
                self._redis = aioredis.from_url(url, socket_connect_timeout=2, decode_responses=False)
                await self._redis.ping()
                logger.info("RpcRateLimiter: Redis connected OK")
                return self._redis
            except Exception as e:
                logger.warning(f"RpcRateLimiter: Redis unavailable ({e}), using in-memory")
                self._redis_failed = True
                return None

    def _get_limit(self, provider: str, method: str) -> tuple[float, int]:
        """Get the effective rate limit for a provider/method combo."""
        pl = PROVIDER_LIMITS.get(provider, PROVIDER_LIMITS["_default"])
        if method in pl.method_limits:
            return pl.method_limits[method]
        return (pl.tokens_per_sec, pl.burst_size)

    async def acquire(self, provider: str, method: str = "", tokens: int = 1) -> tuple[bool, float]:
        """Try to acquire tokens. Returns (allowed, wait_seconds)."""
        rate, burst = self._get_limit(provider, method)
        redis = await self._get_redis()

        if redis:
            return await self._acquire_redis(redis, provider, method, rate, burst, tokens)
        else:
            return await self._acquire_memory(provider, method, rate, burst, tokens)

    async def _acquire_redis(self, redis, provider, method, rate, burst, tokens):
        """Redis-based token bucket using Lua script for atomicity."""
        bucket_key = f"{BUCKET_PREFIX}{provider}"
        burst_key = f"{BURST_PREFIX}{provider}"

        # Lua script for atomic token bucket check + consume
        lua = """
        local bucket_key = KEYS[1]
        local burst_key = KEYS[2]
        local rate = tonumber(ARGV[1])
        local burst = tonumber(ARGV[2])
        local tokens = tonumber(ARGV[3])
        local now = tonumber(ARGV[4])

        -- Read current state
        local tokens_val = redis.call('GET', bucket_key)
        local last_refill = redis.call('GET', burst_key)
        local current_tokens = burst

        if tokens_val and last_refill then
            current_tokens = tonumber(tokens_val)
            local elapsed = now - tonumber(last_refill)
            local refill = elapsed * rate
            current_tokens = math.min(burst, current_tokens + refill)
        end

        -- Can we consume?
        if current_tokens >= tokens then
            current_tokens = current_tokens - tokens
            redis.call('SETEX', bucket_key, 60, current_tokens)
            redis.call('SET', burst_key, now)
            return {1, 0}
        else
            -- How long until we have enough?
            local needed = tokens - current_tokens
            local wait = needed / rate
            return {0, math.ceil(wait * 100) / 100}
        end
        """

        try:
            result = await redis.eval(
                lua, 2, bucket_key, burst_key, str(rate), str(burst), str(tokens), str(time.time())
            )
            allowed = bool(result[0])
            wait = float(result[1])
            return (allowed, wait)
        except Exception as e:
            logger.debug(f"Redis rate limiter error: {e}, falling back to memory")
            return await self._acquire_memory(provider, method, rate, burst, tokens)

    async def _acquire_memory(self, provider, method, rate, burst, tokens):
        """In-memory fallback token bucket."""
        async with self._mem_lock:
            now = time.monotonic()
            bucket = self._mem_buckets.get(provider)

            if bucket:
                current, last_refill, _used = bucket
                elapsed = now - last_refill
                current = min(burst, current + elapsed * rate)
            else:
                current = burst

            if current >= tokens:
                current -= tokens
                self._mem_buckets[provider] = (current, now, 0)
                return (True, 0.0)
            else:
                needed = tokens - current
                wait = needed / rate
                return (False, wait)

    async def stats(self, provider: str | None = None) -> dict:
        """Return rate limiter stats."""
        result = {}
        providers = [provider] if provider else list(PROVIDER_LIMITS.keys())

        redis = await self._get_redis()
        for p in providers:
            pl = self._get_limit(p, "")
            if redis:
                try:
                    tokens_b = await redis.get(f"{BUCKET_PREFIX}{p}")
                    last_b = await redis.get(f"{BURST_PREFIX}{p}")
                    tokens = float(tokens_b) if tokens_b else pl[1]
                    last = float(last_b or 0)
                except Exception:
                    tokens = pl[1]
                    last = 0
            else:
                async with self._mem_lock:
                    bucket = self._mem_buckets.get(p)
                    if bucket:
                        tokens, last, _used = bucket
                    else:
                        tokens = pl[1]
                        last = 0

            result[p] = {
                "available_tokens": round(tokens, 1),
                "burst_limit": pl[1],
                "rate": pl[0],
                "seconds_since_refill": round(time.time() - last, 1) if last else 0,
            }
        return result

    async def get_bucket_state(self, provider: str) -> tuple[float, float, int]:
        """Get current state of a provider's token bucket.
        Returns (tokens, last_refill_ts, burst_used).
        """
        pl = self._get_limit(provider, "")
        redis = await self._get_redis()
        if redis:
            try:
                tokens = await redis.get(f"{BUCKET_PREFIX}{provider}")
                last = await redis.get(f"{BURST_PREFIX}{provider}")
                if tokens is not None and last is not None:
                    return (float(tokens), float(last), 0)
            except Exception:
                pass
        async with self._mem_lock:
            bucket = self._mem_buckets.get(provider)
            if bucket:
                return bucket
        return (float(pl[1]), 0.0, 0)

    def get_all_limits(self) -> dict:
        """Return all configured provider limits (read-only)."""
        return PROVIDER_LIMITS.copy()


# ── Singleton ──────────────────────────────────────────────────────────────

_limiter: RpcRateLimiter | None = None


def get_rate_limiter() -> RpcRateLimiter:
    global _limiter
    if _limiter is None:
        _limiter = RpcRateLimiter()
    return _limiter
