"""Pydantic v2 models for Ollama auto-discovery, hardware detection, and universal engine discovery."""

from __future__ import annotations

import re
from datetime import datetime, timezone

from pydantic import BaseModel, ConfigDict, Field, computed_field

# ---------------------------------------------------------------------------
# Ollama model metadata
# ---------------------------------------------------------------------------


class ModelDetails(BaseModel):
    """Detail block returned by the Ollama /api/tags and /api/ps endpoints."""

    model_config = ConfigDict(populate_by_name=True)

    parent_model: str = ""
    format: str = ""
    family: str = ""
    families: list[str] | None = None
    parameter_size: str = ""
    quantization_level: str = ""


class OllamaModel(BaseModel):
    """A single installed model as reported by ``GET /api/tags``."""

    model_config = ConfigDict(populate_by_name=True)

    name: str
    model: str = ""
    modified_at: str = ""
    size: int = 0
    digest: str = ""
    details: ModelDetails = ModelDetails()

    @computed_field  # type: ignore[prop-decorator]
    @property
    def size_gb(self) -> float:
        """Model size converted to gigabytes."""
        return round(self.size / (1024**3), 2) if self.size else 0.0

    @computed_field  # type: ignore[prop-decorator]
    @property
    def parameter_count(self) -> int:
        """Parse ``parameter_size`` (e.g. ``'8B'``, ``'70B'``, ``'1.5B'``) into an integer."""
        raw = self.details.parameter_size.strip().upper()
        if not raw:
            return 0
        match = re.match(r"^([\d.]+)\s*([BKMGT]?)$", raw)
        if not match:
            return 0
        value = float(match.group(1))
        suffix = match.group(2)
        multipliers = {"B": 1_000_000_000, "M": 1_000_000, "K": 1_000, "T": 1_000_000_000_000, "G": 1_000_000_000}
        return int(value * multipliers.get(suffix, 1))

    @computed_field  # type: ignore[prop-decorator]
    @property
    def is_code_model(self) -> bool:
        """Heuristic: model is code-oriented if its name or family contains code-related keywords."""
        code_keywords = {"code", "coder", "codellama", "starcoder", "deepseek-coder", "codestral", "qwen2.5-coder"}
        name_lower = self.name.lower()
        family_lower = self.details.family.lower()
        families_lower = [f.lower() for f in (self.details.families or [])]
        for kw in code_keywords:
            if kw in name_lower or kw in family_lower or any(kw in f for f in families_lower):
                return True
        return False


class RunningModel(OllamaModel):
    """A model currently loaded in memory, from ``GET /api/ps``."""

    expires_at: str = ""
    size_vram: int = 0

    @computed_field  # type: ignore[prop-decorator]
    @property
    def vram_gb(self) -> float:
        """VRAM consumption in gigabytes."""
        return round(self.size_vram / (1024**3), 2) if self.size_vram else 0.0

    @computed_field  # type: ignore[prop-decorator]
    @property
    def is_fully_loaded(self) -> bool:
        """True when the entire model weight sits in VRAM (no CPU offload)."""
        return self.size_vram >= self.size if self.size > 0 else False


# ---------------------------------------------------------------------------
# Detailed model info (POST /api/show)
# ---------------------------------------------------------------------------


class ModelInfo(BaseModel):
    """Detailed information returned by ``POST /api/show``."""

    model_config = ConfigDict(populate_by_name=True)

    license: str = ""
    modelfile: str = ""
    parameters: str = ""
    template: str = ""
    details: ModelDetails = ModelDetails()
    model_info: dict = {}


# ---------------------------------------------------------------------------
# Health check aggregate
# ---------------------------------------------------------------------------


class OllamaHealth(BaseModel):
    """Result of a full Ollama health check."""

    available: bool
    host: str
    model_count: int = 0
    running_count: int = 0
    models: list[OllamaModel] = []
    running: list[RunningModel] = []
    checked_at: datetime


# ---------------------------------------------------------------------------
# Hardware detection models
# ---------------------------------------------------------------------------


class GPUInfo(BaseModel):
    """Information about a single GPU."""

    name: str
    vram_total_mb: int = 0
    vram_used_mb: int = 0
    driver_version: str = ""
    gpu_type: str = "nvidia"  # "nvidia" | "amd" | "apple_silicon"

    @computed_field  # type: ignore[prop-decorator]
    @property
    def vram_free_mb(self) -> int:
        return max(self.vram_total_mb - self.vram_used_mb, 0)

    @computed_field  # type: ignore[prop-decorator]
    @property
    def effective_vram_mb(self) -> int:
        """Effective VRAM available for inference.

        Apple Silicon reports total system RAM as GPU VRAM (unified memory).
        We subtract 4 GB for OS/system overhead on Apple Silicon to avoid
        over-provisioning models that would thrash with macOS.
        """
        if self.gpu_type == "apple_silicon":
            return max(self.vram_total_mb - 4096, 0)
        return self.vram_total_mb


class HardwareProfile(BaseModel):
    """System hardware profile collected by :class:`HardwareDetector`."""

    gpus: list[GPUInfo] = []
    cpu_cores: int = 0
    cpu_model: str = "unknown"
    ram_total_gb: float = 0.0
    ram_available_gb: float = 0.0
    disk_total_gb: float = 0.0
    disk_free_gb: float = 0.0
    os_name: str = ""
    os_version: str = ""
    python_version: str = ""
    tunnel_tools: list[str] = []
    detected_at: datetime | None = None

    @computed_field  # type: ignore[prop-decorator]
    @property
    def has_gpu(self) -> bool:
        return len(self.gpus) > 0

    @computed_field  # type: ignore[prop-decorator]
    @property
    def total_vram_mb(self) -> int:
        return sum(g.vram_total_mb for g in self.gpus)

    @computed_field  # type: ignore[prop-decorator]
    @property
    def largest_gpu_vram_mb(self) -> int:
        """Largest single GPU's effective VRAM.

        Most inference engines (including Ollama) use a single GPU -- they do
        pipeline parallelism, not tensor parallelism across GPUs.  The largest
        single GPU determines model capability, not the sum of all GPUs.

        Uses ``effective_vram_mb`` which already accounts for the Apple Silicon
        unified-memory OS overhead offset.
        """
        return max((g.effective_vram_mb for g in self.gpus), default=0)


# ---------------------------------------------------------------------------
# Universal engine discovery models
# ---------------------------------------------------------------------------


class DiscoveredModel(BaseModel):
    """A model found on any inference engine."""

    id: str
    size_gb: float = 0.0
    engine_type: str = ""
    quantization: str = ""
    context_length: int = 0


class DiscoveredEngine(BaseModel):
    """A discovered inference engine with its capabilities and models."""

    engine_type: str  # matches EngineType values
    host: str
    port: int
    available: bool = True
    models: list[DiscoveredModel] = Field(default_factory=list)
    api_compat: str = "openai"  # "openai" | "ollama" | "triton"
    confidence: float = 1.0  # 0-1 confidence from fingerprinting
    version: str = ""
    features: list[str] = Field(default_factory=list)  # ["streaming", "tool_calling", "vision", "embeddings"]
    gpu_ids: list[str] = Field(default_factory=list)  # GPU device names for per-GPU remit filtering
    discovered_at: datetime = Field(default_factory=lambda: datetime.now(tz=timezone.utc))

    @computed_field  # type: ignore[prop-decorator]
    @property
    def base_url(self) -> str:
        return f"http://{self.host}:{self.port}"

    @computed_field  # type: ignore[prop-decorator]
    @property
    def model_count(self) -> int:
        return len(self.models)


class ProvisionResult(BaseModel):
    """Result of an engine provisioning operation."""

    success: bool = False
    engine_installed: bool = False
    model_pulled: str = ""
    model_size_gb: float = 0.0
    install_method: str = ""  # "curl" | "winget" | "brew" | "existing"
    error: str = ""
    duration_seconds: float = 0.0


class SavingsSnapshot(BaseModel):
    """A point-in-time savings calculation."""

    tokens_processed: int = 0
    cloud_cost_usd: float = 0.0
    local_cost_usd: float = 0.0
    savings_usd: float = 0.0
    savings_percent: float = 0.0
    period: str = "session"  # "session" | "day" | "month"
    model_used: str = ""
    cloud_equivalent_model: str = ""
