"""#1743 素材使用次数回写测试。

计数口径修复：
- mark_asset_used_for_generation 支持 times 参数，按成片实际片段引用次数累加
- _count_plan_clip_asset_usage 从最终成片 plan 的 clips 统计 {asset_id: 片段引用次数}
- _record_rendered_asset_usage 回写 metadata：plan clips 为准、空 plan 兜底任务 asset_ids、
  未被 plan 选用的素材不计数、单素材失败不影响其他素材
"""

from __future__ import annotations

import sys
from pathlib import Path
from unittest.mock import MagicMock

REPO_ROOT = Path(__file__).resolve().parents[2]
for sub in ("apps/worker", "apps/api", "packages", ""):
    p = str(REPO_ROOT / sub) if sub else str(REPO_ROOT)
    if p not in sys.path:
        sys.path.insert(0, p)

import worker_app.tasks.generation as gen_mod  # noqa: E402
from worker_app.core.asset_usage import mark_asset_used_for_generation  # noqa: E402


class FakeAsset:
    def __init__(self, aid: str, metadata: dict | None = None):
        self.id = aid
        self.metadata = metadata or {}


# ── mark_asset_used_for_generation ──────────────────────────────────────────


class TestMarkAssetUsed:
    def test_default_times_is_one(self):
        a = FakeAsset("a1", metadata={})
        mark_asset_used_for_generation(a)
        assert a.metadata["generation_use_count"] == 1
        assert "last_used_at" in a.metadata

    def test_times_accumulates_by_clip_count(self):
        """同一素材被 3 个片段引用 → 一次回写 +3。"""
        a = FakeAsset("a1", metadata={"generation_use_count": 2})
        mark_asset_used_for_generation(a, times=3)
        assert a.metadata["generation_use_count"] == 5

    def test_times_zero_or_negative_floored_to_one(self):
        a = FakeAsset("a1", metadata={})
        mark_asset_used_for_generation(a, times=0)
        assert a.metadata["generation_use_count"] == 1

    def test_preserves_existing_metadata(self):
        a = FakeAsset("a1", metadata={"tags": ["travel"], "generation_use_count": 4})
        mark_asset_used_for_generation(a, times=2)
        assert a.metadata["tags"] == ["travel"]
        assert a.metadata["generation_use_count"] == 6


# ── _count_plan_clip_asset_usage ────────────────────────────────────────────


def _mock_session_with_clips(clip_asset_ids: list[str]):
    """构造 mock session：query(EditPlanClipModel).filter().all() 返回片段 asset_id 行。"""
    rows = [(aid,) for aid in clip_asset_ids]
    session = MagicMock()
    session.query.return_value.filter.return_value.all.return_value = rows
    return session


class TestCountPlanClipUsage:
    def test_counts_asset_occurrences_across_clips(self):
        """plan clips 中 asset-x 出现 2 次、asset-y 1 次 → {x:2, y:1}。"""
        session = _mock_session_with_clips(["asset-x", "asset-y", "asset-x", ""])
        with _patch_clip_model():
            counts = gen_mod._count_plan_clip_asset_usage(session, "plan-1")
        assert counts == {"asset-x": 2, "asset-y": 1}

    def test_empty_clips_returns_empty(self):
        session = _mock_session_with_clips([])
        with _patch_clip_model():
            counts = gen_mod._count_plan_clip_asset_usage(session, "plan-9")
        assert counts == {}

    def test_blank_asset_ids_skipped(self):
        session = _mock_session_with_clips(["", None, "asset-z"])
        with _patch_clip_model():
            counts = gen_mod._count_plan_clip_asset_usage(session, "plan-1")
        assert counts == {"asset-z": 1}


class _patch_clip_model:
    """patch generation 模块内 EditPlanClipModel 的导入路径（函数内 import）。"""

    def __enter__(self):
        import unittest.mock as mock

        self._patches = [
            mock.patch("packages.adapters.sqlalchemy_impl.models.EditPlanClipModel", MagicMock()),
        ]
        for p in self._patches:
            p.start()
        return self

    def __exit__(self, *exc):
        for p in self._patches:
            p.stop()
        return False


# ── _record_rendered_asset_usage ────────────────────────────────────────────


class TestRecordRenderedAssetUsage:
    def _run(self, clip_ids, fallback_ids=None, repo_get_override=None, repo_update_side=None):
        session = _mock_session_with_clips(clip_ids)
        assets: dict[str, FakeAsset] = {}

        def fake_repo_init(sess):
            repo = MagicMock()

            def fake_get(aid):
                if repo_get_override and aid in repo_get_override:
                    return repo_get_override[aid]
                return assets.setdefault(aid, FakeAsset(aid, metadata={}))

            repo.get.side_effect = fake_get
            if repo_update_side:
                repo.update.side_effect = repo_update_side
            else:
                repo.update.side_effect = lambda a: a
            return repo

        import unittest.mock as mock

        with (
            mock.patch(
                "packages.adapters.sqlalchemy_impl.asset_repository.SQLAlchemyAssetRepository",
                side_effect=fake_repo_init,
            ),
            _patch_clip_model(),
        ):
            written = gen_mod._record_rendered_asset_usage(session, "plan-1", "task-1", fallback_asset_ids=fallback_ids)
        return written, assets

    def test_writes_by_plan_clips_not_request_asset_ids(self):
        """核心口径：plan clips 用 x×2 + y×1；请求里的 z（未被 plan 选用）不计数。"""
        written, assets = self._run(
            clip_ids=["asset-x", "asset-x", "asset-y"],
            fallback_ids=["asset-x", "asset-y", "asset-z"],
        )
        assert written == 2
        assert assets["asset-x"].metadata["generation_use_count"] == 2
        assert assets["asset-y"].metadata["generation_use_count"] == 1
        assert "asset-z" not in assets, "未被 plan 选用的素材不应被计数"

    def test_fallback_to_task_asset_ids_when_plan_empty(self):
        """plan 无有效片段（异常数据）→ 兜底任务 asset_ids 每个计 1 次。"""
        written, assets = self._run(clip_ids=[""], fallback_ids=["asset-a", "asset-b"])
        assert written == 2
        assert assets["asset-a"].metadata["generation_use_count"] == 1
        assert assets["asset-b"].metadata["generation_use_count"] == 1

    def test_no_clips_no_fallback_returns_zero(self):
        written, assets = self._run(clip_ids=[], fallback_ids=[])
        assert written == 0
        assert assets == {}

    def test_single_asset_failure_does_not_block_others(self):
        """单素材 update 抛异常不影响其他素材回写。"""

        def update_side(a):
            if a.id == "bad":
                raise RuntimeError("DB boom")
            return a

        written, assets = self._run(
            clip_ids=["good1", "bad", "good2"],
            fallback_ids=None,
            repo_update_side=update_side,
        )
        assert written == 2
        assert assets["good1"].metadata["generation_use_count"] == 1
        assert assets["good2"].metadata["generation_use_count"] == 1

    def test_missing_asset_skipped(self):
        """repo.get 返回 None 的素材跳过，不报错。"""
        written, assets = self._run(
            clip_ids=["ghost", "real"],
            fallback_ids=None,
            repo_get_override={"ghost": None},
        )
        assert written == 1
        assert assets["real"].metadata["generation_use_count"] == 1
