import re
import requests
import time
import threading
from typing import Dict, List, Optional, Any
from functools import wraps
from dataclasses import dataclass
from utils.logging_config import get_logger
from core.metadata.cache import get_metadata_cache

logger = get_logger("deezer_client")

# Global rate limiting variables
_last_api_call_time = 0
_api_call_lock = threading.Lock()
MIN_API_INTERVAL = 1.0  # 1 second between API calls (Deezer soft limit: 50 req/5s)

def rate_limited(func):
    """Decorator to enforce rate limiting on Deezer API calls"""
    @wraps(func)
    def wrapper(*args, **kwargs):
        global _last_api_call_time

        with _api_call_lock:
            current_time = time.time()
            time_since_last_call = current_time - _last_api_call_time

            if time_since_last_call < MIN_API_INTERVAL:
                sleep_time = MIN_API_INTERVAL - time_since_last_call
                time.sleep(sleep_time)

            _last_api_call_time = time.time()

        from core.api_call_tracker import api_call_tracker
        api_call_tracker.record_call('deezer')

        try:
            result = func(*args, **kwargs)
            return result
        except Exception as e:
            if "rate limit" in str(e).lower() or "429" in str(e):
                logger.warning(f"Deezer rate limit hit, implementing backoff: {e}")
                time.sleep(4.0)
            raise e
    return wrapper


# Pattern matches Deezer's CDN cover/picture URL: a numeric width-x-height
# segment in the path (e.g. ``/1000x1000-000000-80-0-0.jpg``). Captures
# both halves so the replacement can use a single dimension and preserve
# the rest of the path verbatim.
_DEEZER_CDN_SIZE_PATTERN = re.compile(r'/(\d+)x(\d+)-')

# Maximum size Deezer's CDN serves before returning 403. Verified
# empirically against multiple albums — 1900 works reliably, 2000+
# returns Forbidden. CDN serves the source-native size when it's
# smaller than requested, so asking for 1900 is safe even on albums
# whose source upload was lower-res (no upscaling, just same bytes).
_DEEZER_MAX_COVER_SIZE = 1900


def _upgrade_deezer_cover_url(url: str, target_size: int = _DEEZER_MAX_COVER_SIZE) -> str:
    """Rewrite a Deezer CDN cover/picture URL to request a larger size.

    Deezer's API returns ``cover_xl`` / ``picture_xl`` URLs at
    1000×1000, but the underlying CDN serves up to 1900×1900 by
    rewriting the size segment in the URL path. This helper does the
    rewrite — same idea as ``_upgrade_spotify_image_url`` in
    ``spotify_client`` and the ``mzstatic.com`` size-replacement in
    ``download_cover_art``.

    Defensive on every input shape:
    - Empty / None URL → returned as-is
    - Non-Deezer URL (no ``dzcdn`` host, no size segment) → returned as-is
    - Already at or above target size → returned as-is (no point rewriting)

    The CDN returns the source-native image bytes when source < target,
    so asking for 1900 on an album whose source was uploaded at 600
    just returns the 600-pixel image — no upscaling, no failure.
    """
    if not url or 'dzcdn' not in url:
        return url
    match = _DEEZER_CDN_SIZE_PATTERN.search(url)
    if not match:
        return url
    current = int(match.group(1))
    if current >= target_size:
        return url
    return _DEEZER_CDN_SIZE_PATTERN.sub(f'/{target_size}x{target_size}-', url, count=1)


def _is_full_track_payload(payload: Optional[Dict[str, Any]]) -> bool:
    """Distinguish a full `/track/<id>` cache hit from partial album-tracks data.

    Three Deezer endpoints feed the per-track cache:
      - `/track/<id>` — full record, includes both `track_position` AND
        `contributors` (the multi-artist list the contributors-upgrade
        path reads).
      - `/album/<id>/tracks` — partial; includes `track_position` but
        omits `contributors`.
      - `/search/track` — minimal; lacks `track_position`.

    Pre-fix `get_track_details` only checked `track_position`, so
    partial album-tracks payloads were treated as full hits and the
    contributors-upgrade silently fell back to single-artist tagging
    whenever an album had been fetched before its individual tracks
    were post-processed (issue #588).

    `contributors` key presence is the load-bearing distinction —
    `[]` is a valid value for genuinely single-artist tracks fetched
    via the per-track endpoint, so test for key membership not
    truthiness.
    """
    if not isinstance(payload, dict):
        return False
    return 'track_position' in payload and 'contributors' in payload


# ==================== Dataclasses (match iTunesClient / SpotifyClient format) ====================

@dataclass
class Track:
    id: str
    name: str
    artists: List[str]
    album: str
    duration_ms: int
    popularity: int
    preview_url: Optional[str] = None
    external_urls: Optional[Dict[str, str]] = None
    image_url: Optional[str] = None
    release_date: Optional[str] = None
    track_number: Optional[int] = None
    disc_number: Optional[int] = None
    album_type: Optional[str] = None
    total_tracks: Optional[int] = None

    @classmethod
    def from_deezer_track(cls, track_data: Dict[str, Any]) -> 'Track':
        # Extract album image
        album_data = track_data.get('album', {})
        album_image_url = None
        if isinstance(album_data, dict):
            album_image_url = album_data.get('cover_xl') or album_data.get('cover_big') or album_data.get('cover_medium')

        # Get artist name(s) — use contributors for multi-artist tracks (feat. collabs)
        artist_data = track_data.get('artist', {})
        artist_name = artist_data.get('name', 'Unknown Artist') if isinstance(artist_data, dict) else 'Unknown Artist'
        contributors = track_data.get('contributors', [])
        if isinstance(contributors, list) and len(contributors) > 1:
            artist_names = []
            for c in contributors:
                if isinstance(c, dict) and c.get('name'):
                    artist_names.append(c['name'])
            if artist_names:
                all_artists = artist_names
            else:
                all_artists = [artist_name]
        else:
            all_artists = [artist_name]

        # Get album name
        album_name = ''
        if isinstance(album_data, dict):
            album_name = album_data.get('title', '')
        elif isinstance(album_data, str):
            album_name = album_data

        # Build external URLs
        external_urls = {}
        if track_data.get('link'):
            external_urls['deezer'] = track_data['link']

        # Deezer search doesn't return album_type directly; infer if nb_tracks available
        nb_tracks = album_data.get('nb_tracks') if isinstance(album_data, dict) else None
        album_type = track_data.get('type')  # Deezer sometimes returns 'album'/'single'
        if not album_type and nb_tracks:
            if nb_tracks <= 3:
                album_type = 'single'
            elif nb_tracks <= 6:
                album_type = 'ep'
            else:
                album_type = 'album'

        return cls(
            id=str(track_data.get('id', '')),
            name=track_data.get('title', ''),
            artists=all_artists,
            album=album_name,
            duration_ms=track_data.get('duration', 0) * 1000,  # Deezer returns seconds
            popularity=track_data.get('rank', 0),
            preview_url=track_data.get('preview'),
            external_urls=external_urls if external_urls else None,
            image_url=album_image_url,
            release_date=track_data.get('release_date') or (album_data.get('release_date') if isinstance(album_data, dict) else None),
            track_number=track_data.get('track_position'),
            disc_number=track_data.get('disk_number', 1),
            album_type=album_type,
            total_tracks=nb_tracks,
        )


@dataclass
class Artist:
    id: str
    name: str
    popularity: int
    genres: List[str]
    followers: int
    image_url: Optional[str] = None
    external_urls: Optional[Dict[str, str]] = None

    @classmethod
    def from_deezer_artist(cls, artist_data: Dict[str, Any]) -> 'Artist':
        image_url = artist_data.get('picture_xl') or artist_data.get('picture_big') or artist_data.get('picture_medium')

        external_urls = {}
        if artist_data.get('link'):
            external_urls['deezer'] = artist_data['link']

        return cls(
            id=str(artist_data.get('id', '')),
            name=artist_data.get('name', ''),
            popularity=0,
            genres=[],
            followers=artist_data.get('nb_fan', 0),
            image_url=image_url,
            external_urls=external_urls if external_urls else None
        )


@dataclass
class Album:
    id: str
    name: str
    artists: List[str]
    release_date: str
    total_tracks: int
    album_type: str
    image_url: Optional[str] = None
    external_urls: Optional[Dict[str, str]] = None
    explicit: Optional[bool] = None

    @classmethod
    def from_deezer_album(cls, album_data: Dict[str, Any]) -> 'Album':
        image_url = album_data.get('cover_xl') or album_data.get('cover_big') or album_data.get('cover_medium')

        external_urls = {}
        if album_data.get('link'):
            external_urls['deezer'] = album_data['link']

        artist_data = album_data.get('artist', {})
        artist_name = artist_data.get('name', 'Unknown Artist') if isinstance(artist_data, dict) else 'Unknown Artist'

        # Map Deezer record_type
        record_type = album_data.get('record_type', 'album')
        if record_type == 'single':
            album_type = 'single'
        elif record_type == 'ep':
            album_type = 'ep'
        elif record_type == 'compile':
            album_type = 'compilation'
        else:
            album_type = 'album'

        return cls(
            id=str(album_data.get('id', '')),
            name=album_data.get('title', ''),
            artists=[artist_name],
            release_date=album_data.get('release_date', ''),
            total_tracks=album_data.get('nb_tracks', 0),
            album_type=album_type,
            image_url=image_url,
            external_urls=external_urls if external_urls else None,
            explicit=bool(album_data.get('explicit_lyrics', False)),
        )


@dataclass
class Playlist:
    id: str
    name: str
    description: Optional[str]
    owner: str
    public: bool
    collaborative: bool
    tracks: List[Track]
    total_tracks: int


class DeezerClient:
    """
    Deezer API client for music metadata and playlist access.

    Provides metadata parity with iTunesClient for use as a fallback source.
    Also provides enrichment methods (search_artist, search_album, search_track)
    and playlist methods used by deezer_worker.py.

    Free, no authentication required.
    Rate limit: ~50 calls/5s.
    """

    BASE_URL = "https://api.deezer.com"

    def __init__(self):
        self.session = requests.Session()
        self.session.headers.update({
            'User-Agent': 'SoulSync/1.0',
            'Accept': 'application/json'
        })
        self._access_token = None
        self._load_token()
        logger.info("Deezer client initialized" + (" (authenticated)" if self._access_token else " (public)"))

    def _load_token(self):
        """Load OAuth access token from config if available."""
        try:
            from config.settings import config_manager
            self._access_token = config_manager.get('deezer.access_token', None)
        except Exception:
            self._access_token = None

    def is_user_authenticated(self) -> bool:
        """Check if we have a Deezer OAuth user token (for favorites, playlists, etc.)"""
        return bool(self._access_token)

    def is_authenticated(self) -> bool:
        """Deezer public API requires no authentication — always available"""
        return True

    def reload_config(self):
        """Reload configuration — refresh OAuth token from config."""
        self._load_token()

    def _api_get(self, endpoint: str, params: dict = None, timeout: int = 15) -> Optional[Dict[str, Any]]:
        """Generic GET request to Deezer API with error handling.
        Includes OAuth access_token when available for user-level endpoints."""
        try:
            url = f"{self.BASE_URL}/{endpoint.lstrip('/')}"
            if params is None:
                params = {}
            # Include access token for authenticated requests
            if self._access_token and 'access_token' not in params:
                params['access_token'] = self._access_token
            response = self.session.get(url, params=params, timeout=timeout)

            if response.status_code != 200:
                logger.error(f"Deezer API returned status {response.status_code} for {endpoint}")
                return None

            data = response.json()

            if 'error' in data:
                error = data['error']
                error_type = error.get('type', 'Unknown')
                error_msg = error.get('message', 'Unknown error')
                if error_type == 'DataException':
                    logger.debug(f"Deezer data not found: {endpoint}")
                else:
                    logger.error(f"Deezer API error ({error_type}): {error_msg}")
                return None

            return data

        except Exception as e:
            logger.error(f"Error in Deezer API request ({endpoint}): {e}")
            return None

    # ==================== Metadata Source Methods (iTunesClient parity) ====================
    # These methods follow the same interface as iTunesClient so DeezerClient
    # can serve as a drop-in fallback metadata source in SpotifyClient.

    @rate_limited
    def search_tracks(
        self,
        query: str = '',
        limit: int = 20,
        *,
        track: Optional[str] = None,
        artist: Optional[str] = None,
        album: Optional[str] = None,
    ) -> List[Track]:
        """Search for tracks — returns Track dataclass list (metadata source interface).

        Two call modes:

        1. **Free-text** (`query='Foreigner Dirty White Boy'`) — legacy
           shape, passes the string straight to Deezer's `q` param.
           Same behaviour as before, kept for backward compat.

        2. **Field-scoped** (`track='Dirty White Boy', artist='Foreigner'`) —
           builds Deezer's advanced search syntax (`track:"X" artist:"Y"`).
           Massively tighter relevance than the free-text path because
           the API matches each term in the right field instead of
           anywhere across title / lyrics / artist / album / contributors.
           Without this, the Deezer ranking buries the canonical track
           under karaoke / cover / "originally performed by" variants
           — see issue #534.

        Field-scoped form is used whenever ``track`` or ``artist`` is
        provided. ``query`` is ignored in that case (the field params
        are authoritative). When both are missing, falls through to
        ``query``. The cache key is the constructed query string in
        either case so the two paths share entries naturally.
        """
        # Build the actual API query — advanced syntax when callers pass
        # field hints, raw query otherwise.
        used_advanced = bool(track or artist or album)
        if used_advanced:
            api_query = self._build_advanced_query(track=track, artist=artist, album=album)
        else:
            api_query = query

        if not api_query:
            return []

        tracks = self._search_tracks_with_query(api_query, limit)

        # Safety net: Deezer's advanced syntax is `artist:"X"`-style
        # substring match, but in practice it's brittle on artist name
        # variants ("Foreigner [US]", "The Foreigner", etc.) and on
        # tracks indexed under non-canonical title spellings. When the
        # advanced query returns nothing, fall back to a free-text join
        # so the user sees the prior (less-relevant but non-empty) result
        # set rather than "No matches" — same behaviour as pre-fix for
        # this edge case. Caller-side rerank still tightens the result.
        if not tracks and used_advanced:
            fallback_parts = [p for p in (track, artist, album) if p]
            fallback_query = ' '.join(fallback_parts)
            if fallback_query and fallback_query != api_query:
                logger.debug(
                    "[Deezer] Advanced query returned 0 results, falling back "
                    "to free-text: %r → %r", api_query, fallback_query,
                )
                tracks = self._search_tracks_with_query(fallback_query, limit)

        return tracks

    def _search_tracks_with_query(self, api_query: str, limit: int) -> List[Track]:
        """Cache-aware single API call. Pulled out so the
        ``search_tracks`` orchestration can call this twice (advanced
        query → free-text fallback) without duplicating the cache +
        parse + store dance."""
        cache = get_metadata_cache()
        cached_results = cache.get_search_results('deezer', 'track', api_query, limit)
        if cached_results is not None:
            tracks = []
            for raw in cached_results:
                try:
                    tracks.append(Track.from_deezer_track(raw))
                except Exception as e:
                    logger.debug("Track.from_deezer_track cache parse: %s", e)
            if tracks:
                return tracks

        data = self._api_get('search/track', {'q': api_query, 'limit': min(limit, 100)})
        if not data or 'data' not in data:
            return []

        tracks = []
        raw_items = []
        for track_data in data['data']:
            track_obj = Track.from_deezer_track(track_data)
            tracks.append(track_obj)
            raw_items.append(track_data)

        entries = [(str(td.get('id', '')), td) for td in raw_items if td.get('id')]
        if entries:
            cache.store_entities_bulk('deezer', 'track', entries)
            cache.store_search_results('deezer', 'track', api_query, limit,
                                       [str(td.get('id', '')) for td in raw_items if td.get('id')])

        return tracks

    @staticmethod
    def _build_advanced_query(
        *,
        track: Optional[str] = None,
        artist: Optional[str] = None,
        album: Optional[str] = None,
    ) -> str:
        """Compose Deezer's advanced search syntax from field hints.

        Per Deezer's docs:
        https://developers.deezer.com/api/search

            q=track:"X" artist:"Y" album:"Z"

        Quotes around each value preserve multi-word phrases. Empty
        fields are skipped. Embedded double-quotes get stripped (no
        escape mechanism in Deezer's syntax) — rare in practice, but
        a search for `O"Hara` would otherwise produce a malformed
        query.
        """
        parts = []
        if track:
            parts.append(f'track:"{track.replace(chr(34), "")}"')
        if artist:
            parts.append(f'artist:"{artist.replace(chr(34), "")}"')
        if album:
            parts.append(f'album:"{album.replace(chr(34), "")}"')
        return ' '.join(parts)

    @rate_limited
    def search_artists(self, query: str, limit: int = 20) -> List[Artist]:
        """Search for artists — returns Artist dataclass list (metadata source interface)"""
        cache = get_metadata_cache()
        cached_results = cache.get_search_results('deezer', 'artist', query, limit)
        if cached_results is not None:
            artists = []
            for raw in cached_results:
                try:
                    artists.append(Artist.from_deezer_artist(raw))
                except Exception as e:
                    logger.debug("Artist.from_deezer_artist cache parse: %s", e)
            if artists:
                return artists

        data = self._api_get('search/artist', {'q': query, 'limit': min(limit, 100)})
        if not data or 'data' not in data:
            return []

        artists = []
        raw_items = []
        for artist_data in data['data']:
            artist = Artist.from_deezer_artist(artist_data)
            artists.append(artist)
            raw_items.append(artist_data)

        entries = [(str(ad.get('id', '')), ad) for ad in raw_items if ad.get('id')]
        if entries:
            cache.store_entities_bulk('deezer', 'artist', entries)
            cache.store_search_results('deezer', 'artist', query, limit,
                                       [str(ad.get('id', '')) for ad in raw_items if ad.get('id')])

        return artists

    @rate_limited
    def search_albums(self, query: str, limit: int = 20) -> List[Album]:
        """Search for albums — returns Album dataclass list (metadata source interface)"""
        cache = get_metadata_cache()
        cached_results = cache.get_search_results('deezer', 'album', query, limit)
        if cached_results is not None:
            albums = []
            for raw in cached_results:
                try:
                    albums.append(Album.from_deezer_album(raw))
                except Exception as e:
                    logger.debug("Album.from_deezer_album cache parse: %s", e)
            if albums:
                return albums

        data = self._api_get('search/album', {'q': query, 'limit': min(limit, 100)})
        if not data or 'data' not in data:
            return []

        albums = []
        raw_items = []
        for album_data in data['data']:
            album = Album.from_deezer_album(album_data)
            albums.append(album)
            raw_items.append(album_data)

        entries = [(str(ad.get('id', '')), ad) for ad in raw_items if ad.get('id')]
        if entries:
            cache.store_entities_bulk('deezer', 'album', entries, skip_if_exists=True)
            cache.store_search_results('deezer', 'album', query, limit,
                                       [str(ad.get('id', '')) for ad in raw_items if ad.get('id')])

        return albums[:limit]

    def get_track_details(self, track_id: str) -> Optional[Dict[str, Any]]:
        """Get detailed track info — returns Spotify-compatible dict (metadata source interface)"""
        cache = get_metadata_cache()
        cached = cache.get_entity('deezer', 'track', str(track_id))
        if cached and cached.get('title') and _is_full_track_payload(cached):
            return self._build_enhanced_track(cached)
        # Otherwise fall through to fetch full data from API

        data = self._api_get(f'track/{track_id}')
        if not data:
            return None

        cache.store_entity('deezer', 'track', str(track_id), data)
        return self._build_enhanced_track(data)

    def _build_enhanced_track(self, track_data: Dict[str, Any]) -> Dict[str, Any]:
        """Build Spotify-compatible enhanced track dict from raw Deezer data"""
        artist_data = track_data.get('artist', {})
        album_data = track_data.get('album', {})

        artist_name = artist_data.get('name', 'Unknown Artist') if isinstance(artist_data, dict) else 'Unknown Artist'
        album_name = album_data.get('title', '') if isinstance(album_data, dict) else str(album_data) if album_data else ''
        album_id = str(album_data.get('id', '')) if isinstance(album_data, dict) else ''

        # Use contributors for multi-artist tracks
        contributors = track_data.get('contributors', [])
        if isinstance(contributors, list) and len(contributors) > 1:
            all_artists = [c['name'] for c in contributors if isinstance(c, dict) and c.get('name')]
            if not all_artists:
                all_artists = [artist_name]
        else:
            all_artists = [artist_name]

        return {
            'id': str(track_data.get('id', '')),
            'name': track_data.get('title', ''),
            'track_number': track_data.get('track_position', 0),
            'disc_number': track_data.get('disk_number', 1),
            'duration_ms': track_data.get('duration', 0) * 1000,
            'explicit': track_data.get('explicit_lyrics', False),
            'artists': all_artists,
            'primary_artist': artist_name,
            'album': {
                'id': album_id,
                'name': album_name,
                'total_tracks': album_data.get('nb_tracks', 0) if isinstance(album_data, dict) else 0,
                'release_date': track_data.get('release_date', '') or (album_data.get('release_date', '') if isinstance(album_data, dict) else ''),
                'album_type': 'album',
                'artists': [artist_name]
            },
            'is_album_track': (album_data.get('nb_tracks', 0) if isinstance(album_data, dict) else 0) > 1,
            'raw_data': track_data
        }

    def get_track_features(self, track_id: str) -> Optional[Dict[str, Any]]:
        """Deezer does not provide audio features like Spotify"""
        return None

    def get_album_metadata(self, album_id: str, include_tracks: bool = True) -> Optional[Dict[str, Any]]:
        """Get album info — returns Spotify-compatible dict (metadata source interface).

        Matches iTunesClient.get_album() interface. The enrichment method below
        is get_album_raw() (used by deezer_worker.py)."""
        cache = get_metadata_cache()
        cached = cache.get_entity('deezer', 'album', str(album_id))
        # Only use cache if it has full album data (release_date indicates full API response,
        # not just a search result which lacks release_date and track details)
        if cached and cached.get('title') and cached.get('release_date'):
            return self._build_album_result(cached, album_id, include_tracks)

        data = self._api_get(f'album/{album_id}')
        if not data:
            # Fall back to cached if API fails
            if cached and cached.get('title'):
                return self._build_album_result(cached, album_id, include_tracks)
            return None

        cache.store_entity('deezer', 'album', str(album_id), data)
        return self._build_album_result(data, album_id, include_tracks)

    def _build_album_result(self, album_data: Dict[str, Any], album_id: str, include_tracks: bool = True) -> Dict[str, Any]:
        """Build Spotify-compatible album result from Deezer data"""
        images = []
        for size_key, height in [('cover_xl', 1000), ('cover_big', 500), ('cover_medium', 250), ('cover_small', 56)]:
            if album_data.get(size_key):
                images.append({'url': album_data[size_key], 'height': height, 'width': height})

        artist_data = album_data.get('artist', {})
        artist_name = artist_data.get('name', 'Unknown Artist') if isinstance(artist_data, dict) else 'Unknown Artist'
        artist_id = str(artist_data.get('id', '')) if isinstance(artist_data, dict) else ''

        record_type = album_data.get('record_type', 'album')
        if record_type == 'single':
            album_type = 'single'
        elif record_type == 'ep':
            album_type = 'ep'
        elif record_type == 'compile':
            album_type = 'compilation'
        else:
            album_type = 'album'

        album_result = {
            'id': str(album_data.get('id', album_id)),
            'name': album_data.get('title', ''),
            'images': images,
            'artists': [{'name': artist_name, 'id': artist_id}],
            'release_date': album_data.get('release_date', ''),
            'total_tracks': album_data.get('nb_tracks', 0),
            'album_type': album_type,
            'external_urls': {'deezer': album_data.get('link', '')},
            'uri': f"deezer:album:{album_data.get('id', '')}",
            '_source': 'deezer',
            '_raw_data': album_data
        }

        if include_tracks:
            tracks_data = self.get_album_tracks(album_id)
            if tracks_data and 'items' in tracks_data:
                album_result['tracks'] = tracks_data
            else:
                album_result['tracks'] = {'items': [], 'total': 0}

        return album_result

    def get_album_tracks(self, album_id: str) -> Optional[Dict[str, Any]]:
        """Get album tracks — returns Spotify-compatible format (metadata source interface)"""
        cache = get_metadata_cache()
        cached = cache.get_entity('deezer', 'album', f"{album_id}_tracks")
        if cached:
            return cached

        data = self._api_get(f'album/{album_id}/tracks', {'limit': 500})
        if not data or 'data' not in data:
            album_data = self._api_get(f'album/{album_id}')
            if album_data and 'tracks' in album_data and 'data' in album_data['tracks']:
                data = album_data['tracks']
            else:
                return None

        # Get album-level info for images and name
        album_info = self._api_get(f'album/{album_id}')
        album_images = []
        album_name = ''
        if album_info:
            album_name = album_info.get('title', '')
            for size_key, height in [('cover_xl', 1000), ('cover_big', 500), ('cover_medium', 250)]:
                if album_info.get(size_key):
                    album_images.append({'url': album_info[size_key], 'height': height, 'width': height})

        tracks = []
        for item in data['data']:
            artist_data = item.get('artist', {})
            artist_name = artist_data.get('name', 'Unknown Artist') if isinstance(artist_data, dict) else 'Unknown Artist'

            normalized_track = {
                'id': str(item.get('id', '')),
                'name': item.get('title', ''),
                'artists': [{'name': artist_name}],
                'album': {
                    'id': str(album_id),
                    'name': album_name,
                    'images': album_images,
                    'release_date': album_info.get('release_date', '') if album_info else ''
                },
                'duration_ms': item.get('duration', 0) * 1000,
                'track_number': item.get('track_position', 0),
                'disc_number': item.get('disk_number', 1),
                'explicit': item.get('explicit_lyrics', False),
                'preview_url': item.get('preview'),
                'uri': f"deezer:track:{item.get('id', '')}",
                'external_urls': {'deezer': item.get('link', '')},
                '_source': 'deezer'
            }
            tracks.append(normalized_track)

        tracks.sort(key=lambda t: (t.get('disc_number', 1), t.get('track_number', 0)))

        logger.info(f"Retrieved {len(tracks)} tracks for album {album_id}")

        result = {
            'items': tracks,
            'total': len(tracks),
            'limit': len(tracks),
            'next': None
        }

        cache.store_entity('deezer', 'album', f"{album_id}_tracks", result)

        # Cache individual tracks
        for item in data['data']:
            if item.get('id'):
                cache.store_entity('deezer', 'track', str(item['id']), item)

        return result

    def get_artist_top_tracks(self, artist_id: str, limit: int = 10) -> List[Dict[str, Any]]:
        """Return the artist's top tracks in Spotify-compatible dict format.

        Wraps Deezer's `/artist/{id}/top?limit=N`. Returns dicts with the same
        shape Spotify's `artist_top_tracks` produces — id, name, artists, album
        (with album_type / total_tracks / release_date / images), duration_ms,
        track_number, disc_number — so callers don't need to branch on source.
        """
        if not artist_id:
            return []
        try:
            limit = max(1, min(int(limit or 10), 100))
        except (TypeError, ValueError):
            limit = 10

        data = self._api_get(f'artist/{artist_id}/top', {'limit': limit})
        if not data or 'data' not in data:
            return []

        tracks = []
        for track_data in data['data']:
            if not isinstance(track_data, dict):
                continue
            artist_data = track_data.get('artist') or {}
            album_data = track_data.get('album') or {}

            # Build images list from any cover sizes Deezer returned for the album
            images = []
            if isinstance(album_data, dict):
                for size_key, dim in [('cover_xl', 1000), ('cover_big', 500),
                                       ('cover_medium', 250), ('cover_small', 56)]:
                    if album_data.get(size_key):
                        images.append({'url': album_data[size_key], 'height': dim, 'width': dim})

            # Deezer `/artist/{id}/top` results don't include record_type on the
            # nested album object; we don't have a track-count to infer from
            # either. Default 'album' so the path-builder template variable
            # always has something to substitute (existing behavior elsewhere).
            album_payload = {
                'id': str(album_data.get('id', '')) if isinstance(album_data, dict) else '',
                'name': album_data.get('title', '') if isinstance(album_data, dict) else '',
                'album_type': 'album',
                'images': images,
                'release_date': '',
                'total_tracks': 0,
                'artists': [{'name': artist_data.get('name', '')}] if isinstance(artist_data, dict) else [],
            }

            tracks.append({
                'id': str(track_data.get('id', '')),
                'name': track_data.get('title', ''),
                'artists': [{
                    'id': str(artist_data.get('id', '')) if isinstance(artist_data, dict) else '',
                    'name': artist_data.get('name', '') if isinstance(artist_data, dict) else '',
                }],
                'album': album_payload,
                'duration_ms': (track_data.get('duration') or 0) * 1000,  # Deezer is seconds
                'popularity': track_data.get('rank', 0),
                'preview_url': track_data.get('preview'),
                'external_urls': {'deezer': track_data['link']} if track_data.get('link') else {},
                'track_number': track_data.get('track_position'),
                'disc_number': track_data.get('disk_number', 1),
                'explicit': bool(track_data.get('explicit_lyrics', False)),
                '_source': 'deezer',
            })

        return tracks

    def get_artist_info(self, artist_id: str) -> Optional[Dict[str, Any]]:
        """Get full artist details — returns Spotify-compatible dict (metadata source interface).

        Matches iTunesClient.get_artist() interface."""
        cache = get_metadata_cache()
        cached = cache.get_entity('deezer', 'artist', str(artist_id))
        if cached and cached.get('name'):
            return self._build_artist_result(cached)

        data = self._api_get(f'artist/{artist_id}')
        if not data:
            return None

        cache.store_entity('deezer', 'artist', str(artist_id), data)
        return self._build_artist_result(data)

    def _build_artist_result(self, artist_data: Dict[str, Any]) -> Dict[str, Any]:
        """Build Spotify-compatible artist result from Deezer data"""
        images = []
        for size_key, height in [('picture_xl', 1000), ('picture_big', 500), ('picture_medium', 250), ('picture_small', 56)]:
            if artist_data.get(size_key):
                images.append({'url': artist_data[size_key], 'height': height, 'width': height})

        return {
            'id': str(artist_data.get('id', '')),
            'name': artist_data.get('name', ''),
            'images': images,
            'genres': [],
            'popularity': 0,
            'followers': {'total': artist_data.get('nb_fan', 0)},
            'external_urls': {'deezer': artist_data.get('link', '')},
            'uri': f"deezer:artist:{artist_data.get('id', '')}",
            '_source': 'deezer',
            '_raw_data': artist_data
        }

    def get_artist_albums_list(self, artist_id: str, album_type: str = 'album,single', limit: int = 200) -> List[Album]:
        """Get albums by artist ID — returns Album dataclass list (metadata source interface).

        Matches iTunesClient.get_artist_albums() interface.
        Paginates through all results up to the requested limit."""
        albums = []
        all_raw = []
        requested_types = [t.strip() for t in album_type.split(',')]
        offset = 0
        page_size = 100  # Deezer API max per request

        while offset < limit:
            fetch_limit = min(page_size, limit - offset)
            data = self._api_get(f'artist/{artist_id}/albums', {'limit': fetch_limit, 'index': offset})
            if not data or 'data' not in data or len(data['data']) == 0:
                break

            for album_data in data['data']:
                all_raw.append(album_data)
                album = Album.from_deezer_album(album_data)

                if album_type != 'album,single':
                    if album.album_type not in requested_types:
                        if not (album.album_type == 'ep' and 'single' in requested_types):
                            continue

                albums.append(album)

            if len(data['data']) < fetch_limit:
                break  # Last page
            offset += len(data['data'])

        cache = get_metadata_cache()
        # Deezer's /artist/{id}/albums endpoint doesn't include artist info on each album.
        # Inject it so cached album entities have artist_name for discover page display.
        artist_stub = None
        if albums and albums[0].artists:
            artist_stub = {'id': int(artist_id) if artist_id.isdigit() else 0, 'name': albums[0].artists[0]}
        entries = []
        for ad in all_raw:
            if ad.get('id'):
                if artist_stub and not ad.get('artist'):
                    ad['artist'] = artist_stub
                entries.append((str(ad['id']), ad))
        if entries:
            cache.store_entities_bulk('deezer', 'album', entries, skip_if_exists=True)

        logger.info(f"Retrieved {len(albums)} albums for artist {artist_id}")
        return albums[:limit]

    # ==================== Interface Aliases (match iTunesClient method names) ====================
    # These allow SpotifyClient to call self._fallback.get_album() etc. without
    # conditional dispatch — same method names as iTunesClient.
    get_album = get_album_metadata
    get_artist = get_artist_info
    get_artist_albums = get_artist_albums_list

    def _get_artist_image_from_albums(self, artist_id: str) -> Optional[str]:
        """Compatibility with iTunesClient — Deezer artists have direct image URLs."""
        artist_data = self._api_get(f'artist/{artist_id}')
        if artist_data:
            return artist_data.get('picture_xl') or artist_data.get('picture_big') or artist_data.get('picture_medium')
        return None

    # ==================== User Methods (require OAuth) ====================

    @rate_limited
    def get_user_favorite_artists(self, limit: int = 200) -> list:
        """Fetch user's favorite artists from Deezer. Requires OAuth access token.
        Returns list of dicts with deezer_id, name, image_url."""
        if not self._access_token:
            logger.debug("Deezer not user-authenticated — cannot fetch favorites")
            return []
        try:
            artists = []
            index = 0
            while len(artists) < limit:
                data = self._api_get('user/me/artists', params={
                    'limit': min(100, limit - len(artists)),
                    'index': index
                })
                if not data or 'data' not in data:
                    break
                items = data['data']
                if not items:
                    break
                for a in items:
                    artists.append({
                        'deezer_id': str(a.get('id', '')),
                        'name': a.get('name', ''),
                        'image_url': a.get('picture_xl') or a.get('picture_big') or a.get('picture_medium', ''),
                    })
                if not data.get('next'):
                    break
                index += len(items)
                time.sleep(0.3)  # Extra breathing room

            logger.info(f"Retrieved {len(artists)} favorite artists from Deezer")
            return artists
        except Exception as e:
            logger.error(f"Error fetching Deezer favorite artists: {e}")
            return []

    @rate_limited
    def get_user_favorite_albums(self, limit: int = 200) -> list:
        """Fetch user's favorite albums from Deezer. Requires OAuth access token.
        Returns list of dicts with deezer_id, album_name, artist_name, image_url, release_date, total_tracks."""
        if not self._access_token:
            logger.debug("Deezer not user-authenticated — cannot fetch favorite albums")
            return []
        try:
            albums = []
            index = 0
            while len(albums) < limit:
                data = self._api_get('user/me/albums', params={
                    'limit': min(100, limit - len(albums)),
                    'index': index
                })
                if not data or 'data' not in data:
                    break
                items = data['data']
                if not items:
                    break
                for a in items:
                    artist_name = ''
                    if isinstance(a.get('artist'), dict):
                        artist_name = a['artist'].get('name', '')
                    albums.append({
                        'deezer_id': str(a.get('id', '')),
                        'album_name': a.get('title', ''),
                        'artist_name': artist_name,
                        'image_url': a.get('cover_xl') or a.get('cover_big') or a.get('cover_medium', ''),
                        'release_date': a.get('release_date', ''),
                        'total_tracks': a.get('nb_tracks', 0),
                    })
                if not data.get('next'):
                    break
                index += len(items)
                time.sleep(0.3)

            logger.info(f"Retrieved {len(albums)} favorite albums from Deezer")
            return albums
        except Exception as e:
            logger.error(f"Error fetching Deezer favorite albums: {e}")
            return []

    # ==================== Stub Methods (match iTunesClient interface) ====================

    def get_user_playlists(self) -> List[Playlist]:
        """Not supported — Deezer playlists require auth"""
        return []

    def get_user_playlists_metadata_only(self) -> List[Playlist]:
        """Not supported"""
        return []

    def get_saved_tracks_count(self) -> int:
        """Not supported"""
        return 0

    def get_saved_tracks(self) -> List[Track]:
        """Not supported"""
        return []

    def get_playlist_by_id(self, playlist_id: str) -> Optional[Playlist]:
        """Not supported"""
        return None

    def get_user_info(self) -> Optional[Dict[str, Any]]:
        """Not supported — requires auth"""
        return None

    # ==================== Existing Enrichment Methods ====================
    # These methods are used by deezer_worker.py and web_server.py enrichment endpoints.
    # They have different signatures from the metadata-source methods above.

    @rate_limited
    def search_artist(self, artist_name: str) -> Optional[Dict[str, Any]]:
        """
        Search for an artist by name (enrichment interface).

        Args:
            artist_name: Name of the artist to search for

        Returns:
            Artist dict from Deezer or None if not found
        """
        try:
            response = self.session.get(
                f"{self.BASE_URL}/search/artist",
                params={'q': artist_name, 'strict': 'on'},
                timeout=10
            )
            response.raise_for_status()

            data = response.json()
            if 'error' in data:
                logger.error(f"Deezer API error searching artist '{artist_name}': {data['error']}")
                return None

            results = data.get('data', [])
            if results and len(results) > 0:
                result = results[0]
                # Cache the artist entity
                try:
                    cache = get_metadata_cache()
                    cache.store_entity('deezer', 'artist', str(result.get('id', '')), result)
                except Exception as e:
                    logger.debug("cache store_entity artist search: %s", e)
                logger.debug(f"Found artist for query: {artist_name}")
                return result

            logger.debug(f"No artist found for query: {artist_name}")
            return None

        except Exception as e:
            logger.error(f"Error searching for artist '{artist_name}': {e}")
            return None

    @rate_limited
    def search_album(self, artist_name: str, album_title: str) -> Optional[Dict[str, Any]]:
        """
        Search for an album by artist name and album title (enrichment interface).

        Args:
            artist_name: Name of the artist
            album_title: Title of the album

        Returns:
            Album dict from Deezer or None if not found
        """
        try:
            query = f"{artist_name} {album_title}"
            response = self.session.get(
                f"{self.BASE_URL}/search/album",
                params={'q': query},
                timeout=10
            )
            response.raise_for_status()

            data = response.json()
            if 'error' in data:
                logger.error(f"Deezer API error searching album '{query}': {data['error']}")
                return None

            results = data.get('data', [])
            if results and len(results) > 0:
                result = results[0]
                # Cache the album entity
                try:
                    cache = get_metadata_cache()
                    cache.store_entity('deezer', 'album', str(result.get('id', '')), result)
                except Exception as e:
                    logger.debug("cache store_entity album search: %s", e)
                logger.debug(f"Found album for query: {artist_name} - {album_title}")
                return result

            logger.debug(f"No album found for query: {artist_name} - {album_title}")
            return None

        except Exception as e:
            logger.error(f"Error searching for album '{artist_name} - {album_title}': {e}")
            return None

    @rate_limited
    def search_track(self, artist_name: str, track_title: str) -> Optional[Dict[str, Any]]:
        """
        Search for a track by artist name and track title (enrichment interface).

        Args:
            artist_name: Name of the artist
            track_title: Title of the track

        Returns:
            Track dict from Deezer or None if not found
        """
        try:
            query = f'artist:"{artist_name}" track:"{track_title}"'
            response = self.session.get(
                f"{self.BASE_URL}/search",
                params={'q': query},
                timeout=10
            )
            response.raise_for_status()

            data = response.json()
            if 'error' in data:
                logger.error(f"Deezer API error searching track '{query}': {data['error']}")
                return None

            results = data.get('data', [])
            if results and len(results) > 0:
                result = results[0]
                # Cache the track entity
                try:
                    cache = get_metadata_cache()
                    cache.store_entity('deezer', 'track', str(result.get('id', '')), result)
                except Exception as e:
                    logger.debug("cache store_entity track search: %s", e)
                logger.debug(f"Found track for query: {artist_name} - {track_title}")
                return result

            logger.debug(f"No track found for query: {artist_name} - {track_title}")
            return None

        except Exception as e:
            logger.error(f"Error searching for track '{artist_name} - {track_title}': {e}")
            return None

    @rate_limited
    def get_album_raw(self, album_id: int) -> Optional[Dict[str, Any]]:
        """
        Get full album details by ID — raw Deezer format (enrichment interface).
        Used by deezer_worker.py for label/genre/explicit enrichment.
        Checks metadata cache first to avoid redundant API calls.

        Args:
            album_id: Deezer album ID

        Returns:
            Full album dict with label, genres, explicit flag or None
        """
        # Check cache first — get_album_raw is called on every enrichment cycle
        try:
            cache = get_metadata_cache()
            cached = cache.get_entity('deezer', 'album', str(album_id))
            if cached and cached.get('label'):
                # Cache hit with full details (has label = was a get_album response, not just search)
                logger.debug(f"Cache hit for album {album_id}")
                return cached
        except Exception as e:
            logger.debug("cache get_entity album: %s", e)

        try:
            response = self.session.get(
                f"{self.BASE_URL}/album/{album_id}",
                timeout=10
            )
            response.raise_for_status()

            data = response.json()
            if 'error' in data:
                logger.error(f"Deezer API error getting album {album_id}: {data['error']}")
                return None

            # Cache the full album (includes genres, label, explicit)
            try:
                cache = get_metadata_cache()
                cache.store_entity('deezer', 'album', str(album_id), data)
            except Exception as e:
                logger.debug("cache store_entity album full: %s", e)
            logger.debug(f"Got full album details for ID: {album_id}")
            return data

        except Exception as e:
            logger.error(f"Error getting album {album_id}: {e}")
            return None

    @rate_limited
    def get_track_raw(self, track_id: int) -> Optional[Dict[str, Any]]:
        """
        Get full track details by ID — raw Deezer format (enrichment interface, includes BPM).
        Used by deezer_worker.py for BPM enrichment.
        Checks metadata cache first to avoid redundant API calls.

        Args:
            track_id: Deezer track ID

        Returns:
            Full track dict with BPM or None
        """
        # Check cache first
        try:
            cache = get_metadata_cache()
            cached = cache.get_entity('deezer', 'track', str(track_id))
            if cached and cached.get('bpm'):
                logger.debug(f"Cache hit for track {track_id}")
                return cached
        except Exception as e:
            logger.debug("cache get_entity track: %s", e)

        try:
            response = self.session.get(
                f"{self.BASE_URL}/track/{track_id}",
                timeout=10
            )
            response.raise_for_status()

            data = response.json()
            if 'error' in data:
                logger.error(f"Deezer API error getting track {track_id}: {data['error']}")
                return None

            # Cache the full track (includes BPM, ISRC, etc.)
            try:
                cache = get_metadata_cache()
                cache.store_entity('deezer', 'track', str(track_id), data)
            except Exception as e:
                logger.debug("cache store_entity track full: %s", e)
            logger.debug(f"Got full track details for ID: {track_id}")
            return data

        except Exception as e:
            logger.error(f"Error getting track {track_id}: {e}")
            return None

    @rate_limited
    def get_playlist(self, playlist_id) -> Optional[Dict[str, Any]]:
        """
        Get a playlist with all its tracks by ID.

        Fetches playlist metadata and tracks, paginating if the playlist
        contains more tracks than a single response returns (400 per page).

        Args:
            playlist_id: Deezer playlist ID (string or int)

        Returns:
            Dict with id, name, description, track_count, image_url, owner,
            and tracks list, or None on error
        """
        try:
            playlist_id = str(playlist_id)

            response = self.session.get(
                f"{self.BASE_URL}/playlist/{playlist_id}",
                timeout=15
            )
            response.raise_for_status()

            data = response.json()
            if 'error' in data:
                logger.error(f"Deezer API error getting playlist {playlist_id}: {data['error']}")
                return None

            total_tracks = data.get('nb_tracks', 0)
            raw_tracks = data.get('tracks', {}).get('data', [])

            # Paginate if we didn't get all tracks
            while len(raw_tracks) < total_tracks:
                index = len(raw_tracks)
                logger.debug(f"Paginating playlist {playlist_id} tracks at index {index}")
                page_response = self.session.get(
                    f"{self.BASE_URL}/playlist/{playlist_id}/tracks",
                    params={'index': index, 'limit': 400},
                    timeout=15
                )
                page_response.raise_for_status()

                page_data = page_response.json()
                if 'error' in page_data:
                    logger.warning(f"Error paginating playlist tracks at index {index}: {page_data['error']}")
                    break

                page_tracks = page_data.get('data', [])
                if not page_tracks:
                    break

                raw_tracks.extend(page_tracks)

            # Normalize tracks
            tracks: List[Dict[str, Any]] = []
            for i, t in enumerate(raw_tracks, start=1):
                artist_name = t.get('artist', {}).get('name', 'Unknown Artist')
                # Some tracks list multiple artists separated by commas or slashes
                tracks.append({
                    'id': str(t.get('id', '')),
                    'name': t.get('title', ''),
                    'artists': [artist_name],
                    'album': t.get('album', {}).get('title', ''),
                    'duration_ms': t.get('duration', 0) * 1000,
                    'track_number': i,
                })

            result = {
                'id': str(data.get('id', '')),
                'name': data.get('title', ''),
                'description': data.get('description', ''),
                'track_count': total_tracks,
                'image_url': data.get('picture_xl') or data.get('picture_big') or data.get('picture_medium', ''),
                'owner': data.get('creator', {}).get('name', ''),
                'tracks': tracks,
            }

            logger.info(f"Fetched playlist '{result['name']}' with {len(tracks)} tracks")
            return result

        except Exception as e:
            logger.error(f"Error getting playlist {playlist_id}: {e}")
            return None

    @staticmethod
    def parse_playlist_url(url: str) -> Optional[str]:
        """
        Extract a Deezer playlist ID from a URL or raw numeric string.

        Supported formats:
            https://www.deezer.com/playlist/1234567890
            https://www.deezer.com/en/playlist/1234567890
            https://deezer.com/playlist/1234567890
            1234567890

        Args:
            url: Deezer playlist URL or numeric ID

        Returns:
            Playlist ID as a string, or None if the input is invalid
        """
        if not url or not isinstance(url, str):
            return None

        url = url.strip()

        # Raw numeric ID
        if url.isdigit():
            return url

        # URL pattern: optional www, optional locale segment, /playlist/{id}
        match = re.match(
            r'https?://(?:www\.)?deezer\.com/(?:[a-z]{2}/)?playlist/(\d+)',
            url
        )
        if match:
            return match.group(1)

        return None
