"""Hybrid assistant engine for ORION-AI."""

from __future__ import annotations

import logging
import re
import uuid
from dataclasses import dataclass
from datetime import date, datetime, timedelta
from typing import Any

from sqlalchemy.orm import Session

from app.core.config import settings
from app.services.ai import (
    AIProviderConfigError,
    AIProviderResponseError,
    AIProviderUnavailableError,
    generate_text as ai_generate_text,
)
from app.services import coverage_engine, engagements as engagement_service, runtime_config, vault
from app.services.integrations import service as integrations_service
from app.services.integrations.registry import NOTES_PROVIDER_IDS, PROVIDER_META, TASK_PROVIDER_IDS
from app.services.intent_router import IntentCategory, detect_intent, extract_engagement_id, normalize_text

CHAT_MEMORY_MAX_ITEMS = 12
TASK_PREVIEW_LIMIT = 8
VAULT_MAX_CHUNKS = 6
VAULT_MAX_CONTEXT_CHARS = 6000

ES_MARKERS = (
    "que",
    "tarea",
    "tareas",
    "manana",
    "hoy",
    "cuando",
    "cumple",
    "cumpleanos",
    "fecha",
    "hora",
    "cobertura",
    "resumen",
)

EN_MARKERS = (
    "what",
    "task",
    "tasks",
    "tomorrow",
    "today",
    "when",
    "birthday",
    "date",
    "time",
    "coverage",
    "summary",
    "engagement",
)

_ES_WEEKDAYS = ("lunes", "martes", "miércoles", "jueves", "viernes", "sábado", "domingo")
_ES_MONTHS = (
    "enero",
    "febrero",
    "marzo",
    "abril",
    "mayo",
    "junio",
    "julio",
    "agosto",
    "septiembre",
    "octubre",
    "noviembre",
    "diciembre",
)

CHAT_MEMORY: dict[str, list[dict[str, str]]] = {}
LOGGER = logging.getLogger("sentineledge.assistant.service")


class AssistantConfigError(RuntimeError):
    """Raised when assistant provider configuration is missing."""


class AssistantRuntimeError(RuntimeError):
    """Raised when assistant runtime execution fails."""


class AssistantProviderUnavailable(RuntimeError):
    """Raised when the selected AI provider cannot be used."""


@dataclass
class TaskQueryResult:
    """Internal normalized result for generic task question handling."""

    kind: str
    tasks: list[dict[str, Any]]
    target_date: date | None = None
    subject: str | None = None


def _collapse_spaces(value: str) -> str:
    """Normalize whitespace for cleaner prompts and responses."""
    return " ".join(value.split())


def _ai_text_completion(
    prompt: str,
    *,
    context: dict[str, Any] | None = None,
) -> str:
    """Generate assistant response with selected AI provider."""
    LOGGER.info(
        "assistant_service._ai_text_completion prompt_len=%s context_keys=%s",
        len(str(prompt or "")),
        sorted(str(key) for key in (context or {}).keys()),
    )
    try:
        return ai_generate_text(prompt=prompt, context=context).strip()
    except (AIProviderConfigError, AIProviderUnavailableError) as exc:
        raise AssistantProviderUnavailable(str(exc) or "AI provider unavailable") from exc
    except AIProviderResponseError as exc:
        raise AssistantRuntimeError(str(exc) or "AI provider request failed") from exc


def _ai_provider_unavailable_message(language: str) -> str:
    """Localized message for unavailable selected AI provider."""
    if language == "es":
        return "El proveedor de IA seleccionado no está disponible. Revísalo en Config."
    return "The selected AI provider is not available. Please check Config."


def _gemini_missing_key_message(language: str) -> str:
    """Localized Gemini-missing-key guidance."""
    if language == "es":
        return "Gemini no configurado: falta API key. Abre Configuración."
    return "Gemini not configured: missing API key. Open Settings."


def _gemini_invalid_model_message(language: str) -> str:
    """Localized Gemini-invalid-model guidance."""
    if language == "es":
        return "El modelo de Gemini no está disponible. Cámbialo en Configuración > IA."
    return "Gemini model is not available. Change it in Settings > AI."


def _marker_score(text: str, markers: tuple[str, ...]) -> int:
    """Compute language marker score for normalized text."""
    score = 0
    for marker in markers:
        if " " in marker:
            if marker in text:
                score += 1
            continue
        if re.search(rf"\b{re.escape(marker)}\b", text):
            score += 1
    return score


def detect_language(message: str, fallback_language: str = "es") -> str:
    """Detect message language (es/en) with configurable fallback when ambiguous."""
    text = normalize_text(message)
    if not text:
        return "en" if fallback_language == "en" else "es"

    es_score = _marker_score(text, ES_MARKERS)
    en_score = _marker_score(text, EN_MARKERS)

    if es_score == en_score:
        return "en" if fallback_language == "en" else "es"
    if en_score > es_score:
        return "en"
    return "es"


def _format_date_localized(value: date, language: str) -> str:
    """Format date in localized long style without ISO output."""
    if language == "en":
        return value.strftime("%A, %B %d, %Y")

    weekday = _ES_WEEKDAYS[value.weekday()]
    month = _ES_MONTHS[value.month - 1]
    return f"{weekday}, {value.day} de {month} de {value.year}"


def _format_time_localized(now: datetime, language: str) -> str:
    """Format local time string with timezone."""
    zone = now.strftime("%Z") or ("hora local" if language == "es" else "local time")
    return f"{now.strftime('%H:%M:%S')} {zone}".strip()


def _asks_date(text: str) -> bool:
    """Return True when user asks for date context."""
    return any(
        phrase in text
        for phrase in (
            "day",
            "date",
            "today",
            "dia",
            "fecha",
            "hoy",
            "tomorrow",
            "manana",
        )
    )


def _asks_time(text: str) -> bool:
    """Return True when user asks for time context."""
    return any(phrase in text for phrase in ("time", "hora"))


def _system_response(message: str, language: str) -> str:
    """Return local system answer without AI."""
    text = normalize_text(message)
    now = datetime.now().astimezone()

    asks_date = _asks_date(text)
    asks_time = _asks_time(text)

    date_label = _format_date_localized(now.date(), language)
    time_label = _format_time_localized(now, language)

    if language == "en":
        if asks_date and asks_time:
            return f"Today is {date_label} and the current time is {time_label}."
        if asks_time:
            return f"The current local time is {time_label}."
        return f"Today is {date_label}."

    if asks_date and asks_time:
        return f"Hoy es {date_label} y la hora actual es {time_label}."
    if asks_time:
        return f"La hora local actual es {time_label}."
    return f"Hoy es {date_label}."


def _task_due_date(task: dict[str, Any]) -> date | None:
    """Parse task due date from unified task payload."""
    due = task.get("due")
    if not isinstance(due, dict):
        raw = task.get("due_date") or task.get("due_string")
    else:
        raw = due.get("date") or due.get("string") or task.get("due_string")

    raw_text = str(raw or "").strip()
    if not raw_text:
        return None

    if raw_text in {"today", "hoy"}:
        return datetime.now().date()
    if raw_text in {"tomorrow", "manana"}:
        return datetime.now().date() + timedelta(days=1)

    try:
        return datetime.strptime(raw_text[:10], "%Y-%m-%d").date()
    except ValueError:
        return None


def _contains_birthday_marker(text: str) -> bool:
    """Check whether normalized text references birthday tasks."""
    return "birthday" in text or "cumple" in text or "cumpleanos" in text


def _extract_birthday_subject(message: str) -> tuple[str, str] | None:
    """Extract likely person name from birthday-style query."""
    text = normalize_text(message)
    patterns = (
        r"\bwhen is ([a-z0-9][a-z0-9 ]{0,50}?)(?: s)? birthday\b",
        r"\bcuando es (?:el )?(?:cumple|cumpleanos)(?: de)? ([a-z0-9][a-z0-9 ]{0,50})\b",
    )
    for pattern in patterns:
        match = re.search(pattern, text)
        if not match:
            continue
        normalized_subject = " ".join(match.group(1).split()).strip()
        if not normalized_subject:
            continue
        display_subject = " ".join(part.capitalize() for part in normalized_subject.split())
        return normalized_subject, display_subject
    return None


def _select_closest_upcoming(tasks: list[dict[str, Any]]) -> list[dict[str, Any]]:
    """Return one closest upcoming task when multiple birthday matches exist."""
    if len(tasks) <= 1:
        return tasks

    today = datetime.now().date()
    with_date = [(task, _task_due_date(task)) for task in tasks]
    upcoming = [(task, due) for task, due in with_date if due is not None and due >= today]

    if upcoming:
        selected_task, _ = min(upcoming, key=lambda pair: pair[1])
        return [selected_task]

    dated = [(task, due) for task, due in with_date if due is not None]
    if dated:
        selected_task, _ = min(dated, key=lambda pair: pair[1])
        return [selected_task]

    return [tasks[0]]


def _is_tomorrow_query(text: str) -> bool:
    """Return True when message asks specifically for tomorrow."""
    return any(token in text for token in ("tomorrow", "manana", "para manana"))


def _is_today_query(text: str) -> bool:
    """Return True when message asks specifically for today."""
    return any(token in text for token in ("today", "hoy"))


def _asks_exact_date(text: str) -> bool:
    """Return True when user explicitly requests an exact date."""
    return any(
        phrase in text
        for phrase in (
            "exact date",
            "specific date",
            "fecha exacta",
            "fecha especifica",
            "exactamente",
            "iso",
        )
    )


def _find_tasks(message: str, tasks: list[dict[str, Any]]) -> TaskQueryResult:
    """Find task subset based on user question context."""
    text = normalize_text(message)
    today = datetime.now().date()

    birthday_subject = _extract_birthday_subject(message)
    if birthday_subject:
        subject_norm, subject_display = birthday_subject
        matched = []
        for task in tasks:
            content = normalize_text(str(task.get("content", "")))
            if subject_norm in content and _contains_birthday_marker(content):
                matched.append(task)
        return TaskQueryResult(
            kind="birthday",
            tasks=_select_closest_upcoming(matched),
            subject=subject_display,
        )

    if _contains_birthday_marker(text):
        matched = [
            task
            for task in tasks
            if _contains_birthday_marker(normalize_text(str(task.get("content", ""))))
        ]
        return TaskQueryResult(kind="birthday", tasks=_select_closest_upcoming(matched))

    if _is_tomorrow_query(text):
        target = today + timedelta(days=1)
        matched = [task for task in tasks if _task_due_date(task) == target]
        return TaskQueryResult(kind="tomorrow", tasks=matched, target_date=target)

    if _is_today_query(text):
        matched = [task for task in tasks if _task_due_date(task) == today]
        return TaskQueryResult(kind="today", tasks=matched, target_date=today)

    return TaskQueryResult(kind="open", tasks=tasks[:TASK_PREVIEW_LIMIT])


def _task_content(task: dict[str, Any], language: str) -> str:
    """Render task title only (minimal output format)."""
    content = str(task.get("content", "")).strip()
    if content:
        return content
    return "Tarea sin título" if language == "es" else "Untitled task"


def _task_count_word(count: int, language: str) -> str:
    """Return localized singular/plural task word."""
    if language == "es":
        return "tarea" if count == 1 else "tareas"
    return "task" if count == 1 else "tasks"


def _render_task_list_header(
    kind: str,
    count: int,
    language: str,
    target_date: date | None,
    include_exact_date: bool,
) -> str:
    """Build localized task list header."""
    task_word = _task_count_word(count, language)

    if kind == "tomorrow":
        if language == "es":
            header = f"Para mañana tienes {count} {task_word}"
        else:
            header = f"For tomorrow you have {count} {task_word}"
    elif kind == "today":
        if language == "es":
            header = f"Para hoy tienes {count} {task_word}"
        else:
            header = f"For today you have {count} {task_word}"
    else:
        if language == "es":
            header = f"Tienes {count} {task_word} abiertas"
        else:
            header = f"You have {count} open {task_word}"

    if include_exact_date and target_date is not None:
        header = f"{header} ({_format_date_localized(target_date, language)})"

    return f"{header}:"


def _render_task_list(result: TaskQueryResult, message: str, language: str) -> str:
    """Render localized minimal task list response."""
    if not result.tasks:
        if result.kind == "tomorrow":
            if language == "es":
                return "No tienes tareas para mañana."
            return "You have no tasks for tomorrow."
        if result.kind == "today":
            if language == "es":
                return "No tienes tareas para hoy."
            return "You have no tasks for today."
        if language == "es":
            return "No tienes tareas abiertas."
        return "You have no open tasks."

    include_exact_date = _asks_exact_date(normalize_text(message))
    header = _render_task_list_header(
        kind=result.kind,
        count=len(result.tasks),
        language=language,
        target_date=result.target_date,
        include_exact_date=include_exact_date,
    )
    lines = [f"- {_task_content(task, language)}" for task in result.tasks]
    return "\n".join([header, *lines])


def _birthday_subject_from_task(task: dict[str, Any], fallback: str, language: str) -> str:
    """Infer birthday subject from task content when query did not include it."""
    content = str(task.get("content", "")).strip()
    if not content:
        return fallback

    normalized = normalize_text(content)
    cleaned = normalized
    for marker in ("cumpleanos", "cumple", "birthday"):
        cleaned = cleaned.replace(marker, " ")
    cleaned = " ".join(cleaned.split()).strip()
    if not cleaned:
        return fallback

    return " ".join(part.capitalize() for part in cleaned.split())


def _render_birthday_response(result: TaskQueryResult, language: str) -> str:
    """Render localized birthday answer."""
    subject = result.subject or ("la persona" if language == "es" else "that person")

    if not result.tasks:
        if language == "es":
            return f"No encontré una tarea de cumpleaños para {subject}."
        return f"I could not find a birthday task for {subject}."

    selected = result.tasks[0]
    due_date = _task_due_date(selected)
    subject = _birthday_subject_from_task(selected, fallback=subject, language=language)

    if due_date is None:
        if language == "es":
            return f"No encontré fecha para el cumpleaños de {subject}."
        return f"I could not find a date for {subject}'s birthday."

    friendly_date = _format_date_localized(due_date, language)
    if language == "es":
        return f"El cumpleaños de {subject} es el {friendly_date}."
    return f"{subject}'s birthday is {friendly_date}."


def _provider_label(provider_id: str) -> str:
    """Resolve provider display name."""
    meta = PROVIDER_META.get(provider_id)
    if meta is None:
        return provider_id
    return meta.label


def _provider_ready(status_payload: integrations_service.IntegrationStatusPayload, provider_id: str) -> bool:
    """Return True when provider is configured and enabled."""
    item = status_payload.providers.get(provider_id, {})
    return bool(item.get("configured")) and bool(item.get("enabled"))


def _tasks_config_required(language: str, provider_label: str) -> str:
    """Return localized configuration-required guidance for task providers."""
    if language == "es":
        if provider_label.lower() == "todoist":
            return "Para consultar tareas necesito que configures Todoist. Abre ⚙️ Config."
        return f"Necesito que configures {provider_label} en ⚙️ Config."
    if provider_label.lower() == "todoist":
        return "To access tasks I need Todoist configured. Open ⚙️ Config."
    return f"I need {provider_label} configured. Open ⚙️ Config."


def _notes_config_required(language: str, provider_label: str) -> str:
    """Return localized configuration-required guidance for notes providers."""
    if language == "es":
        if provider_label.lower().startswith("vault"):
            return "Para responder con notas necesito que configures Vault. Abre ⚙️ Config."
        return f"Necesito que configures {provider_label} en ⚙️ Config."
    if provider_label.lower().startswith("vault"):
        return "To access notes I need Vault configured. Open ⚙️ Config."
    return f"I need {provider_label} configured. Open ⚙️ Config."


def _task_provider_from_message(message_normalized: str) -> str | None:
    """Resolve explicit task provider mention from text."""
    provider_id = integrations_service.provider_mention_to_id(message_normalized)
    if provider_id in TASK_PROVIDER_IDS:
        return provider_id
    return None


def _notes_provider_from_message(message_normalized: str) -> str | None:
    """Resolve explicit notes provider mention from text."""
    provider_id = integrations_service.provider_mention_to_id(message_normalized)
    if provider_id in NOTES_PROVIDER_IDS:
        return provider_id
    return None


def _wants_all_tasks(message_normalized: str) -> bool:
    """Detect cross-provider tasks request."""
    return any(
        marker in message_normalized
        for marker in (
            "across all",
            "all providers",
            "all tasks",
            "en todos",
            "todas mis tareas",
            "todos los proveedores",
        )
    )


def _wants_all_notes(message_normalized: str) -> bool:
    """Detect cross-provider notes request."""
    return any(
        marker in message_normalized
        for marker in (
            "across all notes",
            "all notes",
            "en todas mis notas",
            "todas las notas",
        )
    )


def _extract_task_create_content(message: str) -> str | None:
    """Extract task title from create/add command."""
    patterns = (
        r"(?:create|add|new)\s+(?:a\s+)?task(?:\s+(?:called|named|to))?\s+(.+)",
        r"(?:crear|crea|agrega|anade)\s+(?:una\s+)?tarea(?:\s+(?:que\s+diga|llamada|para))?\s+(.+)",
    )
    for pattern in patterns:
        match = re.search(pattern, message, flags=re.IGNORECASE)
        if not match:
            continue
        content = match.group(1).strip()
        content = re.sub(
            r"\s+(?:in|en)\s+(?:todoist|google tasks?|microsoft to do|microsoft todo|trello|github(?: issues?)?)\b.*$",
            "",
            content,
            flags=re.IGNORECASE,
        ).strip(" .")
        if content:
            return content
    return None


def _extract_due_string(message_normalized: str) -> str | None:
    """Extract due hint from natural language task request."""
    if "tomorrow" in message_normalized or "manana" in message_normalized:
        return "tomorrow"
    if "today" in message_normalized or "hoy" in message_normalized:
        return "today"
    date_match = re.search(r"\b(\d{4}-\d{2}-\d{2})\b", message_normalized)
    if date_match:
        return date_match.group(1)
    return None


def _tasks_response(message: str, language: str) -> str:
    """Handle TASKS intent with provider-aware logic."""
    text = normalize_text(message)
    status_payload = integrations_service.get_integrations_status()
    explicit_provider = _task_provider_from_message(text)
    create_content = _extract_task_create_content(message)

    if create_content:
        target_provider = explicit_provider or status_payload.default_task_provider
        if target_provider not in TASK_PROVIDER_IDS:
            target_provider = "todoist"
        if not _provider_ready(status_payload, target_provider):
            return _tasks_config_required(language, _provider_label(target_provider))
        try:
            created = integrations_service.create_task(
                content=create_content,
                due_string=_extract_due_string(text),
                provider_id=target_provider,
            )
        except Exception as exc:
            if language == "es":
                return f"No pude crear la tarea: {exc}"
            return f"I could not create the task: {exc}"
        title = str(created.get("content", create_content)).strip() or create_content
        label = _provider_label(target_provider)
        if language == "es":
            return f"Tarea creada en {label}: {title}."
        return f"Task created in {label}: {title}."

    across_all = _wants_all_tasks(text)
    target_provider: str | None = explicit_provider
    if target_provider:
        if not _provider_ready(status_payload, target_provider):
            return _tasks_config_required(language, _provider_label(target_provider))
    elif not across_all:
        default_provider = status_payload.default_task_provider
        if _provider_ready(status_payload, default_provider):
            target_provider = default_provider
        else:
            return _tasks_config_required(language, _provider_label(default_provider))

    try:
        tasks = integrations_service.list_tasks(
            provider_id=target_provider,
            across_all=bool(across_all and target_provider is None),
        )
    except Exception as exc:
        if language == "es":
            return f"No pude consultar tareas: {exc}"
        return f"I could not read tasks: {exc}"

    result = _find_tasks(message=message, tasks=tasks)
    if result.kind == "birthday":
        return _render_birthday_response(result=result, language=language)
    return _render_task_list(result=result, message=message, language=language)


def _vault_enabled() -> bool:
    """Return True when local vault provider is configured and enabled."""
    status_payload = integrations_service.get_integrations_status()
    return _provider_ready(status_payload, "vault_local")


def _notes_hits_response(hits: list[dict[str, Any]], language: str) -> str:
    """Render compact notes search response from provider hits."""
    if not hits:
        if language == "es":
            return "No encontré notas relevantes para esta consulta."
        return "I could not find relevant notes for this request."

    if language == "es":
        header = f"Encontré {len(hits)} resultado(s) en tus notas:"
    else:
        header = f"I found {len(hits)} note result(s):"
    lines: list[str] = [header]
    for hit in hits[:5]:
        provider = _provider_label(str(hit.get("provider", "")))
        title = str(hit.get("title", "")).strip() or ("Nota" if language == "es" else "Note")
        snippet = " ".join(str(hit.get("snippet", "")).split()).strip()
        source = str(hit.get("source", "")).strip()
        if snippet and len(snippet) > 160:
            snippet = f"{snippet[:157]}..."
        detail = snippet or source or title
        lines.append(f"- [{provider}] {title}: {detail}")
    return "\n".join(lines)


def _notes_response(db: Session, message: str, language: str) -> str:
    """Handle VAULT/notes intent through enabled notes providers."""
    text = normalize_text(message)
    status_payload = integrations_service.get_integrations_status()
    explicit_provider = _notes_provider_from_message(text)

    provider_ids: list[str] = []
    if explicit_provider:
        if not _provider_ready(status_payload, explicit_provider):
            return _notes_config_required(language, _provider_label(explicit_provider))
        provider_ids = [explicit_provider]
    elif _wants_all_notes(text):
        provider_ids = [item for item in NOTES_PROVIDER_IDS if _provider_ready(status_payload, item)]
        if not provider_ids:
            return _notes_config_required(language, _provider_label(status_payload.default_notes_provider))
    else:
        default_provider = status_payload.default_notes_provider
        if _provider_ready(status_payload, default_provider):
            provider_ids = [default_provider]
        else:
            return _notes_config_required(language, _provider_label(default_provider))

    if provider_ids == ["vault_local"]:
        return _vault_response(db=db, message=message, language=language, explicit=True) or _vault_no_results(language)

    try:
        hits = integrations_service.search_notes(
            query=message,
            provider_ids=provider_ids,
            limit=5,
            db=db,
        )
    except Exception as exc:
        if language == "es":
            return f"No pude consultar notas: {exc}"
        return f"I could not read notes: {exc}"
    return _notes_hits_response(hits=hits, language=language)


def _resolve_engagement_id(db: Session, message: str) -> int | None:
    """Resolve engagement id from message or fallback to latest engagement."""
    engagement_id = extract_engagement_id(message)
    if engagement_id is not None:
        return engagement_id

    engagements = engagement_service.list_engagements(db)
    if not engagements:
        return None
    return engagements[0].id


def _engagement_response(db: Session, message: str, language: str) -> str:
    """Handle ENGAGEMENT intent using localized deterministic coverage summary."""
    engagement_id = _resolve_engagement_id(db=db, message=message)
    if engagement_id is None:
        if language == "es":
            return "No hay engagements disponibles. Crea uno primero."
        return "No engagements are available yet. Create one first."

    engagement = engagement_service.get_engagement_by_id(db, engagement_id)
    if engagement is None:
        if language == "es":
            return f"No se encontro el engagement {engagement_id}."
        return f"Engagement {engagement_id} was not found."

    try:
        coverage = coverage_engine.get_coverage(db=db, engagement_id=engagement_id)
    except Exception as exc:  # pragma: no cover - defensive runtime branch
        if language == "es":
            return f"No pude leer la cobertura del engagement {engagement_id}: {exc}"
        return f"I could not read coverage for engagement {engagement_id}: {exc}"

    gaps = ", ".join(coverage.gaps) if coverage.gaps else ("ninguno" if language == "es" else "none")
    if language == "es":
        return (
            f"Engagement {engagement.id} ({engagement.name}): cobertura {coverage.coverage_score}%. "
            f"Gaps: {gaps}."
        )

    return (
        f"Engagement {engagement.id} ({engagement.name}) coverage score is {coverage.coverage_score}%. "
        f"Gaps: {gaps}."
    )


def _general_response(message: str, language: str) -> str:
    """Handle GENERAL intent via selected AI provider."""
    target_language = "Spanish" if language == "es" else "English"
    prompt = (
        f"You are {settings.app_name} Copilot, a concise and practical security assistant. "
        f"Respond only in {target_language}. "
        "Answer naturally in plain text.\n\n"
        f"User message:\n{_collapse_spaces(message)}"
    )
    return _ai_text_completion(prompt=prompt)


def _sources_block(sources: list[str]) -> str:
    """Render sources section for vault-backed answers."""
    unique: list[str] = []
    seen: set[str] = set()
    for source in sources:
        item = source.strip()
        if not item or item in seen:
            continue
        seen.add(item)
        unique.append(item)

    if not unique:
        unique = ["none"]
    return "\n".join(["Sources:", *[f"- {source}" for source in unique]])


def _vault_no_results(language: str) -> str:
    """Return localized no-results message with citation block."""
    if language == "es":
        return "No encontré contenido relevante en tu vault para esta pregunta.\n\nSources:\n- none"
    return "I could not find relevant content in your vault for this question.\n\nSources:\n- none"


def _vault_not_indexed(language: str) -> str:
    """Return localized not-indexed guidance."""
    if language == "es":
        return (
            "Tu vault está configurado, pero aún no está indexado para las rutas activas.\n"
            "Ejecuta indexación desde la página Vault o vuelve a intentar en unos segundos.\n\n"
            "Sources:\n- vault_not_indexed"
        )
    return (
        "Your vault is configured, but it is not indexed yet for enabled paths.\n"
        "Run indexing from the Vault page or try again in a few seconds.\n\n"
        "Sources:\n- vault_not_indexed"
    )


def _vault_prompt(message: str, language: str, context_chunks: list[str], sources: list[str]) -> str:
    """Build provider prompt for vault-limited answer generation."""
    target_language = "Spanish" if language == "es" else "English"
    joined_context = "\n\n".join(context_chunks)
    source_lines = "\n".join(f"- {source}" for source in sources)
    return (
        f"You are {settings.app_name} Copilot. Use only the provided note snippets.\n"
        "Do not invent facts. If evidence is missing, say it clearly.\n"
        f"Respond in {target_language} in 1-3 short paragraphs.\n"
        "End with a 'Sources:' bullet list using only the provided file paths.\n\n"
        f"Question:\n{_collapse_spaces(message)}\n\n"
        f"Retrieved snippets:\n{joined_context}\n\n"
        f"Allowed source paths:\n{source_lines}\n"
    )


def _vault_response(db: Session, message: str, language: str, *, explicit: bool) -> str | None:
    """Return vault-backed response or None when auto-retrieval is not relevant."""
    readiness = vault.vault_readiness(db=db)
    LOGGER.info(
        "assistant vault retrieval explicit=%s enabled_paths=%s enabled_files=%s enabled_chunks=%s query=%r",
        explicit,
        readiness.enabled_paths,
        readiness.enabled_files,
        readiness.enabled_chunks,
        _collapse_spaces(message)[:220],
    )
    if readiness.needs_index:
        LOGGER.info(
            "assistant vault retrieval auto-index start enabled_paths=%s query=%r",
            readiness.enabled_paths,
            _collapse_spaces(message)[:160],
        )
        try:
            summary = vault.index_vault(db=db)
            LOGGER.info("assistant vault retrieval auto-index complete stats=%s", summary.get("stats"))
        except vault.VaultIndexInProgressError:
            LOGGER.info("assistant vault retrieval auto-index skipped because index is already running")
        except Exception as exc:
            LOGGER.warning("assistant vault retrieval auto-index failed: %s", exc)

        readiness = vault.vault_readiness(db=db)
        if readiness.needs_index:
            if explicit:
                return _vault_not_indexed(language)
            return None

    try:
        hits = vault.retrieve_relevant_chunks(
            db=db,
            query=message,
            limit=5,
            explicit=explicit,
        )
    except vault.VaultNotIndexedError:
        if explicit:
            return _vault_not_indexed(language)
        return None

    if not hits:
        if explicit:
            return _vault_no_results(language)
        return None

    context_chunks, sources = vault.build_context_chunks(
        hits,
        max_chunks=VAULT_MAX_CHUNKS,
        max_context_chars=VAULT_MAX_CONTEXT_CHARS,
    )
    if not context_chunks:
        return vault.summarize_retrieval(hits, language)

    try:
        prompt = _vault_prompt(message=message, language=language, context_chunks=context_chunks, sources=sources)
        generated = _ai_text_completion(prompt=prompt).strip()
        sources_section = _sources_block(sources)
        if "Sources:" not in generated:
            generated = f"{generated}\n\n{sources_section}"
        return generated
    except (AssistantProviderUnavailable, AssistantConfigError, AssistantRuntimeError):
        # Fall back to retrieval-only path when model is unavailable.
        pass

    return vault.summarize_retrieval(hits, language)


def _build_chat_prompt(history: list[dict[str, str]], message: str, language: str) -> str:
    """Build prompt including short-term conversation memory."""
    transcript_lines: list[str] = []
    for item in history[-CHAT_MEMORY_MAX_ITEMS:]:
        role = item.get("role", "user")
        content = _collapse_spaces(item.get("content", ""))
        if not content:
            continue
        speaker = "User" if role == "user" else "Assistant"
        transcript_lines.append(f"{speaker}: {content}")

    transcript = "\n".join(transcript_lines)
    target_language = "Spanish" if language == "es" else "English"
    return (
        f"You are {settings.app_name} Copilot. Keep replies natural, clear, and concise.\n"
        "If asked security operations questions, provide practical, safe guidance.\n"
        f"Always respond in {target_language}.\n\n"
        f"Conversation so far:\n{transcript}\n"
        f"User: {_collapse_spaces(message)}\n"
        "Assistant:"
    )


def _ensure_session_id(session_id: str | None) -> str:
    """Return provided session id or create a new one."""
    if session_id and session_id.strip():
        return session_id.strip()
    return f"sess_{uuid.uuid4().hex[:12]}"


def query(db: Session, message: str) -> dict[str, str]:
    """Process assistant query with intent routing."""
    language = runtime_config.get_default_language("es")
    intent = detect_intent(message)
    response_intent = intent

    try:
        if intent == IntentCategory.SYSTEM:
            response = _system_response(message=message, language=language)
        elif intent == IntentCategory.TASKS:
            response = _tasks_response(message=message, language=language)
        elif intent == IntentCategory.ENGAGEMENT:
            response = _engagement_response(db=db, message=message, language=language)
        elif intent == IntentCategory.VAULT:
            response = _notes_response(db=db, message=message, language=language)
        else:
            auto_response = None
            if _vault_enabled():
                auto_response = _vault_response(db=db, message=message, language=language, explicit=False)
            if auto_response is not None:
                response = auto_response
                response_intent = IntentCategory.VAULT
            else:
                response = _general_response(message=message, language=language)
    except AssistantProviderUnavailable as exc:
        if "Gemini not configured: missing API key" in str(exc):
            response = _gemini_missing_key_message(language)
        else:
            response = _ai_provider_unavailable_message(language)
    except AssistantConfigError as exc:
        _ = exc
        if language == "es":
            response = "Asistente no configurado. Revisa Configuración > IA."
        else:
            response = "Assistant is not configured. Check Settings > AI."
    except AssistantRuntimeError as exc:
        detail_text = str(exc)
        if "not available for generateContent" in detail_text or "Change model in Settings" in detail_text:
            response = _gemini_invalid_model_message(language)
            return {"intent": response_intent.value, "response": response}
        if language == "es":
            response = f"Fallo en la solicitud del asistente: {exc}"
        else:
            response = f"Assistant request failed: {exc}"
    except Exception as exc:  # pragma: no cover - defensive runtime branch
        if language == "es":
            response = f"El asistente no pudo completar la solicitud: {exc}"
        else:
            response = f"Assistant could not complete the request: {exc}"

    return {"intent": response_intent.value, "response": response}


def chat(message: str, session_id: str | None = None) -> dict[str, str]:
    """Handle free-form conversational chat with in-memory context."""
    language = runtime_config.get_default_language("es")
    session_key = _ensure_session_id(session_id=session_id)
    history = CHAT_MEMORY.get(session_key, [])
    prompt = _build_chat_prompt(history=history, message=message, language=language)

    try:
        response = _ai_text_completion(prompt=prompt)
    except AssistantProviderUnavailable as exc:
        if "Gemini not configured: missing API key" in str(exc):
            response = _gemini_missing_key_message(language)
        else:
            response = _ai_provider_unavailable_message(language)
    except AssistantConfigError as exc:
        _ = exc
        if language == "es":
            response = "Asistente de chat no configurado. Revisa Configuración > IA."
        else:
            response = "Chat assistant is not configured. Check Settings > AI."
    except AssistantRuntimeError as exc:
        detail_text = str(exc)
        if "not available for generateContent" in detail_text or "Change model in Settings" in detail_text:
            response = _gemini_invalid_model_message(language)
            history.append({"role": "user", "content": message})
            history.append({"role": "assistant", "content": response})
            CHAT_MEMORY[session_key] = history[-CHAT_MEMORY_MAX_ITEMS:]
            return {"session_id": session_key, "response": response}
        if language == "es":
            response = f"Fallo en solicitud del chat: {exc}"
        else:
            response = f"Chat assistant request failed: {exc}"
    except Exception as exc:  # pragma: no cover - defensive runtime branch
        if language == "es":
            response = f"Error del asistente de chat: {exc}"
        else:
            response = f"Chat assistant error: {exc}"

    history.append({"role": "user", "content": message})
    history.append({"role": "assistant", "content": response})
    CHAT_MEMORY[session_key] = history[-CHAT_MEMORY_MAX_ITEMS:]

    return {"session_id": session_key, "response": response}
