from __future__ import annotations

import hashlib
import hmac
import json
import logging
import os
import threading
from datetime import datetime, timedelta, timezone
from typing import Any

import httpx
from fastapi import Depends, FastAPI, Header, HTTPException, Query, Request
from fastapi.responses import PlainTextResponse
from sqlalchemy import and_, case, func, inspect, or_, select, text, update
from sqlalchemy.exc import IntegrityError

from services.shared.core import Role, new_id, utc_now_iso
from services.shared.db import engine, get_session
from services.shared.models import (
    AIWhatsAppEnqueueIn,
    EscalateRequest,
    HealthResponse,
    InteractionStatus,
    WhatsAppThreadAISummaryOut,
    WhatsAppThreadAIHandoffIn,
    WhatsAppThreadAIReplyIn,
    WhatsAppThreadEscalateIn,
    WhatsAppThreadMessageOut,
    WhatsAppThreadOut,
    WhatsAppThreadReplyIn,
    WhatsAppWebhookIn,
    WhatsAppWebhookOut,
)
from services.shared.security import issue_app_token, require_roles
from services.shared.sql_init import init_sql_schema
from services.shared.sql_models import (
    AISessionRow,
    Customer,
    CustomerExternalIdentity,
    Interaction,
    InteractionTimeline,
    WhatsAppMessageRow,
    WhatsAppThreadRow,
)

app = FastAPI(title="whatsapp-adapter-service", version="2.0.0")

init_sql_schema()

logger = logging.getLogger(__name__)

WHATSAPP_BOT_DEDUP_INDEX = "ix_whatsapp_messages_chat_external_unique"
WHATSAPP_REPLY_NEXT_ATTEMPT_INDEX = "ix_whatsapp_messages_next_delivery_attempt_at"
WHATSAPP_REPLY_LOCKED_UNTIL_INDEX = "ix_whatsapp_messages_delivery_locked_until"

_reply_delivery_worker_lock = threading.Lock()
_reply_delivery_worker_thread: threading.Thread | None = None
_reply_delivery_worker_stop: threading.Event | None = None
_reply_delivery_worker_wakeup: threading.Event | None = None
_reply_delivery_worker_clients = 0
_HANDOFF_REASON_UNSET = object()


def _utc_now() -> datetime:
    return datetime.now(timezone.utc).replace(microsecond=0)


def _utc_after_seconds(seconds: int) -> str:
    return (_utc_now() + timedelta(seconds=max(seconds, 0))).isoformat()


def _text_column_type() -> str:
    return "TEXT"


def _string_column_type(length: int = 64) -> str:
    return f"VARCHAR({length})"


def _integer_env(name: str, default: int) -> int:
    raw = os.getenv(name)
    if raw is None:
        return default
    try:
        return int(raw.strip())
    except ValueError:
        return default


def _ensure_whatsapp_message_indexes() -> None:
    # Runtime compatibility for WhatsApp lives in shared.sql_init.
    # Keep the hook so the adapter startup shape matches Telegram without
    # duplicating migration logic in a second place.
    return


_ensure_whatsapp_message_indexes()


def _bool_env(name: str, default: bool) -> bool:
    raw = os.getenv(name)
    if raw is None:
        return default
    return raw.strip().lower() in {"1", "true", "yes", "on"}


def _outbound_enabled() -> bool:
    return _bool_env("WHATSAPP_OUTBOUND_ENABLED", False)


def _outbound_auth_token() -> str:
    return os.getenv("WHATSAPP_OUTBOUND_AUTH_TOKEN", "").strip()


def _webhook_secret() -> str:
    return os.getenv("WHATSAPP_WEBHOOK_SECRET", "").strip()


def _meta_verify_token() -> str:
    return (
        os.getenv("WHATSAPP_META_VERIFY_TOKEN", "").strip()
        or os.getenv("WHATSAPP_VERIFY_TOKEN", "").strip()
    )


def _meta_app_secret() -> str:
    return (
        os.getenv("WHATSAPP_META_APP_SECRET", "").strip()
        or os.getenv("WHATSAPP_APP_SECRET", "").strip()
    )


def _meta_access_token() -> str:
    return os.getenv("WHATSAPP_META_ACCESS_TOKEN", "").strip()


def _meta_phone_number_id() -> str:
    return os.getenv("WHATSAPP_META_PHONE_NUMBER_ID", "").strip()


def _meta_graph_api_version() -> str:
    return os.getenv("WHATSAPP_META_GRAPH_API_VERSION", "v22.0").strip() or "v22.0"


def _meta_graph_base_url() -> str:
    return os.getenv("WHATSAPP_META_GRAPH_BASE_URL", "https://graph.facebook.com").rstrip("/")


def _default_queue_id() -> str:
    return os.getenv("WHATSAPP_DEFAULT_QUEUE_ID", "q_whatsapp").strip() or "q_whatsapp"


def _outbound_send_url() -> str:
    return os.getenv("WHATSAPP_OUTBOUND_SEND_URL", "").strip()


def _reply_delivery_worker_enabled() -> bool:
    return _bool_env("WHATSAPP_REPLY_DELIVERY_WORKER_ENABLED", True)


def _reply_delivery_poll_seconds() -> int:
    return max(1, _integer_env("WHATSAPP_REPLY_DELIVERY_POLL_SECONDS", 5))


def _reply_delivery_lease_seconds() -> int:
    return max(5, _integer_env("WHATSAPP_REPLY_DELIVERY_LEASE_SECONDS", 30))


def _reply_delivery_retry_base_seconds() -> int:
    return max(1, _integer_env("WHATSAPP_REPLY_DELIVERY_RETRY_BASE_SECONDS", 5))


def _reply_delivery_retry_cap_seconds() -> int:
    return max(_reply_delivery_retry_base_seconds(), _integer_env("WHATSAPP_REPLY_DELIVERY_RETRY_CAP_SECONDS", 300))


def _reply_delivery_max_attempts() -> int:
    return max(1, _integer_env("WHATSAPP_REPLY_DELIVERY_MAX_ATTEMPTS", 5))


def _interaction_service_url() -> str:
    return os.getenv("INTERACTION_SERVICE_URL", "http://localhost:8004").rstrip("/")


def _ai_service_url() -> str:
    return os.getenv("AI_ORCHESTRATOR_SERVICE_URL", "http://localhost:8017").rstrip("/")


def _ai_whatsapp_enabled() -> bool:
    return _bool_env("AI_WHATSAPP_ENABLED", False)


def _service_auth_headers() -> dict[str, str]:
    token = issue_app_token(
        subject="svc:whatsapp-adapter",
        username="whatsapp-adapter",
        role="admin",
        auth_source="service",
        provider="whatsapp-adapter",
        ttl_seconds=300,
    )
    return {"Authorization": f"Bearer {token}"}


def _internal_service_headers(
    *,
    subject: str,
    username: str,
    provider: str,
) -> dict[str, str]:
    token = issue_app_token(
        subject=subject,
        username=username,
        role="admin",
        auth_source="service",
        provider=provider,
        ttl_seconds=300,
    )
    return {"Authorization": f"Bearer {token}"}


def _interaction_request(method: str, path: str, *, payload: dict | None = None) -> dict:
    with httpx.Client(timeout=5.0) as client:
        response = client.request(
            method,
            f"{_interaction_service_url()}{path}",
            json=payload,
            headers=_service_auth_headers(),
        )
    response.raise_for_status()
    return response.json()


def _ai_enqueue_request(thread_id: str, trigger_message_id: str | None) -> None:
    if not _ai_whatsapp_enabled():
        return
    try:
        with httpx.Client(timeout=10.0) as client:
            response = client.post(
                f"{_ai_service_url()}/ai/whatsapp/threads/{thread_id}/enqueue",
                json=AIWhatsAppEnqueueIn(trigger_message_id=trigger_message_id).model_dump(),
                headers=_internal_service_headers(
                    subject="svc:whatsapp-adapter",
                    username="whatsapp-adapter",
                    provider="whatsapp-adapter",
                ),
            )
        response.raise_for_status()
    except Exception:  # noqa: BLE001
        logger.exception("WhatsApp AI enqueue failed", extra={"thread_id": thread_id})


def _preview_text(text: str, limit: int = 120) -> str:
    value = str(text or "").strip()
    if len(value) <= limit:
        return value
    return f"{value[: limit - 3]}..."


def _normalize_external_subject(value: str | None) -> str | None:
    raw = str(value or "").strip()
    if not raw:
        return None
    if raw.startswith("whatsapp:"):
        return raw.split(":", 1)[1].strip() or None
    return raw


def _first_external_subject(
    whatsapp_user_id: str | None,
    chat_id: str,
    explicit: str | None = None,
) -> str:
    return (
        _normalize_external_subject(whatsapp_user_id)
        or _normalize_external_subject(explicit)
        or _normalize_external_subject(chat_id)
        or chat_id
    )


def _customer_external_id(whatsapp_user_id: str | None, chat_id: str, explicit: str | None = None) -> str:
    return f"whatsapp:{_first_external_subject(whatsapp_user_id, chat_id, explicit)}"


def _customer_id_is_real(customer_id: str | None) -> bool:
    return str(customer_id or "").startswith("cus_")


def _whatsapp_identity_subjects(
    whatsapp_user_id: str | None,
    chat_id: str,
    *,
    phone_number: str | None = None,
    external_subject: str | None = None,
) -> list[str]:
    ordered = [
        _normalize_external_subject(whatsapp_user_id),
        _normalize_external_subject(phone_number),
        _normalize_external_subject(external_subject),
        _normalize_external_subject(chat_id),
    ]
    seen: set[str] = set()
    result: list[str] = []
    for item in ordered:
        if not item or item in seen:
            continue
        seen.add(item)
        result.append(item)
    return result


def _ensure_customer_external_identity(
    session,
    *,
    customer_id: str,
    channel: str,
    external_subject: str,
    display_name_snapshot: str | None,
    now: str,
) -> None:
    row = session.execute(
        select(CustomerExternalIdentity).where(
            CustomerExternalIdentity.channel == channel,
            CustomerExternalIdentity.external_subject == external_subject,
        )
    ).scalar_one_or_none()
    if row:
        row.customer_id = customer_id
        if display_name_snapshot:
            row.display_name_snapshot = display_name_snapshot
        row.updated_at = now
        return
    session.add(
        CustomerExternalIdentity(
            identity_id=new_id("cei"),
            customer_id=customer_id,
            channel=channel,
            external_subject=external_subject,
            display_name_snapshot=display_name_snapshot,
            created_at=now,
            updated_at=now,
        )
    )


def _resolve_or_create_customer_id(
    session,
    *,
    whatsapp_user_id: str | None,
    chat_id: str,
    display_name: str,
    phone_number: str | None = None,
    explicit_customer_id: str | None = None,
    external_subject: str | None = None,
) -> str:
    now = utc_now_iso()
    explicit_value = str(explicit_customer_id or "").strip()
    subjects = _whatsapp_identity_subjects(
        whatsapp_user_id,
        chat_id,
        phone_number=phone_number,
        external_subject=external_subject,
    )

    for subject in subjects:
        identity = session.execute(
            select(CustomerExternalIdentity).where(
                CustomerExternalIdentity.channel == "whatsapp",
                CustomerExternalIdentity.external_subject == subject,
            )
        ).scalar_one_or_none()
        if identity:
            if display_name and identity.display_name_snapshot != display_name:
                identity.display_name_snapshot = display_name
                identity.updated_at = now
            return identity.customer_id

    if _customer_id_is_real(explicit_value):
        customer = session.execute(
            select(Customer).where(Customer.customer_id == explicit_value)
        ).scalar_one_or_none()
        if not customer:
            customer = Customer(
                customer_id=explicit_value,
                display_name=display_name,
                phones_json="[]",
                preferred_phone=None,
                tags_json=json.dumps(["whatsapp"], ensure_ascii=False),
                created_at=now,
            )
            session.add(customer)
        elif display_name and customer.display_name != display_name:
            customer.display_name = display_name
        customer_id = customer.customer_id
    else:
        customer = Customer(
            customer_id=new_id("cus"),
            display_name=display_name,
            phones_json="[]",
            preferred_phone=None,
            tags_json=json.dumps(["whatsapp"], ensure_ascii=False),
            created_at=now,
        )
        session.add(customer)
        customer_id = customer.customer_id

    for subject in subjects or [_normalize_external_subject(chat_id) or chat_id]:
        _ensure_customer_external_identity(
            session,
            customer_id=customer_id,
            channel="whatsapp",
            external_subject=subject,
            display_name_snapshot=display_name,
            now=now,
        )
    return customer_id


def _display_name(
    *,
    username: str | None = None,
    first_name: str | None = None,
    last_name: str | None = None,
    fallback: str | None = None,
) -> str:
    parts = [str(first_name or "").strip(), str(last_name or "").strip()]
    full_name = " ".join(part for part in parts if part).strip()
    if full_name:
        return full_name
    if username:
        return username
    if fallback:
        return fallback
    return "WhatsApp user"


def _inbound_author_type(direction: str) -> str:
    return "system" if direction == "system" else "customer"


def _message_can_trigger_ai(author_type: str | None) -> bool:
    return _ai_whatsapp_enabled() and author_type == "customer"


def _normalize_phone_number(value: str | None) -> str | None:
    raw = str(value or "").strip()
    if not raw:
        return None
    digits = "".join(ch for ch in raw if ch.isdigit())
    if not digits:
        return None
    if raw.startswith("+"):
        return f"+{digits}"
    return digits


def _contact_phone_number(contact: dict | None, *, sender_user_id: str | None) -> str | None:
    if not isinstance(contact, dict):
        return None
    phone_number = _normalize_phone_number(contact.get("phone_number"))
    if not phone_number:
        return None
    contact_user_id = str(contact.get("user_id") or "").strip() or None
    if sender_user_id and contact_user_id and contact_user_id != sender_user_id:
        return None
    return phone_number


def _customer_phone_list(raw: str | None) -> list[str]:
    try:
        value = json.loads(raw or "[]")
    except Exception:  # noqa: BLE001
        return []
    if not isinstance(value, list):
        return []
    result: list[str] = []
    for item in value:
        normalized = _normalize_phone_number(item)
        if normalized and normalized not in result:
            result.append(normalized)
    return result


def _ensure_customer_phone(session, *, customer_id: str, phone_number: str | None) -> None:
    normalized_phone = _normalize_phone_number(phone_number)
    if not normalized_phone or not _customer_id_is_real(customer_id):
        return
    session.flush()
    customer = session.execute(
        select(Customer).where(Customer.customer_id == customer_id)
    ).scalar_one_or_none()
    if not customer:
        return
    phones = _customer_phone_list(customer.phones_json)
    if normalized_phone not in phones:
        phones.append(normalized_phone)
        customer.phones_json = json.dumps(phones, ensure_ascii=False)
    if not customer.preferred_phone:
        customer.preferred_phone = normalized_phone


def _push_timeline(session, interaction_id: str, action: str, metadata: dict | None = None) -> None:
    session.add(
        InteractionTimeline(
            interaction_id=interaction_id,
            timestamp=utc_now_iso(),
            action=action,
            metadata_json=json.dumps(metadata or {}, ensure_ascii=False),
        )
    )


def _get_thread(session, thread_id: str) -> WhatsAppThreadRow:
    row = session.execute(
        select(WhatsAppThreadRow).where(WhatsAppThreadRow.thread_id == thread_id)
    ).scalar_one_or_none()
    if not row:
        raise HTTPException(status_code=404, detail="WhatsApp thread not found")
    return row


def _thread_unread_count(session, thread_id: str) -> int:
    last_outbound_at = session.execute(
        select(func.max(WhatsAppMessageRow.created_at)).where(
            WhatsAppMessageRow.thread_id == thread_id,
            WhatsAppMessageRow.direction == "outbound",
        )
    ).scalar_one_or_none()

    stmt = select(func.count(WhatsAppMessageRow.id)).where(
        WhatsAppMessageRow.thread_id == thread_id,
        WhatsAppMessageRow.direction == "inbound",
        WhatsAppMessageRow.author_type == "customer",
    )
    if last_outbound_at:
        stmt = stmt.where(WhatsAppMessageRow.created_at > last_outbound_at)
    raw = session.execute(stmt).scalar_one_or_none()
    return int(raw or 0)


def _to_thread_out(row: WhatsAppThreadRow, *, unread_count: int = 0) -> WhatsAppThreadOut:
    return WhatsAppThreadOut(
        thread_id=row.thread_id,
        chat_id=row.chat_id,
        interaction_id=row.interaction_id,
        whatsapp_user_id=row.whatsapp_user_id,
        phone_number=row.phone_number,
        display_name=row.display_name,
        queue_id=row.queue_id,
        is_group=bool(row.is_group),
        status=row.status,  # type: ignore[arg-type]
        claimed_by_user=row.claimed_by_user,
        claimed_at=row.claimed_at,
        ai_session_id=row.ai_session_id,
        ai_state=row.ai_state,  # type: ignore[arg-type]
        ai_handoff_reason=row.ai_handoff_reason,
        ai_last_model_at=row.ai_last_model_at,
        unread_count=unread_count,
        last_message_at=row.last_message_at,
        last_message_preview=row.last_message_preview,
        created_at=row.created_at,
        updated_at=row.updated_at,
    )


def _to_message_out(row: WhatsAppMessageRow) -> WhatsAppThreadMessageOut:
    return WhatsAppThreadMessageOut(
        message_id=row.message_id,
        thread_id=row.thread_id or "",
        interaction_id=row.interaction_id or "",
        chat_id=row.chat_id,
        direction=row.direction,  # type: ignore[arg-type]
        text=row.text,
        whatsapp_message_id_external=row.whatsapp_message_id_external,
        operator_user=row.operator_user,
        author_type=row.author_type,  # type: ignore[arg-type]
        author_id=row.author_id,
        customer_id=row.customer_id,
        delivery_status=row.delivery_status,
        payload=json.loads(row.payload_json or "{}"),
        created_at=row.created_at,
    )


def _loads_payload(raw: str | None) -> dict[str, Any]:
    try:
        payload = json.loads(raw or "{}")
    except Exception:  # noqa: BLE001
        return {}
    return payload if isinstance(payload, dict) else {}


def _to_webhook_out(row: WhatsAppMessageRow) -> WhatsAppWebhookOut:
    return WhatsAppWebhookOut(
        message_id=row.message_id,
        chat_id=row.chat_id,
        text=row.text,
        external_message_id=row.whatsapp_message_id_external,
        customer_id=row.customer_id,
        payload=_loads_payload(row.payload_json),
        thread_id=row.thread_id,
        interaction_id=row.interaction_id,
        direction=row.direction,  # type: ignore[arg-type]
        created_at=row.created_at,
    )


def _to_bot_webhook_result(row: WhatsAppMessageRow, *, thread_created: bool) -> dict[str, Any]:
    return {
        "ok": True,
        "thread_id": row.thread_id or "",
        "interaction_id": row.interaction_id or "",
        "message_id": row.message_id,
        "thread_created": thread_created,
    }


def _existing_bot_message(
    session,
    *,
    chat_id: str,
    external_message_id: str | None,
) -> WhatsAppMessageRow | None:
    if not external_message_id:
        return None
    return session.execute(
        select(WhatsAppMessageRow).where(
            WhatsAppMessageRow.chat_id == chat_id,
            WhatsAppMessageRow.whatsapp_message_id_external == external_message_id,
        )
    ).scalar_one_or_none()


def _latest_ai_session_for_thread(session, thread: WhatsAppThreadRow) -> AISessionRow | None:
    if thread.ai_session_id:
        ai_session = session.execute(
            select(AISessionRow).where(AISessionRow.session_id == thread.ai_session_id)
        ).scalar_one_or_none()
        if ai_session:
            return ai_session
    return session.execute(
        select(AISessionRow)
        .where(AISessionRow.thread_id == thread.thread_id)
        .order_by(AISessionRow.updated_at.desc(), AISessionRow.id.desc())
        .limit(1)
    ).scalar_one_or_none()


def _summary_customer_request_text(
    session,
    *,
    thread: WhatsAppThreadRow,
    ai_session: AISessionRow,
    generated_at: str,
) -> str:
    if ai_session.last_user_message_id:
        customer_message = session.execute(
            select(WhatsAppMessageRow).where(WhatsAppMessageRow.message_id == ai_session.last_user_message_id)
        ).scalar_one_or_none()
        if customer_message and customer_message.text.strip():
            return customer_message.text.strip()
    customer_message = session.execute(
        select(WhatsAppMessageRow)
        .where(
            WhatsAppMessageRow.thread_id == thread.thread_id,
            WhatsAppMessageRow.direction == "inbound",
            WhatsAppMessageRow.author_type == "customer",
            WhatsAppMessageRow.created_at <= generated_at,
        )
        .order_by(WhatsAppMessageRow.created_at.desc(), WhatsAppMessageRow.id.desc())
        .limit(1)
    ).scalar_one_or_none()
    if customer_message and customer_message.text.strip():
        return customer_message.text.strip()
    return "Последний запрос клиента недоступен."


def _summary_ai_outcome_text(
    session,
    *,
    thread: WhatsAppThreadRow,
    ai_session: AISessionRow,
    generated_at: str,
) -> str:
    if ai_session.last_ai_message_id:
        ai_message = session.execute(
            select(WhatsAppMessageRow).where(WhatsAppMessageRow.message_id == ai_session.last_ai_message_id)
        ).scalar_one_or_none()
        if ai_message and ai_message.text.strip():
            return ai_message.text.strip()
    ai_message = session.execute(
        select(WhatsAppMessageRow)
        .where(
            WhatsAppMessageRow.thread_id == thread.thread_id,
            WhatsAppMessageRow.direction == "outbound",
            WhatsAppMessageRow.author_type == "ai",
            WhatsAppMessageRow.created_at <= generated_at,
        )
        .order_by(WhatsAppMessageRow.created_at.desc(), WhatsAppMessageRow.id.desc())
        .limit(1)
    ).scalar_one_or_none()
    if ai_message and ai_message.text.strip():
        return ai_message.text.strip()
    return "AI передал диалог оператору без ответа клиенту."


def _summary_status(
    session,
    *,
    thread: WhatsAppThreadRow,
    ai_session: AISessionRow,
    generated_at: str,
) -> tuple[str, str]:
    if ai_session.last_ai_message_id:
        ai_message = session.execute(
            select(WhatsAppMessageRow).where(WhatsAppMessageRow.message_id == ai_session.last_ai_message_id)
        ).scalar_one_or_none()
        if ai_message and ai_message.text.strip():
            return ("AI ответил клиенту", "answered")
    ai_message = session.execute(
        select(WhatsAppMessageRow.message_id)
        .where(
            WhatsAppMessageRow.thread_id == thread.thread_id,
            WhatsAppMessageRow.direction == "outbound",
            WhatsAppMessageRow.author_type == "ai",
            WhatsAppMessageRow.created_at <= generated_at,
        )
        .order_by(WhatsAppMessageRow.created_at.desc(), WhatsAppMessageRow.id.desc())
        .limit(1)
    ).scalar_one_or_none()
    if ai_message:
        return ("AI ответил клиенту", "answered")
    return ("AI передал без ответа", "handoff")


def _summary_recommended_next_step(thread: WhatsAppThreadRow) -> str:
    if thread.ai_state == "handoff_required":
        return "Заберите чат и ответьте клиенту вручную."
    if thread.ai_state == "human_owned":
        return "Продолжайте диалог вручную; AI больше не отвечает в этот thread."
    return ""


def _thread_supports_ai_summary(thread: WhatsAppThreadRow, ai_session: AISessionRow | None) -> bool:
    if not ai_session:
        return False
    if thread.ai_state == "handoff_required":
        return True
    if thread.ai_state == "human_owned":
        return bool(thread.ai_handoff_reason or ai_session.handoff_reason)
    return False


def _build_ai_summary(session, thread: WhatsAppThreadRow) -> WhatsAppThreadAISummaryOut | None:
    ai_session = _latest_ai_session_for_thread(session, thread)
    if not _thread_supports_ai_summary(thread, ai_session):
        return None
    assert ai_session is not None
    generated_at = thread.ai_last_model_at or ai_session.updated_at or thread.updated_at
    handoff_reason = (thread.ai_handoff_reason or ai_session.handoff_reason or "").strip()
    if not handoff_reason:
        return None
    status_label, status_tone = _summary_status(
        session,
        thread=thread,
        ai_session=ai_session,
        generated_at=generated_at,
    )
    return WhatsAppThreadAISummaryOut(
        thread_id=thread.thread_id,
        session_id=ai_session.session_id,
        status_label=status_label,
        status_tone=status_tone,  # type: ignore[arg-type]
        customer_request_text=_summary_customer_request_text(
            session,
            thread=thread,
            ai_session=ai_session,
            generated_at=generated_at,
        ),
        ai_outcome_text=_summary_ai_outcome_text(
            session,
            thread=thread,
            ai_session=ai_session,
            generated_at=generated_at,
        ),
        handoff_reason=handoff_reason,
        recommended_next_step=_summary_recommended_next_step(thread),
        generated_at=generated_at,
    )


def _reactivate_thread_interaction(
    session,
    thread: WhatsAppThreadRow,
    now: str,
    *,
    requeue_ai: bool,
) -> None:
    interaction = session.execute(
        select(Interaction).where(Interaction.interaction_id == thread.interaction_id)
    ).scalar_one_or_none()
    if not interaction:
        return
    interaction.status = "new"
    interaction.assigned_to = None
    interaction.updated_at = now
    thread.status = "new"
    thread.claimed_by_user = None
    thread.claimed_at = None
    thread.ai_handoff_reason = None
    if thread.ai_state != "human_owned":
        thread.ai_state = "queued" if requeue_ai else None
    thread.updated_at = now
    _push_timeline(
        session,
        interaction.interaction_id,
        "interaction.status_changed",
        {"status": "new", "source": "whatsapp-reactivation"},
    )
    _push_timeline(
        session,
        interaction.interaction_id,
        "whatsapp.thread_reactivated",
        {"thread_id": thread.thread_id, "chat_id": thread.chat_id},
    )


def _create_interaction(session, *, subject: str, customer_id: str, queue_id: str, created_at: str) -> str:
    interaction_id = new_id("int")
    session.add(
        Interaction(
            interaction_id=interaction_id,
            channel="whatsapp",
            subject=_preview_text(subject, 120),
            customer_id=customer_id,
            queue_id=queue_id,
            priority=3,
            status="new",
            assigned_to=None,
            created_at=created_at,
            updated_at=created_at,
        )
    )
    _push_timeline(session, interaction_id, "interaction.created", {"channel": "whatsapp"})
    return interaction_id


def _persist_message(
    session,
    *,
    thread: WhatsAppThreadRow,
    text: str,
    payload: dict,
    direction: str,
    created_at: str,
    customer_id: str | None = None,
    external_message_id: str | None = None,
    operator_user: str | None = None,
    author_type: str | None = None,
    author_id: str | None = None,
    delivery_status: str | None = None,
) -> WhatsAppMessageRow:
    resolved_author_type = author_type or ("human" if direction == "outbound" else ("system" if direction == "system" else "customer"))
    row = WhatsAppMessageRow(
        message_id=new_id("wam"),
        thread_id=thread.thread_id,
        interaction_id=thread.interaction_id,
        chat_id=thread.chat_id,
        text=text,
        customer_id=customer_id,
        direction=direction,
        whatsapp_message_id_external=external_message_id,
        operator_user=operator_user,
        author_type=resolved_author_type,
        author_id=author_id,
        delivery_status=delivery_status,
        payload_json=json.dumps(payload, ensure_ascii=False),
        created_at=created_at,
    )
    session.add(row)
    thread.last_message_at = created_at
    thread.last_message_preview = _preview_text(text, 140)
    thread.updated_at = created_at
    return row


def _mark_thread_ai_state(
    thread: WhatsAppThreadRow,
    *,
    ai_state: str | None,
    ai_handoff_reason: str | None = None,
    ai_last_model_at: str | None = None,
) -> None:
    thread.ai_state = ai_state
    thread.ai_handoff_reason = ai_handoff_reason
    if ai_last_model_at is not None:
        thread.ai_last_model_at = ai_last_model_at


def _update_ai_session_state(
    session,
    *,
    session_id: str | None,
    status: str,
    updated_at: str,
    handoff_reason: object = _HANDOFF_REASON_UNSET,
    closed: bool = False,
) -> None:
    if not session_id:
        return
    ai_session = session.execute(
        select(AISessionRow).where(AISessionRow.session_id == session_id)
    ).scalar_one_or_none()
    if not ai_session:
        return
    ai_session.status = status
    ai_session.updated_at = updated_at
    if handoff_reason is not _HANDOFF_REASON_UNSET:
        ai_session.handoff_reason = handoff_reason
    if closed:
        ai_session.closed_at = updated_at


def _maybe_enqueue_ai_for_thread(
    thread: WhatsAppThreadRow,
    trigger_message_id: str | None,
    *,
    author_type: str | None = None,
) -> None:
    if not _ai_whatsapp_enabled():
        return
    if thread.ai_state == "human_owned":
        return
    if author_type and author_type != "customer":
        return
    threading.Thread(
        target=_ai_enqueue_request,
        args=(thread.thread_id, trigger_message_id),
        daemon=True,
        name=f"whatsapp-ai-enqueue-{thread.thread_id}",
    ).start()


def _upsert_thread_for_inbound(
    session,
    *,
    chat_id: str,
    whatsapp_user_id: str | None,
    display_name: str,
    text: str,
    payload: dict,
    customer_id: str | None = None,
    external_subject: str | None = None,
    external_message_id: str | None = None,
    phone_number: str | None = None,
    queue_id: str | None = None,
    is_group: bool = False,
    direction: str = "inbound",
) -> tuple[WhatsAppThreadRow, WhatsAppMessageRow, bool]:
    now = utc_now_iso()
    author_type = _inbound_author_type(direction)
    ai_eligible = _message_can_trigger_ai(author_type)
    thread = session.execute(
        select(WhatsAppThreadRow).where(WhatsAppThreadRow.chat_id == chat_id)
    ).scalar_one_or_none()
    created = False
    assigned_customer_id = _resolve_or_create_customer_id(
        session,
        whatsapp_user_id=whatsapp_user_id,
        chat_id=chat_id,
        display_name=display_name,
        phone_number=phone_number,
        explicit_customer_id=customer_id,
        external_subject=external_subject,
    )
    _ensure_customer_phone(session, customer_id=assigned_customer_id, phone_number=phone_number)
    resolved_queue_id = queue_id or (thread.queue_id if thread else None) or _default_queue_id()

    if not thread:
        interaction_id = _create_interaction(
            session,
            subject=text,
            customer_id=assigned_customer_id,
            queue_id=resolved_queue_id,
            created_at=now,
        )
        thread = WhatsAppThreadRow(
            thread_id=new_id("wht"),
            chat_id=chat_id,
            interaction_id=interaction_id,
            whatsapp_user_id=whatsapp_user_id,
            phone_number=phone_number,
            display_name=display_name,
            queue_id=resolved_queue_id,
            is_group=is_group,
            status="new",
            claimed_by_user=None,
            claimed_at=None,
            ai_session_id=None,
            ai_state="queued" if ai_eligible else None,
            ai_handoff_reason=None,
            ai_last_model_at=None,
            last_message_at=now,
            last_message_preview=_preview_text(text, 140),
            created_at=now,
            updated_at=now,
        )
        session.add(thread)
        created = True
    else:
        if thread.status == "closed":
            _reactivate_thread_interaction(session, thread, now, requeue_ai=ai_eligible)
        thread.whatsapp_user_id = whatsapp_user_id or thread.whatsapp_user_id
        thread.phone_number = phone_number or thread.phone_number
        thread.display_name = display_name or thread.display_name
        thread.queue_id = thread.queue_id or resolved_queue_id
        thread.is_group = bool(thread.is_group or is_group)
        interaction = session.execute(
            select(Interaction).where(Interaction.interaction_id == thread.interaction_id)
        ).scalar_one_or_none()
        if interaction and interaction.customer_id != assigned_customer_id:
            interaction.customer_id = assigned_customer_id
            interaction.updated_at = now
        if ai_eligible and thread.ai_state != "human_owned":
            _mark_thread_ai_state(thread, ai_state="queued", ai_handoff_reason=None)

    message_row = _persist_message(
        session,
        thread=thread,
        text=text,
        payload=payload,
        direction=direction,
        created_at=now,
        customer_id=assigned_customer_id,
        external_message_id=external_message_id,
        author_type=author_type,
        author_id=_first_external_subject(whatsapp_user_id, chat_id, external_subject or phone_number),
        delivery_status="received",
    )
    _push_timeline(
        session,
        thread.interaction_id,
        "whatsapp.message_received",
        {
            "thread_id": thread.thread_id,
            "chat_id": chat_id,
            "direction": direction,
            "display_name": display_name,
        },
    )
    session.flush()
    return thread, message_row, created


def _manual_webhook_metadata(payload: WhatsAppWebhookIn) -> dict[str, Any]:
    raw_payload = payload.payload if isinstance(payload.payload, dict) else {}
    whatsapp_user_id = (
        str(payload.whatsapp_user_id or raw_payload.get("whatsapp_user_id") or raw_payload.get("from") or "").strip()
        or None
    )
    phone_number = _normalize_phone_number(payload.phone_number) or _normalize_phone_number(raw_payload.get("phone_number"))
    display_name = (
        str(payload.display_name or raw_payload.get("display_name") or raw_payload.get("profile_name") or "").strip()
        or phone_number
        or f"WhatsApp {payload.chat_id}"
    )
    queue_id = str(payload.queue_id or raw_payload.get("queue_id") or "").strip() or None
    external_subject = str(payload.external_subject or raw_payload.get("external_subject") or "").strip() or None
    is_group = bool(payload.is_group or raw_payload.get("is_group"))
    return {
        "whatsapp_user_id": whatsapp_user_id,
        "display_name": display_name,
        "phone_number": phone_number,
        "queue_id": queue_id,
        "external_subject": external_subject,
        "is_group": is_group,
    }


def _parse_provider_update(update: dict) -> dict:
    if not isinstance(update, dict):
        raise HTTPException(status_code=400, detail="Unsupported WhatsApp update")

    if str(update.get("chat_id") or "").strip():
        text = str(update.get("text") or "").strip()
        message_type = str(update.get("message_type") or "").strip() or "message"
        direction = "inbound"
        if not text:
            text = "[Shared WhatsApp contact]" if message_type == "contacts" else f"[Unsupported WhatsApp content: {message_type}]"
            direction = "system"
        whatsapp_user_id = str(update.get("whatsapp_user_id") or update.get("from") or update.get("chat_id") or "").strip() or None
        phone_number = _normalize_phone_number(update.get("phone_number") or whatsapp_user_id)
        return {
            "chat_id": str(update.get("chat_id")).strip(),
            "whatsapp_user_id": whatsapp_user_id,
            "display_name": str(update.get("display_name") or phone_number or f"WhatsApp {update.get('chat_id')}").strip(),
            "text": text,
            "payload": update,
            "external_message_id": str(update.get("external_message_id") or update.get("message_id") or "").strip() or None,
            "phone_number": phone_number,
            "external_subject": str(update.get("external_subject") or "").strip() or None,
            "queue_id": str(update.get("queue_id") or "").strip() or None,
            "is_group": bool(update.get("is_group")),
            "direction": direction,
        }

    entry = update.get("entry")
    if not isinstance(entry, list) or not entry:
        raise HTTPException(status_code=400, detail="Unsupported WhatsApp provider payload")
    changes = entry[0].get("changes") if isinstance(entry[0], dict) else None
    if not isinstance(changes, list) or not changes:
        raise HTTPException(status_code=400, detail="WhatsApp provider payload has no changes")
    value = changes[0].get("value") if isinstance(changes[0], dict) else {}
    if not isinstance(value, dict):
        raise HTTPException(status_code=400, detail="WhatsApp provider payload is invalid")
    messages = value.get("messages")
    if not isinstance(messages, list) or not messages:
        raise HTTPException(status_code=400, detail="WhatsApp provider payload has no messages")

    message = messages[0] if isinstance(messages[0], dict) else {}
    chat_id = str(message.get("from") or "").strip()
    if not chat_id:
        raise HTTPException(status_code=400, detail="WhatsApp chat_id is missing")
    message_type = str(message.get("type") or "text").strip() or "text"
    text = ""
    if isinstance(message.get("text"), dict):
        text = str(message["text"].get("body") or "").strip()
    elif isinstance(message.get("interactive"), dict):
        interactive = message["interactive"]
        for key in ("button_reply", "list_reply"):
            if isinstance(interactive.get(key), dict):
                text = str(interactive[key].get("title") or interactive[key].get("description") or "").strip()
                if text:
                    break
    direction = "inbound"
    if not text:
        if message_type == "contacts":
            text = "[Shared WhatsApp contact]"
            direction = "system"
        else:
            text = f"[Unsupported WhatsApp content: {message_type}]"
            direction = "system"

    contacts = value.get("contacts") if isinstance(value.get("contacts"), list) else []
    contact = contacts[0] if contacts and isinstance(contacts[0], dict) else {}
    profile = contact.get("profile") if isinstance(contact.get("profile"), dict) else {}
    display_name = str(profile.get("name") or contact.get("wa_id") or chat_id or f"WhatsApp {chat_id}").strip()
    phone_number = _normalize_phone_number(contact.get("wa_id") or chat_id)
    return {
        "chat_id": chat_id,
        "whatsapp_user_id": str(contact.get("wa_id") or chat_id).strip() or None,
        "display_name": display_name,
        "text": text,
        "payload": update,
        "external_message_id": str(message.get("id") or "").strip() or None,
        "phone_number": phone_number,
        "external_subject": str(contact.get("wa_id") or "").strip() or None,
        "queue_id": str(update.get("queue_id") or "").strip() or None,
        "is_group": bool(message.get("group_id") or value.get("is_group") or update.get("is_group")),
        "direction": direction,
    }


def _provider_status_to_delivery_status(raw_status: str | None) -> str | None:
    value = str(raw_status or "").strip().lower()
    if not value:
        return None
    if value in {"sent", "delivered", "read", "failed"}:
        return value
    return value[:32]


def _handle_provider_status_update(session, payload: dict) -> dict | None:
    if not isinstance(payload, dict):
        return None

    entry = payload.get("entry")
    if not isinstance(entry, list) or not entry:
        return None
    changes = entry[0].get("changes") if isinstance(entry[0], dict) else None
    if not isinstance(changes, list) or not changes:
        return None
    value = changes[0].get("value") if isinstance(changes[0], dict) else {}
    if not isinstance(value, dict):
        return None

    statuses = value.get("statuses")
    if not isinstance(statuses, list) or not statuses:
        return None

    status = statuses[0] if isinstance(statuses[0], dict) else {}
    external_message_id = str(status.get("id") or "").strip()
    if not external_message_id:
        return {"ok": True, "status_event": True, "updated": False}

    row = session.execute(
        select(WhatsAppMessageRow).where(
            WhatsAppMessageRow.whatsapp_message_id_external == external_message_id
        )
    ).scalar_one_or_none()
    if not row:
        return {"ok": True, "status_event": True, "updated": False}

    next_status = _provider_status_to_delivery_status(status.get("status"))
    if next_status:
        row.delivery_status = next_status
    if next_status == "failed":
        error_title = ""
        errors = status.get("errors")
        if isinstance(errors, list) and errors and isinstance(errors[0], dict):
            error_title = str(
                errors[0].get("title") or errors[0].get("message") or errors[0].get("code") or ""
            ).strip()
        if error_title:
            row.last_delivery_error = error_title[:1000]
    payload_json = _loads_payload(row.payload_json)
    payload_json["provider_status"] = status
    row.payload_json = json.dumps(payload_json, ensure_ascii=False)
    session.commit()
    return {
        "ok": True,
        "status_event": True,
        "updated": True,
        "message_id": row.message_id,
        "delivery_status": row.delivery_status,
    }


def _verify_meta_webhook_signature(body: bytes, signature_header: str | None) -> None:
    app_secret = _meta_app_secret()
    if not app_secret:
        return
    if not signature_header:
        raise HTTPException(status_code=403, detail="Missing Meta webhook signature")
    prefix = "sha256="
    signature_value = str(signature_header or "").strip()
    if not signature_value.startswith(prefix):
        raise HTTPException(status_code=403, detail="Invalid Meta webhook signature")
    expected = hmac.new(
        app_secret.encode("utf-8"),
        body,
        hashlib.sha256,
    ).hexdigest()
    actual = signature_value[len(prefix) :].strip().lower()
    if not hmac.compare_digest(actual, expected):
        raise HTTPException(status_code=403, detail="Invalid Meta webhook signature")


def _validate_provider_webhook_request(
    body: bytes,
    *,
    x_whatsapp_webhook_secret: str | None,
    x_hub_signature_256: str | None,
) -> None:
    if x_hub_signature_256:
        _verify_meta_webhook_signature(body, x_hub_signature_256)
        return
    expected_secret = _webhook_secret()
    if expected_secret and x_whatsapp_webhook_secret != expected_secret:
        raise HTTPException(status_code=403, detail="Invalid WhatsApp webhook secret")


def _send_whatsapp_message_via_meta(chat_id: str, text: str) -> dict:
    access_token = _meta_access_token()
    phone_number_id = _meta_phone_number_id()
    if not access_token or not phone_number_id:
        raise HTTPException(status_code=503, detail="WhatsApp Meta outbound delivery is not configured")

    url = f"{_meta_graph_base_url()}/{_meta_graph_api_version()}/{phone_number_id}/messages"
    headers = {
        "Authorization": f"Bearer {access_token}",
        "Content-Type": "application/json",
    }
    payload = {
        "messaging_product": "whatsapp",
        "to": chat_id,
        "type": "text",
        "text": {"body": text},
    }
    with httpx.Client(timeout=10.0) as client:
        response = client.post(url, json=payload, headers=headers)
    if response.status_code >= 400:
        raise HTTPException(status_code=502, detail=f"WhatsApp Meta send failed: {response.text}")
    try:
        response_payload = response.json()
    except ValueError as exc:
        raise HTTPException(status_code=502, detail="WhatsApp Meta send returned non-JSON") from exc
    if response_payload.get("error"):
        raise HTTPException(status_code=502, detail=f"WhatsApp Meta send failed: {response_payload}")
    result = (
        response_payload.get("messages", [{}])[0]
        if isinstance(response_payload.get("messages"), list) and response_payload.get("messages")
        else {}
    )
    message_id = str(result.get("id") or response_payload.get("message_id") or "").strip() or None
    return {
        "ok": True,
        "result": {
            "message_id": message_id,
            "chat_id": chat_id,
        },
        "provider": "meta",
        "raw": response_payload,
    }


def _send_whatsapp_message(chat_id: str, text: str) -> dict:
    if _meta_access_token() and _meta_phone_number_id():
        return _send_whatsapp_message_via_meta(chat_id, text)
    if not _outbound_enabled() or not _outbound_send_url():
        raise HTTPException(status_code=503, detail="WhatsApp outbound delivery is not configured")
    headers: dict[str, str] = {}
    if _outbound_auth_token():
        headers["Authorization"] = f"Bearer {_outbound_auth_token()}"
    with httpx.Client(timeout=10.0) as client:
        response = client.post(
            _outbound_send_url(),
            json={"chat_id": chat_id, "text": text},
            headers=headers,
        )
    if response.status_code >= 400:
        raise HTTPException(status_code=502, detail=f"WhatsApp outbound send failed: {response.text}")
    try:
        payload = response.json()
    except ValueError as exc:
        raise HTTPException(status_code=502, detail="WhatsApp outbound send returned non-JSON") from exc
    if payload.get("ok") is False:
        raise HTTPException(status_code=502, detail=f"WhatsApp outbound send failed: {payload}")
    result = payload.get("result") if isinstance(payload.get("result"), dict) else {}
    message_id = (
        str(result.get("message_id") or payload.get("message_id") or payload.get("id") or "").strip()
        or (
            str(payload.get("messages", [{}])[0].get("id") or "").strip()
            if isinstance(payload.get("messages"), list) and payload.get("messages")
            else ""
        )
    )
    return {
        "ok": True,
        "result": {
            **(result if isinstance(result, dict) else {}),
            "message_id": message_id or None,
        },
    }


def _whatsapp_reply_delivery_filter(now: str):
    return or_(
        WhatsAppMessageRow.delivery_status == "pending",
        WhatsAppMessageRow.delivery_status == "retrying",
        and_(
            WhatsAppMessageRow.delivery_status == "sending",
            or_(
                WhatsAppMessageRow.delivery_locked_until.is_(None),
                WhatsAppMessageRow.delivery_locked_until < now,
            ),
        ),
    )


def _whatsapp_reply_delivery_due_filter(now: str):
    return or_(
        WhatsAppMessageRow.next_delivery_attempt_at.is_(None),
        WhatsAppMessageRow.next_delivery_attempt_at <= now,
    )


def _delivery_error_text(error: Exception) -> str:
    if isinstance(error, HTTPException):
        detail = error.detail
        if isinstance(detail, str):
            return detail[:1000]
        return str(detail)[:1000]
    return str(error or error.__class__.__name__)[:1000]


def _whatsapp_reply_backoff_seconds(next_attempt: int) -> int:
    base = _reply_delivery_retry_base_seconds()
    cap = _reply_delivery_retry_cap_seconds()
    return min(cap, base * (2 ** max(next_attempt - 1, 0)))


def _claim_whatsapp_reply_delivery(session, message_id: str) -> WhatsAppMessageRow | None:
    now = utc_now_iso()
    leased_until = _utc_after_seconds(_reply_delivery_lease_seconds())
    claim = session.execute(
        update(WhatsAppMessageRow)
        .where(
            WhatsAppMessageRow.message_id == message_id,
            WhatsAppMessageRow.direction == "outbound",
            WhatsAppMessageRow.thread_id.is_not(None),
            WhatsAppMessageRow.interaction_id.is_not(None),
            _whatsapp_reply_delivery_filter(now),
            _whatsapp_reply_delivery_due_filter(now),
        )
        .values(
            delivery_status="sending",
            delivery_locked_until=leased_until,
        )
    )
    session.commit()
    if claim.rowcount != 1:
        return None
    return session.execute(
        select(WhatsAppMessageRow).where(WhatsAppMessageRow.message_id == message_id)
    ).scalar_one_or_none()


def _mark_whatsapp_reply_delivery_failure(
    session,
    row: WhatsAppMessageRow,
    error_text: str,
    *,
    allow_retry: bool = True,
) -> None:
    attempts = int(row.delivery_attempts or 0) + 1
    row.delivery_attempts = attempts
    row.delivery_locked_until = None
    row.last_delivery_error = error_text
    should_retry = allow_retry and attempts < _reply_delivery_max_attempts()
    if should_retry:
        row.delivery_status = "retrying"
        row.next_delivery_attempt_at = _utc_after_seconds(_whatsapp_reply_backoff_seconds(attempts))
    else:
        row.delivery_status = "failed"
        row.next_delivery_attempt_at = None
        if row.interaction_id and row.thread_id:
            _push_timeline(
                session,
                row.interaction_id,
                "whatsapp.message_failed",
                {
                    "thread_id": row.thread_id,
                    "operator_user": row.operator_user,
                    "attempts": attempts,
                    "error": error_text,
                },
            )
    session.commit()


def _mark_whatsapp_reply_delivery_success(session, row: WhatsAppMessageRow, whatsapp_payload: dict) -> None:
    row.delivery_attempts = int(row.delivery_attempts or 0) + 1
    row.delivery_status = "sent"
    row.delivery_locked_until = None
    row.next_delivery_attempt_at = None
    row.last_delivery_error = None
    result_message = whatsapp_payload.get("result") if isinstance(whatsapp_payload, dict) else {}
    if isinstance(result_message, dict) and result_message.get("message_id") is not None:
        row.whatsapp_message_id_external = str(result_message.get("message_id"))
    _push_timeline(
        session,
        row.interaction_id,
        "whatsapp.message_sent",
        {"thread_id": row.thread_id, "operator_user": row.operator_user},
    )
    session.commit()


def _deliver_pending_whatsapp_reply(message_id: str) -> bool:
    session = get_session()
    try:
        row = _claim_whatsapp_reply_delivery(session, message_id)
        if not row or not row.thread_id or not row.interaction_id:
            return False

        thread = session.execute(
            select(WhatsAppThreadRow).where(WhatsAppThreadRow.thread_id == row.thread_id)
        ).scalar_one_or_none()
        if not thread:
            _mark_whatsapp_reply_delivery_failure(
                session,
                row,
                "WhatsApp thread not found during reply delivery",
                allow_retry=False,
            )
            return False

        try:
            whatsapp_payload = _send_whatsapp_message(row.chat_id, row.text)
        except Exception as exc:  # noqa: BLE001
            _mark_whatsapp_reply_delivery_failure(session, row, _delivery_error_text(exc))
            return False

        _mark_whatsapp_reply_delivery_success(session, row, whatsapp_payload)
        return True
    finally:
        session.close()


def _claim_next_due_whatsapp_reply_message_id(session) -> str | None:
    now = utc_now_iso()
    return session.execute(
        select(WhatsAppMessageRow.message_id)
        .where(
            WhatsAppMessageRow.direction == "outbound",
            WhatsAppMessageRow.thread_id.is_not(None),
            WhatsAppMessageRow.interaction_id.is_not(None),
            _whatsapp_reply_delivery_filter(now),
            _whatsapp_reply_delivery_due_filter(now),
        )
        .order_by(
            case(
                (WhatsAppMessageRow.delivery_status == "sending", 0),
                (WhatsAppMessageRow.delivery_status == "retrying", 1),
                else_=2,
            ),
            WhatsAppMessageRow.created_at.asc(),
            WhatsAppMessageRow.id.asc(),
        )
        .limit(1)
    ).scalar_one_or_none()


def _process_due_whatsapp_reply_batch(batch_size: int = 20) -> int:
    processed = 0
    attempts = 0
    while processed < batch_size and attempts < batch_size * 2:
        attempts += 1
        session = get_session()
        try:
            message_id = _claim_next_due_whatsapp_reply_message_id(session)
        finally:
            session.close()
        if not message_id:
            break
        if _deliver_pending_whatsapp_reply(message_id):
            processed += 1
    return processed


def _reply_delivery_worker_loop(stop_event: threading.Event, wakeup_event: threading.Event) -> None:
    while not stop_event.is_set():
        wakeup_event.wait(timeout=_reply_delivery_poll_seconds())
        wakeup_event.clear()
        if stop_event.is_set():
            break
        try:
            _process_due_whatsapp_reply_batch()
        except Exception:  # noqa: BLE001
            logger.exception("WhatsApp reply delivery worker cycle failed")


def _wake_whatsapp_reply_delivery_worker() -> None:
    with _reply_delivery_worker_lock:
        wakeup_event = _reply_delivery_worker_wakeup
    if wakeup_event:
        wakeup_event.set()


def _start_reply_delivery_worker() -> None:
    global _reply_delivery_worker_clients
    global _reply_delivery_worker_stop
    global _reply_delivery_worker_thread
    global _reply_delivery_worker_wakeup

    if not _reply_delivery_worker_enabled():
        return

    with _reply_delivery_worker_lock:
        _reply_delivery_worker_clients += 1
        if _reply_delivery_worker_thread and _reply_delivery_worker_thread.is_alive():
            wakeup_event = _reply_delivery_worker_wakeup
        else:
            stop_event = threading.Event()
            wakeup_event = threading.Event()
            worker = threading.Thread(
                target=_reply_delivery_worker_loop,
                args=(stop_event, wakeup_event),
                name="whatsapp-reply-delivery-worker",
                daemon=True,
            )
            _reply_delivery_worker_stop = stop_event
            _reply_delivery_worker_wakeup = wakeup_event
            _reply_delivery_worker_thread = worker
            worker.start()
    if wakeup_event:
        wakeup_event.set()


def _stop_reply_delivery_worker() -> None:
    global _reply_delivery_worker_clients
    global _reply_delivery_worker_stop
    global _reply_delivery_worker_thread
    global _reply_delivery_worker_wakeup

    thread: threading.Thread | None = None
    stop_event: threading.Event | None = None
    wakeup_event: threading.Event | None = None

    with _reply_delivery_worker_lock:
        if _reply_delivery_worker_clients > 0:
            _reply_delivery_worker_clients -= 1
        if _reply_delivery_worker_clients > 0:
            return
        thread = _reply_delivery_worker_thread
        stop_event = _reply_delivery_worker_stop
        wakeup_event = _reply_delivery_worker_wakeup
        _reply_delivery_worker_thread = None
        _reply_delivery_worker_stop = None
        _reply_delivery_worker_wakeup = None

    if stop_event:
        stop_event.set()
    if wakeup_event:
        wakeup_event.set()
    if thread and thread.is_alive():
        thread.join(timeout=2)


def _start_whatsapp_reply_delivery(message_id: str) -> None:
    if _reply_delivery_worker_enabled():
        _wake_whatsapp_reply_delivery_worker()
        return
    threading.Thread(
        target=_deliver_pending_whatsapp_reply,
        args=(message_id,),
        daemon=True,
    ).start()


@app.on_event("startup")
def _start_delivery_worker_on_startup() -> None:
    _start_reply_delivery_worker()


@app.on_event("shutdown")
def _stop_delivery_worker_on_shutdown() -> None:
    _stop_reply_delivery_worker()


def _ensure_operator_can_manage(
    actor: dict,
    thread: WhatsAppThreadRow,
    *,
    for_reply: bool = False,
    require_claim: bool = False,
) -> None:
    if actor["role"] in {Role.ADMIN.value, Role.SUPERVISOR.value}:
        return
    if actor["role"] != Role.OPERATOR.value:
        raise HTTPException(status_code=403, detail="Insufficient role")
    if for_reply and thread.claimed_by_user != actor["user"]:
        raise HTTPException(status_code=403, detail="Thread must be claimed by the current operator")
    if require_claim and thread.claimed_by_user != actor["user"]:
        raise HTTPException(status_code=403, detail="Thread must be claimed by the current operator")
    if not for_reply and thread.claimed_by_user and thread.claimed_by_user != actor["user"]:
        raise HTTPException(status_code=403, detail="Thread is already claimed by another operator")


def _raise_claim_conflict(thread: WhatsAppThreadRow, actor_user: str) -> None:
    if thread.status == "closed":
        raise HTTPException(status_code=409, detail="WhatsApp thread is closed")
    if thread.claimed_by_user and thread.claimed_by_user != actor_user:
        raise HTTPException(status_code=409, detail="WhatsApp thread is already claimed by another operator")
    raise HTTPException(status_code=409, detail="WhatsApp thread claim conflict")


def _ensure_thread_available_for_ai(thread: WhatsAppThreadRow) -> None:
    if thread.status == "closed":
        raise HTTPException(status_code=409, detail="WhatsApp thread is closed")
    if thread.ai_state == "human_owned" or thread.claimed_by_user:
        raise HTTPException(status_code=409, detail="WhatsApp thread is owned by a human operator")


def _claim_thread_locally(
    session,
    *,
    thread_id: str,
    actor_user: str,
    claimed_at: str,
) -> tuple[WhatsAppThreadRow, str, bool]:
    thread = _get_thread(session, thread_id)
    if thread.claimed_by_user == actor_user and thread.status == "in_progress":
        return thread, thread.status, False

    previous_status = thread.status
    result = session.execute(
        update(WhatsAppThreadRow)
        .where(
            WhatsAppThreadRow.thread_id == thread_id,
            WhatsAppThreadRow.status != "closed",
            or_(
                WhatsAppThreadRow.claimed_by_user.is_(None),
                WhatsAppThreadRow.claimed_by_user == actor_user,
            ),
        )
        .values(
            claimed_by_user=actor_user,
            claimed_at=claimed_at,
            updated_at=claimed_at,
        )
    )
    if not result.rowcount:
        session.rollback()
        _raise_claim_conflict(_get_thread(session, thread_id), actor_user)

    session.commit()
    return _get_thread(session, thread_id), previous_status, True


def _rollback_thread_claim(
    session,
    *,
    thread_id: str,
    actor_user: str,
    claimed_at: str,
    previous_status: str,
) -> None:
    try:
        session.execute(
            update(WhatsAppThreadRow)
            .where(
                WhatsAppThreadRow.thread_id == thread_id,
                WhatsAppThreadRow.claimed_by_user == actor_user,
                WhatsAppThreadRow.claimed_at == claimed_at,
            )
            .values(
                claimed_by_user=None,
                claimed_at=None,
                status=previous_status,
                updated_at=utc_now_iso(),
            )
        )
        session.commit()
    except Exception:
        session.rollback()


@app.get("/health", response_model=HealthResponse)
def health() -> HealthResponse:
    return HealthResponse(status="ok", service="whatsapp-adapter-service", version="v2")


@app.post("/integrations/whatsapp/webhook", response_model=WhatsAppWebhookOut)
def webhook(payload: WhatsAppWebhookIn) -> WhatsAppWebhookOut:
    session = get_session()
    try:
        metadata = _manual_webhook_metadata(payload)
        existing = _existing_bot_message(
            session,
            chat_id=payload.chat_id,
            external_message_id=payload.external_message_id,
        )
        if existing:
            return _to_webhook_out(existing)
        text = str(payload.text or "").strip()
        if not text:
            text = "[Unsupported WhatsApp content: message]"
            direction = "system"
        else:
            direction = "inbound"
        thread, row, _ = _upsert_thread_for_inbound(
            session,
            chat_id=payload.chat_id,
            whatsapp_user_id=metadata["whatsapp_user_id"],
            display_name=metadata["display_name"],
            text=text,
            payload=payload.payload,
            customer_id=payload.customer_id,
            external_subject=metadata["external_subject"],
            external_message_id=payload.external_message_id,
            phone_number=metadata["phone_number"],
            queue_id=metadata["queue_id"],
            is_group=metadata["is_group"],
            direction=direction,
        )
        try:
            session.commit()
        except IntegrityError:
            session.rollback()
            existing = _existing_bot_message(
                session,
                chat_id=payload.chat_id,
                external_message_id=payload.external_message_id,
            )
            if existing:
                return _to_webhook_out(existing)
            raise
        session.refresh(row)
        session.refresh(thread)
        _maybe_enqueue_ai_for_thread(thread, row.message_id, author_type=row.author_type)
        return _to_webhook_out(row)
    finally:
        session.close()


@app.get("/integrations/whatsapp/provider/webhook", response_class=PlainTextResponse)
def verify_provider_webhook(
    hub_mode: str | None = Query(default=None, alias="hub.mode"),
    hub_verify_token: str | None = Query(default=None, alias="hub.verify_token"),
    hub_challenge: str | None = Query(default=None, alias="hub.challenge"),
) -> str:
    expected_token = _meta_verify_token()
    if not expected_token:
        raise HTTPException(status_code=503, detail="WhatsApp Meta verify token is not configured")
    if str(hub_mode or "").strip().lower() != "subscribe":
        raise HTTPException(status_code=400, detail="Unsupported WhatsApp webhook mode")
    if hub_verify_token != expected_token:
        raise HTTPException(status_code=403, detail="Invalid WhatsApp verify token")
    return str(hub_challenge or "")


@app.post("/integrations/whatsapp/provider/webhook")
async def provider_webhook(
    request: Request,
    x_whatsapp_webhook_secret: str | None = Header(
        default=None,
        alias="X-WhatsApp-Webhook-Secret",
    ),
    x_hub_signature_256: str | None = Header(
        default=None,
        alias="X-Hub-Signature-256",
    ),
) -> dict:
    raw_body = await request.body()
    _validate_provider_webhook_request(
        raw_body,
        x_whatsapp_webhook_secret=x_whatsapp_webhook_secret,
        x_hub_signature_256=x_hub_signature_256,
    )
    try:
        payload = json.loads((raw_body or b"{}").decode("utf-8"))
    except ValueError as exc:
        raise HTTPException(status_code=400, detail="WhatsApp provider payload is not valid JSON") from exc
    if not isinstance(payload, dict):
        raise HTTPException(status_code=400, detail="Unsupported WhatsApp provider payload")
    session = get_session()
    try:
        status_result = _handle_provider_status_update(session, payload)
        if status_result is not None:
            return status_result
        parsed = _parse_provider_update(payload)
        existing = _existing_bot_message(
            session,
            chat_id=parsed["chat_id"],
            external_message_id=parsed["external_message_id"],
        )
        if existing:
            return _to_bot_webhook_result(existing, thread_created=False)
        thread, row, created = _upsert_thread_for_inbound(
            session,
            chat_id=parsed["chat_id"],
            whatsapp_user_id=parsed["whatsapp_user_id"],
            display_name=parsed["display_name"],
            text=parsed["text"],
            payload=parsed["payload"],
            external_subject=parsed["external_subject"],
            external_message_id=parsed["external_message_id"],
            phone_number=parsed["phone_number"],
            queue_id=parsed["queue_id"],
            is_group=parsed["is_group"],
            direction=parsed["direction"],
        )
        try:
            session.commit()
        except IntegrityError:
            session.rollback()
            existing = _existing_bot_message(
                session,
                chat_id=parsed["chat_id"],
                external_message_id=parsed["external_message_id"],
            )
            if existing:
                return _to_bot_webhook_result(existing, thread_created=False)
            raise
        _maybe_enqueue_ai_for_thread(thread, row.message_id, author_type=row.author_type)
        return _to_bot_webhook_result(row, thread_created=created)
    finally:
        session.close()


@app.get(
    "/integrations/whatsapp/messages",
    response_model=list[WhatsAppWebhookOut],
)
def list_messages(
    limit: int = 100,
    chat_id: str | None = None,
    actor: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.OPERATOR)),
) -> list[WhatsAppWebhookOut]:
    session = get_session()
    try:
        stmt = select(WhatsAppMessageRow).order_by(WhatsAppMessageRow.id.desc())
        if chat_id:
            stmt = stmt.where(WhatsAppMessageRow.chat_id == chat_id)
        rows = session.execute(stmt.limit(max(limit, 1))).scalars().all()
        return [_to_webhook_out(r) for r in reversed(rows)]
    finally:
        session.close()


@app.get(
    "/integrations/whatsapp/threads",
    response_model=list[WhatsAppThreadOut],
)
def list_threads(
    limit: int = 100,
    status: InteractionStatus | None = None,
    actor: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.OPERATOR)),
) -> list[WhatsAppThreadOut]:
    session = get_session()
    try:
        stmt = select(WhatsAppThreadRow).order_by(WhatsAppThreadRow.last_message_at.desc())
        if status:
            stmt = stmt.where(WhatsAppThreadRow.status == status)
        rows = session.execute(stmt.limit(max(limit, 1))).scalars().all()
        return [
            _to_thread_out(row, unread_count=_thread_unread_count(session, row.thread_id))
            for row in rows
        ]
    finally:
        session.close()


@app.get(
    "/integrations/whatsapp/threads/{thread_id}",
    response_model=WhatsAppThreadOut,
)
def get_thread(
    thread_id: str,
    actor: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.OPERATOR)),
) -> WhatsAppThreadOut:
    session = get_session()
    try:
        row = _get_thread(session, thread_id)
        return _to_thread_out(row, unread_count=_thread_unread_count(session, row.thread_id))
    finally:
        session.close()


@app.get(
    "/integrations/whatsapp/threads/{thread_id}/ai-summary",
    response_model=WhatsAppThreadAISummaryOut | None,
)
def get_thread_ai_summary(
    thread_id: str,
    actor: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.OPERATOR)),
) -> WhatsAppThreadAISummaryOut | None:
    session = get_session()
    try:
        thread = _get_thread(session, thread_id)
        if actor["role"] == Role.OPERATOR.value and thread.claimed_by_user and thread.claimed_by_user != actor["user"]:
            raise HTTPException(status_code=403, detail="Thread is already claimed by another operator")
        return _build_ai_summary(session, thread)
    finally:
        session.close()


@app.get(
    "/integrations/whatsapp/threads/{thread_id}/messages",
    response_model=list[WhatsAppThreadMessageOut],
)
def get_thread_messages(
    thread_id: str,
    limit: int = 200,
    actor: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.OPERATOR)),
) -> list[WhatsAppThreadMessageOut]:
    session = get_session()
    try:
        thread = _get_thread(session, thread_id)
        rows = session.execute(
            select(WhatsAppMessageRow)
            .where(WhatsAppMessageRow.thread_id == thread.thread_id)
            .order_by(WhatsAppMessageRow.id.desc())
            .limit(max(limit, 1))
        ).scalars().all()
        return [_to_message_out(row) for row in reversed(rows)]
    finally:
        session.close()


@app.post(
    "/integrations/whatsapp/threads/{thread_id}/claim",
    response_model=WhatsAppThreadOut,
)
def claim_thread(
    thread_id: str,
    actor: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.OPERATOR)),
) -> WhatsAppThreadOut:
    session = get_session()
    try:
        claimed_at = utc_now_iso()
        thread, previous_status, local_claimed = _claim_thread_locally(
            session,
            thread_id=thread_id,
            actor_user=actor["user"],
            claimed_at=claimed_at,
        )
        if not local_claimed:
            return _to_thread_out(thread, unread_count=_thread_unread_count(session, thread.thread_id))
        try:
            result = _interaction_request(
                "PATCH",
                f"/interactions/{thread.interaction_id}/assign",
                payload={"assignee": actor["user"]},
            )
        except Exception:
            _rollback_thread_claim(
                session,
                thread_id=thread_id,
                actor_user=actor["user"],
                claimed_at=claimed_at,
                previous_status=previous_status,
            )
            raise
        thread = _get_thread(session, thread_id)
        thread.status = result.get("status", "in_progress")
        thread.updated_at = utc_now_iso()
        _mark_thread_ai_state(thread, ai_state="human_owned", ai_handoff_reason=None)
        _update_ai_session_state(
            session,
            session_id=thread.ai_session_id,
            status="human_owned",
            updated_at=thread.updated_at,
        )
        _push_timeline(
            session,
            thread.interaction_id,
            "ai.human_takeover",
            {"thread_id": thread.thread_id, "actor_user": actor["user"]},
        )
        session.commit()
        session.refresh(thread)
        return _to_thread_out(thread, unread_count=_thread_unread_count(session, thread.thread_id))
    finally:
        session.close()


@app.post(
    "/integrations/whatsapp/threads/{thread_id}/return-to-ai",
    response_model=WhatsAppThreadOut,
)
def return_thread_to_ai(
    thread_id: str,
    actor: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.OPERATOR)),
) -> WhatsAppThreadOut:
    if not _ai_whatsapp_enabled():
        raise HTTPException(status_code=409, detail="WhatsApp AI is disabled")

    session = get_session()
    try:
        thread = _get_thread(session, thread_id)
        if thread.status == "closed":
            raise HTTPException(status_code=409, detail="WhatsApp thread is closed")
        _ensure_operator_can_manage(actor, thread, require_claim=True)

        now = utc_now_iso()
        interaction = session.execute(
            select(Interaction).where(Interaction.interaction_id == thread.interaction_id)
        ).scalar_one_or_none()
        if interaction:
            interaction.status = "new"
            interaction.assigned_to = None
            interaction.updated_at = now
            _push_timeline(
                session,
                interaction.interaction_id,
                "interaction.status_changed",
                {"status": "new", "source": "whatsapp-return-to-ai"},
            )

        thread.status = "new"
        thread.claimed_by_user = None
        thread.claimed_at = None
        thread.updated_at = now
        _mark_thread_ai_state(thread, ai_state="queued", ai_handoff_reason=None)
        _update_ai_session_state(
            session,
            session_id=thread.ai_session_id,
            status="active",
            updated_at=now,
            handoff_reason=None,
        )
        _push_timeline(
            session,
            thread.interaction_id,
            "ai.returned_to_ai",
            {"thread_id": thread.thread_id, "actor_user": actor["user"]},
        )
        session.commit()
        session.refresh(thread)
        return _to_thread_out(thread, unread_count=_thread_unread_count(session, thread.thread_id))
    finally:
        session.close()


@app.post(
    "/integrations/whatsapp/threads/{thread_id}/messages",
    response_model=WhatsAppThreadMessageOut,
)
def reply_thread(
    thread_id: str,
    payload: WhatsAppThreadReplyIn,
    actor: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.OPERATOR)),
) -> WhatsAppThreadMessageOut:
    session = get_session()
    try:
        thread = _get_thread(session, thread_id)
        if thread.status == "closed":
            raise HTTPException(status_code=409, detail="WhatsApp thread is closed")
        _ensure_operator_can_manage(actor, thread, for_reply=True)
        now = utc_now_iso()
        row = _persist_message(
            session,
            thread=thread,
            text=payload.text,
            payload={"operator_user": actor["user"]},
            direction="outbound",
            created_at=now,
            operator_user=actor["user"],
            author_type="human",
            author_id=actor["user"],
            delivery_status="pending",
        )
        row.delivery_attempts = 0
        row.next_delivery_attempt_at = now
        row.delivery_locked_until = None
        row.last_delivery_error = None
        _mark_thread_ai_state(thread, ai_state="human_owned", ai_handoff_reason=None)
        _update_ai_session_state(
            session,
            session_id=thread.ai_session_id,
            status="human_owned",
            updated_at=now,
        )
        session.commit()
        session.refresh(row)
        _start_whatsapp_reply_delivery(row.message_id)
        return _to_message_out(row)
    finally:
        session.close()


@app.post(
    "/integrations/whatsapp/threads/{thread_id}/ai/reply",
    response_model=WhatsAppThreadMessageOut,
)
def ai_reply_thread(
    thread_id: str,
    payload: WhatsAppThreadAIReplyIn,
    actor: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR)),
) -> WhatsAppThreadMessageOut:
    session = get_session()
    try:
        thread = _get_thread(session, thread_id)
        _ensure_thread_available_for_ai(thread)
        now = utc_now_iso()
        row = _persist_message(
            session,
            thread=thread,
            text=payload.text,
            payload={
                "agent_profile": payload.agent_profile,
                "model": payload.model,
                "trigger_message_id": payload.trigger_message_id,
                "language": payload.language,
                "confidence": payload.confidence,
                "kb_refs": payload.kb_refs,
                **(payload.payload or {}),
            },
            direction="outbound",
            created_at=now,
            author_type="ai",
            author_id=payload.agent_profile,
            delivery_status="pending",
        )
        row.delivery_attempts = 0
        row.next_delivery_attempt_at = now
        row.delivery_locked_until = None
        row.last_delivery_error = None
        _mark_thread_ai_state(thread, ai_state="active", ai_handoff_reason=None, ai_last_model_at=now)
        _update_ai_session_state(
            session,
            session_id=thread.ai_session_id,
            status="active",
            updated_at=now,
            handoff_reason=None,
        )
        if thread.ai_session_id:
            ai_session = session.execute(
                select(AISessionRow).where(AISessionRow.session_id == thread.ai_session_id)
            ).scalar_one_or_none()
            if ai_session:
                ai_session.last_ai_message_id = row.message_id
                if payload.language:
                    ai_session.language = payload.language
        _push_timeline(
            session,
            thread.interaction_id,
            "ai.reply_generated",
            {
                "thread_id": thread.thread_id,
                "message_id": row.message_id,
                "agent_profile": payload.agent_profile,
                "model": payload.model,
                "confidence": payload.confidence,
                "kb_refs": payload.kb_refs,
                "actor_user": actor["user"],
            },
        )
        session.commit()
        session.refresh(row)
        _start_whatsapp_reply_delivery(row.message_id)
        return _to_message_out(row)
    finally:
        session.close()


@app.post(
    "/integrations/whatsapp/threads/{thread_id}/ai/handoff",
    response_model=WhatsAppThreadOut,
)
def ai_handoff_thread(
    thread_id: str,
    payload: WhatsAppThreadAIHandoffIn,
    actor: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR)),
) -> WhatsAppThreadOut:
    session = get_session()
    try:
        thread = _get_thread(session, thread_id)
        _ensure_thread_available_for_ai(thread)
        now = utc_now_iso()
        _mark_thread_ai_state(
            thread,
            ai_state="handoff_required",
            ai_handoff_reason=payload.reason,
            ai_last_model_at=now,
        )
        thread.updated_at = now
        _update_ai_session_state(
            session,
            session_id=thread.ai_session_id,
            status="handoff_required",
            updated_at=now,
            handoff_reason=payload.reason,
        )
        _push_timeline(
            session,
            thread.interaction_id,
            "ai.handoff_requested",
            {
                "thread_id": thread.thread_id,
                "reason": payload.reason,
                "agent_profile": payload.agent_profile,
                "trigger_message_id": payload.trigger_message_id,
                "confidence": payload.confidence,
                "actor_user": actor["user"],
            },
        )
        session.commit()
        session.refresh(thread)
        return _to_thread_out(thread, unread_count=_thread_unread_count(session, thread.thread_id))
    finally:
        session.close()


@app.post(
    "/integrations/whatsapp/threads/{thread_id}/close",
    response_model=WhatsAppThreadOut,
)
def close_thread(
    thread_id: str,
    actor: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.OPERATOR)),
) -> WhatsAppThreadOut:
    session = get_session()
    try:
        thread = _get_thread(session, thread_id)
        _ensure_operator_can_manage(actor, thread, require_claim=True)
        _interaction_request(
            "PATCH",
            f"/interactions/{thread.interaction_id}/status",
            payload={"status": "closed"},
        )
        thread.status = "closed"
        thread.claimed_by_user = None
        thread.claimed_at = None
        thread.updated_at = utc_now_iso()
        _mark_thread_ai_state(thread, ai_state="closed", ai_handoff_reason=None, ai_last_model_at=thread.ai_last_model_at)
        _update_ai_session_state(
            session,
            session_id=thread.ai_session_id,
            status="closed",
            updated_at=thread.updated_at,
            closed=True,
        )
        session.commit()
        session.refresh(thread)
        return _to_thread_out(thread, unread_count=_thread_unread_count(session, thread.thread_id))
    finally:
        session.close()


@app.post(
    "/integrations/whatsapp/threads/{thread_id}/escalate",
    response_model=WhatsAppThreadOut,
)
def escalate_thread(
    thread_id: str,
    payload: WhatsAppThreadEscalateIn,
    actor: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.OPERATOR)),
) -> WhatsAppThreadOut:
    session = get_session()
    try:
        thread = _get_thread(session, thread_id)
        _ensure_operator_can_manage(actor, thread, require_claim=True)
        result = _interaction_request(
            "POST",
            f"/interactions/{thread.interaction_id}/escalate",
            payload=EscalateRequest(target_queue_id=payload.target_queue_id).model_dump(),
        )
        thread.status = result.get("status", "escalated")
        thread.queue_id = result.get("queue_id", payload.target_queue_id)
        thread.updated_at = utc_now_iso()
        _mark_thread_ai_state(thread, ai_state="human_owned", ai_handoff_reason=None)
        _update_ai_session_state(
            session,
            session_id=thread.ai_session_id,
            status="human_owned",
            updated_at=thread.updated_at,
        )
        session.commit()
        session.refresh(thread)
        return _to_thread_out(thread, unread_count=_thread_unread_count(session, thread.thread_id))
    finally:
        session.close()
