#!/usr/bin/env python3
"""
Unified LLM Client — single entry point for all AI calls in the system.

Usage:
    from src.llm import call_agent_llm

    result = call_agent_llm(
        role="趋势分析师",
        system_prompt="You are a macro strategist...",
        user_prompt="Analyze this market data...",
        temperature=0.5,
        max_tokens=600,
    )

The function:
1. Looks up the model configured for `role` in agent_models.json
2. Resolves the provider from llm_providers.json to get baseUrl, apiKey, api type
3. Routes to the correct HTTP API (OpenAI-compatible / Anthropic)
4. Returns {"content": str, "model": str, "latency_ms": float, "degraded": bool}
"""

import json
import logging
import threading
import time
from collections import deque
from typing import Any, Optional

import requests

from .config import resolve_agent_model, resolve_provider_for_model, get_api_key, load_agent_models

_logger = logging.getLogger("llm.client")

# ── Metrics ring buffer (last 1000 calls) ──────────────────────────
_METRICS_LOCK = threading.Lock()
_METRICS_BUF: deque = deque(maxlen=1000)
_METRICS_COUNTERS: dict[str, dict[str, int]] = {}  # {provider: {success:N, failed:N, total_ms:N}}


def _record_llm_metrics(provider: str, model: str, success: bool, latency_ms: float, error: str):
    """Thread-safe ring-buffer metrics collector."""
    entry = {
        "ts": time.time(),
        "provider": provider,
        "model": model,
        "success": success,
        "latency_ms": round(latency_ms, 1),
        "error": error[:200] if error else "",
    }
    with _METRICS_LOCK:
        _METRICS_BUF.append(entry)
        if provider not in _METRICS_COUNTERS:
            _METRICS_COUNTERS[provider] = {"success": 0, "failed": 0, "total_ms": 0}
        c = _METRICS_COUNTERS[provider]
        c["success" if success else "failed"] += 1
        c["total_ms"] += int(latency_ms)


def get_llm_metrics() -> dict[str, Any]:
    """Return aggregated LLM call metrics for monitoring/health dashboards.

    Returns:
        {
            "total_calls": N,
            "providers": {
                "deepseek": {"success": N, "failed": N, "avg_latency_ms": N, "error_rate": 0.X},
                ...
            },
            "recent_failures": [...],
            "overall_success_rate": 0.XX,
        }
    """
    with _METRICS_LOCK:
        recent = list(_METRICS_BUF)[-100:]  # last 100 entries
        providers = {}
        for pname, counts in _METRICS_COUNTERS.items():
            total = counts["success"] + counts["failed"]
            providers[pname] = {
                "success": counts["success"],
                "failed": counts["failed"],
                "total_calls": total,
                "avg_latency_ms": round(counts["total_ms"] / total, 1) if total > 0 else 0,
                "error_rate": round(counts["failed"] / total, 3) if total > 0 else 0,
            }
        total_all = sum(c["success"] + c["failed"] for c in _METRICS_COUNTERS.values())
        recent_failures = [e for e in recent if not e["success"]][-10:]
        return {
            "total_calls": total_all,
            "providers": providers,
            "recent_failures": recent_failures,
            "overall_success_rate": round(
                sum(c["success"] for c in _METRICS_COUNTERS.values()) / total_all, 3
            ) if total_all > 0 else 0,
            "buffer_size": len(_METRICS_BUF),
        }


def call_agent_llm(
    role: str,
    system_prompt: str,
    user_prompt: str,
    temperature: Optional[float] = None,
    max_tokens: Optional[int] = None,
    timeout: Optional[int] = None,
    response_format: Optional[dict] = None,
    **kwargs,  # accept model_override etc. (forward compat)
) -> dict[str, Any]:
    """Call the LLM configured for `role` with the given prompts.

    Args:
        role: Agent role name (e.g. "AI 策略开发师")
        system_prompt: System-level instruction
        user_prompt: User message content
        temperature: Sampling temperature (0.0-1.0). Falls back to persona config then 0.5.
        max_tokens: Max output tokens. Falls back to persona config then 2000.
        timeout: HTTP timeout in seconds. Falls back to persona config then 120.
        response_format: Optional {"type": "json_object"} for JSON mode
        **kwargs: Accepted but ignored (model_override, etc.)

    Returns:
        {
            "content": str,       # model response text
            "model": str,         # actual model used
            "latency_ms": float,  # round-trip time
            "degraded": bool,     # True if fallback was used
        }
    """
    agent = resolve_agent_model(role)
    if not agent:
        return _degraded_result(f"Agent role '{role}' not configured")

    model_id = agent.get("model", "")
    if not model_id:
        return _degraded_result(f"No model assigned to role '{role}'")

    # Apply persona-level defaults when the caller hasn't explicitly overridden them
    if temperature is None:
        temperature = agent.get("temperature", 0.5)
    if max_tokens is None:
        max_tokens = agent.get("max_tokens", 2000)
    if timeout is None:
        timeout = agent.get("timeout_sec", 120)

    provider_name, model_name, provider_config = resolve_provider_for_model(model_id)
    if not provider_config:
        return _degraded_result(f"Provider '{provider_name}' not found in llm_providers.json for model '{model_id}'")

    api_key = get_api_key(provider_config)
    base_url = provider_config.get("baseUrl", "").rstrip("/")
    api_type = provider_config.get("api", "openai-responses")

    if not api_key:
        return _degraded_result(f"API key not configured for provider '{provider_name}' — set it in llm_providers.json or env {provider_name.upper()}_API_KEY")
    if not base_url:
        return _degraded_result(f"No baseUrl configured for provider '{provider_name}'")

    start = time.time()
    try:
        if api_type in ("openai-responses", "openai-completions"):
            content = _call_openai_compatible(
                base_url, api_key, model_name, system_prompt, user_prompt,
                temperature, max_tokens, timeout, response_format,
            )
        elif api_type == "anthropic-messages":
            content = _call_anthropic(
                base_url, api_key, model_name, system_prompt, user_prompt,
                temperature, max_tokens, timeout,
            )
        else:
            return _degraded_result(f"Unsupported API type '{api_type}' for provider '{provider_name}'")

        latency_ms = (time.time() - start) * 1000
        _record_llm_metrics(provider_name, model_name, True, latency_ms, "")
        return {
            "content": content,
            "model": model_id,
            "latency_ms": round(latency_ms, 1),
            "degraded": False,
        }
    except Exception as e:
        latency_ms = (time.time() - start) * 1000
        _record_llm_metrics(provider_name, model_name, False, latency_ms, str(e)[:200])
        return _degraded_result(f"API call failed: {e}", latency_ms)


def _call_openai_compatible(
    base_url: str,
    api_key: str,
    model: str,
    system_prompt: str,
    user_prompt: str,
    temperature: float,
    max_tokens: int,
    timeout: int,
    response_format: Optional[dict] = None,
) -> str:
    url = f"{base_url}/chat/completions"
    body: dict[str, Any] = {
        "model": model,
        "messages": [
            {"role": "system", "content": system_prompt},
            {"role": "user", "content": user_prompt},
        ],
        "max_tokens": max_tokens,
        "temperature": temperature,
    }
    if response_format:
        body["response_format"] = response_format

    headers = {
        "Content-Type": "application/json",
    }
    if api_key:
        headers["Authorization"] = f"Bearer {api_key}"

    resp = requests.post(url, headers=headers, json=body, timeout=timeout)
    resp.raise_for_status()
    data = resp.json()
    return data["choices"][0]["message"]["content"].strip()


def _call_anthropic(
    base_url: str,
    api_key: str,
    model: str,
    system_prompt: str,
    user_prompt: str,
    temperature: float,
    max_tokens: int,
    timeout: int,
) -> str:
    url = f"{base_url}/messages"
    body: dict[str, Any] = {
        "model": model,
        "system": system_prompt,
        "messages": [
            {"role": "user", "content": user_prompt},
        ],
        "max_tokens": max_tokens,
        "temperature": temperature,
    }
    headers = {
        "Content-Type": "application/json",
        "x-api-key": api_key,
        "anthropic-version": "2023-06-01",
    }

    resp = requests.post(url, headers=headers, json=body, timeout=timeout)
    resp.raise_for_status()
    data = resp.json()
    return data["content"][0]["text"].strip()


def _degraded_result(reason: str, latency_ms: float = 0.0) -> dict[str, Any]:
    return {
        "content": "",
        "model": "",
        "latency_ms": latency_ms,
        "degraded": True,
        "degraded_reason": reason,
    }


def get_agent_model_info() -> list[dict[str, Any]]:
    """Return all agent roles with their model info + api_key_configured status.

    Used by gui_backend.py /api/agent_models endpoint.
    """
    from .config import load_llm_providers

    agents = load_agent_models()
    providers = load_llm_providers()
    result = []
    for role, agent in agents.items():
        model_id = agent.get("model", "")
        provider_name = model_id.split("/")[0] if "/" in model_id else ""
        provider_config = providers.get(provider_name, {})
        has_key = bool(get_api_key(provider_config))
        result.append({
            "role": role,
            "model": model_id,
            "status": "ready" if has_key else "no_api_key",
            "api_key_configured": has_key,
            "provider": provider_name,
        })
    return result


# ── Auto-sync: ensure agent_models.json includes all provider models ──
# The compiled EXE's V1 GET /api/agent_models only collects models from
# agent role bindings, not from all providers. This auto-sync writes all
# provider model IDs with configured keys into agent_models.json at module
# load time, so the stale V1 route naturally returns complete results
# without route patching. Synthetic entries use role prefix "_available_"
# and _synthetic flag — the React frontend filters these out from the UI.

_AUTO_SYNC_DONE = False


def _auto_sync_available_models():
    """Write all provider model IDs into agent_models.json as synthetic entries.

    Reads BOTH llm_providers.json AND custom_providers.json so that
    user-added custom providers also appear in role model dropdowns.
    Includes providers even without API keys (user may add key later).
    """
    global _AUTO_SYNC_DONE
    if _AUTO_SYNC_DONE:
        return
    _AUTO_SYNC_DONE = True
    try:
        import config_manager as _cm
        import json as _json, os as _os

        cfg_dir = _cm.get_config_dir()
        agent_path = cfg_dir / "agent_models.json"

        # Merge ALL provider sources: system + custom
        all_providers: dict = {}
        for src_name in ("llm_providers.json", "custom_providers.json"):
            src_path = cfg_dir / src_name
            if not src_path.exists():
                continue
            try:
                src_data = _json.loads(src_path.read_text(encoding="utf-8"))
                src_providers = src_data.get("providers", {}) if isinstance(src_data, dict) else {}
                for pname, pcfg in src_providers.items():
                    if not isinstance(pcfg, dict):
                        continue
                    # Custom overrides system — merge models too
                    if pname in all_providers:
                        existing_models = {m.get("id", ""): m for m in all_providers[pname].get("models", []) if isinstance(m, dict)}
                        for m in pcfg.get("models", []) or []:
                            mid = m.get("id", "") if isinstance(m, dict) else str(m)
                            if mid:
                                existing_models[mid] = m
                        all_providers[pname]["models"] = list(existing_models.values())
                        # Update API key if custom provides one
                        if pcfg.get("apiKey"):
                            all_providers[pname]["apiKey"] = pcfg["apiKey"]
                    else:
                        all_providers[pname] = dict(pcfg)
            except Exception:
                continue

        if not all_providers:
            return

        # Collect ALL models from ALL providers (including those without API keys)
        keyed_models = {}
        for pname, pcfg in all_providers.items():
            models = pcfg.get("models", []) or [] if isinstance(pcfg, dict) else []
            for model in models:
                mid = model.get("id", "") if isinstance(model, dict) else str(model)
                if mid:
                    keyed_models[f"{pname}/{mid}"] = pname

        if not keyed_models:
            return

        # Read existing agent_models.json
        existing = []
        if agent_path.exists():
            try:
                raw = _json.loads(agent_path.read_text(encoding="utf-8"))
                if isinstance(raw, list):
                    existing = raw
            except Exception:
                existing = []

        # Collect existing model IDs from real agent roles
        existing_models = set()
        for entry in existing:
            if isinstance(entry, dict):
                m = str(entry.get("model", "") or "").strip()
                if m:
                    existing_models.add(m)

        # Remove any previous synthetic entries
        existing = [e for e in existing if isinstance(e, dict) and not str(e.get("role", "")).startswith("_available_")]

        # Add one hidden entry per missing keyed model
        added = 0
        for mid, pname in sorted(keyed_models.items()):
            if mid not in existing_models:
                existing.append({
                    "role": f"_available_{mid.replace('/', '_')}",
                    "model": mid,
                    "status": "ready",
                    "_synthetic": True,
                })
                added += 1

        if added > 0:
            # Atomic write: use tempfile to ensure the file is never left in a partial state
            import tempfile as _tempfile
            _fd, _tmp_path = _tempfile.mkstemp(
                suffix=".json", prefix="agent_models_auto_sync_", dir=str(cfg_dir)
            )
            try:
                _os.write(_fd, _json.dumps(existing, ensure_ascii=False, indent=2).encode("utf-8"))
                _os.fsync(_fd)
                _os.close(_fd)
                _os.replace(_tmp_path, str(agent_path))
                _logger.info("[auto-sync] Added %d keyed provider model(s) to agent_models.json", added)
            except Exception:
                try:
                    _os.close(_fd)
                except Exception:
                    pass
                try:
                    _os.unlink(_tmp_path)
                except Exception:
                    pass
    except Exception:
        pass


def _resync_available_models():
    """Re-sync provider models into agent_models.json after a provider save/delete.

    Resets the _AUTO_SYNC_DONE flag so _auto_sync_available_models() runs again.
    Called from provider_admin.save_provider() and delete_provider().
    """
    global _AUTO_SYNC_DONE
    _AUTO_SYNC_DONE = False
    _auto_sync_available_models()


# Run auto-sync at module load time
_auto_sync_available_models()
# Also run after a short delay to catch late config changes
import threading
_thread = threading.Thread(target=lambda: (__import__('time').sleep(5), _auto_sync_available_models()), daemon=True)
_thread.start()


# ── v1.0.15: LLM Circuit Breaker ────────────────────────────────────
class CircuitBreaker:
    """Protect against cascading LLM failures.

    After 5 consecutive failures, the breaker opens for 300 seconds.
    During the open state, all calls are rejected immediately without
    consuming API tokens.
    """

    def __init__(self, failure_threshold: int = 5, recovery_sec: float = 300.0):
        self._threshold = failure_threshold
        self._recovery = recovery_sec
        self._failures = 0
        self._last_failure = 0.0
        self._open = False

    @property
    def is_open(self) -> bool:
        if not self._open:
            return False
        import time as _t
        if _t.time() - self._last_failure > self._recovery:
            self._open = False
            self._failures = 0
            return False
        return True

    def record_success(self) -> None:
        self._failures = 0
        self._open = False

    def record_failure(self) -> None:
        import time as _t
        self._failures += 1
        self._last_failure = _t.time()
        if self._failures >= self._threshold:
            self._open = True
            _logger.warning(
                "LLM CircuitBreaker OPEN after %d consecutive failures — "
                "rejecting all calls for %.0fs",
                self._failures, self._recovery,
            )


# Global circuit breaker instance
_llm_circuit_breaker = CircuitBreaker()


def get_circuit_breaker() -> CircuitBreaker:
    return _llm_circuit_breaker


# ── Patch gui_backend FastAPI app with provider routes ──
# gui_backend.py entry point changes are not bundled by PyInstaller,
# so we mount the provider API routes here as a side-effect of importing src.llm.
# The route mount is idempotent — calling it multiple times is safe.
#
# ALSO: The V1 routes (/api/agent_models, /api/providers) in gui_backend.py
# are STALE in deployed EXEs because gui_backend.py is compiled into the EXE.
# We patch them here so that deployed EXEs get the fixed behavior without
# needing a full PyInstaller rebuild.

_PROVIDER_ROUTES_MOUNTED = False


def _patch_v1_routes():
    """Monkey-patch stale V1 LLM config routes at runtime.

    Replaces route.endpoint directly. Also removes duplicate stale routes
    from app.routes so our patched versions take priority.
    """
    try:
        import sys as _sys
        _main = _sys.modules.get("__main__")
        if not _main or not hasattr(_main, "app"):
            return
        _app = _main.app

        routes_to_remove = []
        routes_to_add = []

        # Collect all registered routes that need patching
        for route in _app.routes:
            rp = getattr(route, 'path', '')
            methods = getattr(route, 'methods', set())

            # PATCH: GET /api/agent_models — include all provider models
            if rp == '/api/agent_models' and 'GET' in methods:
                from src.llm.config import load_agent_models as _la
                from src.llm.config import load_llm_providers as _lop
                from src.llm.config import get_api_key as _gak
                from src.llm.provider_admin import read_allowed_models as _ram
                from fastapi.routing import APIRoute

                def _patched_get_agent_models():
                    agents = _la() or {}
                    # Use resolve_provider_for_model (simpler, one call per model)
                    from src.llm.config import resolve_provider_for_model, get_api_key as _gak2
                    available_models = []
                    seen = set()
                    enriched = []
                    for role, role_cfg in agents.items():
                        mid = str(role_cfg.get("model", "") or "").strip()
                        pname, _, pcfg = resolve_provider_for_model(mid)
                        has_key = bool(_gak2(pcfg)) if pcfg else False
                        out = dict(role_cfg)
                        out.pop("api_key_configured", None)
                        out.pop("provider", None)
                        out["api_key_configured"] = has_key
                        out["provider"] = pname or ""
                        enriched.append(out)
                        if mid and mid not in seen:
                            available_models.append(mid)
                            seen.add(mid)
                    try:
                        for mid in _ram():
                            if mid and mid not in seen:
                                available_models.append(mid)
                                seen.add(mid)
                    except Exception:
                        pass
                    return {"success": True, "agents": enriched, "available_models": available_models}

                routes_to_remove.append(route)
                routes_to_add.append(APIRoute(
                    path='/api/agent_models',
                    endpoint=_patched_get_agent_models,
                    methods=['GET'],
                ))

            # PATCH: POST /api/agent_models — preserve all fields
            elif rp == '/api/agent_models' and 'POST' in methods:
                import json as _json, os as _os
                from fastapi import Request as _Request

                async def _patched_update_agent_model(request: _Request):
                    try:
                        body = await request.json()
                    except Exception:
                        body = {}
                    items = []
                    if isinstance(body, list):
                        items = [x for x in body if isinstance(x, dict)]
                    elif isinstance(body, dict):
                        items = [body]
                    if not items:
                        return {"success": False, "error": "role 与 model 都不能为空"}
                    try:
                        import config_manager as _cm
                        cfg_path = _cm.get_config_dir() / "agent_models.json"
                        cfg_path.parent.mkdir(parents=True, exist_ok=True)
                        existing = []
                        if cfg_path.exists():
                            try:
                                raw = _json.loads(cfg_path.read_text(encoding="utf-8"))
                                if isinstance(raw, list):
                                    existing = [x for x in raw if isinstance(x, dict)]
                            except Exception:
                                existing = []
                        for item in items:
                            role = str(item.get("role", "") or "").strip()
                            model_id = str(item.get("model", "") or "").strip()
                            if not role or not model_id:
                                continue
                            replaced = False
                            for ex in existing:
                                if str(ex.get("role", "") or "").strip() == role:
                                    ex["model"] = model_id
                                    ex["status"] = str(item.get("status", "ready") or "ready")
                                    if item.get("temperature") is not None:
                                        ex["temperature"] = item["temperature"]
                                    if item.get("max_tokens") is not None:
                                        ex["max_tokens"] = item["max_tokens"]
                                    if item.get("timeout_sec") is not None:
                                        ex["timeout_sec"] = item["timeout_sec"]
                                    if item.get("description"):
                                        ex["description"] = item["description"]
                                    replaced = True
                                    break
                            if not replaced:
                                entry = {"role": role, "model": model_id, "status": str(item.get("status", "ready") or "ready")}
                                if item.get("temperature") is not None:
                                    entry["temperature"] = item["temperature"]
                                if item.get("max_tokens") is not None:
                                    entry["max_tokens"] = item["max_tokens"]
                                if item.get("timeout_sec") is not None:
                                    entry["timeout_sec"] = item["timeout_sec"]
                                if item.get("description"):
                                    entry["description"] = item["description"]
                                existing.append(entry)
                        tmp = cfg_path.with_suffix(cfg_path.suffix + ".tmp")
                        tmp.write_text(_json.dumps(existing, ensure_ascii=False, indent=2), encoding="utf-8")
                        _os.replace(tmp, str(cfg_path))
                        return {"success": True, "updated": len(items)}
                    except Exception as exc:
                        return {"success": False, "error": f"write agent_models.json failed: {exc}"}
                route.endpoint = _patched_update_agent_model

            # PATCH: GET /api/providers
            elif rp == '/api/providers' and 'GET' in methods:
                from src.llm.provider_admin import list_providers as _lp
                route.endpoint = _lp

            # PATCH: POST /api/providers
            elif rp == '/api/providers' and 'POST' in methods:
                from src.llm.provider_admin import save_provider as _sp

                def _patched_save_provider(payload: dict):
                    name = str(payload.get("name", "") or "").strip()
                    base_url = str(payload.get("baseUrl", "") or "").strip()
                    api = str(payload.get("api", "openai-responses") or "openai-responses").strip()
                    api_key = str(payload.get("apiKey", "") or "").strip()
                    models = payload.get("models", [])
                    if not isinstance(models, list):
                        models = []
                    if not name or not base_url:
                        return {"success": False, "error": "供应商名称和 baseUrl 不能为空"}
                    return _sp(name, base_url, api, api_key, models)
                route.endpoint = _patched_save_provider

            # PATCH: DELETE /api/providers/{provider_name}
            elif rp == '/api/providers/{provider_name}' and 'DELETE' in methods:
                from src.llm.provider_admin import delete_provider as _dp

                def _patched_delete_provider(provider_name: str):
                    provider_name = str(provider_name or "").strip()
                    if not provider_name:
                        return {"success": False, "error": "供应商名称不能为空"}
                    return _dp(provider_name)
                route.endpoint = _patched_delete_provider

        # Apply route replacements: remove stale routes, add patched ones
        for route in routes_to_remove:
            try:
                _app.routes.remove(route)
            except ValueError:
                pass
        for route in routes_to_add:
            _app.routes.insert(0, route)  # Insert at beginning for priority

        # Also mount V2 routes if not already mounted
        try:
            from src.llm.provider_routes import router as _provider_router
            _app.include_router(_provider_router)
        except Exception:
            pass

    except Exception:
        pass


def _mount_provider_routes():
    """Mount /api/v2/providers routes onto the main FastAPI app."""
    try:
        import sys as _sys
        _main = _sys.modules.get("__main__")
        if _main and hasattr(_main, "app"):
            _app = _main.app
            from src.llm.provider_routes import router as _provider_router
            _app.include_router(_provider_router)
            global _PROVIDER_ROUTES_MOUNTED
            _PROVIDER_ROUTES_MOUNTED = True
    except Exception:
        pass


# Module-level eager import — if this runs AFTER gui_backend's app is created,
# patches immediately. If before, the app's startup handler retries.
# DISABLED: original gui_backend handlers now have enrichment directly.
# _patch_v1_routes()
# _mount_provider_routes()


def mount_provider_routes():
    """Public entry point for gui_backend startup handler to retry mounting.

    Call this from gui_backend's @app.on_event('startup') handler to ensure
    the routes are always mounted, even if src.llm was imported before the
    FastAPI app was instantiated.
    """
    _patch_v1_routes()
    _mount_provider_routes()
    return _PROVIDER_ROUTES_MOUNTED
