import { BadRequestException, Injectable, Logger, NotFoundException, OnModuleInit } from '@nestjs/common';
import { PrismaService } from 'src/prisma/prisma.service';
import { envs } from 'src/config';
import { AyudaConfigService, ConfigConKey } from './ayuda-config.service';
import { AyudaProviderService } from './ayuda-provider.service';
import { extraerMarkdowns, DocSource } from '../ingesta/extractores/markdown.extractor';
import { extraerScreenGuias } from '../ingesta/extractores/screen-guia.extractor';
import { catalogoComoDocs } from '../ingesta/seed-catalogo';
import { chunkMarkdown } from '../ingesta/chunker';
import { transformarADocUsuario } from '../ingesta/transformadores/user-friendly.transformer';

type SourceTipo = 'docs' | 'guias' | 'all' | 'catalogo';

type AccionIngestaLog =
  | 'created'                  // doc nuevo, chunks insertados
  | 'updated'                  // version_hash cambió, re-embeddeado
  | 'skipped_cache'            // version_hash igual, force=false
  | 'skipped_transform_null'   // LLM marcó "sin valor para usuario"
  | 'deleted_orphan'           // existía en BD, ya no en filesystem / cambió a audiencia=dev
  | 'error'                    // falló (ver detalle)
  | 'dryrun_would_create'
  | 'dryrun_would_update'
  | 'dryrun_would_skip'
  | 'dryrun_would_delete';

@Injectable()
export class AyudaIngestaService implements OnModuleInit {
  private readonly logger = new Logger(AyudaIngestaService.name);
  private readonly abortControllers = new Map<string, AbortController>();

  /**
   * Un job vive en memoria: si el proceso se reinicia a mitad de una ingesta, la
   * fila queda en 'corriendo' para siempre. Eso deja el índice a medias y la UI
   * mostrando una ingesta activa que ya no existe, así que al arrancar cerramos
   * los jobs no terminales como interrumpidos.
   */
  async onModuleInit(): Promise<void> {
    try {
      const { count } = await this.prisma.ayuda_ingesta_jobs.updateMany({
        where: { estado: { in: ['pendiente', 'corriendo', 'cancelando'] } },
        data: {
          estado: 'error',
          finished_at: new Date(),
          error: 'Interrumpido por reinicio del backend — volvé a correr la ingesta',
        },
      });
      if (count > 0) {
        this.logger.warn(
          `${count} job(s) de ingesta quedaron a medias por un reinicio y se marcaron como error. ` +
            'El índice puede estar incompleto: volvé a correr la ingesta.',
        );
      }
    } catch (err) {
      this.logger.error(`No se pudieron cerrar los jobs interrumpidos: ${(err as Error).message}`);
    }
  }

  constructor(
    private readonly prisma: PrismaService,
    private readonly configService: AyudaConfigService,
    private readonly provider: AyudaProviderService,
  ) {}

  // -------- Public API --------
  async encolarJob(empresaId: string, source: SourceTipo, force: boolean, dryRun = false) {
    // Cancelar TODOS los jobs no terminales previos para evitar zombies en background
    const previos = await this.prisma.ayuda_ingesta_jobs.findMany({
      where: { estado: { in: ['pendiente', 'corriendo', 'cancelando'] } },
      select: { id: true },
    });
    for (const p of previos) {
      const ctrl = this.abortControllers.get(p.id);
      if (ctrl) ctrl.abort();
      this.abortControllers.delete(p.id);
    }
    if (previos.length > 0) {
      await this.prisma.ayuda_ingesta_jobs.updateMany({
        where: { id: { in: previos.map((p) => p.id) } },
        data: {
          estado: 'cancelado',
          finished_at: new Date(),
          error: 'Reemplazado por nuevo job',
        },
      });
      this.logger.warn(`encolarJob: ${previos.length} job(s) previos cancelados antes de arrancar`);
    }

    const job = await this.prisma.ayuda_ingesta_jobs.create({
      data: { empresa_id: empresaId, source, estado: 'pendiente', dry_run: dryRun },
    });
    // Disparar en background sin esperar
    void this.correrJob(job.id, empresaId, source, force, dryRun).catch((err) => {
      this.logger.error(`Job ${job.id} falló: ${(err as Error).message}`, (err as Error).stack);
    });
    return { job_id: job.id, estado: job.estado, dry_run: dryRun };
  }

  /** Logs detallados por archivo de un job. */
  async getJobLog(jobId: string) {
    const j = await this.prisma.ayuda_ingesta_jobs.findUnique({ where: { id: jobId } });
    if (!j) throw new NotFoundException('Job no encontrado');
    const logs = await this.prisma.ayuda_ingesta_log.findMany({
      where: { job_id: jobId },
      orderBy: { created_at: 'asc' },
    });
    const resumen = logs.reduce<Record<string, number>>((acc, l) => {
      acc[l.accion] = (acc[l.accion] ?? 0) + 1;
      return acc;
    }, {});
    return { job: j, resumen, logs };
  }

  private async escribirLog(
    jobId: string,
    entry: {
      fuente: string;
      origen_path: string | null;
      screen_key: string | null;
      titulo: string | null;
      accion: AccionIngestaLog;
      chunks_count?: number;
      duration_ms?: number;
      detalle?: string;
    },
  ) {
    try {
      await this.prisma.ayuda_ingesta_log.create({
        data: {
          job_id: jobId,
          fuente: entry.fuente,
          origen_path: entry.origen_path,
          screen_key: entry.screen_key,
          titulo: entry.titulo,
          accion: entry.accion,
          chunks_count: entry.chunks_count ?? null,
          duration_ms: entry.duration_ms ?? null,
          detalle: entry.detalle ?? null,
        },
      });
    } catch (err) {
      // Logging no debe romper la ingesta. Solo warn.
      this.logger.warn(`No se pudo escribir ayuda_ingesta_log: ${(err as Error).message}`);
    }
  }

  /** Marca cualquier job no terminal como cancelado y aborta sus tareas en memoria. */
  async cancelarTodosActivos() {
    const activos = await this.prisma.ayuda_ingesta_jobs.findMany({
      where: { estado: { in: ['pendiente', 'corriendo', 'cancelando'] } },
      select: { id: true },
    });
    for (const a of activos) {
      const ctrl = this.abortControllers.get(a.id);
      if (ctrl) ctrl.abort();
      this.abortControllers.delete(a.id);
    }
    const r = await this.prisma.ayuda_ingesta_jobs.updateMany({
      where: { id: { in: activos.map((a) => a.id) } },
      data: {
        estado: 'cancelado',
        finished_at: new Date(),
        error: 'Cancelado por el usuario (limpieza)',
      },
    });
    return { ok: true, cancelados: r.count };
  }

  async getJobStatus(jobId: string) {
    const j = await this.prisma.ayuda_ingesta_jobs.findUnique({ where: { id: jobId } });
    if (!j) throw new NotFoundException('Job no encontrado');
    return j;
  }

  async cancelarJob(jobId: string) {
    const j = await this.prisma.ayuda_ingesta_jobs.findUnique({ where: { id: jobId } });
    if (!j) throw new NotFoundException('Job no encontrado');
    const cancelables = ['corriendo', 'pendiente', 'cancelando'];
    if (!cancelables.includes(j.estado)) {
      return { ok: false, mensaje: `Job en estado "${j.estado}", no se puede cancelar` };
    }
    // Marca terminal inmediato. La tarea en background morirá por abort o terminará al vacío
    // (todas las escrituras posteriores fallarán silenciosamente o ignoraremos el resultado).
    await this.prisma.ayuda_ingesta_jobs.update({
      where: { id: jobId },
      data: {
        estado: 'cancelado',
        finished_at: new Date(),
        error: 'Cancelado por el usuario',
      },
    });
    const ctrl = this.abortControllers.get(jobId);
    if (ctrl) {
      ctrl.abort();
      this.logger.warn(`Job ${jobId}: aborto forzado + estado=cancelado`);
    }
    this.abortControllers.delete(jobId);
    return { ok: true };
  }

  async getJobCorriendoMasReciente() {
    return this.prisma.ayuda_ingesta_jobs.findFirst({
      where: { estado: { in: ['pendiente', 'corriendo', 'cancelando'] } },
      orderBy: { created_at: 'desc' },
    });
  }

  // -------- Pipeline --------
  private async correrJob(
    jobId: string,
    empresaId: string,
    source: SourceTipo,
    force: boolean,
    dryRun = false,
  ) {
    const controller = new AbortController();
    this.abortControllers.set(jobId, controller);
    await this.prisma.ayuda_ingesta_jobs.update({
      where: { id: jobId },
      data: { estado: 'corriendo', started_at: new Date() },
    });

    let config: ConfigConKey;
    try {
      config = await this.configService.getConfigConKey();
    } catch (err) {
      await this.fallarJob(jobId, `Config inválida: ${(err as Error).message}`);
      return;
    }

    try {
      const docs: Array<{
        fuente: 'doc-md' | 'screen-guia' | 'catalogo';
        origen_path: string | null;
        screen_key: string | null;
        titulo: string;
        contenido_md: string;
        version_hash: string;
        requiereTransform: boolean;
      }> = [];

      const origenesUsuarioDocs: string[] = [];
      if (source === 'docs' || source === 'all') {
        const ds = await extraerMarkdowns(envs.backendDocsPath);
        // Sin documentos casi siempre es BACKEND_DOCS_PATH mal apuntado (el default
        // es de otro servidor), no que se hayan borrado todos: sin esta guarda la
        // limpieza de huérfanos de abajo vaciaba la base de ayuda entera.
        if (ds.length === 0) {
          throw new Error(
            `No se encontró ningún documento en BACKEND_DOCS_PATH="${envs.backendDocsPath}". ` +
              'Revisá la variable antes de re-ingestar (no se borró nada).',
          );
        }
        let descartadosDev = 0;
        for (const d of ds) {
          if (d.audiencia === 'dev') {
            descartadosDev++;
            continue;
          }
          origenesUsuarioDocs.push(d.origen_path);
          docs.push({
            fuente: 'doc-md',
            origen_path: d.origen_path,
            screen_key: d.screen_key,
            titulo: d.titulo,
            contenido_md: d.contenido_md,
            version_hash: d.version_hash,
            requiereTransform: true,
          });
        }
        if (descartadosDev > 0) {
          this.logger.log(
            `extraerMarkdowns: ${descartadosDev} documento(s) audiencia=dev excluidos de la BD IA`,
          );
        }

        // Limpieza de huérfanos: fuente=doc-md cuyo origen_path ya no es 'usuario'
        // (archivo borrado o reclasificado a dev). Cascada borra sus chunks.
        const huerfanos = await this.prisma.ayuda_documentos.findMany({
          where: {
            fuente: 'doc-md',
            origen_path: { notIn: origenesUsuarioDocs },
          },
          select: { id: true, origen_path: true },
        });
        if (huerfanos.length > 0) {
          if (!dryRun) {
            await this.prisma.ayuda_documentos.deleteMany({
              where: { id: { in: huerfanos.map((h) => h.id) } },
            });
          }
          this.logger.log(
            `${dryRun ? '[DRY-RUN] HARÍA' : 'Limpieza:'} ${huerfanos.length} documento(s) huérfano(s) ` +
              `${dryRun ? 'eliminación' : 'eliminado(s)'} (${huerfanos.map((h) => h.origen_path).join(', ')})`,
          );
          for (const h of huerfanos) {
            await this.escribirLog(jobId, {
              fuente: 'doc-md',
              origen_path: h.origen_path,
              screen_key: null,
              titulo: null,
              accion: dryRun ? 'dryrun_would_delete' : 'deleted_orphan',
              detalle: 'origen_path ya no es audiencia=usuario',
            });
          }
        }
      }

      if (source === 'guias' || source === 'all') {
        const gs = await extraerScreenGuias(envs.posVentasFrontendPath);
        for (const g of gs) {
          docs.push({
            fuente: 'screen-guia',
            origen_path: g.origen_path,
            screen_key: g.screen_key,
            titulo: g.titulo,
            contenido_md: g.contenido_md,
            version_hash: g.version_hash,
            requiereTransform: false,
          });
        }
      }

      // Catálogo siempre va (es la fuente de "dónde está cada cosa")
      if (source === 'all' || source === 'catalogo') {
        for (const c of catalogoComoDocs()) {
          docs.push({
            fuente: 'catalogo',
            origen_path: null,
            screen_key: c.screen_key,
            titulo: c.titulo,
            contenido_md: c.contenido_md,
            version_hash: c.version_hash,
            requiereTransform: false,
          });
        }
      }

      await this.prisma.ayuda_ingesta_jobs.update({
        where: { id: jobId },
        data: { total_docs: docs.length },
      });
      this.logger.log(
        `Job ${jobId} arrancando: ${docs.length} documentos (proveedor chat=${config.proveedor}/${config.modelo}, embeddings=${config.proveedor_embeddings}/${config.modelo_embeddings})`,
      );

      let chunksTotal = 0;
      let procesados = 0;
      let errores = 0;
      let omitidos = 0;
      const tJob = Date.now();
      let cancelado = false;
      for (const d of docs) {
        const estadoActual = await this.prisma.ayuda_ingesta_jobs.findUnique({
          where: { id: jobId },
          select: { estado: true },
        });
        if (estadoActual?.estado === 'cancelando' || estadoActual?.estado === 'cancelado') {
          cancelado = true;
          this.logger.warn(`Job ${jobId} cancelado por usuario en ${procesados + errores}/${docs.length}`);
          break;
        }
        const idx = procesados + errores + 1;
        const tDoc = Date.now();
        try {
          const existing = await this.prisma.ayuda_documentos.findFirst({
            where: { fuente: d.fuente, origen_path: d.origen_path, screen_key: d.screen_key },
          });
          if (existing && existing.version_hash === d.version_hash && !force) {
            procesados++;
            omitidos++;
            this.logger.debug(`[${idx}/${docs.length}] cache hit: "${d.titulo}"`);
            await this.prisma.ayuda_ingesta_jobs.update({
              where: { id: jobId },
              data: { procesados },
            });
            await this.escribirLog(jobId, {
              fuente: d.fuente,
              origen_path: d.origen_path,
              screen_key: d.screen_key,
              titulo: d.titulo,
              accion: dryRun ? 'dryrun_would_skip' : 'skipped_cache',
              duration_ms: Date.now() - tDoc,
              detalle: 'version_hash sin cambios',
            });
            continue;
          }

          // En dry-run no llamamos al LLM ni embeddeamos: solo declaramos la acción.
          if (dryRun) {
            procesados++;
            await this.prisma.ayuda_ingesta_jobs.update({
              where: { id: jobId },
              data: { procesados },
            });
            await this.escribirLog(jobId, {
              fuente: d.fuente,
              origen_path: d.origen_path,
              screen_key: d.screen_key,
              titulo: d.titulo,
              accion: existing ? 'dryrun_would_update' : 'dryrun_would_create',
              duration_ms: Date.now() - tDoc,
              detalle: existing
                ? `version_hash cambió (${existing.version_hash.slice(0, 8)} → ${d.version_hash.slice(0, 8)})`
                : 'documento nuevo',
            });
            continue;
          }

          this.logger.log(
            `[${idx}/${docs.length}] procesando "${d.titulo}"${d.requiereTransform ? ' (transform LLM)' : ''}…`,
          );

          // Transformar (solo docs internos)
          let contenidoFinal = d.contenido_md;
          if (d.requiereTransform) {
            const transformado = await transformarADocUsuario(
              this.provider,
              config,
              d.contenido_md,
              d.titulo,
              controller.signal,
              empresaId,
            );
            if (!transformado) {
              procesados++;
              omitidos++;
              this.logger.log(`[${idx}/${docs.length}] SKIP (LLM marcó sin valor para usuario)`);
              await this.prisma.ayuda_ingesta_jobs.update({
                where: { id: jobId },
                data: { procesados },
              });
              await this.escribirLog(jobId, {
                fuente: d.fuente,
                origen_path: d.origen_path,
                screen_key: d.screen_key,
                titulo: d.titulo,
                accion: 'skipped_transform_null',
                duration_ms: Date.now() - tDoc,
                detalle: 'LLM consideró que el doc no aporta valor al usuario final',
              });
              continue;
            }
            contenidoFinal = transformado;
          }

          const docRow = existing
            ? await this.prisma.ayuda_documentos.update({
                where: { id: existing.id },
                data: {
                  titulo: d.titulo,
                  contenido_md: contenidoFinal,
                  version_hash: d.version_hash,
                  updated_at: new Date(),
                },
              })
            : await this.prisma.ayuda_documentos.create({
                data: {
                  fuente: d.fuente,
                  origen_path: d.origen_path,
                  screen_key: d.screen_key,
                  titulo: d.titulo,
                  contenido_md: contenidoFinal,
                  version_hash: d.version_hash,
                },
              });

          // Re-chunk + re-embed (borramos chunks anteriores si existían)
          if (existing) {
            await this.prisma.ayuda_chunks.deleteMany({ where: { documento_id: existing.id } });
          }
          const chunks = chunkMarkdown(contenidoFinal, d.titulo);
          if (chunks.length > 0) {
            // Opción B — enriquecer cada chunk con "búsquedas frecuentes" (preguntas
            // y sinónimos en lenguaje de usuario) generadas por LLM, SOLO para el
            // embedding. El texto que ve la IA al responder (`ch.contenido`) queda
            // intacto; el enriquecimiento solo mejora el recall del retrieval.
            const textosParaEmbedding: string[] = [];
            for (const c of chunks) {
              if (controller.signal.aborted) {
                const e = new Error('Aborted');
                e.name = 'AbortError';
                throw e;
              }
              textosParaEmbedding.push(await this.enriquecerChunkParaEmbedding(config, c.contenido, empresaId));
            }
            const vectores = await this.embedEnBatches(
              config,
              textosParaEmbedding,
              controller.signal,
            );
            for (let i = 0; i < chunks.length; i++) {
              const ch = chunks[i];
              const vec = vectores[i];
              const vectorLiteral = `[${vec.join(',')}]`;
              await this.prisma.$executeRawUnsafe(
                `INSERT INTO "ayuda_chunks"("documento_id","orden","contenido","token_count","embedding")
                 VALUES ($1::uuid, $2::int, $3::text, $4::int, $5::vector)`,
                docRow.id,
                i,
                ch.contenido,
                ch.token_count,
                vectorLiteral,
              );
              chunksTotal++;
            }
          }

          procesados++;
          const durMs = Date.now() - tDoc;
          const dt = (durMs / 1000).toFixed(1);
          this.logger.log(
            `[${idx}/${docs.length}] OK "${d.titulo}" → ${chunks.length} chunks (${dt}s)`,
          );
          await this.prisma.ayuda_ingesta_jobs.update({
            where: { id: jobId },
            data: { procesados, chunks_total: chunksTotal },
          });
          await this.escribirLog(jobId, {
            fuente: d.fuente,
            origen_path: d.origen_path,
            screen_key: d.screen_key,
            titulo: d.titulo,
            accion: existing ? 'updated' : 'created',
            chunks_count: chunks.length,
            duration_ms: durMs,
          });
        } catch (err) {
          const e = err as Error & { cause?: { code?: string; message?: string }; status?: number; name?: string };
          // AbortError → marca cancelado y rompe (no cuenta como error)
          if (controller.signal.aborted || e.name === 'AbortError' || e.cause?.code === 'ABORT_ERR') {
            cancelado = true;
            this.logger.warn(`Job ${jobId}: aborto efectivo del LLM en doc "${d.titulo}"`);
            break;
          }
          const detalle = e.cause?.code || e.cause?.message || e.status || '';
          errores++;
          this.logger.warn(
            `[${idx}/${docs.length}] FALLO "${d.titulo}" (${d.origen_path ?? d.screen_key}): ${e.message}${detalle ? ` [${detalle}]` : ''}`,
          );
          if (e.stack) this.logger.debug(e.stack);
          await this.prisma.ayuda_ingesta_jobs.update({
            where: { id: jobId },
            data: { procesados: procesados + errores },
          });
          await this.escribirLog(jobId, {
            fuente: d.fuente,
            origen_path: d.origen_path,
            screen_key: d.screen_key,
            titulo: d.titulo,
            accion: 'error',
            duration_ms: Date.now() - tDoc,
            detalle: `${e.message}${detalle ? ` [${detalle}]` : ''}`,
          });
          // Errores fatales de configuración (auth/endpoint) → abortar todo el job
          const status = e.status ?? 0;
          if (status === 401 || status === 403 || status === 404) {
            this.logger.error(
              `Job ${jobId}: error de configuración (HTTP ${status}) — abortando job completo`,
            );
            controller.abort();
            await this.fallarJob(
              jobId,
              `Error de configuración del proveedor (HTTP ${status}): ${e.message}`,
            );
            return;
          }
        }
      }

      const totSeg = ((Date.now() - tJob) / 1000).toFixed(1);
      this.logger.log(
        `Job ${jobId} listo en ${totSeg}s: ${procesados - omitidos} procesados, ${omitidos} sin cambios, ${errores} con error, ${chunksTotal} chunks insertados`,
      );
      // No sobrescribir un estado terminal puesto por cancelarJob (cancelado/error)
      const estadoFinal = await this.prisma.ayuda_ingesta_jobs.findUnique({
        where: { id: jobId },
        select: { estado: true },
      });
      if (estadoFinal?.estado !== 'cancelado' && estadoFinal?.estado !== 'error') {
        await this.prisma.ayuda_ingesta_jobs.update({
          where: { id: jobId },
          data: {
            estado: cancelado ? 'cancelado' : 'completado',
            finished_at: new Date(),
            procesados: procesados + errores,
            chunks_total: chunksTotal,
            ...(cancelado && { error: 'Cancelado por el usuario' }),
          },
        });
      }
    } catch (err) {
      await this.fallarJob(jobId, (err as Error).message);
    } finally {
      this.abortControllers.delete(jobId);
    }
  }

  /**
   * Opción B — Enriquecimiento de chunk para embedding.
   * Genera con el LLM 5-7 preguntas/frases en lenguaje de usuario que esta sección
   * responde (incluyendo sinónimos y formas coloquiales) y las agrega al texto que
   * se va a embeddear. Así una sección bien documentada se vuelve encontrable aunque
   * el usuario la busque con otras palabras — sistémico, sin depender de un reranker.
   *
   * El resultado se usa SOLO para calcular el embedding; en `ayuda_chunks.contenido`
   * se sigue guardando el texto original (lo que ve la IA al responder).
   *
   * Degrada graceful: si no hay proveedor de chat o el LLM falla, embeddea el original.
   * Se puede desactivar con AYUDA_ENRIQUECER_CHUNKS=false.
   */
  private async enriquecerChunkParaEmbedding(
    config: ConfigConKey,
    contenido: string,
    empresaId: string,
  ): Promise<string> {
    if (process.env.AYUDA_ENRIQUECER_CHUNKS === 'false') return contenido;
    // Sin clave de chat (y no es proveedor local) → no se puede enriquecer.
    if (!config.modelo || (!config.api_key && config.proveedor !== 'ollama')) return contenido;

    try {
      const system =
        'Sos un indexador de la base de ayuda de un ERP. Dada una sección de una guía, ' +
        'generás las variantes de cómo un usuario final (no técnico, español rioplatense) la ' +
        'buscaría o preguntaría. Devolvés solo la lista, sin numerar, sin markdown, sin explicaciones.';
      const prompt =
        `Sección de la guía:\n"""\n${contenido.slice(0, 1800)}\n"""\n\n` +
        'Generá 5 a 7 preguntas o frases MUY cortas, en las palabras de un usuario que no conoce ' +
        'los términos técnicos del sistema, que esta sección responde o con las que la buscaría. ' +
        'Incluí sinónimos y formas coloquiales. Una por línea.';

      const r = await this.provider.completar({
        proveedor: config.proveedor,
        modelo: config.modelo,
        apiKey: config.api_key,
        baseUrl: config.base_url,
        system,
        prompt,
        max_tokens: 220,
        temperatura: 0.3,
        empresa_id: empresaId,
        feature: 'AYUDA',
      });

      const extra = (r.texto || '').trim();
      if (!extra) return contenido;
      return `${contenido}\n\n[Búsquedas frecuentes de esta sección]\n${extra}`;
    } catch (err) {
      this.logger.warn(`Enriquecimiento de chunk falló, embeddeo el original: ${(err as Error).message}`);
      return contenido;
    }
  }

  private async embedEnBatches(
    config: ConfigConKey,
    textos: string[],
    signal?: AbortSignal,
  ): Promise<number[][]> {
    const BATCH = 50;
    const out: number[][] = [];
    for (let i = 0; i < textos.length; i += BATCH) {
      const batch = textos.slice(i, i + BATCH);
      const vecs = await this.provider.embed({
        proveedor: config.proveedor_embeddings,
        modelo: config.modelo_embeddings,
        apiKey: config.api_key_embeddings,
        baseUrl: config.base_url,
        textos: batch,
        signal,
      });
      out.push(...vecs);
    }
    return out;
  }

  private async fallarJob(jobId: string, error: string) {
    await this.prisma.ayuda_ingesta_jobs.update({
      where: { id: jobId },
      data: { estado: 'error', error, finished_at: new Date() },
    });
  }
}
