from __future__ import annotations

from datetime import UTC, date, datetime, timedelta
from decimal import Decimal

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

from app.db.base import Base
from app.models.approval_task import ApprovalTask
from app.models.financial_record import ExpenseClaim, ExpenseClaimItem
from app.models.risk_disposition import RiskDisposition
from app.models.risk_observation import RiskObservation
from app.services.approval_task_projection import ApprovalTaskProjectionService
from app.services.approval_task_projection_refresh import (
    ApprovalTaskProjectionRefreshService,
)
from app.services.risk_observations import RiskObservationService


def _claim(
    *,
    claim_id: str = "claim-projection-1",
    claim_no: str = "RE-PROJECTION-1",
    amount: str = "888.00",
    invoice_count: int = 1,
    risk_flags: list[dict] | None = None,
) -> ExpenseClaim:
    occurred_at = datetime(2026, 7, 15, 9, 0, tzinfo=UTC)
    claim = ExpenseClaim(
        id=claim_id,
        claim_no=claim_no,
        employee_name="张三",
        department_name="市场部",
        project_code="PRJ-PROJECTION",
        expense_type="application" if claim_no.startswith("AP-") else "travel",
        reason="客户现场差旅",
        location="上海",
        amount=Decimal(amount),
        currency="CNY",
        invoice_count=invoice_count,
        occurred_at=occurred_at,
        submitted_at=occurred_at + timedelta(hours=1),
        status="submitted",
        approval_stage="直属领导审批",
        risk_flags_json=list(risk_flags or []),
        created_at=occurred_at,
        updated_at=occurred_at + timedelta(hours=1),
    )
    claim.items = [
        ExpenseClaimItem(
            id=f"item-{claim_id}",
            claim_id=claim_id,
            item_date=date(2026, 7, 15),
            item_type="hotel",
            item_reason="住宿",
            item_location="上海",
            item_note="",
            item_amount=Decimal(amount),
            invoice_id="INV-PROJECTION" if invoice_count else None,
            created_at=occurred_at,
            updated_at=occurred_at,
        )
    ]
    return claim


def test_projection_materializes_explainable_priority_and_safe_batch_blocks() -> None:
    now = datetime(2026, 7, 16, 12, 0, tzinfo=UTC)
    claim = _claim(
        amount="60000.00",
        risk_flags=[
            {
                "source": "ai_pre_review",
                "severity": "high",
                "disposition": "review",
                "route_decision": {"budget_result": {"metrics": {"after_usage_rate": "0.96"}}},
            }
        ],
    )

    projection = ApprovalTaskProjectionService(None).build(  # type: ignore[arg-type]
        claim,
        tenant_id="tenant-a",
        entered_at=now - timedelta(hours=26),
        now=now,
        observation_rows=[],
    )

    reason_codes = {item["code"] for item in projection.priority_reasons_json}
    assert projection.priority_score >= 85
    assert projection.priority_tier == "urgent"
    assert projection.risk_level == "high"
    assert projection.open_risk_count == 1
    assert projection.due_at == now - timedelta(hours=2)
    assert projection.next_escalation_at == projection.due_at
    assert reason_codes >= {
        "open_risk",
        "sla_overdue",
        "budget_pressure",
        "large_amount",
    }
    assert projection.batch_eligible is False
    assert set(projection.batch_block_reasons_json) >= {
        "open_risk",
        "amount_requires_individual_review",
        "budget_pressure",
        "sla_overdue",
    }


def test_resolved_materialized_risk_suppresses_stale_raw_flag_and_application_invoice() -> None:
    now = datetime(2026, 7, 16, 12, 0, tzinfo=UTC)
    claim = _claim(
        claim_id="claim-projection-resolved",
        claim_no="AP-PROJECTION-1",
        invoice_count=0,
        risk_flags=[
            {
                "severity": "critical",
                "triggered": True,
                "observation_key": "risk:projection:resolved",
            }
        ],
    )
    observation = RiskObservation(
        id="observation-projection-resolved",
        tenant_id="tenant-a",
        observation_key="risk:projection:resolved",
        subject_type="expense_claim",
        subject_key=f"claim:{claim.id}",
        subject_label=claim.claim_no,
        claim_id=claim.id,
        claim_no=claim.claim_no,
        risk_type="duplicate_invoice",
        risk_signal="duplicate_invoice",
        title="重复票据",
        description="已人工复核完成。",
        risk_score=98,
        risk_level="critical",
        confidence_score=0.99,
        control_stage="expense_application",
        control_mode="risk_observation",
        automation_mode="manual",
        source="financial_risk_graph",
        algorithm_version="v1",
        status="resolved",
        feedback_status="confirmed",
    )
    disposition = RiskDisposition(
        id="disposition-projection-resolved",
        tenant_id="tenant-a",
        observation_id=observation.id,
        adjudication="confirmed",
        lifecycle_status="resolved",
        version=2,
    )

    projection = ApprovalTaskProjectionService(None).build(  # type: ignore[arg-type]
        claim,
        tenant_id="tenant-a",
        entered_at=now - timedelta(hours=1),
        now=now,
        observation_rows=[(observation, disposition)],
    )

    assert projection.risk_level == "low"
    assert projection.open_risk_count == 0
    assert projection.evidence_completeness == Decimal("1.0000")
    assert projection.priority_score == 0
    assert projection.priority_reasons_json[0]["code"] == "routine"
    assert projection.batch_eligible is True
    assert projection.batch_block_reasons_json == ()


def test_projection_marks_missing_evidence_and_apply_updates_task_without_persistence() -> None:
    now = datetime(2026, 7, 16, 12, 0, tzinfo=UTC)
    claim = _claim(invoice_count=0)
    claim.location = ""
    claim.items = []
    projection = ApprovalTaskProjectionService(None).build(  # type: ignore[arg-type]
        claim,
        tenant_id="tenant-a",
        entered_at=now - timedelta(hours=1),
        now=now,
        observation_rows=[],
    )
    task = ApprovalTask()

    returned = ApprovalTaskProjectionService.apply(task, projection)

    assert returned is task
    assert task.evidence_completeness == Decimal("0.2500")
    assert task.batch_eligible is False
    assert "evidence_incomplete" in task.batch_block_reasons_json
    assert {item["code"] for item in task.priority_reasons_json} >= {"evidence_gap"}


def test_projection_rejects_ambiguous_time_and_invalid_sla() -> None:
    claim = _claim()
    service = ApprovalTaskProjectionService(None)  # type: ignore[arg-type]
    with pytest.raises(ValueError, match="entered_at.*timezone"):
        service.build(
            claim,
            tenant_id="tenant-a",
            entered_at=datetime(2026, 7, 16, 10, 0),
            observation_rows=[],
        )
    with pytest.raises(ValueError, match="sla_hours"):
        service.build(
            claim,
            tenant_id="tenant-a",
            entered_at=datetime(2026, 7, 16, 10, 0, tzinfo=UTC),
            sla_hours=0,
            observation_rows=[],
        )


def test_apply_convenience_form_uses_claim_session_for_tenant_risks() -> None:
    engine = create_engine(
        "sqlite+pysqlite:///:memory:",
        connect_args={"check_same_thread": False},
        poolclass=StaticPool,
    )
    Base.metadata.create_all(bind=engine)
    db_factory = sessionmaker(bind=engine, autoflush=False, expire_on_commit=False)
    now = datetime(2026, 7, 16, 12, 0, tzinfo=UTC)
    with db_factory() as db:
        claim = _claim(claim_id="claim-projection-session")
        observation = RiskObservation(
            id="observation-projection-session",
            tenant_id="tenant-a",
            observation_key="risk:projection:session",
            subject_type="expense_claim",
            subject_key=f"claim:{claim.id}",
            subject_label=claim.claim_no,
            claim_id=claim.id,
            claim_no=claim.claim_no,
            risk_type="policy_violation",
            risk_signal="policy_violation",
            title="政策风险",
            description="等待人工处置。",
            risk_score=80,
            risk_level="high",
            confidence_score=0.9,
            control_stage="reimbursement",
            control_mode="risk_observation",
            automation_mode="manual",
            source="financial_risk_graph",
            algorithm_version="v1",
            status="pending_review",
            feedback_status="unreviewed",
        )
        db.add_all([claim, observation])
        db.flush()
        task = ApprovalTask(
            tenant_id="tenant-a",
            entered_at=now - timedelta(hours=1),
            sla_hours_snapshot=12,
        )

        returned = ApprovalTaskProjectionService.apply(task, claim=claim, now=now)

        assert returned is task
        assert task.risk_level == "high"
        assert task.open_risk_count == 1
        assert task.due_at == now + timedelta(hours=11)


def test_projection_filters_other_business_stage_and_only_honors_active_waiver() -> None:
    now = datetime(2026, 7, 16, 12, 0, tzinfo=UTC)
    claim = _claim(
        claim_id="claim-projection-waiver",
        claim_no="AP-PROJECTION-WAIVER",
        risk_flags=[
            {
                "severity": "critical",
                "triggered": True,
                "business_stage": "reimbursement",
            }
        ],
    )
    other_stage = RiskObservation(
        id="observation-other-stage",
        tenant_id="tenant-a",
        observation_key="risk:projection:other-stage",
        subject_type="expense_claim",
        subject_key=f"claim:{claim.id}",
        subject_label=claim.claim_no,
        claim_id=claim.id,
        claim_no=claim.claim_no,
        risk_type="invoice",
        risk_signal="invoice",
        risk_level="critical",
        control_stage="reimbursement",
        status="pending_review",
        feedback_status="unreviewed",
    )
    waived = RiskObservation(
        id="observation-active-waiver",
        tenant_id="tenant-a",
        observation_key="risk:projection:active-waiver",
        subject_type="expense_claim",
        subject_key=f"claim:{claim.id}",
        subject_label=claim.claim_no,
        claim_id=claim.id,
        claim_no=claim.claim_no,
        risk_type="policy",
        risk_signal="policy",
        risk_level="high",
        control_stage="expense_application",
        status="pending_review",
        feedback_status="confirmed",
    )
    disposition = RiskDisposition(
        tenant_id="tenant-a",
        observation_id=waived.id,
        adjudication="confirmed",
        lifecycle_status="waived",
        waiver_expires_at=now + timedelta(days=1),
        waiver_decision="approved",
    )
    service = ApprovalTaskProjectionService(None)  # type: ignore[arg-type]

    active = service.build(
        claim,
        tenant_id="tenant-a",
        entered_at=now - timedelta(hours=1),
        now=now,
        observation_rows=[(other_stage, None), (waived, disposition)],
    )
    disposition.waiver_expires_at = now - timedelta(seconds=1)
    expired = service.build(
        claim,
        tenant_id="tenant-a",
        entered_at=now - timedelta(hours=1),
        now=now,
        observation_rows=[(other_stage, None), (waived, disposition)],
    )

    assert active.risk_level == "low"
    assert active.open_risk_count == 0
    assert active.batch_eligible is True
    assert expired.risk_level == "high"
    assert expired.open_risk_count == 1
    assert expired.batch_eligible is False


def test_risk_upsert_refreshes_open_task_projection_without_resetting_sla_window() -> None:
    engine = create_engine(
        "sqlite+pysqlite:///:memory:",
        connect_args={"check_same_thread": False},
        poolclass=StaticPool,
    )
    Base.metadata.create_all(bind=engine)
    db_factory = sessionmaker(bind=engine, autoflush=False, expire_on_commit=False)
    now = datetime(2026, 7, 16, 12, 0, tzinfo=UTC)
    with db_factory() as db:
        claim = _claim(claim_id="claim-projection-refresh")
        task = ApprovalTask(
            id="task-projection-refresh",
            tenant_id="default",
            claim_id=claim.id,
            expense_case_id=None,
            node_instance_id="node-projection-refresh",
            node_entry_key="node-entry-projection-refresh",
            parent_task_id=None,
            task_kind="root",
            node_key="direct_manager",
            node_label="直属领导审批",
            node_sequence=1,
            sequence_order=0,
            coordination_mode="single",
            owner_kind="role",
            owner_key="manager",
            owner_name="审批经理",
            assignee_kind="role",
            assignee_key="manager",
            assignee_name="审批经理",
            status="pending",
            version=1,
            claim_status_snapshot="submitted",
            claim_stage_snapshot="直属领导审批",
            entered_at=now - timedelta(hours=1),
            entered_at_source="workflow_event",
            activated_at=now - timedelta(hours=1),
            sla_hours_snapshot=24,
            due_at=now + timedelta(hours=23),
            escalation_level=1,
            next_escalation_at=now + timedelta(hours=2),
            priority_score=10,
            priority_tier="normal",
            priority_reasons_json=[
                {
                    "code": "sla_escalated_l1",
                    "label": "审批超时已升级至 L1",
                    "weight": 10,
                    "tone": "danger",
                }
            ],
            risk_level="low",
            open_risk_count=0,
            evidence_completeness=Decimal("1.0000"),
            batch_eligible=True,
            batch_block_reasons_json=[],
            projection_updated_at=now,
            created_at=now,
            updated_at=now,
        )
        db.add_all([claim, task])
        db.commit()

        observation = RiskObservationService(db).upsert_observation(
            {
                "observation_key": "risk:projection:refresh",
                "subject_type": "expense_claim",
                "subject_key": f"claim:{claim.id}",
                "subject_label": claim.claim_no,
                "claim_id": claim.id,
                "claim_no": claim.claim_no,
                "risk_type": "policy_violation",
                "risk_signal": "policy_violation",
                "title": "政策风险",
                "description": "等待人工处置。",
                "risk_score": 90,
                "risk_level": "high",
                "control_stage": "reimbursement",
                "status": "pending_review",
            },
            tenant_id="default",
        )

        db.refresh(task)
        assert task.risk_level == "high"
        assert task.open_risk_count == 1
        assert task.batch_eligible is False
        next_escalation_at = task.next_escalation_at
        assert next_escalation_at is not None
        if next_escalation_at.tzinfo is None:
            next_escalation_at = next_escalation_at.replace(tzinfo=UTC)
        assert next_escalation_at == now + timedelta(hours=2)
        assert task.escalation_level == 1
        assert any(
            reason["code"] == "sla_escalated_l1"
            for reason in task.priority_reasons_json
        )

        db.add(
            RiskDisposition(
                tenant_id="default",
                observation_id=observation.id,
                adjudication="confirmed",
                lifecycle_status="waived",
                waiver_requester_id="requester-projection-refresh",
                waiver_requester_name="风险申请人",
                waiver_requested_at=now - timedelta(hours=1),
                waiver_reason="业务连续性需要",
                waiver_scope="本次报销单",
                waiver_decision="approved",
                waiver_expires_at=now + timedelta(days=1),
                waiver_conditions_json=["补充主管确认"],
                waiver_decider_id="finance-projection-refresh",
                waiver_decider_name="财务复核人",
                waiver_decided_at=now,
                waiver_decision_reason="风险受控且有补偿措施",
                version=1,
            )
        )
        db.flush()
        ApprovalTaskProjectionRefreshService(db).refresh_claim(
            tenant_id="default",
            claim_id=claim.id,
            now=now,
        )

        assert task.risk_level == "low"
        assert task.open_risk_count == 0
        assert task.batch_eligible is True
        next_escalation_at = task.next_escalation_at
        assert next_escalation_at is not None
        if next_escalation_at.tzinfo is None:
            next_escalation_at = next_escalation_at.replace(tzinfo=UTC)
        assert next_escalation_at == now + timedelta(hours=2)
