"""
Local model registry — tracks installed models in registry.json.

The registry lives inside LLMPM_HOME, which is resolved by
``llmpm.core.context.get_home()``:

  • local mode  — ``<project>/.llmpm/``  (when ``llmpm.json`` is present)
  • global mode — ``~/.llmpm/``           (default fallback)
"""

from __future__ import annotations

import json
from datetime import datetime, timezone
from pathlib import Path
from typing import Any


# ── paths ────────────────────────────────────────────────────────────────────

def llmpm_home() -> Path:
    """Return (and create) the active LLMPM_HOME directory."""
    from llmpm.core import context  # local import avoids circular dependency  # pylint: disable=import-outside-toplevel
    home = context.get_home()
    home.mkdir(parents=True, exist_ok=True)
    return home


def models_dir() -> Path:
    """Return (and create) the ~/.llmpm/models directory."""
    dest = llmpm_home() / "models"
    dest.mkdir(parents=True, exist_ok=True)
    return dest


def registry_path() -> Path:
    """Return the path to the registry JSON file."""
    return llmpm_home() / "registry.json"


# ── registry I/O ─────────────────────────────────────────────────────────────

def _load() -> dict[str, Any]:
    """Load and return the registry, returning an empty schema on failure."""
    reg_path = registry_path()
    if not reg_path.exists():
        return {"version": "1", "models": {}}
    try:
        with open(reg_path, encoding="utf-8") as file_handle:
            data = json.load(file_handle)
        # migrate older schemas
        if "models" not in data:
            data["models"] = {}
        return data
    except (json.JSONDecodeError, OSError):
        return {"version": "1", "models": {}}


def _save(data: dict[str, Any]) -> None:
    """Persist the registry dict to disk."""
    reg_path = registry_path()
    with open(reg_path, "w", encoding="utf-8") as file_handle:
        json.dump(data, file_handle, indent=2, default=str)


# ── public API ───────────────────────────────────────────────────────────────

def all_models() -> dict[str, Any]:
    """Return the full models dict keyed by model id."""
    return _load()["models"]


def get_model(model_id: str) -> dict[str, Any] | None:
    """Return the registry entry for a model, or None if not installed."""
    return _load()["models"].get(model_id)


def find_model(query: str) -> tuple[str, dict[str, Any]] | None:
    """
    Find the closest matching installed model.

    Resolution order:
      1. Exact match
      2. Case-insensitive exact
      3. Repo-name-only exact  (e.g. "llama-3.2-1b-instruct-gguf")
      4. Substring match       (ranked by similarity to the repo name)
      5. Fuzzy match via difflib

    Returns (matched_model_id, entry) or None.
    """
    import difflib  # stdlib  # pylint: disable=import-outside-toplevel

    models = _load()["models"]
    if not models:
        return None

    # 1. Exact
    if query in models:
        return query, models[query]

    q = query.lower().strip()
    keys = list(models.keys())

    # 2. Case-insensitive exact
    for mid in keys:
        if mid.lower() == q:
            return mid, models[mid]

    # 3. Repo-name-only exact (part after the first "/")
    for mid in keys:
        if mid.split("/", 1)[-1].lower() == q:
            return mid, models[mid]

    # 4. Substring match — rank by SequenceMatcher similarity on the repo name
    sub_matches = [
        mid for mid in keys
        if q in mid.lower() or q in mid.split("/", 1)[-1].lower()
    ]
    if sub_matches:
        sub_matches.sort(
            key=lambda mid: difflib.SequenceMatcher(
                None, q, mid.split("/", 1)[-1].lower()
            ).ratio(),
            reverse=True,
        )
        return sub_matches[0], models[sub_matches[0]]

    # 5. Fuzzy — try repo names first (higher specificity), then full ids
    repo_names = [mid.split("/", 1)[-1].lower() for mid in keys]
    close = difflib.get_close_matches(q, repo_names, n=1, cutoff=0.5)
    if close:
        for mid in keys:
            if mid.split("/", 1)[-1].lower() == close[0]:
                return mid, models[mid]

    close = difflib.get_close_matches(q, [k.lower() for k in keys], n=1, cutoff=0.4)
    if close:
        for mid in keys:
            if mid.lower() == close[0]:
                return mid, models[mid]

    return None


def find_models(query: str) -> list[tuple[str, dict[str, Any]]]:
    """
    Find all installed models matching query, ranked by relevance.

    Unlike find_model(), this returns ALL candidates so the caller can
    present a choice when more than one model matches.

    Resolution order (same as find_model):
      1/2/3. Exact / case-insensitive / repo-name-only → single unambiguous result
      4.     All substring matches, ranked by similarity
      5.     All fuzzy matches via difflib (cutoff 0.5 on name, 0.4 on full id)

    Returns a list of (model_id, entry) pairs (may be empty).
    """
    import difflib  # stdlib  # pylint: disable=import-outside-toplevel

    models = _load()["models"]
    if not models:
        return []

    # 1. Exact
    if query in models:
        return [(query, models[query])]

    q = query.lower().strip()
    keys = list(models.keys())

    # 2. Case-insensitive exact
    exact = [mid for mid in keys if mid.lower() == q]
    if exact:
        return [(exact[0], models[exact[0]])]

    # 3. Repo-name-only exact
    name_exact = [mid for mid in keys if mid.split("/", 1)[-1].lower() == q]
    if name_exact:
        return [(name_exact[0], models[name_exact[0]])]

    # 4. All substring matches, ranked by similarity to the repo name
    sub_matches = [
        mid for mid in keys
        if q in mid.lower() or q in mid.split("/", 1)[-1].lower()
    ]
    if sub_matches:
        sub_matches.sort(
            key=lambda mid: difflib.SequenceMatcher(
                None, q, mid.split("/", 1)[-1].lower()
            ).ratio(),
            reverse=True,
        )
        return [(mid, models[mid]) for mid in sub_matches]

    # 5. All fuzzy matches
    repo_names = [mid.split("/", 1)[-1].lower() for mid in keys]
    close_names = difflib.get_close_matches(q, repo_names, n=5, cutoff=0.5)
    if close_names:
        result = []
        for c in close_names:
            for mid in keys:
                if mid.split("/", 1)[-1].lower() == c:
                    result.append((mid, models[mid]))
        return result

    close_ids = difflib.get_close_matches(q, [k.lower() for k in keys], n=5, cutoff=0.4)
    if close_ids:
        result = []
        for c in close_ids:
            for mid in keys:
                if mid.lower() == c:
                    result.append((mid, models[mid]))
        return result

    return []


def register(  # pylint: disable=too-many-arguments
    *,
    model_id: str,
    repo_id: str,
    model_type: str,          # "gguf" | "transformers"
    path: Path | str,
    files: list[str],
    primary_file: str | None,
    size_bytes: int,
    metadata: dict[str, Any] | None = None,
    tags: list[str] | None = None,
) -> None:
    """Add or update a model entry in the registry."""
    data = _load()
    data["models"][model_id] = {
        "model_id": model_id,
        "repo_id": repo_id,
        "model_type": model_type,
        "path": str(path),
        "files": files,
        "primary_file": primary_file,
        "size_bytes": size_bytes,
        "installed_at": datetime.now(timezone.utc).isoformat(),
        "metadata": metadata or {},
        "tags": tags or [],
    }
    _save(data)


def unregister(model_id: str) -> bool:
    """Remove a model from the registry. Returns True if it existed."""
    data = _load()
    existed = model_id in data["models"]
    data["models"].pop(model_id, None)
    _save(data)
    return existed


def model_install_path(repo_id: str) -> Path:
    """Return the install directory for a given HuggingFace repo_id."""
    # "owner/model-name"  →  ~/.llmpm/models/owner/model-name
    parts = repo_id.strip("/").split("/", 1)
    return models_dir().joinpath(*parts)
