"""
DOOBAT - Route Webhook Telegram
Reçoit les mises à jour Telegram et orchestre la logique métier.
Copyright 2026 Vincent Gallet - Vincesoft
"""

import logging
import os
import re
import html
import unicodedata
from configparser import ConfigParser

from fastapi import APIRouter, Header, HTTPException, Request, status
from fastapi.responses import JSONResponse

from services.ai_service import AIService
from services.auth_service import AuthService
from services.cleanup_service import CleanupService
from services.odoo_service import OdooService
from services.pdf_service import PDFService
from services.security_service import SecurityService
from services.telegram_service import TelegramService

logger = logging.getLogger("doobat.webhook")
router = APIRouter()

# ── Chargement config et instanciation des services ───────────────────────────

def _load_config() -> ConfigParser:
    config = ConfigParser()
    config.read("config.ini")
    return config


_config = _load_config()
_odoo = OdooService(_config)
_ai = AIService(_config)
_pdf = PDFService(_config)
_auth = AuthService(_config)
_security = SecurityService(_config)
_telegram = TelegramService(_config)
_cleanup = CleanupService(_config)

# Analyses en attente de confirmation, clé = chat_id
_pending_analysis: dict[int, dict] = {}
_pending_contact_choices: dict[int, dict] = {}
_pending_stock_choices: dict[int, dict] = {}
_pending_stock_retry_choices: dict[int, dict] = {}


# ── Endpoint webhook principal ────────────────────────────────────────────────

@router.post("/telegram")
async def telegram_webhook(
    request: Request,
    x_telegram_bot_api_secret_token: str = Header(default="", alias="X-Telegram-Bot-Api-Secret-Token"),
):
    """
    Reçoit les mises à jour Telegram (webhook POST).
    Valide le secret, parse l'update, route vers la logique métier.
    """
    # 1. Valider le secret webhook
    if not _security.validate_telegram_secret(x_telegram_bot_api_secret_token):
        logger.warning("Webhook : secret token invalide — requête rejetée.")
        raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Forbidden")

    # 2. Parser le body JSON
    try:
        update = await request.json()
    except Exception:
        logger.warning("Webhook : body JSON invalide.")
        raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid JSON")

    # 3. Traiter l'update de façon asynchrone
    try:
        await _handle_update(update)
    except Exception as e:
        logger.error(f"Erreur traitement update Telegram : {e}", exc_info=True)
        # Toujours retourner 200 à Telegram pour éviter les retentatives
    return JSONResponse(content={"ok": True})


# ── Routeur d'update ───────────────────────────────────────────────────────────

async def _handle_update(update: dict) -> None:
    """Dispatche l'update Telegram vers le bon handler."""
    if "message" in update:
        await _handle_message(update["message"])
    elif "callback_query" in update:
        await _handle_callback_query(update["callback_query"])
    else:
        logger.debug(f"Update ignoré (type non géré) : {list(update.keys())}")


# ── Gestion des messages ───────────────────────────────────────────────────────

async def _handle_message(message: dict) -> None:
    """Traite un message entrant Telegram."""
    chat = message.get("chat", {})
    chat_id: int = chat.get("id", 0)
    user = message.get("from", {})
    user_name = _get_display_name(user)
    text: str = message.get("text", "").strip()
    contact = message.get("contact")

    if not chat_id:
        return

    if contact:
        logger.info(f"Contact recu de chat_id={chat_id} ({user_name})")
    else:
        logger.info(f"Message recu de chat_id={chat_id} ({user_name}) : {text[:80]!r}")

    # Rate limiting
    if not _security.check_rate_limit(chat_id):
        await _telegram.send_message(
            chat_id,
            "⏳ Trop de requêtes. Veuillez patienter avant de réessayer.",
        )
        return

    if contact:
        await _handle_contact_message(chat_id, contact, user)
        return

    if not text:
        return

    # Sanitiser le texte
    text_clean = _security.sanitize_text_input(text)

    # Commandes spéciales (commencent par /)
    if text_clean.startswith("/"):
        await _handle_command(chat_id, text_clean, user_name, user)
        return

    # Message en langage naturel
    await _handle_natural_language(chat_id, text_clean, user_name, user)


# ── Commandes Telegram ─────────────────────────────────────────────────────────

async def _handle_command(
    chat_id: int, command: str, user_name: str, user: dict
) -> None:
    """Gère les commandes Telegram (/start, /help, /tarif, /link)."""
    cmd_parts = command.split(maxsplit=1)
    cmd = cmd_parts[0].lower().split("@")[0]  # Supprimer @botname si présent
    args = cmd_parts[1] if len(cmd_parts) > 1 else ""

    if cmd == "/start":
        await _telegram.send_start(chat_id, user_name)
        return

    if cmd == "/help":
        partner = await _resolve_partner(chat_id, user)
        is_com = _odoo.is_commercial(partner) if partner else False
        await _telegram.send_help(chat_id, is_commercial=is_com)
        return

    if cmd == "/tarif":
        partner = await _resolve_partner(chat_id, user)
        if not partner:
            await _telegram.send_not_authorized(chat_id)
            return
        await _process_tarif(chat_id, user_name)
        return

    if cmd == "/link":
        await _handle_link_command(chat_id, args, user)
        return

    # Commande inconnue
    await _telegram.send_message(
        chat_id,
        "❓ Commande inconnue. Utilisez /help pour voir les commandes disponibles.",
    )


async def _handle_link_command(chat_id: int, args: str, user: dict) -> None:
    """
    /link <numéro_téléphone> — Associe un compte Odoo à ce Telegram.
    Ou /link — Affiche les instructions.
    """
    if not args:
        await _telegram.send_message(
            chat_id,
            "📞 <b>Association de compte</b>\n\n"
            "Utilisez : <code>/link votre_numéro_de_téléphone</code>\n"
            "Exemple : <code>/link +33612345678</code>\n\n"
            "Le numéro doit correspondre à votre fiche contact Odoo.",
        )
        return

    phone = _security.sanitize_phone(args.strip())
    if not phone:
        await _telegram.send_message(chat_id, "❌ Numéro de téléphone invalide.")
        return

    # Chercher le partenaire par téléphone dans Odoo
    await _telegram.send_typing_action(chat_id)
    partner = _odoo.find_partner_by_phone(phone)

    if not partner:
        await _telegram.send_message(
            chat_id,
            f"❌ Aucun contact trouvé avec le numéro : <code>{phone}</code>\n\n"
            "Vérifiez que ce numéro est bien enregistré dans votre fiche Odoo.",
        )
        return

    success = _auth.link_telegram_to_partner(chat_id, partner["id"], phone)
    if success:
        role = "Commercial" if _odoo.is_commercial(partner) else "Client"
        await _telegram.send_message(
            chat_id,
            f"✅ <b>Association réussie !</b>\n\n"
            f"Compte lié : <b>{partner['name']}</b>\n"
            f"Rôle : {role}\n\n"
            "Vous pouvez maintenant utiliser le bot.\n"
            "Tapez /help pour voir les fonctionnalités disponibles.",
        )
        logger.info(
            f"Liaison Telegram→Odoo créée : chat_id={chat_id} → "
            f"partner={partner['name']} (ID={partner['id']}), rôle={role}"
        )
    else:
        await _telegram.send_error(chat_id)


async def _handle_contact_message(chat_id: int, contact: dict, user: dict) -> None:
    """Associe le compte via un contact Telegram partage volontairement."""
    contact_user_id = contact.get("user_id")
    telegram_user_id = user.get("id")
    if contact_user_id and telegram_user_id and contact_user_id != telegram_user_id:
        await _telegram.send_message(
            chat_id,
            "❌ Merci de partager votre propre contact Telegram.",
        )
        return

    phone = _security.sanitize_phone(contact.get("phone_number", ""))
    if not phone:
        await _telegram.send_message(chat_id, "❌ Numéro de téléphone invalide.")
        return

    await _handle_link_command(chat_id, phone, user)


# ── Logique langage naturel ────────────────────────────────────────────────────

async def _handle_natural_language(
    chat_id: int, text: str, user_name: str, user: dict
) -> None:
    """Analyse un message en langage naturel et demande confirmation avant d'agir."""
    partner = await _resolve_partner(chat_id, user)

    if not partner:
        await _telegram.send_not_authorized(chat_id)
        return

    await _telegram.send_typing_action(chat_id)
    analysis = _ai.analyze_message(text)
    intent = analysis.get("intent", "unknown")

    # Normaliser les noms extraits
    analysis["client_name"] = _security.sanitize_partner_name(analysis.get("client_name", ""))
    analysis["supplier_name"] = _security.sanitize_partner_name(analysis.get("supplier_name", ""))

    logger.info(
        f"Intention détectée pour chat_id={chat_id} : "
        f"intent={intent}, client='{analysis['client_name']}', "
        f"supplier='{analysis['supplier_name']}', article='{analysis.get('article_name', '')}'"
    )

    # help et unknown → réponse directe, pas de confirmation
    if intent == "help":
        is_com = _odoo.is_commercial(partner)
        await _telegram.send_help(chat_id, is_commercial=is_com)
        return

    if intent == "unknown":
        await _telegram.send_message(
            chat_id,
            "🤔 Je n'ai pas compris votre demande.\n\n"
            "Utilisez /help pour voir les commandes disponibles.\n\n"
            "<i>Exemples :</i>\n"
            "• \"envoie le tarif\"\n"
            "• \"stock du produit REF001\"\n"
            "• \"factures du client DUPONT\"",
        )
        return

    # Pour toute autre intention, demander confirmation
    _pending_analysis[chat_id] = {
        "analysis": analysis,
        "partner": partner,
        "user_name": user_name,
    }

    msg = _build_confirmation_message(analysis)
    reply_markup = {
        "inline_keyboard": [[
            {"text": "✅ Oui, c'est ça", "callback_data": "confirm_yes"},
            {"text": "❌ Non, reformuler", "callback_data": "confirm_no"},
        ]]
    }
    await _telegram.send_message(chat_id, msg, reply_markup=reply_markup)


def _build_confirmation_message(analysis: dict) -> str:
    """Construit un résumé lisible de ce que l'IA a compris."""
    intent = analysis.get("intent", "unknown")
    client = analysis.get("client_name", "")
    supplier = analysis.get("supplier_name", "")
    article = analysis.get("article_name", "")

    intent_labels = {
        "tarif": "Catalogue tarifaire (PDF)",
        "stock": "Informations de stock",
        "client_documents": "Documents client",
        "supplier_documents": "Documents fournisseur",
        "actions": "Actions commerciales / CRM",
    }

    lines = ["🤔 <b>Voici ce que j'ai compris :</b>\n"]
    lines.append(f"📌 <b>Type :</b> {intent_labels.get(intent, intent)}")

    if client:
        lines.append(f"👤 <b>Client :</b> {client}")
    if supplier:
        lines.append(f"🏭 <b>Fournisseur :</b> {supplier}")
    if article:
        lines.append(f"📦 <b>Article / Catégorie :</b> {article}")

    lines.append("\n<i>Est-ce bien ce que vous souhaitez ?</i>")
    return "\n".join(lines)


async def _dispatch_analysis(
    chat_id: int, analysis: dict, partner: dict, user_name: str
) -> None:
    """Exécute l'action correspondant à l'analyse IA confirmée."""
    intent = analysis.get("intent", "unknown")
    client_name = analysis.get("client_name", "")
    supplier_name = analysis.get("supplier_name", "")
    article_name = analysis.get("article_name", "")
    is_com = _odoo.is_commercial(partner)

    if intent == "tarif":
        await _process_tarif(chat_id, user_name)

    elif intent == "stock":
        if not is_com:
            commercial_name = _odoo.get_commercial_of_client(partner["id"]) or ""
            await _telegram.send_client_restricted(chat_id, commercial_name)
            return
        await _process_stock(chat_id, article_name, user_name)

    elif intent == "client_documents":
        if not is_com:
            commercial_name = _odoo.get_commercial_of_client(partner["id"]) or ""
            await _telegram.send_client_restricted(chat_id, commercial_name)
            return
        await _process_document_contact(
            chat_id,
            client_name,
            preferred_kind="client",
            commercial_partner=partner,
            requester=user_name,
        )

    elif intent == "supplier_documents":
        if not is_com:
            commercial_name = _odoo.get_commercial_of_client(partner["id"]) or ""
            await _telegram.send_client_restricted(chat_id, commercial_name)
            return
        contact_name = supplier_name or client_name
        await _process_document_contact(
            chat_id,
            contact_name,
            preferred_kind="supplier",
            commercial_partner=partner,
            requester=user_name,
        )

    elif intent == "actions":
        if not is_com:
            commercial_name = _odoo.get_commercial_of_client(partner["id"]) or ""
            await _telegram.send_client_restricted(chat_id, commercial_name)
            return
        await _telegram.send_message(
            chat_id,
            "📋 <b>Actions commerciales</b>\n\n"
            "Cette fonctionnalité sera disponible prochainement.\n"
            "En attendant, consultez directement Odoo CRM.",
        )

    else:
        await _telegram.send_message(
            chat_id,
            "🤔 Je n'ai pas pu traiter cette demande.\n"
            "Utilisez /help pour voir les commandes disponibles.",
        )


# ── Processeurs de tâches ──────────────────────────────────────────────────────

async def _process_tarif(chat_id: int, requester: str = "") -> None:
    """Génère et envoie le PDF tarif."""
    await _telegram.send_generating_pdf(chat_id, "tarif")
    await _telegram.send_upload_action(chat_id)

    products = _odoo.get_products_paginated()
    if not products:
        await _telegram.send_message(
            chat_id,
            "⚠️ Aucun article actif trouvé dans Odoo.",
        )
        return

    pdf_path = _pdf.generate_tarif_pdf(products, requester=requester)
    if not pdf_path:
        await _telegram.send_error(chat_id, "Erreur génération PDF tarif")
        return

    caption = f"📋 <b>Catalogue Tarifaire DOOBAT</b>\n{len(products)} article(s)"
    success = await _telegram.send_pdf_to_telegram(chat_id, pdf_path, caption)
    if not success:
        await _telegram.send_error(chat_id, "Impossible d'envoyer le PDF")

    # Nettoyage programmé
    _cleanup.cleanup_old_pdfs()


async def _process_stock(
    chat_id: int, article_name: str, requester: str = ""
) -> None:
    """Génère et envoie le PDF stock."""
    stock_scope, stock_filter = _parse_stock_request(article_name)

    if stock_scope == "all":
        await _telegram.send_generating_pdf(chat_id, "stock")
        await _telegram.send_upload_action(chat_id)
        products = _odoo.get_stock_products()
        report_label = "Tous les produits"
    elif stock_scope == "category":
        await _telegram.send_generating_pdf(chat_id, "stock")
        await _telegram.send_upload_action(chat_id)
        products = _odoo.get_stock_products(category_name=stock_filter)
        report_label = f"Catégorie : {stock_filter}"
    else:
        await _telegram.send_typing_action(chat_id)
        products = _odoo.get_stock_for_product(stock_filter)
        report_label = stock_filter
        if len(products) > 1:
            await _send_stock_product_choices(chat_id, stock_filter, products, requester)
            return

        if not products:
            similar_products = _odoo.search_similar_stock_products(stock_filter, limit=12)
            if len(similar_products) > 1:
                await _send_stock_product_choices(
                    chat_id,
                    stock_filter,
                    similar_products,
                    requester,
                )
                return
            if len(similar_products) == 1:
                products = similar_products

        if not products:
            category_products = _odoo.get_stock_products(category_name=stock_filter)
            if category_products:
                products = category_products
                report_label = f"Catégorie : {stock_filter}"

        if not products:
            await _send_empty_stock_result(chat_id, stock_filter, requester)
            return

        await _telegram.send_generating_pdf(chat_id, "stock")
        await _telegram.send_upload_action(chat_id)

    if not products:
        await _send_empty_stock_result(chat_id, report_label, requester)
        return

    pdf_path = _pdf.generate_stock_pdf(products, article_name=report_label, requester=requester)
    if not pdf_path:
        await _telegram.send_error(chat_id, "Erreur génération PDF stock")
        return

    caption = f"📦 <b>Stock : {report_label}</b>\n{len(products)} article(s) trouvé(s)"
    await _telegram.send_pdf_to_telegram(chat_id, pdf_path, caption)
    _cleanup.cleanup_old_pdfs()


async def _send_empty_stock_result(
    chat_id: int,
    requested_name: str,
    requester: str,
) -> None:
    """Revient dans la conversation quand aucun article n'est trouve."""
    retry_options = _build_stock_retry_options(requested_name)
    lines = [
        f"Je n'ai trouvé aucun article pour <b>{html.escape(requested_name or 'cette recherche')}</b>.",
        "",
        "Essayez l'une de ces options, ou reformulez avec un mot-clé plus court ou une référence :",
    ]

    keyboard = []
    if retry_options:
        _pending_stock_retry_choices[chat_id] = {
            "options": retry_options,
            "requester": requester,
        }
        for index, option in enumerate(retry_options):
            keyboard.append([{
                "text": option["button"][:60],
                "callback_data": f"stock_retry:{index}",
            }])

    await _telegram.send_message(
        chat_id,
        "\n".join(lines),
        reply_markup={"inline_keyboard": keyboard} if keyboard else None,
    )


def _build_stock_retry_options(raw_filter: str) -> list[dict]:
    """Construit une relance simple pour une recherche vide.

    Le singulier et le pluriel d'un meme mot comptent comme un seul choix
    (la recherche par sous-chaine sur le singulier couvre aussi le pluriel).
    Une seule action "Rechercher" par terme : elle retombe deja sur la
    categorie correspondante si aucun article ne correspond (cf _process_stock).
    """
    value = _security.sanitize_article_name(raw_filter or "")
    stop_words = {
        "avec", "dans", "des", "les", "pour", "sans", "stock",
        "produit", "article", "articles", "produits", "recherche",
        "cherche", "trouve", "boite", "boîte",
    }
    words = [
        word
        for word in re.split(r"\W+", value.lower())
        if len(word) >= 3
    ]

    terms = []
    for word in words:
        base = word[:-1] if word.endswith("s") and len(word) > 3 else word
        if base not in stop_words:
            _add_unique(terms, base)

    if value and len(words) >= 2:
        _add_unique(terms, " ".join(words[:2]))

    options = []
    for term in terms[:2]:
        options.append({
            "action": "stock",
            "value": term,
            "label": f"Rechercher : {term}",
            "button": f"🔍 {term}",
        })

    options.append({
        "action": "stock",
        "value": "",
        "label": "Voir le stock global",
        "button": "📦 Stock global",
    })
    options.append({
        "action": "tarif",
        "value": "",
        "label": "Ouvrir le catalogue tarifaire",
        "button": "📋 Catalogue tarifaire",
    })
    return options[:4]


def _add_unique(values: list[str], value: str) -> None:
    """Ajoute value si aucune variante (accents/casse) n'est deja presente.
    'boîtes' et 'boites' comptent comme le meme terme."""
    clean_value = value.strip()
    if not clean_value:
        return
    normalized = _strip_accents(clean_value).casefold()
    if not any(_strip_accents(existing).casefold() == normalized for existing in values):
        values.append(clean_value)


def _strip_accents(value: str) -> str:
    return "".join(
        char
        for char in unicodedata.normalize("NFKD", value)
        if not unicodedata.combining(char)
    )


async def _send_stock_product_choices(
    chat_id: int,
    requested_name: str,
    products: list[dict],
    requester: str,
) -> None:
    """Propose les articles ressemblants pour une recherche de stock."""
    candidates = products[:12]
    _pending_stock_choices[chat_id] = {
        "products": candidates,
        "requester": requester,
        "requested_name": requested_name,
    }

    lines = [
        f"J'ai trouvé plusieurs articles ressemblants pour <b>{html.escape(requested_name)}</b>.",
        "Choisissez l'article à consulter, ou obtenez le tarif/stock de tous ces articles :",
    ]
    keyboard = []
    for index, product in enumerate(candidates):
        ref = str(product.get("default_code") or "").strip()
        name = str(product.get("name", "Article")).strip()
        qty = product.get("qty_available", 0) or 0
        label = f"{ref} - {name}" if ref else name
        lines.append(
            f"{index + 1}. {html.escape(label)} "
            f"({qty:.0f} dispo)"
        )
        keyboard.append([{
            "text": f"{index + 1}. {label[:42]}",
            "callback_data": f"stock_product:{index}",
        }])

    keyboard.append([{
        "text": "📋 Catalogue tarifaire de ces articles",
        "callback_data": "stock_group_tarif",
    }])
    keyboard.append([{
        "text": "📦 Stock de tous ces articles",
        "callback_data": "stock_group_stock",
    }])

    await _telegram.send_message(
        chat_id,
        "\n".join(lines),
        reply_markup={"inline_keyboard": keyboard},
    )


async def _send_tarif_pdf_for_products(
    chat_id: int,
    products: list[dict],
    requester: str,
) -> None:
    """Genere et envoie un PDF tarif pour une liste d'articles deja resolue."""
    await _telegram.send_generating_pdf(chat_id, "tarif")
    await _telegram.send_upload_action(chat_id)

    pdf_path = _pdf.generate_tarif_pdf(products, requester=requester)
    if not pdf_path:
        await _telegram.send_error(chat_id, "Erreur génération PDF tarif")
        return

    caption = f"📋 <b>Catalogue Tarifaire</b>\n{len(products)} article(s)"
    success = await _telegram.send_pdf_to_telegram(chat_id, pdf_path, caption)
    if not success:
        await _telegram.send_error(chat_id, "Impossible d'envoyer le PDF")
    _cleanup.cleanup_old_pdfs()


async def _send_stock_pdf_for_products(
    chat_id: int,
    products: list[dict],
    report_label: str,
    requester: str,
) -> None:
    """Genere et envoie un PDF stock pour une liste d'articles deja resolue."""
    await _telegram.send_generating_pdf(chat_id, "stock")
    await _telegram.send_upload_action(chat_id)

    pdf_path = _pdf.generate_stock_pdf(
        products,
        article_name=report_label,
        requester=requester,
    )
    if not pdf_path:
        await _telegram.send_error(chat_id, "Erreur génération PDF stock")
        return

    caption = f"📦 <b>Stock : {report_label}</b>\n{len(products)} article(s) trouvé(s)"
    await _telegram.send_pdf_to_telegram(chat_id, pdf_path, caption)
    _cleanup.cleanup_old_pdfs()


def _parse_stock_request(raw_filter: str) -> tuple[str, str]:
    """Determine si la demande concerne tout le stock, une categorie ou un article."""
    raw_value = re.sub(r"\b[ld]['’]\s*", "", raw_filter or "", flags=re.IGNORECASE)
    value = _security.sanitize_article_name(raw_value)
    normalized = value.lower()

    all_markers = [
        "", "tout", "tous", "toutes", "tout le stock", "stock complet",
        "stock general", "stock général", "inventaire complet",
        "tous les produits", "tout les produits", "tout les produit",
        "tous les articles", "tout les articles",
    ]
    if normalized in all_markers:
        return "all", ""

    category_match = re.match(
        r"^(?:categorie|catégorie|cat[eé]gorie|famille)\s*[:\-\s]\s*(.+)$",
        value,
        re.IGNORECASE,
    )
    if category_match:
        category_name = _security.sanitize_article_name(category_match.group(1))
        if category_name:
            return "category", category_name

    if any(
        marker in normalized
        for marker in ["tous les produits", "tout les produits", "stock complet"]
    ):
        return "all", ""

    return "product", value


async def _process_document_contact(
    chat_id: int,
    contact_name: str,
    preferred_kind: str,
    commercial_partner: dict,
    requester: str = "",
) -> None:
    """Trouve le type client/fournisseur pour une demande de documents."""
    clean_name = (contact_name or "").strip()
    if not clean_name:
        if preferred_kind == "supplier":
            await _process_supplier_documents(chat_id, "", requester)
            return
        await _telegram.send_message(
            chat_id,
            "❓ Précisez le nom du contact.\n"
            "Exemple : <i>\"documents de DUPONT\"</i>",
        )
        return

    await _telegram.send_typing_action(chat_id)
    candidates = _odoo.search_document_contact_candidates(clean_name, limit=6)
    if not candidates:
        await _telegram.send_message(
            chat_id,
            f"❌ Aucun contact trouvé pour : <b>{html.escape(clean_name)}</b>\n\n"
            "Vérifiez l'orthographe ou utilisez le nom exact tel qu'il apparaît dans Odoo.",
        )
        return

    selected = _pick_document_candidate(candidates, preferred_kind)
    if selected:
        await _execute_document_candidate(
            chat_id,
            selected,
            commercial_partner,
            requester,
        )
        return

    await _send_document_contact_choices(
        chat_id,
        clean_name,
        candidates,
        commercial_partner,
        requester,
    )


def _pick_document_candidate(candidates: list[dict], preferred_kind: str) -> dict | None:
    """Selectionne automatiquement un candidat quand le choix est evident."""
    if len(candidates) == 1:
        return candidates[0]

    preferred = [
        candidate
        for candidate in candidates
        if candidate.get("document_kind") == preferred_kind
    ]
    if len(preferred) == 1 and preferred_kind == "supplier":
        return preferred[0]

    kinds = {candidate.get("document_kind") for candidate in candidates}
    if len(kinds) == 1 and len(candidates) == 1:
        return candidates[0]

    return None


async def _send_document_contact_choices(
    chat_id: int,
    requested_name: str,
    candidates: list[dict],
    commercial_partner: dict,
    requester: str,
) -> None:
    """Propose des contacts possibles avec boutons inline."""
    _pending_contact_choices[chat_id] = {
        "candidates": candidates,
        "commercial_partner": commercial_partner,
        "requester": requester,
    }

    lines = [
        f"Je n'ai pas trouvé un contact unique pour <b>{html.escape(requested_name)}</b>.",
        "Choisissez le contact à utiliser :",
    ]
    keyboard = []
    for index, candidate in enumerate(candidates[:6]):
        kind = "Fournisseur" if candidate.get("document_kind") == "supplier" else "Client"
        name = str(candidate.get("name", "Contact"))
        lines.append(f"{index + 1}. {kind} - {html.escape(name)}")
        keyboard.append([{
            "text": f"{index + 1}. {kind} - {name[:32]}",
            "callback_data": f"doc_contact:{index}",
        }])

    await _telegram.send_message(
        chat_id,
        "\n".join(lines),
        reply_markup={"inline_keyboard": keyboard},
    )


async def _execute_document_candidate(
    chat_id: int,
    candidate: dict,
    commercial_partner: dict,
    requester: str,
) -> None:
    """Lance le bon generateur de documents pour un candidat choisi."""
    if candidate.get("document_kind") == "supplier":
        await _process_supplier_documents(
            chat_id,
            str(candidate.get("name", "")),
            requester,
            supplier=candidate,
        )
        return

    await _process_client_documents(
        chat_id,
        str(candidate.get("name", "")),
        commercial_partner,
        requester,
        client=candidate,
    )


async def _process_client_documents(
    chat_id: int,
    client_name: str,
    commercial_partner: dict,
    requester: str = "",
    client: dict | None = None,
) -> None:
    """Génère et envoie le PDF des documents d'un client."""
    if not client_name:
        await _telegram.send_message(
            chat_id,
            "❓ Précisez le nom du client.\n"
            "Exemple : <i>\"factures du client DUPONT\"</i>",
        )
        return

    # Chercher le client dans Odoo
    await _telegram.send_typing_action(chat_id)
    client = client or _odoo.find_partner_by_name(client_name)
    if not client:
        await _telegram.send_message(
            chat_id,
            f"❌ Client introuvable : <b>{client_name}</b>\n\n"
            "Vérifiez l'orthographe ou utilisez le nom exact tel qu'il apparaît dans Odoo.",
        )
        return

    # Vérifier que ce client est bien affecté au commercial
    commercial_user_id = commercial_partner.get("user_id")
    if isinstance(commercial_user_id, (list, tuple)):
        commercial_user_id = commercial_user_id[0]

    if commercial_user_id:
        is_assigned = _odoo.check_client_assigned_to_commercial(
            client["id"], commercial_user_id
        )
        if not is_assigned:
            real_commercial = _odoo.get_commercial_of_client(client["id"]) or ""
            await _telegram.send_client_not_assigned(chat_id, real_commercial)
            return

    await _telegram.send_generating_pdf(chat_id, "client_documents")
    await _telegram.send_upload_action(chat_id)

    documents = _odoo.get_full_client_documents(client["id"], months=12)
    pdf_path = _pdf.generate_client_documents_pdf(
        client_name=client["name"],
        documents=documents,
        requester=requester,
        months=12,
    )
    if not pdf_path:
        await _telegram.send_error(chat_id, "Erreur génération PDF documents")
        return

    total_docs = (
        len(documents.get("quotations", []))
        + len(documents.get("orders", []))
        + len(documents.get("invoices", []))
    )
    caption = (
        f"📁 <b>Documents client : {client['name']}</b>\n"
        f"{total_docs} document(s) | 12 derniers mois"
    )
    await _telegram.send_pdf_to_telegram(chat_id, pdf_path, caption)
    _cleanup.cleanup_old_pdfs()


async def _process_supplier_documents(
    chat_id: int,
    supplier_name: str,
    requester: str = "",
    supplier: dict | None = None,
) -> None:
    """Genere et envoie le PDF des documents d'un fournisseur."""
    await _telegram.send_typing_action(chat_id)
    supplier = supplier or (_odoo.find_supplier_by_name(supplier_name) if supplier_name else None)
    if supplier_name and not supplier:
        await _telegram.send_message(
            chat_id,
            f"❌ Fournisseur introuvable : <b>{supplier_name}</b>",
        )
        return

    await _telegram.send_generating_pdf(chat_id, "supplier_documents")
    await _telegram.send_upload_action(chat_id)
    supplier_id = supplier["id"] if supplier else None
    report_name = supplier["name"] if supplier else "Tous les fournisseurs"
    documents = _odoo.get_full_supplier_documents(supplier_id, months=12)
    pdf_path = _pdf.generate_client_documents_pdf(
        client_name=report_name,
        documents=documents,
        requester=requester,
        months=12,
        document_kind="supplier",
    )
    if not pdf_path:
        await _telegram.send_error(chat_id, "Erreur génération PDF fournisseur")
        return

    total_docs = len(documents.get("orders", [])) + len(documents.get("invoices", []))
    caption = (
        f"📁 <b>Documents fournisseur : {report_name}</b>\n"
        f"{total_docs} document(s) | 12 derniers mois"
    )
    success = await _telegram.send_pdf_to_telegram(chat_id, pdf_path, caption)
    if not success:
        await _telegram.send_error(chat_id, "Impossible d'envoyer le PDF fournisseur")
    _cleanup.cleanup_old_pdfs()


# ── Résolution du partenaire ───────────────────────────────────────────────────

async def _resolve_partner(chat_id: int, user: dict) -> dict | None:
    """
    Tente de résoudre le partenaire Odoo associé à ce chat_id.
    1. Via association manuelle (telegram_contacts.json)
    2. Via numéro de téléphone (si user.phone_number disponible)
    Retourne le dict partenaire ou None.
    """
    # 1. Association manuelle
    partner_id = _auth.find_partner_by_telegram(chat_id)
    if partner_id:
        phone = _auth.get_linked_phone(chat_id)
        if phone:
            # Valider via téléphone d'abord
            partner = _odoo.find_partner_by_phone(phone, refresh=True)
            if partner and partner["id"] == partner_id:
                return partner
            # Fallback : la recherche par téléphone a échoué (Odoo inaccessible,
            # format de numéro différent) → on fait confiance au partner_id stocké
            logger.warning(
                f"Validation téléphone échouée pour chat_id={chat_id} "
                f"(phone={phone}) — fallback sur partner_id={partner_id}"
            )

        # Récupération directe par ID (association ancienne ou fallback)
        partner = _odoo.find_partner_by_id(partner_id, refresh=True)
        if partner:
            # Mettre à jour l'association avec le téléphone si absent
            if not phone:
                new_phone = _odoo.get_partner_phone(partner)
                if new_phone:
                    _auth.link_telegram_to_partner(chat_id, partner_id, new_phone)
            return partner

        return None

    # 2. Téléphone (disponible seulement si l'utilisateur partage son contact)
    phone = user.get("phone_number", "")
    if phone:
        partner = _odoo.find_partner_by_phone(phone)
        if partner:
            # Auto-associer pour les prochaines fois
            _auth.link_telegram_to_partner(chat_id, partner["id"], phone)
            return partner

    return None


# ── Callback query ─────────────────────────────────────────────────────────────

async def _handle_callback_query(callback: dict) -> None:
    """Gère les callbacks des boutons inline (confirmation d'analyse IA)."""
    callback_id = callback.get("id", "")
    chat_id = callback.get("message", {}).get("chat", {}).get("id", 0)
    data = callback.get("data", "")
    logger.debug(f"Callback query : chat_id={chat_id}, data={data!r}")

    # Acquitter immédiatement le clic bouton (obligatoire Telegram)
    await _telegram._api_call("answerCallbackQuery", {"callback_query_id": callback_id})

    if data == "confirm_yes":
        pending = _pending_analysis.pop(chat_id, None)
        if not pending:
            await _telegram.send_message(
                chat_id,
                "⏱️ Session expirée. Veuillez reformuler votre demande.",
            )
            return
        await _dispatch_analysis(
            chat_id,
            pending["analysis"],
            pending["partner"],
            pending["user_name"],
        )

    elif data == "confirm_no":
        _pending_analysis.pop(chat_id, None)
        await _telegram.send_message(
            chat_id,
            "🔄 D'accord, reformulez votre demande.\n\n"
            "Utilisez /help pour voir des exemples de formulations.",
        )

    elif data.startswith("doc_contact:"):
        choice = _pending_contact_choices.pop(chat_id, None)
        if not choice:
            await _telegram.send_message(
                chat_id,
                "⏱️ Session expirée. Veuillez reformuler votre demande.",
            )
            return

        try:
            index = int(data.split(":", 1)[1])
        except (ValueError, IndexError):
            await _telegram.send_message(chat_id, "Choix invalide.")
            return

        candidates = choice.get("candidates", [])
        if index < 0 or index >= len(candidates):
            await _telegram.send_message(chat_id, "Choix invalide.")
            return

        await _execute_document_candidate(
            chat_id,
            candidates[index],
            choice["commercial_partner"],
            choice["requester"],
        )

    elif data.startswith("stock_product:"):
        choice = _pending_stock_choices.pop(chat_id, None)
        if not choice:
            await _telegram.send_message(
                chat_id,
                "⏱️ Session expirée. Veuillez reformuler votre demande.",
            )
            return

        try:
            index = int(data.split(":", 1)[1])
        except (ValueError, IndexError):
            await _telegram.send_message(chat_id, "Choix invalide.")
            return

        products = choice.get("products", [])
        if index < 0 or index >= len(products):
            await _telegram.send_message(chat_id, "Choix invalide.")
            return

        product = products[index]
        label = str(product.get("default_code") or product.get("name") or "Article")
        await _send_stock_pdf_for_products(
            chat_id,
            [product],
            label,
            choice["requester"],
        )

    elif data in ("stock_group_tarif", "stock_group_stock"):
        choice = _pending_stock_choices.pop(chat_id, None)
        if not choice:
            await _telegram.send_message(
                chat_id,
                "⏱️ Session expirée. Veuillez reformuler votre demande.",
            )
            return

        products = choice.get("products", [])
        requester = choice.get("requester", "")
        if data == "stock_group_tarif":
            await _send_tarif_pdf_for_products(chat_id, products, requester)
        else:
            await _send_stock_pdf_for_products(
                chat_id,
                products,
                choice.get("requested_name", ""),
                requester,
            )

    elif data.startswith("stock_retry:"):
        choice = _pending_stock_retry_choices.pop(chat_id, None)
        if not choice:
            await _telegram.send_message(
                chat_id,
                "⏱️ Session expirée. Veuillez reformuler votre demande.",
            )
            return

        try:
            index = int(data.split(":", 1)[1])
        except (ValueError, IndexError):
            await _telegram.send_message(chat_id, "Choix invalide.")
            return

        options = choice.get("options", [])
        if index < 0 or index >= len(options):
            await _telegram.send_message(chat_id, "Choix invalide.")
            return

        option = options[index]
        if option.get("action") == "tarif":
            await _process_tarif(chat_id, choice["requester"])
        else:
            await _process_stock(chat_id, option.get("value", ""), choice["requester"])


# ── Utilitaires ────────────────────────────────────────────────────────────────

def _get_display_name(user: dict) -> str:
    """Construit le nom d'affichage d'un utilisateur Telegram."""
    first = user.get("first_name", "")
    last = user.get("last_name", "")
    username = user.get("username", "")
    if first or last:
        return f"{first} {last}".strip()
    return username or "Inconnu"
