"""Tests for genre section decoupling from home page build."""

import asyncio
import json
import time

import pytest
from unittest.mock import AsyncMock, MagicMock, patch

from api.v1.schemas.home import HomeGenre, HomeResponse, HomeSection
from services.home.genre_service import GenreService, GENRE_SECTION_TTL_DEFAULT


def _make_artist(mbid: str) -> MagicMock:
    a = MagicMock()
    a.musicbrainz_id = mbid
    return a


def _make_genre_service(
    tmp_path=None,
    genre_section_ttl: int = GENRE_SECTION_TTL_DEFAULT,
) -> tuple[GenreService, AsyncMock, AsyncMock]:
    mb = AsyncMock()
    mem_cache = AsyncMock()
    mem_cache.get = AsyncMock(return_value=None)
    mem_cache.set = AsyncMock()
    audiodb = AsyncMock()

    prefs = MagicMock()
    adv = MagicMock()
    adv.genre_section_ttl = genre_section_ttl
    prefs.get_advanced_settings.return_value = adv

    svc = GenreService(
        musicbrainz_repo=mb,
        memory_cache=mem_cache,
        audiodb_image_service=audiodb,
        cache_dir=tmp_path,
        preferences_service=prefs,
    )
    return svc, mb, mem_cache


class TestGenreSectionCacheHit:
    @pytest.mark.asyncio
    async def test_cache_hit_returns_data_no_mb_calls(self, tmp_path):
        """Genre section cache hit should return data without any MB/AudioDB calls."""
        svc, mb, mem_cache = _make_genre_service(tmp_path)
        genre_artists = {"rock": "mbid-1", "jazz": "mbid-2"}
        genre_images = {"rock": "http://img/rock.jpg", "jazz": "http://img/jazz.jpg"}

        mem_cache.get = AsyncMock(return_value=(genre_artists, genre_images))

        result = await svc.get_cached_genre_section("listenbrainz")
        assert result is not None
        assert result[0] == genre_artists
        assert result[1] == genre_images
        mb.search_artists_by_tag.assert_not_awaited()

    @pytest.mark.asyncio
    async def test_cache_miss_returns_none(self, tmp_path):
        """Genre section cache miss should return None."""
        svc, _, mem_cache = _make_genre_service(tmp_path)
        mem_cache.get = AsyncMock(return_value=None)

        result = await svc.get_cached_genre_section("listenbrainz")
        assert result is None


class TestGenreSectionDiskPersistence:
    @pytest.mark.asyncio
    async def test_save_and_read_from_disk(self, tmp_path):
        """Genre section should be readable from disk after save."""
        svc, _, mem_cache = _make_genre_service(tmp_path)
        genre_artists = {"rock": "mbid-1", "pop": "mbid-2"}
        genre_images = {"rock": "http://img/rock.jpg"}

        await svc.save_genre_section("listenbrainz", genre_artists, genre_images)

        mem_cache.get = AsyncMock(return_value=None)
        result = await svc.get_cached_genre_section("listenbrainz")

        assert result is not None
        assert result[0] == genre_artists
        assert result[1] == genre_images

    @pytest.mark.asyncio
    async def test_disk_survives_memory_clear(self, tmp_path):
        """Simulates a restart: memory cache is empty, disk data should be used."""
        svc, _, mem_cache = _make_genre_service(tmp_path)
        genre_artists = {"metal": "mbid-3"}
        genre_images = {"metal": "http://img/metal.jpg"}

        await svc.save_genre_section("lastfm", genre_artists, genre_images)

        mem_cache.get = AsyncMock(return_value=None)
        svc2, _, mem_cache2 = _make_genre_service(tmp_path)
        mem_cache2.get = AsyncMock(return_value=None)
        result = await svc2.get_cached_genre_section("lastfm")

        assert result is not None
        assert result[0] == genre_artists

    @pytest.mark.asyncio
    async def test_expired_disk_returns_none(self, tmp_path):
        """Expired genre section on disk should return None."""
        svc, _, mem_cache = _make_genre_service(tmp_path, genre_section_ttl=1)
        genre_artists = {"rock": "mbid-1"}
        genre_images = {}

        await svc.save_genre_section("listenbrainz", genre_artists, genre_images)

        file_path = tmp_path / "genre_sections" / "listenbrainz.json"
        data = json.loads(file_path.read_text())
        data["built_at"] = time.time() - 100
        file_path.write_text(json.dumps(data))

        mem_cache.get = AsyncMock(return_value=None)
        result = await svc.get_cached_genre_section("listenbrainz")
        assert result is None


class TestGenreSectionBuild:
    @pytest.mark.asyncio
    async def test_build_caches_result(self, tmp_path):
        """build_and_cache_genre_section should build and save to disk."""
        svc, mb, mem_cache = _make_genre_service(tmp_path)
        mb.search_artists_by_tag = AsyncMock(
            side_effect=lambda name, limit=10: [_make_artist(f"mbid-{name}")]
        )

        img_result = MagicMock()
        img_result.is_negative = False
        img_result.wide_thumb_url = "http://img/test.jpg"
        img_result.banner_url = None
        img_result.fanart_url = None
        svc._audiodb_image_service.fetch_and_cache_artist_images = AsyncMock(
            return_value=img_result
        )

        await svc.build_and_cache_genre_section("listenbrainz", ["rock", "jazz"])

        mem_cache.get = AsyncMock(return_value=None)
        result = await svc.get_cached_genre_section("listenbrainz")
        assert result is not None
        assert "rock" in result[0]
        assert "jazz" in result[0]

    @pytest.mark.asyncio
    async def test_build_skips_when_locked(self, tmp_path):
        """Concurrent builds for same source should be skipped if lock is held."""
        svc, mb, _ = _make_genre_service(tmp_path)
        mb.search_artists_by_tag = AsyncMock(
            side_effect=lambda name, limit=10: [_make_artist(f"mbid-{name}")]
        )

        svc._genre_build_locks["listenbrainz"] = asyncio.Lock()
        async with svc._genre_build_locks["listenbrainz"]:
            await svc.build_and_cache_genre_section("listenbrainz", ["rock"])

        mb.search_artists_by_tag.assert_not_awaited()


class TestGenreSectionTTLSetting:
    @pytest.mark.asyncio
    async def test_ttl_from_preferences(self, tmp_path):
        """Genre section TTL should be read from advanced settings."""
        svc, _, _ = _make_genre_service(tmp_path, genre_section_ttl=7200)
        assert svc._get_genre_section_ttl() == 7200

    @pytest.mark.asyncio
    async def test_ttl_default_on_missing(self, tmp_path):
        """Missing preference should fall back to default TTL."""
        svc, _, _ = _make_genre_service(tmp_path)
        svc._preferences_service = None
        assert svc._get_genre_section_ttl() == GENRE_SECTION_TTL_DEFAULT


class TestAdvancedSettingsGenreTTL:
    def test_genre_section_ttl_default(self):
        """AdvancedSettings should include genre_section_ttl with correct default."""
        from api.v1.schemas.advanced_settings import AdvancedSettings
        settings = AdvancedSettings()
        assert settings.genre_section_ttl == 21600

    def test_genre_section_ttl_roundtrip(self):
        """Frontend→backend roundtrip should preserve genre_section_ttl value."""
        from api.v1.schemas.advanced_settings import AdvancedSettings, AdvancedSettingsFrontend
        backend = AdvancedSettings(genre_section_ttl=43200)
        frontend = AdvancedSettingsFrontend.from_backend(backend)
        assert frontend.genre_section_ttl == 12
        roundtripped = frontend.to_backend()
        assert roundtripped.genre_section_ttl == 43200

    def test_genre_section_ttl_validation_rejects_below_minimum(self):
        """genre_section_ttl below 3600 should be rejected."""
        from api.v1.schemas.advanced_settings import AdvancedSettings
        import msgspec
        with pytest.raises(msgspec.ValidationError):
            AdvancedSettings(genre_section_ttl=100)


class TestPerSourceLockIndependence:
    """Per-source locks allow parallel builds for different sources."""

    @pytest.mark.asyncio
    async def test_different_sources_build_independently(self, tmp_path):
        """Building for LB should not block building for LFM."""
        svc, mb, audiodb = _make_genre_service(tmp_path)
        mb.search_artists_by_tag = AsyncMock(
            side_effect=lambda name, limit=10: [_make_artist(f"mbid-{name}")]
        )
        audiodb.get_artist_images_batch = AsyncMock(return_value={})

        results = await asyncio.gather(
            svc.build_and_cache_genre_section("listenbrainz", ["rock"]),
            svc.build_and_cache_genre_section("lastfm", ["jazz"]),
        )

        assert mb.search_artists_by_tag.await_count == 2

    @pytest.mark.asyncio
    async def test_per_source_locks_created_on_demand(self, tmp_path):
        """Lock for a source is created when first needed."""
        svc, mb, audiodb = _make_genre_service(tmp_path)

        assert "listenbrainz" not in svc._genre_build_locks

        mb.search_artists_by_tag = AsyncMock(
            side_effect=lambda name, limit=10: [_make_artist(f"mbid-{name}")]
        )
        audiodb.get_artist_images_batch = AsyncMock(return_value={})
        await svc.build_and_cache_genre_section("listenbrainz", ["rock"])

        assert "listenbrainz" in svc._genre_build_locks


class TestWarmerRetryOnNoop:
    """Warmer uses short retry interval when no sources were warmed."""

    @pytest.mark.asyncio
    async def test_warmer_retries_quickly_on_no_data(self):
        """When no cached home data exists, warmer sleeps 60s not full TTL."""
        from core.tasks import warm_genre_cache_periodically

        home_svc = AsyncMock()
        home_svc.get_cached_home_data = AsyncMock(return_value=None)
        home_svc._genre._get_genre_section_ttl = MagicMock(return_value=21600)

        sleep_values: list[int | float] = []
        call_count = 0

        original_sleep = asyncio.sleep

        async def mock_sleep(seconds):
            nonlocal call_count
            sleep_values.append(seconds)
            call_count += 1
            if call_count >= 2:
                raise asyncio.CancelledError
            return await original_sleep(0)

        with patch("core.tasks.asyncio.sleep", side_effect=mock_sleep):
            try:
                await warm_genre_cache_periodically(home_svc, interval=21600)
            except asyncio.CancelledError:
                pass

        assert len(sleep_values) >= 2
        assert sleep_values[1] == 60
