"""日报结构化组装单元测试（_build_report_data，纯逻辑）。"""

from __future__ import annotations

import json
from datetime import date

import pytest

from scheduler.reporter import _build_report_data


def _fake_event(title: str, importance: int, event_type: str = "其他",
                sentiment: str = "neutral", source_id: str = "cls",
                url: str = "https://x.com/1") -> dict:
    return {
        "title": title,
        "url": url,
        "source_id": source_id,
        "event": {
            "stock_codes": [],
            "company_names": [],
            "industries": [],
            "sentiment": sentiment,
            "importance": importance,
            "event_type": event_type,
            "summary": f"{title}的摘要",
        },
    }


class TestBuildReportData:
    def test_sections_and_ranks(self) -> None:
        news = {
            "total": 2, "hi_threshold": 4,
            "high": [_fake_event("新闻A", 5), _fake_event("新闻B", 4)],
            "sentiments": {"neutral": 2}, "importances": {5: 1, 4: 1},
            "event_types": {"其他": 2},
        }
        cninfo = {
            "total": 1, "hi_threshold": 2,
            "high": [_fake_event("公告C", 3, event_type="公告")],
            "by_day": {"07月10日": 1}, "announcement": 1, "research": 0, "irm": 0,
        }
        pipeline = {"raw_total": 100, "proc": 90}
        xwlb = {"items": [_fake_event("联播D", 4, event_type="新闻联播", source_id="xwlb")],
                "date": "07月10日"}

        r = _build_report_data(news, cninfo, pipeline, "AI摘要", "20260710", xwlb=xwlb)

        assert r.report_date == date(2026, 7, 10)
        assert r.report_type == "finance"
        assert r.file_name == ""
        assert r.ai_summary == "AI摘要"
        assert [(e.section, e.rank) for e in r.events] == [
            ("news", 1), ("news", 2), ("cninfo", 1), ("xwlb", 1),
        ]
        assert r.events[0].source == "cls"
        assert r.events[3].source == "xwlb"

    def test_stats_snapshot(self) -> None:
        news = {"total": 1, "hi_threshold": 4, "high": [], "sentiments": {},
                "importances": {}, "event_types": {}}
        cninfo = {"total": 0, "hi_threshold": 0, "high": [], "by_day": {},
                  "announcement": 0, "research": 0, "irm": 0}
        r = _build_report_data(news, cninfo, {"raw_total": 100}, "s", "20260710")
        # stats 可 JSON 序列化（入库时 json.dumps）
        json.dumps(r.stats, ensure_ascii=False)
        assert r.stats["pipeline"] == {"raw_total": 100}
        assert r.stats["news"]["total"] == 1
        assert "xwlb" not in r.stats

    def test_title_truncated(self) -> None:
        news = {"total": 1, "hi_threshold": 4,
                "high": [_fake_event("长" * 600, 4)], "sentiments": {},
                "importances": {}, "event_types": {}}
        cninfo = {"total": 0, "hi_threshold": 0, "high": [], "by_day": {},
                  "announcement": 0, "research": 0, "irm": 0}
        r = _build_report_data(news, cninfo, {}, "s", "20260710")
        assert len(r.events[0].title) == 512

    def test_empty_events(self) -> None:
        news = {"total": 0, "hi_threshold": 0, "high": [], "sentiments": {},
                "importances": {}, "event_types": {}}
        cninfo = {"total": 0, "hi_threshold": 0, "high": [], "by_day": {},
                  "announcement": 0, "research": 0, "irm": 0}
        r = _build_report_data(news, cninfo, {}, None, "20260710")
        assert r.events == []
        assert r.ai_summary is None


class TestLlmCallRetry:
    """_llm_call 重试逻辑（纯逻辑，mock client）。"""

    @staticmethod
    def _fake_client(failures: int):
        """构造 mock client：前 failures 次抛 ConnectionError，之后成功。"""
        from types import SimpleNamespace

        n = {"count": 0}

        class Completions:
            def create(self, **kwargs):
                n["count"] += 1
                if n["count"] <= failures:
                    raise ConnectionError("transient")
                return SimpleNamespace(
                    choices=[SimpleNamespace(
                        message=SimpleNamespace(content="今日要点摘要"),
                        finish_reason="stop",
                    )]
                )

        return SimpleNamespace(chat=SimpleNamespace(completions=Completions())), n

    def test_success_first_try(self) -> None:
        from scheduler.reporter import _llm_call
        client, n = self._fake_client(0)
        out = _llm_call(client, "deepseek-v4-flash", "p")
        assert out == "今日要点摘要"
        assert n["count"] == 1

    def test_retry_then_success(self, monkeypatch) -> None:
        import scheduler.reporter as rep
        monkeypatch.setattr(rep, "_LLM_RETRY_TIMES", 3)
        monkeypatch.setattr(rep, "_LLM_RETRY_BACKOFF_SEC", 0.01)
        client, n = self._fake_client(2)  # 前 2 次失败，第 3 次成功
        out = rep._llm_call(client, "deepseek-v4-flash", "p")
        assert out == "今日要点摘要"
        assert n["count"] == 3

    def test_exhausts_retries_raises(self, monkeypatch) -> None:
        import scheduler.reporter as rep
        monkeypatch.setattr(rep, "_LLM_RETRY_TIMES", 2)
        monkeypatch.setattr(rep, "_LLM_RETRY_BACKOFF_SEC", 0.01)
        client, n = self._fake_client(99)  # 一直失败
        with pytest.raises(ConnectionError):
            rep._llm_call(client, "deepseek-v4-flash", "p")
        assert n["count"] == 2  # 重试 2 次后放弃


class TestCollectXwlb:
    """_collect_xwlb 取数逻辑：应查询日报前一日(已播出的联播)，并跳过内容提要。"""

    def test_queries_previous_day_and_skips_toc(self, monkeypatch) -> None:
        import json as _json
        import urllib.request

        captured: dict[str, str] = {}

        def fake_urlopen(req, timeout=15):  # noqa: ARG001
            captured["url"] = req.full_url

            class Resp:
                def __enter__(self):
                    return self

                def __exit__(self, *args):
                    return False

                def read(self):
                    return _json.dumps({"data": {"news": [
                        {"daily_sub_id": 1, "news_title": "内容提要", "news_days": "2026-08-04", "news_improve": "开场白"},
                        {"daily_sub_id": 2, "news_title": "联播要闻A", "news_days": "2026-08-04", "news_improve": "正文A"},
                    ]}}).encode("utf-8")

            return Resp()

        monkeypatch.setattr(urllib.request, "urlopen", fake_urlopen)
        from scheduler.reporter import _collect_xwlb

        result = _collect_xwlb("20260805")
        # 查询的是前一日(20260804)而非当日
        assert "start_date=20260804" in captured["url"]
        assert "end_date=20260804" in captured["url"]
        # 跳过第 1 条内容提要
        assert len(result["items"]) == 1
        assert result["items"][0]["title"] == "联播要闻A"
        assert result["source_date"] == "20260804"
        assert result["date"] == "08月04日"


class TestCollectNewsEventsLookback:
    """_collect_news_events 30 小时回溯逻辑。"""

    def test_filters_30h_and_excludes_cninfo(self, monkeypatch) -> None:
        from datetime import datetime, timedelta

        import scheduler.reporter as rep

        now = datetime.now().astimezone()

        def fake_load(day_str: str) -> list[dict]:  # noqa: ARG001
            def ev(title: str, hours_ago: float | None, source: str = "cls",
                   importance: int = 5, aware: bool = False) -> dict:
                pt = None
                if hours_ago is not None:
                    t = now - timedelta(hours=hours_ago)
                    pt = t.isoformat() if not aware else t.astimezone().isoformat()
                return {
                    "title": title, "url": "u", "source_id": source,
                    "publish_time": pt,
                    "event": {"importance": importance, "sentiment": "neutral",
                              "event_type": "其他", "summary": "s"},
                }

            return [
                ev("窗口内新闻", 10),
                ev("窗口内新闻带时区", 12, aware=True),
                ev("窗口外旧闻", 40),
                ev("无时间戳", None),
                ev("公告排除", 5, source="cninfo", importance=2),
            ]

        monkeypatch.setattr(rep, "_load_events_from_dir", fake_load)
        result = rep._collect_news_events("20260805")

        # 两个日期目录各返回 5 条(共 10): 旧闻×2、公告×2 被滤, 保留 6 条
        assert result["total"] == 6
        titles = {e["title"] for e in result["high"]}
        assert "窗口内新闻" in titles
        assert "窗口内新闻带时区" in titles
        assert "无时间戳" in titles
        assert "窗口外旧闻" not in titles
        assert "公告排除" not in titles
