# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0

"""Shared model inventory helpers for the Hub service layer."""

from __future__ import annotations

import json
import os
from pathlib import Path
from typing import List, Literal, Optional
from urllib.parse import quote

from hub.schemas.inventory import (
    LocalModelCapabilities,
    LocalModelInfo,
    ModelFormat,
    ModelRuntime,
)
from hub.utils.gguf import (
    extract_quant_label,
    is_gguf_filename as _is_gguf_filename,
    is_mmproj_filename as _is_mmproj_filename,
    is_mtp_drafter_path as _is_mtp_drafter_path,
)
from hub.utils.paths import is_valid_repo_id as _is_valid_repo_id

ModelType = Literal["text", "vision", "audio", "embeddings"]
LocalModelSource = Literal["models_dir", "hf_cache", "lmstudio", "ollama", "custom"]


def _safe_is_dir(path) -> bool:
    # Py >= 3.12 propagates PermissionError (EACCES) from is_dir(); folder scans
    # probe root-owned system dirs, so treat un-stat-able paths as not-a-dir.
    try:
        return Path(path).is_dir()
    except OSError:
        return False


_LOCAL_CHECKPOINT_EXTENSIONS = (
    ".bin",
    ".pt",
    ".pth",
    ".ckpt",
    ".h5",
    ".msgpack",
    ".npz",
)

_LOCAL_BASE_MODEL_PREFIXES = {
    "checkpoint",
    "checkpoints",
    "export",
    "exports",
    "model",
    "models",
    "output",
    "outputs",
    "run",
    "runs",
    "train",
}
_HF_CACHE_MODEL_FILE_PROBE_LIMIT = 2000


def _is_model_directory(d: Path) -> bool:
    """True when *d* has a config plus real weights; excludes mmproj GGUFs and non-weight ``.bin`` files (``tokenizer.bin``) to avoid false positives."""

    def _is_weight_file(f: Path) -> bool:
        suffix = f.suffix.lower()
        if suffix == ".safetensors":
            return True
        if suffix == ".gguf":
            return "mmproj" not in f.name.lower() and not _is_mtp_drafter_path(f.name)
        if suffix == ".bin":
            name = f.name.lower()
            return (
                name.startswith("pytorch_model")
                or name.startswith("model")
                or name.startswith("adapter_model")
                or name.startswith("consolidated")
            )
        return False

    try:
        has_config = (d / "config.json").exists() or (d / "adapter_config.json").exists()
        if not has_config:
            return False
        return any(_is_weight_file(f) for f in d.iterdir() if f.is_file())
    except OSError:
        return False


def _local_inventory_id(
    source: str,
    model_format: ModelFormat,
    semantic_id: str,
    variant: Optional[str] = None,
) -> str:
    parts = [
        source,
        model_format,
        quote(semantic_id, safe = ""),
    ]
    if variant:
        parts.append(quote(variant, safe = ""))
    return ":".join(parts)


def _runtime_for_format(model_format: ModelFormat) -> ModelRuntime:
    if model_format == "gguf":
        return "llama_cpp"
    if model_format == "adapter":
        return "adapter"
    if model_format in {"safetensors", "checkpoint"}:
        return "transformers"
    return "unknown"


def _capabilities_for_format(
    model_format: ModelFormat,
    source: str,
    *,
    partial: bool = False,
    requires_variant: bool = False,
) -> LocalModelCapabilities:
    is_complete = not partial
    can_chat = model_format in {"gguf", "safetensors", "adapter", "checkpoint"}
    can_train = model_format in {"safetensors", "checkpoint"} and is_complete
    return LocalModelCapabilities(
        can_train = can_train,
        can_chat = can_chat and is_complete,
        can_delete = source == "hf_cache",
        can_download = False,
        requires_variant = requires_variant,
        supports_lora = model_format in {"safetensors", "checkpoint"} and is_complete,
        supports_vision = False,
    )


def _prefer_complete_larger(
    candidate_partial: bool,
    candidate_size_bytes: int,
    existing_partial: bool,
    existing_size_bytes: int,
) -> bool:
    if candidate_partial != existing_partial:
        return not candidate_partial
    return candidate_size_bytes > existing_size_bytes


def _gguf_variant_state_summary(repo_id: str) -> tuple[bool, int]:
    """Whether GGUF variant-scoped state exists and its expected size; a cancelled/in-progress variant may have only manifests/markers/`.incomplete` blobs, which inventory needs to avoid a generic fallback row."""
    from hub.utils import download_manifest

    variant_keys: set[str] = set()
    size_by_variant: dict[str, int] = {}
    for variant, _path in download_manifest.iter_variant_manifests(
        "model",
        repo_id,
    ):
        key = variant.lower()
        variant_keys.add(key)
        manifest = download_manifest.read_manifest("model", repo_id, variant)
        if manifest is None:
            continue
        size_by_variant[key] = max(
            size_by_variant.get(key, 0),
            sum(max(0, int(file.size or 0)) for file in manifest.expected_files),
        )
    for variant, _path in download_manifest.iter_variant_markers(
        "model",
        repo_id,
    ):
        variant_keys.add(variant.lower())
    return bool(variant_keys), sum(size_by_variant.values())


def _apply_format_aware_partial(
    rows: List[LocalModelInfo],
    *,
    snapshot_partial: bool,
    gguf_partial: bool,
    snapshot_partial_transport: Optional[str] = None,
) -> List[LocalModelInfo]:
    """Rewrite each row's partial flag with format-aware predicates so a hybrid (gguf + safetensors) repo's broken format doesn't taint the clean one; capabilities are recomputed from the new flag."""
    rewritten: List[LocalModelInfo] = []
    for row in rows:
        target = gguf_partial if row.model_format == "gguf" else snapshot_partial
        if not target:
            rewritten.append(row)
            continue
        # GGUF row-level transport is ambiguous (variants may differ); per-variant
        # detail lives on GgufVariantDetail.partial_transport via the variants endpoint.
        partial_transport = None if row.model_format == "gguf" else snapshot_partial_transport
        rewritten.append(
            row.model_copy(
                update = {
                    "partial": True,
                    "partial_transport": partial_transport,
                    "capabilities": _capabilities_for_format(
                        row.model_format,
                        row.source,
                        partial = True,
                        requires_variant = row.capabilities.requires_variant,
                    ),
                }
            )
        )
    return rewritten


def _weight_basename(name: str) -> str:
    return name.replace("\\", "/").rsplit("/", 1)[-1].lower()


def _is_adapter_weight_name(name: str) -> bool:
    lower = _weight_basename(name)
    return lower.startswith("adapter_model") and lower.endswith((".safetensors", ".bin"))


def _is_transformers_safetensors_weight_name(name: str) -> bool:
    lower = _weight_basename(name)
    return lower.endswith(".safetensors") and lower.startswith(
        ("model", "pytorch_model", "consolidated")
    )


def _is_transformers_bin_weight_name(name: str) -> bool:
    lower = _weight_basename(name)
    if not lower.endswith(".bin"):
        return False
    return lower.startswith(("pytorch_model", "model", "consolidated", "adapter_model"))


def _is_checkpoint_weight_name(name: str) -> bool:
    lower = _weight_basename(name)
    if lower.endswith(".bin"):
        return _is_transformers_bin_weight_name(lower)
    return lower.endswith(_LOCAL_CHECKPOINT_EXTENSIONS)


def _is_adapter_weight_file(path: Path) -> bool:
    return _is_adapter_weight_name(path.name)


def _is_transformers_safetensors_weight_file(path: Path) -> bool:
    return _is_transformers_safetensors_weight_name(path.name)


def _is_transformers_bin_weight_file(path: Path) -> bool:
    return _is_transformers_bin_weight_name(path.name)


def _is_checkpoint_weight_file(path: Path) -> bool:
    return _is_checkpoint_weight_name(path.name)


def _classify_non_gguf_model_format(
    *,
    has_config: bool,
    has_adapter_config: bool,
    has_adapter_weights: bool,
    has_safetensors: bool,
    has_transformers_safetensors: bool,
    has_checkpoint_weights: bool,
    trusted_hf_cache_repo: bool = False,
) -> Optional[ModelFormat]:
    if has_safetensors and (has_config or (trusted_hf_cache_repo and has_transformers_safetensors)):
        return "safetensors"
    if has_adapter_config and has_adapter_weights:
        return "adapter"
    if has_config and has_checkpoint_weights:
        return "checkpoint"
    return None


def _is_main_gguf_filename(name: str) -> bool:
    return (
        _is_gguf_filename(name) and not _is_mmproj_filename(name) and not _is_mtp_drafter_path(name)
    )


def _iter_gguf_paths(root: Path):
    stack = [root]
    while stack:
        current = stack.pop()
        try:
            entries = list(current.iterdir())
        except OSError:
            continue
        for path in entries:
            try:
                if path.is_dir() and not path.is_symlink():
                    stack.append(path)
                elif path.is_file() and _is_gguf_filename(path.name):
                    yield path
            except OSError:
                continue


def _iter_immediate_files(path: Path, *, include_symlinks: bool = False) -> list[Path]:
    if path.is_file():
        return [path]
    if not path.is_dir():
        return []
    try:
        return [
            entry
            for entry in path.iterdir()
            if entry.is_file() or (include_symlinks and entry.is_symlink())
        ]
    except OSError:
        return []


def _iter_hf_cache_model_files(path: Path) -> list[Path]:
    files = _iter_immediate_files(path, include_symlinks = True)
    if not path.is_dir():
        return files
    if any(
        _is_main_gguf_filename(entry.name)
        or _is_transformers_safetensors_weight_file(entry)
        or _is_checkpoint_weight_file(entry)
        for entry in files
    ):
        return files
    try:
        bounded: list[Path] = []
        for index, entry in enumerate(path.rglob("*"), start = 1):
            if index > _HF_CACHE_MODEL_FILE_PROBE_LIMIT:
                break
            if entry.is_file() or entry.is_symlink():
                bounded.append(entry)
        return bounded
    except OSError:
        return []


def _file_size_bytes(path: Path) -> int:
    try:
        if path.is_file() or path.is_symlink():
            return path.stat().st_size
    except OSError:
        return 0
    return 0


def _sum_file_sizes(paths) -> int:
    return sum(_file_size_bytes(path) for path in paths)


def _main_gguf_files(path: Path, *, include_symlinks: bool = False) -> list[Path]:
    return [
        entry
        for entry in _iter_immediate_files(path, include_symlinks = include_symlinks)
        if _is_main_gguf_filename(entry.name)
    ]


def _format_label(model_format: ModelFormat) -> str:
    if model_format == "gguf":
        return "GGUF"
    if model_format == "safetensors":
        return "Safetensors"
    if model_format == "adapter":
        return "Adapter"
    if model_format == "checkpoint":
        return "Checkpoint"
    return "Unknown"


def _read_adapter_config(path: Path) -> dict:
    if not path.is_dir():
        return {}
    try:
        with (path / "adapter_config.json").open("r", encoding = "utf-8") as f:
            data = json.load(f)
    except Exception:
        return {}
    return data if isinstance(data, dict) else {}


def _clean_optional_string(value: object) -> Optional[str]:
    return value.strip() if isinstance(value, str) and value.strip() else None


def _base_model_looks_local(value: str) -> bool:
    raw = value.strip()
    normalized = raw.replace("\\", "/")
    if raw.startswith(("/", "./", "../", "~", "\\\\")) or (
        len(raw) >= 3 and raw[1] == ":" and raw[0].isalpha()
    ):
        return True
    first = normalized.split("/", 1)[0].lower()
    return "/" in normalized and first in _LOCAL_BASE_MODEL_PREFIXES


def _base_model_source(value: Optional[str], adapter_dir: Path) -> Optional[str]:
    if not value:
        return None
    candidates = [value, value.replace("\\", "/")]
    for candidate in candidates:
        try:
            expanded = Path(os.path.expanduser(candidate))
            if expanded.exists() or (adapter_dir / candidate).exists():
                return "local"
        except (OSError, ValueError):
            return "unknown"
    if _base_model_looks_local(value):
        return "local"
    if _is_valid_repo_id(value):
        return "huggingface"
    return "unknown"


def _local_model_info(
    *,
    scan_path: Path,
    load_path: Path,
    source: LocalModelSource,
    model_format: ModelFormat,
    display_name: Optional[str] = None,
    model_id: Optional[str] = None,
    updated_at: Optional[float] = None,
    partial: bool = False,
    requires_variant: bool = False,
    format_variant: Optional[str] = None,
    size_bytes: int = 0,
    base_model: Optional[str] = None,
    base_model_source: Optional[str] = None,
    adapter_type: Optional[str] = None,
    training_method: Optional[str] = None,
) -> LocalModelInfo:
    load_id = model_id if source == "hf_cache" and model_id else str(load_path)
    semantic_id = model_id or str(load_path)
    return LocalModelInfo(
        id = load_id,
        inventory_id = _local_inventory_id(
            source,
            model_format,
            semantic_id,
            format_variant,
        ),
        load_id = load_id,
        model_id = model_id,
        display_name = display_name or (scan_path.stem if scan_path.is_file() else scan_path.name),
        path = str(load_path),
        size_bytes = max(0, int(size_bytes or 0)),
        source = source,
        base_model = base_model,
        base_model_source = base_model_source,
        adapter_type = adapter_type,
        training_method = training_method,
        updated_at = updated_at,
        partial = partial,
        model_format = model_format,
        runtime = _runtime_for_format(model_format),
        format_variant = format_variant,
        capabilities = _capabilities_for_format(
            model_format,
            source,
            partial = partial,
            requires_variant = requires_variant,
        ),
    )


def _classify_local_path(
    scan_path: Path,
    source: LocalModelSource,
    *,
    load_path: Optional[Path] = None,
    display_name: Optional[str] = None,
    model_id: Optional[str] = None,
    updated_at: Optional[float] = None,
    partial: bool = False,
) -> list[LocalModelInfo]:
    load_path = load_path or scan_path
    files = (
        _iter_hf_cache_model_files(scan_path)
        if source == "hf_cache"
        else _iter_immediate_files(scan_path)
    )
    if not files:
        return []

    rows: list[LocalModelInfo] = []
    include_broken_snapshot_symlinks = source == "hf_cache"
    gguf_files = _main_gguf_files(
        scan_path,
        include_symlinks = include_broken_snapshot_symlinks,
    )
    if gguf_files:
        gguf_size_bytes = _sum_file_sizes(gguf_files)
        variant = (
            extract_quant_label(gguf_files[0].name)
            if scan_path.is_file() and len(gguf_files) == 1
            else None
        )
        rows.append(
            _local_model_info(
                scan_path = scan_path,
                load_path = load_path,
                source = source,
                model_format = "gguf",
                display_name = display_name,
                model_id = model_id,
                updated_at = updated_at,
                partial = partial,
                requires_variant = scan_path.is_dir(),
                format_variant = variant,
                size_bytes = gguf_size_bytes,
            )
        )

    has_config = (scan_path / "config.json").is_file() if scan_path.is_dir() else False
    has_adapter_config = (
        (scan_path / "adapter_config.json").is_file() if scan_path.is_dir() else False
    )
    adapter_config = _read_adapter_config(scan_path) if has_adapter_config else {}
    adapter_base_model = _clean_optional_string(adapter_config.get("base_model_name_or_path"))
    adapter_type = _clean_optional_string(adapter_config.get("peft_type"))
    training_method = _clean_optional_string(adapter_config.get("unsloth_training_method"))
    has_adapter_weights = any(_is_adapter_weight_file(f) for f in files)
    has_safetensors = any(
        f.suffix.lower() == ".safetensors" and not _is_adapter_weight_file(f) for f in files
    )
    has_transformers_safetensors = any(
        _is_transformers_safetensors_weight_file(f) and not _is_adapter_weight_file(f)
        for f in files
    )
    has_checkpoint_weights = any(_is_checkpoint_weight_file(f) for f in files)
    trusted_hf_cache_repo = source == "hf_cache" and bool(model_id)

    model_format = _classify_non_gguf_model_format(
        has_config = has_config,
        has_adapter_config = has_adapter_config,
        has_adapter_weights = has_adapter_weights,
        has_safetensors = has_safetensors,
        has_transformers_safetensors = has_transformers_safetensors,
        has_checkpoint_weights = has_checkpoint_weights,
        trusted_hf_cache_repo = trusted_hf_cache_repo,
    )

    if model_format is not None:
        if model_format == "adapter":
            size_bytes = _sum_file_sizes(f for f in files if _is_adapter_weight_file(f))
        elif model_format == "safetensors":
            size_bytes = _sum_file_sizes(
                f
                for f in files
                if f.suffix.lower() == ".safetensors" and not _is_adapter_weight_file(f)
            )
        else:
            size_bytes = _sum_file_sizes(f for f in files if _is_checkpoint_weight_file(f))
        rows.append(
            _local_model_info(
                scan_path = scan_path,
                load_path = load_path,
                source = source,
                model_format = model_format,
                display_name = display_name,
                model_id = model_id,
                updated_at = updated_at,
                partial = partial,
                size_bytes = size_bytes,
                base_model = adapter_base_model if model_format == "adapter" else None,
                base_model_source = (
                    _base_model_source(adapter_base_model, scan_path)
                    if model_format == "adapter"
                    else None
                ),
                adapter_type = adapter_type if model_format == "adapter" else None,
                training_method = training_method if model_format == "adapter" else None,
            )
        )
    elif not rows:
        fallback_format: ModelFormat = (
            "safetensors" if trusted_hf_cache_repo and has_config else "unknown"
        )
        size_bytes = _sum_file_sizes(files)
        rows.append(
            _local_model_info(
                scan_path = scan_path,
                load_path = load_path,
                source = source,
                model_format = fallback_format,
                display_name = display_name,
                model_id = model_id,
                updated_at = updated_at,
                partial = partial or trusted_hf_cache_repo,
                size_bytes = size_bytes,
            )
        )

    if len(rows) > 1:
        rows = [
            row.model_copy(
                update = {
                    "display_name": f"{row.display_name} ({_format_label(row.model_format)})",
                    "inventory_id": _local_inventory_id(
                        row.source,
                        row.model_format,
                        row.model_id or row.path,
                        row.format_variant,
                    ),
                }
            )
            for row in rows
        ]
    return rows
