# chuk_llm/llm/client.py
"""
Enhanced LLM client factory with transparent discovery support
============================================================

Simple client factory using the unified configuration system with seamless discovery.
API remains identical - discovery happens automatically when needed.
"""

import importlib
import inspect
import logging
import os
from typing import Any

from chuk_llm.configuration import ConfigValidator, Feature, get_config
from chuk_llm.llm.core.base import BaseLLMClient

logger = logging.getLogger(__name__)


def _import_string(import_string: str) -> type:
    """Import class from string path.

    Supports both colon syntax (module:class) and dot syntax (module.class).
    """
    if ":" in import_string:
        module_path, class_name = import_string.split(":", 1)
    else:
        module_path, class_name = import_string.rsplit(".", 1)

    module = importlib.import_module(module_path)
    return getattr(module, class_name)


def _supports_param(cls: type, param_name: str) -> bool:
    """Check if class constructor supports a parameter"""
    sig = inspect.signature(cls.__init__)  # type: ignore[misc]
    params = sig.parameters

    # Check if parameter exists directly
    if param_name in params:
        return True

    # Check if **kwargs parameter exists
    has_kwargs = any(p.kind == inspect.Parameter.VAR_KEYWORD for p in params.values())
    return has_kwargs


def _constructor_kwargs(cls: type, config: dict[str, Any]) -> dict[str, Any]:
    """Get constructor arguments for client class from config"""
    # Get constructor signature
    sig = inspect.signature(cls.__init__)  # type: ignore[misc]
    params = sig.parameters

    # Map config to constructor arguments
    args = {}

    # Common parameter mappings
    if "model" in params and config.get("model"):
        args["model"] = config["model"]

    if "api_key" in params and config.get("api_key"):
        args["api_key"] = config["api_key"]

    if "api_base" in params and config.get("api_base"):
        args["api_base"] = config["api_base"]

    # Check for **kwargs parameter
    has_kwargs = any(p.kind == inspect.Parameter.VAR_KEYWORD for p in params.values())

    if has_kwargs:
        # Add all config values if constructor accepts **kwargs
        for key, value in config.items():
            if key not in args and value is not None:
                args[key] = value
    else:
        # Only add values for known parameters
        for key, value in config.items():
            if key in params and key not in args and value is not None:
                args[key] = value

    return args


def _create_client_internal(
    provider: str,
    model: str | None = None,
    api_key: str | None = None,
    api_base: str | None = None,
    **kwargs,
) -> BaseLLMClient:
    """
    Internal client creation logic (no caching).

    Args:
        provider: Provider name (from YAML config)
        model: Model override (uses provider default if not specified)
        api_key: API key override (uses environment if not specified)
        api_base: API base URL override (uses provider default if not specified)
        **kwargs: Additional client arguments

    Returns:
        Configured LLM client
    """
    try:
        config_manager = get_config()
        provider_config = config_manager.get_provider(provider)
    except Exception as e:
        raise ValueError(f"Failed to get provider '{provider}': {e}") from e

    # Determine the model to use (with transparent discovery)
    target_model = model or provider_config.default_model

    # CRITICAL FIX: Skip validation for Azure OpenAI deployments
    if provider == "azure_openai":
        # Azure deployments can have ANY name - don't validate against a list
        if not target_model:
            # Try to use default if no model specified
            target_model = provider_config.default_model
            if not target_model:
                raise ValueError(
                    "No deployment specified for Azure OpenAI and no default configured"
                )

        logger.info(
            f"Azure OpenAI: Using deployment '{target_model}' (custom deployments supported)"
        )

        # For Azure, pass through Azure-specific parameters
        if "azure_endpoint" not in kwargs:
            kwargs["azure_endpoint"] = (
                api_base
                or provider_config.api_base
                or os.getenv("AZURE_OPENAI_ENDPOINT")
            )
        if "api_version" not in kwargs:
            kwargs["api_version"] = os.getenv("AZURE_OPENAI_API_VERSION", "2024-02-01")
        if "azure_deployment" not in kwargs:
            kwargs["azure_deployment"] = target_model

    # Special handling for llamacpp: resolve model names to GGUF paths
    elif provider == "llamacpp" and target_model:
        from pathlib import Path

        # Check if target_model is already a valid file path
        model_path = Path(target_model)
        if not model_path.exists():
            # Try to resolve from llama.cpp cache first, then Ollama
            try:
                # Check llama.cpp cache directory
                llamacpp_cache = Path.home() / "Library" / "Caches" / "llama.cpp"
                if llamacpp_cache.exists():
                    # Look for GGUF files matching the model name
                    # Convert model name format: "gpt-oss:20b" -> "gpt-oss-20b"
                    model_name_normalized = target_model.lower().replace(":", "-")

                    for gguf_file in llamacpp_cache.glob("*.gguf"):
                        # Skip .etag files and mmproj files (vision projections)
                        if (
                            gguf_file.suffix == ".etag"
                            or "mmproj" in gguf_file.name.lower()
                        ):
                            continue

                        # For llama.cpp cache, we want an exact match of the model name pattern
                        # Files are named like: ggml-org_gpt-oss-20b-GGUF_gpt-oss-20b-mxfp4.gguf
                        filename_lower = gguf_file.stem.lower()

                        # Check if the normalized model name appears in the filename
                        # But also check it's not a vision model (has -vl suffix)
                        if (
                            model_name_normalized in filename_lower
                            and "-vl-" not in filename_lower
                            and not filename_lower.endswith("-vl")
                        ):
                            logger.info(
                                f"Resolved llamacpp model '{target_model}' to cached file: "
                                f"{gguf_file.name} ({gguf_file.stat().st_size / 1e9:.1f} GB)"
                            )
                            target_model = str(gguf_file)
                            break

                # If not found in llama.cpp cache, try Ollama models
                if (
                    target_model == target_model
                ):  # Model name unchanged, not resolved yet
                    pass  # Fall through to Ollama resolution below
            except Exception as e:
                logger.debug(f"Error checking llama.cpp cache: {e}")

            # Try to resolve from Ollama models if not found in cache
            if model_path == Path(target_model) or not Path(target_model).exists():
                try:
                    from chuk_llm.registry.resolvers.llamacpp_ollama import (
                        OllamaModel,
                        discover_ollama_models,
                    )

                    models = [
                        m
                        for m in discover_ollama_models()
                        if not m.name.startswith("sha256-")
                    ]

                    # Try to find matching model by name
                    resolved_ollama_model: OllamaModel | None = None
                    target_lower = target_model.lower()

                    # Try exact match first
                    for m in models:
                        if m.name.lower() == target_lower:
                            resolved_ollama_model = m
                            break

                    # Try exact match with library/ prefix
                    if not resolved_ollama_model:
                        for m in models:
                            if m.name.lower() == f"library/{target_lower}":
                                resolved_ollama_model = m
                                break

                    # Try partial match (e.g., "qwen3" matches "library/qwen3:0.6b")
                    if not resolved_ollama_model:
                        for m in models:
                            # Match if target is in the model name (after library/)
                            model_name_parts = m.name.lower().split("/")
                            model_basename = (
                                model_name_parts[-1]
                                if model_name_parts
                                else m.name.lower()
                            )
                            if target_lower in model_basename:
                                resolved_ollama_model = m
                                break

                    if resolved_ollama_model:
                        logger.info(
                            f"Resolved llamacpp model '{target_model}' to {resolved_ollama_model.name} "
                            f"({resolved_ollama_model.size_bytes / 1e9:.1f} GB)"
                        )
                        target_model = str(resolved_ollama_model.gguf_path)
                    else:
                        # Model not found in Ollama, provide helpful error
                        available = [m.name for m in models[:5]]
                        raise ValueError(
                            f"llamacpp model '{target_model}' not found. "
                            f"Provide a GGUF file path or install via Ollama.\n"
                            f"Available Ollama models: {available}{'...' if len(models) > 5 else ''}\n"
                            f"Install with: ollama pull {target_model}"
                        )
                except ImportError:
                    # Ollama discovery not available, just pass through
                    logger.warning(
                        f"Could not resolve llamacpp model '{target_model}' - "
                        f"provide full GGUF file path"
                    )

    elif target_model:
        # For non-Azure providers, validate the model exists
        # Try to ensure model is available (triggers discovery if needed)
        model_available = config_manager._ensure_model_available(provider, target_model)
        if not model_available:
            # Model not found, check if it's an alias
            if (
                target_model not in provider_config.models
                and "*" not in provider_config.models
            ):
                # Check if it's an alias
                resolved_model = provider_config.model_aliases.get(target_model)
                if resolved_model and resolved_model in provider_config.models:
                    target_model = resolved_model
                else:
                    # Model not found after discovery attempt
                    available = provider_config.models[:5]  # Show first 5
                    raise ValueError(
                        f"Model '{target_model}' not available for provider '{provider}'. "
                        f"Available: {available}{'...' if len(provider_config.models) > 5 else ''}"
                    )

    if not target_model:
        raise ValueError(
            f"No model specified and no default model for provider '{provider}'"
        )

    # Validate provider configuration (be lenient for Azure)
    is_valid, issues = ConfigValidator.validate_provider_config(provider_config)
    if not is_valid:
        if provider == "azure_openai":
            # For Azure, just log warnings but continue
            logger.debug(
                f"Azure OpenAI config validation issues (continuing anyway): {', '.join(issues)}"
            )
        else:
            raise ValueError(
                f"Invalid provider configuration for '{provider}': {', '.join(issues)}"
            )

    # Build client configuration
    client_config = {
        "model": target_model,
        "api_key": api_key or config_manager.get_api_key(provider),
    }

    # Handle api_base differently for Azure
    if provider == "azure_openai":
        # For Azure, prefer azure_endpoint over api_base
        if "azure_endpoint" in kwargs:
            client_config["azure_endpoint"] = kwargs["azure_endpoint"]
        else:
            # Use get_api_base which checks environment variables
            client_config["azure_endpoint"] = (
                api_base
                or config_manager.get_api_base(provider)
                or os.getenv("AZURE_OPENAI_ENDPOINT")
            )
    else:
        # Use get_api_base which checks environment variables
        client_config["api_base"] = api_base or config_manager.get_api_base(provider)

    # Add extra provider config
    client_config.update(provider_config.extra)

    # Add explicit kwargs (highest priority)
    client_config.update(kwargs)

    # Get client class
    client_class = provider_config.client_class
    if not client_class or client_class.strip() == "":
        raise ValueError(f"No client class configured for provider '{provider}'")

    try:
        client_class_type = _import_string(client_class)
    except Exception as e:
        raise ValueError(f"Failed to import client class '{client_class}': {e}") from e

    # Get constructor arguments
    constructor_args = _constructor_kwargs(client_class_type, client_config)

    # Special handling for llamacpp: remove api_base since it constructs it internally
    if provider == "llamacpp" and "api_base" in constructor_args:
        # LlamaCppLLMClient constructs api_base from host:port internally
        del constructor_args["api_base"]

    # Create client instance
    try:
        client = client_class_type(**constructor_args)
        logger.debug(f"Created {provider} client with model {target_model}")
        return client
    except Exception as e:
        if provider == "azure_openai":
            raise ValueError(
                f"Failed to create Azure OpenAI client with deployment '{target_model}': {e}\n"
                f"Ensure AZURE_OPENAI_ENDPOINT and AZURE_OPENAI_API_KEY are set correctly."
            ) from e
        else:
            raise ValueError(f"Failed to create {provider} client: {e}") from e


def get_client(
    provider: str,
    model: str | None = None,
    api_key: str | None = None,
    api_base: str | None = None,
    use_cache: bool = True,
    **kwargs,
) -> BaseLLMClient:
    """
    Enhanced client factory with transparent discovery support and automatic caching.

    FIXED: Skips model validation for Azure OpenAI to support custom deployments.

    Client Caching:
        By default, clients are automatically cached and reused when the same
        provider/model/config is requested. This saves ~12ms per duplicate client
        creation. Caching can be disabled via:
        - use_cache=False parameter
        - CHUK_LLM_CACHE_CLIENTS=0 environment variable

    Args:
        provider: Provider name (from YAML config)
        model: Model override (uses provider default if not specified)
        api_key: API key override (uses environment if not specified)
        api_base: API base URL override (uses provider default if not specified)
        use_cache: Whether to use client registry caching (default: True)
        **kwargs: Additional client arguments

    Returns:
        Configured LLM client (cached if use_cache=True)

    Examples:
        >>> # Automatic caching (saves 12ms per duplicate)
        >>> client1 = get_client("openai", model="gpt-4o")
        >>> client2 = get_client("openai", model="gpt-4o")  # Returns same instance
        >>> assert client1 is client2

        >>> # Disable caching
        >>> client = get_client("openai", model="gpt-4o", use_cache=False)
    """
    # Check if caching is enabled (environment variable or parameter)
    cache_enabled = use_cache and os.getenv("CHUK_LLM_CACHE_CLIENTS", "1") == "1"

    if cache_enabled:
        # Use registry for automatic client caching
        from chuk_llm.client_registry import get_cached_client

        return get_cached_client(
            provider=provider,
            model=model,
            api_key=api_key,
            api_base=api_base,
            **kwargs,
        )

    # Non-cached path - create client directly
    return _create_client_internal(
        provider=provider,
        model=model,
        api_key=api_key,
        api_base=api_base,
        **kwargs,
    )


def validate_request_compatibility(
    provider: str,
    model: str | None = None,
    messages: Any | None = None,
    tools: Any | None = None,
    stream: bool = False,
    **kwargs,
) -> dict[str, Any]:
    """
    Validate if a request is compatible with provider/model.

    Args:
        provider: Provider name
        model: Model name (optional)
        messages: Chat messages (for vision detection)
        tools: Function tools
        stream: Whether streaming is requested
        **kwargs: Additional parameters

    Returns:
        Dictionary with validation results
    """
    try:
        is_valid, issues = ConfigValidator.validate_request_compatibility(
            provider_name=provider,
            model=model,
            messages=messages,
            tools=tools,
            stream=stream,
            **kwargs,
        )

        return {
            "valid": is_valid,
            "issues": issues,
            "provider": provider,
            "model": model,
        }
    except Exception as e:
        return {
            "valid": False,
            "issues": [f"Validation error: {e}"],
            "provider": provider,
            "model": model,
        }


def list_available_providers() -> dict[str, dict[str, Any]]:
    """
    List all available providers and their info including discovered models.

    Returns:
        Dictionary with provider info including model-level capabilities
    """
    config_manager = get_config()
    providers = {}

    for provider_name in config_manager.get_all_providers():
        try:
            provider_config = config_manager.get_provider(provider_name)
            has_api_key = bool(config_manager.get_api_key(provider_name))

            # Get model capabilities info
            model_info = {}
            for model in provider_config.models:
                model_caps = provider_config.get_model_capabilities(model)
                model_info[model] = {
                    "features": [f.value for f in model_caps.features],
                    "max_context_length": model_caps.max_context_length,
                    "max_output_tokens": model_caps.max_output_tokens,
                }

            # Check if discovery is enabled
            discovery_enabled = False
            discovery_stats = {}
            discovery_data = provider_config.extra.get("dynamic_discovery")
            if discovery_data and discovery_data.get("enabled"):
                discovery_enabled = True
                # Get discovery stats if available
                cached_data = config_manager._discovery_cache.get(provider_name)  # type: ignore[attr-defined]
                if cached_data:
                    discovery_stats = {
                        "total_models": len(provider_config.models),
                        "discovered_count": cached_data.get("discovered_count", 0),
                        "new_count": cached_data.get("new_count", 0),
                        "cache_age_seconds": int(
                            time.time() - cached_data["timestamp"]
                        ),
                    }

            providers[provider_name] = {
                "default_model": provider_config.default_model,
                "models": provider_config.models,
                "model_aliases": provider_config.model_aliases,
                "baseline_features": [f.value for f in provider_config.features],
                "model_capabilities": model_info,
                "has_api_key": has_api_key,
                "api_base": provider_config.api_base,
                "rate_limits": provider_config.rate_limits,
                "discovery_enabled": discovery_enabled,
                "discovery_stats": discovery_stats,
            }
        except Exception as e:
            providers[provider_name] = {"error": str(e)}

    return providers


def get_provider_info(provider: str, model: str | None = None) -> dict[str, Any]:
    """
    Get detailed information about a specific provider/model combination.

    Args:
        provider: Provider name
        model: Model name (optional, uses default if not specified)

    Returns:
        Detailed provider/model information
    """
    try:
        config_manager = get_config()
        provider_config = config_manager.get_provider(provider)

        target_model = model or provider_config.default_model
        model_caps = provider_config.get_model_capabilities(target_model)

        # Check if model was discovered dynamically
        is_discovered = False
        discovery_info = {}
        cached_data = config_manager._discovery_cache.get(provider)  # type: ignore[attr-defined]
        if cached_data and target_model in cached_data.get("models", []):
            # Check if this model was added by discovery
            static_models = set()
            for cap in provider_config.model_capabilities:
                if cap.pattern.startswith("^") and cap.pattern.endswith("$"):
                    # Extract exact model name from pattern
                    static_name = cap.pattern[1:-1].replace("\\", "")
                    static_models.add(static_name)

            if target_model not in static_models:
                is_discovered = True
                discovery_info = {
                    "discovered_at": cached_data["timestamp"],
                    "discovery_session": True,
                }

        return {
            "provider": provider,
            "model": target_model,
            "client_class": provider_config.client_class,
            "api_base": provider_config.api_base,
            "has_api_key": bool(config_manager.get_api_key(provider)),
            "features": [f.value for f in model_caps.features],
            "max_context_length": model_caps.max_context_length,
            "max_output_tokens": model_caps.max_output_tokens,
            "rate_limits": provider_config.rate_limits,
            "available_models": provider_config.models,
            "model_aliases": provider_config.model_aliases,
            "is_discovered": is_discovered,
            "discovery_info": discovery_info,
            "supports": {
                "streaming": provider_config.supports_feature(
                    Feature.STREAMING, target_model
                ),
                "tools": provider_config.supports_feature(Feature.TOOLS, target_model),
                "vision": provider_config.supports_feature(
                    Feature.VISION, target_model
                ),
                "json_mode": provider_config.supports_feature(
                    Feature.JSON_MODE, target_model
                ),
                "parallel_calls": provider_config.supports_feature(
                    Feature.PARALLEL_CALLS, target_model
                ),
            },
        }
    except Exception as e:
        return {"error": str(e)}


def validate_provider_setup(provider: str) -> dict[str, Any]:
    """
    Validate provider setup and configuration including discovery status.

    Args:
        provider: Provider name to validate

    Returns:
        Comprehensive validation results
    """
    try:
        config_manager = get_config()
        provider_config = config_manager.get_provider(provider)
    except Exception as e:
        return {
            "valid": False,
            "error": f"Provider not found: {e}",
        }

    # Validate provider config
    is_valid, config_issues = ConfigValidator.validate_provider_config(provider_config)

    warnings = []

    # Check client class import
    client_import_ok = True
    try:
        _import_string(provider_config.client_class)
    except Exception as e:
        client_import_ok = False
        config_issues.append(f"Cannot import client class: {e}")

    # Check API key
    api_key = config_manager.get_api_key(provider)
    has_api_key = bool(api_key)

    # Check models
    if not provider_config.models:
        warnings.append("No models configured")

    # Check discovery status
    discovery_enabled = False
    discovery_stats = {}
    discovery_data = provider_config.extra.get("dynamic_discovery")
    if discovery_data and discovery_data.get("enabled"):
        discovery_enabled = True
        cached_data = config_manager._discovery_cache.get(provider)  # type: ignore[attr-defined]
        if cached_data:
            discovery_stats = {
                "total_models": len(provider_config.models),
                "discovered_count": cached_data.get("discovered_count", 0),
                "new_count": cached_data.get("new_count", 0),
                "last_discovery": cached_data["timestamp"],
                "cache_age_seconds": int(time.time() - cached_data["timestamp"]),
            }

    # Get model capabilities summary
    model_capabilities = {}
    for model in provider_config.models[:5]:  # Limit to first 5 for brevity
        caps = provider_config.get_model_capabilities(model)
        model_capabilities[model] = [f.value for f in caps.features]

    return {
        "valid": is_valid and client_import_ok,
        "issues": config_issues,
        "warnings": warnings,
        "has_api_key": has_api_key,
        "client_import_ok": client_import_ok,
        "default_model": provider_config.default_model,
        "models_count": len(provider_config.models),
        "aliases_count": len(provider_config.model_aliases),
        "baseline_features": [f.value for f in provider_config.features],
        "model_capabilities_sample": model_capabilities,
        "rate_limits": provider_config.rate_limits,
        "discovery_enabled": discovery_enabled,
        "discovery_stats": discovery_stats,
    }


def find_best_provider_for_request(
    required_features: list | None = None,
    model_pattern: str | None = None,
    exclude_providers: list | None = None,
) -> dict[str, Any] | None:
    """
    Find the best provider for a request with specific requirements.

    Args:
        required_features: List of required features (e.g., ['tools', 'vision'])
        model_pattern: Regex pattern to match model names
        exclude_providers: List of providers to exclude

    Returns:
        Best provider info or None if no match found
    """
    from chuk_llm.configuration.unified_config import CapabilityChecker

    if required_features:
        feature_list = [Feature.from_string(f) for f in required_features]

        best_provider = CapabilityChecker.get_best_provider_for_features(
            feature_list, model=model_pattern
        )

        if best_provider:
            return get_provider_info(best_provider)

    return None


# Import time at module level for discovery stats
import time  # noqa: E402


# Backward compatibility aliases
def _get_api_key_with_fallback(config_manager, provider, provider_config=None):
    return config_manager.get_api_key(provider)


_import_class = _import_string
_get_constructor_args = _constructor_kwargs
