"""
test_api_batch_enrich.py - POST /api/batch-enrich 端點整合測試（SSE Streaming）

使用 FastAPI TestClient + mocker，mock web.routers.scraper.enrich_single（使用端）。
"""

import json
import pytest
from unittest.mock import patch, MagicMock


# ── helper ───────────────────────────────────────────────────────────────────

def parse_sse(text: str) -> list:
    """解析 SSE 文字，回傳事件 dict 列表"""
    events = []
    for line in text.splitlines():
        if line.startswith("data: "):
            events.append(json.loads(line[6:]))
    return events


def _ok_result(**kwargs):
    """建立成功的 EnrichResult mock 物件"""
    from core.enricher import EnrichResult
    defaults = dict(
        success=True,
        nfo_written=True,
        cover_written=True,
        extrafanart_written=0,
        fields_filled=[],
        source_used="javbus",
        error=None,
    )
    defaults.update(kwargs)
    return EnrichResult(**defaults)


def _err_result(error: str):
    """建立失敗的 EnrichResult mock 物件"""
    from core.enricher import EnrichResult
    return EnrichResult(
        success=False,
        nfo_written=False,
        cover_written=False,
        extrafanart_written=0,
        fields_filled=[],
        source_used="",
        error=error,
    )


# ── tests ────────────────────────────────────────────────────────────────────

class TestBatchEnrich:

    @pytest.fixture(autouse=True)
    def _stub_search_jav(self, mocker):
        """預設 mock 掉 search_jav。

        refresh_full 模式下 router 會 pre-fetch search_jav（scraper.py:338），
        未 mock 時會真的打外站、空等 timeout（單測曾各拖 7~17 秒）。這裡給個
        stub 預設值，杜絕整個 class 誤連外網；需要驗證 search 行為（call_count /
        負向 cache）的測試會自行 re-patch 覆蓋。enrich_single 在各測試已 mock，
        會忽略這個 cached data，所以回什麼 dict 不影響斷言。
        """
        mocker.patch(
            "web.routers.scraper.search_jav",
            return_value={"title": "stub", "source": "javbus"},
        )

    def test_batch_single_item_sse_events(self, client, mocker):
        """1 筆成功：progress + result-item(success=True) + done 事件順序正確"""
        mocker.patch(
            "web.routers.scraper.enrich_single",
            return_value=_ok_result(),
        )

        response = client.post("/api/batch-enrich", json={
            "items": [{"file_path": "/video/IPZ-154.mp4", "number": "IPZ-154"}],
            "mode": "refresh_full",
        })

        assert response.status_code == 200
        events = parse_sse(response.text)

        # 應有 3 個事件：progress, result-item, done
        assert len(events) == 3

        # 第一個事件：progress
        assert events[0]["type"] == "progress"
        assert events[0]["current"] == 1
        assert events[0]["total"] == 1
        assert events[0]["number"] == "IPZ-154"

        # 第二個事件：result-item，success=True
        assert events[1]["type"] == "result-item"
        assert events[1]["number"] == "IPZ-154"
        assert events[1]["file_path"] == "/video/IPZ-154.mp4"
        assert events[1]["success"] is True

        # 第三個事件：done
        assert events[2]["type"] == "done"
        assert events[2]["summary"]["total"] == 1
        assert events[2]["summary"]["success"] == 1
        assert events[2]["summary"]["failed"] == 0

    def test_batch_done_summary_counts(self, client, mocker):
        """2 筆，1 成功 1 失敗：done.summary.success/failed 正確計數"""
        mocker.patch(
            "web.routers.scraper.enrich_single",
            side_effect=[
                _ok_result(),
                _err_result("檔案不存在"),
            ],
        )

        response = client.post("/api/batch-enrich", json={
            "items": [
                {"file_path": "/video/IPZ-154.mp4", "number": "IPZ-154"},
                {"file_path": "/video/SONE-205.mp4", "number": "SONE-205"},
            ],
            "mode": "refresh_full",
        })

        assert response.status_code == 200
        events = parse_sse(response.text)

        # 取得 done 事件
        done_events = [e for e in events if e["type"] == "done"]
        assert len(done_events) == 1

        summary = done_events[0]["summary"]
        assert summary["total"] == 2
        assert summary["success"] == 1
        assert summary["failed"] == 1

    def test_batch_empty_items_returns_done(self, client, mocker):
        """空 items → 直接 done，summary 全 0"""
        mock_enrich = mocker.patch(
            "web.routers.scraper.enrich_single",
            return_value=_ok_result(),
        )

        response = client.post("/api/batch-enrich", json={
            "items": [],
            "mode": "refresh_full",
        })

        assert response.status_code == 200
        events = parse_sse(response.text)

        # 不呼叫 enrich_single
        mock_enrich.assert_not_called()

        # 只有 done 事件
        assert len(events) == 1
        assert events[0]["type"] == "done"
        assert events[0]["summary"]["total"] == 0
        assert events[0]["summary"]["success"] == 0
        assert events[0]["summary"]["failed"] == 0

    def test_batch_over_limit_returns_422(self, client, mocker):
        """21 筆 → HTTP 422"""
        mocker.patch(
            "web.routers.scraper.enrich_single",
            return_value=_ok_result(),
        )

        items = [
            {"file_path": f"/video/IPZ-{i:03d}.mp4", "number": f"IPZ-{i:03d}"}
            for i in range(21)
        ]

        response = client.post("/api/batch-enrich", json={
            "items": items,
            "mode": "refresh_full",
        })

        assert response.status_code == 422

    def test_batch_duplicate_path_deduped(self, client, mocker):
        """同 file_path 兩筆 → enrich_single 只呼叫 1 次"""
        mock_enrich = mocker.patch(
            "web.routers.scraper.enrich_single",
            return_value=_ok_result(),
        )

        response = client.post("/api/batch-enrich", json={
            "items": [
                {"file_path": "/video/IPZ-154.mp4", "number": "IPZ-154"},
                {"file_path": "/video/IPZ-154.mp4", "number": "IPZ-154"},  # 重複
            ],
            "mode": "refresh_full",
        })

        assert response.status_code == 200

        # enrich_single 只被呼叫 1 次
        assert mock_enrich.call_count == 1

        events = parse_sse(response.text)
        done_events = [e for e in events if e["type"] == "done"]
        # total 只計去重後的數量
        assert done_events[0]["summary"]["total"] == 1

    def test_batch_enrich_failure_continues(self, client, mocker):
        """第 1 筆 enrich_single 回傳 error → result-item success=False，第 2 筆仍正常處理"""
        mocker.patch(
            "web.routers.scraper.enrich_single",
            side_effect=[
                _err_result("找不到番號資料"),
                _ok_result(),
            ],
        )

        response = client.post("/api/batch-enrich", json={
            "items": [
                {"file_path": "/video/XXX-999.mp4", "number": "XXX-999"},
                {"file_path": "/video/SONE-205.mp4", "number": "SONE-205"},
            ],
            "mode": "refresh_full",
        })

        assert response.status_code == 200
        events = parse_sse(response.text)

        result_items = [e for e in events if e["type"] == "result-item"]
        assert len(result_items) == 2

        # 第 1 筆失敗
        assert result_items[0]["success"] is False
        assert result_items[0]["number"] == "XXX-999"

        # 第 2 筆成功
        assert result_items[1]["success"] is True
        assert result_items[1]["number"] == "SONE-205"

        # done summary 正確計數
        done_events = [e for e in events if e["type"] == "done"]
        assert done_events[0]["summary"]["success"] == 1
        assert done_events[0]["summary"]["failed"] == 1

    def test_batch_content_type_is_event_stream(self, client, mocker):
        """response.headers['content-type'] 含 'text/event-stream'"""
        mocker.patch(
            "web.routers.scraper.enrich_single",
            return_value=_ok_result(),
        )

        response = client.post("/api/batch-enrich", json={
            "items": [{"file_path": "/video/IPZ-154.mp4", "number": "IPZ-154"}],
            "mode": "refresh_full",
        })

        assert "text/event-stream" in response.headers.get("content-type", "")

    def test_batch_same_number_only_searches_once(self, client, mocker):
        """同番號不同 file_path → search_jav 只呼叫 1 次（scraper cache 命中）"""
        scraper_result = {"title": "テスト", "source": "javbus"}

        mock_search = mocker.patch(
            "web.routers.scraper.search_jav",
            return_value=scraper_result,
        )
        mocker.patch(
            "web.routers.scraper.enrich_single",
            return_value=_ok_result(),
        )

        response = client.post("/api/batch-enrich", json={
            "items": [
                {"file_path": "/video/IPZ-154a.mp4", "number": "IPZ-154"},
                {"file_path": "/video/IPZ-154b.mp4", "number": "IPZ-154"},
            ],
            "mode": "refresh_full",
        })

        assert response.status_code == 200
        # search_jav 只應被呼叫 1 次，第 2 筆命中 cache
        assert mock_search.call_count == 1

    def test_batch_fill_missing_does_not_prefetch(self, client, mocker):
        """fill_missing mode → search_jav 不被 router 層 pre-fetch，由 enrich_single 內部決定"""
        mock_search = mocker.patch(
            "web.routers.scraper.search_jav",
        )
        mocker.patch(
            "web.routers.scraper.enrich_single",
            return_value=_ok_result(),
        )

        response = client.post("/api/batch-enrich", json={
            "items": [{"file_path": "/video/IPZ-154.mp4", "number": "IPZ-154"}],
            "mode": "fill_missing",
        })

        assert response.status_code == 200
        # router 層不應呼叫 search_jav（fill_missing 由 enrich_single 內部處理）
        mock_search.assert_not_called()

    def test_batch_negative_cache_no_repeat_search(self, client, mocker):
        """search_jav 回 None → 負向 cache 生效，同番號第 2 筆不再打外站"""
        mock_search = mocker.patch(
            "web.routers.scraper.search_jav",
            return_value=None,  # 找不到資料
        )
        mocker.patch(
            "web.routers.scraper.enrich_single",
            return_value=_ok_result(success=False),
        )

        response = client.post("/api/batch-enrich", json={
            "items": [
                {"file_path": "/video/IPZ-154a.mp4", "number": "IPZ-154"},
                {"file_path": "/video/IPZ-154b.mp4", "number": "IPZ-154"},
            ],
            "mode": "refresh_full",
        })

        assert response.status_code == 200
        # search_jav 只應被呼叫 1 次，第 2 筆命中負向 cache
        assert mock_search.call_count == 1

    def test_batch_invalid_mode_returns_422(self, client, mocker):
        """mode='bad_mode' → HTTP 422"""
        mocker.patch(
            "web.routers.scraper.enrich_single",
            return_value=_ok_result(),
        )

        response = client.post("/api/batch-enrich", json={
            "items": [{"file_path": "/video/IPZ-154.mp4", "number": "IPZ-154"}],
            "mode": "bad_mode",
        })

        assert response.status_code == 422


class TestBatchEnrichThumbnailInvalidation:
    """feature/71 T8 邊界5 + PR #60 Codex P2：每筆成功 → invalidate 用 canonical key
    （輸入 URI 原值，非 double-encoded）；失敗不呼叫；去重後至多一次。

    file_path 為 DB file:/// URI（前端送 v.path）。端點走冪等 coerce_to_file_uri，不可
    再套 to_file_uri 造成 file:///file:/// double-encode 砍錯 hash → 舊縮圖殘留。舊測餵裸
    FS path + 斷言 to_file_uri(path, mappings)，是把 bug 行為當合約鎖死，已整套重寫。"""

    @pytest.fixture(autouse=True)
    def _stub(self, mocker):
        mocker.patch(
            "web.routers.scraper.search_jav",
            return_value={"title": "stub", "source": "javbus"},
        )
        mocker.patch(
            "web.routers.scraper.load_config",
            return_value={"gallery": {}, "search": {}},
        )

    def test_each_success_invalidates_canonical(self, client, mocker):
        """2 筆皆成功 → 各以 canonical key（輸入 URI 原值）invalidate 一次，非 double-encoded；
        與縮圖 generate 端 thumb_file_for 同 hash（砍到的正是生成的那張）。"""
        import core.thumbnail_cache as tc
        mocker.patch("web.routers.scraper.enrich_single", return_value=_ok_result())
        inval_spy = mocker.patch("web.routers.scraper.thumbnail_cache.invalidate")

        uri_a, uri_b = "file:///nas/a.mp4", "file:///nas/b.mp4"
        response = client.post("/api/batch-enrich", json={
            "items": [
                {"file_path": uri_a, "number": "IPZ-154"},
                {"file_path": uri_b, "number": "SONE-205"},
            ],
            "mode": "refresh_full",
        })

        assert response.status_code == 200
        called = [c.args[0] for c in inval_spy.call_args_list]
        assert uri_a in called and uri_b in called
        assert len(called) == 2
        assert all("file:///file:///" not in k for k in called), \
            f"invalidate key double-encoded: {called!r}"
        # canonical 一致性：invalidate 的 key hash == generate 端 thumb_file_for 的 hash
        assert tc.thumb_file_for(uri_a) in {tc.thumb_file_for(k) for k in called}

    def test_failed_item_not_invalidated(self, client, mocker):
        """1 成功 1 失敗 → 只有成功筆 invalidate（canonical key）。"""
        mocker.patch(
            "web.routers.scraper.enrich_single",
            side_effect=[_ok_result(), _err_result("找不到番號資料")],
        )
        inval_spy = mocker.patch("web.routers.scraper.thumbnail_cache.invalidate")

        response = client.post("/api/batch-enrich", json={
            "items": [
                {"file_path": "file:///nas/ok.mp4", "number": "IPZ-154"},
                {"file_path": "file:///nas/bad.mp4", "number": "XXX-999"},
            ],
            "mode": "refresh_full",
        })

        assert response.status_code == 200
        called = [c.args[0] for c in inval_spy.call_args_list]
        assert called == ["file:///nas/ok.mp4"]

    def test_duplicate_path_invalidated_once(self, client, mocker):
        """同 file_path 兩筆 → 去重後成功的唯一 file_path 至多 invalidate 一次（canonical key）。"""
        mocker.patch("web.routers.scraper.enrich_single", return_value=_ok_result())
        inval_spy = mocker.patch("web.routers.scraper.thumbnail_cache.invalidate")

        response = client.post("/api/batch-enrich", json={
            "items": [
                {"file_path": "file:///nas/dup.mp4", "number": "IPZ-154"},
                {"file_path": "file:///nas/dup.mp4", "number": "IPZ-154"},
            ],
            "mode": "refresh_full",
        })

        assert response.status_code == 200
        called = [c.args[0] for c in inval_spy.call_args_list]
        assert called == ["file:///nas/dup.mp4"]
