from types import SimpleNamespace
from unittest.mock import Mock, patch

from app.chain.scraping import ScrapingChain, ScrapingConfig, _MusicScrapeFileResult
from app.domain.context import MUSIC_ENTITY_ALBUM, MusicAlbumInfo, MusicInfo, MusicLyrics
from app.runtime.events import Event
from app.domain.meta.metamusic import MetaMusic
from app.schemas import FileItem
from app.schemas.types import EventType, ScrapingPolicy


def _media_chain() -> ScrapingChain:
    """构造不注册全局单例的音乐刮削链测试实例。"""
    return object.__new__(ScrapingChain)


def _album_info() -> MusicInfo:
    """构造专辑批量刮削使用的标准目标。"""
    return MusicInfo(
        media_source="musicbrainz",
        media_id="release-group-1",
        music_type=MUSIC_ENTITY_ALBUM,
        title="叶惠美",
        artists=["周杰伦"],
        album="叶惠美",
        album_artist="周杰伦",
        year=2003,
        total_tracks=11,
        cover_url="https://example.com/album.jpg",
    )


def test_album_scrape_merge_preserves_track_fields_and_applies_album_identity() -> None:
    """专辑批量刮削应保留每首歌自己的曲名和曲序，只统一专辑级字段。"""
    local = MetaMusic(
        title="晴天",
        artists=["周杰伦"],
        album="错误专辑",
        album_artist="错误艺术家",
        year=1999,
        track_number=3,
        total_tracks=99,
    )

    merged = ScrapingChain._merge_music_album_metadata(local, _album_info())

    assert merged.title == "晴天"
    assert merged.artists == ["周杰伦"]
    assert merged.track_number == 3
    assert merged.album == "叶惠美"
    assert merged.album_artist == "周杰伦"
    assert merged.year == 2003
    assert merged.total_tracks == 11
    assert merged.media_source == "musicbrainz"
    assert merged.media_id == "release-group-1"


def test_album_directory_scrape_processes_each_track_and_reuses_cover() -> None:
    """显式选择专辑刮削目录时应逐曲写标签，并让整批文件共用一次封面下载。"""
    chain = _media_chain()
    chain.storagechain = Mock()
    chain.scraping_policies = Mock()

    def scraping_option(_target, metadata):
        """保持原测试只验证标签和封面，歌词由独立用例覆盖。"""
        return SimpleNamespace(
            is_skip=metadata == "lyrics",
            is_overwrite=False,
        )

    chain.scraping_policies.option.side_effect = scraping_option
    audio_files = [
        FileItem(storage="local", path="/music/叶惠美/01.flac", type="file", name="01.flac"),
        FileItem(storage="local", path="/music/叶惠美/02.m4a", type="file", name="02.m4a"),
    ]
    chain.storagechain.list_files.return_value = audio_files
    chain._download_music_cover = Mock(return_value=(b"cover", "image/jpeg"))
    chain._scrape_music_file = Mock(
        return_value=_MusicScrapeFileResult(metadata_success=True)
    )
    album = _album_info()

    with patch(
        "app.chain.scraping.MediaChain.get_music_album",
        return_value=None,
    ) as get_music_album:
        success, message = chain.scrape_music_metadata(
            FileItem(
                storage="local",
                path="/music/叶惠美",
                type="dir",
                name="叶惠美",
            ),
            mediainfo=album,
        )

    assert success is True
    assert message == "已刮削 2 个音频文件"
    chain._download_music_cover.assert_called_once_with(album.cover_url)
    get_music_album.assert_called_once_with(
        media_source=album.media_source,
        media_id=album.media_id,
    )
    assert chain._scrape_music_file.call_count == 2
    assert all(
        call.args[1] is album and call.kwargs["cover"] == (b"cover", "image/jpeg")
        for call in chain._scrape_music_file.call_args_list
    )


def test_music_cover_download_uses_bounded_external_response_cache() -> None:
    """重复刮削同一封面时应复用缓存内容，避免再次访问外部图片接口。"""
    response = SimpleNamespace(
        status_code=200,
        headers={"Content-Type": "image/webp"},
        content=b"cover",
        close=Mock(),
    )
    request = Mock()
    request.get_res.return_value = response
    ScrapingChain._request_music_cover.cache_clear()

    with patch("app.chain.scraping.RequestUtils", return_value=request):
        first = ScrapingChain._download_music_cover("https://example.com/album.webp")
        second = ScrapingChain._download_music_cover("https://example.com/album.webp")

    assert first == second == (b"cover", "image/webp")
    request.get_res.assert_called_once_with("https://example.com/album.webp")
    response.close.assert_called_once()
    ScrapingChain._request_music_cover.cache_clear()


def test_recording_identity_rejects_multi_track_directory_scrape() -> None:
    """单曲身份不得覆盖整目录，否则会把同一首歌的标签写到专辑内所有文件。"""
    chain = _media_chain()
    chain.storagechain = Mock()
    chain.storagechain.list_files.return_value = [
        FileItem(storage="local", path="/music/01.flac", type="file"),
        FileItem(storage="local", path="/music/02.flac", type="file"),
    ]

    success, message = chain.scrape_music_metadata(
        FileItem(storage="local", path="/music", type="dir"),
        mediainfo=MusicInfo(title="晴天", music_type="recording"),
    )

    assert success is False
    assert message == "单曲音乐 ID 仅支持刮削单个音频文件，整目录请选择专辑"


def test_generic_scrape_dispatches_music_without_entering_video_handlers() -> None:
    """统一刮削入口应直接分派音乐，不能因音频后缀过滤或落入电视剧分支。"""
    chain = _media_chain()
    chain.scrape_music_metadata = Mock(return_value=(True, "已刮削 1 个音频文件"))
    music = MusicInfo(
        media_source="musicbrainz",
        media_id="recording-1",
        title="晴天",
        artists=["周杰伦"],
    )
    fileitem = FileItem(
        storage="local",
        path="/music/晴天.flac",
        type="file",
        name="晴天.flac",
        extension="flac",
    )

    result = chain.scrape_metadata(
        fileitem=fileitem,
        meta=MetaMusic(title="晴天", artists=["周杰伦"]),
        mediainfo=music,
        overwrite=False,
    )

    assert result == (True, "已刮削 1 个音频文件")
    chain.scrape_music_metadata.assert_called_once_with(
        fileitem=fileitem,
        mediainfo=music,
        overwrite=False,
    )


def test_default_scraping_config_enables_missing_only_music_lyrics() -> None:
    """新安装和未保存过该字段的用户应默认仅在缺失时下载歌词。"""
    assert ScrapingConfig.get_default_config()["music_lyrics"] == ScrapingPolicy.MISSINGONLY


def test_album_track_match_uses_disc_track_title_and_duration() -> None:
    """整张专辑刮削时应把本地音轨绑定到对应 Recording，不能复用专辑级身份。"""
    album = MusicAlbumInfo(
        media_source="musicbrainz",
        media_id="album-1",
        title="叶惠美",
        tracks=[
                MusicInfo(
                    media_source="musicbrainz",
                    media_id="recording-1",
                title="以父之名",
                artists=["周杰伦"],
                disc_number=1,
                track_number=1,
                duration=342,
            ),
                MusicInfo(
                    media_source="musicbrainz",
                    media_id="recording-3",
                title="晴天",
                artists=["周杰伦"],
                disc_number=1,
                track_number=3,
                duration=269,
            ),
        ],
    )

    matched = ScrapingChain._match_music_album_track(
        MetaMusic(
            title="03 - 晴天",
            artists=["周杰伦"],
            disc_number=1,
            track_number=3,
            duration=270,
        ),
        album,
    )

    assert matched is not None
    assert matched.media_id == "recording-3"
    assert matched.title == "晴天"


def test_music_scrape_can_run_lyrics_without_tags_or_cover() -> None:
    """标签和封面关闭时，歌词开关仍应独立驱动逐曲处理。"""
    chain = _media_chain()
    chain.storagechain = Mock()
    chain.scraping_policies = Mock()

    def scraping_option(_target, metadata):
        """仅开启歌词的缺失刮削策略。"""
        return SimpleNamespace(
            is_skip=metadata != "lyrics",
            is_overwrite=False,
        )

    chain.scraping_policies.option.side_effect = scraping_option
    chain._scrape_music_file = Mock(
        return_value=_MusicScrapeFileResult(
            metadata_success=True,
            lyrics_status="saved",
        )
    )
    music_chain = Mock()

    with patch("app.chain.scraping.LrclibChain", return_value=music_chain):
        success, message = chain.scrape_music_metadata(
            FileItem(
                storage="local",
                path="/music/晴天.flac",
                type="file",
                name="晴天.flac",
            ),
            mediainfo=MusicInfo(title="晴天", artists=["周杰伦"]),
            overwrite=False,
        )

    assert success is True
    assert message == "已刮削 1 个音频文件，歌词新增 1 首、已存在 0 首、未匹配 0 首"
    call = chain._scrape_music_file.call_args
    assert call.kwargs["write_tags"] is False
    assert call.kwargs["with_cover"] is False
    assert call.kwargs["lyrics_chain"] is music_chain


def test_write_music_lyrics_sidecar_creates_same_name_lrc(tmp_path) -> None:
    """同步歌词应以 UTF-8 同名 LRC 文件写入音轨所在目录。"""
    chain = _media_chain()
    chain.storagechain = Mock()
    audio_path = tmp_path / "晴天.flac"
    audio_path.write_bytes(b"audio")

    success = chain._write_music_lyrics_sidecar(
        fileitem=FileItem(
            storage="local",
            path=audio_path.as_posix(),
            type="file",
            name=audio_path.name,
        ),
        local_path=audio_path,
        lyrics=MusicLyrics(
            provider="lrclib",
            provider_id="1",
            synced_lyrics="[00:01.00]故事的小黄花",
        ),
        overwrite=False,
    )

    lyric_path = audio_path.with_suffix(".lrc")
    assert success is True
    assert lyric_path.read_text(encoding="utf-8") == "[00:01.00]故事的小黄花\n"


def test_missing_only_lyrics_skips_existing_sidecar(tmp_path) -> None:
    """仅缺失策略发现同名歌词后不得再次请求歌词源。"""
    chain = _media_chain()
    existing = FileItem(storage="local", path=(tmp_path / "晴天.lrc").as_posix(), type="file")
    chain.storagechain = Mock()
    chain.storagechain.get_file_item.return_value = existing
    music_chain = Mock()
    lyrics_option = SimpleNamespace(is_skip=False)

    status = chain._scrape_music_lyrics(
        fileitem=FileItem(storage="local", path=(tmp_path / "晴天.flac").as_posix(), type="file"),
        local_path=tmp_path / "晴天.flac",
        scrape_info=MetaMusic(title="晴天", artists=["周杰伦"]),
        lyrics_option=lyrics_option,
        overwrite=False,
        lyrics_chain=music_chain,
        album_info=None,
    )

    assert status == "existing"
    music_chain.lyrics.assert_not_called()


def test_write_music_lyrics_sidecar_uploads_to_remote_audio_directory(tmp_path) -> None:
    """远端存储歌词应使用音轨父目录和同名文件上传。"""
    chain = _media_chain()
    chain.storagechain = Mock()
    parent = FileItem(storage="u115", path="/Music/叶惠美", type="dir")
    chain.storagechain.get_parent_item.return_value = parent
    chain.storagechain.upload_file.return_value = FileItem(
        storage="u115",
        path="/Music/叶惠美/晴天.lrc",
        type="file",
    )
    local_path = tmp_path / "晴天.flac"
    local_path.write_bytes(b"audio")
    fileitem = FileItem(
        storage="u115",
        path="/Music/叶惠美/晴天.flac",
        type="file",
        name="晴天.flac",
    )

    success = chain._write_music_lyrics_sidecar(
        fileitem=fileitem,
        local_path=local_path,
        lyrics=MusicLyrics(provider="lrclib", synced_lyrics="[00:01.00]晴天"),
        overwrite=False,
    )

    assert success is True
    upload = chain.storagechain.upload_file.call_args
    assert upload.args[0] is parent
    assert upload.kwargs["new_name"] == "晴天.lrc"


def test_music_scrape_event_preserves_independent_policy_overwrite() -> None:
    """标签覆盖策略不得升级为全局覆盖并误覆盖缺失模式下的歌词。"""
    chain = _media_chain()
    chain.scrape_music_metadata = Mock(return_value=(True, "done"))
    fileitem = FileItem(storage="local", path="/music/叶惠美", type="dir")
    mediainfo = MusicInfo(
        media_source="musicbrainz",
        media_id="album-1",
        music_type=MUSIC_ENTITY_ALBUM,
        title="叶惠美",
    )

    chain.scrape_metadata_event(
        Event(
            event_type=EventType.MetadataScrape,
            event_data={
                "fileitem": fileitem,
                "mediainfo": mediainfo,
                "overwrite": False,
            },
        )
    )

    chain.scrape_music_metadata.assert_called_once_with(
        fileitem=fileitem,
        mediainfo=mediainfo,
        overwrite=False,
    )


def test_music_scrape_event_uses_only_batch_files_and_per_track_contexts() -> None:
    """自动刮削必须按成功文件清单处理，并保留批次内每首歌各自的身份。"""
    chain = _media_chain()
    chain.storagechain = Mock()
    chain.scrape_music_metadata = Mock(return_value=(True, "done"))
    root = FileItem(storage="local", path="/music/叶惠美", type="dir")
    paths = ["/music/叶惠美/01 - 以父之名.flac", "/music/叶惠美/03 - 晴天.flac"]
    audio_files = [
        FileItem(
            storage="local",
            path=path,
            type="file",
            name=path.rsplit("/", 1)[-1],
            extension="flac",
        )
        for path in paths
    ]
    chain.storagechain.get_file_item.side_effect = audio_files
    recordings = [
        MusicInfo(media_source="musicbrainz", media_id="recording-1", title="以父之名"),
        MusicInfo(media_source="musicbrainz", media_id="recording-3", title="晴天"),
    ]

    chain.scrape_metadata_event(Event(
        event_type=EventType.MetadataScrape,
        event_data={
            "fileitem": root,
            "file_list": paths,
            "mediainfo": recordings[0],
            "file_contexts": [
                {"path": path, "mediainfo": recording}
                for path, recording in zip(paths, recordings)
            ],
            "overwrite": False,
        },
    ))

    chain.scrape_music_metadata.assert_called_once_with(
        fileitem=root,
        mediainfo=recordings[0],
        overwrite=False,
        audio_files=audio_files,
        media_by_path=dict(zip(paths, recordings)),
    )


def test_music_scrape_batch_applies_each_recording_to_its_own_file() -> None:
    """多首单曲批次应逐文件使用对应身份，不触发单曲覆盖整目录保护。"""
    chain = _media_chain()
    chain.storagechain = Mock()
    chain.scraping_policies = Mock()
    chain.scraping_policies.option.side_effect = lambda _target, metadata: SimpleNamespace(
        is_skip=metadata == "lyrics",
        is_overwrite=False,
    )
    chain._scrape_music_file = Mock(
        return_value=_MusicScrapeFileResult(metadata_success=True)
    )
    files = [
        FileItem(storage="local", path="/music/01.flac", type="file", name="01.flac"),
        FileItem(storage="local", path="/music/02.flac", type="file", name="02.flac"),
    ]
    recordings = [
        MusicInfo(media_source="musicbrainz", media_id="recording-1", title="Track 1"),
        MusicInfo(media_source="musicbrainz", media_id="recording-2", title="Track 2"),
    ]

    success, message = chain.scrape_music_metadata(
        fileitem=FileItem(storage="local", path="/music", type="dir"),
        mediainfo=recordings[0],
        overwrite=False,
        audio_files=files,
        media_by_path=dict(zip((item.path for item in files), recordings)),
    )

    assert success is True
    assert message == "已刮削 2 个音频文件"
    assert [call.args[1] for call in chain._scrape_music_file.call_args_list] == recordings


def test_music_download_failure_is_attributed_only_to_enabled_outputs() -> None:
    """音频下载失败时只标记实际启用的标签、封面或歌词任务。"""
    chain = _media_chain()
    chain.storagechain = Mock()
    chain.storagechain.download_file.return_value = None
    fileitem = FileItem(
        storage="local",
        path="/music/晴天.flac",
        type="file",
        name="晴天.flac",
    )
    lyrics_option = SimpleNamespace(is_skip=False)

    lyrics_only = chain._scrape_music_file(
        fileitem=fileitem,
        mediainfo=MusicInfo(title="晴天"),
        write_tags=False,
        tag_overwrite=False,
        with_cover=False,
        lyrics_option=lyrics_option,
        lyrics_chain=Mock(),
    )
    metadata_only = chain._scrape_music_file(
        fileitem=fileitem,
        mediainfo=MusicInfo(title="晴天"),
        write_tags=True,
        tag_overwrite=False,
        with_cover=False,
        lyrics_option=SimpleNamespace(is_skip=True),
        lyrics_chain=None,
    )

    assert lyrics_only.metadata_success is True
    assert lyrics_only.lyrics_status == "failed"
    assert metadata_only.metadata_success is False
    assert metadata_only.lyrics_status == "disabled"
