import asyncio
import logging
from typing import Any, Literal, Optional

from api.v1.schemas.discovery import (
    SimilarArtist,
    SimilarArtistsResponse,
    TopSong,
    TopSongsResponse,
    TopAlbum,
    TopAlbumsResponse,
)
from repositories.protocols import ListenBrainzRepositoryProtocol, LastFmRepositoryProtocol, MusicBrainzRepositoryProtocol, LidarrRepositoryProtocol
from infrastructure.cache.memory_cache import CacheInterface
from infrastructure.persistence import LibraryDB
from infrastructure.resilience.retry import CircuitOpenError
from services.preferences_service import PreferencesService

logger = logging.getLogger(__name__)

DISCOVERY_CACHE_TTL_LIBRARY = 21600
DISCOVERY_CACHE_TTL_NON_LIBRARY = 3600
DISCOVERY_EMPTY_CACHE_TTL = 600
CIRCUIT_OPEN_CACHE_TTL = 30
DEFAULT_SIMILAR_COUNT = 15
DEFAULT_TOP_SONGS_COUNT = 10
DEFAULT_TOP_ALBUMS_COUNT = 10
_DISCOVERY_WORKER_TIMEOUT = 120

# Module-level flag survives singleton cache invalidation / instance recreation
_discovery_precache_running = False


class ArtistDiscoveryService:
    def __init__(
        self,
        listenbrainz_repo: ListenBrainzRepositoryProtocol,
        musicbrainz_repo: MusicBrainzRepositoryProtocol,
        library_db: LibraryDB,
        lidarr_repo: LidarrRepositoryProtocol,
        memory_cache: CacheInterface,
        lastfm_repo: Optional[LastFmRepositoryProtocol] = None,
        preferences_service: Optional[PreferencesService] = None,
    ):
        self._lb_repo = listenbrainz_repo
        self._mb_repo = musicbrainz_repo
        self._library_db = library_db
        self._lidarr_repo = lidarr_repo
        self._cache = memory_cache
        self._lastfm_repo = lastfm_repo
        self._preferences_service = preferences_service

    def _resolve_source(
        self, source: Literal["listenbrainz", "lastfm"] | None
    ) -> Literal["listenbrainz", "lastfm"]:
        if source in ("listenbrainz", "lastfm"):
            resolved: Literal["listenbrainz", "lastfm"] = source
        elif self._preferences_service:
            preferred = self._preferences_service.get_primary_music_source().source
            resolved = preferred if preferred in ("listenbrainz", "lastfm") else "listenbrainz"
        else:
            resolved = "listenbrainz"
        return resolved

    def _get_discovery_ttl(self, in_library: bool) -> int:
        if self._preferences_service:
            try:
                advanced_settings = self._preferences_service.get_advanced_settings()
                return (
                    advanced_settings.cache_ttl_artist_discovery_library
                    if in_library
                    else advanced_settings.cache_ttl_artist_discovery_non_library
                )
            except AttributeError:
                logger.debug("Artist discovery advanced settings unavailable", exc_info=True)
        return DISCOVERY_CACHE_TTL_LIBRARY if in_library else DISCOVERY_CACHE_TTL_NON_LIBRARY

    def _get_empty_discovery_ttl(self) -> int:
        return DISCOVERY_EMPTY_CACHE_TTL

    def _build_cache_key(
        self,
        category: Literal["similar", "top_songs", "top_albums"],
        artist_mbid: str,
        count: int,
        source: str,
    ) -> str:
        return f"artist_discovery:{category}:{artist_mbid}:{count}:{source}"

    async def get_similar_artists(
        self,
        artist_mbid: str,
        count: int = 15,
        source: Literal["listenbrainz", "lastfm"] | None = None,
    ) -> SimilarArtistsResponse:
        effective_source = self._resolve_source(source)
        cache_key = self._build_cache_key("similar", artist_mbid, count, effective_source)
        cached = await self._cache.get(cache_key)
        if cached:
            return cached

        if effective_source == "lastfm":
            try:
                result = await self._get_similar_artists_lastfm(artist_mbid, count)
            except Exception as e:  # noqa: BLE001
                logger.warning("Failed to get Last.fm similar artists for %s: %s", artist_mbid[:8], e)
                result = SimilarArtistsResponse(similar_artists=[], source="lastfm")
        elif not self._lb_repo.is_configured():
            return SimilarArtistsResponse(configured=False)
        else:
            try:
                similar = await self._lb_repo.get_similar_artists(artist_mbid, max_similar=count)
                library_artist_mbids = await self._library_db.get_all_artist_mbids()

                artists = [
                    SimilarArtist(
                        musicbrainz_id=a.artist_mbid,
                        name=a.artist_name,
                        listen_count=a.listen_count,
                        in_library=a.artist_mbid in library_artist_mbids,
                    )
                    for a in similar[:count]
                ]
                result = SimilarArtistsResponse(similar_artists=artists)
            except CircuitOpenError:
                logger.warning("Circuit open for similar artists %s, using short TTL", artist_mbid[:8])
                result = SimilarArtistsResponse(similar_artists=[])
                await self._cache.set(cache_key, result, ttl_seconds=CIRCUIT_OPEN_CACHE_TTL)
                return result
            except Exception as e:  # noqa: BLE001
                logger.warning("Failed to get similar artists for %s: %s(%s)", artist_mbid[:8], type(e).__name__, e)
                result = SimilarArtistsResponse(similar_artists=[])

        in_library = await self._is_library_artist(artist_mbid)
        ttl = (
            self._get_discovery_ttl(in_library)
            if result.similar_artists
            else self._get_empty_discovery_ttl()
        )
        await self._cache.set(cache_key, result, ttl_seconds=ttl)
        return result

    async def get_top_songs(
        self,
        artist_mbid: str,
        count: int = 10,
        source: Literal["listenbrainz", "lastfm"] | None = None,
    ) -> TopSongsResponse:
        effective_source = self._resolve_source(source)
        cache_key = self._build_cache_key("top_songs", artist_mbid, count, effective_source)
        cached = await self._cache.get(cache_key)
        if cached:
            return cached

        if effective_source == "lastfm":
            try:
                result = await self._get_top_songs_lastfm(artist_mbid, count)
            except Exception as e:  # noqa: BLE001
                logger.warning("Failed to get Last.fm top songs for %s: %s", artist_mbid[:8], e)
                result = TopSongsResponse(songs=[], source="lastfm")
        elif not self._lb_repo.is_configured():
            return TopSongsResponse(configured=False)
        else:
            try:
                recordings = await self._lb_repo.get_artist_top_recordings(artist_mbid, count=count)

                release_ids = [r.release_mbid for r in recordings if r.release_mbid]

                rg_map = await self._resolve_release_groups(release_ids)

                songs = []
                for r in recordings[:count]:
                    disc_number = None
                    track_number = None
                    if r.release_mbid and r.recording_mbid:
                        pos = await self._mb_repo.get_recording_position_on_release(
                            r.release_mbid, r.recording_mbid
                        )
                        if pos:
                            disc_number, track_number = pos

                    songs.append(TopSong(
                        recording_mbid=r.recording_mbid,
                        title=r.track_name,
                        artist_name=r.artist_name,
                        release_group_mbid=rg_map.get(r.release_mbid) if r.release_mbid else None,
                        original_release_mbid=r.release_mbid,
                        release_name=r.release_name,
                        listen_count=r.listen_count,
                        disc_number=disc_number,
                        track_number=track_number,
                    ))
                result = TopSongsResponse(songs=songs)
            except CircuitOpenError:
                logger.warning("Circuit open for top songs %s, using short TTL", artist_mbid[:8])
                result = TopSongsResponse(songs=[])
                await self._cache.set(cache_key, result, ttl_seconds=CIRCUIT_OPEN_CACHE_TTL)
                return result
            except Exception as e:  # noqa: BLE001
                logger.warning("Failed to get top songs for %s: %s(%s)", artist_mbid[:8], type(e).__name__, e)
                result = TopSongsResponse(songs=[])

        in_library = await self._is_library_artist(artist_mbid)
        ttl = (
            self._get_discovery_ttl(in_library)
            if result.songs
            else self._get_empty_discovery_ttl()
        )
        await self._cache.set(cache_key, result, ttl_seconds=ttl)
        return result

    async def get_top_albums(
        self,
        artist_mbid: str,
        count: int = 10,
        source: Literal["listenbrainz", "lastfm"] | None = None,
    ) -> TopAlbumsResponse:
        effective_source = self._resolve_source(source)
        cache_key = self._build_cache_key("top_albums", artist_mbid, count, effective_source)
        cached = await self._cache.get(cache_key)
        if cached:
            return cached

        if effective_source == "lastfm":
            try:
                result = await self._get_top_albums_lastfm(artist_mbid, count)
            except Exception as e:  # noqa: BLE001
                logger.warning("Failed to get Last.fm top albums for %s: %s", artist_mbid[:8], e)
                result = TopAlbumsResponse(albums=[], source="lastfm")
        elif not self._lb_repo.is_configured():
            return TopAlbumsResponse(configured=False)
        else:
            try:
                release_groups = await self._lb_repo.get_artist_top_release_groups(artist_mbid, count=count)
                if not release_groups:
                    fallback_albums = await self._get_top_albums_from_recordings_fallback(
                        artist_mbid, count
                    )
                    result = TopAlbumsResponse(albums=fallback_albums)
                else:
                    try:
                        library_album_mbids, requested_album_mbids = await asyncio.gather(
                            self._lidarr_repo.get_library_mbids(),
                            self._lidarr_repo.get_requested_mbids(),
                        )
                    except Exception as e:  # noqa: BLE001
                        logger.warning(
                            "Failed to load Lidarr album MBIDs for %s: %s(%s)",
                            artist_mbid[:8],
                            type(e).__name__,
                            e,
                        )
                        library_album_mbids, requested_album_mbids = set(), set()

                    library_album_mbids = {
                        mbid.lower() for mbid in library_album_mbids if isinstance(mbid, str)
                    }
                    requested_album_mbids = {
                        mbid.lower() for mbid in requested_album_mbids if isinstance(mbid, str)
                    }

                    albums = [
                        TopAlbum(
                            release_group_mbid=rg.release_group_mbid,
                            title=rg.release_group_name,
                            artist_name=rg.artist_name,
                            listen_count=rg.listen_count,
                            in_library=rg.release_group_mbid.lower() in library_album_mbids if rg.release_group_mbid else False,
                            requested=rg.release_group_mbid.lower() in requested_album_mbids if rg.release_group_mbid else False,
                        )
                        for rg in release_groups
                    ]
                    result = TopAlbumsResponse(albums=albums)
            except CircuitOpenError:
                logger.warning("Circuit open for top albums %s, using short TTL", artist_mbid[:8])
                result = TopAlbumsResponse(albums=[])
                await self._cache.set(cache_key, result, ttl_seconds=CIRCUIT_OPEN_CACHE_TTL)
                return result
            except Exception as e:  # noqa: BLE001
                logger.warning("Failed to get top albums for %s: %s(%s)", artist_mbid[:8], type(e).__name__, e)
                try:
                    fallback_albums = await self._get_top_albums_from_recordings_fallback(
                        artist_mbid, count
                    )
                    result = TopAlbumsResponse(albums=fallback_albums)
                except Exception as fallback_error:  # noqa: BLE001
                    logger.warning(
                        "Top albums fallback from recordings failed for %s: %s(%s)",
                        artist_mbid[:8],
                        type(fallback_error).__name__,
                        fallback_error,
                    )
                    result = TopAlbumsResponse(albums=[])

        in_library = await self._is_library_artist(artist_mbid)
        empty_ttl = (
            60
            if effective_source == "listenbrainz"
            else self._get_empty_discovery_ttl()
        )
        ttl = (
            self._get_discovery_ttl(in_library)
            if result.albums
            else empty_ttl
        )
        await self._cache.set(cache_key, result, ttl_seconds=ttl)
        return result

    async def _get_top_albums_from_recordings_fallback(
        self,
        artist_mbid: str,
        count: int,
    ) -> list[TopAlbum]:
        recordings = await self._lb_repo.get_artist_top_recordings(
            artist_mbid,
            count=max(count * 8, 80),
        )
        if not recordings:
            return []

        try:
            library_album_mbids, requested_album_mbids = await asyncio.gather(
                self._lidarr_repo.get_library_mbids(),
                self._lidarr_repo.get_requested_mbids(),
            )
        except Exception as e:  # noqa: BLE001
            logger.warning(
                "Fallback Lidarr album MBID load failed for %s: %s(%s)",
                artist_mbid[:8],
                type(e).__name__,
                e,
            )
            library_album_mbids, requested_album_mbids = set(), set()

        library_album_mbids = {
            mbid.lower() for mbid in library_album_mbids if isinstance(mbid, str)
        }
        requested_album_mbids = {
            mbid.lower() for mbid in requested_album_mbids if isinstance(mbid, str)
        }

        release_ids = [r.release_mbid for r in recordings if r.release_mbid]
        rg_map = await self._resolve_release_groups(release_ids) if release_ids else {}

        aggregated: dict[str, dict[str, str | int | None]] = {}
        for recording in recordings:
            release_title = (recording.release_name or "").strip()
            raw_release_mbid = (
                recording.release_mbid.strip().lower()
                if recording.release_mbid and recording.release_mbid.strip()
                else None
            )
            resolved_release_group_mbid = (
                rg_map.get(raw_release_mbid, raw_release_mbid) if raw_release_mbid else None
            )

            key = resolved_release_group_mbid or (f"name:{release_title.lower()}" if release_title else None)
            if not key:
                continue

            if key not in aggregated:
                aggregated[key] = {
                    "title": release_title or "Unknown",
                    "artist_name": recording.artist_name,
                    "listen_count": 0,
                    "release_group_mbid": resolved_release_group_mbid,
                }

            aggregated[key]["listen_count"] = int(aggregated[key]["listen_count"]) + int(
                recording.listen_count
            )

        sorted_albums = sorted(
            aggregated.values(),
            key=lambda album: int(album["listen_count"]),
            reverse=True,
        )[:count]

        return [
            TopAlbum(
                release_group_mbid=album["release_group_mbid"] if isinstance(album["release_group_mbid"], str) else None,
                title=str(album["title"]),
                artist_name=str(album["artist_name"]),
                listen_count=int(album["listen_count"]),
                in_library=(
                    isinstance(album["release_group_mbid"], str)
                    and album["release_group_mbid"] in library_album_mbids
                ),
                requested=(
                    isinstance(album["release_group_mbid"], str)
                    and album["release_group_mbid"] in requested_album_mbids
                ),
            )
            for album in sorted_albums
        ]

    async def _is_library_artist(self, artist_mbid: str) -> bool:
        try:
            library_artist_mbids = await self._library_db.get_all_artist_mbids()
            return artist_mbid in library_artist_mbids
        except Exception:  # noqa: BLE001
            return False

    async def precache_artist_discovery(
        self,
        artist_mbids: list[str],
        delay: float = 0.5,
        status_service: Any = None,
        mbid_to_name: dict[str, str] | None = None,
        generation: int = 0,
    ) -> int:
        global _discovery_precache_running
        if _discovery_precache_running:
            return 0

        _discovery_precache_running = True
        try:
            return await self._do_precache_artist_discovery(
                artist_mbids, delay=delay,
                status_service=status_service, mbid_to_name=mbid_to_name,
                generation=generation,
            )
        finally:
            _discovery_precache_running = False

    async def _do_precache_artist_discovery(
        self,
        artist_mbids: list[str],
        delay: float = 0.5,
        status_service: Any = None,
        mbid_to_name: dict[str, str] | None = None,
        generation: int = 0,
    ) -> int:
        sources: list[Literal["listenbrainz", "lastfm"]] = []
        if self._lb_repo.is_configured():
            sources.append("listenbrainz")
        if (
            self._lastfm_repo
            and self._preferences_service
            and self._preferences_service.is_lastfm_enabled()
        ):
            sources.append("lastfm")
        if not sources:
            logger.debug("Skipping discovery pre-cache: no configured source")
            return 0

        cached_count = 0
        source_fetches = 0
        advanced = self._preferences_service.get_advanced_settings() if self._preferences_service else None
        discovery_concurrency = getattr(advanced, 'artist_discovery_precache_concurrency', 5) if advanced else 5
        sem = asyncio.Semaphore(discovery_concurrency)
        counter_lock = asyncio.Lock()
        progress_counter = 0
        counted_workers: set[int] = set()

        async def process_artist(idx: int, mbid: str) -> bool:
            nonlocal cached_count, source_fetches, progress_counter
            try:
                async with sem:
                    for source in sources:
                        similar_key = self._build_cache_key(
                            "similar", mbid, DEFAULT_SIMILAR_COUNT, source
                        )
                        songs_key = self._build_cache_key(
                            "top_songs", mbid, DEFAULT_TOP_SONGS_COUNT, source
                        )
                        albums_key = self._build_cache_key(
                            "top_albums", mbid, DEFAULT_TOP_ALBUMS_COUNT, source
                        )

                        has_all = (
                            await self._cache.get(similar_key) is not None
                            and await self._cache.get(songs_key) is not None
                            and await self._cache.get(albums_key) is not None
                        )
                        if has_all:
                            continue

                        results = await asyncio.gather(
                            self.get_similar_artists(
                                mbid, count=DEFAULT_SIMILAR_COUNT, source=source
                            ),
                            self.get_top_songs(
                                mbid, count=DEFAULT_TOP_SONGS_COUNT, source=source
                            ),
                            self.get_top_albums(
                                mbid, count=DEFAULT_TOP_ALBUMS_COUNT, source=source
                            ),
                            return_exceptions=True,
                        )
                        errors = [r for r in results if isinstance(r, Exception)]
                        if errors:
                            logger.debug("Discovery precache errors for %s: %s", mbid[:8], errors)
                        async with counter_lock:
                            source_fetches += 1

                if delay > 0:
                    await asyncio.sleep(delay)

                async with counter_lock:
                    cached_count += 1
                    progress_counter += 1
                    local_progress = progress_counter
                    counted_workers.add(idx)

                if status_service:
                    artist_name = (mbid_to_name or {}).get(mbid, mbid[:8])
                    await status_service.update_progress(local_progress, current_item=artist_name, generation=generation)

                return True
            except Exception as e:  # noqa: BLE001
                logger.warning("Failed to precache discovery for %s: %s", mbid[:8], e)
                async with counter_lock:
                    progress_counter += 1
                    local_progress = progress_counter
                    counted_workers.add(idx)
                if status_service:
                    artist_name = (mbid_to_name or {}).get(mbid, mbid[:8])
                    await status_service.update_progress(local_progress, current_item=artist_name, generation=generation)
                return False

        async def process_artist_with_timeout(idx: int, mbid: str) -> bool:
            nonlocal progress_counter
            try:
                return await asyncio.wait_for(
                    process_artist(idx, mbid), timeout=_DISCOVERY_WORKER_TIMEOUT
                )
            except asyncio.TimeoutError:
                logger.warning("Discovery timed out for %s after %ds", mbid[:8], _DISCOVERY_WORKER_TIMEOUT)
                async with counter_lock:
                    if idx not in counted_workers:
                        progress_counter += 1
                        counted_workers.add(idx)
                    local_progress = progress_counter
                if status_service:
                    artist_name = (mbid_to_name or {}).get(mbid, mbid[:8])
                    await status_service.update_progress(
                        local_progress, current_item=f"{artist_name} (timed out)",
                        generation=generation,
                    )
                return False

        chunk = max(discovery_concurrency * 4, 20)
        for i in range(0, len(artist_mbids), chunk):
            if status_service and status_service.is_cancelled():
                break
            batch = artist_mbids[i:i + chunk]
            batch_tasks = [asyncio.create_task(process_artist_with_timeout(i + j, mbid)) for j, mbid in enumerate(batch)]
            if batch_tasks:
                await asyncio.gather(*batch_tasks, return_exceptions=True)

        return cached_count

    async def _resolve_release_groups(self, release_ids: list[str]) -> dict[str, str]:
        if not release_ids:
            return {}

        unique_ids = list(dict.fromkeys(release_ids))
        tasks = [self._mb_repo.get_release_group_id_from_release(rid) for rid in unique_ids]
        results = await asyncio.gather(*tasks, return_exceptions=True)
        
        rg_map = {}
        errors = 0
        for rid, rg_id in zip(unique_ids, results):
            if isinstance(rg_id, Exception):
                errors += 1
                logger.warning(f"Resolution exception for {rid}: {rg_id}")
            elif isinstance(rg_id, str) and rg_id:
                rg_map[rid] = rg_id
            else:
                errors += 1
                logger.warning(f"Resolution returned None/empty for {rid}")
        
        return rg_map

    async def _get_similar_artists_lastfm(
        self, artist_mbid: str, count: int
    ) -> SimilarArtistsResponse:
        if (
            not self._lastfm_repo
            or not self._preferences_service
            or not self._preferences_service.is_lastfm_enabled()
        ):
            return SimilarArtistsResponse(
                similar_artists=[], source="lastfm", configured=False
            )

        try:
            similar = await self._lastfm_repo.get_similar_artists(
                artist="", mbid=artist_mbid, limit=count
            )
            library_artist_mbids = await self._library_db.get_all_artist_mbids()

            artists = [
                SimilarArtist(
                    musicbrainz_id=a.mbid or "",
                    name=a.name,
                    listen_count=0,
                    in_library=bool(a.mbid and a.mbid in library_artist_mbids),
                )
                for a in similar[:count]
                if a.mbid
            ]
            return SimilarArtistsResponse(
                similar_artists=artists, source="lastfm"
            )
        except Exception as e:
            logger.warning(
                "Last.fm similar artists API error for %s: %s", artist_mbid[:8], e
            )
            raise

    async def _get_top_songs_lastfm(
        self, artist_mbid: str, count: int
    ) -> TopSongsResponse:
        if (
            not self._lastfm_repo
            or not self._preferences_service
            or not self._preferences_service.is_lastfm_enabled()
        ):
            return TopSongsResponse(songs=[], source="lastfm", configured=False)

        try:
            tracks = await self._lastfm_repo.get_artist_top_tracks(
                artist="", mbid=artist_mbid, limit=count
            )
            trimmed = tracks[:count]

            songs = [
                TopSong(
                    recording_mbid=t.mbid,
                    title=t.name,
                    artist_name=t.artist_name,
                    release_group_mbid=None,
                    original_release_mbid=None,
                    release_name=None,
                    listen_count=t.playcount,
                )
                for t in trimmed
            ]
            return TopSongsResponse(songs=songs, source="lastfm")
        except Exception as e:
            logger.warning(
                "Last.fm top songs API error for %s: %s", artist_mbid[:8], e
            )
            raise

    async def _get_top_albums_lastfm(
        self, artist_mbid: str, count: int
    ) -> TopAlbumsResponse:
        if (
            not self._lastfm_repo
            or not self._preferences_service
            or not self._preferences_service.is_lastfm_enabled()
        ):
            return TopAlbumsResponse(albums=[], source="lastfm", configured=False)

        try:
            lfm_albums = await self._lastfm_repo.get_artist_top_albums(
                artist="", mbid=artist_mbid, limit=count
            )

            library_album_mbids, requested_album_mbids = await asyncio.gather(
                self._lidarr_repo.get_library_mbids(),
                self._lidarr_repo.get_requested_mbids(),
            )

            trimmed = lfm_albums[:count]

            # Last.fm usually returns release-group MBIDs here, so keep them as-is
            # and let the discover queue resolve the rare mismatches.
            albums = []
            for a in trimmed:
                raw_mbid = a.mbid.strip().lower() if a.mbid and a.mbid.strip() else None
                albums.append(
                    TopAlbum(
                        release_group_mbid=raw_mbid,
                        title=a.name,
                        artist_name=a.artist_name,
                        listen_count=a.playcount,
                        in_library=(
                            raw_mbid in library_album_mbids
                            if raw_mbid
                            else False
                        ),
                        requested=(
                            raw_mbid in requested_album_mbids
                            if raw_mbid
                            else False
                        ),
                    )
                )
            return TopAlbumsResponse(albums=albums, source="lastfm")
        except Exception as e:
            logger.warning(
                "Last.fm top albums API error for %s: %s", artist_mbid[:8], e
            )
            raise
