import asyncio
import copy
import logging
import msgspec
from typing import Any, Optional, TYPE_CHECKING
from api.v1.schemas.artist import ArtistInfo, ArtistExtendedInfo, ArtistReleases, ExternalLink, ReleaseItem
from repositories.protocols import MusicBrainzRepositoryProtocol, LidarrRepositoryProtocol, WikidataRepositoryProtocol
from services.preferences_service import PreferencesService
from services.artist_utils import (
    detect_platform,
    extract_tags,
    extract_aliases,
    extract_life_span,
    extract_external_links,
    categorize_release_groups,
    categorize_lidarr_albums,
    extract_wiki_info,
    build_base_artist_info,
)
from infrastructure.cache.cache_keys import ARTIST_INFO_PREFIX
from infrastructure.cache.memory_cache import CacheInterface
from infrastructure.cache.disk_cache import DiskMetadataCache
from infrastructure.validators import validate_mbid
from core.exceptions import ExternalServiceError, ResourceNotFoundError
from services.audiodb_image_service import AudioDBImageService
from repositories.audiodb_models import AudioDBArtistImages

if TYPE_CHECKING:
    from infrastructure.persistence import LibraryDB

logger = logging.getLogger(__name__)


class ArtistService:
    def __init__(
        self,
        mb_repo: MusicBrainzRepositoryProtocol,
        lidarr_repo: LidarrRepositoryProtocol,
        wikidata_repo: WikidataRepositoryProtocol,
        preferences_service: PreferencesService,
        memory_cache: CacheInterface,
        disk_cache: DiskMetadataCache,
        audiodb_image_service: AudioDBImageService | None = None,
        audiodb_browse_queue: Any = None,
        library_db: 'LibraryDB | None' = None,
    ):
        self._mb_repo = mb_repo
        self._lidarr_repo = lidarr_repo
        self._wikidata_repo = wikidata_repo
        self._preferences_service = preferences_service
        self._cache = memory_cache
        self._disk_cache = disk_cache
        self._audiodb_image_service = audiodb_image_service
        self._audiodb_browse_queue = audiodb_browse_queue
        self._library_db = library_db
        self._artist_in_flight: dict[str, asyncio.Future[ArtistInfo]] = {}
        self._artist_basic_in_flight: dict[str, asyncio.Future[ArtistInfo]] = {}

    async def _get_library_cache_mbids(self) -> set[str]:
        if self._library_db is None:
            return set()
        try:
            raw = await self._library_db.get_all_album_mbids()
            return {m.lower() for m in raw if m}
        except Exception as e:  # noqa: BLE001
            logger.warning("Failed to read library cache MBIDs: %s", e)
            return set()

    async def _revalidate_library_status(self, artist_info: ArtistInfo) -> ArtistInfo:
        """Re-evaluate in_library flags on a cached artist response using fresh LibraryDB data."""
        cache_mbids = await self._get_library_cache_mbids()
        try:
            library_mbids = await self._lidarr_repo.get_library_mbids(include_release_ids=True)
        except Exception:  # noqa: BLE001
            library_mbids = set()
        all_mbids = library_mbids | cache_mbids
        if not all_mbids:
            return artist_info

        result = copy.deepcopy(artist_info)
        changed = False
        for release_list in (result.albums, result.singles, result.eps):
            if not release_list:
                continue
            for release in release_list:
                if isinstance(release, dict):
                    rid = (release.get("id") or "").lower()
                else:
                    rid = (release.id or "").lower()
                if not rid:
                    continue
                new_in_library = rid in all_mbids
                old_in_library = release.get("in_library", False) if isinstance(release, dict) else release.in_library
                if new_in_library != old_in_library:
                    if isinstance(release, dict):
                        release["in_library"] = new_in_library
                        if new_in_library and release.get("requested"):
                            release["requested"] = False
                    else:
                        release.in_library = new_in_library
                        if new_in_library and release.requested:
                            release.requested = False
                    changed = True

        artist_mbids = await self._get_library_artist_mbids()
        new_artist_in_library = result.musicbrainz_id and result.musicbrainz_id.lower() in artist_mbids
        if new_artist_in_library != result.in_library:
            result.in_library = new_artist_in_library

        return result

    async def _get_library_artist_mbids(self) -> set[str]:
        if self._library_db is None:
            return set()
        try:
            raw = await self._library_db.get_all_artist_mbids()
            return {m.lower() for m in raw if m}
        except Exception as e:  # noqa: BLE001
            logger.warning("Failed to read library artist cache MBIDs: %s", e)
            return set()

    async def _apply_audiodb_artist_images(
        self,
        artist_info: ArtistInfo,
        mbid: str,
        name: str | None,
        *,
        allow_fetch: bool = False,
        is_monitored: bool = False,
    ) -> ArtistInfo:
        if self._audiodb_image_service is None:
            return artist_info
        try:
            images: AudioDBArtistImages | None
            if allow_fetch:
                images = await self._audiodb_image_service.fetch_and_cache_artist_images(
                    mbid, name, is_monitored=is_monitored,
                )
            else:
                images = await self._audiodb_image_service.get_cached_artist_images(mbid)
            if images is None or images.is_negative:
                if not allow_fetch and images is None and self._audiodb_browse_queue:
                    settings = self._preferences_service.get_advanced_settings()
                    if settings.audiodb_enabled:
                        await self._audiodb_browse_queue.enqueue("artist", mbid, name=name)
                return artist_info
            if not artist_info.fanart_url and images.fanart_url:
                artist_info.fanart_url = images.fanart_url
            if not artist_info.banner_url and images.banner_url:
                artist_info.banner_url = images.banner_url
            if images.thumb_url:
                artist_info.thumb_url = images.thumb_url
            if images.fanart_url_2:
                artist_info.fanart_url_2 = images.fanart_url_2
            if images.fanart_url_3:
                artist_info.fanart_url_3 = images.fanart_url_3
            if images.fanart_url_4:
                artist_info.fanart_url_4 = images.fanart_url_4
            if images.wide_thumb_url:
                artist_info.wide_thumb_url = images.wide_thumb_url
            if images.logo_url:
                artist_info.logo_url = images.logo_url
            if images.clearart_url:
                artist_info.clearart_url = images.clearart_url
            if images.cutout_url:
                artist_info.cutout_url = images.cutout_url
        except Exception as e:  # noqa: BLE001
            logger.warning("Failed to apply AudioDB images for artist %s: %s", mbid[:8], e)
        return artist_info

    async def get_artist_info(
        self,
        artist_id: str,
        library_artist_mbids: set[str] = None,
        library_album_mbids: dict[str, Any] = None
    ) -> ArtistInfo:
        try:
            artist_id = validate_mbid(artist_id, "artist")
        except ValueError as e:
            logger.error(f"Invalid artist MBID: {e}")
            raise
        try:
            cached = await self._get_cached_artist(artist_id)
            if cached:
                cached = await self._revalidate_library_status(cached)
                cached = await self._apply_audiodb_artist_images(
                    cached, artist_id, cached.name,
                    allow_fetch=False, is_monitored=cached.in_library,
                )
                return cached

            if artist_id in self._artist_in_flight:
                return await asyncio.shield(self._artist_in_flight[artist_id])

            loop = asyncio.get_running_loop()
            future: asyncio.Future[ArtistInfo] = loop.create_future()
            self._artist_in_flight[artist_id] = future
            try:
                artist_info = await self._do_get_artist_info(artist_id, library_artist_mbids, library_album_mbids)
                if not future.done():
                    future.set_result(artist_info)
                return artist_info
            except BaseException as exc:
                if not future.done():
                    future.set_exception(exc)
                raise
            finally:
                self._artist_in_flight.pop(artist_id, None)
        except ValueError:
            raise
        except Exception as e:  # noqa: BLE001
            logger.error(f"API call failed for artist {artist_id}: {e}")
            raise ResourceNotFoundError(f"Failed to get artist info: {e}")

    async def _do_get_artist_info(
        self, artist_id: str,
        library_artist_mbids: set[str] | None,
        library_album_mbids: dict[str, Any] | None,
    ) -> ArtistInfo:
        lidarr_artist = await self._lidarr_repo.get_artist_details(artist_id) if self._lidarr_repo.is_configured() else None
        in_library = lidarr_artist is not None and lidarr_artist.get("monitored", False)
        if in_library and lidarr_artist:
            artist_info = await self._build_artist_from_lidarr(artist_id, lidarr_artist, library_album_mbids)
        else:
            artist_info = await self._build_artist_from_musicbrainz(artist_id, library_artist_mbids, library_album_mbids)
        if lidarr_artist is not None:
            artist_info.in_lidarr = True
            artist_info.monitored = lidarr_artist.get("monitored", False)
            artist_info.auto_download = lidarr_artist.get("monitor_new_items", "none") == "all"
        artist_info = await self._apply_audiodb_artist_images(
            artist_info, artist_id, artist_info.name,
            allow_fetch=False, is_monitored=artist_info.in_library,
        )
        await self._save_artist_to_cache(artist_id, artist_info)
        return artist_info

    async def set_artist_monitoring(
        self, artist_mbid: str, *, monitored: bool, auto_download: bool = False,
    ) -> dict[str, Any]:
        if not self._lidarr_repo.is_configured():
            raise ExternalServiceError("Lidarr is not configured")
        monitor_new_items = "all" if (monitored and auto_download) else "none"
        result = await self._lidarr_repo.update_artist_monitoring(
            artist_mbid, monitored=monitored, monitor_new_items=monitor_new_items,
        )
        await self._cache.delete(f"{ARTIST_INFO_PREFIX}{artist_mbid}")
        return result

    async def get_artist_monitoring_status(self, artist_mbid: str) -> dict[str, Any]:
        if not self._lidarr_repo.is_configured():
            return {"in_lidarr": False, "monitored": False, "auto_download": False}
        details = await self._lidarr_repo.get_artist_details(artist_mbid)
        if details is None:
            return {"in_lidarr": False, "monitored": False, "auto_download": False}
        return {
            "in_lidarr": True,
            "monitored": details.get("monitored", False),
            "auto_download": details.get("monitor_new_items", "none") == "all",
        }

    async def _build_artist_from_lidarr(
        self,
        artist_id: str,
        lidarr_artist: dict[str, Any],
        library_album_mbids: dict[str, Any] = None
    ) -> ArtistInfo:
        description = lidarr_artist.get("overview")
        image = lidarr_artist.get("poster_url")
        fanart_url = lidarr_artist.get("fanart_url")
        banner_url = lidarr_artist.get("banner_url")

        genres = lidarr_artist.get("genres", [])
        
        external_links = []
        for link in lidarr_artist.get("links", []):
            link_name = link.get("name", "")
            link_url = link.get("url", "")
            if link_url:
                label, category = detect_platform(link_url, link_name.lower())
                external_links.append(ExternalLink(type=link_name.lower(), url=link_url, label=label, category=category))
        
        need_musicbrainz = not description or not genres or not external_links

        parallel_tasks: list[Any] = []
        task_names: list[str] = []

        if library_album_mbids is None:
            parallel_tasks.append(self._lidarr_repo.get_library_mbids(include_release_ids=True))
            task_names.append("library_mbids")
        parallel_tasks.append(self._get_library_cache_mbids())
        task_names.append("cache_mbids")
        parallel_tasks.append(self._lidarr_repo.get_artist_albums(artist_id))
        task_names.append("lidarr_albums")
        parallel_tasks.append(self._lidarr_repo.get_requested_mbids())
        task_names.append("requested_mbids")
        if need_musicbrainz:
            parallel_tasks.append(self._mb_repo.get_artist_by_id(artist_id))
            task_names.append("mb_artist")

        results = await asyncio.gather(*parallel_tasks, return_exceptions=True)
        result_map = dict(zip(task_names, results))

        if library_album_mbids is None:
            lib_result = result_map.get("library_mbids")
            library_album_mbids = lib_result if not isinstance(lib_result, Exception) and lib_result else {}
        cache_result = result_map.get("cache_mbids")
        cache_mbids = cache_result if not isinstance(cache_result, Exception) and cache_result else {}
        library_album_mbids = library_album_mbids | cache_mbids
        
        req_result = result_map.get("requested_mbids")
        requested_mbids = req_result if not isinstance(req_result, Exception) and req_result else set()

        albums_result = result_map.get("lidarr_albums")
        lidarr_albums = albums_result if not isinstance(albums_result, Exception) and albums_result else []
        albums, singles, eps = self._categorize_lidarr_albums(lidarr_albums, library_album_mbids, requested_mbids=requested_mbids)
        
        aliases = []
        life_span = None
        artist_type = lidarr_artist.get("artist_type")
        disambiguation = lidarr_artist.get("disambiguation")
        country = None
        release_group_count = len(lidarr_albums)
        
        if need_musicbrainz:
            try:
                mb_artist = result_map.get("mb_artist")
                if isinstance(mb_artist, Exception):
                    raise mb_artist
                if mb_artist:
                    if not description:
                        mb_description, _ = await self._fetch_wikidata_info(mb_artist)
                        description = mb_description
                    
                    if not genres:
                        genres = extract_tags(mb_artist)

                    if not external_links:
                        external_links = self._build_external_links(mb_artist)

                    aliases = extract_aliases(mb_artist)
                    life_span = extract_life_span(mb_artist)
                    country = mb_artist.get("country")
                    
                    if not artist_type:
                        artist_type = mb_artist.get("type")
                    if not disambiguation:
                        disambiguation = mb_artist.get("disambiguation")
                    
                    release_group_count = mb_artist.get("release-group-count", release_group_count)
            except Exception as e:  # noqa: BLE001
                logger.warning(f"MusicBrainz fallback failed for artist {artist_id[:8]}: {e}")
        
        return ArtistInfo(
            name=lidarr_artist.get("name", "Unknown Artist"),
            musicbrainz_id=artist_id,
            disambiguation=disambiguation,
            type=artist_type,
            country=country,
            life_span=life_span,
            description=description,
            image=image,
            fanart_url=fanart_url,
            banner_url=banner_url,
            tags=genres,
            aliases=aliases,
            external_links=external_links,
            in_library=True,
            albums=albums,
            singles=singles,
            eps=eps,
            release_group_count=release_group_count,
        )
    
    def _categorize_lidarr_albums(
        self,
        lidarr_albums: list[dict[str, Any]],
        library_album_mbids: set[str],
        requested_mbids: set[str] | None = None,
    ) -> tuple[list[ReleaseItem], list[ReleaseItem], list[ReleaseItem]]:
        prefs = self._preferences_service.get_preferences()
        included_primary_types = set(t.lower() for t in prefs.primary_types)
        included_secondary_types = set(t.lower() for t in prefs.secondary_types)
        return categorize_lidarr_albums(lidarr_albums, included_primary_types, included_secondary_types, library_album_mbids, requested_mbids=requested_mbids)
    
    async def _build_artist_from_musicbrainz(
        self,
        artist_id: str,
        library_artist_mbids: set[str] = None,
        library_album_mbids: dict[str, Any] = None,
        include_extended: bool = True,
        include_releases: bool = True,
    ) -> ArtistInfo:
        mb_artist, library_mbids, album_mbids, requested_mbids = await self._fetch_artist_data(
            artist_id, library_artist_mbids, library_album_mbids
        )
        in_library = artist_id.lower() in library_mbids
        albums, singles, eps = (await self._get_categorized_releases(mb_artist, album_mbids, requested_mbids)) if include_releases else ([], [], [])
        description, image = (await self._fetch_wikidata_info(mb_artist)) if include_extended else (None, None)
        info = build_base_artist_info(
            mb_artist, artist_id, in_library,
            extract_tags(mb_artist), extract_aliases(mb_artist), extract_life_span(mb_artist),
            self._build_external_links(mb_artist), albums, singles, eps, description, image
        )
        return ArtistInfo(**info)

    async def get_artist_info_basic(self, artist_id: str) -> ArtistInfo:
        artist_id = validate_mbid(artist_id, "artist")
        cached = await self._get_cached_artist(artist_id)
        if cached:
            cached = await self._apply_audiodb_artist_images(
                cached, artist_id, cached.name, allow_fetch=False,
            )
            await self._refresh_library_flags(cached)
            return cached

        if artist_id in self._artist_basic_in_flight:
            return await asyncio.shield(self._artist_basic_in_flight[artist_id])

        loop = asyncio.get_running_loop()
        future: asyncio.Future[ArtistInfo] = loop.create_future()
        self._artist_basic_in_flight[artist_id] = future
        try:
            artist_info = await self._build_artist_from_musicbrainz(artist_id, include_extended=False, include_releases=False)
            artist_info = await self._apply_audiodb_artist_images(
                artist_info, artist_id, artist_info.name, allow_fetch=False,
            )
            await self._refresh_library_flags(artist_info)
            await self._save_artist_to_cache(artist_id, artist_info)
            if not future.done():
                future.set_result(artist_info)
            return artist_info
        except BaseException as exc:
            if not future.done():
                future.set_exception(exc)
            raise
        finally:
            self._artist_basic_in_flight.pop(artist_id, None)

    async def _refresh_library_flags(self, artist_info: ArtistInfo) -> None:
        if not self._lidarr_repo.is_configured():
            return
        try:
            library_mbids, requested_mbids, artist_mbids = await asyncio.gather(
                self._lidarr_repo.get_library_mbids(include_release_ids=False),
                self._lidarr_repo.get_requested_mbids(),
                self._lidarr_repo.get_artist_mbids(),
            )
            for release_list in (artist_info.albums, artist_info.singles, artist_info.eps):
                for rg in release_list:
                    rg_id = (rg.id or "").lower()
                    if not rg_id:
                        continue
                    rg.in_library = rg_id in library_mbids
                    rg.requested = rg_id in requested_mbids and not rg.in_library
            mbid_lower = artist_info.musicbrainz_id.lower()
            is_in_artist_mbids = mbid_lower in artist_mbids
            artist_info.in_library = is_in_artist_mbids
            if is_in_artist_mbids:
                try:
                    lidarr_artist = await self._lidarr_repo.get_artist_details(artist_info.musicbrainz_id)
                    if lidarr_artist is not None:
                        artist_info.in_lidarr = True
                        artist_info.monitored = lidarr_artist.get("monitored", False)
                        artist_info.auto_download = lidarr_artist.get("monitor_new_items", "none") == "all"
                    elif not artist_info.in_lidarr:
                        artist_info.in_lidarr = True
                except Exception:  # noqa: BLE001
                    if not artist_info.in_lidarr:
                        artist_info.in_lidarr = True
        except Exception as e:  # noqa: BLE001
            logger.warning(f"Failed to refresh library flags: {e}")

    async def _get_cached_artist(self, artist_id: str) -> Optional[ArtistInfo]:
        cache_key = f"{ARTIST_INFO_PREFIX}{artist_id}"
        cached_info = await self._cache.get(cache_key)
        if cached_info:
            return cached_info
        disk_data = await self._disk_cache.get_artist(artist_id)
        if disk_data:
            try:
                artist_info = msgspec.convert(disk_data, ArtistInfo, strict=False)
            except (msgspec.ValidationError, TypeError, ValueError) as e:
                logger.warning(f"Corrupt disk cache for artist {artist_id[:8]}, clearing: {e}")
                await self._disk_cache.delete_artist(artist_id)
                return None
            ttl = self._get_artist_ttl(artist_info.in_library)
            await self._cache.set(cache_key, artist_info, ttl_seconds=ttl)
            return artist_info
        return None

    async def _save_artist_to_cache(self, artist_id: str, artist_info: ArtistInfo) -> None:
        cache_key = f"{ARTIST_INFO_PREFIX}{artist_id}"
        ttl = self._get_artist_ttl(artist_info.in_library)
        await self._cache.set(cache_key, artist_info, ttl_seconds=ttl)
        await self._disk_cache.set_artist(
            artist_id, artist_info,
            is_monitored=artist_info.in_library,
            ttl_seconds=ttl if not artist_info.in_library else None
        )

    def _get_artist_ttl(self, in_library: bool) -> int:
        advanced_settings = self._preferences_service.get_advanced_settings()
        return advanced_settings.cache_ttl_artist_library if in_library else advanced_settings.cache_ttl_artist_non_library
    
    async def get_artist_extended_info(self, artist_id: str) -> ArtistExtendedInfo:
        try:
            artist_id = validate_mbid(artist_id, "artist")
            cache_key = f"{ARTIST_INFO_PREFIX}{artist_id}"
            cached_info = await self._cache.get(cache_key)
            if cached_info and cached_info.description is not None:
                return ArtistExtendedInfo(description=cached_info.description, image=cached_info.image)
            mb_artist = await self._mb_repo.get_artist_by_id(artist_id)
            if not mb_artist:
                raise ResourceNotFoundError("Artist not found")
            description, image = await self._fetch_wikidata_info(mb_artist)
            if cached_info:
                cached_info.description = description
                cached_info.image = image
                await self._save_artist_to_cache(artist_id, cached_info)
            return ArtistExtendedInfo(description=description, image=image)
        except Exception as e:  # noqa: BLE001
            logger.error(f"Error fetching extended artist info for {artist_id}: {e}")
            return ArtistExtendedInfo(description=None, image=None)
    
    async def get_artist_releases(
        self,
        artist_id: str,
        offset: int = 0,
        limit: int = 50
    ) -> ArtistReleases:
        try:
            lidarr_artist = await self._lidarr_repo.get_artist_details(artist_id)
            in_library = lidarr_artist is not None and lidarr_artist.get("monitored", False)

            album_mbids, requested_mbids, cache_mbids = await asyncio.gather(
                self._lidarr_repo.get_library_mbids(include_release_ids=True),
                self._lidarr_repo.get_requested_mbids(),
                self._get_library_cache_mbids(),
            )
            album_mbids = album_mbids | cache_mbids

            prefs = self._preferences_service.get_preferences()
            included_primary_types = set(t.lower() for t in prefs.primary_types)
            included_secondary_types = set(t.lower() for t in prefs.secondary_types)

            if in_library:
                if offset == 0:
                    lidarr_albums = await self._lidarr_repo.get_artist_albums(artist_id)
                    albums, singles, eps = self._categorize_lidarr_albums(lidarr_albums, album_mbids, requested_mbids=requested_mbids)
                    total_count = len(albums) + len(singles) + len(eps)
                    return ArtistReleases(
                        albums=albums,
                        singles=singles,
                        eps=eps,
                        offset=0,
                        limit=limit,
                        returned_count=total_count,
                        next_offset=None,
                        has_more=False,
                        source_total_count=None,
                    )
                return ArtistReleases(
                    albums=[], singles=[], eps=[],
                    offset=offset, limit=limit, returned_count=0,
                    next_offset=None, has_more=False, source_total_count=None,
                )

            return await self._filter_aware_release_page(
                artist_id, offset, limit, album_mbids, requested_mbids,
                included_primary_types, included_secondary_types,
            )
        except Exception as e:  # noqa: BLE001
            logger.error(f"Error fetching releases for artist {artist_id} at offset {offset}: {e}")
            return ArtistReleases(
                albums=[], singles=[], eps=[],
                offset=offset, limit=limit, returned_count=0,
                next_offset=None, has_more=False, source_total_count=None,
            )

    async def _filter_aware_release_page(
        self,
        artist_id: str,
        offset: int,
        limit: int,
        album_mbids: set[str],
        requested_mbids: set[str],
        included_primary_types: set[str],
        included_secondary_types: set[str],
    ) -> ArtistReleases:
        if not included_primary_types:
            return ArtistReleases(
                albums=[], singles=[], eps=[],
                offset=offset, limit=limit, returned_count=0,
                next_offset=None, has_more=False, source_total_count=None,
            )

        _SCAN_BATCH = 100
        _MAX_SCAN_BATCHES = 2
        seen_mbids: set[str] = set()
        all_albums: list[ReleaseItem] = []
        all_singles: list[ReleaseItem] = []
        all_eps: list[ReleaseItem] = []

        raw_offset = offset
        source_total: int | None = None
        batches_scanned = 0

        while batches_scanned < _MAX_SCAN_BATCHES:
            release_groups, mb_total = await self._mb_repo.get_artist_release_groups(
                artist_id, raw_offset, _SCAN_BATCH
            )
            if source_total is None:
                source_total = mb_total

            if not release_groups:
                break

            temp_artist = {"release-group-list": release_groups}
            page_albums, page_singles, page_eps = categorize_release_groups(
                temp_artist, album_mbids, included_primary_types,
                included_secondary_types, requested_mbids,
            )

            for item in page_albums:
                if item.id and item.id not in seen_mbids:
                    seen_mbids.add(item.id)
                    all_albums.append(item)
            for item in page_singles:
                if item.id and item.id not in seen_mbids:
                    seen_mbids.add(item.id)
                    all_singles.append(item)
            for item in page_eps:
                if item.id and item.id not in seen_mbids:
                    seen_mbids.add(item.id)
                    all_eps.append(item)

            raw_offset += len(release_groups)
            batches_scanned += 1

            total_collected = len(all_albums) + len(all_singles) + len(all_eps)
            if total_collected >= limit:
                break
            if raw_offset >= mb_total:
                break

        for lst in (all_albums, all_singles, all_eps):
            lst.sort(key=lambda x: (x.year is None, -(x.year or 0)))

        returned_count = len(all_albums) + len(all_singles) + len(all_eps)

        has_more = raw_offset < (source_total or 0)

        next_offset = raw_offset if has_more else None

        return ArtistReleases(
            albums=all_albums,
            singles=all_singles,
            eps=all_eps,
            offset=offset,
            limit=limit,
            returned_count=returned_count,
            next_offset=next_offset,
            has_more=has_more,
            source_total_count=source_total,
        )
    
    async def _fetch_artist_data(
        self,
        artist_id: str,
        library_artist_mbids: set[str] = None,
        library_album_mbids: dict[str, Any] = None
    ) -> tuple[dict, set[str], set[str], set[str]]:
        if library_artist_mbids is not None and library_album_mbids is not None:
            mb_artist = await self._mb_repo.get_artist_by_id(artist_id)
            library_mbids = library_artist_mbids
            album_mbids = library_album_mbids
            requested_result = await asyncio.gather(
                self._lidarr_repo.get_requested_mbids(),
                return_exceptions=True,
            )
            requested_mbids = requested_result[0] if not isinstance(requested_result[0], BaseException) else set()
            if isinstance(requested_result[0], BaseException):
                logger.warning(f"Lidarr unavailable, proceeding without requested data: {requested_result[0]}")
        else:
            mb_artist, *lidarr_results = await asyncio.gather(
                self._mb_repo.get_artist_by_id(artist_id),
                self._lidarr_repo.get_artist_mbids(),
                self._lidarr_repo.get_library_mbids(include_release_ids=True),
                self._lidarr_repo.get_requested_mbids(),
                return_exceptions=True,
            )
            if isinstance(mb_artist, BaseException):
                logger.error(f"Error fetching artist data for {artist_id}: {mb_artist}")
                raise ResourceNotFoundError(f"Failed to fetch artist: {mb_artist}")
            lidarr_failed = any(isinstance(r, BaseException) for r in lidarr_results)
            if lidarr_failed:
                logger.warning(f"Lidarr unavailable for artist {artist_id}, proceeding with MusicBrainz data only")
            library_mbids = lidarr_results[0] if not isinstance(lidarr_results[0], BaseException) else set()
            album_mbids = lidarr_results[1] if not isinstance(lidarr_results[1], BaseException) else set()
            requested_mbids = lidarr_results[2] if not isinstance(lidarr_results[2], BaseException) else set()

        # Supplement with LibraryDB so monitored albums (even with trackFileCount=0)
        # are recognised as "in library", consistent with the Library page.
        cache_mbids = await self._get_library_cache_mbids()
        album_mbids = album_mbids | cache_mbids

        if not mb_artist:
            raise ResourceNotFoundError("Artist not found")

        return mb_artist, library_mbids, album_mbids, requested_mbids
    
    def _build_external_links(self, mb_artist: dict[str, Any]) -> list[ExternalLink]:
        external_links_data = extract_external_links(mb_artist)
        return [
            ExternalLink(type=link["type"], url=link["url"], label=link["label"])
            for link in external_links_data
        ]

    async def _get_categorized_releases(
        self,
        mb_artist: dict[str, Any],
        album_mbids: set[str],
        requested_mbids: set[str] = None
    ) -> tuple[list[ReleaseItem], list[ReleaseItem], list[ReleaseItem]]:
        prefs = self._preferences_service.get_preferences()
        included_primary_types = set(t.lower() for t in prefs.primary_types)
        included_secondary_types = set(t.lower() for t in prefs.secondary_types)
        return categorize_release_groups(
            mb_artist,
            album_mbids,
            included_primary_types,
            included_secondary_types,
            requested_mbids or set()
        )
    
    async def _fetch_wikidata_info(self, mb_artist: dict[str, Any]) -> tuple[Optional[str], Optional[str]]:
        wikidata_id, wiki_urls = self._extract_wiki_info(mb_artist)
        
        tasks = []
        if wiki_urls:
            tasks.append(self._wikidata_repo.get_wikipedia_extract(wiki_urls[0]))
        else:
            tasks.append(asyncio.create_task(asyncio.sleep(0)))
        
        if wikidata_id:
            tasks.append(self._wikidata_repo.get_artist_image_from_wikidata(wikidata_id))
        else:
            tasks.append(asyncio.create_task(asyncio.sleep(0)))
        
        results = await asyncio.gather(*tasks, return_exceptions=True)
        
        description = results[0] if len(results) > 0 and not isinstance(results[0], Exception) and results[0] else None
        image = results[1] if len(results) > 1 and not isinstance(results[1], Exception) and results[1] else None
        
        return description, image
    
    def _extract_wiki_info(self, mb_artist: dict[str, Any]) -> tuple[Optional[str], list[str]]:
        return extract_wiki_info(mb_artist, self._wikidata_repo.get_wikidata_id_from_url)
