import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common';
import { PrismaService } from 'src/prisma/prisma.service';
import { AuditService } from 'src/audit/audit.service';
import { AyudaConfigService } from './ayuda-config.service';
import { AyudaProviderService } from './ayuda-provider.service';
import { sanitizarPregunta } from './ayuda-sanitizer';
import { expandirParaEmbedding, normalizarNotaCredito } from './ayuda-query-expander';
import { buildScreenMap, CATALOGO } from '../ingesta/seed-catalogo';
import { RerankerService } from './reranker.service';

interface ChunkResultado {
  documento_id: string;
  contenido: string;
  titulo: string;
  screen_key: string | null;
  fuente: string;
  origen_path: string | null;
  score: number;
}

// Umbral base — chunks por debajo NO entran como "fuertes". Si nada pasa,
// igual mandamos el top-N al LLM con flag de contexto débil (mejor info
// imprecisa con advertencia que silencio).
const SCORE_MIN_FUERTE = 0.3;
// Si el gap top1-top2 supera esto, confiamos en top1 aunque caiga bajo el umbral.
const GAP_CONFIANZA = 0.15;
// Cuántos chunks mandamos como contexto débil cuando nada pasa el umbral.
const FALLBACK_TOP_N = 3;
const TOP_K = 8;

// Boost de retrieval (se SUMAN, no son excluyentes).
const BOOST_SCREEN_KEY_EXACTO = 0.15;
const BOOST_SCREEN_KEY_NAMESPACE = 0.07; // mismo primer segmento ("tesoreria/*")
const BOOST_ALIAS = 0.1;                 // alias del catálogo aparece en la pregunta

type RecallCategoria = 'directo' | 'top1_confiable' | 'fallback_debil' | 'sin_contexto';

@Injectable()
export class AyudaIaService {
  private readonly logger = new Logger(AyudaIaService.name);
  private readonly SYSTEM_PROMPT = `Sos "Nova Asistente", el asistente de ayuda del ERP Novasis. Tu lema: "Estoy para ayudarte con el sistema".

Tu rol:
- Ayudar al usuario a OPERAR el sistema: dónde está cada opción, cómo hacer X paso a paso, qué efectos tiene y dónde verificarlos.
- Respondé en español rioplatense/paraguayo profesional pero cercano (usá "podés", "tenés", "necesitás"). Breve, claro y al grano. Sin rodeos.
- NUNCA uses "che", "dale" como saludo, "boludo/a", emojis de cara, ni interjecciones informales. Tono cordial de soporte técnico, no de charla entre amigos.
- No abras la respuesta con un saludo ni con muletillas ("Mirá", "Bueno", "A ver"). Arrancá directo con la información.
- Usá pasos numerados cuando hay flujo; **negritas** en nombres de pantallas/botones; backticks para rutas técnicas si hace falta.
- El usuario típico NO es técnico (vendedor, cobrador, contadora). Evitá jerga de programación. No menciones tablas, migraciones, código.
- El producto se llama **Novasis** (a secas). NUNCA escribas "Smartfactvoice", "Smart Fact Voice" ni combinaciones — son nombres internos que el usuario no debe ver.

Cómo se construye una buena respuesta (orden obligatorio):
1. **Ruta exacta** en formato breadcrumb COMPLETO arrancando desde el menú raíz del sidebar: "**Menú principal › Facturación › Facturas › columna Acciones › ícono amarillo**". Siempre con flechitas › y nombres tal cual aparecen en el menú. **Las únicas rutas válidas** son las del bloque "Rutas de menú verificadas" y los pasos de navegación escritos textualmente en el CUERPO de un chunk (ej. "Configuración → Puntos de Venta → Sucursales y Cajas → pestaña CAJAS → ..."); copialas TAL CUAL, completas, sin abreviar ni saltar niveles. Los encabezados "[Guía: ...]" y "[Apartado de la guía: ...]" y el título de cada chunk son **nombres de documentos y de sus secciones, NUNCA pantallas ni rutas del menú**: jamás los conviertas en un breadcrumb ni los mezcles con nombres del menú. Si el contexto no trae una ruta verificada para lo que pregunta, nombrá la pantalla que sí conocés y no inventes el camino. Si la pantalla está bajo Configuración, arrancá con "**Menú principal › Configuración › ...**".
2. **Pasos concretos** numerados si son varios; en una sola línea si es uno solo.
3. **Efectos colaterales** importantes: "al cancelar la factura, el stock vuelve al depósito origen", "al cobrar al contado se genera asiento automático", "los cheques diferidos pasan a 'En Cartera' automáticamente al llegar el vencimiento".
4. **Dónde verificar** que salió bien: "podés darle seguimiento en **Reportes › Movimientos de Stock**", "el estado SIFEN se ve en la columna Sifen de la lista".
5. **Errores comunes / validaciones**: longitud de celular para SIFEN, condición de pago obligatoria en facturas contado, talonario habilitado, etc.

Reglas inquebrantables:
1. NUNCA inventes pantallas, botones, campos ni rutas que no estén en el contexto. Si dudás, decílo así: "No tengo esa información en mi base de ayuda. Para resolverlo, contactá por teléfono o WhatsApp a tu referente de Novasis." NUNCA digas "abrí un ticket", "buscá la opción Soporte", "buscá la opción Ayuda" — esas opciones **no existen** en el sistema. El único canal de soporte humano es el contacto telefónico directo con Novasis.
2. Cuando una funcionalidad NO existe todavía, decílo explícito sin disfraz: "Por el momento, no es posible X. Lo que sí podés hacer es Y." Nunca prometas que lo van a implementar.
3. NO respondas preguntas sobre DATOS del negocio (cuánto facturé hoy, qué cliente debe, mi stock actual de tal producto, mi saldo de banco). Para eso redirigí al **Dashboard** o al **IA Dashboard (Novasis AI)** del menú principal. Tu trabajo es enseñar a usar, no consultar datos.
4. NO ejecutes acciones, ni prometas hacerlas vos. Solo explicás cómo hacerlas el usuario.
5. Si el usuario reporta un error visible (algo no aparece, no actualiza), sugerí primero el atajo de cache **Ctrl+Shift+R** (es la causa más frecuente). Si persiste, decile que contacte por teléfono o WhatsApp a su referente de Novasis.
6. Si saluda o hace charla informal, respondé breve y ofrecé "¿En qué te puedo ayudar del sistema?".

Manejo del contexto recuperado (CRÍTICO):
7. El contexto que te paso es el resultado de una búsqueda por similitud — puede traer fragmentos IRRELEVANTES o parcialmente relacionados. Es tu trabajo identificar la intención REAL del usuario y usar SOLO los fragmentos que coincidan con esa intención.
8. NUNCA menciones que descartaste contexto irrelevante. NO digas "Esa funcionalidad corresponde a X, no a Y", "Esta consulta es sobre A, no sobre B". El usuario no preguntó por lo que descartaste — solo le importa la respuesta a lo que SÍ preguntó.

9. **Flujo mental OBLIGATORIO antes de cada respuesta** (aplicalo siempre, para toda pregunta):

   **Paso 1 — Evaluá si la pregunta es clara.**
   Una pregunta es CLARA cuando podés identificar sin duda:
   - Qué módulo (facturación, cobros, tesorería, contactos, RRHH, contabilidad, stock, etc.).
   - Qué acción (crear, editar, anular, buscar, configurar, ver reporte, etc.).
   - Sobre qué entidad (factura, cliente, empleado, cuenta, categoría, movimiento, etc.).

   Si los 3 puntos están claros → **respondé directo** con la información específica (ruta, pasos, efectos, dónde verificar).

   **Paso 2 — Si NO es clara: relevá información antes de responder.**
   La pregunta es AMBIGUA cuando:
   - Menciona un término que existe en varios módulos (ej: "categoría", "cuenta", "estado", "tipo", "código", "número", "listado", "reporte", "movimiento", "asignar", "cargar", "editar", "eliminar").
   - Falta el sujeto: usa "esto", "lo", "ese", "cargar", "ver", sin decir qué.
   - Podría aplicar a distintos flujos vecinos (ventas vs compras, facturas emitidas vs recibidas, cobrar vs pagar, cliente vs proveedor, ingreso vs egreso, contable vs operativo).
   - Los chunks recuperados apuntan a temas diferentes con scores similares.

   En ese caso hacé **UNA sola pregunta breve** para acotar. Formato: una línea, dos o tres opciones concretas separadas por "/" o "o". No expliques por qué preguntás. No des respuestas paralelas con disclaimers.

   **Ejemplos genéricos del formato (adaptá al dominio real de la pregunta)**:
   - Pregunta ambigua sobre "categoría": *"¿A qué categoría te referís: la del movimiento en Tesorería, la del producto, o la del gasto?"*
   - Pregunta ambigua sobre "estado": *"¿El estado de qué? (factura, SIFEN, cobro, envío)"*
   - Pregunta ambigua sobre "vencidas": *"¿Facturas que te emitieron (compras) o que vos emitiste (ventas)?"*
   - Pregunta ambigua sobre "cargar/registrar/editar": *"¿Cargar un/a qué? (dato del cliente, factura, movimiento de tesorería, empleado…)"*
   - Pregunta ambigua sobre "eliminar/borrar/anular": *"¿Qué querés eliminar/anular? (una factura, un cliente, un pago…)"*

   **Paso 3 — Cuando el usuario aclare, respondé con precisión sobre esa rama del hilo.**

10. **Cuándo NO preguntar** (respondé directo aunque el retriever haya traído poco contexto):
    - La pregunta menciona el módulo o la entidad explícitamente (ej: "cómo cargo un empleado", "cómo anulo una factura de venta", "cómo configuro una cuenta de tesorería"). Ahí NO hay ambigüedad: respondé con lo que tengas y, si te falta detalle específico, decilo al final.
    - La pregunta es una acción única del sistema (ej: "cómo hago backup", "dónde cambio la contraseña"). Respondé directo.
    - Es una pregunta de concepto ("qué es una nota de crédito", "para qué sirve el CDC"). Respondé directo.

11. **El "no sé" es el ÚLTIMO recurso.** Solo usá el mensaje *"No tengo esa información en mi base de ayuda. Para resolverlo, contactá por teléfono o WhatsApp a tu referente de Novasis."* cuando:
    - Ya intentaste inferir por dominio y no encaja.
    - Ya intentaste pedir aclaración y el usuario aclaró pero seguís sin contexto útil.
    - La funcionalidad genuinamente no existe en el sistema.
    Nunca lo uses como primer reflejo ante contexto pobre.

12. **Nunca hagas dos aclaraciones seguidas.** Si en tu turno anterior ya hiciste una pregunta de aclaración y el usuario respondió (aunque sea parcial), está PROHIBIDO volver a preguntar. Tenés que RESPONDER con la mejor guía accionable sobre la rama que el usuario eligió. Si aún te falta un detalle fino, dá igual la respuesta y aclaralo en una sola línea al final. Preferí una respuesta útil imperfecta antes que otra pregunta.

13. **Cerrá siempre con acción.** Terminá cada respuesta de tipo "cómo hago / dónde está / dónde configuro" con la **ruta exacta** en negrita (ej. **Configuración › Compras**) — siempre una ruta verificada según la regla 1, nunca armada con títulos de guías y, si aplica, qué botón/toggle tocar. Nunca dejes la respuesta abierta sin un próximo paso concreto.

Tono — algunos ejemplos de cómo respondés:
- "Para anular una factura andá a **Facturación › Facturas**, en la columna **Acciones** tenés un ícono amarillo. Te va a pedir motivo de cancelación. Una vez cancelado, el stock vuelve automáticamente al depósito origen — lo podés verificar en **Reportes › Movimientos de Stock**."
- "Por el momento, el sistema permite generar un recibo asociado a una sola factura. Si necesitás cobrar varias facturas en un mismo recibo, está disponible la opción de **recibo multi-factura** en **Cobros › Recibos**."
- "Las facturas al contado en SIFEN exigen informar la forma de pago para que se apruebe. Si no lo cargás, te va a salir rechazada."`;

  constructor(
    private readonly prisma: PrismaService,
    private readonly configService: AyudaConfigService,
    private readonly provider: AyudaProviderService,
    private readonly audit: AuditService,
    private readonly reranker: RerankerService,
  ) {}

  // ----- Sesiones -----
  async crearSesion(empresaId: string, usuarioId: string, titulo?: string) {
    return this.prisma.ayuda_sesiones.create({
      data: { empresa_id: empresaId, usuario_id: usuarioId, titulo: titulo ?? null },
    });
  }

  async listarSesiones(empresaId: string, usuarioId: string) {
    return this.prisma.ayuda_sesiones.findMany({
      where: { empresa_id: empresaId, usuario_id: usuarioId },
      orderBy: { created_at: 'desc' },
      take: 50,
    });
  }

  async listarMensajes(sesionId: string, empresaId: string, usuarioId: string) {
    const s = await this.prisma.ayuda_sesiones.findUnique({ where: { id: sesionId } });
    if (!s || s.empresa_id !== empresaId || s.usuario_id !== usuarioId) {
      throw new NotFoundException('Sesión no encontrada');
    }
    return this.prisma.ayuda_mensajes.findMany({
      where: { sesion_id: sesionId },
      orderBy: { created_at: 'asc' },
    });
  }

  // ----- Preguntar (RAG) -----
  async preguntar(params: {
    sesionId: string;
    empresaId: string;
    usuarioId: string;
    pregunta: string;
    screen_key?: string;
    screen_url?: string;
    screen_modulo?: string;
    screen_submodulo?: string;
    ip_address?: string;
    user_agent?: string;
  }) {
    const { sesionId, empresaId, usuarioId } = params;

    const sesion = await this.prisma.ayuda_sesiones.findUnique({ where: { id: sesionId } });
    if (!sesion || sesion.empresa_id !== empresaId || sesion.usuario_id !== usuarioId) {
      throw new NotFoundException('Sesión no encontrada');
    }

    const config = await this.configService.getConfigConKey();
    const preguntaSanitizada = sanitizarPregunta(params.pregunta);

    // Historial previo (antes de persistir la pregunta actual): se reutiliza para
    // condensar la consulta de retrieval (#1) y para el prompt del LLM.
    const historialDb = await this.prisma.ayuda_mensajes.findMany({
      where: { sesion_id: sesionId },
      orderBy: { created_at: 'desc' },
      take: 6,
    });
    historialDb.reverse();
    const historialPrevio = historialDb.map((m) => ({
      rol: m.rol as 'user' | 'assistant',
      contenido: m.contenido,
    }));

    // #2 — ¿El último turno del asistente fue una pregunta de aclaración? Si sí, el
    // usuario ya está respondiéndola → PROHIBIDO volver a preguntar (regla 12).
    const ultimoAsistente = [...historialPrevio].reverse().find((m) => m.rol === 'assistant');
    const usuarioYaAclaro =
      !!ultimoAsistente && ultimoAsistente.contenido.includes('?') && ultimoAsistente.contenido.length < 320;

    // #1 — Condensar: para el retrieval combinamos las últimas preguntas del usuario
    // con la actual, para que un seguimiento corto ("¿y para ventas?") recupere el
    // chunk correcto. El LLM sigue viendo solo `preguntaSanitizada`.
    const preguntasUsuarioPrevias = historialPrevio
      .filter((m) => m.rol === 'user')
      .slice(-2)
      .map((m) => m.contenido);
    const textoRetrieval =
      preguntasUsuarioPrevias.length > 0
        ? [...preguntasUsuarioPrevias, preguntaSanitizada].join('. ')
        : preguntaSanitizada;
    const preguntaParaEmbedding = expandirParaEmbedding(textoRetrieval);

    // 1) Embed pregunta (condensada con historial + expandida)
    const [vector] = await this.provider.embed({
      proveedor: config.proveedor_embeddings,
      modelo: config.modelo_embeddings,
      apiKey: config.api_key_embeddings,
      baseUrl: config.base_url,
      textos: [preguntaParaEmbedding],
    });

    // 2) Buscar chunks similares con boost jerárquico (exacto/namespace/alias).
    //    Si el reranker está activo, pedimos MÁS candidatos (recall amplio)
    //    para que el cross-encoder tenga material suficiente para reordenar.
    const topKInicial = config.reranker_activo ? TOP_K * 3 : TOP_K;
    let candidatos = await this.buscarChunks(
      vector,
      params.screen_key ?? null,
      preguntaParaEmbedding,
      topKInicial,
    );

    // 2.bis) Re-ranking con cross-encoder (opcional según config).
    //        Reordena por relevancia REAL pregunta+chunk (no por similitud).
    //        Si el reranker falla, degrada graceful: deja los candidatos como están.
    let rerankCategoria: 'aplicado' | 'inactivo' | 'sin_match' | 'falla' = 'inactivo';
    if (config.reranker_activo && config.reranker_proveedor && candidatos.length > 1) {
      try {
        const reranked = await this.reranker.rerank(
          {
            proveedor: config.reranker_proveedor as 'cohere' | 'jina' | 'local',
            modelo: config.reranker_modelo || 'rerank-multilingual-v3.0',
            apiKey: config.reranker_api_key,
            baseUrl: config.reranker_base_url,
            topK: config.reranker_top_k,
            minScore: config.reranker_min_score,
          },
          {
            query: preguntaSanitizada,
            documents: candidatos.map((c) => ({
              text: c.contenido,
              payload: c,
            })),
          },
        );
        if (reranked.length === 0) {
          // Ningún chunk superó el threshold → contexto vacío para el LLM
          // (mejor que mandar basura). El selector adaptativo manejará esto.
          candidatos = [];
          rerankCategoria = 'sin_match';
        } else {
          // Reemplazamos el score original (similitud + boost) por el del reranker.
          // Es más confiable para la fase de selección adaptativa.
          candidatos = reranked.map((r) => ({ ...r.payload, score: r.score }));
          rerankCategoria = 'aplicado';
        }
      } catch (err: any) {
        this.logger.warn(`Rerank falló, sigo con scores vectoriales: ${err?.message ?? err}`);
        rerankCategoria = 'falla';
      }
    }

    // 3) Selección adaptativa de chunks:
    //    - 'directo'        : ≥1 chunks pasan SCORE_MIN_FUERTE (caso ideal).
    //    - 'top1_confiable' : top1 < umbral pero gap top1-top2 > GAP_CONFIANZA
    //                         → top1 destaca claramente, confiamos en él.
    //    - 'fallback_debil' : nada destaca, mandamos top-N con advertencia
    //                         de contexto débil (el LLM avisa al usuario).
    //    - 'sin_contexto'   : ni siquiera hubo candidatos (BD vacía / fallo embed).
    const fuertes = candidatos.filter((c) => c.score >= SCORE_MIN_FUERTE);
    let chunks: ChunkResultado[];
    let recall_categoria: RecallCategoria;
    if (fuertes.length > 0) {
      chunks = fuertes;
      recall_categoria = 'directo';
    } else if (candidatos.length === 0) {
      chunks = [];
      recall_categoria = 'sin_contexto';
    } else if (
      candidatos.length >= 2 &&
      candidatos[0].score - candidatos[1].score >= GAP_CONFIANZA
    ) {
      chunks = [candidatos[0]];
      recall_categoria = 'top1_confiable';
    } else {
      chunks = candidatos.slice(0, FALLBACK_TOP_N);
      recall_categoria = 'fallback_debil';
    }

    this.logger.log(
      `preguntar: ${candidatos.length} candidatos, ${chunks.length} elegidos, categoria=${recall_categoria}, rerank=${rerankCategoria}. ` +
        `Scores top: [${candidatos
          .slice(0, 5)
          .map((c) => c.score.toFixed(3))
          .join(', ')}]`,
    );

    // 3) Persistir mensaje del usuario (el historial previo ya se cargó arriba)
    await this.prisma.ayuda_mensajes.create({
      data: { sesion_id: sesionId, rol: 'user', contenido: params.pregunta },
    });

    // 5) Construir prompt
    const contextoChunks = chunks
      .map(
        (c, i) =>
          `### [${i + 1}] ${c.titulo}${c.screen_key ? ` (pantalla: ${c.screen_key})` : ''}\n${c.contenido}`,
      )
      .join('\n\n---\n\n');

    // Rutas de menú verificadas (catálogo de pantallas) de las pantallas a las que
    // pertenecen los chunks. Los chunks de una sección larga se parten y el que
    // responde ("Alta de una numeración") no siempre trae el "Cómo llegar": sin
    // esto el modelo completaba la ruta con los títulos de la guía.
    const rutasVerificadas = [...new Set(chunks.map((c) => c.screen_key).filter(Boolean))]
      .map((key) => CATALOGO.find((e) => e.screen_key === key))
      .filter((e): e is (typeof CATALOGO)[number] => !!e)
      .map((e) => `- ${e.titulo}: **Menú principal › ${e.breadcrumb.filter((b) => b !== 'Menú').join(' › ')}** — ${e.descripcion}`)
      .join('\n');
    const bloqueRutas = rutasVerificadas ? `\n\nRutas de menú verificadas (únicas rutas que podés dar, regla 1):\n${rutasVerificadas}` : '';

    const submoduloContexto = params.screen_submodulo
      ? ` [submódulo: ${params.screen_submodulo}${params.screen_modulo ? ` / módulo: ${params.screen_modulo}` : ''}]`
      : '';
    const pantallaContexto = params.screen_url
      ? `\n\nEl usuario está actualmente en: ${params.screen_url}${params.screen_key ? ` (${params.screen_key})` : ''}${submoduloContexto}`
      : '';

    // Nota: los aviso los uso para RECORDARLE al modelo el flujo del prompt.
    // La lógica de "clarificar / responder / rendirse" ya está en las reglas 9-11
    // del SYSTEM_PROMPT — acá solo agrego el sesgo según qué tan bueno fue el recall.
    const avisoContexto =
      recall_categoria === 'fallback_debil'
        ? '\n\n⚠ Contexto de relevancia MEDIA. Aplicá el Paso 1/2/3 de la regla 9: si la pregunta es clara, respondé con lo que aporten los chunks (rutas, conceptos, pantallas, validaciones); si es ambigua, hacé UNA pregunta breve de aclaración antes de responder. NO te rindas si hay información parcialmente útil.'
        : recall_categoria === 'sin_contexto'
          ? '\n\n⚠ No encontré chunks relevantes en la base de ayuda. Aplicá el Paso 1/2/3 de la regla 9: si podés inferir el módulo/entidad/acción por el propio texto de la pregunta, respondé con esa base (aclarando al final que no tenés detalles finos); si la pregunta es ambigua, hacé UNA pregunta breve de aclaración. Solo si ambas opciones fallan usá el mensaje de "no tengo esa información..." y sugerí contactar por teléfono o WhatsApp a su referente de Novasis. NO inventes pasos. NO menciones "Soporte" ni "Ayuda" del menú porque no existen.'
          : '';

    // #2 — Si el usuario está respondiendo a una aclaración previa, forzar a que
    // ESTE turno sea una respuesta accionable y no otra pregunta (regla 12).
    const directivaConverger = usuarioYaAclaro
      ? '\n\n⚠ IMPORTANTE: en tu turno anterior hiciste una pregunta de aclaración y el usuario ACABA de responderla. Está PROHIBIDO volver a preguntar. Respondé AHORA con la mejor guía accionable posible (ruta exacta + pasos) sobre la rama que eligió, usando el contexto y lo que puedas inferir del hilo. Si te falta un dato fino, aclaralo en UNA línea al final — pero dá igual la respuesta.'
      : '';

    const userPrompt = `${chunks.length === 0 ? '(Sin contexto recuperado de la base de ayuda)' : `Contexto recuperado de la base de ayuda:\n\n${contextoChunks}${bloqueRutas}`}${avisoContexto}${directivaConverger}${pantallaContexto}\n\n---\n\nPregunta del usuario: ${preguntaSanitizada}`;

    // 6) Completar
    const t0 = Date.now();
    const r = await this.provider.completar({
      proveedor: config.proveedor,
      modelo: config.modelo,
      apiKey: config.api_key,
      baseUrl: config.base_url,
      system: this.SYSTEM_PROMPT,
      prompt: userPrompt,
      historial: historialPrevio,
      max_tokens: config.max_tokens,
      temperatura: config.temperatura,
      empresa_id: empresaId,
      feature: 'AYUDA',
      usuario_id: usuarioId,
      referencia_id: sesionId,
    });
    const latency_ms = Date.now() - t0;

    // 7) Persistir respuesta + citas
    const citas = chunks.slice(0, 3).map((c) => ({
      documento_id: c.documento_id,
      titulo: c.titulo,
      screen_key: c.screen_key,
      score: c.score,
      // URL real de la pantalla (catálogo) para el chip "Ir a…" del front.
      ruta_url: CATALOGO.find((e) => e.screen_key === c.screen_key)?.ruta_url ?? null,
    }));

    const mensajeAsistente = await this.prisma.ayuda_mensajes.create({
      data: {
        sesion_id: sesionId,
        rol: 'assistant',
        contenido: r.texto,
        citas_json: citas,
        tokens_in: r.tokens_in,
        tokens_out: r.tokens_out,
        latency_ms,
        recall_categoria,
        screen_key_query: params.screen_key ?? null,
      },
    });

    // Actualizar título de la sesión si era null (usar primeras palabras de la pregunta)
    if (!sesion.titulo) {
      const titulo = params.pregunta.slice(0, 80);
      await this.prisma.ayuda_sesiones.update({
        where: { id: sesionId },
        data: { titulo, updated_at: new Date() },
      });
    } else {
      await this.prisma.ayuda_sesiones.update({
        where: { id: sesionId },
        data: { updated_at: new Date() },
      });
    }

    // 8) Auditoría — registrar la pregunta/respuesta para revisión posterior
    await this.audit.log({
      empresa_id: empresaId,
      user_id: usuarioId,
      action: 'ayuda_ia_preguntar',
      entity_type: 'ayuda_ia_mensaje',
      entity_id: mensajeAsistente.id,
      descripcion: params.pregunta.slice(0, 200),
      ip_address: params.ip_address,
      user_agent: params.user_agent,
      new_value: {
        sesion_id: sesionId,
        pregunta: params.pregunta,
        pregunta_sanitizada: preguntaSanitizada,
        respuesta: r.texto,
        screen_key: params.screen_key ?? null,
        screen_url: params.screen_url ?? null,
        screen_modulo: params.screen_modulo ?? null,
        screen_submodulo: params.screen_submodulo ?? null,
        citas,
        proveedor: config.proveedor,
        modelo: config.modelo,
        tokens_in: r.tokens_in,
        tokens_out: r.tokens_out,
        latency_ms,
        chunks_recuperados: chunks.length,
        candidatos_total: candidatos.length,
        score_min_fuerte: SCORE_MIN_FUERTE,
        gap_confianza: GAP_CONFIANZA,
        recall_categoria,
        rerank_categoria: rerankCategoria,
        rerank_activo: config.reranker_activo,
        rerank_proveedor: config.reranker_proveedor,
        rerank_top_k: config.reranker_top_k,
        rerank_min_score: config.reranker_min_score,
        top_chunks_debug: candidatos.slice(0, 6).map((c) => ({
          titulo: c.titulo,
          screen_key: c.screen_key,
          origen_path: c.origen_path,
          score: Number(c.score.toFixed(4)),
          incluido: chunks.some((ch) => ch.documento_id === c.documento_id && ch.contenido === c.contenido),
        })),
      },
    });

    return {
      mensaje_id: mensajeAsistente.id,
      texto: r.texto,
      citas,
      tokens_in: r.tokens_in,
      tokens_out: r.tokens_out,
      latency_ms,
    };
  }

  async feedback(
    mensajeId: string,
    sesionEmpresaId: string,
    valor: 'up' | 'down',
    meta?: { user_id?: string; ip_address?: string; user_agent?: string },
  ) {
    const m = await this.prisma.ayuda_mensajes.findUnique({
      where: { id: mensajeId },
      include: { sesion: true },
    });
    if (!m) throw new NotFoundException('Mensaje no encontrado');
    if (m.sesion.empresa_id !== sesionEmpresaId) throw new BadRequestException('Sesión de otra empresa');
    const anterior = m.feedback;
    await this.prisma.ayuda_mensajes.update({ where: { id: mensajeId }, data: { feedback: valor } });

    await this.audit.log({
      empresa_id: sesionEmpresaId,
      user_id: meta?.user_id,
      action: 'ayuda_ia_feedback',
      entity_type: 'ayuda_ia_mensaje',
      entity_id: mensajeId,
      descripcion: `Feedback ${valor === 'up' ? '👍' : '👎'} sobre respuesta del asistente`,
      ip_address: meta?.ip_address,
      user_agent: meta?.user_agent,
      old_value: anterior ? { feedback: anterior } : undefined,
      new_value: { feedback: valor },
    });
    return { ok: true };
  }

  // ----- Analytics de calidad del retrieval -----
  /**
   * Resumen de calidad de retrieval para detectar huecos en la base de ayuda.
   *
   * Devuelve:
   *  - `por_categoria`: cuántas respuestas cayeron en cada categoría.
   *  - `por_pantalla`: top pantallas con más respuestas débiles/sin contexto
   *    (= candidatos para documentar próximo).
   *  - `feedback`: ratio 👍 / 👎.
   *  - `quejas_concretas`: últimas preguntas con 👎 (para revisión manual).
   */
  async getRecallAnalytics(empresaId: string, dias = 30) {
    const desde = new Date();
    desde.setDate(desde.getDate() - dias);

    const porCategoria = await this.prisma.ayuda_mensajes.groupBy({
      by: ['recall_categoria'],
      where: {
        rol: 'assistant',
        created_at: { gte: desde },
        sesion: { empresa_id: empresaId },
      },
      _count: { _all: true },
    });

    const porPantallaRaw = await this.prisma.$queryRawUnsafe<
      Array<{ screen_key_query: string | null; recall_categoria: string | null; count: bigint }>
    >(
      `
      SELECT am.screen_key_query, am.recall_categoria, COUNT(*)::bigint AS count
      FROM ayuda_mensajes am
      JOIN ayuda_sesiones ase ON ase.id = am.sesion_id
      WHERE am.rol = 'assistant'
        AND am.created_at >= $1
        AND ase.empresa_id = $2::uuid
        AND am.recall_categoria IN ('fallback_debil', 'sin_contexto')
      GROUP BY am.screen_key_query, am.recall_categoria
      ORDER BY count DESC
      LIMIT 20
      `,
      desde,
      empresaId,
    );

    const feedback = await this.prisma.ayuda_mensajes.groupBy({
      by: ['feedback'],
      where: {
        rol: 'assistant',
        created_at: { gte: desde },
        feedback: { in: ['up', 'down'] },
        sesion: { empresa_id: empresaId },
      },
      _count: { _all: true },
    });

    const quejas = await this.prisma.ayuda_mensajes.findMany({
      where: {
        rol: 'assistant',
        feedback: 'down',
        created_at: { gte: desde },
        sesion: { empresa_id: empresaId },
      },
      orderBy: { created_at: 'desc' },
      take: 20,
      select: {
        id: true,
        created_at: true,
        contenido: true,
        recall_categoria: true,
        screen_key_query: true,
        sesion_id: true,
      },
    });

    return {
      desde,
      dias,
      por_categoria: porCategoria.map((p) => ({
        recall_categoria: p.recall_categoria ?? 'sin_dato',
        count: p._count._all,
      })),
      por_pantalla: porPantallaRaw.map((r) => ({
        screen_key_query: r.screen_key_query ?? '(null)',
        recall_categoria: r.recall_categoria ?? 'sin_dato',
        count: Number(r.count),
      })),
      feedback: feedback.map((f) => ({ valor: f.feedback, count: f._count._all })),
      quejas_concretas: quejas,
    };
  }

  // ----- pgvector search con boost jerárquico + aliases -----
  private async buscarChunks(
    queryEmbedding: number[],
    screenKey: string | null,
    pregunta: string,
    topK: number,
  ): Promise<ChunkResultado[]> {
    const vectorLiteral = `[${queryEmbedding.join(',')}]`;
    // Pre-filtramos por similitud pura, sin boosts en SQL (más simple y permite
    // ajustar la fórmula de boost en TS sin tocar prepared statements).
    // Pedimos topK*2 para tener margen — el boost puede reordenar agresivamente.
    const rows = await this.prisma.$queryRawUnsafe<any[]>(
      `
      SELECT
        ac.documento_id,
        ac.contenido,
        ad.titulo,
        ad.screen_key,
        ad.fuente,
        ad.origen_path,
        (1 - (ac.embedding <=> $1::vector)) AS score_base
      FROM "ayuda_chunks" ac
      JOIN "ayuda_documentos" ad ON ad.id = ac.documento_id
      WHERE ac.embedding IS NOT NULL
      ORDER BY ac.embedding <=> $1::vector
      LIMIT $2::int
    `,
      vectorLiteral,
      topK * 2,
    );

    const screenMap = buildScreenMap();
    // "nota de crédito" no debe sumar el alias "crédito" (Solicitud de Crédito).
    const preguntaLower = normalizarNotaCredito(pregunta.toLowerCase());
    const nsActual = screenKey ? screenKey.split('/')[0] : null;

    return rows
      .map((r) => {
        const docScreenKey = r.screen_key as string | null;
        const base = Number(r.score_base);
        let boost = 0;

        // 1) Match exacto del screen_key actual
        if (screenKey && docScreenKey === screenKey) {
          boost += BOOST_SCREEN_KEY_EXACTO;
        } else if (nsActual && docScreenKey) {
          // 2) Mismo namespace (primer segmento)
          const docNs = docScreenKey.split('/')[0];
          if (docNs === nsActual) boost += BOOST_SCREEN_KEY_NAMESPACE;
        }

        // 3) Alias del catálogo del doc aparece en la pregunta (palabra completa)
        if (docScreenKey) {
          const aliases = screenMap.aliasesPorScreenKey[docScreenKey];
          if (aliases && aliases.length > 0) {
            const hit = aliases.some((a) => {
              const al = a.toLowerCase().trim();
              if (!al) return false;
              const re = new RegExp(`(^|[^\\p{L}\\p{N}])${escapeRegex(al)}([^\\p{L}\\p{N}]|$)`, 'iu');
              return re.test(preguntaLower);
            });
            if (hit) boost += BOOST_ALIAS;
          }
        }

        return {
          documento_id: r.documento_id,
          contenido: r.contenido,
          titulo: r.titulo,
          screen_key: docScreenKey,
          fuente: r.fuente,
          origen_path: r.origen_path,
          score: base + boost,
        };
      })
      .sort((a, b) => b.score - a.score)
      .slice(0, topK);
  }
}

function escapeRegex(s: string): string {
  return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
