# Familiar - Self-Hosted AI Companion Platform
# Copyright (c) 2026 George Scott Foley
# ORCID: 0009-0006-4957-0540
# Email: Georgescottfoley@proton.me
# Licensed under the MIT License - see LICENSE file for details

"""
ProviderManager — extracted from agent.py (PR 2).

Handles LLM provider lifecycle:
- Provider initialization with fallback cascade
- Model router setup for multi-LLM orchestration
- Provider selection per request (auto/manual routing, PHI guard)
- Provider fallback on failure
- Routing configuration (get/set)
"""

import logging
import os

from .providers import (
    LLMProvider,
    get_best_ollama_model,
    get_provider,
    invalidate_provider_cache,
)
from .resilience import ProviderCapacityTracker

try:
    from .providers import OllamaProvider as _OllamaProvider
except ImportError:
    _OllamaProvider = None

logger = logging.getLogger(__name__)

_PRESETS_PATH = None  # set lazily


def _get_presets_path():
    global _PRESETS_PATH
    if _PRESETS_PATH is None:
        from .paths import DATA_DIR

        _PRESETS_PATH = DATA_DIR / "routing_presets.json"
    return _PRESETS_PATH


def _load_presets() -> dict:
    """Load presets from disk, or return default structure."""
    path = _get_presets_path()
    if path.exists():
        import json

        with open(path) as f:
            return json.load(f)
    return _default_presets()


def _save_presets(data: dict):
    """Atomic write presets to disk."""
    import json

    path = _get_presets_path()
    path.parent.mkdir(parents=True, exist_ok=True)
    tmp = path.with_suffix(".tmp")
    with open(tmp, "w") as f:
        json.dump(data, f, indent=2)
    tmp.replace(path)


def _default_presets() -> dict:
    return {
        "version": 1,
        "active_preset": None,
        "presets": [
            {"slot": i, "name": f"Preset {i}", "mode": None, "routes": None}
            for i in range(1, 6)
        ],
    }


class ProviderManager:
    """Manages LLM provider initialization, routing, selection, and fallback."""

    # Local-first defaults: use Ollama models by default so new users
    # don't burn cloud API credits before explicitly opting in.
    _DEFAULT_MANUAL_ROUTES = {
        "coding": "ollama/llama3.2",
        "complex": "ollama/llama3.2",
        "creative": "ollama/qwen2.5:7b",
        "long": "ollama/llama3.2",
        "moderate": "ollama/qwen2.5:7b",
        "private": "ollama/llama3.2",
        "simple": "ollama/qwen2.5:0.5b",
    }

    def __init__(self, agent_ref):
        self._agent = agent_ref
        self.model_router = None
        self._routing_mode = "manual"
        self._manual_routes = dict(self._DEFAULT_MANUAL_ROUTES)
        self.capacity_tracker = ProviderCapacityTracker()
        self._cached_ollama_models: "list[str] | None" = None

    def init_provider(self) -> LLMProvider:
        """Initialize the LLM provider.

        Checks both API key presence AND package availability
        before selecting a provider. Falls through gracefully:
        anthropic → openai → ollama (native, zero deps).
        """
        config = self._agent.config
        provider_name = config.llm.default_provider

        has_anthropic_key = bool(os.environ.get("ANTHROPIC_API_KEY"))
        has_openai_key = bool(os.environ.get("OPENAI_API_KEY"))

        try:
            import anthropic  # noqa: F401

            has_anthropic_pkg = True
        except ImportError:
            has_anthropic_pkg = False

        try:
            import openai  # noqa: F401

            has_openai_pkg = True
        except ImportError:
            has_openai_pkg = False

        has_anthropic = has_anthropic_key and has_anthropic_pkg
        has_openai = has_openai_key and has_openai_pkg

        if has_anthropic_key and not has_anthropic_pkg:
            logger.warning("ANTHROPIC_API_KEY is set but 'anthropic' package not installed.")
        if has_openai_key and not has_openai_pkg:
            logger.warning("OPENAI_API_KEY is set but 'openai' package not installed.")

        # Fallback logic
        if provider_name in ["anthropic", "claude"] and not has_anthropic:
            if has_openai:
                provider_name = "openai"
                logger.warning("Anthropic unavailable, falling back to OpenAI")
            else:
                provider_name = "ollama"
                logger.warning("No cloud providers available, falling back to Ollama (local)")

        if provider_name in ["openai", "gpt"] and not has_openai:
            if has_anthropic:
                provider_name = "anthropic"
                logger.warning("OpenAI unavailable, falling back to Anthropic")
            else:
                provider_name = "ollama"
                logger.warning("No cloud providers available, falling back to Ollama (local)")

        provider = get_provider(provider_name, config)
        self._validate_connectivity(provider)
        return provider

    def _validate_connectivity(self, provider: LLMProvider) -> None:
        """Warn-only connectivity check after provider init. Never raises."""
        try:
            key = provider.provider_key
            if key == "ollama":
                import urllib.request

                req = urllib.request.Request(
                    f"{provider.base_url}/api/tags", method="GET"
                )
                urllib.request.urlopen(req, timeout=2)
            elif key == "anthropic":
                api_key = os.environ.get("ANTHROPIC_API_KEY", "")
                if not api_key.startswith("sk-ant-"):
                    logger.warning("ANTHROPIC_API_KEY does not start with 'sk-ant-' — may be invalid")
            elif key == "openai":
                api_key = os.environ.get("OPENAI_API_KEY", "")
                if not api_key.startswith("sk-"):
                    logger.warning("OPENAI_API_KEY does not start with 'sk-' — may be invalid")
            elif key == "gemini":
                api_key = os.environ.get("GEMINI_API_KEY") or os.environ.get("GOOGLE_API_KEY", "")
                if len(api_key) < 10:
                    logger.warning("GEMINI_API_KEY appears too short — may be invalid")
        except Exception as e:
            logger.warning(f"Connectivity check for {provider.name} failed: {e}")

    def init_model_router(self):
        """Initialize multi-LLM model router if multiple providers are available."""
        config = self._agent.config
        try:
            from .model_router import ModelRouter, RoutingStrategy
        except ImportError:
            logger.debug("Model router module not available")
            return

        has_anthropic = bool(os.environ.get("ANTHROPIC_API_KEY"))
        has_openai = bool(os.environ.get("OPENAI_API_KEY"))
        has_gemini = bool(os.environ.get("GEMINI_API_KEY") or os.environ.get("GOOGLE_API_KEY"))
        has_ollama = False
        ollama_models = []

        try:
            import urllib.request

            req = urllib.request.Request(
                f"{config.llm.ollama_base_url}/api/tags",
                method="GET",
            )
            with urllib.request.urlopen(req, timeout=1) as resp:
                import json

                data = json.loads(resp.read())
                ollama_models = [m["name"] for m in data.get("models", [])]
                has_ollama = len(ollama_models) > 0
                self._cached_ollama_models = ollama_models
        except Exception:
            logger.debug("Ollama availability check failed, assuming unavailable")

        available_count = sum([has_anthropic, has_openai, has_gemini, has_ollama])
        if available_count < 2:
            logger.debug(f"Model router: {available_count} provider(s), skipping (need 2+)")
            return

        router = ModelRouter(
            default_model=self._agent.provider.name,
            default_provider=config.llm.default_provider,
            strategy=RoutingStrategy.FIRST_MATCH,
        )

        # Private data → always route to local Ollama
        if has_ollama:
            ollama_model = config.llm.ollama_model
            router.add_model(
                name=f"ollama/{ollama_model}",
                provider="ollama",
                predicate=lambda q: q.is_private,
                priority=5,
            )

        # Complex reasoning or tool use → best cloud model
        if has_anthropic:
            router.add_model(
                name="claude-sonnet",
                provider="anthropic",
                predicate=lambda q: q.complexity > 0.5 or q.requires_reasoning or q.requires_tools,
                priority=10,
            )
        elif has_openai:
            router.add_model(
                name="gpt-4o",
                provider="openai",
                predicate=lambda q: q.complexity > 0.5 or q.requires_reasoning or q.requires_tools,
                priority=10,
            )
        elif has_gemini:
            router.add_model(
                name="gemini-2.5-flash",
                provider="gemini",
                predicate=lambda q: q.complexity > 0.5 or q.requires_reasoning or q.requires_tools,
                priority=10,
            )

        # Simple/fast queries → lightweight
        if has_anthropic:
            router.add_model(
                name="claude-haiku",
                provider="anthropic",
                predicate=lambda q: q.is_simple,
                priority=30,
            )
        elif has_openai:
            router.add_model(
                name="gpt-4o-mini",
                provider="openai",
                predicate=lambda q: q.is_simple,
                priority=30,
            )
        elif has_gemini:
            router.add_model(
                name="gemini-2.5-flash",
                provider="gemini",
                predicate=lambda q: q.is_simple,
                priority=30,
            )

        # Register remaining provider models
        extra_models = {}
        if has_anthropic:
            extra_models.update(
                {
                    "claude-sonnet": "anthropic",
                    "claude-haiku": "anthropic",
                }
            )
        if has_openai:
            extra_models.update(
                {
                    "gpt-4o": "openai",
                    "gpt-4o-mini": "openai",
                }
            )
        if has_gemini:
            extra_models.update(
                {
                    "gemini-2.5-flash": "gemini",
                }
            )
        if has_ollama:
            for om in ollama_models:
                extra_models[f"ollama/{om}"] = "ollama"
        for name, prov in extra_models.items():
            if not router.has_model(name):
                router.add_model(name=name, provider=prov)

        # Build fallback chain
        fallbacks = []
        if has_anthropic:
            fallbacks.append("claude-sonnet")
        if has_openai:
            fallbacks.append("gpt-4o")
        if has_gemini:
            fallbacks.append("gemini-2.5-flash")
        if has_ollama:
            fallbacks.append(f"ollama/{config.llm.ollama_model}")
        router.set_fallback_chain(fallbacks)

        self.model_router = router
        logger.info(
            f"Model router active: {available_count} providers, "
            f"{router.get_model_count()} models configured, "
            f"fallback chain: {' → '.join(fallbacks)}"
        )

        # Auto-load active routing preset
        try:
            data = _load_presets()
            active = data.get("active_preset")
            if active:
                preset = next((p for p in data["presets"] if p["slot"] == active), None)
                if preset and preset["mode"]:
                    self._routing_mode = preset["mode"]
                    self._manual_routes = dict(preset["routes"])
                    logger.info(
                        f"Loaded routing preset '{preset['name']}' (slot {active})"
                    )
        except Exception as e:
            logger.debug(f"Routing preset auto-load skipped: {e}")

    def select_provider(self, message: str, session):
        """
        Route message to the best available provider.

        Returns (active_provider, model_name).
        """
        agent = self._agent
        active_provider = agent.provider
        model_name = active_provider.model_name
        manual_routed = False

        # Manual routing
        if self.model_router and self._routing_mode == "manual" and self._manual_routes:
            try:
                analyzer = self.model_router.analyzer
                qc = analyzer.analyze(message)
                route_model = self._manual_routes.get(qc.query_type.value)
                if route_model and self.model_router.has_model(route_model):
                    cfg = self.model_router.get_model(route_model)
                    routed = get_provider(cfg.provider, agent.config)
                    active_provider = routed
                    model_name = route_model
                    manual_routed = True
                    logger.info(
                        f"Manual route: {qc.query_type.value} -> {route_model} ({cfg.provider})"
                    )
            except Exception as e:
                logger.debug(f"Manual routing fallback to auto: {e}")

        # Auto routing (skipped if manual routing already succeeded)
        if self.model_router and not manual_routed:
            try:
                model_name, provider_name = self.model_router.route(message)
                routed = get_provider(provider_name, agent.config)
                active_provider = routed
                if provider_name != agent.config.llm.default_provider:
                    logger.info(f"Routed to {model_name} ({provider_name})")
            except Exception as e:
                logger.debug(f"Router fallback to default: {e}")

        # PHI guard — hard constraint: PHI never leaves device in HIPAA mode
        if agent.compliance_mode and hasattr(active_provider, "provider_key"):
            try:
                from .compliance import check_phi_content

                if check_phi_content(message, agent.compliance_mode):
                    is_cloud = active_provider.provider_key in ("anthropic", "openai", "gemini")
                    if is_cloud:
                        try:
                            local_provider = get_provider("ollama", agent.config)
                            logger.warning(
                                f"PHI detected — rerouted from {model_name} to "
                                f"local Ollama ({agent.config.llm.ollama_model})"
                            )
                            return local_provider, model_name
                        except Exception as fallback_err:
                            logger.error("PHI detected but no local provider available")
                            raise RuntimeError("PHI_NO_LOCAL_FALLBACK") from fallback_err
            except ImportError:
                logger.debug("PHI detection library not available, skipping PHI guard")

        return active_provider, model_name

    def provider_fallback(self, active_provider, primary_error: Exception):
        """
        Attempt a provider fallback after a failure.

        Returns the fallback provider or None if no fallback is available.
        """
        agent = self._agent
        active_key = active_provider.provider_key

        # Record circuit breaker failure
        if self.model_router:
            try:
                self.model_router.record_failure_for_provider(active_key)
            except Exception as e:
                logger.debug(f"Circuit breaker update failed: {e}")

        # Record rate limit events for capacity tracking
        err_type = type(primary_error).__name__
        err_str = str(primary_error).lower()
        if "ratelimit" in err_type.lower() or "rate limit" in err_str or "429" in err_str:
            self.capacity_tracker.record_rate_limit(active_key)
            logger.info(f"Capacity tracker: recorded rate limit for {active_key}")

        fallback = None
        attempted = []
        skipped = []

        fallback_order = ["ollama", "anthropic", "openai", "gemini"]
        for fb_name in fallback_order:
            if fb_name == active_key:
                continue
            if self.capacity_tracker.is_rate_limited(fb_name):
                skipped.append(fb_name)
                logger.info(f"Skipping {fb_name} fallback — rate limited (capacity tracker)")
                continue
            attempted.append(fb_name)
            try:
                if fb_name == "ollama":
                    fallback = get_best_ollama_model(agent.config)
                    if not fallback:
                        fallback = get_provider("ollama", agent.config)
                else:
                    fallback = get_provider(fb_name, agent.config)
                logger.warning(f"{active_provider.name} failed — falling back to {fallback.name}")
                break
            except Exception as e:
                logger.debug("Error suppressed: %s", e)
                continue

        if fallback is None:
            logger.warning(
                "All fallback providers exhausted. Tried: %s. Skipped: %s",
                attempted,
                skipped,
            )

        return fallback

    def get_routing_config(self) -> dict:
        """Return current routing configuration for the dashboard."""
        agent = self._agent
        from .model_router import QueryContext, QueryType

        result = {
            "mode": self._routing_mode,
            "router_active": self.model_router is not None,
            "available_models": [],
            "categories": {},
        }

        if not self.model_router:
            result["available_models"] = [agent.provider.name]
            return result

        router_models = set(self.model_router.get_model_names())
        # Merge in all Ollama catalog + installed models
        try:
            from familiar.channels.connect_wizard._llm import OLLAMA_MODEL_CATALOG

            for tag, *_ in OLLAMA_MODEL_CATALOG:
                router_models.add(f"ollama/{tag}")
        except ImportError:
            pass
        try:
            import httpx as _httpx

            resp = _httpx.get("http://localhost:11434/api/tags", timeout=3)
            for m in resp.json().get("models", []):
                name = m.get("name", "")
                if name:
                    router_models.add(f"ollama/{name}")
        except Exception:
            pass
        result["available_models"] = sorted(router_models)

        category_descriptions = {
            "simple": "Quick factual answers, greetings",
            "moderate": "Standard Q&A conversations",
            "complex": "Multi-step reasoning tasks",
            "coding": "Code generation and review",
            "creative": "Writing and brainstorming",
            "private": "Contains sensitive data (local only)",
            "long": "Large input context",
        }
        category_flags = {
            "simple": {"is_simple": True, "complexity": 0.1},
            "moderate": {"complexity": 0.5},
            "complex": {"is_complex": True, "complexity": 0.9, "requires_reasoning": True},
            "coding": {"is_coding": True, "complexity": 0.6, "requires_tools": True},
            "creative": {"is_creative": True, "complexity": 0.5},
            "private": {"is_private": True, "complexity": 0.5},
            "long": {"is_long_context": True, "complexity": 0.5},
        }

        for qt in QueryType:
            flags = category_flags.get(qt.value, {"complexity": 0.5})
            ctx = QueryContext(**flags)
            try:
                auto_model, _ = self.model_router.route(ctx)
            except Exception:
                auto_model = self.model_router.default_model
            manual_model = self._manual_routes.get(qt.value, auto_model)
            result["categories"][qt.value] = {
                "auto_model": auto_model,
                "model": manual_model,
                "description": category_descriptions.get(qt.value, ""),
            }

        return result

    def get_presets(self) -> dict:
        """Return all 5 preset slots + active_preset indicator."""
        return _load_presets()

    def save_preset(self, slot: int, name: str = None) -> dict:
        """Save current routing config to a slot (1-5)."""
        if slot < 1 or slot > 5:
            raise ValueError("Slot must be 1-5")
        data = _load_presets()
        preset = next(p for p in data["presets"] if p["slot"] == slot)
        preset["mode"] = self._routing_mode
        preset["routes"] = dict(self._manual_routes)
        if name:
            preset["name"] = name
        _save_presets(data)
        return data

    def load_preset(self, slot: int) -> dict:
        """Load a preset into the active routing config."""
        data = _load_presets()
        preset = next(p for p in data["presets"] if p["slot"] == slot)
        if preset["mode"] is None:
            raise ValueError(f"Slot {slot} is empty")
        self._routing_mode = preset["mode"]
        self._manual_routes = dict(preset["routes"])
        return self.get_routing_config()

    def set_active_preset(self, slot: "int | None") -> dict:
        """Set which preset auto-loads on startup (None to clear)."""
        data = _load_presets()
        if slot is not None:
            preset = next(p for p in data["presets"] if p["slot"] == slot)
            if preset["mode"] is None:
                raise ValueError(f"Slot {slot} is empty — save a config first")
        data["active_preset"] = slot
        _save_presets(data)
        return data

    def rename_preset(self, slot: int, name: str) -> dict:
        """Rename a preset slot."""
        data = _load_presets()
        preset = next(p for p in data["presets"] if p["slot"] == slot)
        preset["name"] = name
        _save_presets(data)
        return data

    def delete_preset(self, slot: int) -> dict:
        """Clear a preset slot."""
        data = _load_presets()
        preset = next(p for p in data["presets"] if p["slot"] == slot)
        preset["mode"] = None
        preset["routes"] = None
        preset["name"] = f"Preset {slot}"
        if data["active_preset"] == slot:
            data["active_preset"] = None
        _save_presets(data)
        return data

    def set_routing_config(self, mode: str, routes: dict = None) -> dict:
        """Update routing mode and manual route assignments."""
        if mode not in ("auto", "manual"):
            raise ValueError(f"Invalid routing mode: {mode!r} (expected 'auto' or 'manual')")
        self._routing_mode = mode
        if routes is not None:
            self._manual_routes = dict(routes)
        return self.get_routing_config()

    def switch_provider(self, provider_name: str) -> str:
        """Switch to a different LLM provider."""
        agent = self._agent
        try:
            invalidate_provider_cache(provider_name)
            agent.provider = get_provider(provider_name, agent.config)
            self.init_model_router()
            return f"Switched to {agent.provider.name}"
        except ValueError as e:
            return str(e)
