from __future__ import annotations

import difflib
import importlib
import json
import re
from typing import Any

from fastapi import HTTPException
from sqlalchemy import select

from services.shared.ai_context_summary import (
    context_summary_context_texts,
    dump_context_summary,
    load_context_summary,
    render_context_summary_text,
    update_context_summary_from_assistant_turn,
    update_context_summary_from_user_turn,
)
from services.shared.core import new_id, utc_now_iso
from services.shared.db import get_session
from services.shared.models import VoiceAIStartIn, VoiceAIStartOut, VoiceAITurnDecisionOut, VoiceAITurnIn, VoiceStartResult
from services.shared.sql_models import (
    AISessionRow,
    AITurnRow,
    AsteriskCallLinkRow,
    Customer,
    CustomerExternalIdentity,
    Interaction,
    InteractionTimeline,
    VoiceAISessionRow,
    VoiceTranscriptSegmentRow,
)
from services.ai_orchestrator_service import operator_persona as persona
from services.ai_orchestrator_service.voice_name_config import (
    load_effective_voice_name_collection_config,
    voice_name_collection_confirmation_greeting,
    voice_name_collection_followup_markers,
    voice_name_collection_inline_followup,
    voice_name_collection_personalized_greeting,
    voice_name_collection_start_prompt,
)


def _app():
    return importlib.import_module("services.ai_orchestrator_service.app")


def _voice_max_context_segments() -> int:
    app = _app()
    return max(6, app._int_env("AI_VOICE_MAX_CONTEXT_SEGMENTS", 12))


def _voice_disclosure_prefix(language: str) -> str:
    return persona.voice_disclosure_prefix(language)


def _voice_greeting(language: str, operator_config: Any | None = None) -> str:
    return persona.voice_greeting(language, operator_config)


def _voice_handoff_reply(language: str) -> str:
    return persona.voice_handoff_reply(language)


def _normalize_phone(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 raw
    if raw.startswith("+"):
        return f"+{digits}"
    return digits


def _voice_identity_subjects(caller_number: str | None) -> list[str]:
    normalized = _normalize_phone(caller_number)
    values = [normalized, str(caller_number or "").strip() or None]
    seen: set[str] = set()
    result: list[str] = []
    for value in values:
        if not value or value in seen:
            continue
        seen.add(value)
        result.append(value)
    return result


def _resolve_caller_from_call(session, call_id: str) -> tuple[str | None, str | None]:
    row = session.execute(
        select(AsteriskCallLinkRow).where(AsteriskCallLinkRow.call_id == call_id)
    ).scalar_one_or_none()
    if row is None:
        return None, None
    return row.caller_number, row.caller_name


def _ensure_voice_identity(
    session,
    *,
    customer_id: str,
    caller_number: str,
    caller_name: str | None,
    now: str,
) -> None:
    for subject in _voice_identity_subjects(caller_number):
        identity = session.execute(
            select(CustomerExternalIdentity).where(
                CustomerExternalIdentity.channel == "voice",
                CustomerExternalIdentity.external_subject == subject,
            )
        ).scalar_one_or_none()
        if identity:
            identity.customer_id = customer_id
            identity.display_name_snapshot = caller_name or identity.display_name_snapshot
            identity.updated_at = now
            continue
        session.add(
            CustomerExternalIdentity(
                identity_id=new_id("cei"),
                customer_id=customer_id,
                channel="voice",
                external_subject=subject,
                display_name_snapshot=caller_name,
                created_at=now,
                updated_at=now,
            )
        )


def _resolve_or_create_voice_customer_id(
    session,
    *,
    interaction: Interaction,
    call_id: str,
) -> str | None:
    app = _app()
    if app._customer_id_is_real(interaction.customer_id):
        return str(interaction.customer_id)

    caller_number, caller_name = _resolve_caller_from_call(session, call_id)
    subjects = _voice_identity_subjects(caller_number)
    now = utc_now_iso()
    for subject in subjects:
        identity = session.execute(
            select(CustomerExternalIdentity).where(
                CustomerExternalIdentity.channel == "voice",
                CustomerExternalIdentity.external_subject == subject,
            )
        ).scalar_one_or_none()
        if identity:
            interaction.customer_id = identity.customer_id
            interaction.updated_at = now
            return identity.customer_id

    if not caller_number:
        return str(interaction.customer_id or "").strip() or None

    customer = Customer(
        customer_id=new_id("cus"),
        display_name=caller_name or caller_number,
        phones_json=json.dumps([caller_number], ensure_ascii=False),
        preferred_phone=caller_number,
        tags_json=json.dumps(["voice"], ensure_ascii=False),
        created_at=now,
    )
    session.add(customer)
    interaction.customer_id = customer.customer_id
    interaction.updated_at = now
    _ensure_voice_identity(
        session,
        customer_id=customer.customer_id,
        caller_number=caller_number,
        caller_name=caller_name,
        now=now,
    )
    return customer.customer_id


def _voice_start_stage(agent_profile: str | None, metadata: dict[str, Any] | None) -> bool:
    payload = metadata if isinstance(metadata, dict) else {}
    if str(agent_profile or "").strip() == "voice_start":
        return True
    return str(payload.get("stage") or "").strip() == "voice_start"


def _voice_start_language(language_hint: str | None, metadata: dict[str, Any] | None, fallback: str | None = None) -> str:
    payload = metadata if isinstance(metadata, dict) else {}
    return (
        str(payload.get("voice_start_language") or language_hint or fallback or "ru").strip()
        or "ru"
    )


def _voice_start_name_prompt(language: str, config) -> str:
    return voice_name_collection_start_prompt(config, language)


def _voice_start_downstream_queue(metadata: dict[str, Any] | None, voice_session: VoiceAISessionRow) -> tuple[str | None, str | None]:
    payload = metadata if isinstance(metadata, dict) else {}
    queue_code = str(payload.get("downstream_queue_code") or payload.get("next_queue_code") or "").strip() or None
    queue_id = str(payload.get("downstream_queue_id") or payload.get("next_queue_id") or voice_session.handoff_target_queue_id or "").strip() or None
    return queue_code, queue_id


def _display_name_looks_trusted(
    customer: Customer | None,
    caller_number: str | None,
    caller_name: str | None,
) -> bool:
    display_name = str(getattr(customer, "display_name", "") or "").strip()
    if not display_name:
        return False
    normalized_display = _voice_text_key(display_name)
    normalized_number = _voice_text_key(_normalize_phone(caller_number) or caller_number)
    normalized_caller_name = _voice_text_key(caller_name)
    if not normalized_display:
        return False
    if normalized_display == normalized_number or normalized_display == normalized_caller_name:
        return False
    if re.fullmatch(r"\+?\d[\d\s\-()]{4,}", display_name):
        return False
    placeholder_markers = {
        "caller",
        "client",
        "customer",
        "unknown",
        "anonymous",
        "\u043a\u043b\u0438\u0435\u043d\u0442",
        "\u043d\u0435\u0438\u0437\u0432\u0435\u0441\u0442\u043d\u044b\u0439",
        "\u0430\u0431\u043e\u043d\u0435\u043d\u0442",
    }
    tokens = [token for token in re.findall(r"[^\W\d_]+", normalized_display, flags=re.UNICODE) if token]
    if not tokens:
        return False
    return not all(token in placeholder_markers for token in tokens)


def _canonical_name(text: str) -> str:
    words = [part for part in re.split(r"\s+", text.strip()) if part]
    return " ".join(word[:1].upper() + word[1:].lower() if len(word) > 1 else word.upper() for word in words)


def _normalize_name_candidate(text: str | None) -> str | None:
    raw = str(text or "").strip(" \t\r\n,.;:!?\"'()[]{}")
    if not raw or any(ch.isdigit() for ch in raw):
        return None
    words = re.findall(r"[^\W\d_]+(?:[-'][^\W\d_]+)*", raw, flags=re.UNICODE)
    if not words or len(words) > 4:
        return None
    lowered = [_voice_text_key(word) for word in words]
    stop_words = {
        "\u043c\u0435\u043d\u044f",
        "\u0437\u043e\u0432\u0443\u0442",
        "\u044d\u0442\u043e",
        "\u044f",
        "\u043c\u043e\u0435",
        "\u0438\u043c\u044f",
        "\u043c\u0435\u043d\u0456\u04a3",
        "\u0430\u0442\u044b\u043c",
        "\u0430\u0442\u044b\u043c\u044b",
        "\u0430\u0442\u044b\u043c",
        "\u0431\u043e\u043b\u0430\u0434\u044b",
    }
    filtered = [word for word, lowered_word in zip(words, lowered) if lowered_word not in stop_words]
    if not filtered:
        return None
    if len(filtered) == 2 and len(filtered[1]) == 1:
        filtered = [filtered[0]]
    elif len(filtered) == 2 and len(filtered[0]) == 1:
        filtered = [filtered[1]]
    invalid_tokens = {
        "\u0434\u0430",
        "\u0445\u0430\u0447\u0443",
        "\u043d\u0435\u0442",
        "\u0430\u043b\u043b\u043e",
        "\u043f\u0440\u0438\u0432\u0435\u0442",
        "\u0445\u043e\u0447\u0443",
        "\u0443\u0437\u043d\u0430\u0442\u044c",
        "\u043f\u043e\u043c\u043e\u0449\u044c",
        "\u0432\u043e\u043f\u0440\u043e\u0441",
        "\u0442\u0430\u0440\u0438\u0444",
        "\u0441\u0442\u0430\u0442\u0443\u0441",
        "\u043e\u043f\u0435\u0440\u0430\u0442\u043e\u0440",
        "\u043f\u0440\u043e\u0431\u043b\u0435\u043c\u0430",
        "\u043a\u0435\u0440\u0435\u043a",
        "\u0441\u04b1\u0440\u0430\u049b",
        "\u043a\u04e9\u043c\u0435\u043a",
        "\u0442\u0430\u0440\u0438\u0444",
    }
    if any(_voice_text_key(word) in invalid_tokens for word in filtered):
        return None
    if len(filtered) > 2:
        return None
    if len(filtered) == 2:
        similarity = difflib.SequenceMatcher(
            None,
            _voice_text_key(filtered[0]),
            _voice_text_key(filtered[1]),
        ).ratio()
        if similarity >= 0.72:
            return None
    return _canonical_name(" ".join(filtered))


def _name_followup_needed(text: str) -> bool:
    normalized = _voice_text_key(text)
    request_markers = (
        "\u0445\u043e\u0442\u0435\u043b",
        "\u043d\u0443\u0436\u043d",
        "\u043f\u043e\u043c\u043e\u0433",
        "\u0432\u043e\u043f\u0440\u043e\u0441",
        "\u043f\u0440\u043e\u0431\u043b\u0435\u043c",
        "\u0442\u0430\u0440\u0438\u0444",
        "\u0441\u0442\u0430\u0442\u0443\u0441",
        "\u043e\u043f\u0435\u0440\u0430\u0442\u043e\u0440",
        "\u043a\u0430\u0436\u0435\u0442\u0441\u044f",
        "\u0431\u043e\u043b\u0430\u0434\u044b",
        "\u043a\u0435\u0440\u0435\u043a",
        "\u0441\u04b1\u0440\u0430\u0493",
        "\u043a\u04e9\u043c\u0435\u043a",
        "\u0442\u0430\u0440\u0438\u0444",
        "\u043e\u043f\u0435\u0440\u0430\u0442\u043e\u0440",
    )
    return any(marker in normalized for marker in request_markers)


def _extract_name_candidate(text: str | None, language: str) -> tuple[str | None, bool]:
    raw = str(text or "").strip()
    if not raw:
        return None, False
    normalized = _voice_text_key(raw)
    service_topic = _voice_has_service_topic(raw)
    explicit_patterns = [
        r"(?:\u043c\u0435\u043d\u044f\s+\u0437\u043e\u0432\u0443\u0442|my name is|i am|this is)\s+(.+)",
        r"(?:\u044f|it's me)\s+(.+)",
        r"(?:\u043c\u0435\u043d\u0456\u04a3\s+\u0430\u0442\u044b\u043c|mening atym|aty\u043c)\s+(.+)",
        r"(?:\u043c\u0435\u043d)\s+(.+)",
    ]
    for pattern in explicit_patterns:
        match = re.search(pattern, normalized, flags=re.IGNORECASE)
        if not match:
            continue
        tail = match.group(1).strip()
        tail_words = re.findall(r"[^\W\d_]+(?:[-'][^\W\d_]+)*", tail, flags=re.UNICODE)
        cutoff_tokens = {
            "\u0445\u043e\u0442\u0435\u043b",
            "\u0445\u043e\u0447\u0443",
            "\u043c\u043d\u0435",
            "\u043d\u0430\u0434\u043e",
            "\u043d\u0443\u0436\u043d\u043e",
            "\u0443\u0437\u043d\u0430\u0442\u044c",
            "\u0433\u0440\u0430\u0444\u0438\u043a",
            "\u0440\u0430\u0431\u043e\u0442\u044b",
            "\u0430\u0434\u0440\u0435\u0441",
            "\u0444\u0438\u043b\u0438\u0430\u043b",
            "\u0433\u043e\u0440\u043e\u0434",
            "\u0442\u0430\u0440\u0438\u0444",
            "\u0441\u0442\u0430\u0442\u0443\u0441",
            "\u043e\u043f\u0435\u0440\u0430\u0442\u043e\u0440",
            "\u0432\u043e\u043f\u0440\u043e\u0441",
            "\u043a\u0435\u0440\u0435\u043a",
            "\u0441\u04b1\u0440\u0430\u0493",
            "\u043a\u04e9\u043c\u0435\u043a",
        }
        candidate_words: list[str] = []
        for word in tail_words:
            if _voice_text_key(word) in cutoff_tokens:
                break
            candidate_words.append(word)
            if len(candidate_words) >= 3:
                break
        candidate = _normalize_name_candidate(" ".join(candidate_words)) or _normalize_name_candidate(tail)
        if candidate:
            return candidate, False

    candidate = _normalize_name_candidate(raw)
    if candidate:
        if service_topic:
            return None, False
        lower_cand = candidate.lower()
        stopwords = {
            "здравствуйте", "привет", "алло", "да", "нет", "добрый", "день",
            "саламатсыз", "сәлеметсіз", "ба", "бе", "ау", "слышно", "интернет", "не", "работает", "вопрос", "у", "меня"
        }
        cand_words = set(lower_cand.split())
        if cand_words.issubset(stopwords) or len(lower_cand) < 2:
            return None, False

        word_count = len(re.findall(r"[^\W\d_]+(?:[-'][^\W\d_]+)*", raw, flags=re.UNICODE))
        if word_count <= 2 and not _name_followup_needed(raw):
            return candidate, False
        return candidate, True
    return None, False


def _voice_start_name_outcome(text: str | None, language: str) -> tuple[str, str | None, str]:
    raw = str(text or "").strip()
    if not raw or _voice_is_low_signal_caller_text(raw):
        return "name_not_obtained", None, "none"
    candidate, needs_followup = _extract_name_candidate(raw, language)
    if candidate and not needs_followup:
        return "name_obtained", candidate, "voice_start"
    if candidate:
        return "name_followup_required", candidate, "voice_start"
    if _name_followup_needed(raw):
        return "name_followup_required", None, "none"
    return "name_not_obtained", None, "none"


def _voice_start_result_metadata(result: VoiceStartResult) -> dict[str, Any]:
    return {
        "voice_start_language": result.language,
        "customer_name_status": result.customer_name_status,
        "customer_name_value": result.customer_name_value,
        "customer_name_source": result.customer_name_source,
        "customer_name_resolved_at": result.resolved_at,
        "downstream_queue_code": result.downstream_queue_code,
        "downstream_queue_id": result.downstream_queue_id,
        "customer_id": result.customer_id,
    }


def _persist_voice_start_result(
    session,
    *,
    voice_session: VoiceAISessionRow,
    interaction: Interaction,
    result: VoiceStartResult,
) -> None:
    now = result.resolved_at or utc_now_iso()
    voice_session.voice_start_language = result.language
    voice_session.customer_name_status = result.customer_name_status
    voice_session.customer_name_value = result.customer_name_value
    voice_session.customer_name_source = result.customer_name_source
    voice_session.customer_name_resolved_at = now
    voice_session.updated_at = now
    link = session.execute(
        select(AsteriskCallLinkRow).where(AsteriskCallLinkRow.call_id == voice_session.call_id)
    ).scalar_one_or_none()
    if link is not None:
        link.voice_start_language = result.language
        link.customer_name_status = result.customer_name_status
        link.customer_name_value = result.customer_name_value
        link.customer_name_source = result.customer_name_source
        link.customer_name_resolved_at = now
        link.updated_at = now
    _append_timeline(
        interaction.interaction_id,
        "voice.start.completed",
        {
            "call_id": voice_session.call_id,
            "voice_session_id": voice_session.session_id,
            "ai_session_id": voice_session.ai_session_id,
            "language": result.language,
            "customer_id": result.customer_id,
            "customer_name_status": result.customer_name_status,
            "customer_name_value": result.customer_name_value,
            "customer_name_source": result.customer_name_source,
            "downstream_queue_code": result.downstream_queue_code,
            "downstream_queue_id": result.downstream_queue_id,
        },
    )


def _voice_short_name(name: str | None) -> str | None:
    canonical = _normalize_name_candidate(name)
    if not canonical:
        raw = str(name or "").strip()
        if not raw:
            return None
        canonical = _canonical_name(raw)
    return canonical.split(" ", 1)[0].strip() or None


def _voice_personalized_greeting(language: str, name: str | None, config) -> str:
    short_name = _voice_short_name(name)
    if not short_name:
        return _voice_greeting(language)
    return voice_name_collection_personalized_greeting(config, language, short_name)


def _voice_name_confirmation_greeting(language: str, name: str | None, config) -> str:
    short_name = _voice_short_name(name)
    if not short_name:
        return _voice_greeting(language)
    return voice_name_collection_confirmation_greeting(config, language, short_name)


def _voice_inline_name_followup(language: str, config) -> str:
    return voice_name_collection_inline_followup(config, language)


def _voice_name_followup_asked(transcript_window: list[VoiceTranscriptSegmentRow], language: str, config) -> bool:
    markers = tuple(_voice_text_key(marker) for marker in voice_name_collection_followup_markers(config, language))
    for segment in transcript_window:
        if segment.speaker != "assistant":
            continue
        normalized = _voice_text_key(segment.text)
        if any(marker in normalized for marker in markers):
            return True
    return False


def _voice_explicit_name_candidate(text: str | None) -> str | None:
    raw = str(text or "").strip()
    if not raw:
        return None
    normalized = _voice_text_key(raw)
    cutoff_tokens = {
        "\u043c\u043d\u0435",
        "\u043d\u0430\u0434\u043e",
        "\u043d\u0443\u0436\u043d\u043e",
        "\u0445\u043e\u0447\u0443",
        "\u0445\u043e\u0442\u0435\u043b",
        "\u0443\u0437\u043d\u0430\u0442\u044c",
        "\u0433\u0440\u0430\u0444\u0438\u043a",
        "\u0440\u0430\u0431\u043e\u0442\u044b",
        "\u0430\u0434\u0440\u0435\u0441",
        "\u0444\u0438\u043b\u0438\u0430\u043b",
        "\u0433\u043e\u0440\u043e\u0434",
        "\u0442\u0430\u0440\u0438\u0444",
        "\u0441\u0442\u0430\u0442\u0443\u0441",
        "\u043e\u043f\u0435\u0440\u0430\u0442\u043e\u0440",
        "\u0432\u043e\u043f\u0440\u043e\u0441",
        "\u043a\u0435\u0440\u0435\u043a",
        "\u0441\u04b1\u0440\u0430\u0493",
        "\u043a\u04e9\u043c\u0435\u043a",
    }
    patterns = (
        r"(?:меня зовут|мое имя|это|my name is|i am|this is)\s+(.+)",
        r"(?:менің атым|аты[мң]?|mening atym)\s+(.+)",
    )
    for pattern in patterns:
        match = re.search(pattern, normalized, flags=re.IGNORECASE)
        if not match:
            continue
        tail = match.group(1).strip()
        tail_words = re.findall(r"[^\W\d_]+(?:[-'][^\W\d_]+)*", tail, flags=re.UNICODE)
        candidate_words: list[str] = []
        for word in tail_words:
            if _voice_text_key(word) in cutoff_tokens:
                break
            candidate_words.append(word)
            if len(candidate_words) >= 3:
                break
        candidate = _normalize_name_candidate(" ".join(candidate_words)) or _normalize_name_candidate(tail)
        if candidate:
            return candidate
    return None


def _voice_is_name_confirmation(text: str | None, current_name: str | None) -> bool:
    normalized = _voice_text_key(text)
    if not normalized or not current_name:
        return False
    yes_markers = {
        "да",
        "ага",
        "верно",
        "правильно",
        "именно",
        "точно",
        "иә",
        "ия",
        "дурыс",
        "durys",
        "yes",
        "correct",
        "right",
    }
    if normalized in yes_markers:
        return True
    current_keys = {
        _voice_text_key(current_name),
        _voice_text_key(_voice_short_name(current_name) or ""),
    }
    if normalized in current_keys:
        return True
    normalized_tokens = {token for token in normalized.split(" ") if token}
    if normalized_tokens & yes_markers and normalized_tokens & {key for key in current_keys if key}:
        return True
    return any(marker in normalized for marker in ("да это", "верно это", "ия бұл", "дұрыс"))


def _voice_has_name_correction(text: str | None) -> bool:
    normalized = _voice_text_key(text)
    correction_markers = (
        "нет",
        "неа",
        "не так",
        "ошибка",
        "не правильно",
        "жок",
        "жоқ",
        "меня зовут",
        "мое имя",
        "менің атым",
        "аты",
    )
    return any(marker in normalized for marker in correction_markers)


def _voice_reply_with_name(language: str, reply_text: str, name: str | None) -> str:
    short_name = _voice_short_name(name)
    if not short_name:
        return reply_text
    prefix = _voice_disclosure_prefix(language)
    normalized_short = _voice_text_key(short_name)
    if reply_text.startswith(prefix):
        rest = reply_text[len(prefix) :].lstrip()
        if normalized_short in _voice_text_key(rest).split(" "):
            return reply_text
        return f"{prefix}{short_name}, {rest}"
    if normalized_short in _voice_text_key(reply_text).split(" "):
        return reply_text
    return f"{short_name}, {reply_text}"


def _voice_name_metadata(
    *,
    language: str,
    customer_id: str | None,
    status: str,
    value: str | None,
    source: str,
    resolved_at: str | None,
) -> dict[str, Any]:
    return {
        "voice_start_language": language,
        "customer_id": customer_id,
        "customer_name_status": status,
        "customer_name_value": value,
        "customer_name_source": source,
        "customer_name_resolved_at": resolved_at,
    }


def _persist_voice_name_state(
    session,
    *,
    voice_session: VoiceAISessionRow,
    status: str,
    value: str | None,
    source: str,
    resolved_at: str | None,
) -> None:
    voice_session.customer_name_status = status
    voice_session.customer_name_value = value
    voice_session.customer_name_source = source
    voice_session.customer_name_resolved_at = resolved_at
    link = session.execute(
        select(AsteriskCallLinkRow).where(AsteriskCallLinkRow.call_id == voice_session.call_id)
    ).scalar_one_or_none()
    if link is not None:
        link.customer_name_status = status
        link.customer_name_value = value
        link.customer_name_source = source
        link.customer_name_resolved_at = resolved_at
        link.updated_at = utc_now_iso()


def _finalize_customer_name(
    session,
    *,
    customer: Customer | None,
    customer_id: str | None,
    call_id: str,
    final_name: str | None,
    resolved_at: str,
) -> str | None:
    canonical_name = _normalize_name_candidate(final_name)
    if not canonical_name:
        return None
    if customer is None and customer_id:
        customer = session.execute(
            select(Customer).where(Customer.customer_id == customer_id)
        ).scalar_one_or_none()
    if customer is not None:
        customer.display_name = canonical_name
    identities = session.execute(
        select(CustomerExternalIdentity).where(
            CustomerExternalIdentity.customer_id == (customer.customer_id if customer else customer_id),
            CustomerExternalIdentity.channel == "voice",
        )
    ).scalars().all()
    for identity in identities:
        identity.display_name_snapshot = canonical_name
        identity.updated_at = resolved_at
    caller_number, _ = _resolve_caller_from_call(session, call_id)
    if customer is not None and caller_number:
        _ensure_voice_identity(
            session,
            customer_id=customer.customer_id,
            caller_number=caller_number,
            caller_name=canonical_name,
            now=resolved_at,
        )
    return canonical_name


def _voice_downstream_name_update(
    *,
    language: str,
    transcript_text: str,
    transcript_window: list[VoiceTranscriptSegmentRow],
    current_status: str,
    current_name: str | None,
    current_source: str,
    current_resolved_at: str | None,
    now: str,
    config,
) -> dict[str, Any]:
    status = str(current_status or "name_not_obtained").strip() or "name_not_obtained"
    name_value = _normalize_name_candidate(current_name) or (str(current_name or "").strip() or None)
    source = str(current_source or "none").strip() or "none"
    resolved_at = str(current_resolved_at or "").strip() or None
    action = "ignored"
    service_topic = _voice_has_service_topic(transcript_text)

    explicit_candidate = _voice_explicit_name_candidate(transcript_text)
    candidate, needs_followup = _extract_name_candidate(transcript_text, language)
    candidate = explicit_candidate or candidate
    candidate_key = _voice_text_key(candidate)
    current_key = _voice_text_key(name_value)
    uncertain_behavior = config.downstream.uncertain_name_behavior

    if status == "name_followup_required" and uncertain_behavior == "discard_and_collect":
        status = "name_not_obtained"
        name_value = None
        source = "none"
        resolved_at = None

    if status == "name_followup_required":
        if uncertain_behavior == "finalize_immediately" and name_value:
            action = "finalize_existing"
            candidate = name_value
        elif name_value and _voice_is_name_confirmation(transcript_text, name_value):
            action = "confirm"
            candidate = name_value
        elif candidate and name_value and candidate_key and candidate_key != current_key and (explicit_candidate or not service_topic):
            action = "correct"
        elif candidate and (explicit_candidate or not service_topic):
            action = "provide" if not name_value else "confirm"
            candidate = candidate or name_value
    elif status == "name_obtained":
        if (
            candidate
            and candidate_key
            and candidate_key != current_key
            and _voice_has_name_correction(transcript_text)
        ):
            action = "correct"
    else:
        if explicit_candidate:
            action = "provide"
        elif candidate and not needs_followup and not service_topic:
            action = "provide"

    finalizable = False
    if action == "confirm":
        finalizable = config.downstream.finalize_on_confirmation and bool(candidate or name_value)
    elif action in {"correct", "provide"}:
        finalizable = config.downstream.finalize_on_explicit_name and bool(candidate or name_value)
    elif action == "finalize_existing":
        finalizable = bool(name_value)

    if finalizable:
        status = "name_obtained"
        name_value = _normalize_name_candidate(candidate or name_value)
        if action == "finalize_existing":
            source = str(current_source or "none").strip() or "none"
        else:
            source = current_source if action == "confirm" and current_status == "name_obtained" else "voice_followup"
        resolved_at = now
    elif action in {"confirm", "correct", "provide"} and bool(candidate or name_value):
        status = "name_followup_required"
        name_value = _normalize_name_candidate(candidate or name_value)
        if action == "confirm":
            source = str(current_source or "none").strip() or "none"
            resolved_at = resolved_at or now
        else:
            source = "voice_followup"
            resolved_at = now

    inline_followup = (
        config.downstream.missing_name_behavior == "ask_inline_once"
        and status == "name_not_obtained"
        and action == "ignored"
        and not _voice_name_followup_asked(transcript_window, language, config)
        and not _voice_is_low_signal_caller_text(transcript_text)
        and not service_topic
    )
    return {
        "status": status,
        "value": name_value,
        "source": source,
        "resolved_at": resolved_at,
        "action": action,
        "finalizable": finalizable,
        "inline_followup": inline_followup,
    }


def _voice_recent_segments(
    session,
    *,
    voice_session_id: str,
    limit: int,
) -> list[VoiceTranscriptSegmentRow]:
    rows = session.execute(
        select(VoiceTranscriptSegmentRow)
        .where(VoiceTranscriptSegmentRow.session_id == voice_session_id)
        .where(VoiceTranscriptSegmentRow.is_final.is_(True))
        .order_by(VoiceTranscriptSegmentRow.id.desc())
        .limit(max(limit, 1))
    ).scalars().all()
    return list(reversed(rows))


def _voice_text_key(text: str | None) -> str:
    compact = re.sub(r"[^\w\s]+", " ", str(text or "").lower(), flags=re.UNICODE)
    return re.sub(r"\s+", " ", compact).strip()


def _voice_recent_caller_texts(
    transcript_window: list[VoiceTranscriptSegmentRow],
    *,
    limit: int = 12,
) -> list[str]:
    caller_texts = [str(segment.text or "").strip() for segment in transcript_window if segment.speaker == "caller"]
    return caller_texts[-max(limit, 1) :]


def _voice_context_texts_with_summary(
    transcript_window: list[VoiceTranscriptSegmentRow],
    context_summary: str | dict[str, Any] | None,
) -> list[str]:
    result = context_summary_context_texts(context_summary)
    result.extend(_voice_recent_caller_texts(transcript_window, limit=12))
    deduped: list[str] = []
    seen: set[str] = set()
    for text in result:
        normalized = _voice_text_key(text)
        if not normalized or normalized in seen:
            continue
        seen.add(normalized)
        deduped.append(str(text).strip())
    return deduped[-12:]


def _voice_is_low_signal_caller_text(text: str | None) -> bool:
    normalized = _voice_text_key(text)
    if not normalized:
        return True
    low_signal_phrases = {
        "алло",
        "ага",
        "да",
        "добрый день",
        "здравствуйте",
        "ладно",
        "неа",
        "нет",
        "ой",
        "ок",
        "понял",
        "поняла",
        "привет",
        "слышу",
        "слышно",
        "угу",
        "хорошо",
        "ясно",
    }
    return normalized in low_signal_phrases


def _voice_is_hearing_check_caller_text(text: str | None) -> bool:
    normalized = _voice_text_key(text)
    if not normalized:
        return False
    markers = (
        "алло ты меня слышишь",
        "вы меня слышите",
        "меня слышно",
        "меня слышишь",
        "ты меня слышишь",
        "слышишь меня",
    )
    return any(marker in normalized for marker in markers)


def _voice_is_confused_caller_text(text: str | None) -> bool:
    normalized = _voice_text_key(text)
    confusion_markers = (
        "не понял",
        "не поняла",
        "не понимаю",
        "неясно",
        "о каком",
        "каком запросе",
        "повторите",
        "что именно",
        "что вы имеете",
    )
    return any(marker in normalized for marker in confusion_markers)


def _voice_recent_clarification_count(transcript_window: list[VoiceTranscriptSegmentRow]) -> int:
    markers = (
        "в двух словах",
        "график работы какого",
        "какая услуга",
        "какой филиал",
        "назовите пожалуйста",
        "опишите пожалуйста",
        "подскажите",
        "скажите коротко",
        "уточните",
        "цель звонка",
        "что вам нужно",
        "что именно не работает",
        "чтобы помочь",
    )
    count = 0
    for segment in transcript_window:
        if segment.speaker != "assistant":
            continue
        normalized = _voice_text_key(segment.text)
        if any(marker in normalized for marker in markers):
            count += 1
    return count


def _voice_repeated_assistant_reply_count(transcript_window: list[VoiceTranscriptSegmentRow]) -> int:
    assistant_texts = [
        _voice_text_key(segment.text)
        for segment in transcript_window
        if segment.speaker == "assistant" and str(segment.text or "").strip()
    ]
    if not assistant_texts:
        return 0
    last_text = assistant_texts[-1]
    return sum(1 for text in assistant_texts[-3:] if text == last_text)


def _voice_topic_prompt(language: str, caller_texts: list[str]) -> str | None:
    context = _voice_text_key(" ".join(caller_texts))
    if not context:
        return None
    if any(marker in context for marker in ("график", "время работы", "режим работы", "часы работы", "работаете")):
        city_scope = any(
            marker in context
            for marker in (
                "алмат",
                "астан",
                "в городе",
                "город",
                "караганд",
                "кызылорд",
                "павлодар",
                "семей",
                "тараз",
                "шымкент",
            )
        )
        if city_scope:
            if language == "kz":
                return "Osy qaladagy qaysy filial nemese mekenjai qyzyqtyratynyn aitnyz."
            return "Подскажите, какой филиал или адрес в этом городе вас интересует?"
        if language == "kz":
            return "Qai filialdyn, mekendyng nemese qalanyng jumys uaqyty qyzyqtyratynyn aitnyz."
        return "Подскажите, график работы какого филиала, адреса или города вас интересует?"
    if any(marker in context for marker in ("статус", "заявк", "заказ", "обращени", "запрос")):
        if language == "kz":
            return "Otinish no'mirin nemese resimdegen telefon nomerin aitnyz."
        return "Назовите, пожалуйста, номер заявки или телефон, по которому она оформлялась."
    if any(marker in context for marker in ("тариф", "стоимост", "цен", "оплат", "оплата", "услуг")):
        if language == "kz":
            return "Qai qyzmet, tarif nemese bagasy qyzyqtyratynyn naqtylańyz."
        return "Уточните, какая услуга, тариф или стоимость вас интересует."
    if any(marker in context for marker in ("адрес", "филиал", "город", "офис", "отделени", "где находит")):
        if language == "kz":
            return "Qai filial, mekenjai nemese qala qyzyqtyratynyn aitnyz."
        return "Подскажите, какой филиал, адрес или город вас интересует."
    if any(marker in context for marker in ("не работает", "ошибка", "проблем", "сбой", "интернет", "связь")):
        if language == "kz":
            return "Bir soilemmen naqty aitynyz: ne istep turmagan?"
        return "Опишите, пожалуйста, проблему одним предложением: что именно не работает?"
    return None


def _voice_summary_slot_prompt(language: str, context_summary: str | dict[str, Any] | None) -> str | None:
    summary = load_context_summary(context_summary)
    intent = str(summary.get("active_intent") or "").strip()
    facts = summary.get("confirmed_facts") if isinstance(summary.get("confirmed_facts"), dict) else {}
    city = str(facts.get("city") or "").strip()
    branch_hint = str(facts.get("branch_hint") or "").strip()
    if intent == "schedule" and city and not branch_hint:
        if language == "kz":
            return f"{city} qalasy boiynsha qaysy filial nemese mekenjai qyzyqtyratynyn aitnyz."
        return f"По городу {city} уточните, пожалуйста, филиал или адрес."
    return None


def _voice_generic_prompt(language: str) -> str:
    if language == "kz":
        return "Jyldam komektesu ushin eki ush sozben ne kerek ekenin aitnyz: jumys uaqyty, otinish statusy, tarif nemese operator."
    return "Чтобы помочь быстрее, скажите в двух словах, что вам нужно: график работы, статус заявки, тариф или оператор."


def _voice_has_service_topic(text: str | None) -> bool:
    normalized = _voice_text_key(text)
    if not normalized:
        return False
    service_markers = (
        "график",
        "время работы",
        "режим работы",
        "статус",
        "заявк",
        "заказ",
        "обращен",
        "запрос",
        "тариф",
        "стоимост",
        "цен",
        "оплат",
        "услуг",
        "адрес",
        "филиал",
        "город",
        "офис",
        "отделени",
        "не работает",
        "ошибк",
        "проблем",
        "сбой",
        "интернет",
        "связь",
        "оператор",
        "менеджер",
        "сотрудник",
        "компан",
        "подключ",
        "доставк",
    )
    return any(marker in normalized for marker in service_markers)


def _voice_is_off_domain_request(text: str | None) -> bool:
    normalized = _voice_text_key(text)
    if not normalized or _voice_has_service_topic(normalized):
        return False
    broad_markers = (
        "ядерн",
        "реактор",
        "кондиционер",
        "компрессор",
        "испарител",
        "конденсатор",
        "космос",
        "планет",
        "математ",
        "теорем",
        "физик",
        "хими",
        "биолог",
        "истори",
        "рецепт",
        "борщ",
        "салат",
        "анекдот",
        "стих",
        "програм",
        "python",
        "java",
        "javascript",
        "погод",
    )
    if any(marker in normalized for marker in broad_markers):
        return True
    broad_openers = (
        "как работает",
        "как устроен",
        "что такое",
        "объясни",
        "расскажи про",
        "почему",
    )
    return any(normalized.startswith(prefix) for prefix in broad_openers)


def _voice_off_domain_reply(language: str) -> tuple[str, str]:
    if language == "kz":
        return (
            "Men kompaniyamyzdyn qyzmetteri men otinishteri boiynsha komek bere alamyn. "
            "Eger suraq bizdin qyzmetke qatysty bolsa, qysqasha naqtylaңыз. Qalasaңыз, operatorga qosamyn.",
            "AI qongyraudyn taqyrybyn kompaniya qyzmetteri sheginde naqtylaudy usyndy.",
        )
    return (
        "Я помогу по вопросам наших услуг и обращений. Если вопрос связан с нашей компанией, скажите коротко, что именно нужно. Если хотите, сразу соединю с оператором.",
        "AI мягко вернул разговор к вопросам компании и предложил перевод на оператора.",
    )


def _voice_confusion_prompt(language: str, caller_texts: list[str]) -> str:
    topic_prompt = _voice_topic_prompt(language, caller_texts)
    if topic_prompt:
        if language == "kz":
            return f"Naqtylap alaiyn. {topic_prompt}"
        return f"Подскажите точнее. {topic_prompt}"
    if language == "kz":
        return "Naqtylap alaiyn: jumys uaqyty, otinish statusy, tarif nemese operator degenniń birin aitnyz."
    return "Подскажите точнее: график работы, статус заявки, тариф или оператор."


def _voice_loop_handoff(language: str) -> tuple[str, str, str]:
    if language == "kz":
        return (
            "Men suraqty birneshe ret naqtylap korgendim. Sizdi kuttirmey, tiri operatorga qosamyn.",
            "Voice AI birneshe naqtylau areketinen keyin suraqty anyqtay almady.",
            "AI birneshe naqtylau areketinen keyin qongyraudyn maqratyn operatorga berdi.",
        )
    return (
        "Похоже, я не смог точно уточнить вопрос в голосовом режиме. Чтобы не задерживать вас, перевожу на оператора.",
        "Voice AI не смог уточнить запрос после нескольких попыток без прогресса.",
        "AI не смог уточнить цель звонка после нескольких попыток и перевел клиента на оператора.",
    )


def _voice_recent_timeline(
    session,
    *,
    interaction_id: str,
    limit: int = 6,
) -> list[dict[str, Any]]:
    rows = session.execute(
        select(InteractionTimeline)
        .where(InteractionTimeline.interaction_id == interaction_id)
        .order_by(InteractionTimeline.id.desc())
        .limit(max(limit, 1))
    ).scalars().all()
    result: list[dict[str, Any]] = []
    for row in reversed(rows):
        try:
            metadata = json.loads(row.metadata_json or "{}")
        except Exception:
            metadata = {}
        result.append({"timestamp": row.timestamp, "action": row.action, "metadata": metadata})
    return result


def _append_timeline(interaction_id: str, action: str, metadata: dict[str, Any]) -> None:
    app = _app()
    try:
        app._interaction_request(
            "POST",
            f"/interactions/{interaction_id}/timeline",
            payload={"action": action, "metadata": metadata},
        )
    except Exception:
        session = get_session()
        try:
            app._push_timeline(session, interaction_id, action, metadata)
            session.commit()
        finally:
            session.close()


def _record_voice_ai_turn(
    session,
    *,
    ai_session_id: str,
    interaction_id: str,
    role: str,
    source_type: str,
    text: str,
    payload: dict[str, Any],
    model: str | None = None,
    latency_ms: int | None = None,
) -> None:
    session.add(
        AITurnRow(
            turn_id=new_id("ait"),
            session_id=ai_session_id,
            thread_id=None,
            interaction_id=interaction_id,
            role=role,
            source_type=source_type,
            text=text,
            payload_json=json.dumps(payload, ensure_ascii=False),
            model=model,
            finish_reason="stop",
            latency_ms=latency_ms,
            created_at=utc_now_iso(),
        )
    )


def _ensure_voice_ai_session(
    session,
    *,
    voice_session: VoiceAISessionRow,
    interaction: Interaction,
    customer_id: str | None,
    language: str,
    agent_profile: str,
) -> tuple[AISessionRow, bool]:
    existing = None
    if str(voice_session.ai_session_id or "").strip():
        existing = session.execute(
            select(AISessionRow).where(AISessionRow.session_id == voice_session.ai_session_id)
        ).scalar_one_or_none()
    if (
        existing
        and existing.status not in {"closed", "error"}
        and str(existing.agent_profile or "").strip() == str(agent_profile or "").strip()
    ):
        existing.call_id = voice_session.call_id
        existing.interaction_id = interaction.interaction_id
        existing.customer_id = customer_id
        existing.language = language
        existing.agent_profile = agent_profile
        existing.updated_at = utc_now_iso()
        return existing, False
    if existing and existing.status not in {"closed", "error"}:
        now = utc_now_iso()
        existing.status = "closed"
        existing.closed_at = now
        existing.updated_at = now

    now = utc_now_iso()
    ai_session = AISessionRow(
        session_id=new_id("ais"),
        channel="voice",
        call_id=voice_session.call_id,
        thread_id=None,
        interaction_id=interaction.interaction_id,
        customer_id=customer_id,
        agent_profile=agent_profile,
        language=language,
        status="active",
        summary_text="",
        last_user_message_id=None,
        last_ai_message_id=None,
        handoff_reason=None,
        created_at=now,
        updated_at=now,
        closed_at=None,
    )
    session.add(ai_session)
    voice_session.ai_session_id = ai_session.session_id
    return ai_session, True


def _voice_policy_mode() -> str:
    return persona.voice_policy_mode()


def _voice_reply_phase(metadata: dict[str, Any] | None = None) -> str:
    payload = metadata if isinstance(metadata, dict) else {}
    return str(payload.get("reply_phase") or "final").strip().lower() or "final"


def _voice_v2_enabled(metadata: dict[str, Any] | None = None) -> bool:
    payload = metadata if isinstance(metadata, dict) else {}
    if bool(payload.get("voice_v2_enabled")):
        return True
    return _voice_policy_mode() in {"v2_fast_conversational", "v2_streaming_duplex"}


def _voice_early_intent_bucket(text: str) -> str:
    normalized = " ".join(str(text or "").strip().lower().split())
    if not normalized:
        return "unknown"
    if any(token in normalized for token in ("оператор", "оператором", "человек", "менеджер", "сотрудник")):
        return "operator_request"
    if any(token in normalized for token in ("график", "распис", "жұмыс")):
        return "schedule"
    if any(token in normalized for token in ("адрес", "филиал", "офис", "мекен", "қайда")):
        return "address"
    if any(token in normalized for token in ("тариф", "цена", "стоимость", "баға", "сколько стоит")):
        return "price"
    if any(token in normalized for token in ("статус", "заявк", "заказ", "өтінім")):
        return "status"
    if any(token in normalized for token in ("не работает", "ошибка", "проблем", "істемей")):
        return "problem"
    return "unknown"


def _voice_ack_kind_for_intent(intent: str) -> str:
    if intent == "operator_request":
        return "handoff"
    if intent in {"schedule", "address", "price", "status", "problem"}:
        return "understanding"
    return "generic"


def _voice_compact_reply_text(text: str, *, language: str) -> str:
    normalized = " ".join(str(text or "").strip().split())
    if not normalized:
        return normalized
    if len(normalized) <= 180:
        return normalized
    parts = [segment.strip() for segment in re.split(r"(?<=[.!?])\s+", normalized) if segment.strip()]
    if parts:
        compact = " ".join(parts[:2]).strip()
        if len(compact) <= 180:
            return compact
    shortened = normalized[:177].rsplit(" ", 1)[0].strip()
    if not shortened:
        shortened = normalized[:177].strip()
    ending = "…" if language == "kz" else "..."
    return f"{shortened}{ending}"


def _voice_caller_context_before_current(caller_texts: list[str], transcript_text: str) -> list[str]:
    if not caller_texts:
        return []
    current_key = _voice_text_key(transcript_text)
    if current_key and _voice_text_key(caller_texts[-1]) == current_key:
        return caller_texts[:-1]
    return caller_texts


def _voice_service_context_texts(caller_texts: list[str]) -> list[str]:
    return [text for text in caller_texts if _voice_has_service_topic(text)]


def _voice_is_midcall_greeting_reply(text: str | None) -> bool:
    normalized = _voice_text_key(text)
    if not normalized:
        return False
    greeting_markers = ("здравствуйте", "сәлеметсіз", "сәлем")
    followup_markers = (
        "чем помочь",
        "как я могу помочь",
        "коротко расскажите",
        "о чем именно",
        "что именно",
        "что вас интересует",
        "қалай көмектесе",
    )
    return any(marker in normalized for marker in greeting_markers) and any(
        marker in normalized for marker in followup_markers
    )


def _voice_contains_false_lookup_promise(text: str | None) -> bool:
    normalized = _voice_text_key(text)
    if not normalized:
        return False
    markers = (
        "сейчас уточню",
        "я уточню",
        "уточню",
        "минуточ",
        "подождите",
        "подождите пожалуйста",
        "проверю",
        "сейчас проверю",
        "я проверю",
        "посмотрю",
        "сейчас посмотрю",
    )
    return any(marker in normalized for marker in markers)


def _voice_hearing_check_reply(language: str, caller_texts: list[str]) -> str:
    followup = _voice_topic_prompt(language, caller_texts) or _voice_generic_prompt(language)
    if language == "kz":
        return f"Ia, estip turmyn. {followup}"
    return f"Да, вас слышу. {followup}"


def _voice_postprocess_reply_text(
    *,
    language: str,
    transcript_text: str,
    transcript_window: list[VoiceTranscriptSegmentRow],
    context_summary: str | dict[str, Any] | None = None,
    reply_text: str,
    kb_results: list[Any],
    needs_handoff: bool,
) -> str:
    normalized_reply = str(reply_text or "").strip()
    if not normalized_reply or needs_handoff:
        return normalized_reply
    caller_texts = _voice_context_texts_with_summary(transcript_window, context_summary)
    prior_caller_texts = _voice_caller_context_before_current(caller_texts, transcript_text)
    active_topic_texts = _voice_service_context_texts(prior_caller_texts or caller_texts)
    active_topic_prompt = _voice_topic_prompt(language, active_topic_texts) if active_topic_texts else None
    if _voice_is_hearing_check_caller_text(transcript_text):
        return _voice_hearing_check_reply(language, prior_caller_texts or caller_texts)
    if _voice_contains_false_lookup_promise(normalized_reply) and not kb_results:
        return active_topic_prompt or _voice_topic_prompt(language, caller_texts) or _voice_generic_prompt(language)
    if _voice_is_midcall_greeting_reply(normalized_reply) and any(
        segment.speaker == "assistant" for segment in transcript_window
    ):
        context_texts = prior_caller_texts if _voice_is_low_signal_caller_text(transcript_text) else caller_texts
        return _voice_topic_prompt(language, context_texts) or _voice_generic_prompt(language)
    if (
        active_topic_prompt
        and not _voice_has_service_topic(transcript_text)
        and (
            "тариф" in _voice_text_key(normalized_reply)
            or "услуг" in _voice_text_key(normalized_reply)
            or "как я могу помочь" in _voice_text_key(normalized_reply)
        )
    ):
        return active_topic_prompt
    return normalized_reply


def _voice_v2_metadata(
    transcript_text: str,
    request_metadata: dict[str, Any] | None = None,
) -> dict[str, Any]:
    if not _voice_v2_enabled(request_metadata):
        return {}
    payload = request_metadata if isinstance(request_metadata, dict) else {}
    early_intent = _voice_early_intent_bucket(transcript_text)
    metadata: dict[str, Any] = {
        "voice_v2_enabled": True,
        "early_intent": early_intent,
        "ack_kind": _voice_ack_kind_for_intent(early_intent),
    }
    for key in ("response_plan_id", "playback_generation", "partial_transcript"):
        value = payload.get(key)
        if value not in {None, ""}:
            metadata[key] = value
    return metadata


def _voice_early_plan(
    *,
    language: str,
    transcript_text: str,
    context_summary: str | dict[str, Any] | None = None,
    request_metadata: dict[str, Any] | None = None,
) -> dict[str, Any]:
    v2_metadata = _voice_v2_metadata(transcript_text, request_metadata)
    payload = request_metadata if isinstance(request_metadata, dict) else {}
    early_intent = str(payload.get("early_intent") or v2_metadata.get("early_intent") or "").strip()
    lower_text = str(transcript_text or "").strip().lower()
    if _app()._looks_like_human_request(lower_text) or _app()._is_sensitive_request(lower_text):
        return {
            "language": language,
            "intent": "handoff_request",
            "reply_text": _voice_handoff_reply(language),
            "confidence": 0.25,
            "needs_handoff": True,
            "handoff_reason": "Запрос требует участия живого оператора.",
            "case_action": "keep_open",
            "kb_refs": [],
            "summary_text": "AI заранее распознал необходимость подключить оператора.",
            "model": "voice_early_plan",
            "latency_ms": 1,
            "metadata": {
                **v2_metadata,
                "reply_phase": "early_plan",
            },
        }
    if _voice_is_off_domain_request(transcript_text):
        reply_text, summary_text = _voice_off_domain_reply(language)
        return {
            "language": language,
            "intent": "clarification",
            "reply_text": _voice_compact_reply_text(reply_text, language=language),
            "confidence": 0.6,
            "needs_handoff": False,
            "handoff_reason": None,
            "case_action": "keep_open",
            "kb_refs": [],
            "summary_text": summary_text,
            "model": "voice_early_plan_off_domain",
            "latency_ms": 1,
            "metadata": {
                **v2_metadata,
                "reply_phase": "early_plan",
            },
        }
    if early_intent in {"schedule", "address", "price", "status", "problem", "operator_request"}:
        reply_text = _voice_summary_slot_prompt(language, context_summary) or _voice_topic_prompt(language, [transcript_text])
        if early_intent == "operator_request":
            reply_text = _voice_handoff_reply(language)
        if not reply_text and language == "kz":
            if early_intent == "schedule":
                reply_text = "Qai filialdyn, mekenjaidyn nemese qalanyng jumys uaqyty qyzyqtyratynyn aitnyz."
            elif early_intent == "address":
                reply_text = "Qai filial, mekenjai nemese qala qyzyqtyratynyn aitnyz."
            elif early_intent == "price":
                reply_text = "Qai qyzmet, tarif nemese bagasy qyzyqtyratynyn naqtylanyz."
            elif early_intent == "status":
                reply_text = "Otinish nomirin nemese resimdegen telefon nomirin aitnyz."
            elif early_intent == "problem":
                reply_text = "Bir soilemmen naqty aitynyz: ne istep turmagan?"
        if not reply_text:
            if early_intent == "schedule":
                reply_text = "\u041f\u043e\u0434\u0441\u043a\u0430\u0436\u0438\u0442\u0435, \u0433\u0440\u0430\u0444\u0438\u043a \u0440\u0430\u0431\u043e\u0442\u044b \u043a\u0430\u043a\u043e\u0433\u043e \u0444\u0438\u043b\u0438\u0430\u043b\u0430, \u0430\u0434\u0440\u0435\u0441\u0430 \u0438\u043b\u0438 \u0433\u043e\u0440\u043e\u0434\u0430 \u0432\u0430\u0441 \u0438\u043d\u0442\u0435\u0440\u0435\u0441\u0443\u0435\u0442?"
            elif early_intent == "address":
                reply_text = "\u041f\u043e\u0434\u0441\u043a\u0430\u0436\u0438\u0442\u0435, \u043a\u0430\u043a\u043e\u0439 \u0444\u0438\u043b\u0438\u0430\u043b, \u0430\u0434\u0440\u0435\u0441 \u0438\u043b\u0438 \u0433\u043e\u0440\u043e\u0434 \u0432\u0430\u0441 \u0438\u043d\u0442\u0435\u0440\u0435\u0441\u0443\u0435\u0442."
            elif early_intent == "price":
                reply_text = "\u0423\u0442\u043e\u0447\u043d\u0438\u0442\u0435, \u043a\u0430\u043a\u0430\u044f \u0443\u0441\u043b\u0443\u0433\u0430, \u0442\u0430\u0440\u0438\u0444 \u0438\u043b\u0438 \u0441\u0442\u043e\u0438\u043c\u043e\u0441\u0442\u044c \u0432\u0430\u0441 \u0438\u043d\u0442\u0435\u0440\u0435\u0441\u0443\u0435\u0442."
            elif early_intent == "status":
                reply_text = "\u041d\u0430\u0437\u043e\u0432\u0438\u0442\u0435, \u043f\u043e\u0436\u0430\u043b\u0443\u0439\u0441\u0442\u0430, \u043d\u043e\u043c\u0435\u0440 \u0437\u0430\u044f\u0432\u043a\u0438 \u0438\u043b\u0438 \u0442\u0435\u043b\u0435\u0444\u043e\u043d, \u043f\u043e \u043a\u043e\u0442\u043e\u0440\u043e\u043c\u0443 \u043e\u043d\u0430 \u043e\u0444\u043e\u0440\u043c\u043b\u044f\u043b\u0430\u0441\u044c."
            elif early_intent == "problem":
                reply_text = "\u041e\u043f\u0438\u0448\u0438\u0442\u0435, \u043f\u043e\u0436\u0430\u043b\u0443\u0439\u0441\u0442\u0430, \u043f\u0440\u043e\u0431\u043b\u0435\u043c\u0443 \u043e\u0434\u043d\u0438\u043c \u043f\u0440\u0435\u0434\u043b\u043e\u0436\u0435\u043d\u0438\u0435\u043c: \u0447\u0442\u043e \u0438\u043c\u0435\u043d\u043d\u043e \u043d\u0435 \u0440\u0430\u0431\u043e\u0442\u0430\u0435\u0442?"
        if reply_text:
            return {
                "language": language,
                "intent": "handoff_request" if early_intent == "operator_request" else "clarification",
                "reply_text": _voice_compact_reply_text(reply_text, language=language),
                "confidence": 0.62,
                "needs_handoff": early_intent == "operator_request",
                "handoff_reason": (
                    "Р—Р°РїСЂРѕСЃ С‚СЂРµР±СѓРµС‚ СѓС‡Р°СЃС‚РёСЏ Р¶РёРІРѕРіРѕ РѕРїРµСЂР°С‚РѕСЂР°."
                    if early_intent == "operator_request"
                    else None
                ),
                "case_action": "keep_open",
                "kb_refs": [],
                "summary_text": "Early domain plan is prepared.",
                "model": "voice_early_plan_domain",
                "latency_ms": 1,
                "metadata": {
                    **v2_metadata,
                    "reply_phase": "early_plan",
                    "early_intent": early_intent,
                },
            }
    return {
        "language": language,
        "intent": "clarification",
        "reply_text": "",
        "confidence": 0.45,
        "needs_handoff": False,
        "handoff_reason": None,
        "case_action": "keep_open",
        "kb_refs": [],
        "summary_text": "Early plan is prepared.",
        "model": "voice_early_plan",
        "latency_ms": 1,
        "metadata": {
            **v2_metadata,
            "reply_phase": "early_plan",
        },
    }


def _voice_llm_prompt_messages(
    *,
    language: str,
    customer: Customer | None,
    interaction: Interaction,
    transcript_text: str,
    transcript_window: list[VoiceTranscriptSegmentRow],
    conversation_summary_text: str = "",
    kb_results: list[Any],
    name_value: str | None,
    name_status: str | None,
    operator_config: Any | None = None,
) -> list[dict[str, str]]:
    app = _app()
    history = [
        {
            "speaker": segment.speaker,
            "text": segment.text,
            "sequence_no": segment.sequence_no,
            "source_type": segment.source_type,
            "interrupted": bool(segment.barge_in_interrupted),
            "created_at": segment.created_at,
        }
        for segment in transcript_window[-12:]
    ]
    kb_context = [
        {
            "article_id": article.article_id,
            "title": article.title,
            "snippet": app._article_snippet(article, limit=240),
        }
        for article in kb_results[:3]
    ]
    payload = {
        "customer": {
            "customer_id": customer.customer_id if customer else interaction.customer_id,
            "display_name": customer.display_name if customer else None,
            "name_status": name_status,
            "name_value": name_value,
            "channel": "voice",
        },
        "interaction": {
            "interaction_id": interaction.interaction_id,
            "status": interaction.status,
            "queue_id": interaction.queue_id,
            "subject": interaction.subject,
        },
        "voice_turn": {
            "last_user_text": transcript_text,
            "language": language,
        },
        "conversation_summary": conversation_summary_text,
        "kb_results": kb_context,
        "history": history,
    }
    system_prompt = persona.operator_system_prompt(
        language=language,
        channel_label="Voice",
        is_voice=True,
        config=operator_config,
    )
    if name_status in ("name_not_obtained", "name_followup_required"):
        system_prompt += (
            " The user's name is not yet obtained. If the user explicitly provided their name in this turn, "
            "extract it into the `extracted_name` JSON field. Extract ONLY the actual name, not surrounding words "
            "like commands or verbs (e.g. from 'Меня зовут Ернор. Перезаписать.' extract only 'Ернор'). "
            "If they did not provide a name or just stated a problem "
            "(e.g., 'У меня не работает интернет'), set `extracted_name` to null and politely ask for their name "
            "in your `reply_text` before assisting."
        )
    elif name_status == "name_obtained" and name_value:
        system_prompt += (
            f" The customer's name is currently recorded as '{name_value}'. "
            "If the user explicitly corrects their name in this turn (e.g. 'Нет, меня зовут X' or 'Моё имя Y'), "
            "extract the corrected name into `extracted_name`. Extract ONLY the actual name. "
            "Otherwise set `extracted_name` to null."
        )

    return [
        {
            "role": "system",
            "content": system_prompt,
        },
        {"role": "user", "content": json.dumps(payload, ensure_ascii=False)},
    ]


def _voice_llm_decision(
    *,
    language: str,
    customer: Customer | None,
    interaction: Interaction,
    transcript_text: str,
    transcript_window: list[VoiceTranscriptSegmentRow],
    conversation_summary_text: str = "",
    kb_results: list[Any],
    name_value: str | None,
    name_status: str | None,
    operator_config: Any | None = None,
) -> dict[str, Any] | None:
    app = _app()
    if _voice_policy_mode() not in {"llm_guarded", "v2_fast_conversational", "v2_streaming_duplex"}:
        return None
    if app._ai_provider() != "openai_compatible":
        return None
    try:
        raw = app._request_structured_model_decision(
            _voice_llm_prompt_messages(
                language=language,
                customer=customer,
                interaction=interaction,
                transcript_text=transcript_text,
                transcript_window=transcript_window,
                conversation_summary_text=conversation_summary_text,
                kb_results=kb_results,
                name_value=name_value,
                name_status=name_status,
                operator_config=operator_config,
            ),
            timeout_seconds=app._ai_voice_timeout_seconds(),
        )
    except Exception:
        return None
    decision = app._sanitize_decision(raw, fallback_language=language)
    if not str(decision.get("reply_text") or "").strip():
        decision["reply_text"] = _voice_generic_prompt(language)
    if decision.get("needs_handoff") and not decision.get("handoff_reason"):
        decision["handoff_reason"] = "Требуется участие живого оператора."
    if decision.get("needs_handoff") and not str(decision.get("reply_text") or "").strip():
        decision["reply_text"] = _voice_handoff_reply(language)
    return {
        "language": decision["language"],
        "intent": decision["intent"],
        "reply_text": str(decision["reply_text"] or "").strip(),
        "confidence": float(decision["confidence"] or 0.0),
        "needs_handoff": bool(decision["needs_handoff"]),
        "handoff_reason": decision["handoff_reason"],
        "case_action": decision["case_action"],
        "kb_refs": decision["kb_refs"],
        "summary_text": (
            str(decision.get("reply_text") or "").strip()
            or str(decision.get("handoff_reason") or "").strip()
            or "AI подготовил ответ операторским стилем."
        ),
        "model": decision["_model"],
        "latency_ms": decision["_latency_ms"],
    }


def _voice_decision(
    *,
    language: str,
    customer: Customer | None,
    interaction: Interaction,
    transcript_text: str,
    transcript_window: list[VoiceTranscriptSegmentRow],
    context_summary: str | dict[str, Any] | None = None,
    kb_results: list[Any],
    disclosure_required: bool,
    customer_name_value: str | None = None,
    customer_name_status: str | None = None,
    request_metadata: dict[str, Any] | None = None,
    operator_config: Any | None = None,
) -> dict[str, Any]:
    app = _app()
    normalized = str(transcript_text or "").strip()
    lower_text = normalized.lower()
    caller_texts = _voice_context_texts_with_summary(transcript_window, context_summary)
    model = app._ai_model()
    v2_metadata = _voice_v2_metadata(transcript_text, request_metadata)
    reply_phase = _voice_reply_phase(request_metadata)
    conversation_summary_text = render_context_summary_text(context_summary)

    if reply_phase == "early_plan":
        return _voice_early_plan(
            language=language,
            transcript_text=transcript_text,
            context_summary=context_summary,
            request_metadata=request_metadata,
        )

    if persona.is_identity_request(normalized):
        decision = {
            "language": language,
            "intent": "identity_question",
            "reply_text": persona.identity_reply(language, operator_config),
            "confidence": 0.98,
            "needs_handoff": False,
            "handoff_reason": None,
            "case_action": "keep_open",
            "kb_refs": [],
            "summary_text": "AI ответил на вопрос о своей личности.",
            "model": "operator_identity_policy",
            "latency_ms": 1,
        }
        if v2_metadata:
            decision["reply_text"] = _voice_compact_reply_text(decision["reply_text"], language=language)
            decision["metadata"] = {**v2_metadata, "reply_phase": "final"}
        return decision

    if app._looks_like_human_request(lower_text) or app._is_sensitive_request(lower_text):
        return {
            "language": language,
            "intent": "handoff_request",
            "reply_text": _voice_handoff_reply(language),
            "confidence": 0.25,
            "needs_handoff": True,
            "handoff_reason": "Запрос требует участия живого оператора.",
            "case_action": "keep_open",
            "kb_refs": [],
            "summary_text": "AI собрал первичный контекст и запросил живого оператора.",
            "model": model,
            "latency_ms": 1,
        }

    if _voice_is_hearing_check_caller_text(normalized):
        prior_caller_texts = _voice_caller_context_before_current(caller_texts, transcript_text)
        decision = {
            "language": language,
            "intent": "clarification",
            "reply_text": _voice_hearing_check_reply(language, prior_caller_texts or caller_texts),
            "confidence": 0.66,
            "needs_handoff": False,
            "handoff_reason": None,
            "case_action": "keep_open",
            "kb_refs": [],
            "summary_text": "AI подтвердил, что слышит клиента, и продолжил активный сценарий без сброса диалога.",
            "model": "voice_policy_hearing_check",
            "latency_ms": 1,
        }
        if v2_metadata:
            decision["reply_text"] = _voice_compact_reply_text(decision["reply_text"], language=language)
            decision["metadata"] = {**v2_metadata, "reply_phase": "final"}
        return decision

    clarification_count = _voice_recent_clarification_count(transcript_window)
    repeated_reply_count = _voice_repeated_assistant_reply_count(transcript_window)
    recent_caller_window = caller_texts[-3:]
    low_signal_count = sum(1 for text in recent_caller_window if _voice_is_low_signal_caller_text(text))
    caller_confused = _voice_is_confused_caller_text(normalized)
    if clarification_count >= 4 or (
        clarification_count >= 3 and (caller_confused or low_signal_count >= 2 or repeated_reply_count >= 2)
    ):
        reply_text, handoff_reason, summary_text = _voice_loop_handoff(language)
        return {
            "language": language,
            "intent": "handoff_request",
            "reply_text": reply_text,
            "confidence": 0.34,
            "needs_handoff": True,
            "handoff_reason": handoff_reason,
            "case_action": "keep_open",
            "kb_refs": [],
            "summary_text": summary_text,
            "model": model,
            "latency_ms": 1,
        }

    if not kb_results and _voice_is_off_domain_request(normalized):
        reply_text, summary_text = _voice_off_domain_reply(language)
        decision = {
            "language": language,
            "intent": "clarification",
            "reply_text": reply_text,
            "confidence": 0.58,
            "needs_handoff": False,
            "handoff_reason": None,
            "case_action": "keep_open",
            "kb_refs": [],
            "summary_text": summary_text,
            "model": "voice_policy_off_domain",
            "latency_ms": 1,
        }
        if v2_metadata:
            decision["reply_text"] = _voice_compact_reply_text(decision["reply_text"], language=language)
            decision["metadata"] = v2_metadata
        return decision

    summary_slot_prompt = _voice_summary_slot_prompt(language, context_summary)
    if not kb_results and summary_slot_prompt:
        decision = {
            "language": language,
            "intent": "clarification",
            "reply_text": summary_slot_prompt,
            "confidence": 0.72,
            "needs_handoff": False,
            "handoff_reason": None,
            "case_action": "keep_open",
            "kb_refs": [],
            "summary_text": "AI продолжил активный сценарий из conversation summary и уточнил недостающий слот.",
            "model": "voice_policy_context_summary",
            "latency_ms": 1,
        }
        if v2_metadata:
            decision["reply_text"] = _voice_compact_reply_text(decision["reply_text"], language=language)
            decision["metadata"] = {**v2_metadata, "reply_phase": "final"}
        return decision

    llm_decision = _voice_llm_decision(
        language=language,
        customer=customer,
        interaction=interaction,
        transcript_text=transcript_text,
        transcript_window=transcript_window,
        conversation_summary_text=conversation_summary_text,
        kb_results=kb_results,
        name_value=customer_name_value or (customer.display_name if customer else None),
        name_status=customer_name_status,
        operator_config=operator_config,
    )
    if llm_decision is not None:
        if v2_metadata:
            llm_decision["reply_text"] = _voice_compact_reply_text(
                str(llm_decision.get("reply_text") or ""),
                language=language,
            )
            llm_decision["metadata"] = {
                **(llm_decision.get("metadata") or {}),
                **v2_metadata,
                "reply_phase": "final",
            }
        return llm_decision

    if kb_results:
        article = kb_results[0]
        snippet = app._article_snippet(article, limit=220)
        decision = {
            "language": language,
            "intent": "kb_answer",
            "reply_text": (
                f"Қысқаша айтайын: {snippet}"
                if language == "kz"
                else f"Коротко подскажу: {snippet}"
            ),
            "confidence": 0.78,
            "needs_handoff": False,
            "handoff_reason": None,
            "case_action": "keep_open",
            "kb_refs": [article.article_id],
            "summary_text": "AI дал ответ по доступному контексту.",
            "model": "voice_policy_fallback",
            "latency_ms": 1,
        }
        if v2_metadata:
            decision["reply_text"] = _voice_compact_reply_text(decision["reply_text"], language=language)
            decision["metadata"] = {**v2_metadata, "reply_phase": "final"}
        return decision

    reply_text = _voice_confusion_prompt(language, caller_texts) if caller_confused else (
        _voice_topic_prompt(language, caller_texts) or _voice_generic_prompt(language)
    )
    decision = {
        "language": language,
        "intent": "clarification",
        "reply_text": reply_text,
        "confidence": 0.62,
        "needs_handoff": False,
        "handoff_reason": None,
        "case_action": "keep_open",
        "kb_refs": [],
        "summary_text": "AI уточняет цель звонка и собирает контекст.",
        "model": "voice_policy_fallback",
        "latency_ms": 1,
    }
    if v2_metadata:
        decision["reply_text"] = _voice_compact_reply_text(decision["reply_text"], language=language)
        decision["metadata"] = {**v2_metadata, "reply_phase": "final"}
    return decision


def start_voice_session(session_id: str, payload: VoiceAIStartIn) -> VoiceAIStartOut:
    app = _app()
    session = get_session()
    try:
        voice_session = session.execute(
            select(VoiceAISessionRow).where(VoiceAISessionRow.session_id == session_id)
        ).scalar_one_or_none()
        if voice_session is None:
            raise HTTPException(status_code=404, detail="Voice AI session not found")
        interaction = session.execute(
            select(Interaction).where(Interaction.interaction_id == payload.interaction_id)
        ).scalar_one_or_none()
        if interaction is None:
            raise HTTPException(status_code=404, detail="Interaction not found")

        metadata = payload.metadata if isinstance(payload.metadata, dict) else {}
        language = _voice_start_language(
            payload.language_hint,
            metadata,
            voice_session.voice_start_language or voice_session.language,
        )
        config = load_effective_voice_name_collection_config(session)
        operator_config = app._load_ai_operator_config(session)
        customer_id = payload.customer_id or _resolve_or_create_voice_customer_id(
            session,
            interaction=interaction,
            call_id=payload.call_id,
        )
        customer = None
        if customer_id:
            customer = session.execute(
                select(Customer).where(Customer.customer_id == customer_id)
            ).scalar_one_or_none()
        ai_session, created = _ensure_voice_ai_session(
            session,
            voice_session=voice_session,
            interaction=interaction,
            customer_id=customer_id,
            language=language,
            agent_profile=payload.agent_profile or voice_session.agent_profile,
        )
        now = utc_now_iso()
        voice_session.customer_id = customer_id
        voice_session.language = language
        voice_session.status = "greeting"
        voice_session.updated_at = now
        if customer and not interaction.customer_id:
            interaction.customer_id = customer.customer_id
            interaction.updated_at = now
        if _voice_start_stage(payload.agent_profile or voice_session.agent_profile, metadata):
            caller_number, caller_name = _resolve_caller_from_call(session, payload.call_id)
            downstream_queue_code, downstream_queue_id = _voice_start_downstream_queue(metadata, voice_session)
            trusted_name = customer.display_name if _display_name_looks_trusted(customer, caller_number, caller_name) else None

            def _complete_voice_start_handoff(
                *,
                status: str,
                value: str | None,
                source: str,
                summary_text: str,
            ) -> VoiceAIStartOut:
                result = VoiceStartResult(
                    language=language,
                    customer_id=customer_id,
                    customer_name_status=status,
                    customer_name_value=value,
                    customer_name_source=source,
                    downstream_queue_id=downstream_queue_id,
                    downstream_queue_code=downstream_queue_code,
                    resolved_at=now,
                )
                _persist_voice_start_result(
                    session,
                    voice_session=voice_session,
                    interaction=interaction,
                    result=result,
                )
                voice_session.status = "handoff_requested"
                ai_session.status = "handoff_required"
                ai_session.summary_text = summary_text
                ai_session.handoff_reason = "voice_start_completed"
                ai_session.updated_at = now
                session.commit()
                if created:
                    _append_timeline(
                        interaction.interaction_id,
                        "ai.session_started",
                        {
                            "call_id": payload.call_id,
                            "voice_session_id": voice_session.session_id,
                            "ai_session_id": ai_session.session_id,
                            "language": language,
                        },
                    )
                response_metadata = _voice_start_result_metadata(result)
                return VoiceAIStartOut(
                    session_id=ai_session.session_id,
                    language=language,
                    greeting_text="",
                    disclosure_required=False,
                    needs_handoff=True,
                    handoff_reason="voice_start_completed",
                    summary_text=summary_text,
                    start_result=result,
                    metadata=response_metadata,
                )

            if not config.enabled:
                return _complete_voice_start_handoff(
                    status="name_not_obtained",
                    value=None,
                    source="none",
                    summary_text="Voice start name collection is disabled and handed off without a name.",
                )

            if trusted_name and config.start.known_customer_behavior == "trust_and_handoff":
                return _complete_voice_start_handoff(
                    status="name_obtained",
                    value=trusted_name,
                    source="known_customer",
                    summary_text="Voice start completed using a trusted known customer name.",
                )

            if trusted_name and config.start.known_customer_behavior == "confirm_in_downstream":
                return _complete_voice_start_handoff(
                    status="name_followup_required",
                    value=trusted_name,
                    source="known_customer",
                    summary_text="Voice start handed off a known customer name for downstream confirmation.",
                )

            should_ask_on_start = False
            if config.start.ask_name_on_start:
                if trusted_name:
                    should_ask_on_start = config.start.known_customer_behavior == "ask_on_start"
                else:
                    should_ask_on_start = config.start.unknown_customer_behavior == "ask_on_start"

            if not should_ask_on_start:
                return _complete_voice_start_handoff(
                    status="name_not_obtained",
                    value=None,
                    source="none",
                    summary_text="Voice start skipped name collection and handed off without a name.",
                )

            voice_session.status = "greeting"
            ai_session.status = "active"
            ai_session.summary_text = ""
            ai_session.handoff_reason = None
            ai_session.updated_at = now
            session.commit()
            if created:
                _append_timeline(
                    interaction.interaction_id,
                    "ai.session_started",
                    {
                        "call_id": payload.call_id,
                        "voice_session_id": voice_session.session_id,
                        "ai_session_id": ai_session.session_id,
                        "language": language,
                    },
                )
            return VoiceAIStartOut(
                session_id=ai_session.session_id,
                language=language,
                greeting_text=_voice_start_name_prompt(language, config),
                disclosure_required=voice_session.disclosure_played_at is None,
                metadata={
                    "voice_start_language": language,
                    "downstream_queue_code": downstream_queue_code,
                    "downstream_queue_id": downstream_queue_id,
                },
            )
        session.commit()
        if created:
            _append_timeline(
                interaction.interaction_id,
                "ai.session_started",
                {
                    "call_id": payload.call_id,
                    "voice_session_id": voice_session.session_id,
                    "ai_session_id": ai_session.session_id,
                    "language": language,
                },
            )
        name_status = str(voice_session.customer_name_status or "name_not_obtained").strip() or "name_not_obtained"
        name_value = _normalize_name_candidate(voice_session.customer_name_value) or (
            str(voice_session.customer_name_value or "").strip() or None
        )
        name_source = str(voice_session.customer_name_source or "none").strip() or "none"
        name_resolved_at = str(voice_session.customer_name_resolved_at or "").strip() or None
        if name_status == "name_not_obtained" and not name_value:
            caller_number, caller_name = _resolve_caller_from_call(session, payload.call_id)
            if _display_name_looks_trusted(customer, caller_number, caller_name):
                name_status = "name_obtained"
                name_value = customer.display_name
                name_source = "known_customer"
                name_resolved_at = now
                _persist_voice_name_state(
                    session,
                    voice_session=voice_session,
                    status=name_status,
                    value=name_value,
                    source=name_source,
                    resolved_at=name_resolved_at,
                )
        if name_status == "name_obtained" and name_value:
            finalized_name = _finalize_customer_name(
                session,
                customer=customer,
                customer_id=customer_id,
                call_id=payload.call_id,
                final_name=name_value,
                resolved_at=now,
            )
            if finalized_name:
                name_value = finalized_name
                name_resolved_at = now
                _persist_voice_name_state(
                    session,
                    voice_session=voice_session,
                    status=name_status,
                    value=name_value,
                    source=name_source,
                    resolved_at=name_resolved_at,
                )
        greeting_text = _voice_greeting(language, operator_config)
        if name_status == "name_followup_required":
            if config.downstream.uncertain_name_behavior == "finalize_immediately" and name_value:
                finalized_name = _finalize_customer_name(
                    session,
                    customer=customer,
                    customer_id=customer_id,
                    call_id=payload.call_id,
                    final_name=name_value,
                    resolved_at=now,
                )
                if finalized_name:
                    name_status = "name_obtained"
                    name_value = finalized_name
                    name_resolved_at = now
                    _persist_voice_name_state(
                        session,
                        voice_session=voice_session,
                        status=name_status,
                        value=name_value,
                        source=name_source,
                        resolved_at=name_resolved_at,
                    )
            elif config.downstream.uncertain_name_behavior == "discard_and_collect":
                name_status = "name_not_obtained"
                name_value = None
                name_source = "none"
                name_resolved_at = None
                _persist_voice_name_state(
                    session,
                    voice_session=voice_session,
                    status=name_status,
                    value=name_value,
                    source=name_source,
                    resolved_at=name_resolved_at,
                )
        if name_status == "name_obtained" and name_value:
            greeting_text = _voice_personalized_greeting(language, name_value, config)
        elif name_status == "name_followup_required":
            greeting_text = _voice_name_confirmation_greeting(language, name_value, config)
        else:
            if config.enabled and config.downstream.missing_name_behavior == "ask_inline_once":
                inline_prompt = _voice_inline_name_followup(language, config)
                if inline_prompt and inline_prompt not in greeting_text:
                    greeting_text = f"{greeting_text} {inline_prompt}"
        session.commit()
        return VoiceAIStartOut(
            session_id=ai_session.session_id,
            language=language,
            greeting_text=greeting_text,
            disclosure_required=voice_session.disclosure_played_at is None,
            metadata=_voice_name_metadata(
                language=voice_session.voice_start_language or language,
                customer_id=customer_id,
                status=name_status,
                value=name_value,
                source=name_source,
                resolved_at=name_resolved_at,
            ),
        )
    finally:
        session.close()


def turn_voice_session(session_id: str, payload: VoiceAITurnIn) -> VoiceAITurnDecisionOut:
    app = _app()
    session = get_session()
    try:
        voice_session = session.execute(
            select(VoiceAISessionRow).where(VoiceAISessionRow.session_id == session_id)
        ).scalar_one_or_none()
        if voice_session is None:
            raise HTTPException(status_code=404, detail="Voice AI session not found")
        interaction = session.execute(
            select(Interaction).where(Interaction.interaction_id == payload.interaction_id)
        ).scalar_one_or_none()
        if interaction is None:
            raise HTTPException(status_code=404, detail="Interaction not found")

        customer_id = _resolve_or_create_voice_customer_id(
            session,
            interaction=interaction,
            call_id=payload.call_id,
        )
        customer = None
        if customer_id:
            customer = session.execute(
                select(Customer).where(Customer.customer_id == customer_id)
            ).scalar_one_or_none()

        ai_session, _ = _ensure_voice_ai_session(
            session,
            voice_session=voice_session,
            interaction=interaction,
            customer_id=customer_id,
            language=str(payload.language or voice_session.language or "ru").strip() or "ru",
            agent_profile=voice_session.agent_profile,
        )
        now = utc_now_iso()
        request_metadata = payload.metadata if isinstance(payload.metadata, dict) else {}
        early_plan_only = _voice_reply_phase(request_metadata) == "early_plan"
        voice_session.customer_id = customer_id
        if not early_plan_only:
            voice_session.last_user_utterance_at = now
            voice_session.status = "thinking"
        voice_session.updated_at = now
        if not early_plan_only:
            ai_session.updated_at = now
        ai_session.call_id = payload.call_id
        ai_session.interaction_id = interaction.interaction_id
        ai_session.customer_id = customer_id
        ai_session.language = str(payload.language or voice_session.language or "ru").strip() or "ru"
        config = load_effective_voice_name_collection_config(session)
        operator_config = app._load_ai_operator_config(session)

        if not early_plan_only:
            _record_voice_ai_turn(
                session,
                ai_session_id=ai_session.session_id,
                interaction_id=interaction.interaction_id,
                role="user",
                source_type="voice_asr",
                text=payload.transcript_text,
                payload={
                    "voice_session_id": payload.voice_session_id,
                    "call_id": payload.call_id,
                    "sequence_no": payload.sequence_no,
                    "barge_in": payload.barge_in,
                    "metadata": payload.metadata,
                },
            )
            if payload.barge_in:
                last_assistant_segment = session.execute(
                    select(VoiceTranscriptSegmentRow)
                    .where(VoiceTranscriptSegmentRow.session_id == voice_session.session_id)
                    .where(VoiceTranscriptSegmentRow.speaker == "assistant")
                    .order_by(VoiceTranscriptSegmentRow.id.desc())
                    .limit(1)
                ).scalar_one_or_none()
                if last_assistant_segment:
                    last_assistant_segment.barge_in_interrupted = True
        transcript_window = _voice_recent_segments(
            session,
            voice_session_id=voice_session.session_id,
            limit=_voice_max_context_segments(),
        )
        if _voice_start_stage(voice_session.agent_profile, payload.metadata):
            language = ai_session.language or "ru"
            downstream_queue_code, downstream_queue_id = _voice_start_downstream_queue(payload.metadata, voice_session)
            if config.enabled and config.start.ask_name_on_start:
                status, name_value, name_source = _voice_start_name_outcome(payload.transcript_text, language)
            else:
                status, name_value, name_source = "name_not_obtained", None, "none"
            result = VoiceStartResult(
                language=language,
                customer_id=customer_id,
                customer_name_status=status,
                customer_name_value=name_value,
                customer_name_source=name_source,
                downstream_queue_id=downstream_queue_id,
                downstream_queue_code=downstream_queue_code,
                resolved_at=now,
            )
            _persist_voice_start_result(
                session,
                voice_session=voice_session,
                interaction=interaction,
                result=result,
            )
            decision_metadata = _voice_start_result_metadata(result)
            summary_text = (
                f"Voice start completed with status {status}."
                if not name_value
                else f"Voice start completed with status {status} and candidate name {name_value}."
            )
            _record_voice_ai_turn(
                session,
                ai_session_id=ai_session.session_id,
                interaction_id=interaction.interaction_id,
                role="assistant",
                source_type="voice_policy",
                text=summary_text,
                payload={
                    "voice_session_id": payload.voice_session_id,
                    "call_id": payload.call_id,
                    "decision": {
                        "language": language,
                        "customer_name_status": status,
                        "customer_name_value": name_value,
                        "customer_name_source": name_source,
                        "downstream_queue_code": downstream_queue_code,
                        "downstream_queue_id": downstream_queue_id,
                    },
                },
                model="voice_start_policy",
                latency_ms=1,
            )
            voice_session.language = language
            voice_session.status = "handoff_requested"
            voice_session.handoff_reason = "voice_start_completed"
            voice_session.updated_at = now
            ai_session.status = "handoff_required"
            ai_session.handoff_reason = "voice_start_completed"
            ai_session.summary_text = summary_text
            ai_session.updated_at = now
            session.commit()
            return VoiceAITurnDecisionOut(
                language=language,
                intent="voice_start_identity",
                reply_text="",
                confidence=1.0 if status == "name_obtained" else 0.55,
                needs_handoff=True,
                handoff_reason="voice_start_completed",
                case_action="keep_open",
                kb_refs=[],
                summary_text=summary_text,
                model="voice_start_policy",
                latency_ms=1,
                status="handoff_requested",
                metadata=decision_metadata,
            )
        timeline_window = _voice_recent_timeline(
            session,
            interaction_id=interaction.interaction_id,
        )
        current_name_status = str(voice_session.customer_name_status or "name_not_obtained").strip() or "name_not_obtained"
        current_name_value = _normalize_name_candidate(voice_session.customer_name_value) or (
            str(voice_session.customer_name_value or "").strip() or None
        )
        current_name_source = str(voice_session.customer_name_source or "none").strip() or "none"
        current_name_resolved_at = str(voice_session.customer_name_resolved_at or "").strip() or None
        effective_name_status = current_name_status
        effective_name_value = current_name_value
        effective_name_source = current_name_source
        effective_name_resolved_at = current_name_resolved_at
        inline_name_followup = False
        if not early_plan_only:
            name_update = _voice_downstream_name_update(
                language=ai_session.language or "ru",
                transcript_text=payload.transcript_text,
                transcript_window=transcript_window,
                current_status=current_name_status,
                current_name=current_name_value,
                current_source=current_name_source,
                current_resolved_at=current_name_resolved_at,
                now=now,
                config=config,
            )
            if name_update["finalizable"]:
                finalized_name = _finalize_customer_name(
                    session,
                    customer=customer,
                    customer_id=customer_id,
                    call_id=payload.call_id,
                    final_name=name_update["value"],
                    resolved_at=now,
                )
                if finalized_name:
                    name_update["value"] = finalized_name
                    name_update["resolved_at"] = now
            effective_name_status = name_update["status"]
            effective_name_value = name_update["value"]
            effective_name_source = name_update["source"]
            effective_name_resolved_at = name_update["resolved_at"]
            inline_name_followup = bool(name_update["inline_followup"])
            _persist_voice_name_state(
                session,
                voice_session=voice_session,
                status=effective_name_status,
                value=effective_name_value,
                source=effective_name_source,
                resolved_at=effective_name_resolved_at,
            )
            user_context_summary = update_context_summary_from_user_turn(
                ai_session.context_summary_json,
                channel="voice",
                language=ai_session.language or "ru",
                customer_name=effective_name_value or (customer.display_name if customer else None),
                text=payload.transcript_text,
                now=now,
            )
            ai_session.context_summary_json = dump_context_summary(user_context_summary)
            ai_session.context_summary_updated_at = now
        kb_results = []
        if not early_plan_only:
            kb_results = app._kb_search(
                session,
                payload.transcript_text,
                language=ai_session.language,
            )
        disclosure_required = voice_session.disclosure_played_at is None
        decision = _voice_decision(
            language=ai_session.language or "ru",
            customer=customer,
            interaction=interaction,
            transcript_text=payload.transcript_text,
            transcript_window=transcript_window,
            context_summary=ai_session.context_summary_json,
            kb_results=kb_results,
            disclosure_required=disclosure_required,
            customer_name_value=effective_name_value,
            customer_name_status=effective_name_status,
            request_metadata=request_metadata,
            operator_config=operator_config,
        )
        decision["reply_text"] = _voice_postprocess_reply_text(
            language=decision["language"],
            transcript_text=payload.transcript_text,
            transcript_window=transcript_window,
            context_summary=ai_session.context_summary_json,
            reply_text=str(decision.get("reply_text") or ""),
            kb_results=kb_results,
            needs_handoff=bool(decision.get("needs_handoff")),
        )

        if decision.get("extracted_name") and not early_plan_only:
            effective_name_status = "name_obtained"
            effective_name_value = str(decision["extracted_name"]).strip()
            effective_name_source = "llm_extraction"
            effective_name_resolved_at = now
            inline_name_followup = False
            finalized_name = _finalize_customer_name(
                session,
                customer=customer,
                customer_id=customer_id,
                call_id=payload.call_id,
                final_name=effective_name_value,
                resolved_at=now,
            )
            if finalized_name:
                effective_name_value = finalized_name
            _persist_voice_name_state(
                session,
                voice_session=voice_session,
                status=effective_name_status,
                value=effective_name_value,
                source=effective_name_source,
                resolved_at=effective_name_resolved_at,
            )

        decision_metadata = _voice_name_metadata(
            language=voice_session.voice_start_language or ai_session.language or "ru",
            customer_id=customer_id,
            status=effective_name_status,
            value=effective_name_value,
            source=effective_name_source,
            resolved_at=effective_name_resolved_at,
        )
        decision_metadata.update(decision.get("metadata") or {})
        suppress_name_prefix = bool(request_metadata.get("suppress_name_prefix")) if isinstance(request_metadata, dict) else False
        if effective_name_status == "name_obtained" and effective_name_value and not early_plan_only and not suppress_name_prefix:
            decision["reply_text"] = _voice_reply_with_name(
                decision["language"],
                decision["reply_text"],
                effective_name_value,
            )
        elif inline_name_followup and not decision["needs_handoff"] and not early_plan_only:
            inline_followup = _voice_inline_name_followup(decision["language"], config)
            decision["reply_text"] = (
                f"{decision['reply_text']} {inline_followup}".strip()
                if str(decision["reply_text"] or "").strip()
                else inline_followup
            )
        if decision_metadata.get("voice_v2_enabled"):
            decision["reply_text"] = _voice_compact_reply_text(
                decision["reply_text"],
                language=decision["language"],
            )
        decision["metadata"] = decision_metadata
        if early_plan_only:
            decision_status = str(voice_session.status or "active").strip() or "active"
            session.rollback()
            return VoiceAITurnDecisionOut(
                language=decision["language"],
                intent=decision["intent"],
                reply_text=decision["reply_text"],
                confidence=decision["confidence"],
                needs_handoff=decision["needs_handoff"],
                handoff_reason=decision["handoff_reason"],
                case_action=decision["case_action"],
                kb_refs=decision["kb_refs"],
                summary_text=decision["summary_text"],
                model=decision["model"],
                latency_ms=decision["latency_ms"],
                status=decision_status,
                metadata=decision_metadata,
            )
        _record_voice_ai_turn(
            session,
            ai_session_id=ai_session.session_id,
            interaction_id=interaction.interaction_id,
            role="assistant",
            source_type="voice_policy",
            text=decision["reply_text"] or decision["summary_text"] or decision["handoff_reason"],
            payload={
                "voice_session_id": payload.voice_session_id,
                "call_id": payload.call_id,
                "kb_refs": decision["kb_refs"],
                "timeline_window": timeline_window,
                "context_segments": [
                    {
                        "speaker": segment.speaker,
                        "text": segment.text,
                        "sequence_no": segment.sequence_no,
                    }
                    for segment in transcript_window
                ],
                "decision": decision,
            },
            model=decision["model"],
            latency_ms=decision["latency_ms"],
        )
        voice_session.language = decision["language"]
        voice_session.updated_at = now
        voice_session.status = "handoff_requested" if decision["needs_handoff"] else "active"
        voice_session.handoff_reason = decision["handoff_reason"]
        ai_session.status = "handoff_required" if decision["needs_handoff"] else "active"
        ai_session.handoff_reason = decision["handoff_reason"]
        updated_context_summary = update_context_summary_from_assistant_turn(
            ai_session.context_summary_json,
            language=decision["language"],
            customer_name=effective_name_value or (customer.display_name if customer else None),
            reply_text=decision["reply_text"] or decision["handoff_reason"] or "",
            decision_intent=decision["intent"],
            needs_handoff=bool(decision["needs_handoff"]),
            handoff_reason=decision["handoff_reason"],
            now=now,
        )
        ai_session.context_summary_json = dump_context_summary(updated_context_summary)
        ai_session.context_summary_updated_at = now
        ai_session.summary_text = decision["summary_text"] or decision["reply_text"] or ai_session.summary_text
        ai_session.updated_at = now
        session.commit()
        _append_timeline(
            interaction.interaction_id,
            "ai.handoff_requested" if decision["needs_handoff"] else "ai.reply_generated",
            {
                "call_id": payload.call_id,
                "voice_session_id": payload.voice_session_id,
                "ai_session_id": ai_session.session_id,
                "sequence_no": payload.sequence_no,
                "confidence": decision["confidence"],
                "kb_refs": decision["kb_refs"],
                "handoff_reason": decision["handoff_reason"],
                **decision_metadata,
            },
        )
        return VoiceAITurnDecisionOut(
            language=decision["language"],
            intent=decision["intent"],
            reply_text=decision["reply_text"],
            confidence=decision["confidence"],
            needs_handoff=decision["needs_handoff"],
            handoff_reason=decision["handoff_reason"],
            case_action=decision["case_action"],
            kb_refs=decision["kb_refs"],
            summary_text=decision["summary_text"],
            model=decision["model"],
            latency_ms=decision["latency_ms"],
            status="handoff_requested" if decision["needs_handoff"] else "active",
            metadata=decision_metadata,
        )
    except HTTPException:
        raise
    except Exception as exc:
        try:
            session.rollback()
        finally:
            pass
        raise HTTPException(status_code=502, detail=f"Voice AI turn failed: {exc}") from exc
    finally:
        session.close()


def close_voice_session(session_id: str) -> dict[str, Any]:
    session = get_session()
    try:
        voice_session = session.execute(
            select(VoiceAISessionRow).where(VoiceAISessionRow.session_id == session_id)
        ).scalar_one_or_none()
        if voice_session is None:
            raise HTTPException(status_code=404, detail="Voice AI session not found")
        now = utc_now_iso()
        voice_session.status = "completed" if voice_session.status != "error" else "error"
        voice_session.ended_at = voice_session.ended_at or now
        voice_session.updated_at = now
        if str(voice_session.ai_session_id or "").strip():
            ai_session = session.execute(
                select(AISessionRow).where(AISessionRow.session_id == voice_session.ai_session_id)
            ).scalar_one_or_none()
            if ai_session:
                ai_session.status = "closed" if ai_session.status != "error" else "error"
                ai_session.closed_at = now
                ai_session.updated_at = now
        session.commit()
        return {
            "ok": True,
            "voice_session_id": voice_session.session_id,
            "ai_session_id": voice_session.ai_session_id,
            "status": voice_session.status,
        }
    finally:
        session.close()
