"""Issue #1714：HEVC 转码后禁止兜底新建重复 READY 记录，必须回写占位 asset。

覆盖：
- 转码成功 + 占位 asset 存在（按原始 key 找到）→ 更新占位为 READY、
  storage_key 改写为 *_h264，绝不 create 新记录（回归 P1 孤儿 PROCESSING bug）
- job.asset_id 透传时优先按 id 关联占位（即使 key 对不上也能命中）
- 无占位记录（旧链路）→ 兜底新建（保留兼容）
- 非 HEVC：占位同样被更新为 READY，不新建
- 无效媒体：占位标记为 ERROR，不新建 ERROR 记录
- ingest 异常：占位（按还原后的原始 key）标记 ERROR
"""

from __future__ import annotations

import sys
import tempfile
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import MagicMock, patch

# ── 与 test_ingest_hevc_transcode_task.py 相同的 worker 模块加载方式 ──
_SAVED_MODULES_KEYS = set(sys.modules.keys())

_mock_db_module = MagicMock()
_mock_db_module.SessionLocal = MagicMock()
sys.modules["worker_app.db"] = _mock_db_module
sys.modules["worker_app.core.config"] = MagicMock()

_mock_celery_module = MagicMock()


def _passthrough_decorator(*args, **kwargs):
    if len(args) == 1 and callable(args[0]):
        return args[0]
    return lambda f: f


_mock_celery_module.celery_app.task = MagicMock(side_effect=_passthrough_decorator)
sys.modules["worker_app.celery_app"] = _mock_celery_module

sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "apps" / "worker"))

import pytest  # noqa: E402
from worker_app.tasks import ingest as ingest_mod  # noqa: E402

from packages.domain import Asset, AssetStatus  # noqa: E402

for _key in list(sys.modules.keys()):
    if _key not in _SAVED_MODULES_KEYS and not _key.startswith("video_processing"):
        del sys.modules[_key]
del _SAVED_MODULES_KEYS


# ── 假仓储 ──────────────────────────────────────────────────────────────
class _FakeJobRepo:
    def __init__(self, job):
        self.job = job
        self.updated = None

    def get(self, job_id):
        return self.job

    def update(self, job):
        self.updated = job
        return job


class _FakeAssetRepo:
    """记录 create 调用；find_* 按内部 assets 列表查询。"""

    def __init__(self, assets: list[Asset] | None = None):
        self.assets = list(assets or [])
        self.created: list[Asset] = []
        self.updated: list[Asset] = []

    def create(self, asset: Asset) -> Asset:
        self.created.append(asset)
        self.assets.append(asset)
        return asset

    def update(self, asset: Asset) -> Asset:
        self.updated.append(asset)
        return asset

    def find_by_id(self, asset_id: str) -> Asset | None:
        return next((a for a in self.assets if a.id == asset_id), None)

    def find_by_storage_key(self, storage_key: str) -> Asset | None:
        return next((a for a in self.assets if a.storage_key == storage_key), None)


def _make_job(asset_id: str = "", storage_key: str = "uploads/proj/IMG_2282.MOV"):
    return SimpleNamespace(
        id="job-1",
        project_id="proj-1",
        library_id="lib-1",
        storage_key=storage_key,
        file_hash="hash-1",
        asset_id=asset_id,
        status=None,
        error_message=None,
        result_asset_id=None,
        updated_at=None,
    )


def _make_placeholder(storage_key: str = "uploads/proj/IMG_2282.MOV", asset_id: str = "asset-ph"):
    return Asset(
        id=asset_id,
        project_id="proj-1",
        library_id="lib-1",
        name="IMG_2282.MOV",
        storage_key=storage_key,
        mime_type="video/quicktime",
        status=AssetStatus.PROCESSING,
        file_hash="hash-1",
    )


def _video_metadata(codec="hevc"):
    return {
        "codec": codec,
        "width": 1920,
        "height": 1080,
        "duration": 10.0,
        "size_bytes": 5 * 1024 * 1024,
    }


@pytest.fixture
def transcode_env(tmp_path):
    """HEVC 转码成功的标准 mock 环境（同 test_ingest_hevc_transcode_task）。"""
    local_file = tmp_path / "local_hevc.MOV"
    local_file.write_bytes(b"fake-hevc-source")
    tc_out = tmp_path / "transcode_out_h264.mp4"

    control = {
        "validate_ok": True,
        "tc_out": tc_out,
        "local_file": local_file,
        "download_ok": True,
        "extract_success": True,
        "codec": "hevc",
        "raise_in_flow": None,
    }

    def fake_ntf(*args, **kwargs):
        mock_file = MagicMock()
        mock_file.name = str(tc_out) if kwargs.get("suffix") == "_h264.mp4" else str(local_file)
        mock_file.close = MagicMock()
        mock_file.__enter__.return_value = mock_file
        mock_file.__exit__.return_value = False
        return mock_file

    def fake_subprocess_run(cmd, **kwargs):
        if cmd and cmd[0] == "ffmpeg" and "libx264" in cmd:
            Path(cmd[-1]).write_bytes(b"fake-h264-output")
            return SimpleNamespace(returncode=0, stderr="")
        return SimpleNamespace(returncode=0, stdout="", stderr="")

    control["patchers"] = {
        "session": patch.object(ingest_mod, "SessionLocal", return_value=MagicMock()),
        "download": patch.object(ingest_mod, "download_asset", side_effect=lambda *a, **kw: control["download_ok"]),
        "upload": patch("video_processing.oss_helpers.upload_to_oss", return_value="https://oss/x"),
        "metadata": patch.object(
            ingest_mod,
            "extract_media_metadata",
            side_effect=lambda path, mt: (
                (_video_metadata("h264"), control["extract_success"])
                if Path(path).name == tc_out.name
                else (_video_metadata(control["codec"]), control["extract_success"])
            ),
        ),
        "validate": patch.object(
            ingest_mod, "validate_transcode_output", side_effect=lambda p, portrait: control["validate_ok"]
        ),
        "subprocess": patch.object(ingest_mod.subprocess, "run", side_effect=fake_subprocess_run),
        "ntf": patch.object(tempfile, "NamedTemporaryFile", side_effect=fake_ntf),
        "thumb": patch(
            "video_processing.thumbnail_generator.extract_first_frame",
            side_effect=RuntimeError("skip thumb"),
        ),
    }
    return control


def _start(control, job, assets):
    job_repo = _FakeJobRepo(job)
    asset_repo = _FakeAssetRepo(assets)
    patchers = dict(control["patchers"])
    patchers["job_repo"] = patch.object(ingest_mod, "SQLAlchemyIngestJobRepository", return_value=job_repo)
    patchers["asset_repo"] = patch.object(ingest_mod, "SQLAlchemyAssetRepository", return_value=asset_repo)
    started = {name: p.start() for name, p in patchers.items()}
    return started, job_repo, asset_repo


def _stop(control):
    for p in control["patchers"].values():
        p.stop()


class TestHEVCTranscodePlaceholderRewrite:
    def test_transcode_success_updates_placeholder_no_duplicate_ready(self, transcode_env):
        """转码成功 → 占位 asset 原地更新为 READY + storage_key 改写 _h264，禁止新建。"""
        control = transcode_env
        placeholder = _make_placeholder()
        job = _make_job()  # 旧 job 无 asset_id，靠原始 key 关联
        mocks, job_repo, asset_repo = _start(control, job, [placeholder])
        try:
            result = ingest_mod.ingest_asset("job-1")
        finally:
            _stop(control)

        assert result["status"] == "completed"
        # 核心断言 1：没有新建任何 READY 记录（旧 bug 会 create 一条 _h264 READY）
        assert asset_repo.created == [], "转码回写不得新建 asset 记录"
        # 核心断言 2：占位被更新为 READY，且 storage_key 已是 _h264
        assert len(asset_repo.updated) == 1
        updated = asset_repo.updated[0]
        assert updated.id == placeholder.id
        assert updated.status == AssetStatus.READY
        assert updated.storage_key == "uploads/proj/IMG_2282_h264.MOV"
        assert updated.metadata.get("hevc_transcoded") is True
        assert updated.metadata.get("original_storage_key") == "uploads/proj/IMG_2282.MOV"
        # job 关联到同一条 asset
        assert job_repo.updated.result_asset_id == placeholder.id
        assert job_repo.updated.storage_key == "uploads/proj/IMG_2282_h264.MOV"

    def test_placeholder_resolved_by_job_asset_id(self, transcode_env):
        """job.asset_id 透传时优先按 id 关联（即使 storage_key 对不上也命中）。"""
        control = transcode_env
        placeholder = _make_placeholder(storage_key="uploads/different/key.MOV", asset_id="asset-by-id")
        job = _make_job(asset_id="asset-by-id")
        _, _, asset_repo = _start(control, job, [placeholder])
        try:
            result = ingest_mod.ingest_asset("job-1")
        finally:
            _stop(control)

        assert result["status"] == "completed"
        assert asset_repo.created == []
        assert len(asset_repo.updated) == 1
        assert asset_repo.updated[0].id == "asset-by-id"
        assert asset_repo.updated[0].status == AssetStatus.READY

    def test_no_placeholder_fallback_creates_ready(self, transcode_env):
        """旧链路无占位记录 → 兜底新建 READY（兼容保留，但必须是唯一一条）。"""
        control = transcode_env
        job = _make_job(asset_id="")
        _, _, asset_repo = _start(control, job, [])
        try:
            result = ingest_mod.ingest_asset("job-1")
        finally:
            _stop(control)

        assert result["status"] == "completed"
        assert len(asset_repo.created) == 1
        created = asset_repo.created[0]
        assert created.status == AssetStatus.READY
        assert created.storage_key == "uploads/proj/IMG_2282_h264.MOV"
        assert asset_repo.updated == []

    def test_non_hevc_placeholder_updated_no_create(self, transcode_env):
        """非 HEVC（h264）不转码：占位按原始 key 找到并更新 READY，不新建。"""
        control = transcode_env
        control["codec"] = "h264"
        placeholder = _make_placeholder()
        job = _make_job()
        mocks, _, asset_repo = _start(control, job, [placeholder])
        try:
            result = ingest_mod.ingest_asset("job-1")
        finally:
            _stop(control)

        assert result["status"] == "completed"
        assert asset_repo.created == []
        assert len(asset_repo.updated) == 1
        updated = asset_repo.updated[0]
        assert updated.status == AssetStatus.READY
        assert updated.storage_key == "uploads/proj/IMG_2282.MOV"  # 未转码，key 不变
        mocks["upload"].assert_not_called()

    def test_invalid_media_marks_placeholder_error_no_create(self, transcode_env):
        """无效媒体：占位标记 ERROR 并 update，禁止再 create 一条 ERROR。"""
        control = transcode_env
        control["download_ok"] = False  # 下载失败 → extract_success=False → 无效媒体路径
        placeholder = _make_placeholder()
        job = _make_job()
        _, job_repo, asset_repo = _start(control, job, [placeholder])
        try:
            result = ingest_mod.ingest_asset("job-1")
        finally:
            _stop(control)

        assert result["status"] == "failed"
        assert asset_repo.created == [], "无效媒体不得新建 ERROR 记录"
        assert len(asset_repo.updated) == 1
        assert asset_repo.updated[0].id == placeholder.id
        assert asset_repo.updated[0].status == AssetStatus.ERROR
        assert job_repo.updated.result_asset_id == placeholder.id

    def test_exception_path_marks_placeholder_error(self, transcode_env):
        """ingest 主流程抛异常（如元数据提取炸了）→ 占位按原始 key 找到并标 ERROR。"""
        control = transcode_env
        placeholder = _make_placeholder()
        job = _make_job()
        started, _, asset_repo = _start(control, job, [placeholder])
        started["metadata"].side_effect = RuntimeError("boom in flow")
        try:
            result = ingest_mod.ingest_asset("job-1")
        finally:
            _stop(control)

        assert result["status"] == "failed"
        # 异常路径把占位标 ERROR（旧实现用被改写的 _h264 key 回查会落空）
        error_marked = [a for a in asset_repo.assets if a.id == placeholder.id and a.status == AssetStatus.ERROR]
        assert error_marked, "异常路径必须把占位 asset 标为 ERROR"
