"""add tenant-safe approval task work queue and append-only events

Revision ID: 20260716_0013
Revises: 20260716_0012
Create Date: 2026-07-16 18:20:00
"""

from __future__ import annotations

from collections.abc import Sequence

import sqlalchemy as sa

from alembic import op

revision: str = "20260716_0013"
down_revision: str | None = "20260716_0012"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None


def _require_postgresql() -> None:
    dialect_name = op.get_bind().dialect.name
    if dialect_name != "postgresql":
        raise RuntimeError(
            "20260716_0013 only supports PostgreSQL; "
            f"refusing to mutate {dialect_name} without transactional constraint DDL"
        )


def _require_empty_audit_chain_for_downgrade() -> None:
    bind = op.get_bind()
    task_count = int(bind.scalar(sa.text("SELECT COUNT(*) FROM approval_tasks")) or 0)
    event_count = int(bind.scalar(sa.text("SELECT COUNT(*) FROM approval_task_events")) or 0)
    if task_count or event_count:
        raise RuntimeError(
            "cannot downgrade approval tasks: audit chain is not empty "
            f"({task_count} tasks, {event_count} events)"
        )


def upgrade() -> None:
    _require_postgresql()
    op.create_table(
        "approval_tasks",
        sa.Column("id", sa.String(length=36), nullable=False),
        sa.Column("tenant_id", sa.String(length=64), nullable=False),
        # expense_claims 仍由 legacy bootstrap 管理，故这里只保存软引用。
        sa.Column("claim_id", sa.String(length=36), nullable=False),
        sa.Column("expense_case_id", sa.String(length=36), nullable=True),
        sa.Column("node_instance_id", sa.String(length=120), nullable=False),
        sa.Column("node_entry_key", sa.String(length=160), nullable=False),
        sa.Column("parent_task_id", sa.String(length=36), nullable=True),
        sa.Column("task_kind", sa.String(length=24), nullable=False),
        sa.Column("node_key", sa.String(length=32), nullable=False),
        sa.Column("node_label", sa.String(length=120), nullable=False),
        sa.Column("node_sequence", sa.Integer(), nullable=False),
        sa.Column("sequence_order", sa.Integer(), nullable=False, server_default="0"),
        sa.Column("coordination_mode", sa.String(length=32), nullable=False),
        sa.Column("owner_kind", sa.String(length=20), nullable=False),
        sa.Column("owner_key", sa.String(length=120), nullable=False),
        sa.Column("owner_employee_id", sa.String(length=36), nullable=True),
        sa.Column("owner_name", sa.String(length=120), nullable=False),
        sa.Column("assignee_kind", sa.String(length=20), nullable=False),
        sa.Column("assignee_key", sa.String(length=120), nullable=False),
        sa.Column("assignee_employee_id", sa.String(length=36), nullable=True),
        sa.Column("assignee_name", sa.String(length=120), nullable=False),
        sa.Column("delegated_by", sa.String(length=120), nullable=True),
        sa.Column("delegation_expires_at", sa.DateTime(timezone=True), nullable=True),
        sa.Column(
            "status",
            sa.String(length=24),
            nullable=False,
            server_default="waiting",
        ),
        sa.Column("decision", sa.String(length=20), nullable=True),
        sa.Column("opinion", sa.Text(), nullable=True),
        sa.Column("version", sa.Integer(), nullable=False, server_default="1"),
        sa.Column("claim_status_snapshot", sa.String(length=30), nullable=False),
        sa.Column("claim_stage_snapshot", sa.String(length=50), nullable=False),
        sa.Column("entered_at", sa.DateTime(timezone=True), nullable=False),
        sa.Column("entered_at_source", sa.String(length=24), nullable=False),
        sa.Column("activated_at", sa.DateTime(timezone=True), nullable=True),
        sa.Column("sla_hours_snapshot", sa.Integer(), nullable=False),
        sa.Column("due_at", sa.DateTime(timezone=True), nullable=False),
        sa.Column("completed_at", sa.DateTime(timezone=True), nullable=True),
        sa.Column("cancelled_at", sa.DateTime(timezone=True), nullable=True),
        sa.Column("escalation_level", sa.Integer(), nullable=False, server_default="0"),
        sa.Column("escalated_at", sa.DateTime(timezone=True), nullable=True),
        sa.Column("next_escalation_at", sa.DateTime(timezone=True), nullable=True),
        sa.Column("priority_score", sa.Integer(), nullable=False, server_default="0"),
        sa.Column(
            "priority_tier",
            sa.String(length=16),
            nullable=False,
            server_default="normal",
        ),
        sa.Column(
            "priority_reasons_json",
            sa.JSON(),
            nullable=False,
            server_default=sa.text("'[]'::json"),
        ),
        sa.Column(
            "risk_level",
            sa.String(length=16),
            nullable=False,
            server_default="low",
        ),
        sa.Column("open_risk_count", sa.Integer(), nullable=False, server_default="0"),
        sa.Column(
            "evidence_completeness",
            sa.Numeric(precision=5, scale=4),
            nullable=False,
            server_default="0",
        ),
        sa.Column(
            "batch_eligible",
            sa.Boolean(),
            nullable=False,
            server_default=sa.false(),
        ),
        sa.Column(
            "batch_block_reasons_json",
            sa.JSON(),
            nullable=False,
            server_default=sa.text("'[]'::json"),
        ),
        sa.Column(
            "projection_updated_at",
            sa.DateTime(timezone=True),
            nullable=False,
            server_default=sa.func.now(),
        ),
        sa.Column(
            "created_at",
            sa.DateTime(timezone=True),
            nullable=False,
            server_default=sa.func.now(),
        ),
        sa.Column(
            "updated_at",
            sa.DateTime(timezone=True),
            nullable=False,
            server_default=sa.func.now(),
        ),
        sa.CheckConstraint(
            "task_kind IN ('root', 'add_sign', 'countersign')",
            name="ck_approval_tasks_task_kind",
        ),
        sa.CheckConstraint(
            "node_key IN ('direct_manager', 'budget_manager', 'finance')",
            name="ck_approval_tasks_node_key",
        ),
        sa.CheckConstraint(
            "coordination_mode IN ('single', 'sequential_before_owner', 'parallel_all')",
            name="ck_approval_tasks_coordination_mode",
        ),
        sa.CheckConstraint(
            "owner_kind IN ('employee', 'role')",
            name="ck_approval_tasks_owner_kind",
        ),
        sa.CheckConstraint(
            "assignee_kind IN ('employee', 'role')",
            name="ck_approval_tasks_assignee_kind",
        ),
        sa.CheckConstraint(
            "status IN ('waiting', 'pending', 'completed', 'returned', "
            "'cancelled', 'superseded')",
            name="ck_approval_tasks_status",
        ),
        sa.CheckConstraint(
            "decision IS NULL OR decision IN ('approved', 'returned')",
            name="ck_approval_tasks_decision",
        ),
        sa.CheckConstraint(
            "entered_at_source IN ('workflow_event', 'submitted_at', 'updated_at', 'backfill')",
            name="ck_approval_tasks_entered_at_source",
        ),
        sa.CheckConstraint(
            "priority_tier IN ('normal', 'high', 'urgent')",
            name="ck_approval_tasks_priority_tier",
        ),
        sa.CheckConstraint(
            "risk_level IN ('low', 'medium', 'high', 'critical')",
            name="ck_approval_tasks_risk_level",
        ),
        sa.CheckConstraint(
            "(task_kind = 'root' AND parent_task_id IS NULL AND sequence_order = 0) OR "
            "(task_kind IN ('add_sign', 'countersign') "
            "AND parent_task_id IS NOT NULL AND sequence_order > 0)",
            name="ck_approval_tasks_parent_shape",
        ),
        sa.CheckConstraint(
            "(status = 'completed' AND decision = 'approved' "
            "AND completed_at IS NOT NULL AND cancelled_at IS NULL) OR "
            "(status = 'returned' AND decision = 'returned' "
            "AND completed_at IS NOT NULL AND cancelled_at IS NULL) OR "
            "(status IN ('cancelled', 'superseded') AND decision IS NULL "
            "AND completed_at IS NULL AND cancelled_at IS NOT NULL) OR "
            "(status IN ('waiting', 'pending') AND decision IS NULL "
            "AND completed_at IS NULL AND cancelled_at IS NULL)",
            name="ck_approval_tasks_lifecycle",
        ),
        sa.CheckConstraint(
            "status NOT IN ('pending', 'completed', 'returned') OR activated_at IS NOT NULL",
            name="ck_approval_tasks_activation",
        ),
        sa.CheckConstraint(
            "delegation_expires_at IS NULL OR delegated_by IS NOT NULL",
            name="ck_approval_tasks_delegation",
        ),
        sa.CheckConstraint("version >= 1", name="ck_approval_tasks_version"),
        sa.CheckConstraint("node_sequence >= 1", name="ck_approval_tasks_node_sequence"),
        sa.CheckConstraint("sla_hours_snapshot > 0", name="ck_approval_tasks_sla_hours"),
        sa.CheckConstraint("due_at >= entered_at", name="ck_approval_tasks_due_at"),
        sa.CheckConstraint("escalation_level >= 0", name="ck_approval_tasks_escalation"),
        sa.CheckConstraint(
            "priority_score >= 0 AND priority_score <= 100",
            name="ck_approval_tasks_priority_score",
        ),
        sa.CheckConstraint(
            "open_risk_count >= 0",
            name="ck_approval_tasks_open_risk_count",
        ),
        sa.CheckConstraint(
            "evidence_completeness >= 0 AND evidence_completeness <= 1",
            name="ck_approval_tasks_evidence_completeness",
        ),
        sa.ForeignKeyConstraint(
            ["tenant_id", "parent_task_id"],
            ["approval_tasks.tenant_id", "approval_tasks.id"],
            name="fk_approval_tasks_tenant_parent",
            ondelete="RESTRICT",
        ),
        sa.ForeignKeyConstraint(
            ["tenant_id", "expense_case_id"],
            ["expense_cases.tenant_id", "expense_cases.id"],
            name="fk_approval_tasks_tenant_expense_case",
            ondelete="RESTRICT",
        ),
        sa.PrimaryKeyConstraint("id"),
        sa.UniqueConstraint("tenant_id", "id", name="uq_approval_tasks_tenant_id"),
        sa.UniqueConstraint(
            "tenant_id",
            "node_entry_key",
            name="uq_approval_tasks_tenant_node_entry",
        ),
        sa.UniqueConstraint(
            "tenant_id",
            "node_instance_id",
            "assignee_kind",
            "assignee_key",
            name="uq_approval_tasks_node_participant",
        ),
    )
    op.create_index(
        "uq_approval_tasks_open_root_per_claim",
        "approval_tasks",
        ["tenant_id", "claim_id"],
        unique=True,
        postgresql_where=sa.text(
            "task_kind = 'root' AND status IN ('waiting', 'pending')"
        ),
    )
    op.create_index(
        "ix_approval_tasks_personal_inbox",
        "approval_tasks",
        ["tenant_id", "assignee_kind", "assignee_key", "status", "due_at"],
        unique=False,
    )
    op.create_index(
        "ix_approval_tasks_tenant_queue",
        "approval_tasks",
        ["tenant_id", "status", "priority_score", "due_at"],
        unique=False,
    )
    op.create_index(
        "ix_approval_tasks_tenant_claim",
        "approval_tasks",
        ["tenant_id", "claim_id", "node_sequence"],
        unique=False,
    )
    op.create_index(
        "ix_approval_tasks_tenant_node",
        "approval_tasks",
        ["tenant_id", "node_instance_id", "sequence_order"],
        unique=False,
    )

    op.create_table(
        "approval_task_events",
        sa.Column("id", sa.String(length=36), nullable=False),
        sa.Column("tenant_id", sa.String(length=64), nullable=False),
        sa.Column("task_id", sa.String(length=36), nullable=False),
        sa.Column("node_instance_id", sa.String(length=120), nullable=False),
        sa.Column("event_type", sa.String(length=50), nullable=False),
        sa.Column("actor_id", sa.String(length=120), nullable=False),
        sa.Column("actor_name", sa.String(length=120), nullable=False),
        sa.Column("actor_type", sa.String(length=20), nullable=False),
        sa.Column("request_id", sa.String(length=120), nullable=False),
        sa.Column("expected_task_version", sa.Integer(), nullable=False),
        sa.Column("result_task_version", sa.Integer(), nullable=False),
        sa.Column("payload_fingerprint", sa.String(length=80), nullable=False),
        sa.Column("payload_json", sa.JSON(), nullable=False),
        sa.Column("before_json", sa.JSON(), nullable=False),
        sa.Column("after_json", sa.JSON(), nullable=False),
        sa.Column("response_json", sa.JSON(), nullable=False),
        sa.Column("approval_action_ledger_id", sa.String(length=36), nullable=True),
        sa.Column("business_event_id", sa.String(length=36), nullable=True),
        sa.Column("correlation_id", sa.String(length=64), nullable=True),
        sa.Column("causation_id", sa.String(length=64), nullable=True),
        sa.Column(
            "occurred_at",
            sa.DateTime(timezone=True),
            nullable=False,
            server_default=sa.func.now(),
        ),
        sa.CheckConstraint(
            "length(trim(event_type)) > 0",
            name="ck_approval_task_events_type",
        ),
        sa.CheckConstraint(
            "actor_type IN ('user', 'system', 'agent', 'service')",
            name="ck_approval_task_events_actor_type",
        ),
        sa.CheckConstraint(
            "expected_task_version >= 0 AND result_task_version >= 1 "
            "AND result_task_version >= expected_task_version",
            name="ck_approval_task_events_version",
        ),
        sa.ForeignKeyConstraint(
            ["tenant_id", "task_id"],
            ["approval_tasks.tenant_id", "approval_tasks.id"],
            name="fk_approval_task_events_tenant_task",
            ondelete="RESTRICT",
        ),
        sa.PrimaryKeyConstraint("id"),
        sa.UniqueConstraint(
            "tenant_id",
            "actor_id",
            "request_id",
            name="uq_approval_task_events_actor_request",
        ),
        sa.UniqueConstraint(
            "tenant_id",
            "task_id",
            "result_task_version",
            name="uq_approval_task_events_task_version",
        ),
    )
    op.create_index(
        "ix_approval_task_events_tenant_task_time",
        "approval_task_events",
        ["tenant_id", "task_id", "occurred_at"],
        unique=False,
    )
    op.create_index(
        "ix_approval_task_events_tenant_node_time",
        "approval_task_events",
        ["tenant_id", "node_instance_id", "occurred_at"],
        unique=False,
    )
    op.execute(
        "CREATE FUNCTION reject_approval_task_event_mutation() "
        "RETURNS trigger AS $$ "
        "BEGIN "
        "RAISE EXCEPTION 'approval_task_events is append-only'; "
        "RETURN OLD; "
        "END; "
        "$$ LANGUAGE plpgsql"
    )
    op.execute(
        "CREATE TRIGGER trg_approval_task_events_append_only "
        "BEFORE UPDATE OR DELETE ON approval_task_events "
        "FOR EACH ROW EXECUTE FUNCTION reject_approval_task_event_mutation()"
    )


def downgrade() -> None:
    _require_postgresql()
    # 任务投影与事件共同组成审批审计链，降级前必须显式迁移或清空数据。
    _require_empty_audit_chain_for_downgrade()
    op.execute("DROP TRIGGER trg_approval_task_events_append_only ON approval_task_events")
    op.execute("DROP FUNCTION reject_approval_task_event_mutation()")
    op.drop_index(
        "ix_approval_task_events_tenant_node_time",
        table_name="approval_task_events",
    )
    op.drop_index(
        "ix_approval_task_events_tenant_task_time",
        table_name="approval_task_events",
    )
    op.drop_table("approval_task_events")
    op.drop_index("ix_approval_tasks_tenant_node", table_name="approval_tasks")
    op.drop_index("ix_approval_tasks_tenant_claim", table_name="approval_tasks")
    op.drop_index("ix_approval_tasks_tenant_queue", table_name="approval_tasks")
    op.drop_index("ix_approval_tasks_personal_inbox", table_name="approval_tasks")
    op.drop_index("uq_approval_tasks_open_root_per_claim", table_name="approval_tasks")
    op.drop_table("approval_tasks")
