"""Genosis SDK client.

SDK duties (4 things only):
1. Hash — SHA-256 each system block's text
2. Optimize — fetch manifest, apply cache_control breakpoints to matching blocks
3. Report — send blocks (hash, tokens, position, cached) + raw LLM usage. NO cost math.
4. Manifest refresh — trigger optimization, fetch fresh manifest on interval or first call

All cost calculation, savings math, pricing lookups, and billing happen server-side only.
The SDK never calculates or sends cost/savings. The server uses the LLM provider's actual
usage tokens (from the response) for all cost math.
"""

from __future__ import annotations

import hashlib
import json
import logging
import os
import platform
import re
import threading
import time
import uuid
from abc import ABC, abstractmethod
from collections import OrderedDict
from typing import Any, Callable, Optional, TypeVar

from .errors import GenosisError

# SDK logger — customers can configure this to see SDK errors in their own logs.
# Example: logging.getLogger("genosis").setLevel(logging.DEBUG)
logger = logging.getLogger("genosis")
from .http import HttpClient
from .types import (
    Account,
    AccountUsage,
    ApiKey,
    BlockFrequency,
    CacheManifest,
    CallResult,
    CostBreakdownEntry,
    CreatedApiKey,
    ManifestHistoryEntry,
    ManifestResponse,
    ManifestsListEntry,
    OptimizationResult,
    OptimizationResultsResponse,
    OptimizationStatusResponse,
    TelemetrySummary,
    _TelemetryBlock,
    _TelemetryEvent,
    _TelemetryUsage,
)

T = TypeVar("T")

DEFAULT_BASE_URL = "https://api.usegenosis.ai"

# After receiving an empty manifest, retry every 60s instead of the full refresh
# interval. Stops fast-retrying after FAST_RETRY_MAX consecutive empty responses
# (~10 minutes), then falls back to the normal interval.
FAST_RETRY_INTERVAL = 60.0  # seconds
FAST_RETRY_MAX = 10


# ── Memo Storage Interface ───────────────────────────────────────────────────
# Default is in-process LRU. Customers can plug in their own backend (Redis,
# SQLite, etc.) by implementing MemoStorage and passing it to the constructor.

class MemoStorage(ABC):
    """Interface for pluggable memoization storage."""

    @abstractmethod
    def get(self, fingerprint: str) -> Optional[Any]:
        """Return cached response or None if missing/expired."""
        ...

    @abstractmethod
    def set(self, fingerprint: str, response: Any, ttl_seconds: int) -> None:
        """Store a response with a TTL."""
        ...


class _MemoEntry:
    def __init__(self, response: Any, expires_at: float) -> None:
        self.response = response
        self.expires_at = expires_at


class InMemoryMemoStorage(MemoStorage):
    """Default in-process LRU memo cache. Content never leaves the customer's environment."""

    def __init__(self, max_entries: int) -> None:
        self._max = max_entries
        self._map: OrderedDict[str, _MemoEntry] = OrderedDict()

    def get(self, fingerprint: str) -> Optional[Any]:
        entry = self._map.get(fingerprint)
        if not entry:
            return None
        if time.time() > entry.expires_at:
            del self._map[fingerprint]
            return None
        self._map.move_to_end(fingerprint)
        return entry.response

    def set(self, fingerprint: str, response: Any, ttl_seconds: int) -> None:
        if fingerprint in self._map:
            del self._map[fingerprint]
        elif len(self._map) >= self._max:
            self._map.popitem(last=False)
        self._map[fingerprint] = _MemoEntry(response, time.time() + ttl_seconds)

    def __len__(self) -> int:
        return len(self._map)

    def __bool__(self) -> bool:
        # Storage exists even when empty — don't let Python treat an empty cache as falsy
        return True


# ── Sub-clients ───────────────────────────────────────────────────────────────

class AccountClient:
    """Manage your Genosis account, usage, and API keys."""

    def __init__(self, http: HttpClient) -> None:
        self._http = http

    def get(self) -> Account:
        """Get account details (name, tier, created date)."""
        return self._http.get("/v1/account").data

    def get_usage(self) -> AccountUsage:
        """Get usage summary for the current billing period."""
        return self._http.get("/v1/account/usage").data

    def list_api_keys(self) -> list[ApiKey]:
        """List all API keys for your account."""
        return self._http.get("/v1/account/api-keys").data["api_keys"]

    def create_api_key(self, name: str, scopes: Optional[list[str]] = None) -> CreatedApiKey:
        """Create a new API key with the specified name and scopes."""
        body: dict[str, Any] = {"name": name}
        if scopes is not None:
            body["scopes"] = scopes
        return self._http.post("/v1/account/api-keys", body).data

    def revoke_api_key(self, id: str) -> dict[str, bool]:
        """Revoke an API key by its ID."""
        return self._http.delete(f"/v1/account/api-keys/{id}").data


class ManifestClient:
    """Fetch cache manifests. Automatically tracks ETags for conditional requests."""

    def __init__(self, http: HttpClient) -> None:
        self._http = http
        self._etag_map: dict[str, str] = {}

    def get(
        self,
        provider: str,
        model: str,
        etag: Optional[str] = None,
    ) -> ManifestResponse:
        """Get the active manifest for a provider/model combo. Returns data=None on 304."""
        from urllib.parse import quote
        key = f"{provider}/{model}"
        stored_etag = etag or self._etag_map.get(key)
        headers: dict[str, str] = {}
        if stored_etag:
            headers["If-None-Match"] = stored_etag

        resp = self._http.get(
            f"/v1/manifest?provider={quote(provider)}&model={quote(model)}",
            headers=headers,
        )
        new_etag = resp.headers.get("etag")
        if new_etag:
            self._etag_map[key] = new_etag

        if resp.status == 304:
            return {"data": None, "etag": self._etag_map.get(key)}

        return {"data": resp.data, "etag": new_etag}

    def list_all(self) -> list[ManifestsListEntry]:
        """List all active manifests across all provider/model combos."""
        return self._http.get("/v1/manifests").data["manifests"]

    def get_history(
        self,
        provider: Optional[str] = None,
        model: Optional[str] = None,
    ) -> list[ManifestHistoryEntry]:
        """Get manifest version history, optionally filtered by provider/model."""
        params: list[str] = []
        if provider:
            params.append(f"provider={provider}")
        if model:
            params.append(f"model={model}")
        qs = ("?" + "&".join(params)) if params else ""
        return self._http.get(f"/v1/manifest/history{qs}").data["manifests"]


class OptimizationClient:
    """Trigger and monitor on-demand optimization runs."""

    def __init__(self, http: HttpClient) -> None:
        self._http = http

    def trigger(
        self,
        provider: Optional[str] = None,
        model: Optional[str] = None,
    ) -> OptimizationResult:
        """Trigger an optimization run. Refreshes cache manifests for your models."""
        body: dict[str, Any] = {}
        if provider:
            body["provider"] = provider
        if model:
            body["model"] = model
        return self._http.post("/v1/optimize", body or None).data

    def get_status(
        self,
        provider: Optional[str] = None,
        model: Optional[str] = None,
    ) -> OptimizationStatusResponse:
        """Check the status of the most recent optimization run."""
        params: list[str] = []
        if provider:
            params.append(f"provider={provider}")
        if model:
            params.append(f"model={model}")
        qs = ("?" + "&".join(params)) if params else ""
        return self._http.get(f"/v1/optimize/status{qs}").data

    def get_results(
        self,
        provider: Optional[str] = None,
        model: Optional[str] = None,
    ) -> OptimizationResultsResponse:
        """Get the results of the most recent optimization run."""
        params: list[str] = []
        if provider:
            params.append(f"provider={provider}")
        if model:
            params.append(f"model={model}")
        qs = ("?" + "&".join(params)) if params else ""
        return self._http.get(f"/v1/optimize/results{qs}").data


class TelemetryClient:
    """Query telemetry data: summaries, cost breakdowns, and block frequencies."""

    def __init__(self, http: HttpClient) -> None:
        self._http = http

    def _qs(
        self,
        days: Optional[int] = None,
        provider: Optional[str] = None,
        model: Optional[str] = None,
    ) -> str:
        params: list[str] = []
        if days is not None:
            params.append(f"days={days}")
        if provider:
            params.append(f"provider={provider}")
        if model:
            params.append(f"model={model}")
        return ("?" + "&".join(params)) if params else ""

    def get_summary(
        self,
        days: int = 30,
        provider: Optional[str] = None,
        model: Optional[str] = None,
    ) -> TelemetrySummary:
        """Get aggregated telemetry summary (total requests, costs, savings)."""
        return self._http.get(f"/v1/telemetry/summary{self._qs(days, provider, model)}").data

    def get_cost_breakdown(
        self,
        days: int = 30,
        provider: Optional[str] = None,
        model: Optional[str] = None,
    ) -> list[CostBreakdownEntry]:
        """Get daily cost breakdown (cached vs uncached vs output)."""
        return self._http.get(f"/v1/telemetry/cost{self._qs(days, provider, model)}").data["cost_breakdown"]

    def get_block_frequencies(
        self,
        days: int = 30,
        provider: Optional[str] = None,
        model: Optional[str] = None,
    ) -> dict[str, Any]:
        """Get frequency and cache hit rates for each content block."""
        return self._http.get(f"/v1/telemetry/blocks{self._qs(days, provider, model)}").data


# ── Main client ───────────────────────────────────────────────────────────────

class Genosis:
    """
    Genosis SDK client.

    One method on the hot path: ``call()``. Everything else is management.

    The SDK is a thin client with 4 duties:
    1. Hash system blocks (SHA-256)
    2. Apply manifest (cache_control breakpoints)
    3. Report telemetry (blocks + raw LLM usage — no cost math)
    4. Refresh manifest (fetch + ack + trigger optimization)

    On ANY internal error, ``call()`` invokes your function with the original
    unmodified params and reports the error to the Genosis API. Your LLM calls
    cannot be broken by Genosis.
    """

    DEFAULT_REFRESH_INTERVAL = 5 * 60  # 5 minutes in seconds

    def __init__(
        self,
        api_key: str,
        base_url: str = DEFAULT_BASE_URL,
        max_retries: int = 2,
        timeout: float = 60.0,
        manifest_refresh_interval: float = DEFAULT_REFRESH_INTERVAL,
        memoization_enabled: bool = True,
        memoization_max_entries: int = 1000,
        memo_storage: Optional[MemoStorage] = None,
        buffer_path: Optional[str] = None,
        buffer_max_size: int = 10_000,
        provider_map: Optional[dict[str, str]] = None,
        default_provider: Optional[str] = None,
    ) -> None:
        """Create a Genosis client.

        Args:
            provider_map: Map of deployment names to provider strings.
                Use for Azure OpenAI or custom deployments where the model
                name doesn't follow a recognized pattern.
                Example: ``{"my-gpt4-prod": "openai", "my-claude": "anthropic"}``
            default_provider: Fallback provider for all unrecognized model names.
                Useful when all Azure deployments in your endpoint are the same provider.
                Example: ``default_provider="openai"``
        """
        if not api_key:
            raise ValueError("api_key is required")
        if not re.match(r"^gns_(live|test)_", api_key):
            raise ValueError("Invalid API key format. Expected gns_live_* or gns_test_*")

        from .buffer import DiskBuffer
        from .worker import BackgroundWorker

        self._http = HttpClient(
            api_key=api_key,
            base_url=base_url,
            max_retries=max_retries,
            timeout=timeout,
        )

        # Disk-backed buffer for crash-safe telemetry delivery.
        # Scope by API key prefix so different apps don't share a buffer.
        if buffer_path is None:
            key_prefix = api_key.split("_")[2] if api_key.count("_") >= 2 else "default"
            buffer_path = os.path.join(
                os.path.expanduser("~"), ".genosis", f"buffer_{key_prefix}.db"
            )
        self._buffer = DiskBuffer(path=buffer_path, max_size=buffer_max_size)

        # Background worker: drains buffer → Genosis API over persistent connection
        self._worker = BackgroundWorker(
            buffer=self._buffer,
            base_url=base_url,
            api_key=api_key,
            timeout=timeout,
        )
        self._worker.start()

        # Worker ID: identifies this SDK instance for multi-worker tracing.
        # Stable per process — hostname + PID + random suffix.
        hostname = platform.node()[:20] or "unknown"
        self._worker_id = f"{hostname}-{os.getpid()}-{uuid.uuid4().hex[:6]}"

        self._manifest_refresh_interval = manifest_refresh_interval
        self._manifest_data: dict[str, CacheManifest] = {}
        self._last_manifest_fetch: dict[str, float] = {}
        self._empty_manifest_retries: dict[str, int] = {}
        self._refresh_in_progress: set[str] = set()
        self._refresh_lock = threading.Lock()
        self._provider_map: dict[str, str] = provider_map or {}
        self._default_provider: Optional[str] = default_provider

        # Memoization: enabled by default. Responses stored locally, never sent
        # to our servers. Default is in-process LRU. Customer can provide their
        # own MemoStorage implementation (Redis, etc.) for cross-worker sharing.
        self._memo_storage: Optional[MemoStorage] = None
        if memoization_enabled:
            self._memo_storage = memo_storage or InMemoryMemoStorage(memoization_max_entries)

        self.account = AccountClient(self._http)
        self.manifest = ManifestClient(self._http)
        self.optimization = OptimizationClient(self._http)
        self.telemetry = TelemetryClient(self._http)

    # ── Buffer management ──────────────────────────────────────────────────

    def flush(self, timeout: float = 30.0) -> int:
        """Flush the telemetry buffer, blocking until empty or timeout.

        Call this before process exit to ensure all telemetry is delivered.
        Returns the number of events remaining in the buffer (0 = fully flushed).

        Usage::

            g = Genosis(api_key="gns_...")
            # ... make calls ...
            g.flush(timeout=10)  # Ensure delivery before exit
        """
        start = time.time()
        while self._buffer.size() > 0:
            if time.time() - start > timeout:
                break
            time.sleep(0.25)
        return self._buffer.size()

    # ── Manifest management ────────────────────────────────────────────────

    def refresh_manifest(self, provider: str, model: str) -> Optional[dict]:
        """Force an immediate manifest refresh for a provider/model.

        Call this after triggering optimization to pick up the new manifest
        without waiting for the background refresh interval.

        Returns the manifest data or None.
        """
        return self._get_manifest(provider, model)

    # ── Core method ───────────────────────────────────────────────────────────

    def call(
        self,
        params: dict[str, Any],
        fn: Callable[[dict[str, Any]], Any],
    ) -> CallResult:
        """
        Make an LLM API call with automatic cache optimization and memoization.

        **What Genosis modifies:**

        - Anthropic: adds ``cache_control`` breakpoints to high-value system/tool blocks.
          Content is never changed — only metadata is added.
        - OpenAI/Google: may reorder system content blocks and tool definitions to
          maximize prefix cache hits. All content is preserved; only order may change.
          If your prompt has strict block-ordering requirements, keep order-sensitive
          content in a single block rather than multiple separate blocks.
        - All other params (model, messages, temperature, etc.) pass through unchanged.

        **Safety guarantee:** On any internal error, calls ``fn`` with the original
        unmodified params. Telemetry is queued to a local buffer and sent
        asynchronously — this method never blocks on network I/O to the Genosis API.

        :param params:    Raw LLM API params (model, system, messages, etc.)
        :param fn:        Function that makes the LLM API call. Receives optimized params.
        :returns:         :class:`CallResult` with ``response`` and ``memoized`` flag.
        """
        try:
            optimized = self._optimize(params)
            # detectProvider may raise for unknown models — already reported in _optimize().
            # Wrap silently so we don't double-log; memoization is skipped for unknown providers.
            provider: str | None = None
            try:
                provider = self._detect_provider(optimized)
            except Exception:
                pass  # already reported in _optimize()

            manifest: dict | None = None
            if provider is not None:
                model = self._normalize_model(str(optimized.get("model", "")))
                manifest = self._manifest_data.get(f"{provider}/{model}")

            memo_suspended = (manifest or {}).get("mode", {}).get("suspend_memoization", False)

            # Memoization: check if we have a cached response for this exact request.
            # This is independent of cache optimization — even if cache_train is empty,
            # memoization candidates may still exist.
            if provider is not None and self._memo_storage and not memo_suspended:
                blocks = self._extract_blocks(optimized, provider)
                user_msg = self._extract_user_message(optimized)
                fingerprint = self._compute_fingerprint(blocks, user_msg)

                if fingerprint:
                    candidate = self._find_memo_candidate(provider, model, fingerprint)

                    cached = self._memo_storage.get(fingerprint)
                    if cached is not None and candidate:
                        approx_response_tokens = max(1, len(str(cached)) // 4)
                        self._queue_memoized_telemetry(optimized, blocks, fingerprint, approx_response_tokens)
                        return CallResult(cached, True)

                    response = fn(optimized)
                    self._queue_telemetry(optimized, response)

                    if candidate:
                        max_response_tokens = candidate.get("max_response_tokens", 2000)
                        usage = self._extract_usage(response, provider)
                        approx_tokens = usage["output"] if usage["output"] > 0 else max(1, len(str(response)) // 4)
                        if approx_tokens <= max_response_tokens:
                            max_ttl = (manifest or {}).get("memoization", {}).get("max_ttl_seconds", 3600)
                            ttl = min(candidate["ttl_seconds"], max_ttl)
                            self._memo_storage.set(fingerprint, response, ttl)
                    return CallResult(response, False)

            response = fn(optimized)
            self._queue_telemetry(optimized, response)
            return CallResult(response, False)

        except Exception as exc:
            logger.warning("genosis SDK: call() falling back to original params: %s", exc)
            self._queue_error("call() fallback", str(exc), params.get("model"))
            response = fn(params)
            return CallResult(response, False)

    # ── Private helpers ───────────────────────────────────────────────────────

    def _optimize(self, params: dict[str, Any]) -> dict[str, Any]:
        try:
            model = params.get("model")
            if not model:
                return params

            provider = self._detect_provider(params)
            normalized = self._normalize_model(str(model))
            key = f"{provider}/{normalized}"

            if self._manifest_refresh_interval > 0:
                last = self._last_manifest_fetch.get(key, 0)
                if last == 0:
                    # First call for this provider/model — just fetch the manifest.
                    # Don't trigger optimization yet — no telemetry has been ingested,
                    # so no config exists. The ingestion route auto-triggers optimization
                    # once enough telemetry arrives.
                    self._get_manifest(provider, normalized)
                elif time.time() - last > self._manifest_refresh_interval:
                    # Refresh interval passed — trigger background refresh.
                    # Keep using the current manifest. It's still good.
                    self._last_manifest_fetch[key] = time.time()
                    self._trigger_background_refresh(provider, normalized)

            # Always apply the best manifest we have. A stale manifest is
            # better than no optimization. If we have nothing, pass through.
            manifest = self._manifest_data.get(key)
            if not manifest or not manifest.get("cache_train"):
                return params

            # Apply optimization based on wire format, not provider name.
            # Anthropic/VeryGoodAI use "system" key → breakpoint placement.
            # OpenAI uses messages[role=system] → frequency-based reordering.
            if "system" in params:
                return self._apply_anthropic_cache(params, manifest)

            messages = params.get("messages")
            if isinstance(messages, list) and any(
                isinstance(m, dict) and m.get("role") == "system" for m in messages
            ):
                return self._apply_openai_order(params, manifest)

            return params
        except Exception as exc:
            self._queue_error("_optimize failed", str(exc), params.get("model"))
            return params

    def _detect_provider(self, params: dict[str, Any]) -> str:
        """Detect provider from model prefix and request shape.

        Returns the canonical provider name (e.g. "anthropic" for both direct
        API and AWS Bedrock). Cloud provider wrappers use the same underlying
        model behavior, cache format, and usage response fields.

        No fallbacks — if we don't recognize the provider, we raise an error.
        The caller (call()) catches this and falls back to sending the original
        message unchanged. The error is reported to the Genosis API so we can
        detect patterns: many errors for model X may indicate a new provider
        we should add support for.
        """
        model = str(params.get("model", ""))
        # Explicit override takes top priority (Azure deployments, custom names)
        if model in self._provider_map:
            return self._provider_map[model]
        # Model prefix detection (authoritative when available)
        if model.startswith("vgai"):
            return "verygoodai"
        if model.startswith("claude"):
            return "anthropic"
        # AWS Bedrock Anthropic: "anthropic.claude-*" or "us.anthropic.claude-*"
        if model.startswith("anthropic.claude") or ".anthropic.claude" in model:
            return "anthropic"
        if model.startswith("gemini"):
            return "google"
        if model.startswith("gpt-") or re.match(r"^o\d+(-|$)", model):
            return "openai"
        # Request shape detection for models we recognize by format but not prefix
        if "system" in params:
            return "anthropic"
        if "systemInstruction" in params:
            return "google"
        # User-configured default (e.g. all Azure deployments are openai)
        if self._default_provider:
            return self._default_provider
        raise ValueError(
            f"Unrecognized provider for model '{model}'. "
            f"Pass provider_map={{'{model}': 'openai'}} or default_provider='openai' "
            f"in the Genosis constructor for Azure/custom deployments."
        )

    @staticmethod
    def _normalize_model(model: str) -> str:
        """Normalize cloud-provider model IDs to canonical names.

        AWS Bedrock:  "anthropic.claude-sonnet-4-6-20250514-v1:0" -> "claude-sonnet-4-6-20250514"
                      "us.anthropic.claude-3-5-haiku-20241022-v1:0" -> "claude-3-5-haiku-20241022"
        Azure OpenAI: deployment names pass through unchanged (user controls these)
        Vertex AI:    same model names as direct Google API (no normalization needed)
        Direct APIs:  pass through unchanged
        """
        import re
        # AWS Bedrock: strip provider prefix and version suffix
        # Pattern: [region.]anthropic.claude-<model>-v<N>:<N>
        bedrock_match = re.match(
            r"(?:[a-z]{2}\.)?anthropic\.(claude-[a-z0-9-]+?)(?:-v\d+(?::\d+)?)?$",
            model,
        )
        if bedrock_match:
            return bedrock_match.group(1)
        return model

    def _find_memo_candidate(self, provider: str, model: str, fingerprint: str) -> Optional[dict]:
        """Find a memoization candidate in the manifest for the given fingerprint."""
        manifest = self._manifest_data.get(f"{provider}/{model}")
        if not manifest:
            return None
        for c in (manifest.get("memoization") or {}).get("candidates", []):
            if c.get("fingerprint") == fingerprint:
                return c
        return None

    def _get_manifest(self, provider: str, model: str) -> Optional[CacheManifest]:
        key = f"{provider}/{model}"
        try:
            result = self.manifest.get(provider, model)
            if result["data"]:
                is_empty = not result["data"].get("cache_train")
                if is_empty:
                    retries = self._empty_manifest_retries.get(key, 0) + 1
                    self._empty_manifest_retries[key] = retries
                    if retries <= FAST_RETRY_MAX and self._manifest_refresh_interval > 0:
                        # Expire the fetch timestamp early so _optimize() retries in ~60s
                        fast = min(FAST_RETRY_INTERVAL, self._manifest_refresh_interval)
                        self._last_manifest_fetch[key] = time.time() - self._manifest_refresh_interval + fast - 0.1
                    else:
                        self._last_manifest_fetch[key] = time.time()
                else:
                    # Non-empty manifest arrived — stop fast-retrying
                    self._empty_manifest_retries.pop(key, None)
                    self._last_manifest_fetch[key] = time.time()
                self._manifest_data[key] = result["data"]
                self._ack_manifest(result["data"])
                return result["data"]
            self._last_manifest_fetch[key] = time.time()
            return self._manifest_data.get(key)
        except Exception as exc:
            self._queue_error("manifest fetch failed", str(exc), model)
            return self._manifest_data.get(key)

    def _ack_manifest(self, manifest: CacheManifest) -> None:
        """Queue a manifest acknowledgment for the background worker."""
        try:
            version = manifest.get("manifest_version")
            token = manifest.get("manifest_token")
            if version and token:
                self._buffer.put("manifest_ack", {
                    "event_id": str(uuid.uuid4()),
                    "manifest_version": version,
                    "manifest_token": token,
                })
        except Exception as exc:
            self._queue_error("manifest ack failed", str(exc))

    def _trigger_background_refresh(self, provider: str, model: str) -> None:
        key = f"{provider}/{model}"
        with self._refresh_lock:
            if key in self._refresh_in_progress:
                return
            self._refresh_in_progress.add(key)

        def _refresh() -> None:
            try:
                try:
                    self.optimization.trigger(provider, model)
                except Exception as exc:
                    self._queue_error("background optimization trigger failed", str(exc), model)
                result = self.manifest.get(provider, model)
                if result["data"]:
                    is_empty = not result["data"].get("cache_train")
                    if is_empty:
                        retries = self._empty_manifest_retries.get(key, 0) + 1
                        self._empty_manifest_retries[key] = retries
                        if retries <= FAST_RETRY_MAX and self._manifest_refresh_interval > 0:
                            fast = min(FAST_RETRY_INTERVAL, self._manifest_refresh_interval)
                            self._last_manifest_fetch[key] = time.time() - self._manifest_refresh_interval + fast - 0.1
                        else:
                            self._last_manifest_fetch[key] = time.time()
                    else:
                        self._empty_manifest_retries.pop(key, None)
                        self._last_manifest_fetch[key] = time.time()
                    self._manifest_data[key] = result["data"]
                    self._ack_manifest(result["data"])
                else:
                    self._last_manifest_fetch[key] = time.time()
            except Exception as exc:
                self._queue_error("background manifest refresh failed", str(exc), model)
            finally:
                with self._refresh_lock:
                    self._refresh_in_progress.discard(key)

        t = threading.Thread(target=_refresh, daemon=True)
        t.start()

    def _apply_openai_order(
        self, params: dict[str, Any], manifest: CacheManifest
    ) -> dict[str, Any]:
        """Reorder system message content blocks and tool definitions by manifest frequency order.

        OpenAI uses implicit prefix caching — the provider automatically caches
        the longest matching prefix. The manifest's cache_train is already sorted
        by frequency (most stable blocks first), so reordering the system content
        and tools to match maximizes the stable prefix and cache hit probability.

        Blocks not in the cache_train are pushed to the end of the content array.
        """
        cache_train = manifest.get("cache_train", [])
        if not cache_train:
            return params

        result = params

        # ── System content blocks ────────────────────────────────────────
        messages = params.get("messages")
        if isinstance(messages, list):
            system_idx = next(
                (i for i, m in enumerate(messages) if isinstance(m, dict) and m.get("role") == "system"),
                -1,
            )
            if system_idx != -1:
                sys_msg = messages[system_idx]
                content = sys_msg.get("content")
                if isinstance(content, list) and len(content) > 1:
                    order_index: dict[str, int] = {entry["hash"]: i for i, entry in enumerate(cache_train)}

                    def sort_key(block: Any) -> int:
                        text = block if isinstance(block, str) else (block.get("text", "") if isinstance(block, dict) else "")
                        if not text:
                            return float("inf")  # type: ignore[return-value]
                        return order_index.get(_sha256(text), float("inf"))  # type: ignore[return-value]

                    sorted_content = sorted(content, key=sort_key)
                    new_messages = list(messages)
                    new_messages[system_idx] = {**sys_msg, "content": sorted_content}
                    result = {**result, "messages": new_messages}

        # ── Tool definitions ─────────────────────────────────────────────
        tool_entries = [e for e in cache_train if e.get("source") == "tool"]
        tools = result.get("tools")
        if isinstance(tools, list) and len(tools) > 1 and tool_entries:
            tool_order = {e["hash"]: i for i, e in enumerate(tool_entries)}

            def tool_sort_key(tool: Any) -> int:
                clean = {k: v for k, v in tool.items() if k != "cache_control"} if isinstance(tool, dict) else tool
                serialized = json.dumps(clean, sort_keys=True, separators=(",", ":"))
                return tool_order.get(_sha256(serialized), float("inf"))  # type: ignore[return-value]

            sorted_tools = sorted(tools, key=tool_sort_key)
            result = {**result, "tools": sorted_tools}

        return result

    def _apply_anthropic_cache(
        self, params: dict[str, Any], manifest: CacheManifest
    ) -> dict[str, Any]:
        """Apply cache_control markers to Anthropic-format system blocks and tool definitions.

        The server computed which cache_train positions are breakpoints and already
        validated minimum segment sizes. The SDK finds each block by hash and injects
        cache_control only on the blocks at breakpoint positions. No token counting —
        if Anthropic changes their prefix order or minimums, the server updates and
        redeploys; no SDK update required.
        """
        cache_train = manifest.get("cache_train", [])
        if not cache_train:
            return params

        hints = (manifest.get("provider_hints") or {}).get("anthropic") or {}
        cache_type = hints.get("cache_type", "ephemeral")

        breakpoint_positions = set(hints.get("breakpoint_positions") or [])
        hash_to_position = {
            e["hash"]: e["position"]
            for e in cache_train
            if "hash" in e and "position" in e
        }

        def is_breakpoint(h: str) -> bool:
            pos = hash_to_position.get(h)
            return pos is not None and pos in breakpoint_positions

        result = params

        # Build position lookup for reordering
        hash_to_entry = {e["hash"]: e for e in cache_train if "hash" in e}

        # ── Reorder tools by manifest position ───────────────────────────
        # The server canonically ordered tools→system→messages when building
        # breakpoints. Re-apply that order so Anthropic's cache prefix is
        # identical across calls regardless of how the caller ordered the array.
        tool_entries = [e for e in cache_train if e.get("source") == "tool"]
        tools = result.get("tools")
        if isinstance(tools, list) and len(tools) > 1 and tool_entries:
            tool_order = {e["hash"]: e["position"] for e in tool_entries}
            def _tool_sort_key(t: Any) -> int:
                if not isinstance(t, dict):
                    return 10**9
                clean = {k: v for k, v in t.items() if k != "cache_control"}
                serialized = json.dumps(clean, sort_keys=True, separators=(",", ":"))
                return tool_order.get(_sha256(serialized), 10**9)
            result = {**result, "tools": sorted(tools, key=_tool_sort_key)}

        # ── Reorder system blocks by manifest position ────────────────────
        sys_entries = [e for e in cache_train if e.get("source") == "system"]
        system_val = result.get("system")
        if isinstance(system_val, list) and len(system_val) > 1 and sys_entries:
            sys_order = {e["hash"]: e["position"] for e in sys_entries}
            def _sys_sort_key(b: Any) -> int:
                text = b if isinstance(b, str) else (b.get("text", "") if isinstance(b, dict) else "")
                return sys_order.get(_sha256(text), 10**9) if text else 10**9
            result = {**result, "system": sorted(system_val, key=_sys_sort_key)}

        # ── System blocks — inject cache_control ──────────────────────────
        system = result.get("system")
        if system is not None:
            if isinstance(system, str):
                if is_breakpoint(_sha256(system)):
                    result = {**result, "system": [{"type": "text", "text": system, "cache_control": {"type": cache_type}}]}
            elif isinstance(system, list):
                blocks = []
                for b in system:
                    if isinstance(b, str):
                        blocks.append({"type": "text", "text": b})
                    elif isinstance(b, dict):
                        blocks.append({k: v for k, v in b.items() if k != "cache_control"})
                    else:
                        blocks.append(b)

                any_placed = False
                for i, block in enumerate(blocks):
                    text = block.get("text", "") if isinstance(block, dict) else ""
                    if text and is_breakpoint(_sha256(text)):
                        blocks[i] = {**blocks[i], "cache_control": {"type": cache_type}}
                        any_placed = True

                if any_placed:
                    result = {**result, "system": blocks}

        # ── Tool definitions — inject cache_control ───────────────────────
        tools = result.get("tools")
        if isinstance(tools, list) and tools:
            clean_tools = [
                {k: v for k, v in t.items() if k != "cache_control"} if isinstance(t, dict) else t
                for t in tools
            ]
            any_placed = False
            for i, tool in enumerate(clean_tools):
                serialized = json.dumps(tool, sort_keys=True, separators=(",", ":"))
                if is_breakpoint(_sha256(serialized)):
                    clean_tools[i] = {**clean_tools[i], "cache_control": {"type": cache_type}}
                    any_placed = True
            if any_placed:
                result = {**result, "tools": clean_tools}

        return result

    def _extract_blocks(self, params: dict[str, Any], provider: str) -> list[_TelemetryBlock]:
        """Extract content blocks from request params based on wire format, not provider name.

        Token estimation uses len(text) // 4 — this is approximate and intentionally so.
        It is used ONLY for relative block sizing, NOT for billing.
        All billing uses the LLM provider's actual reported token counts from the response.

        The 'cached' flag reflects whether the block is part of a cached segment,
        NOT just whether it has an explicit cache_control marker. In Anthropic's
        model, a breakpoint caches the entire prefix up to that point. So all
        blocks before a cache_control marker are also cached.

        Position ordering follows the actual context window order:
          Anthropic: tools (lower positions) → system (higher positions)
          OpenAI/Google: system (lower positions) → tools (higher positions)

        Tool definitions are serialized with sort_keys=True for cross-SDK hash stability.
        """
        blocks: list[_TelemetryBlock] = []
        tools = params.get("tools")

        # ── Anthropic format: top-level "system" key ─────────────────────────
        # Context window order: tools → system → messages.
        system = params.get("system")
        if system is not None:
            pos = 0

            # 1. Tool definitions first (lower positions match context window order)
            if isinstance(tools, list):
                tool_blocks: list[_TelemetryBlock] = []
                for tool in tools:
                    tool_clean = {k: v for k, v in tool.items() if k != "cache_control"} if isinstance(tool, dict) else tool
                    serialized = json.dumps(tool_clean, sort_keys=True, separators=(",", ":"))
                    if serialized:
                        tool_blocks.append({
                            "hash": _sha256(serialized),
                            "tokens": max(1, len(serialized) // 4),
                            "cached": bool(isinstance(tool, dict) and tool.get("cache_control")),
                            "position": pos,
                            "source": "tool",
                        })
                        pos += 1
                bp_indices = [i for i, b in enumerate(tool_blocks) if b["cached"]]
                if bp_indices:
                    seg_start = 0
                    for bp_idx in bp_indices:
                        for i in range(seg_start, bp_idx):
                            tool_blocks[i]["cached"] = True
                        seg_start = bp_idx + 1
                blocks.extend(tool_blocks)

            # 2. System blocks after tools
            items = [{"type": "text", "text": system}] if isinstance(system, str) else (system if isinstance(system, list) else [])
            sys_blocks: list[_TelemetryBlock] = []
            for item in items:
                text = item if isinstance(item, str) else (item.get("text", "") if isinstance(item, dict) else "")
                if not text:
                    continue
                sys_blocks.append({
                    "hash": _sha256(text),
                    "tokens": max(1, len(text) // 4),
                    "cached": bool(item.get("cache_control") if isinstance(item, dict) else False),
                    "position": pos,
                    "source": "system",
                })
                pos += 1
            bp_indices = [i for i, b in enumerate(sys_blocks) if b["cached"]]
            if bp_indices:
                seg_start = 0
                for bp_idx in bp_indices:
                    for i in range(seg_start, bp_idx):
                        sys_blocks[i]["cached"] = True
                    seg_start = bp_idx + 1
            blocks.extend(sys_blocks)
            return blocks

        # ── Google format: "systemInstruction" with "parts" ──────────────────
        sys_instr = params.get("systemInstruction")
        if sys_instr is not None:
            for i, part in enumerate((sys_instr if isinstance(sys_instr, dict) else {}).get("parts", [])):
                text = part.get("text", "")
                if not text:
                    continue
                blocks.append({
                    "hash": _sha256(text),
                    "tokens": max(1, len(text) // 4),
                    "cached": False,
                    "position": i,
                    "source": "system",
                })
            return blocks

        # ── OpenAI format: system messages in "messages" array ───────────────
        # content may be a plain string or list of content blocks.
        # Context window order: system → tools.
        pos = 0
        messages = params.get("messages", [])
        for msg in messages:
            if msg.get("role") != "system":
                continue
            content = msg.get("content")
            if isinstance(content, str):
                blocks.append({
                    "hash": _sha256(content),
                    "tokens": max(1, len(content) // 4),
                    "cached": False,
                    "position": pos,
                    "source": "system",
                })
                pos += 1
            elif isinstance(content, list):
                for item in content:
                    text = item if isinstance(item, str) else (item.get("text", "") if isinstance(item, dict) else "")
                    if not text:
                        continue
                    blocks.append({
                        "hash": _sha256(text),
                        "tokens": max(1, len(text) // 4),
                        "cached": False,
                        "position": pos,
                        "source": "system",
                    })
                    pos += 1

        # Tools after system for OpenAI
        if isinstance(tools, list):
            for tool in tools:
                tool_clean = {k: v for k, v in tool.items() if k != "cache_control"} if isinstance(tool, dict) else tool
                serialized = json.dumps(tool_clean, sort_keys=True, separators=(",", ":"))
                if serialized:
                    blocks.append({
                        "hash": _sha256(serialized),
                        "tokens": max(1, len(serialized) // 4),
                        "cached": False,
                        "position": pos,
                        "source": "tool",
                    })
                    pos += 1

        return blocks

    def _extract_usage(self, response: Any, provider: str) -> dict[str, int]:
        """Extract usage from LLM response based on response shape, not provider name.

        Each provider reports token counts differently. We detect the format from
        the response structure itself, not from the provider string. This ensures
        correct extraction for any provider that uses a standard response format
        (e.g. VeryGoodAI uses Anthropic format).
        """
        # Support both raw dicts and SDK response objects (Pydantic models, etc.)
        if not isinstance(response, dict):
            if hasattr(response, "model_dump"):
                response = response.model_dump()
            elif hasattr(response, "to_dict"):
                response = response.to_dict()
            elif hasattr(response, "__dict__"):
                response = response.__dict__
            else:
                return {"input": 0, "output": 0, "cache_write": 0, "cache_read": 0}

        # Google format: "usageMetadata" key
        if "usageMetadata" in response:
            meta = response["usageMetadata"] or {}
            return {
                "input": meta.get("promptTokenCount", 0),
                "output": meta.get("candidatesTokenCount", 0),
                "cache_write": 0,
                "cache_read": meta.get("cachedContentTokenCount", 0),
            }

        usage = response.get("usage") or {}

        # Anthropic format: "input_tokens" key in usage
        if "input_tokens" in usage:
            return {
                "input": usage.get("input_tokens", 0),
                "output": usage.get("output_tokens", 0),
                "cache_write": usage.get("cache_creation_input_tokens", 0),
                "cache_read": usage.get("cache_read_input_tokens", 0),
            }

        # OpenAI format: "prompt_tokens" key in usage
        details = usage.get("prompt_tokens_details") or {}
        return {
            "input": usage.get("prompt_tokens", 0),
            "output": usage.get("completion_tokens", 0),
            "cache_write": 0,
            "cache_read": details.get("cached_tokens", 0),
        }

    def _extract_user_message(self, params: dict[str, Any]) -> str:
        """Extract the user message text from the request params.

        Returns the concatenated text of all user messages. This is included
        in the fingerprint so that memoization only returns cached responses
        for the EXACT same question — not just the same system blocks.
        """
        parts: list[str] = []

        # Anthropic / OpenAI format: messages array with role + content
        messages = params.get("messages", [])
        for msg in messages:
            if msg.get("role") == "user":
                content = msg.get("content", "")
                if isinstance(content, str):
                    parts.append(content)
                elif isinstance(content, list):
                    for block in content:
                        if isinstance(block, dict) and block.get("text"):
                            parts.append(block["text"])

        # Google format: contents array with role + parts
        contents = params.get("contents", [])
        for item in contents:
            if item.get("role") == "user":
                for part in item.get("parts", []):
                    if isinstance(part, dict) and part.get("text"):
                        parts.append(part["text"])

        return " ".join(parts)

    def _compute_fingerprint(self, blocks: list[_TelemetryBlock], user_message: str = "") -> Optional[str]:
        """Compute a deterministic fingerprint from block hashes + user message.

        Fingerprint = SHA-256 of (block hashes sorted by position + user message hash).
        Two requests must have the SAME system blocks AND the SAME user message
        to produce the same fingerprint. This ensures memoization never returns
        a cached response to the wrong question.
        """
        if not blocks:
            return None
        ordered = sorted(blocks, key=lambda b: b["position"])
        combined = "+".join(b["hash"] for b in ordered)
        if user_message:
            combined += "+" + _sha256(user_message)
        return _sha256(combined)

    # ── Telemetry queuing ───────────────────────────────────────────────────
    # The SDK queues observations to a local buffer. The background worker
    # drains the buffer and sends to the Genosis API asynchronously.
    # g.call() NEVER blocks on network I/O to Genosis.

    def _queue_telemetry(self, optimized: dict[str, Any], response: Any) -> None:
        """Queue telemetry for a normal (non-memoized) LLM call."""
        try:
            try:
                provider = self._detect_provider(optimized)
            except Exception:
                return  # Unknown provider already warned in _optimize() — skip silently
            model = self._normalize_model(str(optimized.get("model", "")))
            blocks = self._extract_blocks(optimized, provider)
            user_msg = self._extract_user_message(optimized)
            fingerprint = self._compute_fingerprint(blocks, user_msg)
            usage = self._extract_usage(response, provider)
            manifest_key = f"{provider}/{model}"
            manifest = self._manifest_data.get(manifest_key, {})

            event = {
                "event_id": str(uuid.uuid4()),
                "timestamp": _now_iso(),
                "model": model,
                "provider": provider,
                "blocks": blocks,
                "latency_ms": {"sdk_overhead": 0, "inference": 0, "total": 0},
                "manifest_version": manifest.get("manifest_version"),
                "manifest_token": manifest.get("manifest_token"),
                "usage": {
                    "input_tokens": usage["input"],
                    "output_tokens": usage["output"],
                    "cache_write_tokens": usage["cache_write"],
                    "cache_read_tokens": usage["cache_read"],
                },
                "fingerprint": fingerprint,
                "worker_id": self._worker_id,
                "memoized": False,
            }
            self._buffer.put("telemetry", event)
        except Exception as exc:
            self._queue_error("telemetry queue failed", str(exc), optimized.get("model"))

    def _queue_memoized_telemetry(
        self, optimized: dict[str, Any], blocks: list[_TelemetryBlock],
        fingerprint: str, approx_response_tokens: int,
    ) -> None:
        """Queue telemetry for a memoized response.

        Explicit memoized flag + zero usage tells the server this was a cache hit.
        """
        try:
            provider = self._detect_provider(optimized)
            model = self._normalize_model(str(optimized.get("model", "")))
            manifest_key = f"{provider}/{model}"
            manifest = self._manifest_data.get(manifest_key, {})

            event = {
                "event_id": str(uuid.uuid4()),
                "timestamp": _now_iso(),
                "model": model,
                "provider": provider,
                "blocks": blocks,
                "latency_ms": {"sdk_overhead": 0, "inference": 0, "total": 0},
                "manifest_version": manifest.get("manifest_version"),
                "manifest_token": manifest.get("manifest_token"),
                "usage": {"input_tokens": 0, "output_tokens": 0, "cache_write_tokens": 0, "cache_read_tokens": 0},
                "fingerprint": fingerprint,
                "worker_id": self._worker_id,
                "memoized": True,
            }
            self._buffer.put("telemetry", event)
        except Exception as exc:
            self._queue_error("memoized telemetry queue failed", str(exc), optimized.get("model"))

    # ── Error reporting ──────────────────────────────────────────────────────

    def _queue_error(self, context: str, message: str, model: Any = None) -> None:
        """Log an SDK error locally and queue a sanitized report for the server."""
        logger.warning("genosis SDK error [%s]: %s (model=%s)", context, _sanitize_error(message), model)
        try:
            self._buffer.put("error_report", {
                "event_id": str(uuid.uuid4()),
                "context": context,
                "message": _sanitize_error(message),
                "model": str(model) if model else None,
                "timestamp": _now_iso(),
            })
        except Exception:
            logger.debug("genosis SDK: failed to queue error report")


# ── Helpers ───────────────────────────────────────────────────────────────────

def _sha256(text: str) -> str:
    """SHA-256 hash of text. Used for content-blind block identification."""
    return hashlib.sha256(text.encode("utf-8")).hexdigest()


def _now_iso() -> str:
    """Current UTC time in ISO 8601 format."""
    from datetime import datetime, timezone
    return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")


def _sanitize_error(message: str) -> str:
    """Remove potentially sensitive information from error messages before sending to the API.

    Strips: API key fragments (gns_live_*, gns_test_*), file paths, and Bearer tokens.
    """
    sanitized = re.sub(r"gns_(live|test)_\w+", "gns_***_REDACTED", message)
    sanitized = re.sub(r"Bearer\s+\S+", "Bearer REDACTED", sanitized)
    sanitized = re.sub(r"(/Users/|/home/|/var/|/tmp/|C:\\)\S+", "[PATH_REDACTED]", sanitized)
    return sanitized
