from __future__ import annotations

from datetime import datetime, timezone
import hashlib
import json
import logging
from pathlib import Path
import threading
from typing import Any

import httpx
from sqlalchemy import select, update

from services.shared.core import new_id, utc_now_iso
from services.shared.db import get_session
from services.shared.reporting_facts import upsert_reporting_interaction_fact
from services.shared.sql_models import (
    AsteriskCallActionLogRow,
    AsteriskCallLinkRow,
    AsteriskEventLogRow,
    EscalationRow,
    Interaction,
    VoiceAISessionRow,
)


logger = logging.getLogger("uvicorn.error")


def _bridge_app():
    import services.asterisk_bridge_service.app as bridge_app

    return bridge_app


def _parse_iso_timestamp(raw: str | None) -> datetime | None:
    if not raw:
        return None
    try:
        value = datetime.fromisoformat(raw.replace("Z", "+00:00"))
    except ValueError:
        return None
    if value.tzinfo is None:
        value = value.replace(tzinfo=timezone.utc)
    return value.astimezone(timezone.utc)


def _seconds_between(start_ts: str | None, end_ts: str | None) -> int | None:
    start = _parse_iso_timestamp(start_ts)
    end = _parse_iso_timestamp(end_ts)
    if not start or not end:
        return None
    return max(int((end - start).total_seconds()), 0)


def _upsert_voice_reporting_fact(
    session,
    link: AsteriskCallLinkRow,
    *,
    status: str,
    answered: bool | None = None,
    abandoned: bool | None = None,
    wait_seconds: int | None = None,
    handle_seconds: int | None = None,
    closed_at: str | None = None,
) -> None:
    interaction_id = str(link.interaction_id or "").strip()
    if not interaction_id:
        return
    payload: dict[str, Any] = {
        "interaction_id": interaction_id,
        "channel": "voice",
        "queue_id": link.queue_id,
        "agent_id": link.claimed_by_user,
        "status": status,
        "created_at": link.started_at,
        "source": "asterisk-bridge",
    }
    if answered is not None:
        payload["answered"] = answered
    if abandoned is not None:
        payload["abandoned"] = abandoned
    if wait_seconds is not None:
        payload["wait_seconds"] = wait_seconds
    if handle_seconds is not None:
        payload["handle_seconds"] = handle_seconds
    if closed_at is not None:
        payload["closed_at"] = closed_at
    upsert_reporting_interaction_fact(session, payload)


def create_bridge_log(
    session,
    *,
    ami_event_name: str,
    call_id: str,
    linked_id: str | None,
    payload: dict[str, Any],
) -> AsteriskEventLogRow:
    now = utc_now_iso()
    row = AsteriskEventLogRow(
        bridge_event_id=new_id("abe"),
        ami_event_name=ami_event_name,
        call_id=call_id,
        linked_id=linked_id,
        interaction_id=None,
        recording_id=None,
        forward_status="received",
        payload_json=json.dumps(payload, ensure_ascii=False),
        last_error=None,
        created_at=now,
        updated_at=now,
    )
    session.add(row)
    session.flush()
    return row


def mark_log_forwarded(
    session,
    row: AsteriskEventLogRow,
    *,
    interaction_id: str | None = None,
    recording_id: str | None = None,
) -> None:
    row.interaction_id = interaction_id or row.interaction_id
    row.recording_id = recording_id or row.recording_id
    row.forward_status = "forwarded"
    row.last_error = None
    row.updated_at = utc_now_iso()
    session.flush()


def mark_log_failed(session, row: AsteriskEventLogRow, message: str) -> None:
    row.forward_status = "failed"
    row.last_error = message[:4000]
    row.updated_at = utc_now_iso()
    session.flush()


def stable_source_event_id(*parts: str) -> str:
    raw = ":".join(str(part).strip() for part in parts if str(part).strip())
    if len(raw) <= 64:
        return raw
    digest = hashlib.sha1(raw.encode("utf-8")).hexdigest()[:16]
    return f"{raw[:47]}:{digest}"


def bridge_source_event_id(row: AsteriskEventLogRow, effect: str) -> str:
    return stable_source_event_id(row.bridge_event_id, effect)


def reconcile_source_event_id(call_id: str, effect: str) -> str:
    return stable_source_event_id("reconcile", call_id, effect)


def load_bridge_event_row(session, bridge_event_id: str) -> AsteriskEventLogRow | None:
    return session.execute(
        select(AsteriskEventLogRow).where(AsteriskEventLogRow.bridge_event_id == bridge_event_id)
    ).scalar_one_or_none()


def claim_bridge_event_for_processing(
    bridge_event_id: str,
    *,
    allowed_statuses: tuple[str, ...] = ("received", "failed"),
) -> bool:
    if not allowed_statuses:
        return False

    session = get_session()
    try:
        result = session.execute(
            update(AsteriskEventLogRow)
            .where(AsteriskEventLogRow.bridge_event_id == bridge_event_id)
            .where(AsteriskEventLogRow.forward_status.in_(allowed_statuses))
            .values(
                forward_status="processing",
                last_error=None,
                updated_at=utc_now_iso(),
            )
        )
        session.commit()
        return int(result.rowcount or 0) > 0
    finally:
        session.close()


def process_claimed_bridge_event(bridge_event_id: str) -> AsteriskEventLogRow | None:
    bridge = _bridge_app()
    session = get_session()
    try:
        row = bridge._load_bridge_event_row(session, bridge_event_id)
        if row is None:
            return None
        if row.forward_status != "processing":
            return row
        try:
            bridge._process_bridge_row(session, row)
            session.commit()
            logger.warning(
                "bridge.processed event=%s call_id=%s final_status=%s",
                row.ami_event_name,
                row.call_id,
                row.forward_status,
            )
        except Exception as exc:
            session.rollback()
            row = bridge._load_bridge_event_row(session, bridge_event_id)
            if row is None:
                return None
            bridge._mark_log_failed(session, row, str(exc))
            session.commit()
            logger.warning(
                "bridge.process_failed event=%s call_id=%s error=%s",
                row.ami_event_name,
                row.call_id,
                str(exc)[:500],
            )
        session.refresh(row)
        return row
    finally:
        session.close()


def create_action_log(
    session,
    *,
    call_id: str,
    interaction_id: str | None,
    action_type: str,
    actor_user: str,
    actor_role: str,
    request_payload: dict[str, Any],
) -> AsteriskCallActionLogRow:
    row = AsteriskCallActionLogRow(
        action_id=new_id("aca"),
        call_id=call_id,
        interaction_id=interaction_id,
        action_type=action_type,
        actor_user=actor_user,
        actor_role=actor_role,
        request_json=json.dumps(request_payload, ensure_ascii=False),
        result_status="ok",
        ami_action_id=None,
        error=None,
        created_at=utc_now_iso(),
    )
    session.add(row)
    return row


def set_action_result(
    session,
    row: AsteriskCallActionLogRow,
    *,
    result_status: str,
    ami_action_id: str | None = None,
    error: str | None = None,
) -> None:
    row.result_status = result_status
    row.ami_action_id = ami_action_id
    row.error = (error or "")[:4000] or None


def find_call_link(session, call_id: str) -> AsteriskCallLinkRow | None:
    return session.execute(
        select(AsteriskCallLinkRow).where(AsteriskCallLinkRow.call_id == call_id)
    ).scalar_one_or_none()


def find_voice_ai_session_by_call_id(session, call_id: str) -> VoiceAISessionRow | None:
    return session.execute(
        select(VoiceAISessionRow)
        .where(VoiceAISessionRow.call_id == call_id)
        .order_by(VoiceAISessionRow.id.desc())
        .limit(1)
    ).scalar_one_or_none()


def find_interaction_in_db_by_call_id(session, call_id: str) -> Interaction | None:
    marker = f"[{call_id}]"
    return session.execute(
        select(Interaction)
        .where(Interaction.subject.like(f"%{marker}%"))
        .order_by(Interaction.id.desc())
        .limit(1)
    ).scalars().first()


def recover_call_link_from_started_event(
    session,
    *,
    call_id: str,
    exclude_bridge_event_id: str,
) -> AsteriskCallLinkRow | None:
    bridge = _bridge_app()
    existing = bridge._find_call_link(session, call_id)
    if existing:
        return existing

    started_events = session.execute(
        select(AsteriskEventLogRow)
        .where(AsteriskEventLogRow.ami_event_name == f"{bridge._ami_prefix()}CallStarted")
        .where(AsteriskEventLogRow.call_id == call_id)
        .order_by(AsteriskEventLogRow.id.desc())
    ).scalars().all()

    for started in started_events:
        if started.bridge_event_id == exclude_bridge_event_id:
            continue
        if started.forward_status == "forwarded":
            return bridge._find_call_link(session, call_id)
        try:
            bridge._process_bridge_row(session, started)
        except Exception as exc:
            bridge._mark_log_failed(session, started, str(exc))
        recovered = bridge._find_call_link(session, call_id)
        if recovered:
            return recovered
    return None


def find_forwarded_log(
    session,
    *,
    event_name: str,
    call_id: str,
    exclude_bridge_event_id: str,
) -> AsteriskEventLogRow | None:
    rows = session.execute(
        select(AsteriskEventLogRow)
        .where(AsteriskEventLogRow.ami_event_name == event_name)
        .where(AsteriskEventLogRow.call_id == call_id)
        .where(AsteriskEventLogRow.forward_status == "forwarded")
        .order_by(AsteriskEventLogRow.id.desc())
    ).scalars().all()
    for row in rows:
        if row.bridge_event_id != exclude_bridge_event_id:
            return row
    return None


def create_interaction(*, caller_number: str | None, queue_id: str, call_id: str) -> dict[str, Any]:
    bridge = _bridge_app()
    subject_value = caller_number.strip() if caller_number else "unknown"
    return bridge._post_json(
        f"{bridge._interaction_service_url()}/interactions",
        {
            "channel": "voice",
            "subject": f"Inbound call {subject_value} [{call_id}]",
            "queue_id": queue_id,
            "priority": 3,
        },
    )


def find_interaction_by_call_id(call_id: str) -> dict[str, Any] | None:
    bridge = _bridge_app()
    with httpx.Client(timeout=bridge._forward_timeout_seconds(45.0)) as client:
        response = bridge._request_with_bridge_auth(
            client,
            method="GET",
            url=f"{bridge._interaction_service_url()}/interactions",
        )
        payload = response.json()
    if not isinstance(payload, list):
        return None
    marker = f"[{call_id}]"
    for item in payload:
        if not isinstance(item, dict):
            continue
        subject = str(item.get("subject") or "")
        if marker in subject:
            return item
    return None


def resolve_started_interaction_id(
    session,
    *,
    call_id: str,
    queue_id: str,
    caller_number: str | None,
) -> tuple[AsteriskCallLinkRow | None, str]:
    bridge = _bridge_app()
    existing_link = bridge._find_call_link(session, call_id)
    interaction_id = None
    if existing_link:
        interaction_id = str(existing_link.interaction_id or "").strip() or None
    if interaction_id:
        return existing_link, interaction_id

    recovered_interaction = bridge._find_interaction_in_db_by_call_id(session, call_id)
    if recovered_interaction and str(recovered_interaction.interaction_id or "").strip():
        return existing_link, str(recovered_interaction.interaction_id).strip()

    create_error: Exception | None = None
    try:
        created = bridge._create_interaction(
            caller_number=caller_number,
            queue_id=queue_id,
            call_id=call_id,
        )
        created_interaction_id = str((created or {}).get("interaction_id") or "").strip()
        if created_interaction_id:
            return existing_link, created_interaction_id
        create_error = RuntimeError("Interaction service did not return interaction_id")
    except Exception as exc:
        create_error = exc

    recovered_interaction = bridge._find_interaction_in_db_by_call_id(session, call_id)
    if recovered_interaction and str(recovered_interaction.interaction_id or "").strip():
        return existing_link, str(recovered_interaction.interaction_id).strip()

    recovered_payload = bridge._find_interaction_by_call_id(call_id)
    recovered_interaction_id = str((recovered_payload or {}).get("interaction_id") or "").strip()
    if recovered_interaction_id:
        return existing_link, recovered_interaction_id

    if create_error is not None:
        raise create_error
    raise RuntimeError("Unable to resolve interaction_id for started call")


def callcontrol_side_effect_timeout_seconds() -> float:
    bridge = _bridge_app()
    return min(bridge._forward_timeout_seconds(45.0), 2.5)


def callcontrol_side_effect_max_attempts() -> int:
    return 1


def emit_voice_event(
    *,
    event_type: str,
    call_id: str,
    interaction_id: str | None,
    source_event_id: str | None = None,
    payload: dict[str, Any],
) -> dict[str, Any]:
    bridge = _bridge_app()
    return bridge._post_json(
        f"{bridge._voice_adapter_service_url()}/integrations/voice/events",
        {
            "event_type": event_type,
            "call_id": call_id,
            "interaction_id": interaction_id,
            "source_event_id": source_event_id,
            "payload": payload,
        },
    )


def assign_interaction(*, interaction_id: str, assignee: str) -> dict[str, Any]:
    bridge = _bridge_app()
    return bridge._patch_json(
        f"{bridge._interaction_service_url()}/interactions/{interaction_id}/assign",
        {"assignee": assignee},
        timeout_seconds=bridge._callcontrol_side_effect_timeout_seconds(),
        max_attempts=bridge._callcontrol_side_effect_max_attempts(),
        retry_backoff_seconds=0.0,
    )


def upload_recording(
    *,
    local_path: Path,
    call_id: str,
    interaction_id: str | None,
    source_event_id: str | None,
    file_name: str,
    mime_type: str | None,
    duration_seconds: int | None,
) -> dict[str, Any]:
    bridge = _bridge_app()
    data = {
        "call_id": call_id,
        "file_name": file_name,
    }
    if interaction_id:
        data["interaction_id"] = interaction_id
    if source_event_id:
        data["source_event_id"] = source_event_id
    if mime_type:
        data["mime_type"] = mime_type
    if duration_seconds is not None:
        data["duration_seconds"] = str(duration_seconds)

    last_exc: Exception | None = None
    with local_path.open("rb") as handle, httpx.Client(timeout=bridge._forward_timeout_seconds(60.0)) as client:
        for attempt in range(1, bridge._forward_max_attempts() + 1):
            try:
                response = bridge._request_with_bridge_auth(
                    client,
                    method="POST",
                    url=f"{bridge._recording_service_url()}/recordings/import-upload",
                    data=data,
                    files={"file": (file_name, handle, mime_type or "application/octet-stream")},
                    retry_reset=lambda: handle.seek(0),
                )
                return response.json()
            except httpx.HTTPStatusError as exc:
                last_exc = exc
                status = exc.response.status_code if exc.response is not None else 0
                if status < 500 or attempt >= bridge._forward_max_attempts():
                    raise
            except httpx.RequestError as exc:
                last_exc = exc
                if attempt >= bridge._forward_max_attempts():
                    raise
            handle.seek(0)
    if last_exc is not None:
        raise last_exc
    raise RuntimeError("Failed to upload recording")


def normalize_duration(value: Any) -> int | None:
    if value in (None, ""):
        return None
    try:
        parsed = int(str(value))
    except ValueError:
        return None
    return max(parsed, 0)


def process_call_started(
    session,
    row: AsteriskEventLogRow,
    payload: dict[str, Any],
) -> None:
    bridge = _bridge_app()
    forwarded = bridge._find_forwarded_log(
        session,
        event_name=row.ami_event_name,
        call_id=row.call_id,
        exclude_bridge_event_id=row.bridge_event_id,
    )
    if forwarded:
        bridge._mark_log_forwarded(session, row, interaction_id=forwarded.interaction_id)
        return

    queue_code = str(payload.get("QueueCode") or "").strip()
    queue_id = bridge._queue_map().get(queue_code)
    if not queue_id:
        raise RuntimeError(f"Unknown QueueCode: {queue_code or '<empty>'}")

    caller_number = str(payload.get("CallerNumber") or "").strip() or None
    existing_link, interaction_id = bridge._resolve_started_interaction_id(
        session,
        call_id=row.call_id,
        queue_id=queue_id,
        caller_number=caller_number,
    )

    bridge._emit_voice_event(
        event_type="call.started",
        call_id=row.call_id,
        interaction_id=interaction_id,
        source_event_id=bridge._bridge_source_event_id(row, "call.started"),
        payload={
            "source": "asterisk",
            "caller_number": payload.get("CallerNumber"),
            "caller_name": payload.get("CallerName"),
            "extension": payload.get("Extension"),
            "context": payload.get("Context"),
            "linked_id": row.linked_id,
            "direction": payload.get("Direction", "inbound"),
        },
    )

    now = utc_now_iso()
    channel_name = bridge._resolve_channel_from_payload(payload)
    link = existing_link or AsteriskCallLinkRow(
        call_id=row.call_id,
        linked_id=row.linked_id,
        queue_code=queue_code,
        queue_id=queue_id,
        interaction_id=interaction_id,
        caller_number=caller_number,
        caller_name=str(payload.get("CallerName") or "").strip() or None,
        status="active",
        telephony_status="ringing",
        claimed_by_user=None,
        claimed_at=None,
        operator_extension=None,
        channel_name=channel_name,
        started_at=now,
        connected_at=None,
        ended_at=None,
        updated_at=now,
    )
    if not existing_link:
        session.add(link)
    else:
        link.linked_id = row.linked_id
        link.queue_code = queue_code
        link.queue_id = queue_id
        link.interaction_id = interaction_id
        link.caller_number = caller_number
        link.caller_name = str(payload.get("CallerName") or "").strip() or None
        link.status = "active"
        link.telephony_status = "ringing"
        if channel_name:
            link.channel_name = channel_name
        link.updated_at = now

    _upsert_voice_reporting_fact(session, link, status="new")

    ai_config = bridge._ai_voice_config_for_queue(queue_code)
    if ai_config:
        link.ai_state = "queued"
        link.ai_handoff_reason = None
        link.ai_last_model_at = now
        logger.warning(
            "bridge.call_started_ai call_id=%s interaction_id=%s queue_code=%s queue_id=%s",
            row.call_id,
            interaction_id,
            queue_code,
            queue_id,
        )
        try:
            started = bridge._start_voice_ai_session(
                call_id=row.call_id,
                linked_id=row.linked_id,
                interaction_id=interaction_id,
                queue_id=queue_id,
                queue_code=queue_code,
                caller_number=caller_number,
                caller_name=str(payload.get("CallerName") or "").strip() or None,
                ai_config=ai_config,
            )
            link.voice_session_id = str((started or {}).get("voice_session_id") or "").strip() or None
            link.ai_session_id = str((started or {}).get("ai_session_id") or "").strip() or None
            link.ai_state = str((started or {}).get("status") or "queued").strip() or "queued"
            link.ai_last_model_at = utc_now_iso()
            logger.warning(
                "bridge.call_started_ai_result call_id=%s voice_session_id=%s ai_session_id=%s ai_state=%s",
                row.call_id,
                link.voice_session_id,
                link.ai_session_id,
                link.ai_state,
            )
            try:
                bridge._append_interaction_timeline(
                    interaction_id=interaction_id,
                    action="ai.session_started",
                    metadata={
                        "call_id": row.call_id,
                        "voice_session_id": link.voice_session_id,
                        "ai_session_id": link.ai_session_id,
                        "queue_code": queue_code,
                    },
                )
            except Exception:
                pass
        except Exception as exc:
            link.ai_state = "error"
            link.ai_handoff_reason = str(exc)[:1000]
            link.ai_last_model_at = utc_now_iso()
            logger.warning(
                "bridge.call_started_ai_error call_id=%s queue_code=%s error=%s",
                row.call_id,
                queue_code,
                str(exc)[:500],
            )
            try:
                bridge._append_interaction_timeline(
                    interaction_id=interaction_id,
                    action="ai.error",
                    metadata={
                        "call_id": row.call_id,
                        "queue_code": queue_code,
                        "error": str(exc)[:500],
                    },
                )
            except Exception:
                pass

    bridge._mark_log_forwarded(session, row, interaction_id=interaction_id)


def process_audio_bridge_requested(
    session,
    row: AsteriskEventLogRow,
    payload: dict[str, Any],
) -> None:
    bridge = _bridge_app()
    forwarded = bridge._find_forwarded_log(
        session,
        event_name=row.ami_event_name,
        call_id=row.call_id,
        exclude_bridge_event_id=row.bridge_event_id,
    )
    if forwarded:
        bridge._mark_log_forwarded(session, row, interaction_id=forwarded.interaction_id)
        return

    link = bridge._find_call_link(session, row.call_id)
    if not link:
        link = bridge._recover_call_link_from_started_event(
            session,
            call_id=row.call_id,
            exclude_bridge_event_id=row.bridge_event_id,
        )
    if not link:
        raise RuntimeError(f"Unknown CallID: {row.call_id}")
    voice_session_id = str(link.voice_session_id or "").strip() or None
    ai_session_id = str(link.ai_session_id or "").strip() or None
    ai_state = str(link.ai_state or "").strip() or None
    if not str(link.voice_session_id or "").strip():
        voice_session = find_voice_ai_session_by_call_id(session, row.call_id)
        if voice_session is not None:
            voice_session_id = str(voice_session.session_id or "").strip() or None
            ai_session_id = str(voice_session.ai_session_id or "").strip() or None
            if ai_state in {None, "", "queued"}:
                ai_state = str(voice_session.status or "greeting").strip() or "greeting"
    if not voice_session_id:
        raise RuntimeError("Voice AI session is not ready for AudioSocket bridge")

    media_uuid = bridge._first_non_empty(payload.get("MediaUUID"), payload.get("MediaUuid"))
    if not media_uuid:
        raise RuntimeError("MediaUUID is required for AudioSocket bridge request")
    channel = bridge._resolve_channel_from_payload(payload)
    service_address = bridge._first_non_empty(
        payload.get("AudioSocketService"),
        payload.get("ServiceAddress"),
    )
    logger.warning(
        "bridge.audio_bridge_requested call_id=%s voice_session_id=%s ai_session_id=%s ai_state=%s media_uuid=%s service=%s channel=%s",
        row.call_id,
        voice_session_id,
        ai_session_id,
        ai_state,
        media_uuid,
        service_address,
        channel,
    )
    bridge._register_voice_ai_media_bridge(
        voice_session_id=voice_session_id,
        event_type="requested",
        media_uuid=media_uuid,
        call_id=row.call_id,
        linked_id=row.linked_id,
        channel=channel,
        service_address=service_address,
    )
    logger.warning(
        "bridge.audio_bridge_registered call_id=%s voice_session_id=%s media_uuid=%s",
        row.call_id,
        voice_session_id,
        media_uuid,
    )
    link.voice_session_id = voice_session_id
    link.ai_session_id = ai_session_id
    if channel:
        link.channel_name = channel
    link.ai_state = "greeting"
    link.ai_last_model_at = utc_now_iso()
    link.updated_at = utc_now_iso()
    bridge._mark_log_forwarded(session, row, interaction_id=link.interaction_id)


def process_audio_bridge_ended(
    session,
    row: AsteriskEventLogRow,
    payload: dict[str, Any],
) -> None:
    bridge = _bridge_app()
    forwarded = bridge._find_forwarded_log(
        session,
        event_name=row.ami_event_name,
        call_id=row.call_id,
        exclude_bridge_event_id=row.bridge_event_id,
    )
    if forwarded:
        bridge._mark_log_forwarded(session, row, interaction_id=forwarded.interaction_id)
        return

    link = bridge._find_call_link(session, row.call_id)
    if not link:
        link = bridge._recover_call_link_from_started_event(
            session,
            call_id=row.call_id,
            exclude_bridge_event_id=row.bridge_event_id,
        )
    if not link:
        raise RuntimeError(f"Unknown CallID: {row.call_id}")
    media_uuid = bridge._first_non_empty(payload.get("MediaUUID"), payload.get("MediaUuid"))
    reason = bridge._first_non_empty(payload.get("Reason"), payload.get("TryExecStatus"))
    logger.warning(
        "bridge.audio_bridge_ended call_id=%s voice_session_id=%s media_uuid=%s reason=%s",
        row.call_id,
        str(link.voice_session_id or "").strip() or None,
        media_uuid,
        reason,
    )
    if str(link.voice_session_id or "").strip() and media_uuid:
        bridge._register_voice_ai_media_bridge(
            voice_session_id=link.voice_session_id,
            event_type="ended",
            media_uuid=media_uuid,
            call_id=row.call_id,
            linked_id=row.linked_id,
            channel=bridge._resolve_channel_from_payload(payload),
            service_address=bridge._first_non_empty(
                payload.get("AudioSocketService"),
                payload.get("ServiceAddress"),
            ),
            reason=reason,
        )
    link.ai_last_model_at = utc_now_iso()
    link.updated_at = utc_now_iso()
    bridge._mark_log_forwarded(session, row, interaction_id=link.interaction_id)


def process_call_ended(
    session,
    row: AsteriskEventLogRow,
    payload: dict[str, Any],
) -> None:
    bridge = _bridge_app()
    forwarded = bridge._find_forwarded_log(
        session,
        event_name=row.ami_event_name,
        call_id=row.call_id,
        exclude_bridge_event_id=row.bridge_event_id,
    )
    if forwarded:
        bridge._mark_log_forwarded(session, row, interaction_id=forwarded.interaction_id)
        return

    link = bridge._find_call_link(session, row.call_id)
    if not link:
        link = bridge._recover_call_link_from_started_event(
            session,
            call_id=row.call_id,
            exclude_bridge_event_id=row.bridge_event_id,
        )
    if not link:
        raise RuntimeError(f"Unknown CallID: {row.call_id}")

    bridge._emit_voice_event(
        event_type="call.ended",
        call_id=row.call_id,
        interaction_id=link.interaction_id,
        source_event_id=bridge._bridge_source_event_id(row, "call.ended"),
        payload={
            "source": "asterisk",
            "hangup_cause": payload.get("HangupCause"),
            "duration_seconds": bridge._normalize_duration(payload.get("DurationSeconds")),
            "linked_id": row.linked_id,
        },
    )
    now = utc_now_iso()
    link.status = "ended"
    link.telephony_status = "ended"
    link.ended_at = now
    link.updated_at = now
    answered = bool(link.connected_at)
    _upsert_voice_reporting_fact(
        session,
        link,
        status="closed" if answered else "abandoned",
        answered=answered,
        abandoned=not answered,
        wait_seconds=_seconds_between(link.started_at, link.connected_at) if answered else None,
        handle_seconds=_seconds_between(link.connected_at, link.ended_at) if answered else None,
        closed_at=link.ended_at,
    )
    if str(link.ai_state or "").strip() and str(link.ai_state or "").strip() != "human_owned":
        link.ai_state = "closed"
        link.ai_last_model_at = now
    logger.warning(
        "bridge.call_ended call_id=%s voice_session_id=%s hangup_cause=%s duration_seconds=%s ai_state=%s",
        row.call_id,
        link.voice_session_id,
        payload.get("HangupCause"),
        payload.get("DurationSeconds"),
        link.ai_state,
    )
    open_escalation = session.execute(
        select(EscalationRow).where(
            EscalationRow.call_id == row.call_id,
            EscalationRow.status.in_(["requested", "ringing"]),
        )
    ).scalar_one_or_none()
    if open_escalation is not None:
        open_escalation.status = "completed" if answered else "failed"
        open_escalation.completed_at = now
        if answered and not open_escalation.connected_at:
            open_escalation.connected_at = now
    try:
        bridge._release_routing_agent(row.call_id)
    except Exception:
        pass
    try:
        bridge._notify_voice_ai_telephony_event(
            voice_session_id=link.voice_session_id,
            event_type="call.ended",
            payload={
                "call_id": row.call_id,
                "hangup_cause": payload.get("HangupCause"),
                "duration_seconds": payload.get("DurationSeconds"),
            },
        )
    except Exception:
        pass
    bridge._mark_log_forwarded(session, row, interaction_id=link.interaction_id)


def process_operator_connected(
    session,
    row: AsteriskEventLogRow,
    payload: dict[str, Any],
) -> None:
    bridge = _bridge_app()
    forwarded = bridge._find_forwarded_log(
        session,
        event_name=row.ami_event_name,
        call_id=row.call_id,
        exclude_bridge_event_id=row.bridge_event_id,
    )
    if forwarded:
        bridge._mark_log_forwarded(session, row, interaction_id=forwarded.interaction_id)
        return

    link = bridge._find_call_link(session, row.call_id)
    if not link:
        link = bridge._recover_call_link_from_started_event(
            session,
            call_id=row.call_id,
            exclude_bridge_event_id=row.bridge_event_id,
        )
    if not link:
        raise RuntimeError(f"Unknown CallID: {row.call_id}")

    operator_extension = bridge._first_non_empty(
        payload.get("OperatorExtension"),
        payload.get("OperatorExten"),
        payload.get("Exten"),
        payload.get("Extension"),
    )
    claimed_by = bridge._first_non_empty(
        payload.get("ClaimedBy"),
        payload.get("ClaimedByUser"),
        payload.get("OperatorUser"),
    )
    channel = bridge._resolve_channel_from_payload(payload)
    if operator_extension in {"", "s"}:
        operator_extension = bridge._extract_extension_from_channel(channel)
    if claimed_by in {"", "s"}:
        claimed_by = None
    if not claimed_by and str(link.claimed_by_user or "").strip():
        claimed_by = str(link.claimed_by_user or "").strip()
    if not claimed_by and operator_extension:
        claimed_by = bridge._user_for_operator_extension(operator_extension)
    now = utc_now_iso()

    if operator_extension:
        link.operator_extension = operator_extension
    if claimed_by:
        link.claimed_by_user = claimed_by
        link.claimed_at = link.claimed_at or now
    if channel:
        link.channel_name = channel
    link.telephony_status = "connected"
    link.connected_at = now
    link.updated_at = now
    _upsert_voice_reporting_fact(
        session,
        link,
        status="in_progress",
        answered=True,
        abandoned=False,
        wait_seconds=_seconds_between(link.started_at, link.connected_at),
    )
    ai_event_type = "call.connected"
    if str(link.ai_state or "").strip() in {"handoff_requested", "handoff_required", "active", "speaking", "thinking", "listening", "greeting"}:
        link.ai_state = "human_owned"
        link.ai_last_model_at = now
        ai_event_type = "operator.connected"
        try:
            bridge._append_interaction_timeline(
                interaction_id=link.interaction_id,
                action="ai.handoff_completed",
                metadata={
                    "call_id": row.call_id,
                    "voice_session_id": link.voice_session_id,
                    "ai_session_id": link.ai_session_id,
                    "operator_extension": operator_extension,
                    "claimed_by_user": claimed_by,
                },
            )
        except Exception:
            pass

    bridge._emit_voice_event(
        event_type="call.connected",
        call_id=row.call_id,
        interaction_id=link.interaction_id,
        source_event_id=bridge._bridge_source_event_id(row, "call.connected"),
        payload={
            "source": "asterisk",
            "linked_id": row.linked_id,
            "operator_extension": operator_extension,
            "claimed_by_user": claimed_by,
            "channel_name": channel,
        },
    )
    try:
        bridge._notify_voice_ai_telephony_event(
            voice_session_id=link.voice_session_id,
            event_type=ai_event_type,
            payload={
                "call_id": row.call_id,
                "operator_extension": operator_extension,
                "claimed_by_user": claimed_by,
                "channel_name": channel,
            },
        )
    except Exception:
        pass
    logger.warning(
        "bridge.operator_connected call_id=%s voice_session_id=%s operator_extension=%s claimed_by=%s ai_event_type=%s",
        row.call_id,
        link.voice_session_id,
        operator_extension,
        claimed_by,
        ai_event_type,
    )
    bridge._mark_log_forwarded(session, row, interaction_id=link.interaction_id)


def process_recording_ready(
    session,
    row: AsteriskEventLogRow,
    payload: dict[str, Any],
) -> None:
    bridge = _bridge_app()
    forwarded = bridge._find_forwarded_log(
        session,
        event_name=row.ami_event_name,
        call_id=row.call_id,
        exclude_bridge_event_id=row.bridge_event_id,
    )
    if forwarded:
        bridge._mark_log_forwarded(
            session,
            row,
            interaction_id=forwarded.interaction_id,
            recording_id=forwarded.recording_id,
        )
        return

    link = bridge._find_call_link(session, row.call_id)
    if not link:
        link = bridge._recover_call_link_from_started_event(
            session,
            call_id=row.call_id,
            exclude_bridge_event_id=row.bridge_event_id,
        )
    if not link:
        raise RuntimeError(f"Unknown CallID: {row.call_id}")

    remote_path = str(payload.get("RemotePath") or "").strip()
    file_name = str(payload.get("FileName") or "").strip() or Path(remote_path).name or "call.wav"
    mime_type = str(payload.get("MimeType") or "").strip() or None
    duration_seconds = bridge._normalize_duration(payload.get("DurationSeconds"))
    if not remote_path:
        raise RuntimeError("RemotePath is required")

    ended_exists = bridge._has_voice_event(session, call_id=row.call_id, event_type="call.ended")
    if not ended_exists:
        bridge._emit_voice_event(
            event_type="call.ended",
            call_id=row.call_id,
            interaction_id=link.interaction_id,
            source_event_id=bridge._bridge_source_event_id(row, "call.ended"),
            payload={
                "source": "asterisk",
                "hangup_cause": "unknown",
                "duration_seconds": duration_seconds,
                "linked_id": row.linked_id,
                "reconciled": True,
                "reason": "recording.ready_without_call.ended",
            },
        )
        ended_exists = True

    if ended_exists and link.status != "ended":
        now = utc_now_iso()
        link.status = "ended"
        link.telephony_status = "ended"
        link.ended_at = link.ended_at or now
        link.updated_at = now
        session.flush()

    existing_record = bridge._latest_recording_for_call(session, call_id=row.call_id)
    if existing_record is not None:
        if not bridge._has_voice_event(session, call_id=row.call_id, event_type="recording.ready"):
            bridge._emit_voice_event(
                event_type="recording.ready",
                call_id=row.call_id,
                interaction_id=link.interaction_id,
                source_event_id=bridge._bridge_source_event_id(row, "recording.ready"),
                payload={
                    "source": "asterisk",
                    "remote_path": remote_path,
                    "file_name": file_name,
                    "mime_type": mime_type,
                    "duration_seconds": duration_seconds,
                    "linked_id": row.linked_id,
                },
            )
        bridge._mark_log_forwarded(
            session,
            row,
            interaction_id=link.interaction_id,
            recording_id=existing_record.recording_id,
        )
        return

    bridge._emit_voice_event(
        event_type="recording.ready",
        call_id=row.call_id,
        interaction_id=link.interaction_id,
        source_event_id=bridge._bridge_source_event_id(row, "recording.ready"),
        payload={
            "source": "asterisk",
            "remote_path": remote_path,
            "file_name": file_name,
            "mime_type": mime_type,
            "duration_seconds": duration_seconds,
            "linked_id": row.linked_id,
        },
    )
    try:
        bridge._notify_voice_ai_telephony_event(
            voice_session_id=link.voice_session_id,
            event_type="recording.ready",
            payload={
                "call_id": row.call_id,
                "file_name": file_name,
                "mime_type": mime_type,
                "duration_seconds": duration_seconds,
            },
        )
    except Exception:
        pass

    local_path, cleanup_required = bridge._fetch_recording_file(remote_path, file_name)
    try:
        created = bridge._upload_recording(
            local_path=local_path,
            call_id=row.call_id,
            interaction_id=link.interaction_id,
            source_event_id=bridge._bridge_source_event_id(row, "recording.import"),
            file_name=file_name,
            mime_type=mime_type,
            duration_seconds=duration_seconds,
        )
        bridge._mark_log_forwarded(
            session,
            row,
            interaction_id=link.interaction_id,
            recording_id=created["recording_id"],
        )
    finally:
        if cleanup_required and local_path.exists():
            local_path.unlink(missing_ok=True)


def process_bridge_row(session, row: AsteriskEventLogRow) -> AsteriskEventLogRow:
    bridge = _bridge_app()
    payload = json.loads(row.payload_json or "{}")
    logger.warning(
        "bridge.process_event event=%s call_id=%s status=%s",
        row.ami_event_name,
        row.call_id,
        row.forward_status,
    )
    if row.ami_event_name == f"{bridge._ami_prefix()}CallStarted":
        bridge._process_call_started(session, row, payload)
    elif row.ami_event_name == f"{bridge._ami_prefix()}AIAudioBridgeRequested":
        bridge._process_audio_bridge_requested(session, row, payload)
    elif row.ami_event_name == f"{bridge._ami_prefix()}AIAudioBridgeEnded":
        bridge._process_audio_bridge_ended(session, row, payload)
    elif row.ami_event_name in {
        f"{bridge._ami_prefix()}AIAudioSocketAttempt",
        f"{bridge._ami_prefix()}AIAudioSocketResult",
    }:
        logger.warning(
            "bridge.audio_socket_diag event=%s call_id=%s payload=%s",
            row.ami_event_name,
            row.call_id,
            row.payload_json,
        )
        interaction_id = None
        link = bridge._find_call_link(session, row.call_id)
        if link is not None:
            interaction_id = link.interaction_id
        bridge._mark_log_forwarded(session, row, interaction_id=interaction_id)
    elif row.ami_event_name == f"{bridge._ami_prefix()}OperatorConnected":
        bridge._process_operator_connected(session, row, payload)
    elif row.ami_event_name == f"{bridge._ami_prefix()}CallEnded":
        bridge._process_call_ended(session, row, payload)
    elif row.ami_event_name == f"{bridge._ami_prefix()}RecordingReady":
        bridge._process_recording_ready(session, row, payload)
    else:
        row.forward_status = "received"
        row.updated_at = utc_now_iso()
        session.flush()
    return row


def record_ami_payload(payload: dict[str, Any]) -> None:
    bridge = _bridge_app()
    event_name = str(payload.get("UserEvent") or "").strip()
    call_id = bridge._extract_call_id(payload)
    linked_id = bridge._extract_linked_id(payload, call_id)
    if not event_name or not call_id:
        return

    session = get_session()
    try:
        row = bridge._create_bridge_log(
            session,
            ami_event_name=event_name,
            call_id=call_id,
            linked_id=linked_id,
            payload=payload,
        )
        session.commit()
        bridge_event_id = row.bridge_event_id
    finally:
        session.close()

    if not bridge._claim_bridge_event_for_processing(bridge_event_id, allowed_statuses=("received",)):
        return
    bridge._process_claimed_bridge_event(bridge_event_id)


def retry_failed_events_once() -> None:
    bridge = _bridge_app()
    session = get_session()
    try:
        rows = session.execute(
            select(AsteriskEventLogRow)
            .where(AsteriskEventLogRow.forward_status.in_(("failed", "received")))
            .order_by(AsteriskEventLogRow.id.asc())
        ).scalars().all()
        event_ids = [row.bridge_event_id for row in rows[: bridge._failed_retry_batch_size()]]
    finally:
        session.close()

    for bridge_event_id in event_ids:
        if not bridge._claim_bridge_event_for_processing(bridge_event_id):
            continue
        bridge._process_claimed_bridge_event(bridge_event_id)


def failed_retry_loop(stop_event: threading.Event | None = None) -> None:
    bridge = _bridge_app()
    active_stop_event = stop_event or bridge._background_stop_event()
    while not active_stop_event.is_set():
        if bridge._bridge_enabled() and bridge._failed_retry_enabled():
            try:
                bridge._retry_failed_events_once()
            except Exception:
                pass
        if bridge._bridge_enabled() and bridge._reconcile_enabled():
            try:
                bridge._reconcile_stale_calls_once()
            except Exception:
                pass
        if active_stop_event.wait(bridge._failed_retry_interval_seconds()):
            break
