"""
Rate-Limit-Aware Multi-Provider System
=======================================

Tracks usage across ALL free AI providers in real-time (Redis + memory).
Smart dispatcher routes to the provider with most remaining capacity.
Embedding cache avoids redundant API calls.

Provider limits (verified June 2026):
  OpenRouter free (no credits):  50 req/day,  20 rpm
  OpenRouter free ($10+ credits): 1000 req/day, 20 rpm  ← we have credits
  NVIDIA NIM free:               1000 credits, 40 rpm    ← different keys, same account
  Local BGE:                     unlimited,    0 cost
  Hash fallback:                 unlimited,    0 cost

Auto-detects credit status and adjusts limits accordingly.
"""

import asyncio
import contextlib
import hashlib
import logging
import os
import time
from dataclasses import dataclass
from typing import Any

logger = logging.getLogger("rate.limiter")

# ──────────────────────────────────────────────────────────────
# Verified Provider Limits (June 2026)
# ──────────────────────────────────────────────────────────────


@dataclass
class ProviderLimit:
    id: str
    name: str
    model: str
    dims: int
    base_url: str
    key_env: str
    rpm: int  # Requests per minute
    rpd: int  # Requests per day
    free: bool = True
    priority: int = 100  # Higher = preferred within free tier
    timeout: float = 15.0


# Configured limits — auto-detected from credit status
PROVIDERS: list[ProviderLimit] = []


def configure_providers(has_openrouter_credits: bool = True):
    """Configure provider limits based on credit status."""
    global PROVIDERS

    # OpenRouter — different limits based on credit status
    or_rpd = 1000 if has_openrouter_credits else 50

    PROVIDERS = [
        # ── Tier 1: Google Gemini #1 (3072d, FREE, best quality) ──
        ProviderLimit(
            id="gemini_1",
            name="Google Gemini #1",
            model="gemini-embedding-001",
            dims=3072,
            base_url="https://generativelanguage.googleapis.com/v1beta/models/gemini-embedding-001:embedContent",
            key_env="GEMINI_API_KEY",
            rpm=30,
            rpd=1000,
            free=True,
            priority=115,
        ),
        # ── Tier 1b: Google Gemini #2 (3072d, separate project quota) ──
        ProviderLimit(
            id="gemini_2",
            name="Google Gemini #2",
            model="gemini-embedding-001",
            dims=3072,
            base_url="https://generativelanguage.googleapis.com/v1beta/models/gemini-embedding-001:embedContent",
            key_env="GEMINI_API_KEY_2",
            rpm=30,
            rpd=1000,
            free=True,
            priority=110,
        ),
        # ── Tier 1c: Google Gemini #3 (3072d, separate project quota) ──
        ProviderLimit(
            id="gemini_3",
            name="Google Gemini #3",
            model="gemini-embedding-001",
            dims=3072,
            base_url="https://generativelanguage.googleapis.com/v1beta/models/gemini-embedding-001:embedContent",
            key_env="GEMINI_API_KEY_3",
            rpm=30,
            rpd=1000,
            free=True,
            priority=105,
        ),
        # ── Tier 1d: Google Vertex AI (768d, Cloud credits — separate from AI Studio) ──
        ProviderLimit(
            id="vertex_ai",
            name="Google Vertex AI",
            model="text-embedding-004",
            dims=768,
            base_url="vertex",  # Special — handled by gcloud_manager
            key_env="",
            rpm=50,
            rpd=5000,
            free=True,
            priority=102,
        ),
        # ── Tier 2: Mistral Embed (1024d, 1B free tokens/month) ──
        ProviderLimit(
            id="mistral",
            name="Mistral Embed",
            model="mistral-embed",
            dims=1024,
            base_url="https://api.mistral.ai/v1/embeddings",
            key_env="MISTRAL_API_KEY",
            rpm=60,
            rpd=50000,
            free=True,
            priority=98,
        ),
        # ── Tier 3: NVIDIA via OpenRouter (2048d, FREE) ──
        ProviderLimit(
            id="nv_openrouter",
            name="NVIDIA (OpenRouter)",
            model="nvidia/llama-nemotron-embed-vl-1b-v2:free",
            dims=2048,
            base_url="https://openrouter.ai/api/v1/embeddings",
            key_env="OPENROUTER_API_KEY",
            rpm=20,
            rpd=or_rpd,
            free=True,
            priority=100,
        ),
        # ── Tier 2: NVIDIA Direct (separate quota from OpenRouter) ──
        ProviderLimit(
            id="nv_direct",
            name="NVIDIA Direct",
            model="nvidia/llama-nemotron-embed-vl-1b-v2",
            dims=2048,
            base_url="https://integrate.api.nvidia.com/v1/embeddings",
            key_env="NVIDIA_API_KEY",
            rpm=40,
            rpd=1000,
            free=True,
            priority=95,
        ),
        # ── Tier 3: BGE-M3 via OpenRouter (paid, minimized) ──
        ProviderLimit(
            id="bge_m3",
            name="BGE-M3 (OpenRouter)",
            model="BAAI/bge-m3",
            dims=1024,
            base_url="https://openrouter.ai/api/v1/embeddings",
            key_env="OPENROUTER_API_KEY",
            rpm=10,
            rpd=100,
            free=False,
            priority=20,
        ),
        # ── Tier 4: Local BGE-small (CPU, always available) ──
        ProviderLimit(
            id="local_bge",
            name="Local BGE-small",
            model="local/bge-small-en-v1.5",
            dims=384,
            base_url="",
            key_env="",
            rpm=99999,
            rpd=99999,
            free=True,
            priority=10,
        ),
        # ── Tier 5: Hash fallback (zero cost, deterministic) ──
        ProviderLimit(
            id="hash",
            name="Hash Fallback",
            model="hash-sha256",
            dims=384,
            base_url="",
            key_env="",
            rpm=99999,
            rpd=99999,
            free=True,
            priority=0,
        ),
    ]


# Default: assume credits
configure_providers(has_openrouter_credits=True)


# ──────────────────────────────────────────────────────────────
# Redis-Backed Rate Tracker
# ──────────────────────────────────────────────────────────────


class RateTracker:
    """Tracks per-provider usage in Redis + memory. Survives restarts."""

    def __init__(self):
        self._redis = None
        self._memory: dict[str, dict[str, int]] = {}  # provider_id → {minute, day, total}
        self._last_flush: dict[str, float] = {}

    async def _connect(self):
        if self._redis:
            return
        try:
            import redis.asyncio as aioredis

            self._redis = aioredis.Redis(
                host=os.getenv("REDIS_HOST", "rmi-redis"),
                port=int(os.getenv("REDIS_PORT", "6379")),
                password=os.getenv("REDIS_PASSWORD", ""),
                db=int(os.getenv("REDIS_DB", "0")),
                socket_connect_timeout=2,
                decode_responses=True,
            )
            await self._redis.ping()
        except Exception as e:
            logger.warning(f"RateTracker Redis unavailable: {e} — memory-only mode")
            self._redis = None

    async def _get_counts(self, provider_id: str) -> dict[str, int]:
        """Get current usage counts for a provider."""
        today = time.strftime("%Y-%m-%d")
        minute_key = f"rmi:rate:{provider_id}:{today}:{int(time.time() // 60)}"

        if self._redis:
            try:
                day = await self._redis.get(f"rmi:rate:{provider_id}:{today}")
                minute = await self._redis.get(minute_key)
                total = await self._redis.get(f"rmi:rate:{provider_id}:total")
                return {
                    "day": int(day or 0),
                    "minute": int(minute or 0),
                    "total": int(total or 0),
                }
            except Exception:
                pass

        # Fallback to memory
        return self._memory.get(provider_id, {"day": 0, "minute": 0, "total": 0})

    async def _incr(self, provider_id: str):
        """Increment usage counters."""
        today = time.strftime("%Y-%m-%d")
        minute_key = f"rmi:rate:{provider_id}:{today}:{int(time.time() // 60)}"

        if self._redis:
            try:
                pipe = self._redis.pipeline()
                pipe.incr(f"rmi:rate:{provider_id}:{today}")
                pipe.expire(f"rmi:rate:{provider_id}:{today}", 86400)
                pipe.incr(minute_key)
                pipe.expire(minute_key, 120)
                pipe.incr(f"rmi:rate:{provider_id}:total")
                await pipe.execute()
                return
            except Exception:
                pass

        # Memory fallback
        mem = self._memory.setdefault(provider_id, {"day": 0, "minute": 0, "total": 0})
        mem["day"] += 1
        mem["minute"] += 1
        mem["total"] += 1

    async def can_call(self, provider: ProviderLimit) -> bool:
        """Check if we can call this provider without hitting limits."""
        counts = await self._get_counts(provider.id)

        # Use 90% of limit as safety margin
        day_margin = int(provider.rpd * 0.9)
        minute_margin = int(provider.rpm * 0.9)

        if counts["day"] >= day_margin:
            logger.debug(f"{provider.name}: daily limit approached ({counts['day']}/{provider.rpd})")
            return False
        if counts["minute"] >= minute_margin:
            logger.debug(f"{provider.name}: RPM limit approached ({counts['minute']}/{provider.rpm})")
            return False

        return True

    async def record(self, provider: ProviderLimit):
        """Record a successful call."""
        await self._incr(provider.id)

    async def remaining(self, provider: ProviderLimit) -> dict[str, int]:
        """Get remaining capacity."""
        counts = await self._get_counts(provider.id)
        return {
            "day_remaining": max(0, provider.rpd - counts["day"]),
            "minute_remaining": max(0, provider.rpm - counts["minute"]),
            "total_used": counts["total"],
        }

    async def stats(self) -> dict[str, Any]:
        """Get usage stats for all providers."""
        result = {"providers": [], "timestamp": time.time()}
        for p in PROVIDERS:
            counts = await self._get_counts(p.id)
            result["providers"].append(
                {
                    "id": p.id,
                    "name": p.name,
                    "free": p.free,
                    "day_used": counts["day"],
                    "day_limit": p.rpd,
                    "minute_used": counts["minute"],
                    "minute_limit": p.rpm,
                    "total": counts["total"],
                    "available": await self.can_call(p),
                    "day_pct": round(counts["day"] / max(p.rpd, 1) * 100, 1),
                }
            )
        return result


# ──────────────────────────────────────────────────────────────
# Embedding Cache
# ──────────────────────────────────────────────────────────────


class EmbeddingCache:
    """Caches embedding results to avoid redundant API calls.

    Uses content hash → embedding mapping. Same text always returns
    same embedding from the same provider. Zero API cost for repeats.
    """

    def __init__(self, max_size: int = 10000):
        self._cache: dict[str, tuple[list[float], str]] = {}  # hash → (vector, provider)
        self._max = max_size
        self._hits = 0
        self._misses = 0

    def key(self, text: str) -> str:
        return hashlib.sha256(text.encode()).hexdigest()[:32]

    def get(self, text: str) -> tuple[list[float], str] | None:
        k = self.key(text)
        if k in self._cache:
            self._hits += 1
            return self._cache[k]
        self._misses += 1
        return None

    def set(self, text: str, vector: list[float], provider: str):
        if len(self._cache) >= self._max:
            # Evict oldest 10%
            keys = list(self._cache.keys())[: self._max // 10]
            for k in keys:
                del self._cache[k]
        self._cache[self.key(text)] = (vector, provider)
        # Persist to Redis asynchronously (best-effort)
        self._save_to_redis()

    def _save_to_redis(self):
        """Persist cache to Redis so it survives restarts."""
        try:
            import json

            import redis

            r = redis.Redis(
                host="localhost",
                port=6379,
                db=3,
                password=os.environ.get("REDIS_PASSWORD", ""),
                socket_connect_timeout=2,
                socket_timeout=2,
            )
            # Save 500 most recent entries with full vectors
            entries = {k: [v[0], v[1]] for k, v in list(self._cache.items())[-500:]}
            r.setex("embed_cache:data", 86400, json.dumps(entries))
        except Exception:
            pass

    def load_from_redis(self):
        """Restore cache from Redis on startup."""
        try:
            import json

            import redis

            r = redis.Redis(
                host="localhost",
                port=6379,
                db=3,
                password=os.environ.get("REDIS_PASSWORD", ""),
                socket_connect_timeout=2,
                socket_timeout=2,
            )
            raw = r.get("embed_cache:data")
            if raw:
                entries = json.loads(raw)
                for k, (vec, prov) in entries.items():
                    self._cache[k] = (vec, prov)
                logger.info(f"Loaded {len(entries)} cache entries from Redis")
        except Exception as e:
            logger.info(f"Cache restore skipped: {e}")

    def stats(self) -> dict:
        total = self._hits + self._misses
        return {
            "size": len(self._cache),
            "hits": self._hits,
            "misses": self._misses,
            "hit_rate": round(self._hits / max(total, 1) * 100, 1),
        }


# ──────────────────────────────────────────────────────────────
# Smart Embedding Dispatcher
# ──────────────────────────────────────────────────────────────


class EmbeddingDispatcher:
    """Routes embedding requests to the best provider with available capacity."""

    def __init__(self):
        self.tracker = RateTracker()
        self.cache = EmbeddingCache()
        self._local_model = None
        self._providers = PROVIDERS

    async def init(self):
        await self.tracker._connect()
        # Restore cache from Redis if available
        self.cache.load_from_redis()
        try:
            import sentence_transformers

            self._local_model = sentence_transformers.SentenceTransformer("BAAI/bge-small-en-v1.5", device="cpu")
            logger.info("Local BGE-small loaded")
        except Exception as e:
            logger.warning(f"Local model: {e}")

    async def embed(self, texts: list[str], task: str = "default") -> tuple[list[list[float]], str]:
        """Smart embed with quality-tier routing.

        task: 'scam_detection', 'user_search', 'news_ingestion', 'bulk_ingestion', etc.
        Routes to appropriate provider tier — saves premium credits for critical tasks.
        """
        from app.embed_tiers import get_allowed_providers, get_min_dims, get_tier

        get_tier(task)
        allowed = get_allowed_providers(task)
        min_dims = get_min_dims(task)
        if not texts:
            return [], "none"

        # Check cache first
        cached = []
        to_embed = []
        for t in texts:
            c = self.cache.get(t)
            if c:
                cached.append(c[0])
            else:
                to_embed.append(t)

        if not to_embed:
            return cached, "cache"

        # Pick best available provider — filtered by tier
        vectors = None
        provider_name = "none"

        for p in sorted(self._providers, key=lambda x: -x.priority):
            # Tier filter: only use providers allowed for this task
            if allowed and p.id not in allowed:
                if p.id not in ("local_bge", "hash"):  # Local/hash always available as fallback
                    continue
            # Dimension filter
            if p.dims < min_dims and p.id not in ("local_bge", "hash"):
                continue

            if not await self.tracker.can_call(p):
                continue

            # ── Local ──
            if p.id == "local_bge":
                try:
                    vecs = await self._embed_local(to_embed)
                    if vecs:
                        await self.tracker.record(p)
                        provider_name = p.name
                        vectors = vecs
                        break
                except Exception as e:
                    logger.warning(f"Local: {e}")
                continue

            # ── Hash ──
            if p.id == "hash":
                vecs = self._embed_hash(to_embed)
                await self.tracker.record(p)
                provider_name = p.name
                vectors = vecs
                break

            # ── Vertex AI (uses gcloud_manager, not direct API) ──
            if p.id == "vertex_ai":
                try:
                    from app.gcloud_manager import get_gcloud

                    gcloud = get_gcloud()
                    vecs = await gcloud.embed_vertex(to_embed)
                    if vecs:
                        await self.tracker.record(p)
                        provider_name = p.name
                        vectors = vecs
                        break
                except Exception as e:
                    logger.warning(f"Vertex AI: {e}")
                continue

            # ── API providers ──
            key = os.environ.get(p.key_env, "") or os.environ.get(p.key_env.replace("_KEY", "_API_KEY"), "")
            if not key or len(key) < 10:
                continue

            try:
                vecs = await self._embed_api(p, key, to_embed)
                if vecs:
                    await self.tracker.record(p)
                    provider_name = p.name
                    vectors = vecs
                    break
            except Exception as e:
                logger.warning(f"{p.name}: {e}")
                continue

        # Absolute last resort
        if vectors is None:
            vectors = self._embed_hash(to_embed)
            provider_name = "hash_emergency"

        # Cache results
        for text, vec in zip(to_embed, texts, strict=False):
            self.cache.set(text, vec, provider_name)

        # Stream usage to BigQuery (best-effort, non-blocking)
        with contextlib.suppress(Exception):
            asyncio.create_task(self._log_to_bigquery(provider_name, task, len(texts)))

        # Merge with cached
        result = []
        ci = 0
        for t in texts:
            c = self.cache.get(t)
            if c and ci < len(cached):
                result.append(cached[ci])
                ci += 1
            elif vectors and len(result) - ci < len(vectors):
                result.append(vectors[len(result) - ci])
            else:
                result.append([])

        return result, provider_name

    async def _log_to_bigquery(self, provider: str, task: str, count: int):
        """Log embedding usage to BigQuery (best-effort, fire-and-forget)."""
        try:
            from app.bigquery_pipeline import stream_embedding_usage
            from app.rate_limiter import PROVIDERS

            dims = next((p.dims for p in PROVIDERS if p.name == provider), 0)
            await stream_embedding_usage(provider, task, dims, count)
        except Exception:
            pass

    async def _embed_api(self, p: ProviderLimit, key: str, texts: list[str]) -> list[list[float]] | None:
        import httpx

        # Gemini uses different auth (key in query param) and different payload format
        if p.id.startswith("gemini"):
            vectors = []
            async with httpx.AsyncClient(timeout=p.timeout) as c:
                for text in texts:
                    r = await c.post(
                        f"{p.base_url}?key={key}",
                        json={"content": {"parts": [{"text": text}]}},
                    )
                    if r.status_code == 200:
                        data = r.json()
                        vec = data.get("embedding", {}).get("values", [])
                        if vec:
                            vectors.append(vec)
                    elif r.status_code == 429:
                        logger.warning(f"{p.name} rate limited (429)")
                        return None
                    else:
                        logger.warning(f"{p.name} HTTP {r.status_code}")
                        return None
            return vectors if vectors else None

        # OpenRouter / NVIDIA format
        headers = {"Authorization": f"Bearer {key}", "Content-Type": "application/json"}
        if "openrouter" in p.base_url:
            headers["HTTP-Referer"] = "https://rugmunch.io"
            headers["X-Title"] = "RMI SENTINEL"

        payload = {"model": p.model, "input": texts}

        async with httpx.AsyncClient(timeout=p.timeout) as c:
            r = await c.post(p.base_url, json=payload, headers=headers)
            if r.status_code == 429:
                logger.warning(f"{p.name} rate limited (429)")
                return None
            if r.status_code != 200:
                logger.warning(f"{p.name} HTTP {r.status_code}: {r.text[:150]}")
                return None
            data = r.json()
            return [e.get("embedding", []) for e in data.get("data", [])]

    async def _embed_local(self, texts: list[str]) -> list[list[float]] | None:
        if not self._local_model:
            try:
                import sentence_transformers

                self._local_model = sentence_transformers.SentenceTransformer("BAAI/bge-small-en-v1.5", device="cpu")
            except Exception:
                return None
        loop = asyncio.get_event_loop()
        vecs = await loop.run_in_executor(None, lambda: self._local_model.encode(texts, normalize_embeddings=True))
        return vecs.tolist() if hasattr(vecs, "tolist") else [[float(x) for x in v] for v in vecs]

    def _embed_hash(self, texts: list[str]) -> list[list[float]]:
        import numpy as np

        vecs = []
        for t in texts:
            h = hashlib.sha256(t.encode()).digest() + hashlib.md5(t.encode()).digest()
            v = [(int.from_bytes(h[i : i + 4], "big") / 2**32) * 2 - 1 for i in range(0, min(len(h), 1536), 4)]
            while len(v) < 384:
                v.append(0.0)
            v = v[:384]
            norm = np.sqrt(sum(x * x for x in v))
            if norm > 0:
                v = [x / norm for x in v]
            vecs.append(v)
        return vecs

    async def stats(self) -> dict:
        return {
            "rate_limits": await self.tracker.stats(),
            "cache": self.cache.stats(),
            "providers_configured": len(self._providers),
        }

    def health(self) -> dict:
        """Quick health check (no async needed)."""
        available = sum(1 for p in self._providers if p.free)
        return {
            "providers": len(self._providers),
            "free_providers": available,
            "local_model_loaded": self._local_model is not None,
        }


# ── Singleton ──
_dispatcher: EmbeddingDispatcher | None = None


async def get_dispatcher() -> EmbeddingDispatcher:
    global _dispatcher
    if _dispatcher is None:
        _dispatcher = EmbeddingDispatcher()
        await _dispatcher.init()
    return _dispatcher


async def smart_embed(texts: list[str]) -> tuple[list[list[float]], str]:
    return await (await get_dispatcher()).embed(texts)
