from __future__ import annotations

from collections.abc import Generator
from dataclasses import dataclass
from datetime import UTC, date, datetime, timedelta
from decimal import Decimal

import pytest
from auth_helpers import install_legacy_header_auth_override
from fastapi import FastAPI
from fastapi.testclient import TestClient
from sqlalchemy import create_engine
from sqlalchemy.orm import Session, sessionmaker
from sqlalchemy.pool import StaticPool

from app.api.deps import CurrentUserContext, get_db
from app.api.v1.endpoints.risk_observations import router as risk_observations_router
from app.db.base import Base
from app.models.approval_task import ApprovalTask
from app.models.employee import Employee
from app.models.financial_record import ExpenseClaim, ExpenseClaimItem
from app.models.risk_disposition import RiskDisposition
from app.models.risk_observation import RiskObservation
from app.schemas.risk_disposition import RiskDispositionActionCreate
from app.services.approval_task_projection_refresh import (
    ApprovalTaskProjectionRefreshService,
)
from app.services.risk_disposition_read_projection import RiskDispositionActionPolicy
from app.services.risk_dispositions import RiskDispositionService
from app.services.risk_observations import RiskObservationService


def test_available_actions_enforce_decider_separation_tenant_state_and_expiry(
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    monkeypatch.setenv("FEW_SHOT_INJECTION_ENABLED", "false")
    client, factory = _build_client()
    client.close()
    with factory() as db:
        seeded = _seed_waiver_request(db)
        policy = RiskDispositionActionPolicy(db)

        decider_actions = set(
            policy.evaluate(
                seeded.observation,
                seeded.disposition,
                seeded.decider,
            ).available_actions
        )
        assert {"approve_waiver", "reject_waiver"} <= decider_actions

        for user in (seeded.requester, seeded.admin, seeded.inactive_finance):
            actions = set(
                policy.evaluate(
                    seeded.observation,
                    seeded.disposition,
                    user,
                ).available_actions
            )
            assert "approve_waiver" not in actions
            assert "reject_waiver" not in actions

        foreign_permission = policy.evaluate(
            seeded.observation,
            seeded.disposition,
            _user(seeded.decider_employee, roles=["finance"], tenant_id="tenant-b"),
        )
        assert foreign_permission.available_actions == ()
        assert "租户" in foreign_permission.read_only_reason

        expires_at = seeded.disposition.waiver_expires_at
        assert expires_at is not None
        expired_actions = set(
            policy.evaluate(
                seeded.observation,
                seeded.disposition,
                seeded.decider,
                now=_as_utc(expires_at) + timedelta(seconds=1),
            ).available_actions
        )
        assert "approve_waiver" not in expired_actions
        assert "reject_waiver" in expired_actions

        seeded.disposition.lifecycle_status = "waived"
        seeded.disposition.waiver_decision = "approved"
        waived_actions = set(
            policy.evaluate(
                seeded.observation,
                seeded.disposition,
                seeded.decider,
            ).available_actions
        )
        assert "approve_waiver" not in waived_actions
        assert "reject_waiver" not in waived_actions

        seeded.disposition.lifecycle_status = "open"
        seeded.disposition.waiver_decision = None
        seeded.disposition.waiver_requester_id = None
        open_actions = set(
            policy.evaluate(
                seeded.observation,
                seeded.disposition,
                seeded.decider,
            ).available_actions
        )
        assert "request_waiver" in open_actions


def test_read_and_mutation_endpoints_return_server_projected_actions(
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    monkeypatch.setenv("FEW_SHOT_INJECTION_ENABLED", "false")
    client, factory = _build_client()
    with factory() as db:
        seeded = _seed_waiver_request(db)
        unreviewed = RiskObservationService(db).upsert_observation(
            _observation_payload("risk:projection:unreviewed")
        )
        db.commit()
        observation_id = seeded.observation.id
        unreviewed_id = unreviewed.id

    decider_headers = _headers(seeded.decider_employee, roles=["finance"])
    claim_response = client.get(
        "/api/v1/risk-observations/claim/claim-risk-projection",
        headers=decider_headers,
    )
    assert claim_response.status_code == 200
    by_id = {item["id"]: item for item in claim_response.json()}
    requested_item = by_id[observation_id]
    assert {"approve_waiver", "reject_waiver"} <= set(
        requested_item["available_actions"]
    )
    assert requested_item["read_only_reason"] == ""
    assert requested_item["disposition"]["available_actions"] == requested_item[
        "available_actions"
    ]
    assert requested_item["disposition"]["read_only_reason"] == ""
    assert by_id[unreviewed_id]["disposition"] is None
    assert {"confirm", "false_positive"} <= set(
        by_id[unreviewed_id]["available_actions"]
    )

    for path in (
        "/api/v1/risk-observations",
        f"/api/v1/risk-observations/{observation_id}",
        f"/api/v1/risk-observations/{observation_id}/disposition",
    ):
        response = client.get(path, headers=decider_headers)
        assert response.status_code == 200
        payload = response.json()
        if path.endswith("/risk-observations"):
            payload = next(item for item in payload["items"] if item["id"] == observation_id)
        if not path.endswith("/disposition"):
            payload = payload["disposition"]
        assert {"approve_waiver", "reject_waiver"} <= set(payload["available_actions"])
        assert payload["read_only_reason"] == ""

    for employee, roles, is_admin in (
        (seeded.requester_employee, ["finance"], False),
        (seeded.admin_employee, [], True),
    ):
        response = client.get(
            f"/api/v1/risk-observations/{observation_id}/disposition",
            headers=_headers(employee, roles=roles, is_admin=is_admin),
        )
        assert response.status_code == 200
        assert "approve_waiver" not in response.json()["available_actions"]
        assert "reject_waiver" not in response.json()["available_actions"]

    mutation_response = client.post(
        f"/api/v1/risk-observations/{observation_id}/disposition/actions",
        headers=decider_headers,
        json={
            "action": "reject_waiver",
            "expected_version": 2,
            "request_id": "projection-reject-waiver",
            "comment": "当前证据不足，不同意风险豁免。",
        },
    )
    assert mutation_response.status_code == 200
    mutation_disposition = mutation_response.json()["disposition"]
    assert "available_actions" in mutation_disposition
    assert "read_only_reason" in mutation_disposition
    assert "approve_waiver" not in mutation_disposition["available_actions"]
    assert "reject_waiver" not in mutation_disposition["available_actions"]


def test_disposition_mutations_refresh_task_risk_without_resetting_sla(
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    monkeypatch.setenv("FEW_SHOT_INJECTION_ENABLED", "false")
    client, factory = _build_client()
    client.close()
    with factory() as db:
        owner = _employee("projection-owner")
        requester_employee = _employee("projection-requester")
        decider_employee = _employee("projection-decider")
        claim = _claim(owner.id)
        observation = RiskObservationService(db).upsert_observation(
            _observation_payload("risk:projection:task-refresh")
        )
        now = datetime.now(UTC)
        task = _approval_task(claim, now=now)
        db.add_all([owner, requester_employee, decider_employee, claim, task])
        db.commit()

        original_sla = (
            _as_utc(task.entered_at),
            task.sla_hours_snapshot,
            _as_utc(task.due_at),
            _as_utc(task.next_escalation_at),
            task.escalation_level,
        )
        requester = _user(requester_employee, roles=["finance"])
        decider = _user(decider_employee, roles=["finance"])
        service = RiskDispositionService(db)

        service.execute_action(
            observation.id,
            RiskDispositionActionCreate(
                action="confirm",
                expected_version=0,
                request_id="task-refresh-confirm-risk",
            ),
            tenant_id="default",
            actor_id=requester_employee.id,
            actor_name=requester_employee.name,
            current_user=requester,
        )
        db.refresh(task)
        assert task.risk_level == "high"
        assert task.open_risk_count == 1
        assert task.batch_eligible is False
        assert "open_risk" in task.batch_block_reasons_json
        _assert_sla_window(task, original_sla)

        service.execute_action(
            observation.id,
            _waiver_request(version=1, request_id="task-refresh-waiver-request-1"),
            tenant_id="default",
            actor_id=requester_employee.id,
            actor_name=requester_employee.name,
            current_user=requester,
        )
        service.execute_action(
            observation.id,
            RiskDispositionActionCreate(
                action="reject_waiver",
                expected_version=2,
                request_id="task-refresh-reject-waiver",
                comment="补偿措施不足，拒绝豁免。",
            ),
            tenant_id="default",
            actor_id=decider_employee.id,
            actor_name=decider_employee.name,
            current_user=decider,
        )
        db.refresh(task)
        assert task.open_risk_count == 1
        assert task.batch_eligible is False
        _assert_sla_window(task, original_sla)

        service.execute_action(
            observation.id,
            _waiver_request(version=3, request_id="task-refresh-waiver-request-2"),
            tenant_id="default",
            actor_id=requester_employee.id,
            actor_name=requester_employee.name,
            current_user=requester,
        )
        service.execute_action(
            observation.id,
            RiskDispositionActionCreate(
                action="approve_waiver",
                expected_version=4,
                request_id="task-refresh-approve-waiver",
                comment="补偿措施完整，同意限时豁免。",
            ),
            tenant_id="default",
            actor_id=decider_employee.id,
            actor_name=decider_employee.name,
            current_user=decider,
        )
        db.refresh(task)
        assert task.risk_level == "low"
        assert task.open_risk_count == 0
        assert task.evidence_completeness == Decimal("1.0000")
        assert task.batch_eligible is True
        _assert_sla_window(task, original_sla)

        disposition = service.get_disposition(observation.id, tenant_id="default")
        assert disposition is not None
        refresh_at = datetime.now(UTC)
        disposition.waiver_requested_at = refresh_at - timedelta(days=2)
        disposition.waiver_expires_at = refresh_at - timedelta(hours=1)
        db.flush()
        ApprovalTaskProjectionRefreshService(db).refresh_claim(
            tenant_id="default",
            claim_id=claim.id,
            now=refresh_at,
        )
        db.commit()
        db.refresh(task)
        assert task.risk_level == "high"
        assert task.open_risk_count == 1
        assert task.batch_eligible is False
        _assert_sla_window(task, original_sla)


@dataclass(slots=True)
class _SeededWaiver:
    observation: RiskObservation
    disposition: RiskDisposition
    requester_employee: Employee
    decider_employee: Employee
    admin_employee: Employee
    requester: CurrentUserContext
    decider: CurrentUserContext
    admin: CurrentUserContext
    inactive_finance: CurrentUserContext


def _seed_waiver_request(db: Session) -> _SeededWaiver:
    owner = _employee("owner")
    requester_employee = _employee("requester")
    decider_employee = _employee("decider")
    admin_employee = _employee("admin")
    inactive_employee = _employee("inactive", employment_status="离职")
    db.add_all(
        [
            owner,
            requester_employee,
            decider_employee,
            admin_employee,
            inactive_employee,
            _claim(owner.id),
        ]
    )
    observation = RiskObservationService(db).upsert_observation(
        _observation_payload("risk:projection:waiver")
    )
    db.commit()

    requester = _user(requester_employee, roles=["finance"])
    service = RiskDispositionService(db)
    service.execute_action(
        observation.id,
        RiskDispositionActionCreate(
            action="confirm",
            expected_version=0,
            request_id="projection-confirm-risk",
        ),
        tenant_id="default",
        actor_id=requester_employee.id,
        actor_name=requester_employee.name,
        current_user=requester,
    )
    requested = service.execute_action(
        observation.id,
        RiskDispositionActionCreate(
            action="request_waiver",
            expected_version=1,
            request_id="projection-request-waiver",
            waiver_reason="客户现场暂时无法补齐原件。",
            waiver_scope="仅限本次报销单的当前风险观察。",
            waiver_expires_at=datetime.now(UTC) + timedelta(days=2),
            waiver_conditions=["到期前补齐原件"],
        ),
        tenant_id="default",
        actor_id=requester_employee.id,
        actor_name=requester_employee.name,
        current_user=requester,
    )
    disposition = service.get_disposition(observation.id, tenant_id="default")
    assert disposition is not None
    assert requested.disposition.version == 2
    return _SeededWaiver(
        observation=observation,
        disposition=disposition,
        requester_employee=requester_employee,
        decider_employee=decider_employee,
        admin_employee=admin_employee,
        requester=requester,
        decider=_user(decider_employee, roles=["finance"]),
        admin=_user(admin_employee, roles=[], is_admin=True),
        inactive_finance=_user(inactive_employee, roles=["finance"]),
    )


def _build_client() -> tuple[TestClient, sessionmaker[Session]]:
    engine = create_engine(
        "sqlite+pysqlite:///:memory:",
        connect_args={"check_same_thread": False},
        poolclass=StaticPool,
    )
    Base.metadata.create_all(bind=engine)
    factory = sessionmaker(
        bind=engine,
        autoflush=False,
        autocommit=False,
        expire_on_commit=False,
    )
    app = FastAPI()
    app.include_router(risk_observations_router, prefix="/api/v1")
    install_legacy_header_auth_override(app)

    def override_db() -> Generator[Session, None, None]:
        with factory() as db:
            yield db

    app.dependency_overrides[get_db] = override_db
    return TestClient(app), factory


def _employee(suffix: str, *, employment_status: str = "在职") -> Employee:
    return Employee(
        id=f"emp-{suffix}",
        employee_no=f"E-{suffix.upper()}",
        name=f"员工-{suffix}",
        email=f"{suffix}@risk-projection.example.com",
        position="财务专员",
        grade="P6",
        employment_status=employment_status,
    )


def _user(
    employee: Employee,
    *,
    roles: list[str],
    is_admin: bool = False,
    tenant_id: str = "default",
) -> CurrentUserContext:
    return CurrentUserContext(
        username=employee.email,
        name=employee.name,
        role_codes=roles,
        is_admin=is_admin,
        tenant_id=tenant_id,
        employee_no=employee.employee_no,
        employee_id=employee.id,
    )


def _headers(
    employee: Employee,
    *,
    roles: list[str],
    is_admin: bool = False,
) -> dict[str, str]:
    return {
        "X-Auth-Username": employee.email,
        "X-Auth-Name": employee.id,
        "X-Auth-Role-Codes": ",".join(roles),
        "X-Auth-Is-Admin": str(is_admin).lower(),
        "X-Auth-Employee-No": employee.employee_no,
        "X-Auth-Employee-Id": employee.id,
        "X-Auth-Tenant-Id": "default",
    }


def _waiver_request(*, version: int, request_id: str) -> RiskDispositionActionCreate:
    return RiskDispositionActionCreate(
        action="request_waiver",
        expected_version=version,
        request_id=request_id,
        waiver_reason="客户现场暂时无法补齐原件。",
        waiver_scope="仅限当前报销单的当前风险观察。",
        waiver_expires_at=datetime.now(UTC) + timedelta(days=2),
        waiver_conditions=["到期前补齐原件"],
    )


def _claim(owner_id: str) -> ExpenseClaim:
    now = datetime.now(UTC)
    claim = ExpenseClaim(
        id="claim-risk-projection",
        claim_no="BX-RISK-PROJECTION",
        employee_id=owner_id,
        employee_name="风险申请人",
        department_id="dept-risk",
        department_name="风控部",
        expense_type="travel",
        reason="客户拜访",
        location="上海",
        amount=Decimal("1200"),
        currency="CNY",
        invoice_count=1,
        occurred_at=now,
        submitted_at=now,
        status="submitted",
        approval_stage="财务审批",
        risk_flags_json=[],
    )
    claim.items = [
        ExpenseClaimItem(
            id="item-risk-projection",
            claim_id=claim.id,
            item_date=date.today(),
            item_type="travel",
            item_reason="客户拜访交通费",
            item_location="上海",
            item_note="",
            item_amount=claim.amount,
            invoice_id="INV-RISK-PROJECTION",
            created_at=now,
            updated_at=now,
        )
    ]
    return claim


def _approval_task(claim: ExpenseClaim, *, now: datetime) -> ApprovalTask:
    return ApprovalTask(
        id="task-risk-projection",
        tenant_id="default",
        claim_id=claim.id,
        expense_case_id=None,
        node_instance_id="node-risk-projection",
        node_entry_key="node-entry-risk-projection",
        parent_task_id=None,
        task_kind="root",
        node_key="finance",
        node_label="财务审批",
        node_sequence=3,
        sequence_order=0,
        coordination_mode="single",
        owner_kind="role",
        owner_key="finance",
        owner_name="财务审批人",
        assignee_kind="role",
        assignee_key="finance",
        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=7),
        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,
    )


def _assert_sla_window(
    task: ApprovalTask,
    expected: tuple[datetime, int, datetime, datetime, int],
) -> None:
    actual = (
        _as_utc(task.entered_at),
        task.sla_hours_snapshot,
        _as_utc(task.due_at),
        _as_utc(task.next_escalation_at),
        task.escalation_level,
    )
    assert actual == expected


def _observation_payload(observation_key: str) -> dict[str, object]:
    return {
        "observation_key": observation_key,
        "subject_type": "expense_claim",
        "subject_key": "claim:claim-risk-projection",
        "subject_label": "BX-RISK-PROJECTION",
        "claim_id": "claim-risk-projection",
        "claim_no": "BX-RISK-PROJECTION",
        "risk_type": "duplicate_invoice",
        "risk_signal": "duplicate_invoice",
        "title": "重复票据风险",
        "description": "同一票据可能重复报销。",
        "risk_score": 86,
        "risk_level": "high",
        "confidence_score": 0.91,
        "control_stage": "reimbursement",
        "control_mode": "risk_observation",
        "automation_mode": "semi_auto_review",
        "source": "financial_risk_graph",
        "algorithm_version": "financial_risk_graph.v1",
        "contribution_scores": {},
        "baseline": {},
        "evidence": [],
        "graph_node_keys": [],
        "graph_edge_keys": [],
        "policy_refs": [],
        "similar_case_claim_ids": [],
        "ontology_json": {},
        "decision_trace": {},
    }


def _as_utc(value: datetime) -> datetime:
    if value.tzinfo is None:
        return value.replace(tzinfo=UTC)
    return value.astimezone(UTC)
