"""Deterministic tests for bounded Spotify grounding."""

import asyncio
import time
from collections.abc import Awaitable, Callable

from app.adapters.spotify.errors import (
    SpotifyQuotaExhaustedError,
    SpotifyRateLimitedError,
    SpotifyUnavailableError,
)
from app.config import Settings
from app.domain.models import Familiarity, Track, TrackCandidate
from app.pipeline.grounding import Grounder
from app.ports.protocols import TimeRange

SearchHandler = Callable[[str], Awaitable[list[Track]]]


class FakeCatalog:
    """Expose a programmable search surface for grounding tests."""

    def __init__(self, search_handler: SearchHandler) -> None:
        """Store the search behavior and call trace."""
        self.search_handler = search_handler
        self.search_queries: list[str] = []

    async def search_tracks(self, query: str, limit: int = 10) -> list[Track]:
        """Record and delegate one fake search."""
        self.search_queries.append(query)
        return await self.search_handler(query)

    async def fetch_top_artists(self, time_range: TimeRange, limit: int) -> list[str]:
        """Return no top artists."""
        return []

    async def fetch_top_tracks(self, time_range: TimeRange, limit: int) -> list[Track]:
        """Return no top tracks."""
        return []

    async def fetch_saved_tracks(self, limit: int) -> list[Track]:
        """Return no saved tracks."""
        return []


def test_early_stop_honors_pool_target() -> None:
    async def run() -> None:
        async def search(query: str) -> list[Track]:
            title = query.split('track:"', 1)[1].split('"', 1)[0]
            return [_track(title, title, "Artist")]

        catalog = FakeCatalog(search)
        grounder = Grounder(_settings(grounding_concurrency=2))
        candidates = tuple(TrackCandidate(f"track-{index}", "Artist") for index in range(6))

        result = await grounder.ground(
            catalog,
            candidates,
            frozenset(),
            Familiarity.MIX,
            2,
            _deadline(),
        )

        assert len(result.tracks) == 2
        assert len(catalog.search_queries) == 2

    asyncio.run(run())


def test_miss_and_mismatch_are_counted_separately() -> None:
    async def run() -> None:
        async def search(query: str) -> list[Track]:
            if "Missing" in query:
                return []
            return [_track("wrong", "Different Song", "Different Artist")]

        catalog = FakeCatalog(search)
        candidates = (
            TrackCandidate("Missing", "Artist"),
            TrackCandidate("Rejected", "Artist"),
        )

        result = await Grounder(_settings()).ground(
            catalog,
            candidates,
            frozenset(),
            Familiarity.MIX,
            2,
            _deadline(),
        )

        assert result.metrics.miss_count == 1
        assert result.metrics.mismatch_guard_count == 1
        assert result.metrics.attempted_count == 2

    asyncio.run(run())


def test_resolution_cache_hit_skips_catalog() -> None:
    async def run() -> None:
        async def search(query: str) -> list[Track]:
            return [_track("cached", "Cached Song", "Artist")]

        catalog = FakeCatalog(search)
        grounder = Grounder(_settings())
        candidates = (TrackCandidate("Cached Song", "Artist"),)

        await grounder.ground(
            catalog,
            candidates,
            frozenset(),
            Familiarity.MIX,
            1,
            _deadline(),
        )
        first_call_count = len(catalog.search_queries)
        second = await grounder.ground(
            catalog,
            candidates,
            frozenset(),
            Familiarity.MIX,
            1,
            _deadline(),
        )

        assert len(catalog.search_queries) == first_call_count
        assert second.metrics.cache_hit_count == 1

    asyncio.run(run())


def test_deadline_returns_resolved_partial_pool() -> None:
    async def run() -> None:
        never_finishes = asyncio.Event()

        async def search(query: str) -> list[Track]:
            if "Slow Song" in query:
                await never_finishes.wait()
            return [_track("fast", "Fast Song", "Artist")]

        catalog = FakeCatalog(search)
        settings = _settings(grounding_concurrency=2, request_deadline_seconds=0.02)
        candidates = (
            TrackCandidate("Fast Song", "Artist"),
            TrackCandidate("Slow Song", "Artist"),
        )

        result = await Grounder(settings).ground(
            catalog,
            candidates,
            frozenset(),
            Familiarity.MIX,
            2,
            time.monotonic() + settings.request_deadline_seconds,
        )

        assert [track.id for track in result.tracks] == ["fast"]
        assert result.metrics.did_reach_deadline

    asyncio.run(run())


def test_spotify_failure_is_counted_and_remaining_candidates_continue() -> None:
    async def run() -> None:
        async def search(query: str) -> list[Track]:
            if "Failing Song" in query:
                raise SpotifyUnavailableError(503)
            title = query.split('track:"', 1)[1].split('"', 1)[0]
            return [_track(title.lower().replace(" ", "-"), title, "Artist")]

        catalog = FakeCatalog(search)
        candidates = (
            TrackCandidate("Failing Song", "Artist"),
            TrackCandidate("First Good Song", "Artist"),
            TrackCandidate("Second Good Song", "Artist"),
        )

        result = await Grounder(_settings()).ground(
            catalog,
            candidates,
            frozenset(),
            Familiarity.MIX,
            2,
            _deadline(),
        )

        assert [track.title for track in result.tracks] == [
            "First Good Song",
            "Second Good Song",
        ]
        assert result.metrics.failed_count == 1
        assert result.metrics.attempted_count == 3
        assert not result.metrics.did_exhaust_quota

    asyncio.run(run())


def test_plain_rate_limit_continues_but_quota_exhaustion_stops_fanout() -> None:
    async def run() -> None:
        async def plain_rate_limited_search(query: str) -> list[Track]:
            if "Rate Limited" in query:
                raise SpotifyRateLimitedError(6.0)
            return [_track("found", "Found Song", "Artist")]

        plain_catalog = FakeCatalog(plain_rate_limited_search)
        candidates = (
            TrackCandidate("Rate Limited", "Artist"),
            TrackCandidate("Found Song", "Artist"),
        )
        plain_result = await Grounder(_settings()).ground(
            plain_catalog,
            candidates,
            frozenset(),
            Familiarity.MIX,
            1,
            _deadline(),
        )

        async def quota_search(query: str) -> list[Track]:
            raise SpotifyQuotaExhaustedError(0.0, "QUOTA_EXCEEDED")

        quota_catalog = FakeCatalog(quota_search)
        quota_result = await Grounder(_settings()).ground(
            quota_catalog,
            candidates,
            frozenset(),
            Familiarity.MIX,
            1,
            _deadline(),
        )

        assert [track.id for track in plain_result.tracks] == ["found"]
        assert plain_result.metrics.failed_count == 1
        assert not plain_result.metrics.did_exhaust_quota
        assert quota_result.tracks == ()
        assert quota_result.metrics.did_exhaust_quota
        assert len(quota_catalog.search_queries) == 1

    asyncio.run(run())


def test_grounding_concurrency_is_shared_across_requests() -> None:
    async def run() -> None:
        active_searches = 0
        maximum_active_searches = 0

        async def search(query: str) -> list[Track]:
            nonlocal active_searches, maximum_active_searches
            active_searches += 1
            maximum_active_searches = max(maximum_active_searches, active_searches)
            await asyncio.sleep(0.01)
            active_searches -= 1
            title = query.split('track:"', 1)[1].split('"', 1)[0]
            return [_track(title, title, "Artist")]

        catalog = FakeCatalog(search)
        grounder = Grounder(_settings(grounding_concurrency=2))
        candidates = tuple(TrackCandidate(f"Song {index}", "Artist") for index in range(2))

        await asyncio.gather(
            grounder.ground(
                catalog,
                candidates,
                frozenset(),
                Familiarity.MIX,
                2,
                _deadline(),
            ),
            grounder.ground(
                catalog,
                candidates,
                frozenset(),
                Familiarity.MIX,
                2,
                _deadline(),
            ),
        )

        assert maximum_active_searches == 2

    asyncio.run(run())


def _settings(**overrides: object) -> Settings:
    values: dict[str, object] = {
        "grounding_concurrency": 1,
        "request_deadline_seconds": 1.0,
    }
    values.update(overrides)
    return Settings.model_validate(values)


def _deadline() -> float:
    return time.monotonic() + 1.0


def _track(track_id: str, title: str, artist: str) -> Track:
    return Track(
        id=track_id,
        uri=f"spotify:track:{track_id}",
        title=title,
        artists=(artist,),
        album_name="Album",
        album_art_url=None,
        external_url=None,
    )
