import asyncio
from unittest.mock import AsyncMock, Mock

import pytest

from app.chain.media import MediaChain
from app.domain.context import MusicAlbumInfo, MusicInfo
from app.domain.meta.metamusic import MetaMusic
from app.modules.musicbrainz import MusicBrainzModule


def _release_detail(release_id: str, title: str, artist: str, tracks: list[tuple[str, int]]):
    """构造 MusicBrainz Release 详情响应，tracks 为 (曲名, 时长秒) 列表。"""
    return {
        "id": release_id,
        "title": title,
        "date": "2004-08-03",
        "artist-credit": [{"artist": {"id": "artist-1", "name": artist}}],
        "release-group": {"id": "rg-1", "primary-type": "Album", "secondary-types": []},
        "media": [
            {
                "position": 1,
                "track-count": len(tracks),
                "tracks": [
                    {
                        "position": index + 1,
                        "length": length * 1000,
                        "title": name,
                        "recording": {"id": f"rec-{index + 1}", "title": name, "length": length * 1000},
                    }
                    for index, (name, length) in enumerate(tracks)
                ],
            }
        ],
    }


ALBUM_TRACKS = [("我的地盘", 215), ("七里香", 299), ("借口", 265)]


def _local_tracks():
    """构造无标签整专目录读取得到的本地曲目元数据。"""
    return [
        MetaMusic(title=name, track_number=index + 1, duration=length, audio_format="WAV")
        for index, (name, length) in enumerate(ALBUM_TRACKS)
    ]


def test_match_music_album_selects_release_by_count_and_duration(monkeypatch):
    """曲目数和时长一致的发行版本应被选中并返回曲目表。"""
    module = MusicBrainzModule()
    detail = _release_detail("release-1", "七里香", "周杰伦", ALBUM_TRACKS)

    def fake_request(path, params=None):
        if path == "/release":
            return {"releases": [{"id": "release-1", "title": "七里香"}]}
        if path == "/release/release-1":
            return detail
        return None

    monkeypatch.setattr(module, "_request_json", fake_request)

    album = module.match_music_album(
        MetaMusic(album="七里香", artists=["周杰伦"]),
        _local_tracks(),
    )

    assert album is not None
    assert album.media_id == "rg-1"
    assert album.title == "七里香"
    assert album.artists == ["周杰伦"]
    assert [track.media_id for track in album.tracks] == ["rec-1", "rec-2", "rec-3"]
    assert album.tracks[0].track_number == 1
    assert album.tracks[0].album == "七里香"


def test_match_music_album_rejects_mismatched_trackset(monkeypatch):
    """曲目数和时长都对不上的候选应被拒绝，避免写错标签。"""
    module = MusicBrainzModule()
    # 候选只有 1 首歌且时长差异巨大
    detail = _release_detail("release-1", "七里香", "周杰伦", [("七里香", 60)])

    def fake_request(path, params=None):
        if path == "/release":
            return {"releases": [{"id": "release-1", "title": "七里香"}]}
        if path == "/release/release-1":
            return detail
        return None

    monkeypatch.setattr(module, "_request_json", fake_request)

    album = module.match_music_album(
        MetaMusic(album="七里香", artists=["周杰伦"]),
        _local_tracks(),
    )

    assert album is None


def test_release_queries_fallback_to_track_titles():
    """目录名没有专辑线索时应使用代表性曲名反查发行版本。"""
    queries = MusicBrainzModule._release_queries(
        MetaMusic(title="Various"),
        [MetaMusic(title="晴天"), MetaMusic(title="七里香"), MetaMusic(title="03")],
    )

    assert any("recording:" in query for query in queries)
    # 纯数字文件名不能作为曲名线索
    assert all('"03"' not in query for query in queries)


@pytest.fixture()
def media_chain():
    """构造媒体链并清理目录匹配缓存。"""
    chain = MediaChain()
    MediaChain._album_dir_cache.clear()
    yield chain
    MediaChain._album_dir_cache.clear()


def test_recognize_album_directory_maps_files(tmp_path, media_chain, monkeypatch):
    """目录级匹配应把每个音频文件对位到专辑曲目并缓存结果。"""
    album_dir = tmp_path / "周杰伦 - 七里香 (2004)"
    album_dir.mkdir()
    files = []
    for index, (name, length) in enumerate(ALBUM_TRACKS):
        file = album_dir / f"{index + 1:02d}.{name}.wav"
        file.write_bytes(b"RIFF")
        files.append(file)

    album = MusicAlbumInfo(
        media_source="musicbrainz",
        media_id="rg-1",
        title="七里香",
        artists=["周杰伦"],
        tracks=[
            MusicInfo(
                media_source="musicbrainz",
                media_id=f"rec-{index + 1}",
                title=name,
                artists=["周杰伦"],
                album="七里香",
                track_number=index + 1,
                duration=length,
            )
            for index, (name, length) in enumerate(ALBUM_TRACKS)
        ],
    )
    source_chain = Mock()
    source_chain.match_music_album.side_effect = lambda *_args, **_kwargs: album
    monkeypatch.setattr(
        "app.chain.media.MusicBrainzChain",
        Mock(return_value=source_chain),
    )

    matched = media_chain.recognize_music_album_directory(album_dir)

    assert len(matched) == len(files)
    for index, file in enumerate(files):
        info = matched[str(file.resolve())]
        assert info.media_id == f"rec-{index + 1}"
        assert info.title == ALBUM_TRACKS[index][0]
    # 同一目录再次识别直接命中缓存，不重复请求模块
    assert media_chain.recognize_music_album_directory(album_dir) == matched
    source_chain.match_music_album.assert_called_once()


def test_async_recognize_album_directory_calls_async_module(
        tmp_path,
        media_chain,
        monkeypatch,
):
    """异步目录识别应直接调用模块异步接口，并兼容单个专辑返回值。"""
    album_dir = tmp_path / "周杰伦 - 七里香 (2004)"
    album_dir.mkdir()
    files = []
    for index, (name, _length) in enumerate(ALBUM_TRACKS):
        file = album_dir / f"{index + 1:02d}.{name}.wav"
        file.write_bytes(b"RIFF")
        files.append(file)

    album = MusicAlbumInfo(
        media_source="musicbrainz",
        media_id="rg-1",
        title="七里香",
        artists=["周杰伦"],
        tracks=[
            MusicInfo(
                media_source="musicbrainz",
                media_id=f"rec-{index + 1}",
                title=name,
                artists=["周杰伦"],
                album="七里香",
                track_number=index + 1,
                duration=length,
            )
            for index, (name, length) in enumerate(ALBUM_TRACKS)
        ],
    )
    async_run_module = AsyncMock(return_value=album)
    run_module = Mock(side_effect=AssertionError("异步目录识别不应调用同步模块接口"))
    source_chain = Mock()
    source_chain.async_match_music_album = async_run_module
    source_chain.match_music_album = run_module
    monkeypatch.setattr("app.chain.media.MusicBrainzChain", Mock(return_value=source_chain))
    monkeypatch.setattr(
        "app.chain.media.AudioMetadataHelper.read_many",
        lambda _files: _local_tracks(),
    )

    matched = asyncio.run(media_chain.async_recognize_music_album_directory(album_dir))

    assert [matched[str(file.resolve())].media_id for file in files] == [
        "rec-1",
        "rec-2",
        "rec-3",
    ]
    async_run_module.assert_awaited_once()
    assert async_run_module.await_args.args[0].album == "七里香"
    run_module.assert_not_called()


def test_async_recognize_album_directory_checks_path_in_threadpool(
        tmp_path,
        media_chain,
        monkeypatch,
):
    """异步专辑识别应把目录元数据检查移出事件循环。"""
    check_directory = AsyncMock(return_value=False)
    monkeypatch.setattr("app.chain.media.run_in_threadpool", check_directory)

    result = asyncio.run(
        media_chain.async_recognize_music_album_directory(tmp_path / "missing")
    )

    assert result == {}
    check_directory.assert_awaited_once()
    assert check_directory.await_args.args[1] == tmp_path / "missing"


def test_async_album_fallback_propagates_cancellation_during_path_check(
        tmp_path,
        media_chain,
        monkeypatch,
):
    """异步专辑兜底不得吞掉文件检查被取消的信号。"""
    started = asyncio.Event()

    async def wait_for_check(*_args, **_kwargs):
        """模拟慢文件系统检查，直到调用方取消。"""
        started.set()
        await asyncio.Event().wait()

    monkeypatch.setattr("app.chain.media.run_in_threadpool", wait_for_check)

    async def exercise_cancellation():
        """在同一事件循环中取消正在等待文件检查的调用。"""
        task = asyncio.create_task(
            media_chain._async_music_album_dir_fallback(tmp_path / "track.flac")
        )
        await started.wait()
        task.cancel()
        with pytest.raises(asyncio.CancelledError):
            await task

    asyncio.run(exercise_cancellation())


def test_recognize_album_directory_skips_single_file(tmp_path, media_chain, monkeypatch):
    """单文件目录不走专辑匹配，交给单曲识别链路。"""
    album_dir = tmp_path / "单曲"
    album_dir.mkdir()
    (album_dir / "晴天.wav").write_bytes(b"RIFF")

    def fake_run_module(method, **kwargs):
        raise AssertionError("单文件目录不应触发专辑匹配")

    monkeypatch.setattr(media_chain, "_match_music_album_directory", fake_run_module)

    assert media_chain.recognize_music_album_directory(album_dir) == {}


def test_recognize_album_directory_invalidates_cache_after_same_count_rename(
        tmp_path,
        media_chain,
        monkeypatch,
):
    """目录内文件数量不变但文件名变化时，专辑曲目映射缓存必须失效。"""
    album_dir = tmp_path / "Album"
    album_dir.mkdir()
    first = album_dir / "01 - First.wav"
    second = album_dir / "02 - Second.wav"
    first.write_bytes(b"RIFF")
    second.write_bytes(b"RIFF")
    calls = []

    def fake_match(_dir_path, files):
        """记录目录匹配输入，返回空映射以专注验证缓存签名。"""
        calls.append([file.name for file in files])
        return {}

    monkeypatch.setattr(media_chain, "_match_music_album_directory", fake_match)

    media_chain.recognize_music_album_directory(album_dir)
    first.rename(album_dir / "01 - Renamed.wav")
    media_chain.recognize_music_album_directory(album_dir)

    assert calls == [
        ["01 - First.wav", "02 - Second.wav"],
        ["01 - Renamed.wav", "02 - Second.wav"],
    ]


def test_recognize_music_by_path_falls_back_to_album_match(tmp_path, monkeypatch):
    """单曲识别无远端身份时应用目录级匹配结果兜底。"""
    album_dir = tmp_path / "周杰伦 - 七里香 (2004)"
    album_dir.mkdir()
    file = album_dir / "01.我的地盘.wav"
    file.write_bytes(b"RIFF")

    matched_info = MusicInfo(
        media_source="musicbrainz",
        media_id="rec-1",
        title="我的地盘",
        artists=["周杰伦"],
        album="七里香",
        track_number=1,
    )
    # recognize_music_by_path 已下沉至 MediaChain 统一识别入口
    chain = MediaChain()
    monkeypatch.setattr(chain, "recognize_media", lambda **kwargs: None)
    monkeypatch.setattr(
        chain,
        "_music_album_dir_fallback",
        lambda path: matched_info,
    )

    meta, info = chain.recognize_music_by_path(file)

    assert info.media_id == "rec-1"
    assert info.title == "我的地盘"
    assert info.album == "七里香"
    # 本地音频参数应保留在识别结果中
    assert meta.audio_format == "WAV"


def test_async_music_album_fallback_calls_async_directory_match(tmp_path, monkeypatch):
    """异步路径回退应等待目录异步识别，不得调用同步目录识别。"""
    album_dir = tmp_path / "七里香"
    album_dir.mkdir()
    file = album_dir / "01.我的地盘.wav"
    file.write_bytes(b"RIFF")
    matched_info = MusicInfo(
        media_source="musicbrainz",
        media_id="rec-1",
        title="我的地盘",
    )
    async_recognize = AsyncMock(
        return_value={str(file.resolve()): matched_info}
    )
    sync_recognize = Mock(side_effect=AssertionError("异步回退不应调用同步目录识别"))
    monkeypatch.setattr(
        MediaChain,
        "async_recognize_music_album_directory",
        async_recognize,
    )
    monkeypatch.setattr(
        MediaChain,
        "recognize_music_album_directory",
        sync_recognize,
    )

    result = asyncio.run(MediaChain()._async_music_album_dir_fallback(file))

    assert result is matched_info
    async_recognize.assert_awaited_once_with(album_dir)
    sync_recognize.assert_not_called()
