from __future__ import annotations

import json
from datetime import UTC, date, datetime
from decimal import Decimal
from pathlib import Path
from types import SimpleNamespace

import pytest
from sqlalchemy import create_engine
from sqlalchemy.orm import Session, sessionmaker
from sqlalchemy.pool import StaticPool

from app.api.deps import CurrentUserContext
from app.core.agent_enums import (
    AgentAssetDomain,
    AgentAssetStatus,
    AgentAssetType,
    AgentReviewStatus,
)
from app.db.base import Base
from app.models.agent_asset import AgentAsset
from app.models.employee import Employee
from app.models.financial_record import ExpenseClaim, ExpenseClaimItem
from app.models.golden_case import GoldenCase
from app.schemas.agent_asset import (
    AgentAssetReviewCreate,
    AgentAssetRiskRuleGenerateRequest,
    AgentAssetRiskRuleReportRequest,
    AgentAssetRiskRuleSampleTestRequest,
    AgentAssetRiskRuleScenarioTestRequest,
    AgentAssetRiskRuleSimulationRequest,
)
from app.services.agent_asset_release_guard import (
    AgentAssetReleaseGuardService,
    ReleaseEvaluationInput,
)
from app.services.agent_asset_rule_library import AgentAssetRuleLibraryManager
from app.services.agent_asset_spreadsheet import RISK_RULES_LIBRARY
from app.services.agent_assets import AgentAssetService
from app.services.agent_foundation_risk_rules import AgentFoundationRiskRuleMixin
from app.services.expense_claim_platform_risk import ExpenseClaimPlatformRiskMixin
from app.services.risk_rule_flow_diagram import (
    RiskRuleFlowDiagramRenderer,
    RiskRuleFlowDiagramSpec,
)
from app.services.risk_rule_generation import RiskRuleGenerationService
from app.services.risk_rule_generation_jobs import RiskRuleGenerationJobService
from app.services.risk_rule_manifest_classifier import is_budget_risk_manifest
from app.services.risk_rule_manifest_normalizer import normalize_risk_rule_manifest
from app.services.risk_rule_scoring import calculate_risk_rule_score, risk_level_from_score
from app.services.risk_rule_template_executor import RiskRuleTemplateExecutor


class NullRuntimeChatService:
    def complete(self, *args, **kwargs) -> None:
        return None


class TravelRouteSemanticRuntimeChatService:
    def complete(self, *args, **kwargs) -> str:
        return json.dumps(
            {
                "name": "差旅票据路线一致性校验",
                "description": "交通票或住宿票据城市需要与申报行程形成一致关系。",
                "template_key": "field_compare_v1",
                "semantic_type": "travel_route_city_consistency",
                "field_keys": [
                    "attachment.route_cities",
                    "attachment.hotel_city",
                    "claim.location",
                    "item.item_location",
                    "claim.reason",
                    "item.item_reason",
                ],
                "condition_summary": (
                    "A=交通票行程城市∪住宿发票城市，B=申报目的地∪明细发生地点，"
                    "A与B无交集且无合理说明，或A中出现无法由本次票据起终点和申报目的地解释的额外城市时命中。"
                ),
                "keywords": [],
                "exception_keywords": ["绕行", "跨城办事", "临时改签"],
                "flow": {
                    "start": "差旅报销提交",
                    "evidence": "读取票据城市、申报地点、明细地点和报销事由",
                    "decision": "票据城市是否覆盖申报行程，是否出现额外中转城市",
                    "pass": "票据城市与申报行程一致",
                    "fail": "票据城市与申报行程不一致，进入复核",
                },
            },
            ensure_ascii=False,
        )


def build_session() -> Session:
    engine = create_engine(
        "sqlite+pysqlite:///:memory:",
        connect_args={"check_same_thread": False},
        poolclass=StaticPool,
    )
    Base.metadata.create_all(bind=engine)
    session_factory = sessionmaker(bind=engine, autoflush=False, autocommit=False)
    return session_factory()


def test_generate_risk_rule_asset_creates_draft_json_rule(tmp_path) -> None:
    with build_session() as db:
        service = RiskRuleGenerationService(
            db,
            rule_library_manager=AgentAssetRuleLibraryManager(rule_root=tmp_path / "rules"),
            runtime_chat_service=NullRuntimeChatService(),
        )

        asset_id = service.generate_rule_asset(
            AgentAssetRiskRuleGenerateRequest(
                business_domain=AgentAssetDomain.EXPENSE,
                expense_category="travel",
                rule_title="差旅住宿城市一致性校验",
                risk_level="high",
                natural_language="住宿城市必须出现在本次差旅行程城市中，否则提示高风险。",
            ),
            actor="pytest",
        )

        asset = db.get(AgentAsset, asset_id)
        assert asset is not None
        assert asset.name == "差旅住宿城市一致性校验"
        assert asset.status == AgentAssetStatus.DRAFT.value
        assert asset.config_json["detail_mode"] == "json_risk"
        assert asset.config_json["evaluator"] == "template_rule"
        assert asset.config_json["expense_category"] == "travel"
        assert asset.config_json["risk_category"] == "差旅费"
        assert asset.config_json["business_stage"] == "reimbursement"
        assert asset.config_json["business_stage_label"] == "费用报销"
        assert asset.scenario_json == ["差旅费"]
        assert asset.current_version == "v0.1.0"

        file_name = asset.config_json["rule_document"]["file_name"]
        rule_path = tmp_path / "rules" / RISK_RULES_LIBRARY / file_name
        payload = json.loads(rule_path.read_text(encoding="utf-8"))
        assert payload["rule_code"] == asset.code
        assert payload["name"] == "差旅住宿城市一致性校验"
        assert payload["applies_to"]["expense_categories"] == ["travel"]
        assert payload["applies_to"]["business_stages"] == ["reimbursement"]
        assert payload["risk_category"] == "差旅费"
        assert payload["metadata"]["expense_category"] == "travel"
        assert payload["metadata"]["business_stage"] == "reimbursement"
        assert payload["metadata"]["business_stage_label"] == "费用报销"
        assert payload["metadata"]["rule_title"] == "差旅住宿城市一致性校验"
        assert isinstance(payload["metadata"]["risk_score"], int)
        assert payload["metadata"]["risk_level"] == payload["outcomes"]["fail"]["severity"]
        assert asset.config_json["risk_score"] == payload["metadata"]["risk_score"]
        assert payload["outcomes"]["fail"]["severity"] == "high"
        assert payload["template_key"] == "field_compare_v1"
        assert payload["metadata"]["natural_language"].startswith("住宿城市")
        assert payload["inputs"]["fields"]
        assert payload["flow_diagram_svg"].startswith("<svg")
        assert 'width="860" height="360"' in payload["flow_diagram_svg"]
        assert 'data-risk-flow-style="review-node-only"' in payload["flow_diagram_svg"]
        assert 'data-risk-flow-detail="logic-v2"' in payload["flow_diagram_svg"]
        assert "RULE FLOW" in payload["flow_diagram_svg"]
        assert "字段事实" in payload["flow_diagram_svg"]
        assert "判断条件" in payload["flow_diagram_svg"]
        assert "命中逻辑" in payload["flow_diagram_svg"]
        assert "进入复核" in payload["flow_diagram_svg"]
        assert "否" in payload["flow_diagram_svg"]
        assert "是" in payload["flow_diagram_svg"]
        assert "#dc2626" in payload["flow_diagram_svg"]
        assert "#fecaca" in payload["flow_diagram_svg"]
        assert "#10a37f" not in payload["flow_diagram_svg"]
        assert "#f97316" not in payload["flow_diagram_svg"]
        assert "feDropShadow" not in payload["flow_diagram_svg"]


def test_risk_score_model_thresholds_and_critical_level() -> None:
    assert risk_level_from_score(30) == "low"
    assert risk_level_from_score(31) == "medium"
    assert risk_level_from_score(61) == "high"
    assert risk_level_from_score(81) == "critical"

    result = calculate_risk_rule_score(
        natural_language="同一发票号码重复报销时禁止提交并进入审计复核。",
        draft={
            "template_key": "composite_rule_v1",
            "field_keys": ["attachment.invoice_no", "claim.amount"],
            "conditions": [{"id": "duplicate_invoice", "operator": "overlap"}],
            "risk_scoring_evidence": {
                "impact_level": "critical",
                "violation_certainty": "critical",
                "evidence_strength": "high",
                "exception_dependence": "medium",
                "control_action": "block",
                "business_sensitivity": "critical",
            },
        },
        fields=[],
        expense_category="travel",
        expense_category_label="差旅费",
        requires_attachment=True,
    )

    assert result["score"] >= 81
    assert result["level"] == "critical"
    assert result["level_label"] == "极高风险"


def test_generate_expense_application_risk_rule_marks_business_stage(tmp_path) -> None:
    with build_session() as db:
        manager = AgentAssetRuleLibraryManager(rule_root=tmp_path / "rules")
        service = RiskRuleGenerationService(
            db,
            rule_library_manager=manager,
            runtime_chat_service=NullRuntimeChatService(),
        )

        asset_id = service.generate_rule_asset(
            AgentAssetRiskRuleGenerateRequest(
                business_domain=AgentAssetDomain.EXPENSE,
                business_stage="expense_application",
                expense_category="travel",
                rule_title="差旅申请预算余额校验",
                natural_language="费用申请时，若差旅申请金额超过可用预算余额，则提示风险并要求补充审批说明。",
            ),
            actor="pytest",
        )

        asset = db.get(AgentAsset, asset_id)
        assert asset is not None
        assert asset.config_json["business_stage"] == "expense_application"
        assert asset.config_json["business_stage_label"] == "费用申请"

        payload = manager.read_rule_library_json(
            library=RISK_RULES_LIBRARY,
            file_name=asset.config_json["rule_document"]["file_name"],
        )
        assert payload["applies_to"]["business_stages"] == ["expense_application"]
        assert payload["metadata"]["business_stage_label"] == "费用申请"
        assert payload["params"]["business_stage_label"] == "费用申请"


def test_generate_risk_rule_asset_supports_all_expense_category(tmp_path) -> None:
    with build_session() as db:
        manager = AgentAssetRuleLibraryManager(rule_root=tmp_path / "rules")
        service = RiskRuleGenerationService(
            db,
            rule_library_manager=manager,
            runtime_chat_service=NullRuntimeChatService(),
        )

        asset_id = service.generate_rule_asset(
            AgentAssetRiskRuleGenerateRequest(
                business_domain=AgentAssetDomain.EXPENSE,
                business_stage="expense_application",
                expense_category="all",
                rule_title="预算可用余额不足",
                natural_language="费用申请时，如果申请金额超过预算可用余额，则提示预算风险并要求补充说明。",
            ),
            actor="pytest",
        )

        asset = db.get(AgentAsset, asset_id)
        assert asset is not None
        assert asset.config_json["expense_category"] == "all"
        assert asset.config_json["expense_category_label"] == "全部"
        assert asset.scenario_json == ["全部"]

        payload = manager.read_rule_library_json(
            library=RISK_RULES_LIBRARY,
            file_name=asset.config_json["rule_document"]["file_name"],
        )
        assert payload["applies_to"]["expense_categories"] == ["all"]
        assert payload["metadata"]["expense_category"] == "all"
        assert payload["metadata"]["expense_category_label"] == "全部"


def test_risk_score_model_keeps_explicit_low_control_rules_low() -> None:
    field_keys = ["attachment.invoice_no", "attachment.goods_name", "claim.reason"]
    result = calculate_risk_rule_score(
        natural_language=(
            "差旅报销时，票据已上传但发票号码或商品服务名称缺失，"
            "且报销事由、人员和部门能够说明费用归属，则标记为低风险，"
            "仅提醒补齐票据要素。"
        ),
        draft={
            "template_key": "field_required_v1",
            "field_keys": field_keys,
            "condition_summary": "票据要素缺失但归属清晰时提醒补齐。",
        },
        fields=[SimpleNamespace(key=key, source=key.split(".", 1)[0]) for key in field_keys],
        expense_category="travel",
        expense_category_label="差旅费",
        requires_attachment=True,
    )

    assert result["score"] <= 30
    assert result["level"] == "low"
    assert result["calibration"]["rules"][0]["name"] == "explicit_low_control_cap"


def test_risk_score_model_ignores_negated_hard_risk_words_for_low_rules() -> None:
    result = calculate_risk_rule_score(
        natural_language=(
            "差旅费报销提交时，若缺少申报目的地、明细地点或明细事由，"
            "但暂未发现票据城市冲突、金额异常或重复报销迹象，则标记为低风险，"
            "提示经办人补齐基础差旅信息后继续提交。"
        ),
        draft={
            "template_key": "field_required_v1",
            "field_keys": ["claim.location", "item.item_location", "item.item_reason"],
            "condition_summary": "基础差旅字段缺失但暂无硬风险迹象时提示补齐。",
        },
        fields=[],
        expense_category="travel",
        expense_category_label="差旅费",
        requires_attachment=False,
    )

    assert result["score"] <= 30
    assert result["level"] == "low"


def test_risk_score_model_does_not_cap_hard_risk_signals() -> None:
    result = calculate_risk_rule_score(
        natural_language=(
            "差旅报销时，交通票或住宿票据城市均无法与申报目的地一致，"
            "且没有绕行、跨城办事或改签说明，则标记为高风险，要求补充说明或退回修改。"
        ),
        draft={
            "template_key": "composite_rule_v1",
            "field_keys": ["claim.destination_city", "attachment.route_cities"],
            "conditions": [{"id": "city_mismatch", "operator": "not_overlap"}],
        },
        fields=[],
        expense_category="travel",
        expense_category_label="差旅费",
        requires_attachment=True,
    )

    assert result["score"] >= 61
    assert result["level"] == "high"
    assert not result["calibration"]["rules"]


def test_set_risk_rule_level_rejects_manual_override(tmp_path) -> None:
    with build_session() as db:
        manager = AgentAssetRuleLibraryManager(rule_root=tmp_path / "rules")
        generator = RiskRuleGenerationService(
            db,
            rule_library_manager=manager,
            runtime_chat_service=NullRuntimeChatService(),
        )

        asset_id = generator.generate_rule_asset(
            AgentAssetRiskRuleGenerateRequest(
                business_domain=AgentAssetDomain.EXPENSE,
                expense_category="travel",
                rule_title="差旅住宿城市一致性校验",
                risk_level="high",
                natural_language="住宿城市必须出现在本次差旅行程城市中，否则提示高风险。",
            ),
            actor="pytest",
        )

        asset_service = AgentAssetService(db)
        asset_service.rule_library_manager = manager
        with pytest.raises(ValueError, match="评分模型"):
            asset_service.set_risk_rule_level(
                asset_id,
                risk_level="low",
                actor="pytest",
            )

        asset = db.get(AgentAsset, asset_id)
        assert asset is not None
        assert asset.config_json["severity"] != "low"


def test_enqueue_risk_rule_generation_creates_visible_generating_asset(tmp_path) -> None:
    with build_session() as db:
        service = RiskRuleGenerationJobService(
            db,
            rule_library_manager=AgentAssetRuleLibraryManager(rule_root=tmp_path / "rules"),
            runtime_chat_service=NullRuntimeChatService(),
        )
        request = AgentAssetRiskRuleGenerateRequest(
            business_domain=AgentAssetDomain.EXPENSE,
            expense_category="travel",
            rule_title="差旅城市一致性校验",
            risk_level="high",
            natural_language="住宿城市必须出现在本次差旅行程城市中，否则提示高风险。",
        )

        asset_id = service.enqueue_rule_asset_generation(request, actor="pytest")

        asset = db.get(AgentAsset, asset_id)
        assert asset is not None
        assert asset.status == AgentAssetStatus.GENERATING.value
        assert asset.owner == "pytest"
        assert asset.name == "差旅城市一致性校验"
        assert asset.config_json["generation_status"] == "generating"
        assert asset.config_json["expense_category_label"] == "差旅费"
        assert asset.current_version is None
        detail = AgentAssetService(db).get_asset(asset_id)
        assert detail is not None
        assert detail.status == AgentAssetStatus.GENERATING.value
        assert detail.latest_test_summary is None

        service.complete_rule_asset_generation(asset_id, request, actor="pytest")
        db.refresh(asset)

        assert asset.status == AgentAssetStatus.DRAFT.value
        assert asset.working_version == "v0.1.0"
        assert asset.config_json["generation_status"] == "completed"
        assert asset.config_json["expense_category_label"] == "差旅费"
        assert asset.scenario_json == ["差旅费"]


def test_platform_risk_sync_skips_natural_language_drafts() -> None:
    assert AgentFoundationRiskRuleMixin._is_user_generated_risk_manifest(
        {
            "rule_code": "risk.expense.travel.generated_20260525123000000000",
            "metadata": {
                "stability": "generated_draft",
                "source_ref": "自然语言风险规则",
            },
        }
    )
    assert not AgentFoundationRiskRuleMixin._is_user_generated_risk_manifest(
        {
            "rule_code": "risk.travel.destination_location_mismatch",
            "metadata": {"source_ref": "平台内置风险规则"},
        }
    )


def test_stale_demo_risk_rules_are_marked_deprecated() -> None:
    class FoundationRiskSyncProbe(AgentFoundationRiskRuleMixin):
        def __init__(self, db: Session) -> None:
            self.db = db

    with build_session() as db:
        stale_asset = AgentAsset(
            asset_type=AgentAssetType.RULE.value,
            code="risk.standard.training_per_capita_over_limit",
            name="培训费人均超标准",
            domain=AgentAssetDomain.EXPENSE.value,
            owner="风控与审计部",
            reviewer="顾承宇",
            status=AgentAssetStatus.ACTIVE.value,
            config_json={
                "enabled": True,
                "tag": "风险规则",
                "source_ref": "费用管控 Demo 风险规则库",
            },
        )
        kept_asset = AgentAsset(
            asset_type=AgentAssetType.RULE.value,
            code="risk.standard.software_contract_missing",
            name="软件服务费缺少合同",
            domain=AgentAssetDomain.EXPENSE.value,
            owner="风控与审计部",
            reviewer="顾承宇",
            status=AgentAssetStatus.ACTIVE.value,
            config_json={
                "enabled": True,
                "tag": "风险规则",
                "source_ref": "费用管控 Demo 风险规则库",
            },
        )
        db.add_all([stale_asset, kept_asset])
        db.flush()

        FoundationRiskSyncProbe(db)._hide_stale_demo_risk_rules(
            {"risk.standard.software_contract_missing"}
        )

        assert stale_asset.status == AgentAssetStatus.DISABLED.value
        assert stale_asset.config_json["tag"] == "废弃风险规则"
        assert stale_asset.config_json["enabled"] is False
        assert kept_asset.status == AgentAssetStatus.ACTIVE.value
        assert kept_asset.config_json["tag"] == "风险规则"


def test_platform_risk_applies_to_chinese_expense_type_labels() -> None:
    class PlatformRiskProbe(ExpenseClaimPlatformRiskMixin):
        pass

    claim = ExpenseClaim(
        claim_no="TEST-MARKETING-RISK",
        employee_name="测试员工",
        department_name="市场部",
        expense_type="市场推广费",
        reason="品牌投放活动",
        amount=Decimal("20000.00"),
        currency="CNY",
        invoice_count=1,
        occurred_at=datetime.now(UTC),
        status="draft",
    )
    manifest = {
        "applies_to": {
            "domains": ["expense"],
            "expense_types": ["marketing"],
        }
    }

    assert PlatformRiskProbe()._risk_manifest_applies_to_claim(
        manifest,
        claim=claim,
        contexts=[],
    )

    manifest["applies_to"]["expense_types"] = ["software"]

    assert not PlatformRiskProbe()._risk_manifest_applies_to_claim(
        manifest,
        claim=claim,
        contexts=[],
    )


def test_platform_risk_all_expense_scope_matches_any_budget_category() -> None:
    class PlatformRiskProbe(ExpenseClaimPlatformRiskMixin):
        pass

    claim = ExpenseClaim(
        claim_no="TEST-COMMUNICATION-RISK",
        employee_name="测试员工",
        department_name="市场部",
        expense_type="通信费",
        reason="客户支持电话费",
        amount=Decimal("300.00"),
        currency="CNY",
        invoice_count=1,
        occurred_at=datetime.now(UTC),
        status="draft",
    )
    manifest = {
        "applies_to": {
            "domains": ["expense"],
            "expense_types": ["all"],
        }
    }

    assert PlatformRiskProbe()._risk_manifest_applies_to_claim(
        manifest,
        claim=claim,
        contexts=[],
    )

    manifest["applies_to"] = {"domains": ["expense"], "expense_categories": ["全部"]}

    assert PlatformRiskProbe()._risk_manifest_applies_to_claim(
        manifest,
        claim=claim,
        contexts=[],
    )


def test_risk_rule_flow_diagram_uses_risk_level_palette() -> None:
    renderer = RiskRuleFlowDiagramRenderer()

    def render(severity: str, label: str) -> str:
        return renderer.render(
            RiskRuleFlowDiagramSpec(
                title="测试规则",
                domain_label="报销",
                severity=severity,
                severity_label=label,
                fields=(),
                start="业务单据提交",
                evidence="读取规则字段",
                decision="判断是否命中风险",
                basis="根据规则字段判断",
                pass_text="未命中风险，继续流转",
                fail_text=f"命中{label}，进入复核",
            )
        )

    assert "#2563eb" in render("low", "低风险")
    assert "#f97316" in render("medium", "中风险")
    high_svg = render("high", "高风险")
    assert "#dc2626" in high_svg
    assert high_svg.count("#dc2626") >= 1
    assert "#10a37f" not in high_svg


def test_non_budget_platform_risk_manifests_do_not_use_budget_or_employee_location() -> None:
    rule_root = Path("server/rules/risk-rules")
    checked = 0
    for path in sorted(rule_root.glob("*.json")):
        payload = json.loads(path.read_text(encoding="utf-8"))
        if is_budget_risk_manifest(payload):
            continue

        checked += 1
        normalized = normalize_risk_rule_manifest(payload)
        params = normalized.get("params") if isinstance(normalized.get("params"), dict) else {}
        text_blob = json.dumps(normalized, ensure_ascii=False)
        home_city_fields = params.get("home_city_fields")
        condition_summary = str(
            normalized.get("condition_summary") or params.get("condition_summary") or ""
        )
        template_key = str(
            normalized.get("template_key") or params.get("template_key") or ""
        ).strip()
        looks_like_city_rule = any(token in text_blob for token in ("城市", "目的地", "行程城市"))

        assert "budget." not in text_blob, path.name
        assert "employee.location" not in text_blob, path.name
        assert not (
            isinstance(home_city_fields, list)
            and any(str(item or "").strip() for item in home_city_fields)
        ), path.name
        assert "风险关键词" not in condition_summary, path.name
        assert not (template_key == "keyword_match_v1" and looks_like_city_rule), path.name

    assert checked == 28


def test_risk_rule_simulation_extracts_ticket_route_cities() -> None:
    with build_session() as db:
        service = AgentAssetService(db)
        value = service._find_attachment_field_value(
            "attachment.route_cities",
            "行程城市",
            [
                {
                    "document_fields": [
                        {"key": "route", "label": "行程路线", "value": "上海虹桥-武汉"}
                    ],
                    "ocr_text": "G123 上海虹桥 至 武汉 二等座",
                    "summary": "高铁票 上海虹桥-武汉",
                }
            ],
        )

        assert value == ["上海", "武汉"]


def test_current_keyword_city_consistency_rule_hits_ticket_city_mismatch() -> None:
    manifest = {
        "template_key": "keyword_match_v1",
        "params": {
            "template_key": "keyword_match_v1",
            "field_keys": [
                "attachment.hotel_city",
                "claim.location",
                "attachment.route_cities",
                "item.item_location",
            ],
            "search_fields": [
                "attachment.hotel_city",
                "claim.location",
                "attachment.route_cities",
                "item.item_location",
            ],
            "natural_language": (
                "差旅报销时，交通票或住宿票据中的城市必须与申报目的地一致；"
                "未说明绕行、跨城或改签原因时标记风险。"
            ),
            "condition_summary": "检查住宿城市、申报地点、行程城市是否一致",
            "keywords": ["绕行", "跨城", "改签", "变更"],
        },
        "outcomes": {"fail": {"severity": "medium"}},
    }
    claim = ExpenseClaim(
        claim_no="TEST-CURRENT-RISK",
        employee_name="测试员工",
        department_name="测试部门",
        expense_type="差旅费",
        reason="去北京出差3天",
        location="北京",
        amount=Decimal("320.00"),
        currency="CNY",
        invoice_count=1,
        occurred_at=datetime.now(UTC),
        status="draft",
    )
    claim.items = [
        ExpenseClaimItem(
            item_date=date.today(),
            item_type="交通费",
            item_reason="去北京出差3天",
            item_location="北京",
            item_amount=Decimal("320.00"),
        )
    ]
    result = RiskRuleTemplateExecutor().evaluate(
        manifest,
        claim=claim,
        contexts=[
            {
                "document_info": {
                    "route_cities": ["武汉", "上海"],
                    "fields": [
                        {"key": "route_cities", "label": "行程城市", "value": ["武汉", "上海"]}
                    ],
                },
                "ocr_text": "武汉 到 上海",
            }
        ],
    )

    assert result is not None
    assert result["evidence"]["city_consistency"]["attachment_values"] == ["武汉", "上海"]
    assert result["evidence"]["city_consistency"]["reference_values"] == ["北京"]


def test_travel_route_city_consistency_allows_normal_round_trip_to_declared_destination() -> None:
    manifest = {
        "template_key": "field_compare_v1",
        "params": {
            "template_key": "field_compare_v1",
            "semantic_type": "travel_route_city_consistency",
            "field_keys": [
                "attachment.route_cities",
                "claim.location",
                "item.item_location",
                "employee.location",
                "claim.reason",
            ],
            "attachment_city_fields": ["attachment.route_cities"],
            "reference_city_fields": ["claim.location", "item.item_location"],
            "home_city_fields": ["employee.location"],
            "exception_fields": ["claim.reason"],
            "exception_keywords": ["绕行", "跨城办事", "临时改签"],
        },
        "outcomes": {"fail": {"severity": "high"}},
    }
    claim = ExpenseClaim(
        claim_no="TEST-ROUND-TRIP",
        employee_name="测试员工",
        department_name="测试部门",
        expense_type="差旅费",
        reason="去上海支撑项目部署",
        location="上海",
        amount=Decimal("708.00"),
        currency="CNY",
        invoice_count=2,
        occurred_at=datetime.now(UTC),
        status="draft",
    )
    claim.employee = Employee(
        employee_no="TEST-ROUND-TRIP-EMP",
        name="测试员工",
        email="round-trip@example.com",
        location="武汉",
    )
    claim.items = [
        ExpenseClaimItem(
            item_date=date.today(),
            item_type="交通费",
            item_reason="去上海支撑项目部署",
            item_location="上海",
            item_amount=Decimal("354.00"),
        )
    ]

    result = RiskRuleTemplateExecutor().evaluate(
        manifest,
        claim=claim,
        contexts=[
            {
                "document_info": {
                    "fields": [
                        {"key": "route", "label": "行程", "value": "武汉-上海"},
                    ],
                },
                "ocr_text": "铁路电子客票 2026-02-20 武汉-上海 二等座",
            },
            {
                "document_info": {
                    "fields": [
                        {"key": "route", "label": "行程", "value": "上海-武汉"},
                    ],
                },
                "ocr_text": "铁路电子客票 2026-02-23 上海-武汉 二等座",
            },
        ],
    )

    assert result is None


def test_travel_route_city_consistency_allows_inferred_round_trip_origin() -> None:
    manifest = normalize_risk_rule_manifest(
        AgentAssetRuleLibraryManager().read_rule_library_json(
            library=RISK_RULES_LIBRARY,
            file_name="risk.travel.high.city_mismatch.json",
        )
    )
    claim = ExpenseClaim(
        claim_no="TEST-INFERRED-ROUND-TRIP",
        employee_name="测试员工",
        department_name="测试部门",
        expense_type="travel",
        reason="支撑国网仿生产环境部署",
        location="上海",
        amount=Decimal("708.00"),
        currency="CNY",
        invoice_count=2,
        occurred_at=datetime.now(UTC),
        status="draft",
    )
    claim.employee = Employee(
        employee_no="TEST-INFERRED-ROUND-TRIP-EMP",
        name="测试员工",
        email="inferred-round-trip@example.com",
        location="上海",
    )
    claim.items = [
        ExpenseClaimItem(
            item_date=date(2026, 2, 20),
            item_type="travel",
            item_reason="支撑国网仿生产环境部署",
            item_location="上海",
            item_amount=Decimal("354.00"),
        )
    ]

    result = RiskRuleTemplateExecutor().evaluate(
        manifest,
        claim=claim,
        contexts=[
            {
                "document_info": {
                    "document_type": "train_ticket",
                    "scene_code": "travel",
                    "fields": [{"key": "route", "label": "行程", "value": "武汉-上海"}],
                },
                "ocr_text": "铁路电子客票 2026-02-20 武汉-上海 二等座",
                "ocr_summary": "武汉到上海高铁票",
                "item": claim.items[0],
            },
            {
                "document_info": {
                    "document_type": "train_ticket",
                    "scene_code": "travel",
                    "fields": [{"key": "route", "label": "行程", "value": "上海-武汉"}],
                },
                "ocr_text": "铁路电子客票 2026-02-23 上海-武汉 二等座",
                "ocr_summary": "上海到武汉高铁票",
                "item": claim.items[0],
            },
        ],
    )

    assert result is None


def test_travel_route_city_consistency_uses_application_location_not_employee_origin() -> None:
    manifest = normalize_risk_rule_manifest(
        AgentAssetRuleLibraryManager().read_rule_library_json(
            library=RISK_RULES_LIBRARY,
            file_name="risk.travel.high.city_mismatch.json",
        )
    )
    claim = ExpenseClaim(
        claim_no="TEST-APPLICATION-LOCATION-NO-FALSE-POSITIVE",
        employee_name="测试员工",
        department_name="测试部门",
        expense_type="travel",
        reason="支撑国网仿生产环境部署",
        location="待补充",
        amount=Decimal("354.00"),
        currency="CNY",
        invoice_count=1,
        occurred_at=datetime.now(UTC),
        status="draft",
        risk_flags_json=[
            {
                "source": "application_link",
                "application_claim_no": "AP-202606-LOCAL",
                "application_detail": {
                    "application_location": "上海",
                    "application_reason": "支撑国网仿生产环境部署",
                    "application_time": "2026-02-20 至 2026-02-23",
                },
            }
        ],
    )
    claim.employee = Employee(
        employee_no="TEST-APPLICATION-LOCATION-EMP",
        name="测试员工",
        email="application-location@example.com",
        location="武汉",
    )
    claim.items = [
        ExpenseClaimItem(
            item_date=date(2026, 2, 20),
            item_type="travel",
            item_reason="支撑国网仿生产环境部署",
            item_location="",
            item_amount=Decimal("354.00"),
        )
    ]

    result = RiskRuleTemplateExecutor().evaluate(
        manifest,
        claim=claim,
        contexts=[
            {
                "document_info": {
                    "document_type": "train_ticket",
                    "scene_code": "travel",
                    "fields": [{"key": "route", "label": "行程", "value": "武汉-上海"}],
                },
                "ocr_text": "铁路电子客票 2026-02-20 武汉-上海 二等座",
                "ocr_summary": "武汉到上海高铁票",
                "item": claim.items[0],
            }
        ],
    )

    assert result is None


def test_travel_route_city_mismatch_evidence_uses_application_claim_and_attachment() -> None:
    manifest = normalize_risk_rule_manifest(
        AgentAssetRuleLibraryManager().read_rule_library_json(
            library=RISK_RULES_LIBRARY,
            file_name="risk.travel.high.city_mismatch.json",
        )
    )
    claim = ExpenseClaim(
        claim_no="TEST-APPLICATION-LOCATION-MISMATCH",
        employee_name="测试员工",
        department_name="测试部门",
        expense_type="travel",
        reason="去北京参加项目会议",
        location="北京",
        amount=Decimal("354.00"),
        currency="CNY",
        invoice_count=1,
        occurred_at=datetime.now(UTC),
        status="draft",
        risk_flags_json=[
            {
                "source": "application_link",
                "application_claim_no": "AP-202606-MISMATCH",
                "application_detail": {
                    "application_location": "北京",
                    "application_reason": "去北京参加项目会议",
                    "application_time": "2026-02-20 至 2026-02-23",
                },
            }
        ],
    )
    claim.employee = Employee(
        employee_no="TEST-APPLICATION-MISMATCH-EMP",
        name="测试员工",
        email="application-mismatch@example.com",
        location="武汉",
    )
    claim.items = [
        ExpenseClaimItem(
            item_date=date(2026, 2, 20),
            item_type="travel",
            item_reason="去北京参加项目会议",
            item_location="北京",
            item_amount=Decimal("354.00"),
        )
    ]

    result = RiskRuleTemplateExecutor().evaluate(
        manifest,
        claim=claim,
        contexts=[
            {
                "document_info": {
                    "document_type": "train_ticket",
                    "scene_code": "travel",
                    "fields": [{"key": "route", "label": "行程", "value": "武汉-上海"}],
                },
                "ocr_text": "铁路电子客票 2026-02-20 武汉-上海 二等座",
                "ocr_summary": "武汉到上海高铁票",
                "item": claim.items[0],
            }
        ],
    )

    assert result is not None
    evidence = result["evidence"]["city_consistency"]
    assert evidence["application_reference_values"] == ["北京"]
    assert evidence["claim_reference_values"] == ["北京"]
    assert evidence["attachment_values"] == ["武汉", "上海"]
    assert evidence["unexpected_route_cities"] == ["武汉", "上海"]
    assert "home_values" not in evidence
    assert "ignored_employee_context_values" not in evidence


def test_travel_route_city_consistency_still_hits_onward_city_after_destination() -> None:
    manifest = normalize_risk_rule_manifest(
        AgentAssetRuleLibraryManager().read_rule_library_json(
            library=RISK_RULES_LIBRARY,
            file_name="risk.travel.high.city_mismatch.json",
        )
    )
    claim = ExpenseClaim(
        claim_no="TEST-ONWARD-CITY",
        employee_name="测试员工",
        department_name="测试部门",
        expense_type="travel",
        reason="支撑国网仿生产环境部署",
        location="上海",
        amount=Decimal("840.00"),
        currency="CNY",
        invoice_count=2,
        occurred_at=datetime.now(UTC),
        status="draft",
    )
    claim.employee = Employee(
        employee_no="TEST-ONWARD-CITY-EMP",
        name="测试员工",
        email="onward-city@example.com",
        location="上海",
    )
    claim.items = [
        ExpenseClaimItem(
            item_date=date(2026, 2, 20),
            item_type="travel",
            item_reason="支撑国网仿生产环境部署",
            item_location="上海",
            item_amount=Decimal("480.00"),
        )
    ]

    result = RiskRuleTemplateExecutor().evaluate(
        manifest,
        claim=claim,
        contexts=[
            {
                "document_info": {
                    "document_type": "flight_itinerary",
                    "scene_code": "travel",
                    "fields": [{"key": "route", "label": "行程", "value": "武汉-上海"}],
                },
                "ocr_text": "电子行程单 2026-02-20 武汉-上海 金额 480元",
                "ocr_summary": "武汉到上海机票",
                "item": claim.items[0],
            },
            {
                "document_info": {
                    "document_type": "flight_itinerary",
                    "scene_code": "travel",
                    "fields": [{"key": "route", "label": "行程", "value": "上海-成都"}],
                },
                "ocr_text": "电子行程单 2026-02-21 上海-成都 金额 360元",
                "ocr_summary": "上海到成都机票",
                "item": claim.items[0],
            },
        ],
    )

    assert result is not None
    assert result["evidence"]["city_consistency"]["unexpected_route_cities"] == ["成都"]


def test_generate_complex_travel_route_rule_uses_formula_not_keyword_match(tmp_path) -> None:
    text = (
        "差旅报销时，先检查是否已上传交通票据、住宿票据或其他能识别城市的附件；"
        "再读取申报目的地、明细发生地点、交通票行程城市和住宿发票城市。"
        "若交通票或住宿票据中的城市均无法与申报目的地、明细地点形成一致关系，"
        "且报销事由中没有说明绕行、跨城办事或临时改签原因，则标记为高风险，"
        "要求补充行程说明或退回修改。"
    )
    with build_session() as db:
        manager = AgentAssetRuleLibraryManager(rule_root=tmp_path / "rules")
        service = RiskRuleGenerationService(
            db,
            rule_library_manager=manager,
            runtime_chat_service=TravelRouteSemanticRuntimeChatService(),
        )

        asset_id = service.generate_rule_asset(
            AgentAssetRiskRuleGenerateRequest(
                business_domain=AgentAssetDomain.EXPENSE,
                expense_category="travel",
                rule_title="差旅票据路线一致性校验",
                risk_level="high",
                natural_language=text,
                requires_attachment=True,
            ),
            actor="pytest",
        )

        asset = db.get(AgentAsset, asset_id)
        assert asset is not None
        payload = manager.read_rule_library_json(
            library=RISK_RULES_LIBRARY,
            file_name=asset.config_json["rule_document"]["file_name"],
        )
        assert payload["template_key"] == "field_compare_v1"
        assert payload["semantic_type"] == "travel_route_city_consistency"
        assert payload["params"]["semantic_type"] == "travel_route_city_consistency"
        assert payload["params"]["keywords"] == []
        assert payload["params"]["exception_keywords"][:3] == ["绕行", "跨城办事", "跨城"]
        assert "A=交通票行程城市" in payload["params"]["condition_summary"]
        assert "风险关键词" not in payload["params"]["condition_summary"]
        assert "employee.location" not in payload["params"]["field_keys"]
        assert "route_anomaly_policy" in payload["params"]


def test_legacy_city_route_keyword_manifest_is_normalized_before_display_and_execution() -> None:
    manifest = {
        "schema_version": "2.0",
        "rule_code": "risk.expense.travel.legacy_city_keyword",
        "name": "差旅票据路线一致性校验",
        "description": "差旅报销时读取申报目的地、明细发生地点、交通票行程城市和住宿发票城市。",
        "evaluator": "template_rule",
        "template_key": "keyword_match_v1",
        "risk_category": "差旅费",
        "inputs": {
            "fields": [
                {"key": "attachment.hotel_city", "label": "住宿城市"},
                {"key": "claim.location", "label": "申报地点"},
                {"key": "attachment.route_cities", "label": "行程城市"},
            ]
        },
        "params": {
            "template_key": "keyword_match_v1",
            "field_keys": [
                "attachment.hotel_city",
                "claim.location",
                "attachment.route_cities",
            ],
            "search_fields": [
                "attachment.hotel_city",
                "claim.location",
                "attachment.route_cities",
            ],
            "keywords": ["绕行", "跨城办事", "临时改签"],
            "condition_summary": "检查住宿城市、申报地点、行程城市是否出现规则描述中的风险关键词",
            "natural_language": (
                "差旅报销时，先检查是否已上传交通票据、住宿票据或其他能识别城市的附件；"
                "再读取申报目的地、明细发生地点、交通票行程城市和住宿发票城市。"
                "若交通票或住宿票据中的城市均无法与申报目的地、明细地点形成一致关系，"
                "且报销事由中没有说明绕行、跨城办事或临时改签原因，则标记为高风险。"
            ),
        },
        "outcomes": {"fail": {"severity": "high"}},
        "metadata": {
            "condition_summary": "检查住宿城市、申报地点、行程城市是否出现规则描述中的风险关键词",
            "flow": {"decision": "检查住宿城市、申报地点、行程城市是否出现规则描述中的风险关键词"},
        },
        "flow_diagram_svg": (
            '<svg data-risk-flow-style="review-node-only">'
            "检查住宿城市、申报地点、行程城市是否出现规则描述中的风险关键词"
            "</svg>"
        ),
    }

    normalized = normalize_risk_rule_manifest(manifest)
    assert normalized["template_key"] == "field_compare_v1"
    assert normalized["semantic_type"] == "travel_route_city_consistency"
    assert normalized["params"]["keywords"] == []
    assert "风险关键词" not in normalized["params"]["condition_summary"]
    assert "风险关键词" not in normalized["metadata"]["flow"]["decision"]
    assert "风险关键词" not in normalized["flow_diagram_svg"]

    claim = ExpenseClaim(
        claim_no="TEST-LEGACY-NORMALIZER",
        employee_name="测试员工",
        department_name="测试部门",
        expense_type="差旅费",
        reason="去上海办事",
        location="上海",
        amount=Decimal("520.00"),
        currency="CNY",
        invoice_count=1,
        occurred_at=datetime.now(UTC),
        status="draft",
    )
    claim.employee = Employee(
        employee_no="TEST-EMPLOYEE",
        name="测试员工",
        email="legacy-route-risk@example.com",
        location="武汉",
    )
    claim.items = [
        ExpenseClaimItem(
            item_date=date.today(),
            item_type="交通费",
            item_reason="去上海办事",
            item_location="上海",
            item_amount=Decimal("520.00"),
        )
    ]

    result = RiskRuleTemplateExecutor().evaluate(
        normalized,
        claim=claim,
        contexts=[{"document_info": {"route_cities": ["上海", "北京", "武汉"]}}],
    )

    assert result is not None
    assert result["evidence"]["city_consistency"]["unexpected_route_cities"] == ["北京", "武汉"]


def test_travel_route_rule_does_not_use_employee_location_as_allowed_endpoint() -> None:
    manifest = {
        "template_key": "field_compare_v1",
        "params": {
            "template_key": "field_compare_v1",
            "semantic_type": "travel_route_city_consistency",
            "field_keys": [
                "attachment.route_cities",
                "claim.location",
                "item.item_location",
                "employee.location",
                "claim.reason",
            ],
            "attachment_city_fields": ["attachment.route_cities"],
            "reference_city_fields": ["claim.location", "item.item_location"],
            "home_city_fields": ["employee.location"],
            "exception_fields": ["claim.reason"],
            "exception_keywords": ["绕行", "跨城办事", "临时改签"],
            "condition_summary": (
                "A=票据路线城市，B=申报城市，"
                "A中出现无法由本次票据起终点和申报目的地解释的额外城市则命中。"
            ),
        },
        "outcomes": {"fail": {"severity": "high"}},
    }
    claim = ExpenseClaim(
        claim_no="TEST-ROUTE-ANOMALY",
        employee_name="测试员工",
        department_name="测试部门",
        expense_type="差旅费",
        reason="去上海办事",
        location="上海",
        amount=Decimal("520.00"),
        currency="CNY",
        invoice_count=1,
        occurred_at=datetime.now(UTC),
        status="draft",
    )
    claim.employee = Employee(
        employee_no="TEST-EMPLOYEE",
        name="测试员工",
        email="route-risk@example.com",
        location="武汉",
    )
    claim.items = [
        ExpenseClaimItem(
            item_date=date.today(),
            item_type="交通费",
            item_reason="去上海办事",
            item_location="上海",
            item_amount=Decimal("520.00"),
        )
    ]

    result = RiskRuleTemplateExecutor().evaluate(
        manifest,
        claim=claim,
        contexts=[
            {
                "document_info": {
                    "route_cities": ["上海", "北京", "武汉"],
                    "fields": [
                        {
                            "key": "route_cities",
                            "label": "行程城市",
                            "value": ["上海", "北京", "武汉"],
                        }
                    ],
                },
                "ocr_text": "上海 到 北京 到 武汉",
            }
        ],
    )

    assert result is not None
    evidence = result["evidence"]["city_consistency"]
    assert evidence["reference_values"] == ["上海"]
    assert evidence["unexpected_route_cities"] == ["北京", "武汉"]
    assert "home_values" not in evidence


def test_simulation_uses_current_rule_manifest_for_ticket_city_mismatch(tmp_path) -> None:
    with build_session() as db:
        manager = AgentAssetRuleLibraryManager(rule_root=tmp_path / "rules")
        generator = RiskRuleGenerationService(
            db,
            rule_library_manager=manager,
            runtime_chat_service=NullRuntimeChatService(),
        )
        asset_id = generator.generate_rule_asset(
            AgentAssetRiskRuleGenerateRequest(
                business_domain=AgentAssetDomain.EXPENSE,
                expense_category="travel",
                rule_title="当前差旅票据城市一致性规则",
                risk_level="medium",
                natural_language="差旅报销时，交通票或住宿票据中的城市必须与申报目的地一致；未说明绕行、跨城或改签原因时标记风险。",
                requires_attachment=True,
            ),
            actor="pytest",
        )
        service = AgentAssetService(
            db,
            current_user=CurrentUserContext(
                username="pytest",
                name="pytest",
                role_codes=["manager"],
                is_admin=True,
                tenant_id="default",
            ),
        )
        service.rule_library_manager = manager
        asset = db.get(AgentAsset, asset_id)
        assert asset is not None
        manifest = manager.read_rule_library_json(
            library=RISK_RULES_LIBRARY,
            file_name=asset.config_json["rule_document"]["file_name"],
        )
        manifest["template_key"] = "keyword_match_v1"
        manifest["params"]["template_key"] = "keyword_match_v1"
        manifest["params"]["keywords"] = ["绕行", "跨城", "改签", "变更"]
        manifest["params"]["search_fields"] = [
            "attachment.hotel_city",
            "claim.location",
            "attachment.route_cities",
            "item.item_location",
        ]
        manifest["params"]["field_keys"] = manifest["params"]["search_fields"]
        manager.write_rule_library_json(
            library=RISK_RULES_LIBRARY,
            file_name=asset.config_json["rule_document"]["file_name"],
            payload=manifest,
        )

        simulation = service.simulate_risk_rule_message(
            asset_id,
            AgentAssetRiskRuleSimulationRequest(
                message="去北京出差3天",
                attachments=[
                    {
                        "name": "train-ticket.pdf",
                        "content_type": "application/pdf",
                        "ocr_text": "武汉 到 上海",
                        "summary": "高铁票 武汉-上海",
                        "document_fields": [
                            {"key": "route", "label": "行程路线", "value": "武汉-上海"}
                        ],
                    }
                ],
            ),
        )

        assert simulation.ready is True
        assert simulation.hit is True
        assert simulation.field_values["claim.location"] == "北京"
        assert simulation.field_values["attachment.route_cities"] == ["武汉", "上海"]


def test_risk_rule_requires_test_report_before_review_and_publish(tmp_path) -> None:
    with build_session() as db:
        manager = AgentAssetRuleLibraryManager(rule_root=tmp_path / "rules")
        generator = RiskRuleGenerationService(
            db,
            rule_library_manager=manager,
            runtime_chat_service=NullRuntimeChatService(),
        )
        asset_id = generator.generate_rule_asset(
            AgentAssetRiskRuleGenerateRequest(
                business_domain=AgentAssetDomain.EXPENSE,
                risk_level="high",
                natural_language="酒店发票城市必须与行程城市一致，不一致时标记高风险。",
            ),
            actor="pytest",
        )
        service = AgentAssetService(
            db,
            current_user=CurrentUserContext(
                username="pytest",
                name="pytest",
                role_codes=["manager"],
                is_admin=True,
                tenant_id="default",
            ),
        )
        service.rule_library_manager = manager

        asset = db.get(AgentAsset, asset_id)
        assert asset is not None
        try:
            service.create_review(
                asset_id,
                AgentAssetReviewCreate(
                    version=asset.working_version or "v0.1.0",
                    reviewer="manager",
                    review_status=AgentReviewStatus.PENDING,
                    review_note="送审",
                ),
                actor="pytest",
            )
        except PermissionError as exc:
            assert "测试通过" in str(exc)
        else:
            raise AssertionError("未测试通过的风险规则不应允许提交审核")

        simulation = service.simulate_risk_rule_message(
            asset_id,
            AgentAssetRiskRuleSimulationRequest(
                message="我想仿真一张酒店报销单，酒店发票城市上海，申报目的地北京，金额580元。",
            ),
        )
        assert simulation.execution_mode == "risk_rule_simulation"
        assert simulation.ready is True
        assert simulation.hit is True
        assert simulation.severity == "high"
        assert "不创建业务单据" in simulation.summary
        assert service.get_latest_risk_rule_test_summary(asset_id).sample is None

        blocked_simulation = service.simulate_risk_rule_message(
            asset_id,
            AgentAssetRiskRuleSimulationRequest(
                message="请识别上传单据是否命中风险规则。",
                attachments=[{"name": "hotel-invoice.pdf", "content_type": "application/pdf"}],
            ),
        )
        assert blocked_simulation.ready is False
        assert blocked_simulation.stage == "needs_recognition"
        assert blocked_simulation.hit is False
        assert "尚未完成识别" in blocked_simulation.summary

        db.add(
            ExpenseClaim(
                claim_no="TEST-CLAIM-001",
                employee_name="张三",
                department_name="财务部",
                expense_type="住宿费",
                reason="北京出差住宿",
                location="北京",
                amount=Decimal("300.00"),
                currency="CNY",
                invoice_count=0,
                occurred_at=datetime.now(UTC),
                created_at=datetime.now(UTC),
                status="draft",
            )
        )
        db.commit()

        sample = service.run_risk_rule_sample_test(
            asset_id,
            AgentAssetRiskRuleSampleTestRequest(),
            actor="pytest",
        )
        assert sample.passed is True

        scenario = service.run_risk_rule_scenario_test(
            asset_id,
            AgentAssetRiskRuleScenarioTestRequest(
                target_tenant_id="default",
                intent="用最近30天的住宿报销单试运行",
            ),
            actor="pytest",
        )
        assert scenario.passed is True
        assert scenario.result_json["total_count"] == 1

        report = service.confirm_risk_rule_test_report(
            asset_id,
            AgentAssetRiskRuleReportRequest(confirm_passed=True),
            actor="pytest",
        )
        assert report.passed is True

        for case in sample.input_json["cases"]:
            db.add(
                GoldenCase(
                    case_key=f"{asset.code}:{case['case_id']}",
                    rule_code=asset.code,
                    name=case["name"],
                    values_json=case["values"],
                    expected_hit=case["expected_hit"],
                    expected_severity=case["expected_severity"],
                    status="active",
                    source="pytest",
                )
            )
        db.commit()

        review = service.create_review(
            asset_id,
            AgentAssetReviewCreate(
                version=asset.working_version or "v0.1.0",
                reviewer="manager",
                review_status=AgentReviewStatus.PENDING,
                review_note="送审",
            ),
            actor="pytest",
        )
        assert review.review_status == AgentReviewStatus.PENDING.value
        published = service.publish_risk_rule(asset_id, actor="manager")
        assert published.status == AgentAssetStatus.REVIEW.value
        assert published.published_version is None
        assert published.config_json["release_guard"]["stage"] == "shadow"
        approved = service.create_review(
            asset_id,
            AgentAssetReviewCreate(
                version=asset.working_version or "v0.1.0",
                reviewer="manager",
                review_status=AgentReviewStatus.APPROVED,
                review_note="批准进入灰度发布",
            ),
            actor="manager",
        )
        assert approved.review_status == AgentReviewStatus.APPROVED.value
        release_guard = AgentAssetReleaseGuardService(
            db,
            rule_library_manager=manager,
        )
        release_guard.record_evaluation(
            asset_id,
            ReleaseEvaluationInput(
                total=20,
                failure_count=0,
                precision=1.0,
                details={
                    "metric_source": "release_runtime_telemetry",
                    "negative_ground_truth_status": "available",
                    "recall_lower_bound": 1.0,
                },
            ),
            actor="monitor",
        )
        release_guard.promote(asset_id, actor="manager")
        release_guard.record_evaluation(
            asset_id,
            ReleaseEvaluationInput(
                total=100,
                failure_count=0,
                precision=1.0,
                details={
                    "metric_source": "release_runtime_telemetry",
                    "negative_ground_truth_status": "available",
                    "recall_lower_bound": 1.0,
                },
            ),
            actor="monitor",
        )
        release_guard.promote(asset_id, actor="manager")
        published = db.get(AgentAsset, asset_id)
        assert published is not None
        assert published.status == AgentAssetStatus.ACTIVE.value
        assert published.published_version == asset.working_version
        golden_run = service.repository.get_latest_test_run(
            asset_id,
            version=asset.working_version,
            test_type="golden",
        )
        assert golden_run is not None
        assert golden_run.passed is True

        disabled = service.set_risk_rule_enabled(
            asset_id,
            enabled=False,
            actor="manager",
        )
        assert disabled.status == AgentAssetStatus.DISABLED.value
        assert disabled.published_version == asset.working_version
        assert disabled.config_json["enabled"] is False
        assert disabled.config_json["last_operation"]["action"] == "offline"
        rule_document = disabled.config_json["rule_document"]
        manifest = manager.read_rule_library_json(
            library=RISK_RULES_LIBRARY,
            file_name=rule_document["file_name"],
        )
        assert manifest["enabled"] is False

        enabled = service.set_risk_rule_enabled(asset_id, enabled=True, actor="manager")
        assert enabled.status == AgentAssetStatus.ACTIVE.value
        assert enabled.config_json["enabled"] is True
        assert enabled.config_json["last_operation"]["action"] == "online"

        attachment_required_id = generator.generate_rule_asset(
            AgentAssetRiskRuleGenerateRequest(
                business_domain=AgentAssetDomain.EXPENSE,
                risk_level="medium",
                natural_language="发票号码不能为空，缺失时进入中风险复核。",
                requires_attachment=True,
            ),
            actor="pytest",
        )
        attachment_required_asset = db.get(AgentAsset, attachment_required_id)
        assert attachment_required_asset is not None
        assert attachment_required_asset.config_json["requires_attachment"] is True
        attachment_rule_document = attachment_required_asset.config_json["rule_document"]
        attachment_manifest = manager.read_rule_library_json(
            library=RISK_RULES_LIBRARY,
            file_name=attachment_rule_document["file_name"],
        )
        assert attachment_manifest["requires_attachment"] is True
        no_attachment_simulation = service.simulate_risk_rule_message(
            attachment_required_id,
            AgentAssetRiskRuleSimulationRequest(message="请测试这条规则。"),
        )
        assert no_attachment_simulation.ready is False
        assert no_attachment_simulation.stage == "needs_attachment"

        attachment_only_simulation = service.simulate_risk_rule_message(
            attachment_required_id,
            AgentAssetRiskRuleSimulationRequest(
                message="请识别上传单据是否命中风险规则。",
                attachments=[
                    {
                        "name": "invoice.pdf",
                        "content_type": "application/pdf",
                        "document_fields": [
                            {"key": "invoice_no", "label": "发票号码", "value": "INV-001"}
                        ],
                    }
                ],
            ),
        )
        assert attachment_only_simulation.ready is False
        assert attachment_only_simulation.stage == "needs_test_intent"


def test_delete_unpublished_risk_rule_removes_asset_and_json_file(tmp_path) -> None:
    with build_session() as db:
        manager = AgentAssetRuleLibraryManager(rule_root=tmp_path / "rules")
        asset_id = RiskRuleGenerationService(
            db,
            rule_library_manager=manager,
            runtime_chat_service=NullRuntimeChatService(),
        ).generate_rule_asset(
            AgentAssetRiskRuleGenerateRequest(
                business_domain=AgentAssetDomain.EXPENSE,
                risk_level="medium",
                natural_language="报销事由不能为空，缺失时进入中风险复核。",
            ),
            actor="pytest",
        )
        asset = db.get(AgentAsset, asset_id)
        assert asset is not None
        file_name = asset.config_json["rule_document"]["file_name"]
        rule_path = tmp_path / "rules" / RISK_RULES_LIBRARY / file_name
        assert rule_path.exists()

        service = AgentAssetService(db)
        service.rule_library_manager = manager
        service.delete_unpublished_asset(asset_id, actor="pytest")

        assert db.get(AgentAsset, asset_id) is None
        assert not rule_path.exists()
