"""
订阅的写入路径：app/application/subscription/write.py 的 add_subscribe / async_add_subscribe。

这两个函数是订阅表的唯一写入口，把 MediaInfo / MusicInfo 翻译成一行订阅：
标题、年份、类型、海报背景、评分简介、剧集组、音乐实体与曲目数，再叠上
持久化类型强转（布尔开关转整型、年份转字符串）。字段映射错了不会报错，
只会让订阅静静地记错——而搜索、洗版、完成判定、去重全都读这张表。

因此这里断言的是「落库后每个字段的实际值」，不是「调用了什么」：
同目录的 test_subscribe_oper.py 用替身钉的是查重语义（谁被查、查几次），
证明不了写进去的到底是什么。两者互补，缺一不可。

唯一的例外是**持久化类型强转**（年份转字符串、布尔开关转整型）。这两步是
为 PostgreSQL 的严格类型检查而存在的，而测试库是 SQLite——SQLite 的类型
亲和会在写入时把 ``2003`` 悄悄转成 ``'2003'``、把 ``True`` 转成 ``1``，
落库后的值对「写入路径自己有没有转」完全无感（已用变异验证确认：删掉
``_normalize_year`` 后按落库值断言的用例全部照过）。所以这两类契约必须在
建模那一刻、即 ``Subscribe(**kwargs)`` 的入参上断言，见 ``payloads`` 夹具。
强转跟着订阅表的列走，因此仍留在 ``app/db/oper/subscribe.py``，夹具也就
仍然钉在那个模块的 ``Subscribe`` 上。

同步与异步两条链路是两份逐字复制的实现，任何一条改了另一条没跟上都属于
真实缺陷，故每个字段契约都在两条链路上各断言一次。
"""
import asyncio

import pytest

from app.application.subscription.write import add_subscribe, async_add_subscribe
from app.db.models.subscribe import Subscribe
from app.db.oper.subscribe import SubscribeOper
from app.domain.context import MediaInfo, MusicInfo
from app.schemas.types import MediaSource, MediaType


@pytest.fixture(autouse=True)
def _track(db):
    """把订阅表纳入用例级回收。"""
    db.watermark(Subscribe)


def _media_id(tag: str) -> str:
    """给每个用例分配独立媒体 ID，避免共用测试库时互相去重。"""
    return f"wp-{tag}"


def _mediainfo(media_id: str, title: str = "识别标题",
               mtype: MediaType = MediaType.TV, year: str = "2026",
               episode_group: str = None) -> MediaInfo:
    """构造带完整展示字段的识别结果。"""
    media = MediaInfo()
    media.type = mtype
    media.title = title
    media.year = year
    media.media_source = MediaSource.TMDB
    media.media_id = media_id
    media.episode_group = episode_group
    media.vote_average = 8.5
    media.overview = "测试简介"
    media.poster_path = "https://image.tmdb.org/t/p/original/poster.jpg"
    media.backdrop_path = "https://image.tmdb.org/t/p/original/backdrop.jpg"
    return media


def _musicinfo(media_id: str, music_type: str, **kwargs) -> MusicInfo:
    """构造音乐订阅所需的标准音乐信息。"""
    return MusicInfo(media_source=MediaSource.MusicBrainz, media_id=media_id,
                     music_type=music_type, **kwargs)


def _add(oper: SubscribeOper, is_async: bool, **kwargs):
    """按链路分派到同步或异步新增，让同一份字段契约跑两遍。"""
    if is_async:
        return asyncio.run(async_add_subscribe(subscribe_oper=oper, **kwargs))
    return add_subscribe(subscribe_oper=oper, **kwargs)


def _row(db, subscribe_id: int) -> Subscribe:
    """按主键读回落库的订阅行。"""
    db.session.expire_all()
    return Subscribe.get(db.session, subscribe_id)


class _SubscribeSpy:
    """记录建模入参并转交真实模型，保持写入路径仍然真的落库。"""

    def __init__(self, recorded: list):
        self._recorded = recorded

    def __call__(self, **kwargs):
        """截获 ``Subscribe(**kwargs)`` 的入参后构造真实模型实例。"""
        self._recorded.append(dict(kwargs))
        return Subscribe(**kwargs)

    def __getattr__(self, name):
        """查重用的类方法（exists / async_exists 等）原样透传给真实模型。"""
        return getattr(Subscribe, name)


@pytest.fixture
def payloads(monkeypatch):
    """
    捕获写入路径建模时的原始 kwargs，即落库**前**的值与类型。

    只用于持久化类型强转这一类契约：SQLite 的类型亲和会在写入时替写入路径
    把类型「修好」，落库后的值证明不了转换真的发生过；而这两步转换恰恰是为
    PostgreSQL 而写的，漏了只会在生产库上炸。
    """
    recorded: list = []
    monkeypatch.setattr("app.db.oper.subscribe.Subscribe", _SubscribeSpy(recorded))
    return recorded


# 每个字段契约都在同步与异步两条链路上跑一遍：两份实现是逐字复制的，
# 只测一条等于放任另一条漂移
_BOTH_PATHS = pytest.mark.parametrize(
    "is_async", [pytest.param(False, id="sync"), pytest.param(True, id="async")]
)


# --------------------------------------------------------------------------- #
# 展示字段的翻译
# --------------------------------------------------------------------------- #

@_BOTH_PATHS
def test_add_maps_every_display_field_onto_the_row(db, is_async):
    """
    识别结果的展示字段必须完整落库。

    标题、年份、类型、评分、简介都是订阅列表和通知的唯一数据来源，
    错一项用户就看到一条张冠李戴的订阅。
    """
    oper = SubscribeOper()
    media_id = _media_id(f"display-{is_async}")

    sid, message = _add(oper, is_async, mediainfo=_mediainfo(media_id), season=1)

    assert message == "新增订阅成功"
    row = _row(db, sid)
    assert row.name == "识别标题"
    assert row.year == "2026"
    assert row.type == MediaType.TV.value
    assert row.media_source == MediaSource.TMDB.value
    assert row.media_id == media_id
    assert row.season == 1
    assert row.vote == 8.5
    assert row.description == "测试简介"


@_BOTH_PATHS
def test_add_persists_poster_and_backdrop_from_media(db, is_async):
    """
    海报与背景取自识别结果的图片接口，而不是原始路径字段。

    接口会把 original 尺寸换成 w500，直接存 poster_path 会让列表页
    每张卡片都去拉原图。
    """
    oper = SubscribeOper()
    media_id = _media_id(f"image-{is_async}")

    sid, _ = _add(oper, is_async, mediainfo=_mediainfo(media_id), season=1)

    row = _row(db, sid)
    assert row.poster == "https://image.tmdb.org/t/p/w500/poster.jpg"
    assert row.backdrop == "https://image.tmdb.org/t/p/w500/backdrop.jpg"


@_BOTH_PATHS
def test_add_persists_episode_group(db, is_async):
    """
    剧集组必须来自识别结果并落库。

    订阅按剧集组去重、搜索也按剧集组匹配集数，丢了它主季与自定义组会互相顶替。
    """
    oper = SubscribeOper()
    media_id = _media_id(f"eg-{is_async}")

    sid, _ = _add(oper, is_async,
                  mediainfo=_mediainfo(media_id, episode_group="eg-1"), season=1)

    assert _row(db, sid).episode_group == "eg-1"


@_BOTH_PATHS
def test_add_stamps_creation_date(db, is_async):
    """
    新增时间由写入路径盖戳，调用方传入的值不作数。

    订阅列表默认按 date 排序、过期清理也读它，留空会让这条订阅永远排在最后。
    """
    oper = SubscribeOper()
    media_id = _media_id(f"date-{is_async}")

    sid, _ = _add(oper, is_async, mediainfo=_mediainfo(media_id), season=1,
                  date="1970-01-01 00:00:00")

    date = _row(db, sid).date
    assert date is not None
    assert date != "1970-01-01 00:00:00"
    # 形如 2026-08-14 12:34:56
    assert len(date) == 19 and date[4] == "-" and date[13] == ":"


# --------------------------------------------------------------------------- #
# 持久化类型强转
# --------------------------------------------------------------------------- #

@_BOTH_PATHS
def test_add_converts_boolean_flags_to_integers(db, payloads, is_async):
    """
    历史兼容的布尔开关建模前必须转成整型。

    PostgreSQL 的整型列拒收布尔值，不转会让新增订阅在 PG 上直接抛类型错误。
    断言落在建模入参上而非落库值：SQLite 会替我们把 True 存成 1，
    按落库值断言的话删掉转换也照样通过。
    """
    oper = SubscribeOper()
    media_id = _media_id(f"flags-{is_async}")

    _add(oper, is_async, mediainfo=_mediainfo(media_id), season=1,
         best_version=True, best_version_full=False, manual_total_episode=True)

    payload = payloads[-1]
    for field, expected in (("best_version", 1), ("best_version_full", 0),
                            ("manual_total_episode", 1)):
        assert payload[field] == expected
        assert type(payload[field]) is int, f"{field} 仍是 {type(payload[field])}"


@_BOTH_PATHS
@pytest.mark.parametrize(
    "supplied, expected",
    [
        pytest.param(True, 1, id="真"),
        pytest.param(False, 0, id="假"),
        pytest.param(None, 0, id="缺省"),
    ],
)
def test_add_normalizes_search_imdbid_to_zero_or_one(db, payloads, is_async,
                                                     supplied, expected):
    """
    search_imdbid 无论传什么都归一到整型 0/1。

    这一列参与搜索分支判定，存进 None 或 True 会让「是否用 imdbid 搜」
    在不同订阅上表现不一致，在 PG 上还会直接拒写。
    """
    oper = SubscribeOper()
    media_id = _media_id(f"imdb-{is_async}-{supplied}")

    _add(oper, is_async, mediainfo=_mediainfo(media_id), season=1,
         search_imdbid=supplied)

    payload = payloads[-1]
    assert payload["search_imdbid"] == expected
    assert type(payload["search_imdbid"]) is int


@_BOTH_PATHS
def test_add_converts_numeric_year_to_string(db, payloads, is_async):
    """
    音乐链路的年份是数字，而 year 列是字符串，建模前必须转换。

    不转在 PostgreSQL 上直接抛类型错误。同样只能在建模入参上验证：
    SQLite 的 TEXT 亲和会把整数 2003 自动存成 '2003'，读回来看不出差别。
    """
    oper = SubscribeOper()
    media_id = _media_id(f"year-{is_async}")

    sid, _ = _add(oper, is_async,
                  mediainfo=_musicinfo(media_id, "album", title="叶惠美", year=2003))

    assert payloads[-1]["year"] == "2003"
    assert type(payloads[-1]["year"]) is str
    # 落库值也要对得上，转换不能只发生在建模而在写入时被改回去
    assert _row(db, sid).year == "2003"


@_BOTH_PATHS
def test_add_keeps_missing_year_as_null(db, payloads, is_async):
    """
    年份缺失时留空，不能变成字符串 "None"。

    转字符串离无脑 str() 只有一步之遥，写成 "None" 后年份筛选会命中一个
    不存在的年份，而这条订阅从此在按年份筛选的界面里凭空消失。
    """
    oper = SubscribeOper()
    media_id = _media_id(f"noyear-{is_async}")

    sid, _ = _add(oper, is_async,
                  mediainfo=_musicinfo(media_id, "album", title="无年份专辑"))

    assert payloads[-1]["year"] is None
    assert _row(db, sid).year is None


# --------------------------------------------------------------------------- #
# 音乐字段
# --------------------------------------------------------------------------- #

@_BOTH_PATHS
def test_add_persists_album_entity_and_track_count(db, is_async):
    """
    专辑订阅要落实体类型和总曲目数。

    整专完成判定拿 total_tracks 当分母，缺了这条订阅永远判不到完成。
    """
    oper = SubscribeOper()
    media_id = _media_id(f"album-{is_async}")

    sid, _ = _add(oper, is_async,
                  mediainfo=_musicinfo(media_id, "album", title="叶惠美", total_tracks=11))

    row = _row(db, sid)
    assert row.type == MediaType.MUSIC.value
    assert row.music_type == "album"
    assert row.total_tracks == 11


@_BOTH_PATHS
def test_add_drops_track_count_for_single_recording(db, is_async):
    """
    单曲订阅只留实体类型，专辑曲目数必须丢弃。

    单曲带着专辑的 total_tracks 会让完成判定把一首歌当整专等，永远不完成。
    """
    oper = SubscribeOper()
    media_id = _media_id(f"recording-{is_async}")

    sid, _ = _add(oper, is_async,
                  mediainfo=_musicinfo(media_id, "recording", title="晴天", total_tracks=11))

    row = _row(db, sid)
    assert row.music_type == "recording"
    assert row.total_tracks is None


@_BOTH_PATHS
def test_add_clears_music_fields_for_non_music_media(db, is_async):
    """
    非音乐媒体的音乐字段一律置空，调用方传进来的也要被覆盖。

    影视订阅带上 music_type 会被音乐去重逻辑当成音乐实体，造成串号。
    """
    oper = SubscribeOper()
    media_id = _media_id(f"nonmusic-{is_async}")

    sid, _ = _add(oper, is_async, mediainfo=_mediainfo(media_id), season=1,
                  music_type="album", total_tracks=99)

    row = _row(db, sid)
    assert row.music_type is None
    assert row.total_tracks is None


@_BOTH_PATHS
def test_add_persists_music_cover_as_poster_and_backdrop(db, is_async):
    """
    音乐订阅的海报与背景都用发行封面。

    音乐没有独立背景图，留空会让订阅卡片在列表里显示成一块空白。
    """
    oper = SubscribeOper()
    media_id = _media_id(f"cover-{is_async}")
    cover = "https://coverartarchive.org/release-group/example/front-500"

    sid, _ = _add(oper, is_async,
                  mediainfo=_musicinfo(media_id, "album", title="封面专辑", cover_url=cover))

    row = _row(db, sid)
    assert row.poster == cover
    assert row.backdrop == cover


# --------------------------------------------------------------------------- #
# 调用方字段的保留
# --------------------------------------------------------------------------- #

@_BOTH_PATHS
def test_add_keeps_caller_supplied_subscription_settings(db, is_async):
    """
    调用方传入的订阅设置要原样保留，不被媒体翻译覆盖。

    写入路径只负责翻译媒体身份与展示字段；把用户填的保存路径、过滤词、
    总集数一并覆盖掉，等于用户每次新增订阅的设置都白填。
    """
    oper = SubscribeOper()
    media_id = _media_id(f"settings-{is_async}")

    sid, _ = _add(oper, is_async, mediainfo=_mediainfo(media_id), season=1,
                  username="alice", save_path="/media/tv", keyword="关键字",
                  include="内嵌", exclude="预告", total_episode=12,
                  start_episode=3, downloader="qbittorrent", state="R")

    row = _row(db, sid)
    assert row.username == "alice"
    assert row.save_path == "/media/tv"
    assert row.keyword == "关键字"
    assert (row.include, row.exclude) == ("内嵌", "预告")
    assert (row.total_episode, row.start_episode) == (12, 3)
    assert row.downloader == "qbittorrent"
    assert row.state == "R"


@_BOTH_PATHS
def test_add_overrides_caller_supplied_media_fields(db, is_async):
    """
    媒体身份与展示字段以识别结果为准，调用方传的同名值要被覆盖。

    否则上游一个陈旧的 name/year 就能让订阅记成另一部剧，而去重按身份走、
    发现不了这种错位。
    """
    oper = SubscribeOper()
    media_id = _media_id(f"override-{is_async}")

    sid, _ = _add(oper, is_async,
                  mediainfo=_mediainfo(media_id, title="正确标题", year="2026"),
                  season=1, name="错误标题", year="1999", type="电影")

    row = _row(db, sid)
    assert row.name == "正确标题"
    assert row.year == "2026"
    assert row.type == MediaType.TV.value
