from __future__ import annotations

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

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

from app.api.deps import CurrentUserContext, get_db
from app.db.base import Base
from app.main import create_app
from app.models.approval_action import ApprovalActionLedger
from app.models.approval_task import ApprovalTask, ApprovalTaskEvent
from app.models.employee import Employee
from app.models.expense_case import ExpenseCase, ExpenseCaseLink
from app.models.financial_record import ExpenseClaim, ExpenseClaimItem
from app.models.role import Role
from app.schemas.approval_task import (
    ApprovalTaskBatchApproveCreate,
    ApprovalTaskBatchApproveItem,
)
from app.services.approval_task_batch import ApprovalTaskBatchService
from app.services.approval_task_query import ApprovalTaskQueryService
from app.services.expense_claim_workflow_constants import (
    DIRECT_MANAGER_APPROVAL_STAGE,
    FINANCE_APPROVAL_STAGE,
)


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


def _user(
    *,
    tenant_id: str = "tenant-a",
    employee_id: str = "approver-1",
    username: str = "approver@example.com",
    role_codes: list[str] | None = None,
    is_admin: bool = False,
) -> CurrentUserContext:
    return CurrentUserContext(
        username=username,
        name="审批人",
        role_codes=list(role_codes or ["manager"]),
        is_admin=is_admin,
        tenant_id=tenant_id,
        employee_id=employee_id,
        employee_no=employee_id,
    )


def _task_status_fields(
    status: str,
    *,
    now: datetime,
) -> dict[str, object]:
    if status == "completed":
        return {
            "decision": "approved",
            "completed_at": now,
            "cancelled_at": None,
        }
    if status == "returned":
        return {
            "decision": "returned",
            "completed_at": now,
            "cancelled_at": None,
        }
    if status in {"cancelled", "superseded"}:
        return {
            "decision": None,
            "completed_at": None,
            "cancelled_at": now,
        }
    return {"decision": None, "completed_at": None, "cancelled_at": None}


def _build_queue_task(
    *,
    tenant_id: str,
    case_id: str,
    index: int,
    now: datetime,
    assignee_id: str,
    status: str = "pending",
    risk_level: str | None = None,
    node_key: str | None = None,
    due_at: datetime | None = None,
    reason: str | None = None,
) -> tuple[ExpenseClaim, ExpenseCaseLink, ApprovalTask]:
    claim_id = f"queue-claim-{tenant_id[-1]}-{index:03d}"
    task_id = f"queue-task-{tenant_id[-1]}-{index:03d}"
    selected_node = node_key or ("direct_manager" if index % 2 == 0 else "finance")
    stage = (
        DIRECT_MANAGER_APPROVAL_STAGE
        if selected_node == "direct_manager"
        else FINANCE_APPROVAL_STAGE
    )
    # 任务进入节点后才会产生 SLA 截止时间；即使构造“已逾期”样本，
    # due_at 也必须晚于 entered_at，保持测试数据满足真实表约束。
    entered_at = now - timedelta(hours=12 + index % 5, minutes=index % 7)
    selected_due_at = due_at or (
        now - timedelta(hours=2)
        if index % 3 == 0
        else now + timedelta(hours=2)
        if index % 3 == 1
        else now + timedelta(hours=8)
    )
    selected_risk = risk_level or ("high" if index % 5 == 0 else "low")
    claim = ExpenseClaim(
        id=claim_id,
        tenant_id=tenant_id,
        claim_no=f"RE-QUEUE-{tenant_id[-1].upper()}-{index:03d}",
        employee_id=None,
        employee_name=f"申请人{index:03d}",
        department_id=None,
        department_name="市场部",
        project_code="PRJ-QUEUE",
        expense_type="misc",
        reason=reason or f"队列分页测试费用 {index:03d}",
        location="上海",
        amount=Decimal("888.00") + Decimal(index),
        currency="CNY",
        invoice_count=1,
        occurred_at=now - timedelta(days=1),
        submitted_at=entered_at,
        status="submitted",
        approval_stage=stage,
        risk_flags_json=[],
        created_at=entered_at - timedelta(hours=1),
        updated_at=entered_at,
    )
    link = ExpenseCaseLink(
        id=f"queue-link-{tenant_id[-1]}-{index:03d}",
        tenant_id=tenant_id,
        expense_case_id=case_id,
        resource_type="expense_claim",
        resource_id=claim_id,
        relation_type="claim",
        created_at=entered_at,
    )
    lifecycle = _task_status_fields(status, now=now)
    task = ApprovalTask(
        id=task_id,
        tenant_id=tenant_id,
        claim_id=claim_id,
        expense_case_id=case_id,
        node_instance_id=f"queue-node-{tenant_id[-1]}-{index:03d}",
        node_entry_key=f"queue-entry-{tenant_id[-1]}-{index:03d}",
        parent_task_id=None,
        task_kind="root",
        node_key=selected_node,
        node_label=stage,
        node_sequence=1,
        sequence_order=0,
        coordination_mode="single",
        owner_kind="employee",
        owner_key=assignee_id,
        owner_employee_id=assignee_id,
        owner_name="审批人",
        assignee_kind="employee",
        assignee_key=assignee_id,
        assignee_employee_id=assignee_id,
        assignee_name="审批人张经理",
        status=status,
        decision=lifecycle["decision"],
        version=2 if status != "pending" else 1,
        claim_status_snapshot="submitted",
        claim_stage_snapshot=stage,
        entered_at=entered_at,
        entered_at_source="submitted_at",
        activated_at=entered_at,
        sla_hours_snapshot=24,
        due_at=selected_due_at,
        completed_at=lifecycle["completed_at"],
        cancelled_at=lifecycle["cancelled_at"],
        escalation_level=0,
        next_escalation_at=selected_due_at,
        priority_score=(index % 4) * 25,
        priority_tier="urgent" if index % 4 == 3 else "high" if index % 4 == 2 else "normal",
        priority_reasons_json=[],
        risk_level=selected_risk,
        open_risk_count=1 if selected_risk != "low" else 0,
        evidence_completeness=Decimal("1.0000"),
        batch_eligible=(status == "pending" and selected_risk == "low"),
        batch_block_reasons_json=[] if selected_risk == "low" else ["open_risk"],
        projection_updated_at=now,
        created_at=entered_at,
        updated_at=now,
    )
    return claim, link, task


def _seed_queue(
    db: Session,
    *,
    tenant_id: str = "tenant-a",
    pending_count: int = 27,
    assignee_id: str = "approver-1",
    include_processed: bool = False,
) -> tuple[datetime, list[ApprovalTask]]:
    now = datetime.now(UTC).replace(microsecond=0)
    case_id = f"queue-case-{tenant_id[-1]}"
    expense_case = ExpenseCase(
        id=case_id,
        tenant_id=tenant_id,
        case_no=f"CASE-QUEUE-{tenant_id[-1].upper()}",
        scene_code="misc",
        title="审批队列测试",
        current_stage="reviewing",
        status="active",
        created_at=now - timedelta(days=2),
        updated_at=now,
    )
    db.add(expense_case)
    tasks: list[ApprovalTask] = []
    for index in range(pending_count):
        claim, link, task = _build_queue_task(
            tenant_id=tenant_id,
            case_id=case_id,
            index=index,
            now=now,
            assignee_id=assignee_id,
            reason=("火星专项客户拜访" if index == 7 else None),
        )
        db.add_all([claim, link, task])
        tasks.append(task)
    if include_processed:
        for offset, status in enumerate(
            ("completed", "returned", "cancelled", "superseded"),
            start=pending_count,
        ):
            claim, link, task = _build_queue_task(
                tenant_id=tenant_id,
                case_id=case_id,
                index=offset,
                now=now,
                assignee_id=assignee_id,
                status=status,
            )
            db.add_all([claim, link, task])
            tasks.append(task)
    db.commit()
    return now, tasks


def test_query_uses_real_database_pagination_and_stable_persisted_sorting() -> None:
    factory = _session_factory()
    with factory() as db:
        _now, tasks = _seed_queue(db)
        expected = sorted(
            tasks,
            key=lambda task: (
                -task.priority_score,
                task.due_at,
                task.entered_at,
                task.id,
            ),
        )

        page = ApprovalTaskQueryService(db).list_tasks(
            _user(),
            page=2,
            page_size=10,
        )
        repeated = ApprovalTaskQueryService(db).list_tasks(
            _user(),
            page=2,
            page_size=10,
        )

        expected_ids = [task.id for task in expected[10:20]]
        assert page.total == 27
        assert page.page == 2
        assert page.page_size == 10
        assert page.total_pages == 3
        assert [item.task.id for item in page.items] == expected_ids
        assert [item.task.id for item in repeated.items] == expected_ids


def test_query_supports_status_risk_sla_node_and_keyword_filters() -> None:
    factory = _session_factory()
    with factory() as db:
        _now, tasks = _seed_queue(db, include_processed=True)
        tasks[0].escalation_level = 1
        db.commit()
        service = ApprovalTaskQueryService(db)
        user = _user()

        assert service.list_tasks(user, status="processed").total == 4
        assert service.list_tasks(user, status="completed").total == 1
        assert service.list_tasks(user, risk_level="high").total == 6
        assert service.list_tasks(user, sla_state="overdue").total == 9
        assert service.list_tasks(user, sla_state="due_soon").total == 9
        assert service.list_tasks(user, sla_state="on_track").total == 9
        assert service.list_tasks(user, sla_state="escalated").total == 1
        assert service.list_tasks(user, node_key="finance").total == 13
        keyword = service.list_tasks(user, keyword="火星专项")
        assert keyword.total == 1
        assert keyword.items[0].claim.reason == "火星专项客户拜访"
        with pytest.raises(ValueError, match="SLA"):
            service.list_tasks(user, sla_state="unknown")


def test_query_enforces_tenant_scope_and_admin_visibility_without_action_authority() -> None:
    factory = _session_factory()
    with factory() as db:
        _seed_queue(db, tenant_id="tenant-a", pending_count=3)
        _seed_queue(
            db,
            tenant_id="tenant-b",
            pending_count=2,
            assignee_id="other-approver",
        )
        service = ApprovalTaskQueryService(db)

        personal = service.list_tasks(_user(tenant_id="tenant-a"))
        admin_a = service.list_tasks(
            _user(
                tenant_id="tenant-a",
                employee_id="admin-a",
                username="admin-a@example.com",
                role_codes=["admin"],
                is_admin=True,
            )
        )
        admin_b = service.list_tasks(
            _user(
                tenant_id="tenant-b",
                employee_id="admin-b",
                username="admin-b@example.com",
                role_codes=["admin"],
                is_admin=True,
            )
        )

        assert personal.total == 3
        assert all(item.task.can_act for item in personal.items)
        assert admin_a.total == 3
        assert all(item.task.can_act is False for item in admin_a.items)
        assert all("approve" not in item.task.available_actions for item in admin_a.items)
        assert admin_b.total == 2
        assert {item.task.tenant_id for item in admin_b.items} == {"tenant-b"}


def _seed_batch(
    db: Session,
) -> tuple[CurrentUserContext, ApprovalTask, ApprovalTask]:
    now = datetime.now(UTC).replace(microsecond=0)
    finance_role = Role(
        id="batch-role-finance",
        role_code="finance",
        name="批处理财务",
    )
    finance = Employee(
        id="batch-finance",
        tenant_id="tenant-a",
        employee_no="BATCH-FINANCE",
        name="财务审批人",
        email="batch-finance@example.com",
        roles=[finance_role],
    )
    owner = Employee(
        id="batch-owner",
        tenant_id="tenant-a",
        employee_no="BATCH-OWNER",
        name="批处理申请人",
        email="batch-owner@example.com",
    )
    expense_case = ExpenseCase(
        id="batch-case",
        tenant_id="tenant-a",
        case_no="CASE-BATCH-APPROVAL",
        scene_code="misc",
        title="批量审批事务测试",
        owner_employee_id=owner.id,
        current_stage="reviewing",
        status="active",
        created_at=now - timedelta(days=1),
        updated_at=now,
    )
    db.add_all([finance_role, finance, owner, expense_case])
    tasks: list[ApprovalTask] = []
    for index, eligible in enumerate((True, False), start=1):
        claim_id = f"batch-claim-{index}"
        claim = ExpenseClaim(
            id=claim_id,
            tenant_id="tenant-a",
            claim_no=f"RE-BATCH-{index:03d}",
            employee=owner,
            employee_name=owner.name,
            department_name="市场部",
            project_code="PRJ-BATCH",
            expense_type="misc",
            reason=f"批量审批测试 {index}",
            location="上海",
            amount=Decimal("88.00"),
            currency="CNY",
            invoice_count=1,
            occurred_at=now - timedelta(days=1),
            submitted_at=now - timedelta(hours=1),
            status="submitted",
            approval_stage=FINANCE_APPROVAL_STAGE,
            risk_flags_json=[],
            created_at=now - timedelta(days=1),
            updated_at=now,
        )
        claim.items = [
            ExpenseClaimItem(
                id=f"batch-item-{index}",
                claim_id=claim_id,
                item_date=date.today(),
                item_type="misc",
                item_reason="测试",
                item_location="上海",
                item_note="",
                item_amount=Decimal("88.00"),
                invoice_id=f"BATCH-INVOICE-{index}",
                created_at=now,
                updated_at=now,
            )
        ]
        link = ExpenseCaseLink(
            id=f"batch-link-{index}",
            tenant_id="tenant-a",
            expense_case_id=expense_case.id,
            resource_type="expense_claim",
            resource_id=claim_id,
            relation_type="claim",
            created_at=now,
        )
        task = ApprovalTask(
            id=f"batch-task-{index}",
            tenant_id="tenant-a",
            claim_id=claim_id,
            expense_case_id=expense_case.id,
            node_instance_id=f"batch-node-{index}",
            node_entry_key=f"batch-entry-{index}",
            parent_task_id=None,
            task_kind="root",
            node_key="finance",
            node_label=FINANCE_APPROVAL_STAGE,
            node_sequence=1,
            sequence_order=0,
            coordination_mode="single",
            owner_kind="employee",
            owner_key=finance.id,
            owner_employee_id=finance.id,
            owner_name=finance.name,
            assignee_kind="employee",
            assignee_key=finance.id,
            assignee_employee_id=finance.id,
            assignee_name=finance.name,
            status="pending",
            decision=None,
            version=1,
            claim_status_snapshot="submitted",
            claim_stage_snapshot=FINANCE_APPROVAL_STAGE,
            entered_at=now - timedelta(hours=1),
            entered_at_source="submitted_at",
            activated_at=now - timedelta(hours=1),
            sla_hours_snapshot=8,
            due_at=now + timedelta(hours=7),
            escalation_level=0,
            next_escalation_at=now + timedelta(hours=7),
            priority_score=0,
            priority_tier="normal",
            priority_reasons_json=[],
            risk_level="low" if eligible else "high",
            open_risk_count=0 if eligible else 1,
            evidence_completeness=Decimal("1.0000"),
            batch_eligible=eligible,
            batch_block_reasons_json=[] if eligible else ["open_risk"],
            projection_updated_at=now,
            created_at=now,
            updated_at=now,
        )
        db.add_all([claim, link, task])
        tasks.append(task)
    db.commit()
    return (
        _user(
            tenant_id="tenant-a",
            employee_id=finance.id,
            username=finance.email,
            role_codes=["finance"],
        ),
        tasks[0],
        tasks[1],
    )


def test_batch_items_use_independent_transactions_and_retry_replays_success() -> None:
    factory = _session_factory()
    with factory() as db:
        user, successful_task, blocked_task = _seed_batch(db)
        payload = ApprovalTaskBatchApproveCreate(
            batch_request_id="batch-request-001",
            items=[
                ApprovalTaskBatchApproveItem(
                    task_id=successful_task.id,
                    expected_task_version=1,
                    expected_status="submitted",
                    expected_approval_stage=FINANCE_APPROVAL_STAGE,
                    opinion="批量审批同意",
                ),
                ApprovalTaskBatchApproveItem(
                    task_id=blocked_task.id,
                    expected_task_version=1,
                    expected_status="submitted",
                    expected_approval_stage=FINANCE_APPROVAL_STAGE,
                    opinion="批量审批同意",
                ),
            ],
        )

    first = ApprovalTaskBatchService(factory).approve(payload, user)
    repeated = ApprovalTaskBatchService(factory).approve(payload, user)

    assert first.status == "partial"
    assert first.succeeded_count == 1
    assert first.blocked_count == 1
    assert [item.status for item in first.items] == ["succeeded", "blocked"]
    assert repeated.status == "partial"
    assert repeated.replayed_count == 1
    assert repeated.blocked_count == 1
    assert [item.status for item in repeated.items] == ["replayed", "blocked"]
    with factory() as db:
        successful_claim = db.get(ExpenseClaim, successful_task.claim_id)
        blocked_claim = db.get(ExpenseClaim, blocked_task.claim_id)
        persisted_success = db.get(ApprovalTask, successful_task.id)
        persisted_blocked = db.get(ApprovalTask, blocked_task.id)
        assert successful_claim.status == "pending_payment"
        assert successful_claim.approval_stage == "待付款"
        assert persisted_success.status == "completed"
        assert persisted_success.decision == "approved"
        assert blocked_claim.status == "submitted"
        assert persisted_blocked.status == "pending"
        assert db.scalar(select(func.count()).select_from(ApprovalActionLedger)) == 1
        assert db.scalar(select(func.count()).select_from(ApprovalTaskEvent)) == 1


def test_approval_task_list_http_contract_preserves_pagination_metadata() -> None:
    factory = _session_factory()
    with factory() as db:
        _now, tasks = _seed_queue(db)
        expected = sorted(
            tasks,
            key=lambda task: (
                -task.priority_score,
                task.due_at,
                task.entered_at,
                task.id,
            ),
        )
    app = create_app()
    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
    client = TestClient(app)
    try:
        response = client.get(
            "/api/v1/approval-tasks",
            params={"page": 3, "page_size": 10, "status": "pending"},
            headers={
                "X-Auth-Username": "approver@example.com",
                "X-Auth-Employee-Id": "approver-1",
                "X-Auth-Role-Codes": "manager",
                "X-Auth-Tenant-Id": "tenant-a",
            },
        )
    finally:
        client.close()
        app.dependency_overrides.clear()

    body = response.json()
    assert response.status_code == 200
    assert body["total"] == 27
    assert body["page"] == 3
    assert body["page_size"] == 10
    assert body["total_pages"] == 3
    assert len(body["items"]) == 7
    assert body["items"][0]["task"]["id"] == expected[20].id
