"""
test_integration_seams.py — 汇合联调接缝验证（无 Docker，不依赖 Redis/DB）
验证三条核心接缝：
  1. AIE→BE 接缝：pipeline_steps / pipeline_io 数据流契约
  2. BE→FE 接缝：SSE 事件字段与 generationStore 期望字段对齐
  3. FE→BE 接缝：API 请求 DTO 与 BE 路由入参对齐

原则：纯 Python/结构断言，不联网，不起服务。
联调真实运行需 docker compose up + 真实 API Key。
"""
import sys, os
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))

import pytest
import json


# ═══════════════════════════════════════════════════════════════
# Seam 1 — AIE→BE: pipeline_steps 数据流
# ═══════════════════════════════════════════════════════════════

class TestAiePipelineStepsContract:
    """pipeline_steps.build_product_dict 产出符合 AI 引擎 dict 契约"""

    def _make_mock_product(self):
        """模拟 ORM Product 对象（duck typing）"""
        class P:
            name = "倍分子素颜霜"
            category = "美妆护肤"
            selling_points = '["轻薄不厚重","水润自然"]'
            style_tone = "素人分享风"
            text_angles = '["痛点切入","场景型"]'
            custom_prompt = ""
            image_path = None   # 前端图片上传完成前为空
        return P()

    def test_build_product_dict_keys(self):
        from app.workers.pipeline_steps import build_product_dict
        d = build_product_dict(self._make_mock_product())
        for key in ("name", "category", "selling_points", "style_tone",
                    "text_angles", "custom_prompt", "image_path"):
            assert key in d, f"build_product_dict 缺字段: {key}"

    def test_build_product_dict_lists_parsed(self):
        from app.workers.pipeline_steps import build_product_dict
        d = build_product_dict(self._make_mock_product())
        assert isinstance(d["selling_points"], list)
        assert isinstance(d["text_angles"], list)

    def test_build_product_dict_no_key_field(self):
        """绝不含任何 key/token 相关字段（基石B）"""
        from app.workers.pipeline_steps import build_product_dict
        d = build_product_dict(self._make_mock_product())
        for forbidden in ("api_key", "key", "token", "secret", "encrypted"):
            assert forbidden not in d, f"product_dict 不应含 {forbidden}"


# ═══════════════════════════════════════════════════════════════
# Seam 2 — BE→FE: SSE 事件字段对齐
# ═══════════════════════════════════════════════════════════════

class TestSseEventFieldAlignment:
    """BE 推的 SSE 事件字段与 FE useSse handleEvent 期望的字段一一对应"""

    # FE useSse.ts 中 handleEvent 使用的字段（从 FE 代码提取）
    EXPECTED_FIELDS = {
        "task_started":       {"total_text", "total_image"},
        "text_progress":      {"done", "total"},
        "text_candidate":     {"candidate_id", "angle_label", "content", "score"},
        "image_progress":     {"done", "total"},
        "image_candidate":    {"candidate_id", "strategy", "url", "role"},
        "batch_failed":       {"batch", "reason", "retryable"},
        "flywheel_injected":  {"recent_preference", "reject_reasons"},
    }

    # BE pipeline_io 实际推的事件字段（从代码提取）
    ACTUAL_FIELDS = {
        "task_started": {
            "task_id": 1, "total_text": 5, "total_image": 3,
        },
        "text_progress": {
            "done": 1, "total": 5,
        },
        "text_candidate": {
            "candidate_id": 1, "angle_label": "场景型",
            "content": "测试正文", "score": 88,
        },
        "image_progress": {
            "done": 1, "total": 3,
        },
        "image_candidate": {
            "candidate_id": 1, "strategy": "A", "url": "/uploads/ws/1/1/01_hook.jpg",
            "role": "hook",
        },
        "batch_failed": {
            "batch": "hook", "reason": "502", "retryable": True,
        },
        "flywheel_injected": {
            "recent_preference": "最近偏好角度：场景型",
            "reject_reasons": [],
        },
    }

    def test_all_fe_required_fields_present(self):
        """FE 期望的每个字段都在 BE 实际推的 data 中"""
        for event_type, required_fields in self.EXPECTED_FIELDS.items():
            actual = self.ACTUAL_FIELDS.get(event_type, {})
            for field in required_fields:
                assert field in actual, (
                    f"SSE 事件 [{event_type}] 缺字段 [{field}]，FE 会拿到 undefined"
                )

    def test_no_extra_pii_in_sse_events(self):
        """SSE 事件不含 key/token/password 等敏感字段"""
        for event_type, data in self.ACTUAL_FIELDS.items():
            for field in data:
                assert "key" not in field.lower() and "token" not in field.lower(), (
                    f"SSE 事件 [{event_type}] 含敏感字段 [{field}]"
                )


# ═══════════════════════════════════════════════════════════════
# Seam 3 — FE→BE: CreateTaskRequest DTO 对齐
# ═══════════════════════════════════════════════════════════════

class TestCreateTaskRequestContract:
    """FE tasks/new/page.tsx 发的请求体与 BE CreateTaskRequest 契约对齐（独立 DTO 验证，不依赖 motor）"""

    @pytest.fixture(autouse=True)
    def _stub_motor(self, monkeypatch):
        """database.py 顶层 import motor 在测试环境可能缺失，mock 掉避免 ModuleNotFoundError"""
        import sys
        import types
        if "motor" not in sys.modules:
            motor_mod = types.ModuleType("motor")
            motor_async = types.ModuleType("motor.motor_asyncio")
            motor_async.AsyncIOMotorClient = object
            sys.modules["motor"] = motor_mod
            sys.modules["motor.motor_asyncio"] = motor_async

    def _get_dto(self):
        from app.api.v1.tasks import CreateTaskRequest
        return CreateTaskRequest

    def test_create_task_required_fields(self):
        Req = self._get_dto()
        req = Req(product_id=1, theme="今日主题", text_count=8, image_count=5, track="ai")
        assert req.product_id == 1
        assert req.theme == "今日主题"
        assert req.track == "ai"

    def test_count_validation(self):
        import pydantic
        Req = self._get_dto()
        with pytest.raises(pydantic.ValidationError):
            Req(product_id=1, text_count=0, track="ai")    # 0 < 1
        with pytest.raises(pydantic.ValidationError):
            Req(product_id=1, text_count=21, track="ai")   # 21 > 20

    def test_track_validation(self):
        import pydantic
        Req = self._get_dto()
        with pytest.raises(pydantic.ValidationError):
            Req(product_id=1, track="auto")  # 非法 track

    def test_import_track_no_key_check(self):
        """轨B import 字段校验通过（业务层 key 检查在 service，不在 DTO）"""
        Req = self._get_dto()
        req = Req(product_id=1, track="import")
        assert req.track == "import"


# ═══════════════════════════════════════════════════════════════
# Seam 4 — SSE ticket 流程
# ═══════════════════════════════════════════════════════════════

class TestSseTicket:
    """ticket 签发/消费/防重放逻辑（用 mock redis）"""

    def _make_mock_redis(self):
        store: dict = {}

        class MockRedis:
            def setex(self, key, ttl, value):
                store[key] = value

            def getdel(self, key):
                return store.pop(key, None)

        return MockRedis(), store

    def test_issue_and_consume(self):
        from app.core.sse_ticket import issue_ticket, consume_ticket
        r, _ = self._make_mock_redis()
        ticket = issue_ticket(r, task_id=42, workspace_id=7)
        result = consume_ticket(r, ticket)
        assert result == (42, 7)

    def test_consume_once_only(self):
        from app.core.sse_ticket import issue_ticket, consume_ticket
        r, _ = self._make_mock_redis()
        ticket = issue_ticket(r, task_id=1, workspace_id=1)
        consume_ticket(r, ticket)          # 第一次消费
        result = consume_ticket(r, ticket) # 第二次 → None
        assert result is None

    def test_invalid_ticket_returns_none(self):
        from app.core.sse_ticket import consume_ticket
        r, _ = self._make_mock_redis()
        assert consume_ticket(r, "fake-ticket") is None

    def test_empty_ticket_returns_none(self):
        from app.core.sse_ticket import consume_ticket
        r, _ = self._make_mock_redis()
        assert consume_ticket(r, "") is None


# ═══════════════════════════════════════════════════════════════
# Seam 5 — SSE sse_utils 格式化
# ═══════════════════════════════════════════════════════════════

class TestSseFormat:
    """format_sse 输出符合 text/event-stream 规范，FE EventSource 能解析"""

    def test_format_contains_event_and_data(self):
        from app.utils.sse_utils import format_sse
        out = format_sse("text_candidate", {"candidate_id": 1, "score": 92}, seq=3)
        assert "event: text_candidate" in out
        assert "data:" in out
        assert "id: 3" in out

    def test_format_ends_with_double_newline(self):
        from app.utils.sse_utils import format_sse
        out = format_sse("task_done", {"status": "pending_selection"})
        assert out.endswith("\n\n")

    def test_data_is_valid_json(self):
        from app.utils.sse_utils import format_sse
        out = format_sse("heartbeat", {"ts": 1234567.0})
        data_line = [l for l in out.split("\n") if l.startswith("data:")][0]
        json.loads(data_line[len("data:"):].strip())  # 不应抛异常
