"""Metadata operations — thin in-process wrappers around LoRA Manager internal services.

All functions are simple Python async functions that delegate to the
appropriate internal service.  They use **relative imports** within the
``py`` package, so ``sys.modules`` caching works normally and there is no
risk of double import or circular dependencies.

Usage (in-process, primary)::

    from py.metadata_ops import list_base_models, read_metadata

    models = await list_base_models()
    meta   = await read_metadata("/path/to/model.safetensors")

Usage (subprocess, debugging / external)::

    python -m py.metadata_ops base-models list
    python -m py.metadata_ops metadata read /path/to/model.safetensors
"""

from __future__ import annotations

import asyncio
import logging
import os
from typing import Any, Dict, List, Optional

logger = logging.getLogger(__name__)


# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------

SCANNER_TYPE_MAP: dict[str, str] = {
    "get_lora_scanner": "lora",
    "get_checkpoint_scanner": "checkpoint",
    "get_embedding_scanner": "embedding",
}

SCANNER_GETTER_NAMES = tuple(SCANNER_TYPE_MAP.keys())


async def _find_model_entry(
    model_path: str,
) -> tuple[Any, object, str | None] | tuple[None, None, None]:
    """Iterate all scanners and return the first (scanner, entry, getter_name)
    that owns *model_path*.  Returns ``(None, None, None)`` when no scanner
    claims it.
    """
    from ..services.service_registry import ServiceRegistry

    normalized = os.path.normpath(model_path)
    for getter_name in SCANNER_GETTER_NAMES:
        getter = getattr(ServiceRegistry, getter_name, None)
        if getter is None:
            continue
        try:
            scanner = await getter()
            if scanner is None:
                continue
            cache = await scanner.get_cached_data()
            for entry in cache.raw_data:
                if os.path.normpath(entry.get("file_path", "")) == normalized:
                    return scanner, entry, getter_name
        except Exception as exc:
            logger.debug(
                "Scanner %s check failed for %s: %s",
                getter_name, model_path, exc,
            )
    return None, None, None


async def _find_scanner_for_model(
    model_path: str,
) -> tuple[Any, object] | tuple[None, None]:
    """Find the (scanner, cache_entry) responsible for *model_path*."""
    scanner, entry, _ = await _find_model_entry(model_path)
    return scanner, entry


async def identify_model_type(model_path: str) -> str:
    """Determine the model type (``\"lora\"``, ``\"checkpoint\"``, or
    ``\"embedding\"``) for *model_path*.

    Falls back to ``\"lora\"`` when unknown.
    """
    _, _, getter_name = await _find_model_entry(model_path)
    return SCANNER_TYPE_MAP[getter_name] if getter_name else "lora"


# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------


async def list_base_models(limit: int = 0) -> List[str]:
    """Return all valid CivitAI base model names.

    Uses ``CivitaiBaseModelService.get_base_models()`` which merges a
    hardcoded list (``SUPPORTED_DOWNLOAD_SKIP_BASE_MODELS``) with remote
    models fetched from the CivitAI API.  Never empty — the hardcoded
    fallback always provides a complete set.

    The result is sorted alphabetically.  Pass *limit* = 0 for all models.
    """
    from ..services.civitai_base_model_service import (
        CivitaiBaseModelService,
    )

    try:
        service = await CivitaiBaseModelService.get_instance()
        response = await service.get_base_models()
        names: List[str] = response.get("models", [])
    except Exception as exc:
        logger.warning("list_base_models failed: %s", exc)
        names = []
    if limit > 0:
        return names[:limit]
    return names


async def read_metadata(model_path: str) -> Dict[str, Any]:
    """Load the full metadata payload for *model_path* from disk.

    Returns an empty dict when the metadata file does not exist or cannot
    be parsed — never raises.
    """
    from ..utils.metadata_manager import MetadataManager

    try:
        return await MetadataManager.load_metadata_payload(model_path) or {}
    except Exception as exc:
        logger.warning("read_metadata failed for %s: %s", model_path, exc)
        return {}


async def apply_metadata_updates(
    model_path: str,
    updates: Dict[str, Any],
) -> List[str]:
    """Merge *updates* into the model's on-disk metadata and persist.

    Returns the list of field names that actually changed.
    """
    from ..utils.metadata_manager import MetadataManager

    metadata = await read_metadata(model_path)
    updated_fields: List[str] = []
    for key, value in updates.items():
        old = metadata.get(key)
        if old != value:
            metadata[key] = value
            updated_fields.append(key)
    if updated_fields:
        await MetadataManager.save_metadata(model_path, metadata)
    return updated_fields


async def download_preview(
    model_path: str,
    url: str,
    *,
    target_width: int = 480,
    quality: int = 85,
) -> str | None:
    """Download a preview image from *url*, optimise to .webp, and save it.

    The output file is placed alongside the model file with a ``.webp``
    extension.  Returns the local file path on success, ``None`` on failure.
    """
    from ..services.downloader import get_downloader
    from ..utils.exif_utils import ExifUtils

    if not url or not url.strip():
        return None

    base_name = os.path.splitext(os.path.basename(model_path))[0]
    preview_dir = os.path.dirname(model_path)
    output_path = os.path.join(preview_dir, base_name + ".webp")

    downloader = await get_downloader()

    # Try in-memory download + optimise first
    success, content, _headers = await downloader.download_to_memory(
        url, use_auth=False,
    )
    if success and content:
        try:
            optimized_data, _ = ExifUtils.optimize_image(
                image_data=content,
                target_width=target_width,
                format="webp",
                quality=quality,
                preserve_metadata=False,
            )
            with open(output_path, "wb") as f:
                f.write(optimized_data)
            return output_path
        except Exception as exc:
            logger.warning("Preview optimisation failed, saving raw: %s", exc)
            # Fall through to raw save

    # Fallback: download directly to file
    try:
        ok, _ = await downloader.download_file(url, output_path, use_auth=False)
        if ok:
            return output_path
    except Exception as exc:
        logger.warning("Preview fallback download failed for %s: %s", model_path, exc)

    return None


async def refresh_cache(model_path: str) -> bool:
    """Invalidate and reload the scanner cache entry for *model_path*.

    Returns ``True`` when the model was found and the cache was refreshed.
    """
    scanner, entry = await _find_scanner_for_model(model_path)
    if scanner is None:
        logger.warning("refresh_cache: no scanner found for %s", model_path)
        return False
    try:
        metadata = await read_metadata(model_path)
        if not metadata:
            logger.warning("refresh_cache: no metadata for %s", model_path)
            return False
        await scanner.update_single_model_cache(model_path, model_path, metadata)
        return True
    except Exception as exc:
        logger.warning("refresh_cache failed for %s: %s", model_path, exc)
        return False
