"""Per-source album-art lookups + availability for the cover-art picker.

Bridges the pure resolver in ``art_sources.py`` to the real metadata clients.
Each supported source contributes two things:

- **availability** — is this source usable for the current user right now?
  Free sources (CAA, Deezer, iTunes, AudioDB) are always available; account
  sources (Spotify) only when connected. This is what powers "not everybody
  has access to every source": the UI offers only available sources and the
  resolver skips the rest.
- **lookup** — ``(artist, album, metadata) -> cover_url | None``, calling an
  EXISTING client method. Every lookup is individually guarded so any error or
  miss degrades to ``None`` (the resolver then falls through to the next
  source, finally to the download's own art) — a flaky source can never raise
  into, or break, a download.

Lookups are cached per album via ``build_art_lookup`` so resolving art for a
16-track album hits each source at most once.

Client accessors are imported lazily inside each function to keep this module
import-light (the pure resolver + its tests never pull a network client).
"""

from __future__ import annotations

import re
from typing import Callable, Dict, List, Optional

from utils.logging_config import get_logger

from core.metadata.art_sources import ART_CAPABLE_SOURCES

logger = get_logger("metadata.art_lookup")

# Sources that need no account/config — always offered.
_FREE_SOURCES = ("caa", "deezer", "itunes", "audiodb")


# ---------------------------------------------------------------------------
# Availability
# ---------------------------------------------------------------------------


def _spotify_available() -> bool:
    """Spotify art is only usable when the user is connected. Reuse the
    canonical accessor, which returns a client only when authenticated."""
    try:
        from core.metadata.registry import get_client_for_source
        return get_client_for_source("spotify") is not None
    except Exception:
        return False


_AVAILABILITY: Dict[str, Callable[[], bool]] = {
    "spotify": _spotify_available,
}


def is_art_source_available(source: str) -> bool:
    """Is ``source`` usable right now? Free sources are always available;
    account sources defer to their connection check. Unknown/unsupported
    sources are never available."""
    name = (source or "").strip().lower()
    if name not in ART_CAPABLE_SOURCES:
        return False
    if name in _FREE_SOURCES:
        return True
    check = _AVAILABILITY.get(name)
    return bool(check()) if check else False


def available_art_sources() -> List[str]:
    """The supported art sources currently usable for this user, in the
    default priority order — for populating the settings UI."""
    return [s for s in ART_CAPABLE_SOURCES if is_art_source_available(s)]


# ---------------------------------------------------------------------------
# Album-match validation. Every client's search returns its top hit
# unvalidated (results[0]), so a source that lacks the album could hand back a
# DIFFERENT one — embedding wrong-album art, which is worse than the download's
# own cover. We therefore confirm the returned album matches the request before
# trusting its art; a mismatch returns None so the resolver falls through,
# preserving the "worst case = the cover you'd get today" guarantee.
# ---------------------------------------------------------------------------

_STOPWORDS = {"the", "a", "an"}


def _norm(s) -> str:
    return re.sub(r"[^a-z0-9]+", " ", str(s or "").lower()).strip()


def _significant_tokens(s) -> set:
    return {t for t in _norm(s).split() if t not in _STOPWORDS}


def _result_album_artist(obj):
    """Best-effort (album_name, artist_name) from a search result — handles
    both raw dicts (Deezer/AudioDB) and dataclasses (iTunes/Spotify)."""
    if isinstance(obj, dict):
        name = obj.get("title") or obj.get("name") or obj.get("strAlbum") or ""
        artist = obj.get("strArtist") or ""
        a = obj.get("artist")
        if not artist and isinstance(a, dict):
            artist = a.get("name", "")
        elif not artist and isinstance(a, str):
            artist = a
        return name, artist
    name = getattr(obj, "name", None) or getattr(obj, "title", None) or ""
    arts = getattr(obj, "artists", None)
    if arts is None:
        arts = getattr(obj, "artist", None)
    if isinstance(arts, (list, tuple)):
        artist = ", ".join(str(x) for x in arts if x)
    else:
        artist = str(arts) if arts else ""
    return name, artist


def _album_matches(req_artist, req_album, got_artist, got_album) -> bool:
    """True when the returned album plausibly IS the requested one.

    Both album and artist are compared by significant-token subset (stopwords
    dropped), which tolerates leading articles, word order, "(Deluxe)"/
    "- Remastered" suffixes, punctuation, and "feat."/"&"/multi-artist ordering.
    Lenient enough to never reject a genuine hit — a false reject just falls
    back to today's art — yet strict enough to drop a different album (the
    generic-title case, e.g. two "Greatest Hits", is caught by the artist gate)."""
    ra, ga = _significant_tokens(req_album), _significant_tokens(got_album)
    if not ra or not ga:
        return False
    if not (ra <= ga or ga <= ra):
        return False
    # Sokhi: the subset tolerance exists for '(Deluxe)'/'- Remastered'
    # suffixes, but a NUMERIC difference is a different release, not a
    # suffix. 'B小町 …CD Vol.4' normalizes to {b,tv,cd,vol,4} — a subset of
    # Vol.4.5's {b,tv,cd,vol,4,5} — so volume 4 was hanging volume 4.5's
    # cover. Any number present on only ONE side (volume, part, sequel,
    # remaster year) rejects the match; the resolver then falls through to
    # the next source / the download's own art, which is the designed cost
    # of a false reject here. (Shared rule — the MusicBrainz release matcher
    # applies the same guard so the MBID-keyed CAA path can't slip either.)
    from core.text.title_match import numeric_tokens_differ
    if numeric_tokens_differ(req_album, got_album):
        return False
    ta, tg = _significant_tokens(req_artist), _significant_tokens(got_artist)
    if not ta:
        return True                      # requested artist unknown -> album match suffices
    if not tg:
        return False                     # asked for an artist, none returned -> can't confirm
    return ta <= tg or tg <= ta or bool(ta & tg)


# ---------------------------------------------------------------------------
# Per-source lookups — each returns a cover URL or None, never raises.
# Non-CAA sources validate the returned album before trusting its art.
# ---------------------------------------------------------------------------


def _caa_art(artist: str, album: str, metadata: dict) -> Optional[str]:
    # Cover Art Archive is keyed by the MusicBrainz release id, so it's THE
    # release's art by definition — no fuzzy match to validate. Resolves only
    # once MusicBrainz enrichment has found the release.
    mbid = (metadata or {}).get("musicbrainz_release_id")
    if not mbid:
        return None
    return f"https://coverartarchive.org/release/{mbid}/front-1200"


def _deezer_art(artist: str, album: str, metadata: dict) -> Optional[str]:
    from core.metadata.registry import get_deezer_client
    client = get_deezer_client()
    if not client:
        return None
    data = client.search_album(artist, album)
    if not data:
        return None
    got_album, got_artist = _result_album_artist(data)
    if not _album_matches(artist, album, got_artist, got_album):
        return None
    url = data.get("cover_xl") or data.get("cover_big") or data.get("cover_medium")
    if not url:
        return None
    try:
        from core.deezer_client import _upgrade_deezer_cover_url
        return _upgrade_deezer_cover_url(url)
    except Exception:
        return url


def _itunes_art(artist: str, album: str, metadata: dict) -> Optional[str]:
    from core.metadata.registry import get_itunes_client
    client = get_itunes_client()
    if not client:
        return None
    for alb in (client.search_albums(f"{artist} {album}") or []):
        url = getattr(alb, "image_url", None)
        if not url:
            continue
        got_album, got_artist = _result_album_artist(alb)
        if _album_matches(artist, album, got_artist, got_album):
            # iTunes serves any size via the WxH segment — request the max so
            # iTunes contributes high-res art, not the 600px default.
            return re.sub(r"/\d+x\d+bb\.", "/3000x3000bb.", url)
    return None


def _audiodb_art(artist: str, album: str, metadata: dict) -> Optional[str]:
    from core.audiodb_client import AudioDBClient
    data = AudioDBClient().search_album(artist, album)
    if not data:
        return None
    got_album, got_artist = _result_album_artist(data)
    if not _album_matches(artist, album, got_artist, got_album):
        return None
    return data.get("strAlbumThumb") or None


def _spotify_art(artist: str, album: str, metadata: dict) -> Optional[str]:
    from core.metadata.registry import get_client_for_source
    client = get_client_for_source("spotify")
    if not client:
        return None
    for alb in (client.search_albums(f"{artist} {album}") or []):
        url = getattr(alb, "image_url", None)
        if not url:
            continue
        got_album, got_artist = _result_album_artist(alb)
        if _album_matches(artist, album, got_artist, got_album):
            return url
    return None


_LOOKUPS: Dict[str, Callable[[str, str, dict], Optional[str]]] = {
    "caa": _caa_art,
    "deezer": _deezer_art,
    "itunes": _itunes_art,
    "audiodb": _audiodb_art,
    "spotify": _spotify_art,
}


def select_preferred_art_url(
    artist: Optional[str],
    album: Optional[str],
    metadata: Optional[dict],
    configured_order,
    validate: Optional[Callable[[str, str], bool]] = None,
) -> Optional[str]:
    """Pick a cover-art URL from the user's configured source order, or None.

    ``None`` means "feature off, or nothing in the list resolved" — the caller
    then keeps its existing art (today's behavior), so the worst case is simply
    the cover you'd get today. This is the single entry point the art pipeline
    calls; it's a no-op (returns ``None`` immediately) unless ``album_art_order``
    is an explicit non-empty list, which keeps every existing install untouched.

    ``validate(source, url)`` is an optional gate forwarded to the resolver — the
    art pipeline passes one that fetches the candidate and rejects images below a
    minimum resolution, so a too-small cover (e.g. a low-res Cover Art Archive
    upload) is skipped and the next source is tried instead of winning by
    priority alone.
    """
    if not isinstance(configured_order, (list, tuple)) or not configured_order:
        return None
    from core.metadata.art_sources import effective_art_order, resolve_cover_art
    order = [s for s in effective_art_order(configured_order) if is_art_source_available(s)]
    if not order:
        return None
    lookup = build_art_lookup(artist or "", album or "", metadata or {})
    url, _src = resolve_cover_art(order, lookup, validate=validate)
    return url


def build_art_lookup(
    artist: str,
    album: str,
    metadata: Optional[dict] = None,
) -> Callable[[str], Optional[str]]:
    """Return a ``source_name -> cover_url | None`` callable for one album,
    suitable to pass straight to ``art_sources.resolve_cover_art``. Results are
    cached per source so re-resolving across an album's tracks costs at most one
    lookup per source, and every lookup is guarded (errors → None)."""
    meta = metadata or {}
    cache: Dict[str, Optional[str]] = {}

    def lookup(source: str) -> Optional[str]:
        name = (source or "").strip().lower()
        if name in cache:
            return cache[name]
        fn = _LOOKUPS.get(name)
        url: Optional[str] = None
        if fn is not None:
            try:
                url = fn(artist, album, meta)
            except Exception as exc:
                logger.debug("[art] %s lookup failed: %s", name, exc)
                url = None
        cache[name] = url
        return url

    return lookup
