"""预算系统技术改进测试 (2026-08-28 yanxue-budget-tech-improve)

覆盖:
  P1-③ 告警归因(alert_type/attribution/scenario_id + 详情接口)
  P1-④ 实际值自动归集(映射CRUD/采集器/覆盖率)
  P2-① 真零基逐项论证(CRUD/generate/method-comparison is_demo)
  P2-② 派生规则可配置(规则CRUD/apply-method rule_source)
  P2-⑤ 双路径合并(两出口级别一致, 无第二套阈值逻辑)
  P2-⑥ 现金流分类规则(待分类队列/一键归类)
"""
import pytest
from fastapi.testclient import TestClient
from sqlalchemy.orm import Session

from tests.conftest import create_test_user, get_token_for_user, auth_header, create_test_kpi
from app.models import (
    BudgetPlan, KPIValue, BudgetDeviationAlert, KPIAlert,
    KPIValueSource, KPIValueCollectLog,
    BudgetZeroBasedItem, BudgetDerivationRule,
    CashPlanClassifyRule, CashPlanUnclassified, CashPlan,
    ScenarioSuggestion, KPIDefinition,
)


class TestP1AlertAttribution:
    """P1-③ 告警归因: 告警从'差多少'到'差在哪+怎么办'"""

    BASE = "/api/cma/budget"

    def _setup_alert(self, client, db, kpi_code="ATTRIB_KPI", kpi_name="销售费用", actual=150.0, budget=100.0):
        create_test_user(db)
        token = get_token_for_user(client)
        kpi = create_test_kpi(db, kpi_code=kpi_code, kpi_name=kpi_name)
        db.add(BudgetPlan(kpi_id=kpi.id, period="2026-06", budget_value=budget,
                          budget_year=2026, budget_month=6, status="active"))
        db.add(KPIValue(kpi_id=kpi.id, period="2026-06", actual_value=actual))
        db.commit()
        resp = client.post(f"{self.BASE}/deviation-check", headers=auth_header(token),
                           json={"period": "2026-06", "threshold": 20})
        assert resp.status_code == 200
        return token, kpi, resp

    def test_deviation_check_writes_attribution(self, client: TestClient, db: Session):
        """生成告警时同步写 alert_type/attribution/scenario_id"""
        token, kpi, resp = self._setup_alert(client, db, actual=150.0, budget=100.0)
        assert resp.json()["alerts_generated"] == 1

        alert = db.query(BudgetDeviationAlert).filter(BudgetDeviationAlert.kpi_id == kpi.id).first()
        assert alert is not None
        assert alert.alert_level == "warning"
        # 归因JSON结构
        assert alert.attribution is not None
        attr = alert.attribution
        assert "dimensions" in attr and "subjects" in attr
        assert "variance_type" in attr and "trend" in attr
        assert attr["variance_type"] in ("quantity_diff", "price_diff", "mixed")
        assert "anomaly" in attr["trend"]
        # 场景建议关联（费用类KPI → cost_high 模板）
        if alert.scenario_id:
            s = db.query(ScenarioSuggestion).filter(ScenarioSuggestion.id == alert.scenario_id).first()
            assert s is not None
            assert s.alert_type in ("cash_low", "cash_critical", "cost_high", "revenue_drop")

    def test_attribution_detail_endpoint(self, client: TestClient, db: Session):
        """GET /deviation-alerts/{id}/attribution 返回归因+场景建议"""
        token, kpi, _ = self._setup_alert(client, db, actual=200.0, budget=100.0)
        alert = db.query(BudgetDeviationAlert).filter(BudgetDeviationAlert.kpi_id == kpi.id).first()

        resp = client.get(f"{self.BASE}/deviation-alerts/{alert.id}/attribution",
                          headers=auth_header(token))
        assert resp.status_code == 200
        data = resp.json()
        assert data["attribution"] != {}
        assert "dimensions" in data["attribution"]
        # scenario 建议联查（无匹配时可空, 有模板时必须带文本）
        if data["scenario"]:
            assert data["scenario"]["title"]

    def test_list_alerts_has_attribution_fields(self, client: TestClient, db: Session):
        """列表响应新增 alert_type/attribution/scenario_id 字段（可空）"""
        token, kpi, _ = self._setup_alert(client, db)
        resp = client.get(f"{self.BASE}/deviation-alerts", headers=auth_header(token))
        row = resp.json()["data"][0]
        assert "alert_type" in row
        assert "attribution" in row
        assert "scenario_id" in row

    def test_alert_direction_config(self, client: TestClient, db: Session):
        """P2-⑤ 方向配置 GET/PUT system_configs"""
        create_test_user(db)
        token = get_token_for_user(client)

        resp = client.get(f"{self.BASE}/alert-direction", headers=auth_header(token))
        assert resp.status_code == 200
        assert "SALES_TOTAL" in resp.json()["codes"]

        resp2 = client.put(f"{self.BASE}/alert-direction", headers=auth_header(token),
                           json={"codes": ["SALES_TOTAL", "CUSTOM_COUNT"]})
        assert resp2.status_code == 200
        assert resp2.json()["codes"] == ["SALES_TOTAL", "CUSTOM_COUNT"]

        resp3 = client.get(f"{self.BASE}/alert-direction", headers=auth_header(token))
        assert resp3.json()["codes"] == ["SALES_TOTAL", "CUSTOM_COUNT"]
        assert resp3.json()["is_configured"] is True


class TestP1ValueCollect:
    """P1-④ 实际值自动归集"""

    BASE = "/api/cma/budget"

    def test_value_source_crud_and_collect(self, client: TestClient, db: Session):
        """映射CRUD → 采集器 → kpi_values 出现 auto_collect"""
        create_test_user(db)
        token = get_token_for_user(client)
        kpi = create_test_kpi(db, kpi_code="COLLECT_KPI")

        # 建映射
        resp = client.post(f"{self.BASE}/value-sources", headers=auth_header(token), json={
            "kpi_id": kpi.id,
            "source_table": "voucher_details",
            "source_field": "credit_amount",
            "aggregate": "sum",
            "filter_rule": {"direction": "credit"},
            "period_field": "period",
            "unit_conversion": 1,
        })
        assert resp.status_code == 200

        # 采集器试跑（不写库）
        test_resp = client.post(f"{self.BASE}/value-sources/test", headers=auth_header(token), json={
            "kpi_id": kpi.id,
            "source_table": "voucher_details",
            "source_field": "credit_amount",
            "aggregate": "sum",
            "filter_rule": {"direction": "credit"},
            "period_field": "period",
        })
        assert test_resp.status_code == 200
        assert test_resp.json()["value"] is not None

        # 手动触发采集
        run_resp = client.post(f"{self.BASE}/value-collect/run", headers=auth_header(token),
                               json={"period": "2026-06"})
        assert run_resp.status_code == 200
        assert run_resp.json()["collected"] >= 1

        # 验证 kpi_values 落库
        val = db.query(KPIValue).filter(
            KPIValue.kpi_id == kpi.id,
            KPIValue.period == "2026-06",
            KPIValue.source_type == "auto_collect",
        ).first()
        assert val is not None
        assert val.actual_value is not None
        assert val.remark and "自动归集" in val.remark

        # 采集日志
        logs = db.query(KPIValueCollectLog).filter(KPIValueCollectLog.kpi_id == kpi.id).all()
        assert len(logs) >= 1

        # 覆盖率
        cov = client.get(f"{self.BASE}/value-sources/coverage", headers=auth_header(token))
        assert cov.status_code == 200
        assert cov.json()["mapped_count"] >= 1

    def test_collector_idempotent(self, client: TestClient, db: Session):
        """同kpi+period 重复采集 → 更新不新增"""
        create_test_user(db)
        token = get_token_for_user(client)
        kpi = create_test_kpi(db, kpi_code="COLLECT_IDEMP")
        client.post(f"{self.BASE}/value-sources", headers=auth_header(token), json={
            "kpi_id": kpi.id, "source_table": "voucher_details",
            "source_field": "credit_amount", "aggregate": "sum",
        })
        client.post(f"{self.BASE}/value-collect/run", headers=auth_header(token), json={"period": "2026-06"})
        client.post(f"{self.BASE}/value-collect/run", headers=auth_header(token), json={"period": "2026-06"})

        rows = db.query(KPIValue).filter(
            KPIValue.kpi_id == kpi.id,
            KPIValue.period == "2026-06",
            KPIValue.source_type == "auto_collect",
        ).all()
        assert len(rows) == 1

    def test_collector_logs_filter(self, client: TestClient, db: Session):
        """采集日志 status 过滤"""
        create_test_user(db)
        token = get_token_for_user(client)
        kpi = create_test_kpi(db, kpi_code="COLLECT_LOG")
        client.post(f"{self.BASE}/value-sources", headers=auth_header(token), json={
            "kpi_id": kpi.id, "source_table": "voucher_details",
            "source_field": "credit_amount", "aggregate": "sum",
        })
        client.post(f"{self.BASE}/value-collect/run", headers=auth_header(token), json={"period": "2026-06"})
        resp = client.get(f"{self.BASE}/value-collect/logs", headers=auth_header(token),
                          params={"status": "success"})
        assert resp.json()["total"] >= 1


class TestP2ZeroBased:
    """P2-① 真零基逐项论证"""

    BASE = "/api/cma/budget"

    def _setup_kpi_with_plans(self, client, db):
        create_test_user(db)
        token = get_token_for_user(client)
        # 核心4KPI（apply-method 需要）
        for code in ("F_REVENUE", "F_NET_PROFIT", "F_COST_RATIO", "F_GROSS_MARGIN"):
            create_test_kpi(db, kpi_code=code, kpi_name=code)
        kpi = db.query(KPIDefinition).filter(KPIDefinition.kpi_code == "F_REVENUE").first()
        return token, kpi

    def test_zero_based_items_crud_and_generate(self, client: TestClient, db: Session):
        """录入3个科目 → 逐项论证 → generate → budget_plans 出现且金额=Σ建议值"""
        token, kpi = self._setup_kpi_with_plans(client, db)

        # 录入3个论证项
        items = [
            {"item_name": "房租", "item_category": "fixed", "base_value": 15, "proposed_value": 15, "justification": "合同锁定"},
            {"item_name": "招待费", "item_category": "discretionary", "base_value": 16, "proposed_value": 8, "justification": "压缩50%"},
            {"item_name": "杂项", "item_category": "discretionary", "base_value": 12, "proposed_value": 8, "justification": "压缩30%"},
        ]
        for it in items:
            r = client.post(f"{self.BASE}/zero-based/items", headers=auth_header(token), json={
                "kpi_id": kpi.id, "period": "2026-06", **it,
            })
            assert r.status_code == 200

        # 列表+合计
        lst = client.get(f"{self.BASE}/zero-based/items", headers=auth_header(token),
                         params={"kpi_id": kpi.id, "period": "2026-06"})
        assert lst.json()["total"] == 3
        assert lst.json()["total_proposed"] == 31.0

        # generate → budget_plans
        gen = client.post(f"{self.BASE}/zero-based/generate", headers=auth_header(token),
                          json={"kpi_id": kpi.id, "period": "2026-06"})
        assert gen.status_code == 200
        assert gen.json()["total"] == 31.0

        plan = db.query(BudgetPlan).filter(
            BudgetPlan.kpi_id == kpi.id,
            BudgetPlan.period == "2026-06",
            BudgetPlan.version.like("zbb-%"),
        ).first()
        assert plan is not None
        assert plan.budget_value == 31.0
        assert plan.calc_logic == "zero_based_itemized"

    def test_method_comparison_zero_based_is_demo_false(self, client: TestClient, db: Session):
        """method-comparison 传论证KPI → is_demo=false; 不传 → is_demo=true"""
        token, kpi = self._setup_kpi_with_plans(client, db)
        client.post(f"{self.BASE}/zero-based/items", headers=auth_header(token), json={
            "kpi_id": kpi.id, "period": "2026-06",
            "item_name": "房租", "item_category": "fixed",
            "base_value": 15, "proposed_value": 15,
        })

        # 有论证项 → 真零基
        r1 = client.post(f"{self.BASE}/method-comparison", headers=auth_header(token), json={
            "zero_based_kpi_id": kpi.id, "zero_based_period": "2026-06",
        })
        zbb1 = [m for m in r1.json()["methods"] if m["id"] == "zero_based"][0]
        assert zbb1["is_demo"] is False
        assert zbb1["item_count"] == 1

        # 无论证项 → demo fallback
        r2 = client.post(f"{self.BASE}/method-comparison", headers=auth_header(token), json={})
        zbb2 = [m for m in r2.json()["methods"] if m["id"] == "zero_based"][0]
        assert zbb2["is_demo"] is True

    def test_apply_method_zero_based_writes_plan(self, client: TestClient, db: Session):
        """apply-method zero_based → 落库 zbb 版本"""
        token, kpi = self._setup_kpi_with_plans(client, db)
        client.post(f"{self.BASE}/zero-based/items", headers=auth_header(token), json={
            "kpi_id": kpi.id, "period": "2026-06",
            "item_name": "房租", "item_category": "fixed",
            "base_value": 15, "proposed_value": 15,
        })
        r = client.post(f"{self.BASE}/apply-method", headers=auth_header(token), json={
            "method": "zero_based", "year": 2026,
            "zero_based_kpi_id": kpi.id, "zero_based_period": "2026-06",
        })
        assert r.status_code == 200


class TestP2DerivationRules:
    """P2-② 派生规则可配置"""

    BASE = "/api/cma/budget"

    def _setup(self, client, db):
        create_test_user(db)
        token = get_token_for_user(client)
        for code in ("F_REVENUE", "F_NET_PROFIT", "F_COST_RATIO", "F_GROSS_MARGIN"):
            create_test_kpi(db, kpi_code=code, kpi_name=code)
        return token

    def test_rule_crud_and_apply(self, client: TestClient, db: Session):
        """配置 F_NET_PROFIT 派生率 5% → apply-method → rule_source=configured 且结果变化"""
        token = self._setup(client, db)
        rev = db.query(KPIDefinition).filter(KPIDefinition.kpi_code == "F_REVENUE").first()
        np_kpi = db.query(KPIDefinition).filter(KPIDefinition.kpi_code == "F_NET_PROFIT").first()

        # 无规则时 apply → default 比例(2%)
        r_default = client.post(f"{self.BASE}/apply-method", headers=auth_header(token),
                                json={"method": "incremental", "year": 2026})
        np_default = [a for a in r_default.json()["applied"] if a["kpi_code"] == "F_NET_PROFIT"][0]
        assert np_default["rule_source"] == "default"
        assert r_default.json()["rule_source"] == "default"

        # 建规则: percentage_of → 来源F_REVENUE × 5%
        # 先给来源KPI实际值（真实链路: base_kpi实际值 × rate）
        db.add(KPIValue(kpi_id=rev.id, period="2026-05", actual_value=2000.0))
        db.commit()
        r_rule = client.post(f"{self.BASE}/derivation-rules", headers=auth_header(token), json={
            "kpi_id": np_kpi.id,
            "rule_type": "percentage_of",
            "base_kpi_id": rev.id,
            "params": {"rate": 0.05},
            "formula_text": "净利润 = 营业收入 × 5%",
        })
        assert r_rule.status_code == 200

        # 配置后 apply → rule_source=configured, 金额=2000×5%=100
        r2 = client.post(f"{self.BASE}/apply-method", headers=auth_header(token),
                         json={"method": "incremental", "year": 2026})
        assert r2.json()["rule_source"] == "configured"
        np_after = [a for a in r2.json()["applied"] if a["kpi_code"] == "F_NET_PROFIT"][0]
        assert np_after["rule_source"] == "configured"
        assert np_after["budget_value"] == 100.0

        # 规则列表
        lst = client.get(f"{self.BASE}/derivation-rules", headers=auth_header(token))
        assert lst.json()["total"] == 1
        assert lst.json()["data"][0]["rule_type"] == "percentage_of"


class TestP2SingleAlertPath:
    """P2-⑤ 双路径合并: 单一告警逻辑, 两出口级别一致"""

    BASE = "/api/cma/budget"

    def test_single_build_function_two_exits(self, client: TestClient, db: Session):
        """run_deviation_check 走统一逻辑写 KPIAlert; deviation-check 写 budget_deviation_alerts"""
        from app.utils.deviation_engine import build_deviation_alert, run_deviation_check

        create_test_user(db)
        token = get_token_for_user(client)
        kpi = create_test_kpi(db, kpi_code="SINGLE_PATH_KPI", kpi_name="测试成本")
        db.add(BudgetPlan(kpi_id=kpi.id, period="2026-06", budget_value=100.0,
                          budget_year=2026, budget_month=6, status="active"))
        db.add(KPIValue(kpi_id=kpi.id, period="2026-06", actual_value=160.0))  # 60% 超支
        db.commit()

        # KPIAlert 出口: 级别 red(≥30)
        r = build_deviation_alert(db, kpi, "2026-06")
        assert r["triggered"] is True
        assert r["kpi_alert_level"] == "red"
        assert r["level"] == "critical"  # >50

        # budget 出口 API: deviation-check
        resp = client.post(f"{self.BASE}/deviation-check", headers=auth_header(token),
                           json={"period": "2026-06", "threshold": 20})
        assert resp.json()["alerts_generated"] == 1

        # run_deviation_check 写 KPIAlert
        n = run_deviation_check(db, "2026-06")
        assert n >= 1
        kpi_alert = db.query(KPIAlert).filter(
            KPIAlert.kpi_id == kpi.id,
            KPIAlert.alert_message.contains("[差异预警]"),
        ).first()
        assert kpi_alert is not None
        assert kpi_alert.alert_level == "red"
        assert kpi_alert.suggestion  # 非模板空文案

    def test_no_second_threshold_logic(self, client: TestClient, db: Session):
        """deviation_engine 中不应再有独立阈值/方向列表（grep 验证在代码review, 此处测函数可用）"""
        from app.utils.deviation_engine import build_deviation_alert, get_higher_better_codes
        create_test_user(db)
        token = get_token_for_user(client)
        kpi = create_test_kpi(db, kpi_code="HB_KPI", kpi_name="营业收入")
        assert "SALES_TOTAL" in get_higher_better_codes(db)


class TestP2CashClassify:
    """P2-⑥ 现金流分类规则表"""

    BASE = "/api/cma/budget"

    def test_unclassified_queue_and_classify(self, client: TestClient, db: Session):
        """无关键词KPI → sync-cash-plans → 待分类队列(不静默跳过) → 一键归类 → CashPlan"""
        create_test_user(db)
        token = get_token_for_user(client)
        # 无任何关键词的KPI（不会命中默认关键词）
        kpi = create_test_kpi(db, kpi_code="MYSTERY_KPI", kpi_name="部门专项投入待定")
        # 移除'投入'关键词冲突: 名称改无关键词
        kpi.kpi_name = "神秘专项"
        db.commit()

        client.post(f"{self.BASE}/plans", headers=auth_header(token),
                    json={"kpi_id": kpi.id, "period": "2026-06", "budget_value": 300.0})

        # sync → 进待分类队列
        resp = client.post(f"{self.BASE}/sync-cash-plans", headers=auth_header(token))
        assert resp.status_code == 200
        assert resp.json()["unclassified_count"] >= 1

        item = db.query(CashPlanUnclassified).filter(
            CashPlanUnclassified.kpi_id == kpi.id,
            CashPlanUnclassified.status == "pending",
        ).first()
        assert item is not None
        assert item.reason == "未匹配任何分类规则"

        # 队列列表
        lst = client.get(f"{self.BASE}/cash-unclassified", headers=auth_header(token),
                         params={"status": "pending"})
        assert any(r["kpi_id"] == kpi.id for r in lst.json()["data"])

        # 一键归类 receive
        cls = client.post(f"{self.BASE}/cash-unclassified/{item.id}/classify", headers=auth_header(token),
                          json={"plan_type": "receive"})
        assert cls.status_code == 200
        assert cls.json()["rule_created"] is True

        # 规则自动补建 + CashPlan 生成
        rule = db.query(CashPlanClassifyRule).filter(
            CashPlanClassifyRule.entity_id == 1,
            CashPlanClassifyRule.kpi_id == kpi.id,
        ).first()
        assert rule is not None and rule.plan_type == "receive"

        plan = db.query(CashPlan).filter(CashPlan.related_kpi_id == kpi.id).first()
        assert plan is not None and plan.plan_type == "receive"

        # 队列状态 → classified
        db.refresh(item)
        assert item.status == "classified"

    def test_rule_priority_over_keyword(self, client: TestClient, db: Session):
        """规则表精确匹配优先于默认关键词"""
        create_test_user(db)
        token = get_token_for_user(client)
        kpi = create_test_kpi(db, kpi_code="OVERRIDE_KPI", kpi_name="营业收入")  # 默认会命中 receive

        # 规则表强制 pay
        r = client.post(f"{self.BASE}/cash-classify-rules", headers=auth_header(token), json={
            "kpi_id": kpi.id, "plan_type": "pay", "priority": 1,
        })
        assert r.status_code == 200

        client.post(f"{self.BASE}/plans", headers=auth_header(token),
                    json={"kpi_id": kpi.id, "period": "2026-06", "budget_value": 500.0})
        client.post(f"{self.BASE}/sync-cash-plans", headers=auth_header(token))

        plan = db.query(CashPlan).filter(CashPlan.related_kpi_id == kpi.id).first()
        assert plan is not None
        assert plan.plan_type == "pay"  # 规则覆盖关键词
