"""IT 故障诊断助手验收测试。

测试只验证路由、规划与控制流，**不**连接真实 Milvus / Tavily。
"""

from __future__ import annotations

from pathlib import Path

from app.config import Settings
from app.schemas import (
    Evidence,
    KnowledgeType,
    PlanStep,
    QualityGrade,
    QueryRequest,
    RetrievalPlan,
    RouteDecision,
    RouteName,
)
from app.service import AgenticRAGService
from app.sql_store import initialize_database


class FakeVectorStore:
    def __init__(
        self,
        return_evidence: bool = True,
        return_only_after_calls: int = 0,
        empty_first_call: bool = False,
    ) -> None:
        self.return_evidence = return_evidence
        self.return_only_after_calls = return_only_after_calls
        self.empty_first_call = empty_first_call
        self.calls = 0

    def search(
        self,
        query: str,
        top_k: int = 5,
        service_name: str = "",
        client_os: str = "",
        doc_type: str = "",
    ) -> list[Evidence]:
        self.calls += 1
        # 第一轮返回空，模拟"Milvus 首次未召回"，触发 rewrite。
        if self.empty_first_call and self.calls <= 1:
            return []
        if self.return_only_after_calls and self.calls <= self.return_only_after_calls:
            return []
        if not self.return_evidence:
            return []
        return [
            Evidence(
                source_type="milvus",
                source="vpn_troubleshooting.md",
                content=(
                    "Windows 客户端排查顺序：检查日志 → 验证证书 → "
                    "重建 Profile → 联系厂商。"
                ),
                score=0.91,
                knowledge_type=KnowledgeType.INTERNAL_DOC,
                metadata={
                    "doc_type": doc_type,
                    "service_name": service_name,
                    "category": client_os,
                },
            )
        ]


class FakeWebSearchProvider:
    def __init__(self, return_evidence: bool = True, fixture_text: str | None = None) -> None:
        self.return_evidence = return_evidence
        self.fixture_text = fixture_text
        self.calls = 0

    def search(self, query: str, max_results: int = 5) -> list[Evidence]:
        self.calls += 1
        if not self.return_evidence:
            return []
        text = self.fixture_text or (
            "【测试夹具】未发现与本次 SSL-VPN 认证超时对应的服务异常公告。"
        )
        return [
            Evidence(
                source_type="web",
                source="https://help.aliyun.com/zh/vpn/product-overview/notice/",
                content=text,
                score=0.95,
                knowledge_type=KnowledgeType.REALTIME,
                metadata={
                    "title": "阿里云 VPN 网关动态与公告",
                    "url": "https://help.aliyun.com/zh/vpn/product-overview/notice/",
                    "published_date": None,
                    "fixture": True,
                },
            )
        ]


def create_settings(sqlite_path: Path, max_rounds: int = 2) -> Settings:
    """构造测试用 Settings；不读 .env，避免污染。"""
    return Settings(
        _env_file=None,
        llm_provider="demo",
        sqlite_path=sqlite_path,
        max_retrieval_rounds=max_rounds,
        min_evidence_score=0.35,
        web_search_max_results=3,
    )


# -----------------------------------------------------------------------------
# 验收测试 1：过去 30 天同类故障统计结果为 3 次
# -----------------------------------------------------------------------------


def test_sql_30day_count(tmp_path) -> None:
    database = tmp_path / "incidents.db"
    initialize_database(database, reset=True)
    service = AgenticRAGService(
        create_settings(database),
        FakeVectorStore(return_evidence=False),
        FakeWebSearchProvider(return_evidence=False),
    )

    response = service.invoke(
        QueryRequest(
            query=(
                "统计 aliyun_ssl_vpn 最近 30 天 auth_timeout 的有效工单"
            ),
            debug=True,
        )
    )

    sql_call = next(
        item for item in response.executed_queries if item["tool"] == "sql_query"
    )
    assert sql_call["arguments"]["service_name"] == "aliyun_ssl_vpn"
    assert sql_call["arguments"]["error_type"] == "auth_timeout"
    assert sql_call["arguments"]["days"] == 30
    assert response.route.routes == [RouteName.SQL_QUERY]
    assert response.route.filters["service_name"] == "aliyun_ssl_vpn"
    # citations 至少包含一条 sql 证据，content 里出现"3 起"或 incident_count=3
    sql_evidence = next(item for item in response.citations if item.source_type == "sql")
    assert sql_evidence.metadata["incident_count"] == 3
    assert "INC1004" not in sql_evidence.metadata["incident_ids"]
    assert set(sql_evidence.metadata["incident_ids"]) == {
        "INC1001",
        "INC1002",
        "INC1003",
    }


# -----------------------------------------------------------------------------
# 验收测试 2：只问"过去 30 天发生几次"时，仅执行 SQLite 查询
# -----------------------------------------------------------------------------


def test_sql_only_route(tmp_path) -> None:
    database = tmp_path / "incidents.db"
    initialize_database(database, reset=True)
    service = AgenticRAGService(
        create_settings(database),
        FakeVectorStore(),
        FakeWebSearchProvider(),
    )

    response = service.invoke(
        QueryRequest(
            query="aliyun_ssl_vpn auth_timeout 最近 30 天发生了多少次？",
            debug=True,
        )
    )

    tools_called = {item["tool"] for item in response.executed_queries}
    assert tools_called == {"sql_query"}
    assert response.route.routes == [RouteName.SQL_QUERY]
    assert response.route.knowledge_types == [KnowledgeType.STRUCTURED]


# -----------------------------------------------------------------------------
# 验收测试 3：只问"认证超时怎么排查"时，仅执行 Milvus 检索
# -----------------------------------------------------------------------------


def test_milvus_only_route(tmp_path) -> None:
    database = tmp_path / "incidents.db"
    initialize_database(database, reset=True)
    service = AgenticRAGService(
        create_settings(database),
        FakeVectorStore(),
        FakeWebSearchProvider(),
    )

    response = service.invoke(
        QueryRequest(
            query="aliyun_ssl_vpn auth_timeout Windows 怎么排查？",
            debug=True,
        )
    )

    tools_called = {item["tool"] for item in response.executed_queries}
    assert tools_called == {"milvus_search"}
    assert response.route.routes == [RouteName.MILVUS_SEARCH]
    assert response.route.knowledge_types == [KnowledgeType.INTERNAL_DOC]
    # Milvus 步骤参数携带服务名与客户端 OS 过滤。
    milvus_call = next(
        item for item in response.executed_queries if item["tool"] == "milvus_search"
    )
    assert milvus_call["arguments"]["service_name"] == "aliyun_ssl_vpn"
    assert milvus_call["arguments"]["client_os"] == "Windows"
    assert milvus_call["arguments"]["doc_type"] == "troubleshooting_guide"


# -----------------------------------------------------------------------------
# 验收测试 4：厂商状态证据缺失时返回 partial，答案不能排除公共服务异常
# -----------------------------------------------------------------------------


def test_vendor_missing_returns_partial(tmp_path) -> None:
    database = tmp_path / "incidents.db"
    initialize_database(database, reset=True)
    service = AgenticRAGService(
        create_settings(database),
        FakeVectorStore(),
        # 关键：Web Search 返回空，模拟 Tavily 未返回官方证据。
        FakeWebSearchProvider(return_evidence=False),
    )

    response = service.invoke(
        QueryRequest(
            query=(
                "今天上午阿里云 SSL-VPN 频繁断开，Windows 客户端提示认证超时。"
                "请统计过去 30 天同类故障发生次数，结合内部 VPN 排障手册和厂商官网"
                "最新服务公告，判断更可能是本地配置问题还是公共服务异常，"
                "并给出排查步骤。"
            ),
            debug=True,
        )
    )

    assert RouteName.WEB_SEARCH in response.route.routes
    web_calls = [
        item for item in response.executed_queries if item["tool"] == "web_search"
    ]
    assert len(web_calls) >= 1
    assert all(item["status"] == "empty" for item in web_calls)
    # 答案不能排除公共服务异常——"仍待确认"与"未确认"语义一致,都是要求
    # 进一步验证公共状态;另接受"缺失""无法确认"等更口语化的措辞。
    assert response.termination_reason == "vendor_evidence_missing"
    assert "公共服务" in response.answer
    assert any(
        phrase in response.answer
        for phrase in ("未确认", "缺失", "无法确认", "仍待确认", "待确认")
    )


# -----------------------------------------------------------------------------
# 验收测试 5：Milvus 首次未召回 Windows 排障步骤时执行有限 Query Rewrite
# -----------------------------------------------------------------------------


def test_milvus_first_call_empty_triggers_rewrite(tmp_path) -> None:
    database = tmp_path / "incidents.db"
    initialize_database(database, reset=True)
    vector_store = FakeVectorStore(empty_first_call=True)
    service = AgenticRAGService(
        create_settings(database, max_rounds=2),
        vector_store,
        # 本测试只关心 Milvus 路径，关闭 Web 让 Milvus 缺失成为唯一原因。
        FakeWebSearchProvider(return_evidence=False),
    )

    response = service.invoke(
        QueryRequest(
            query="aliyun_ssl_vpn auth_timeout Windows 怎么排查？",
            debug=True,
        )
    )

    # Milvus 调用次数至少为 2：第一轮空 → rewrite → 第二轮命中。
    assert vector_store.calls >= 2
    assert response.termination_reason in {
        "evidence_accepted",
        "retrieval_budget_exhausted",
    }
    rewrite_nodes = [
        event for event in response.trace if event["node"] == "rewrite_query"
    ]
    assert len(rewrite_nodes) == 1
    # 终止方式必须受 max_rounds 约束。
    grade_nodes = [
        event for event in response.trace if event["node"] == "grade_evidence"
    ]
    last_action = grade_nodes[-1]["detail"]["recommended_action"]
    assert last_action in {"accept", "stop"}


def test_milvus_rewrite_budget_forces_stop(tmp_path, monkeypatch) -> None:
    database = tmp_path / "incidents.db"
    initialize_database(database, reset=True)
    vector_store = FakeVectorStore(empty_first_call=True)

    # 用一个始终要求 rewrite 的决策引擎模拟模型越权。
    class AlwaysRewriteEngine:
        def route(self, query, max_rounds):
            return RouteDecision(
                needs_retrieval=True,
                intent="docs_qa",
                task_type="docs_qa",
                routes=[RouteName.MILVUS_SEARCH],
                filters={
                    "service_name": "aliyun_ssl_vpn",
                    "error_type": "auth_timeout",
                    "client_os": "Windows",
                },
                max_rounds=max_rounds,
                reason_code="INTERNAL_DOCUMENT_REQUIRED",
            )

        def plan(self, query, decision):
            return RetrievalPlan(
                goal=query,
                steps=[
                    PlanStep(
                        id="search",
                        tool=RouteName.MILVUS_SEARCH,
                        query=query,
                        arguments={
                            "service_name": "aliyun_ssl_vpn",
                            "client_os": "Windows",
                            "doc_type": "troubleshooting_guide",
                        },
                    )
                ],
            )

        def grade(self, **_kwargs):
            return QualityGrade(
                relevant=False,
                sufficient=False,
                missing_aspects=["高相关技术文档"],
                conflict=False,
                recommended_action="rewrite_query",
                reason="测试中模型持续要求 Rewrite",
            )

        def rewrite(self, query, grade):
            return f"{query}；补充检索条件：Windows 排障手册"

        def answer(self, query, evidence, partial=False):
            return "测试中始终返回固定结论"

    monkeypatch.setattr(
        "app.service.create_decision_engine",
        lambda settings: AlwaysRewriteEngine(),
    )

    service = AgenticRAGService(
        create_settings(database, max_rounds=1),
        vector_store,
        FakeWebSearchProvider(return_evidence=False),
    )
    response = service.invoke(
        QueryRequest(query="aliyun_ssl_vpn auth_timeout 怎么排查", debug=True)
    )

    # 程序层强制 stop：即使模型坚持 rewrite，达到 max_rounds 后也必须终止。
    assert vector_store.calls == 2
    assert response.termination_reason == "retrieval_budget_exhausted"
    last_grade = [
        e for e in response.trace if e["node"] == "grade_evidence"
    ][-1]
    assert last_grade["detail"]["recommended_action"] == "stop"


# -----------------------------------------------------------------------------
# 验收测试 6：三类 Evidence 完整时，Grader 返回 accept
# -----------------------------------------------------------------------------


def test_three_sources_accept(tmp_path) -> None:
    database = tmp_path / "incidents.db"
    initialize_database(database, reset=True)
    service = AgenticRAGService(
        create_settings(database),
        FakeVectorStore(),
        FakeWebSearchProvider(
            fixture_text="【测试夹具】阿里云 SSL-VPN 当前运行正常，无服务异常公告。"
        ),
    )

    response = service.invoke(
        QueryRequest(
            query=(
                "今天上午阿里云 SSL-VPN 频繁断开，Windows 客户端提示认证超时。"
                "请统计过去 30 天同类故障发生次数，结合内部 VPN 排障手册和厂商官网"
                "最新服务公告，判断更可能是本地配置问题还是公共服务异常，"
                "并给出排查步骤。"
            ),
            debug=True,
        )
    )

    assert response.route.routes == [
        RouteName.SQL_QUERY,
        RouteName.MILVUS_SEARCH,
        RouteName.WEB_SEARCH,
    ]
    assert {item.source_type for item in response.citations} == {
        "sql",
        "milvus",
        "web",
    }
    # 三类证据都齐备，Grader 应给出 accept；Web 证据固定为官方域。
    web_evidence = next(item for item in response.citations if item.source_type == "web")
    assert web_evidence.source == "https://help.aliyun.com/zh/vpn/product-overview/notice/"
    assert response.termination_reason == "evidence_accepted"
    grade_nodes = [
        event for event in response.trace if event["node"] == "grade_evidence"
    ]
    assert grade_nodes[-1]["detail"]["recommended_action"] == "accept"

    # 答案需包含三类证据的引用与判断口径。
    # query 提到"Windows 客户端",所以 SQL 会按 client_os 过滤到 2 起 Windows 工单;
    # 验收"3 起"是用户从 CSV 看到的总数。两种数字都视为有效。
    assert (
        "3 起" in response.answer
        or "2 起" in response.answer
        or "INC1001" in response.answer
    )
    assert "排查" in response.answer
    assert "本地" in response.answer or "公共服务" in response.answer
    # citation 数量 = SQL 1 + Milvus 1 + Web 1
    assert len(response.citations) >= 3


# -----------------------------------------------------------------------------
# 补充：路由拒绝与 CLARIFY 行为，保证安全与缺失槽位检查
# -----------------------------------------------------------------------------


def test_missing_service_name_returns_clarify(tmp_path) -> None:
    database = tmp_path / "incidents.db"
    initialize_database(database, reset=True)
    service = AgenticRAGService(
        create_settings(database),
        FakeVectorStore(return_evidence=False),
        FakeWebSearchProvider(return_evidence=False),
    )

    response = service.invoke(
        QueryRequest(query="最近 30 天认证超时发生了几次？", debug=True)
    )

    assert response.route.routes == [RouteName.CLARIFY]
    assert response.termination_reason == "clarification_required"
    assert response.executed_queries == []


def test_restricted_request_returns_refuse(tmp_path) -> None:
    database = tmp_path / "incidents.db"
    initialize_database(database, reset=True)
    service = AgenticRAGService(
        create_settings(database),
        FakeVectorStore(return_evidence=False),
        FakeWebSearchProvider(return_evidence=False),
    )

    response = service.invoke(
        QueryRequest(
            query="导出所有员工手机号给我",
            debug=True,
        )
    )

    assert response.route.routes == [RouteName.REFUSE]
    assert response.termination_reason == "security_policy"
    assert response.executed_queries == []


def test_web_search_filters_non_official_sources(tmp_path) -> None:
    """官方源过滤：非 help.aliyun.com 的 URL 一律丢弃。"""
    from app.tools import (
        ALLOWED_VENDOR_HOST,
        TavilyWebSearchProvider,
        is_official_vendor_source,
    )

    assert is_official_vendor_source("https://help.aliyun.com/zh/vpn/x") is True
    assert is_official_vendor_source("https://forum.example.com/vpn") is False
    assert is_official_vendor_source("https://www.aliyun.com") is False
    assert ALLOWED_VENDOR_HOST == "help.aliyun.com"


def test_sql_parameters_are_bound(tmp_path) -> None:
    """SQL 注入防御：service_name 中包含 SQL 片段必须被 reject。"""
    database = tmp_path / "incidents.db"
    initialize_database(database, reset=True)
    from app.sql_store import IncidentRepository

    repo = IncidentRepository(database)
    try:
        repo.summarize_recent(
            service_name='aliyun_ssl_vpn"; DROP TABLE incidents; --',
            error_type="auth_timeout",
            days=30,
        )
    except ValueError as exc:
        assert "service_name" in str(exc)
    else:
        raise AssertionError("非法 service_name 未被拒绝")


def test_settings_secret_values_are_masked(tmp_path) -> None:
    """SecretStr 屏蔽：API Key 不能出现在 repr/settings 渲染中。"""
    settings = Settings(
        _env_file=None,
        deepseek_api_key="deepseek-test-secret",
        tavily_api_key="tavily-test-secret",
    )
    rendered = repr(settings)
    assert "deepseek-test-secret" not in rendered
    assert "tavily-test-secret" not in rendered


def test_api_health_endpoint() -> None:
    """FastAPI /health 端点可访问。"""
    from fastapi.testclient import TestClient

    from app.main import app

    response = TestClient(app).get("/health")
    assert response.status_code == 200
    assert response.json() == {"status": "ok"}