import { BadRequestException, Injectable, Logger, NotFoundException, OnModuleInit } from '@nestjs/common';
import { ModuleRef } from '@nestjs/core';
import { AuditService } from 'src/audit/audit.service';
import { envs } from 'src/config/envs';
import { ContabilidadIntegracionService } from 'src/contabilidad/services/integracion.service';
import { MapeoCuentasService } from 'src/contabilidad/services/mapeo-cuentas.service';
import { PeriodosService } from 'src/contabilidad/services/periodos.service';
import { PrismaService } from 'src/prisma/prisma.service';
import { RendicionViaticosContabilidadService } from 'src/rendicion-viaticos/rendicion-viaticos-contabilidad.service';
import { CONT_ORIGEN_REGISTRY_SEED, RegistryEntry } from './registry.seed';
import { facturaVentaContabilizable, movimientoSeContabilizaPorSuOrigen } from 'src/contabilidad/services/reglas-contabilizacion';
import { explicarPendiente, type ExplicacionPendiente } from './causa-pendiente';

export interface FiltrosConciliacion {
  desde?: string;
  hasta?: string;
  origen_tipo?: string;
  origen_tipos?: string[];
  search?: string;
  limit?: number;
}

export interface ResumenItem {
  origen_tipo: string;
  descripcion: string;
  modulo: string;
  pendientes: number;
  monto_pendiente: number;
  /** Cuántos se crearon antes de activar Contabilidad (nunca iban a tener asiento). */
  anteriores_a_contabilidad: number;
  /** Cuántos vienen de una importación (Marangatu, migración, extracto bancario): no pasaron por el flujo que asienta. */
  importados_extracto: number;
  /** Fecha de activación del módulo, para mostrarla junto al conteo. */
  activacion_contabilidad: Date | null;
}

export interface PendienteItem {
  id: string;
  origen_tipo: string;
  fecha: Date | null;
  numero: string | null;
  monto: number;
  empresa_id: string;
  descripcion?: string | null;
  contraparte?: string | null;
  /** Por qué no se generó el asiento en su momento (causa probable con evidencia). */
  causa?: ExplicacionPendiente;
  /**
   * Prueba de que hoy no tiene asiento: sin documento contable, o con uno que no
   * está confirmado (borrador / revertido al anular).
   */
  verificacion?: { documento_estado: 'SIN_DOCUMENTO' | 'BORRADOR' | 'REVERTIDO'; desde: Date | null };
}

/**
 * ConciliacionService — Fase 1: detección de asientos pendientes.
 *
 * Se apoya en el registry `cont_origen_registry` que declara qué orígenes son
 * contabilizables, dónde viven y cómo generarles asiento (los integradores ya
 * existentes en `ContabilidadIntegracionService`).
 *
 * La detección es en vivo: un registro es "pendiente" si en `cont_documentos`
 * no existe una fila CONFIRMADA para su par (origen_tipo, origen_id).
 */
@Injectable()
export class ContabilidadConciliacionService implements OnModuleInit {
  private readonly logger = new Logger(ContabilidadConciliacionService.name);

  private readonly integradores: Record<string, Record<string, (...args: unknown[]) => Promise<unknown>>>;

  constructor(
    private readonly prisma: PrismaService,
    private readonly moduleRef: ModuleRef,
    private readonly mapeo: MapeoCuentasService,
    private readonly periodos: PeriodosService,
    private readonly audit: AuditService,
    private readonly contabilidadIntegracion: ContabilidadIntegracionService,
    private readonly viaticosContabilidad: RendicionViaticosContabilidadService,
  ) {
    // Mapa nombre-de-clase → instancia. Cuando se agrega un origen nuevo cuyo
    // service integrador no esté acá, hay que inyectarlo y sumarlo a este mapa.
    this.integradores = {
      ContabilidadIntegracionService: this.contabilidadIntegracion as unknown as Record<
        string,
        (...args: unknown[]) => Promise<unknown>
      >,
      RendicionViaticosContabilidadService: this.viaticosContabilidad as unknown as Record<
        string,
        (...args: unknown[]) => Promise<unknown>
      >,
    };
  }

  async onModuleInit() {
    await this.sincronizarRegistry();
  }

  /**
   * Seed idempotente del registry. Corre al bootear el módulo.
   */
  async sincronizarRegistry(): Promise<void> {
    for (const entry of CONT_ORIGEN_REGISTRY_SEED) {
      await this.prisma.cont_origen_registry.upsert({
        where: { origen_tipo: entry.origen_tipo },
        update: {
          descripcion: entry.descripcion,
          modulo: entry.modulo,
          submodulo: entry.submodulo ?? null,
          tabla_origen: entry.tabla_origen,
          campo_fecha: entry.campo_fecha,
          campo_monto: entry.campo_monto ?? null,
          campo_numero: entry.campo_numero ?? null,
          filtro_extra: (entry.filtro_extra as never) ?? undefined,
          service_name: entry.service_name,
          service_method: entry.service_method,
          arg_style: entry.arg_style ?? 'id',
          conceptos_req: (entry.conceptos_req as never) ?? undefined,
          orden: entry.orden,
          updated_at: new Date(),
        },
        create: {
          origen_tipo: entry.origen_tipo,
          descripcion: entry.descripcion,
          modulo: entry.modulo,
          submodulo: entry.submodulo ?? null,
          tabla_origen: entry.tabla_origen,
          campo_fecha: entry.campo_fecha,
          campo_monto: entry.campo_monto ?? null,
          campo_numero: entry.campo_numero ?? null,
          filtro_extra: (entry.filtro_extra as never) ?? undefined,
          service_name: entry.service_name,
          service_method: entry.service_method,
          arg_style: entry.arg_style ?? 'id',
          conceptos_req: (entry.conceptos_req as never) ?? undefined,
          orden: entry.orden,
        },
      });
    }
    // Desactivar orígenes que ya no están en el seed (ej. renombrados).
    // No los borramos para conservar la auditoría de intentos históricos.
    const tiposActivos = CONT_ORIGEN_REGISTRY_SEED.map((e) => e.origen_tipo);
    const desactivados = await this.prisma.cont_origen_registry.updateMany({
      where: {
        origen_tipo: { notIn: tiposActivos },
        activo: true,
      },
      data: { activo: false, updated_at: new Date() },
    });
    if (desactivados.count > 0) {
      this.logger.warn(
        `Registry sincronizado: ${desactivados.count} orígenes desactivados (ya no están en el seed).`,
      );
    }
    this.logger.log(`Registry sincronizado (${CONT_ORIGEN_REGISTRY_SEED.length} orígenes activos).`);
  }

  /**
   * Lista todos los orígenes activos del registry, con metadata.
   */
  async listarRegistry() {
    return this.prisma.cont_origen_registry.findMany({
      where: { activo: true },
      orderBy: [{ orden: 'asc' }, { descripcion: 'asc' }],
    });
  }

  /**
   * Verifica que la empresa tenga módulo Contabilidad activo.
   */
  private async tieneModuloContabilidad(empresaId: string): Promise<boolean> {
    const registro = await this.prisma.suscripcion_modulos.findFirst({
      where: {
        activo: true,
        modulos: { codigo: 'CONTABILIDAD' },
        suscripciones: { empresa_id: empresaId },
      },
    });
    return !!registro;
  }

  /**
   * Construye el filtro WHERE para "registros del origen que NO tienen documento
   * contable" — usa un NOT IN subquery contra `cont_documentos`.
   */
  private async buildWhereConSinDocumento(
    empresaId: string,
    entry: {
      origen_tipo: string;
      tabla_origen: string;
      campo_fecha: string;
      filtro_extra: unknown;
    },
    filtros: FiltrosConciliacion,
  ): Promise<Record<string, unknown>> {
    // IDs que ya tienen documento contable CONFIRMADO.
    // Documentos en BORRADOR o reversados no cuentan como "asentado" y por
    // tanto el registro origen sigue siendo pendiente de conciliación.
    const conDocumento = await this.prisma.cont_documentos.findMany({
      where: {
        empresa_id: empresaId,
        origen_tipo: entry.origen_tipo,
        estado: 'CONFIRMADO',
      },
      select: { origen_id: true },
    });
    const idsExcluidos = conDocumento
      .map((d) => d.origen_id)
      .filter((v): v is string => !!v);

    const where: Record<string, unknown> = {
      empresa_id: empresaId,
    };

    if (idsExcluidos.length > 0) {
      where.id = { notIn: idsExcluidos };
    }

    // Filtros extra específicos del origen (ej. estado != anulada).
    if (entry.filtro_extra && typeof entry.filtro_extra === 'object') {
      Object.assign(where, entry.filtro_extra);
    }

    // Rango de fechas.
    if (filtros.desde || filtros.hasta) {
      const rango: { gte?: Date; lte?: Date } = {};
      if (filtros.desde) rango.gte = new Date(filtros.desde);
      if (filtros.hasta) rango.lte = new Date(filtros.hasta);
      where[entry.campo_fecha] = rango;
    }

    return where;
  }


  /**
   * Desde cuándo la empresa pudo generar asientos: el módulo activo Y un
   * ejercicio contable creado. Con el módulo pero sin ejercicio (el caso típico
   * de las primeras semanas) el integrador no encuentra período y no asienta.
   */
  private async fechaActivacionContabilidad(empresaId: string): Promise<Date | null> {
    const [modulo, ejercicio] = await Promise.all([
      this.prisma.suscripcion_modulos.findFirst({
        where: { activo: true, modulos: { codigo: 'CONTABILIDAD' }, suscripciones: { empresa_id: empresaId } },
        orderBy: { created_at: 'asc' },
        select: { created_at: true },
      }),
      this.prisma.cont_ejercicios.findFirst({
        where: { empresa_id: empresaId },
        orderBy: { created_at: 'asc' },
        select: { created_at: true },
      }),
    ]);
    const fechas = [modulo?.created_at, ejercicio?.created_at].filter((d): d is Date => !!d);
    if (fechas.length === 0) return null;
    return new Date(Math.max(...fechas.map((d) => d.getTime())));
  }

  /**
   * Devuelve resumen agrupado por tipo de origen: cantidad pendiente + monto.
   */
  async resumen(empresaId: string, filtros: FiltrosConciliacion = {}): Promise<ResumenItem[]> {
    const entries = await this.listarRegistry();
    const tiposFiltro = new Set(
      filtros.origen_tipos && filtros.origen_tipos.length > 0
        ? filtros.origen_tipos
        : entries.map((e) => e.origen_tipo),
    );

    const resultados: ResumenItem[] = [];
    const activadoEl = await this.fechaActivacionContabilidad(empresaId);

    for (const entry of entries) {
      if (!tiposFiltro.has(entry.origen_tipo)) continue;
      try {
        const where = await this.buildWhereConSinDocumento(
          empresaId,
          {
            origen_tipo: entry.origen_tipo,
            tabla_origen: entry.tabla_origen,
            campo_fecha: entry.campo_fecha,
            filtro_extra: entry.filtro_extra,
          },
          filtros,
        );

        const model = (this.prisma as unknown as Record<string, {
          count: (args: unknown) => Promise<number>;
          aggregate?: (args: unknown) => Promise<{ _sum: Record<string, unknown> }>;
        }>)[entry.tabla_origen];
        if (!model) {
          this.logger.warn(`Tabla ${entry.tabla_origen} no encontrada en prisma client`);
          continue;
        }

        const count = await model.count({ where });
        let monto = 0;
        if (entry.campo_monto && model.aggregate) {
          const agg = await model.aggregate({
            where,
            _sum: { [entry.campo_monto]: true } as Record<string, boolean>,
          });
          const raw = agg._sum[entry.campo_monto];
          monto = raw === null || raw === undefined ? 0 : Number(raw);
        }

        // Por qué hay tantos: los creados antes de activar el módulo y, en
        // tesorería, los importados de extracto. Es lo primero que pregunta la empresa.
        const anteriores = activadoEl && count > 0
          ? await model.count({ where: { ...where, created_at: { lt: activadoEl } } })
          : 0;
        let importados = 0;
        if (count > 0) {
          if (entry.origen_tipo === 'tes_movimientos') {
            importados = await model.count({ where: { ...where, origen_tipo: 'IMPORT_EXTRACTO' } });
          } else if (entry.origen_tipo === 'factura_cab' || entry.origen_tipo === 'nota_credito_cab') {
            importados = await model.count({
              where: { ...where, OR: [{ dinfadic: { startsWith: 'MARANGATU' } }, { dinfadic: { startsWith: 'Migrad' } }] },
            });
          } else if (entry.origen_tipo === 'compra_cab') {
            importados = await model.count({ where: { ...where, estado: 'importado' } });
          }
        }

        resultados.push({
          origen_tipo: entry.origen_tipo,
          descripcion: entry.descripcion,
          modulo: entry.modulo,
          pendientes: count,
          monto_pendiente: monto,
          anteriores_a_contabilidad: anteriores,
          importados_extracto: importados,
          activacion_contabilidad: activadoEl,
        });
      } catch (err) {
        this.logger.warn(
          `Fallo consultando pendientes para ${entry.origen_tipo}: ${err instanceof Error ? err.message : String(err)}`,
        );
        resultados.push({
          origen_tipo: entry.origen_tipo,
          descripcion: entry.descripcion,
          modulo: entry.modulo,
          pendientes: 0,
          monto_pendiente: 0,
          anteriores_a_contabilidad: 0,
          importados_extracto: 0,
          activacion_contabilidad: activadoEl,
        });
      }
    }

    return resultados;
  }

  /**
   * Detalle paginado de registros pendientes para un origen dado.
   */
  async detalle(
    empresaId: string,
    origenTipo: string,
    filtros: FiltrosConciliacion = {},
  ): Promise<{ items: PendienteItem[]; total: number }> {
    const entry = await this.prisma.cont_origen_registry.findUnique({
      where: { origen_tipo: origenTipo },
    });
    if (!entry || !entry.activo) {
      throw new BadRequestException(`Origen "${origenTipo}" no está registrado o está inactivo`);
    }

    const where = await this.buildWhereConSinDocumento(
      empresaId,
      {
        origen_tipo: entry.origen_tipo,
        tabla_origen: entry.tabla_origen,
        campo_fecha: entry.campo_fecha,
        filtro_extra: entry.filtro_extra,
      },
      filtros,
    );

    const model = (this.prisma as unknown as Record<string, {
      count: (args: unknown) => Promise<number>;
      findMany: (args: unknown) => Promise<Record<string, unknown>[]>;
    }>)[entry.tabla_origen];
    if (!model) {
      throw new BadRequestException(`Tabla ${entry.tabla_origen} no accesible`);
    }

    const limit = Math.min(Math.max(1, filtros.limit ?? 100), 500);

    // Select mínimo para no traer joins pesados.
    const selectFields: Record<string, boolean> = {
      id: true,
      empresa_id: true,
      [entry.campo_fecha]: true,
    };
    if (entry.campo_monto) selectFields[entry.campo_monto] = true;
    if (entry.campo_numero) selectFields[entry.campo_numero] = true;
    // Para explicar por qué quedó pendiente hace falta saber CUÁNDO se creó.
    selectFields.created_at = true;

    const [rawItems, total] = await Promise.all([
      model.findMany({
        where,
        select: selectFields,
        orderBy: { [entry.campo_fecha]: 'desc' },
        take: limit,
      }),
      model.count({ where }),
    ]);

    const items: PendienteItem[] = rawItems.map((r) => {
      const fecha = entry.campo_fecha in r ? (r[entry.campo_fecha] as Date | null) : null;
      const numero = entry.campo_numero && entry.campo_numero in r
        ? (r[entry.campo_numero] as string | null)
        : null;
      const montoRaw = entry.campo_monto && entry.campo_monto in r ? r[entry.campo_monto] : 0;
      return {
        id: String(r.id),
        origen_tipo: entry.origen_tipo,
        fecha,
        numero,
        monto: Number(montoRaw ?? 0),
        empresa_id: String(r.empresa_id),
        _creado_en: (r.created_at as Date | null) ?? null,
      } as PendienteItem & { _creado_en: Date | null };
    });

    // Enriquecer con contraparte + descripción (joins específicos por origen).
    const ids = items.map((i) => i.id);
    if (ids.length > 0) {
      const enriched = await this.enriquecerItems(entry.origen_tipo, ids, empresaId);
      for (const it of items) {
        const extras = enriched.get(it.id);
        if (extras) {
          it.contraparte = extras.contraparte ?? null;
          it.descripcion = extras.descripcion ?? null;
          // Si el enriquecimiento trae un número más completo (est-punto-nro), lo usamos.
          if (extras.numero) it.numero = extras.numero;
          // Si el enriquecimiento resolvió un monto real, sobrescribimos el 0/null base.
          if (extras.monto !== undefined && (!it.monto || it.monto === 0)) {
            it.monto = extras.monto;
          }
        }
      }
    }

    await this.explicarPendientes(empresaId, entry, items as Array<PendienteItem & { _creado_en: Date | null }>);
    for (const it of items) delete (it as { _creado_en?: Date | null })._creado_en;

    return { items, total };
  }

  /**
   * Completa `causa` y `verificacion` de cada item. Todo en pocas consultas
   * agrupadas: activación del módulo, documentos no confirmados, último intento
   * fallido y (para tesorería) las categorías mapeadas después del movimiento.
   */
  private async explicarPendientes(
    empresaId: string,
    entry: { origen_tipo: string; tabla_origen: string; conceptos_req: unknown },
    items: Array<PendienteItem & { _creado_en: Date | null }>,
  ): Promise<void> {
    if (items.length === 0) return;
    const ids = items.map((i) => i.id);

    const [activacion, docs, intentos] = await Promise.all([
      this.fechaActivacionContabilidad(empresaId),
      this.prisma.cont_documentos.findMany({
        where: { empresa_id: empresaId, origen_tipo: entry.origen_tipo, origen_id: { in: ids } },
        select: { origen_id: true, estado: true, updated_at: true },
      }),
      this.prisma.audit_logs.findMany({
        where: { empresa_id: empresaId, entity_type: entry.tabla_origen, entity_id: { in: ids }, action: 'CONT_CONC_REGENERAR_FALLA' },
        orderBy: { created_at: 'desc' },
        select: { entity_id: true, created_at: true, descripcion: true, new_value: true },
      }),
    ]);
    // Integración en tiempo real auditada como OK sin asiento (recibos multi).
    const marcadosOk = new Map<string, Date>();
    if (entry.origen_tipo === 'recibos_multi') {
      const oks = await this.prisma.audit_logs.findMany({
        where: { empresa_id: empresaId, entity_type: 'recibo_multi_integracion', entity_id: { in: ids } },
        orderBy: { created_at: 'desc' },
        select: { entity_id: true, created_at: true, new_value: true },
      });
      for (const a of oks) {
        const nv = (a.new_value ?? {}) as Record<string, unknown>;
        if (a.entity_id && nv.resultado === 'OK' && !marcadosOk.has(a.entity_id) && a.created_at) marcadosOk.set(a.entity_id, a.created_at);
      }
    }
    const docPorId = new Map(docs.map((d) => [String(d.origen_id), d]));
    const intentoPorId = new Map<string, { fecha: Date; error: string }>();
    for (const a of intentos) {
      if (!a.entity_id || intentoPorId.has(a.entity_id)) continue;
      const nv = (a.new_value ?? {}) as Record<string, unknown>;
      intentoPorId.set(a.entity_id, {
        fecha: a.created_at ?? new Date(),
        error: String(nv.descripcion ?? nv.motivo ?? a.descripcion ?? 'sin detalle'),
      });
    }

    // Mapeos cargados después del registro: categorías de tesorería o conceptos del origen.
    const mapeosPorId = new Map<string, Array<{ nombre: string; fecha: Date }>>();
    if (entry.origen_tipo === 'tes_movimientos') {
      const dets = await this.prisma.tes_movimiento_det.findMany({
        where: { movimiento_id: { in: ids } },
        select: { movimiento_id: true, categoria: { select: { id: true, nombre: true } } },
      });
      const catIds = [...new Set(dets.map((d) => d.categoria?.id).filter((v): v is string => !!v))];
      const mapeos = catIds.length
        ? await this.prisma.tes_categoria_cuenta_contable.findMany({
            where: { empresa_id: empresaId, categoria_id: { in: catIds } },
            select: { categoria_id: true, created_at: true },
          })
        : [];
      const mapeadoEl = new Map(mapeos.map((m) => [m.categoria_id, m.created_at]));
      const creadoPorId = new Map(items.map((i) => [i.id, i._creado_en]));
      for (const d of dets) {
        const creado = creadoPorId.get(d.movimiento_id);
        const fechaMapeo = d.categoria ? mapeadoEl.get(d.categoria.id) : undefined;
        if (creado && fechaMapeo && fechaMapeo > creado) {
          const lista = mapeosPorId.get(d.movimiento_id) ?? [];
          if (!lista.some((x) => x.nombre === d.categoria!.nombre)) lista.push({ nombre: d.categoria!.nombre, fecha: fechaMapeo });
          mapeosPorId.set(d.movimiento_id, lista);
        }
      }
    } else {
      const conceptos = Array.isArray(entry.conceptos_req) ? (entry.conceptos_req as string[]) : [];
      if (conceptos.length) {
        const mapeos = await this.prisma.cont_mapeo_cuentas.findMany({
          where: { empresa_id: empresaId, concepto: { in: conceptos } },
          select: { concepto: true, created_at: true },
        });
        for (const it of items) {
          const posteriores = mapeos
            .filter((m) => it._creado_en && m.created_at && m.created_at > it._creado_en)
            .map((m) => ({ nombre: m.concepto, fecha: m.created_at as Date }));
          if (posteriores.length) mapeosPorId.set(it.id, posteriores);
        }
      }
    }

    // Documentos importados (Marangatu / migración): no pasaron por la emisión.
    const importadoDesde = new Map<string, string>();
    if (entry.origen_tipo === 'factura_cab' || entry.origen_tipo === 'nota_credito_cab') {
      const modelo = entry.origen_tipo === 'factura_cab' ? this.prisma.factura_cab : this.prisma.nota_credito_cab;
      const rows = await (modelo as unknown as { findMany: (a: unknown) => Promise<Array<{ id: string; dinfadic: string | null }>> }).findMany({
        where: { id: { in: ids } },
        select: { id: true, dinfadic: true },
      });
      for (const r of rows) {
        const tag = r.dinfadic ?? '';
        if (tag.startsWith('MARANGATU')) importadoDesde.set(r.id, 'Marangatu');
        else if (/^Migrad/i.test(tag)) importadoDesde.set(r.id, 'el sistema anterior');
      }
    }
    if (entry.origen_tipo === 'compra_cab') {
      const rows = await this.prisma.compra_cab.findMany({ where: { id: { in: ids } }, select: { id: true, estado: true } });
      for (const r of rows) if (r.estado === 'importado') importadoDesde.set(r.id, 'Marangatu');
    }

    // Origen del movimiento (para reconocer importaciones de extracto).
    const origenMov = new Map<string, string | null>();
    if (entry.origen_tipo === 'tes_movimientos') {
      const movs = await this.prisma.tes_movimientos.findMany({ where: { id: { in: ids } }, select: { id: true, origen_tipo: true } });
      for (const m of movs) origenMov.set(m.id, m.origen_tipo);
    }

    for (const it of items) {
      const doc = docPorId.get(it.id);
      it.verificacion = doc
        ? { documento_estado: doc.estado === 'REVERTIDO' ? 'REVERTIDO' : 'BORRADOR', desde: doc.updated_at ?? null }
        : { documento_estado: 'SIN_DOCUMENTO', desde: null };
      it.causa = explicarPendiente({
        creadoEn: it._creado_en,
        activacionContabilidad: activacion,
        origenTipo: origenMov.get(it.id) ?? null,
        importadoDesde: importadoDesde.get(it.id) ?? null,
        mapeosPosteriores: mapeosPorId.get(it.id) ?? [],
        ultimoIntento: intentoPorId.get(it.id) ?? null,
        integracionMarcadaOk: marcadosOk.get(it.id) ?? null,
      });
    }
  }

  /**
   * Trae contraparte + descripción para una lista de IDs de un origen dado.
   * Cada origen conoce sus joins/campos específicos.
   */
  private async enriquecerItems(
    origenTipo: string,
    ids: string[],
    empresaId: string,
  ): Promise<Map<string, { contraparte?: string; descripcion?: string; numero?: string; monto?: number }>> {
    const map = new Map<string, { contraparte?: string; descripcion?: string; numero?: string; monto?: number }>();
    if (!ids.length) return map;
    const whereBase = { id: { in: ids }, empresa_id: empresaId };

    try {
      switch (origenTipo) {
        case 'factura_cab': {
          const rows = await this.prisma.factura_cab.findMany({
            where: whereBase,
            select: {
              id: true,
              dnumdoc: true,
              dest: true,
              dpunexp: true,
              total_factura: true,
              clientes: {
                select: {
                  nombre_fantasia: true,
                  personas: { select: { razon_social: true } },
                },
              },
              factura_subtotales: { select: { dtotope: true } },
            },
          });
          for (const r of rows) {
            const nro = [r.dest, r.dpunexp, r.dnumdoc].filter(Boolean).join('-');
            // Preferir dtotope (fuente de verdad SET); fallback a total_factura.
            const totalSubs = (r.factura_subtotales ?? []).reduce(
              (acc, s) => acc + Number(s.dtotope ?? 0),
              0,
            );
            const monto = totalSubs > 0 ? totalSubs : Number(r.total_factura ?? 0);
            map.set(r.id, {
              contraparte:
                r.clientes?.personas?.razon_social ||
                r.clientes?.nombre_fantasia ||
                null,
              numero: nro || null,
              descripcion: null,
              monto,
            });
          }
          break;
        }
        case 'nota_credito_cab': {
          const rows = await this.prisma.nota_credito_cab.findMany({
            where: whereBase,
            select: {
              id: true,
              dnumdoc: true,
              dest: true,
              dpunexp: true,
              clientes: {
                select: {
                  nombre_fantasia: true,
                  personas: { select: { razon_social: true } },
                },
              },
              nota_credito_subtotal: { select: { dtotope: true } },
            },
          });
          for (const r of rows) {
            const nro = [r.dest, r.dpunexp, r.dnumdoc].filter(Boolean).join('-');
            const monto = (r.nota_credito_subtotal ?? []).reduce(
              (acc, s) => acc + Number(s.dtotope ?? 0),
              0,
            );
            map.set(r.id, {
              contraparte:
                r.clientes?.personas?.razon_social ||
                r.clientes?.nombre_fantasia ||
                null,
              numero: nro || null,
              monto,
            });
          }
          break;
        }
        case 'compra_cab': {
          const rows = await this.prisma.compra_cab.findMany({
            where: whereBase,
            select: {
              id: true,
              numero_factura: true,
              establecimiento: true,
              punto_expedicion: true,
              proveedores: {
                select: { personas: { select: { razon_social: true } } },
              },
            },
          });
          for (const r of rows) {
            const nro = [r.establecimiento, r.punto_expedicion, r.numero_factura].filter(Boolean).join('-');
            map.set(r.id, {
              contraparte: r.proveedores?.personas?.razon_social ?? null,
              numero: nro || null,
            });
          }
          break;
        }
        case 'gasto_cab': {
          const rows = await this.prisma.gasto_cab.findMany({
            where: whereBase,
            select: {
              id: true,
              numero_factura: true,
              establecimiento: true,
              punto_expedicion: true,
              descripcion: true,
              proveedores: {
                select: { personas: { select: { razon_social: true } } },
              },
            },
          });
          for (const r of rows) {
            const nro = [r.establecimiento, r.punto_expedicion, r.numero_factura].filter(Boolean).join('-');
            map.set(r.id, {
              contraparte: r.proveedores?.personas?.razon_social ?? null,
              numero: nro || null,
              descripcion: r.descripcion ?? null,
            });
          }
          break;
        }
        case 'recibos_cobro':
        case 'recibos_multi': {
          const rows = await this.prisma.recibos_cobro.findMany({
            where: whereBase,
            select: {
              id: true,
              numero_recibo: true,
              clientes: {
                select: {
                  nombre_fantasia: true,
                  personas: { select: { razon_social: true } },
                },
              },
            },
          });
          for (const r of rows) {
            map.set(r.id, {
              contraparte:
                r.clientes?.personas?.razon_social ||
                r.clientes?.nombre_fantasia ||
                null,
              numero: r.numero_recibo || null,
            });
          }
          break;
        }
        case 'pagos_proveedor': {
          const rows = await this.prisma.pagos_proveedor.findMany({
            where: whereBase,
            select: {
              id: true,
              proveedores: {
                select: { personas: { select: { razon_social: true } } },
              },
              medio_pago: { select: { descripcion: true } },
            },
          });
          for (const r of rows) {
            map.set(r.id, {
              contraparte: r.proveedores?.personas?.razon_social ?? null,
              descripcion: r.medio_pago?.descripcion ?? null,
            });
          }
          break;
        }
        case 'orden_pago_proveedor_cab': {
          const rows = await this.prisma.orden_pago_proveedor_cab.findMany({
            where: whereBase,
            select: {
              id: true,
              numero_orden_pago: true,
              proveedores: {
                select: { personas: { select: { razon_social: true } } },
              },
            },
          });
          for (const r of rows) {
            map.set(r.id, {
              contraparte: r.proveedores?.personas?.razon_social ?? null,
              numero: r.numero_orden_pago || null,
            });
          }
          break;
        }
        case 'tes_movimientos': {
          const rows = await this.prisma.tes_movimientos.findMany({
            where: whereBase,
            select: {
              id: true,
              tipo: true,
              descripcion: true,
              referencia: true,
              cuenta: { select: { nombre: true } },
            },
          });
          for (const r of rows) {
            const cuenta = r.cuenta?.nombre;
            const desc = [r.tipo, r.descripcion].filter(Boolean).join(' — ');
            map.set(r.id, {
              contraparte: cuenta ?? null,
              descripcion: desc || null,
              numero: r.referencia || null,
            });
          }
          break;
        }
        case 'rrhh_liquidacion': {
          const rows = await this.prisma.rrhh_liquidaciones_cabecera.findMany({
            where: whereBase,
            select: {
              id: true,
              periodo_anio: true,
              periodo_mes: true,
              cantidad_empleados: true,
              tipo: true,
            },
          });
          for (const r of rows) {
            const periodo = `${String(r.periodo_mes).padStart(2, '0')}/${r.periodo_anio}`;
            map.set(r.id, {
              contraparte: `${r.cantidad_empleados ?? 0} empleado(s)`,
              descripcion: `${r.tipo ?? 'MENSUAL'} · ${periodo}`,
            });
          }
          break;
        }
        case 'rendicion_adelanto': {
          const rows = await this.prisma.rendicion_adelanto.findMany({
            where: whereBase,
            select: {
              id: true,
              numero_operacion: true,
              rendicion: {
                select: {
                  numero_rendicion: true,
                  concepto: true,
                  empleado: { select: { nombres: true, apellidos: true } },
                },
              },
            },
          });
          for (const r of rows) {
            const emp = r.rendicion?.empleado
              ? `${r.rendicion.empleado.nombres} ${r.rendicion.empleado.apellidos}`.trim()
              : null;
            map.set(r.id, {
              contraparte: emp,
              descripcion: r.rendicion?.concepto ?? null,
              numero: r.rendicion?.numero_rendicion || r.numero_operacion || null,
            });
          }
          break;
        }
        case 'rendicion_devolucion': {
          const rows = await this.prisma.rendicion_devolucion.findMany({
            where: whereBase,
            select: {
              id: true,
              tipo: true,
              numero_operacion: true,
              rendicion: {
                select: {
                  numero_rendicion: true,
                  empleado: { select: { nombres: true, apellidos: true } },
                },
              },
            },
          });
          for (const r of rows) {
            const emp = r.rendicion?.empleado
              ? `${r.rendicion.empleado.nombres} ${r.rendicion.empleado.apellidos}`.trim()
              : null;
            map.set(r.id, {
              contraparte: emp,
              descripcion: r.tipo ?? null,
              numero: r.rendicion?.numero_rendicion || r.numero_operacion || null,
            });
          }
          break;
        }
        default:
          break;
      }
    } catch (err) {
      this.logger.warn(
        `enriquecerItems falló para ${origenTipo}: ${err instanceof Error ? err.message : String(err)}`,
      );
    }
    return map;
  }

  /**
   * Devuelve las suscripciones/estado de módulo para el reporte de
   * "por qué faltan asientos" a nivel empresa.
   */
  async estadoModuloContabilidad(empresaId: string): Promise<{ activo: boolean }> {
    return { activo: await this.tieneModuloContabilidad(empresaId) };
  }

  // ── Fase 2: Diagnóstico por registro ─────────────────────────────────────

  /**
   * Diagnostica por qué un registro no tiene asiento contable.
   * Retorna el motivo probable y contexto para que el operador lo resuelva.
   */
  async diagnosticarRegistro(
    empresaId: string,
    origenTipo: string,
    id: string,
  ): Promise<DiagnosticoResultado> {
    // Módulo activo
    if (!(await this.tieneModuloContabilidad(empresaId))) {
      return {
        motivo: 'MODULO_INACTIVO',
        descripcion:
          'El módulo Contabilidad no estaba activo cuando se creó el registro. Al activarlo y regenerar, se contabiliza.',
        contexto: {},
      };
    }

    // Entry del registry
    const entry = await this.prisma.cont_origen_registry.findUnique({
      where: { origen_tipo: origenTipo },
    });
    if (!entry) {
      return {
        motivo: 'SIN_MOTIVO',
        descripcion: `Origen "${origenTipo}" no está registrado en el catálogo.`,
        contexto: {},
      };
    }

    // Traer el registro origen (fecha + campos clave)
    const model = (this.prisma as unknown as Record<string, {
      findFirst: (args: unknown) => Promise<Record<string, unknown> | null>;
    }>)[entry.tabla_origen];
    if (!model) {
      return {
        motivo: 'SIN_MOTIVO',
        descripcion: `Tabla ${entry.tabla_origen} no accesible.`,
        contexto: {},
      };
    }
    const selectFields: Record<string, boolean> = {
      id: true,
      empresa_id: true,
      [entry.campo_fecha]: true,
    };
    const registro = await model.findFirst({
      where: { id, empresa_id: empresaId },
      select: selectFields,
    });
    if (!registro) {
      return {
        motivo: 'SIN_MOTIVO',
        descripcion: 'Registro no encontrado.',
        contexto: {},
      };
    }

    // 1) Chequear mapeos requeridos.
    const conceptos = Array.isArray(entry.conceptos_req) ? (entry.conceptos_req as string[]) : [];
    for (const c of conceptos) {
      try {
        await this.mapeo.getCuentaPorConcepto(empresaId, c);
      } catch (err) {
        // El mapeo puede existir pero apuntar a una cuenta desactivada / OLD-:
        // eso no es "falta mapeo", y el arreglo es distinto (reasignar, no crear).
        if (!(err instanceof NotFoundException)) {
          const cuenta = await this.prisma.cont_mapeo_cuentas.findFirst({
            where: { empresa_id: empresaId, concepto: c },
            select: { cuenta: { select: { codigo: true } } },
          });
          return {
            motivo: 'CUENTA_OLD',
            descripcion: `El concepto "${c}" está mapeado a la cuenta ${cuenta?.cuenta?.codigo ?? '(?)'}, que está desactivada. Reasignalo a una cuenta vigente en Contabilidad → Mapeo de Cuentas.`,
            contexto: { concepto: c, cuenta_codigo: cuenta?.cuenta?.codigo ?? null },
          };
        }
        return {
          motivo: 'SIN_MAPEO',
          descripcion: `Falta mapeo del concepto "${c}" en Contabilidad → Mapeo de Cuentas.`,
          contexto: { concepto_faltante: c },
        };
      }
    }

    // 2) Chequear período abierto para la fecha del registro.
    const fecha = (registro[entry.campo_fecha] as Date | null) ?? new Date();
    try {
      await this.periodos.resolverPeriodoPorFecha(empresaId, fecha);
    } catch {
      return {
        motivo: 'PERIODO_CERRADO',
        descripcion: `El período contable para la fecha ${fecha.toISOString().substring(0, 10)} está cerrado o no existe.`,
        contexto: { fecha: fecha.toISOString().substring(0, 10) },
      };
    }

    // 3) Chequear si los mapeos apuntan a cuenta OLD-/inactiva.
    for (const c of conceptos) {
      try {
        const cuentaId = await this.mapeo.getCuentaPorConcepto(empresaId, c);
        const cuenta = await this.prisma.cont_plan_cuentas.findUnique({
          where: { id: cuentaId },
          select: { codigo: true, active: true },
        });
        if (!cuenta || !cuenta.active) {
          return {
            motivo: 'CUENTA_OLD',
            descripcion: `El concepto "${c}" mapea a una cuenta inactiva. Reasignar una cuenta vigente.`,
            contexto: { concepto: c, cuenta_codigo: cuenta?.codigo ?? null },
          };
        }
        if (cuenta.codigo && cuenta.codigo.startsWith('OLD-')) {
          return {
            motivo: 'CUENTA_OLD',
            descripcion: `El concepto "${c}" mapea a la cuenta ${cuenta.codigo} (plan reemplazado). Reasignar a la cuenta vigente.`,
            contexto: { concepto: c, cuenta_codigo: cuenta.codigo },
          };
        }
      } catch {
        // ya se manejó en el primer loop
      }
    }

    // 4) Chequeos específicos por origen.
    const especifico = await this.diagnosticoEspecifico(empresaId, origenTipo, id);
    if (especifico) return especifico;

    // 5) Nada obvio — probablemente un bug histórico o un registro creado antes
    //    de la activación del hook contable. Se puede regenerar sin más.
    return {
      motivo: 'SIN_MOTIVO',
      descripcion:
        'El registro cumple los checks pero no tiene asiento. Se puede regenerar directamente.',
      contexto: {},
    };
  }

  /**
   * Chequeos específicos por origen que van más allá del catálogo genérico.
   * Ej: para movimientos de tesorería, verificar que la categoría del detalle
   * tenga cuenta contable mapeada.
   */
  private async diagnosticoEspecifico(
    empresaId: string,
    origenTipo: string,
    id: string,
  ): Promise<DiagnosticoResultado | null> {
    if (origenTipo === 'factura_cab' || origenTipo === 'nota_credito_cab') {
      const doc =
        origenTipo === 'factura_cab'
          ? await this.prisma.factura_cab.findFirst({ where: { id, empresa_id: empresaId }, select: { estado: true } })
          : await this.prisma.nota_credito_cab.findFirst({ where: { id, empresa_id: empresaId }, select: { estado: true } });
      if (doc && !facturaVentaContabilizable(doc.estado)) {
        return {
          motivo: 'NO_CONTABILIZABLE',
          descripcion: `Está en estado "${doc.estado}": solo los documentos aprobados por SIFEN generan asiento.`,
          contexto: { estado: doc.estado },
        };
      }
    }

    if (origenTipo === 'tes_movimientos') {
      // 1) La cuenta de tesorería tiene cuenta contable.
      const mov = await this.prisma.tes_movimientos.findFirst({
        where: { id, empresa_id: empresaId },
        select: {
          id: true,
          cuenta_id: true,
          origen_tipo: true,
          fecha: true,
          cuenta: { select: { nombre: true, moneda: true, cuenta_contable_id: true } },
        },
      });
      if (!mov) return null;
      if (movimientoSeContabilizaPorSuOrigen(mov.origen_tipo)) {
        return {
          motivo: 'NO_CONTABILIZABLE',
          descripcion: `Lo generó un documento (${mov.origen_tipo}) cuyo asiento ya incluye este movimiento. Asentarlo aparte lo duplicaría.`,
          contexto: { origen_tipo: mov.origen_tipo },
        };
      }
      // Cuenta en moneda extranjera: el asiento se convierte a guaraníes con la
      // cotización del día. Sin ella, el integrador falla en silencio.
      if (mov.cuenta?.moneda && mov.cuenta.moneda !== 'PYG' && mov.fecha) {
        const tc = await this.prisma.cont_tipo_cambio.findFirst({
          where: { empresa_id: empresaId, moneda: mov.cuenta.moneda, fecha: { lte: mov.fecha } },
          select: { id: true },
        });
        if (!tc) {
          return {
            motivo: 'SIN_TIPO_CAMBIO',
            descripcion: `La cuenta "${mov.cuenta.nombre}" está en ${mov.cuenta.moneda} y no hay cotización cargada para el ${mov.fecha.toISOString().substring(0, 10)} ni antes. Cargala en Contabilidad → Tipo de Cambio.`,
            contexto: { moneda: mov.cuenta.moneda, fecha: mov.fecha.toISOString().substring(0, 10) },
          };
        }
      }
      if (!mov.cuenta?.cuenta_contable_id) {
        return {
          motivo: 'CUENTA_OLD',
          descripcion: `La cuenta de tesorería "${mov.cuenta?.nombre ?? '(sin nombre)'}" no tiene cuenta contable asignada.`,
          contexto: { cuenta_id: mov.cuenta_id },
        };
      }
      // 2) Cada categoría del detalle tiene cuenta contable.
      const detalles = await this.prisma.tes_movimiento_det.findMany({
        where: { movimiento_id: id },
        select: {
          categoria_id: true,
          cuenta_contable_id: true,
          categoria: { select: { nombre: true, cuenta_contable_id: true } },
        },
      });
      if (detalles.length > 0) {
        const categoriaIds = [...new Set(detalles.map((d) => d.categoria_id).filter(Boolean))];
        const mapeos = await this.prisma.tes_categoria_cuenta_contable.findMany({
          where: { empresa_id: empresaId, categoria_id: { in: categoriaIds } },
          select: { categoria_id: true, cuenta_contable_id: true },
        });
        const mapeoByCat = new Map(mapeos.map((m) => [m.categoria_id, m.cuenta_contable_id]));

        for (const det of detalles) {
          const ccat =
            det.cuenta_contable_id ??
            mapeoByCat.get(det.categoria_id) ??
            det.categoria?.cuenta_contable_id ??
            null;
          if (!ccat) {
            const nombreCat = det.categoria?.nombre ?? '(sin nombre)';
            return {
              motivo: 'CATEGORIA_SIN_CUENTA',
              descripcion: `La categoría "${nombreCat}" del movimiento no tiene cuenta contable asignada. Sin cuenta el sistema no puede armar el asiento.`,
              contexto: { categoria_id: det.categoria_id, categoria_nombre: nombreCat },
            };
          }
        }
      }
    }
    return null;
  }

  /**
   * Diagnóstico en lote de una lista de registros del mismo origen.
   * Devuelve un array paralelo con motivos.
   */
  async diagnosticarLote(
    empresaId: string,
    origenTipo: string,
    ids: string[],
  ): Promise<Array<DiagnosticoResultado & { id: string }>> {
    if (!ids.length) return [];
    // Optimización: primero chequeamos condiciones globales una sola vez.
    if (!(await this.tieneModuloContabilidad(empresaId))) {
      return ids.map((id) => ({
        id,
        motivo: 'MODULO_INACTIVO' as MotivoConciliacion,
        descripcion: 'El módulo Contabilidad no está activo.',
        contexto: {},
      }));
    }
    const entry = await this.prisma.cont_origen_registry.findUnique({
      where: { origen_tipo: origenTipo },
    });
    if (!entry) {
      return ids.map((id) => ({
        id,
        motivo: 'SIN_MOTIVO' as MotivoConciliacion,
        descripcion: `Origen "${origenTipo}" no registrado.`,
        contexto: {},
      }));
    }

    // Precomputar concepto->cuenta y validez.
    const conceptos = Array.isArray(entry.conceptos_req) ? (entry.conceptos_req as string[]) : [];
    const cache = new Map<string, { cuentaId: string | null; codigo: string | null; active: boolean }>();
    let mapeoFaltante: string | null = null;
    let cuentaOld: { concepto: string; codigo: string | null } | null = null;
    for (const c of conceptos) {
      try {
        const cuentaId = await this.mapeo.getCuentaPorConcepto(empresaId, c);
        const cuenta = await this.prisma.cont_plan_cuentas.findUnique({
          where: { id: cuentaId },
          select: { codigo: true, active: true },
        });
        cache.set(c, {
          cuentaId,
          codigo: cuenta?.codigo ?? null,
          active: !!cuenta?.active,
        });
        if (!cuenta || !cuenta.active) {
          if (!cuentaOld) cuentaOld = { concepto: c, codigo: cuenta?.codigo ?? null };
        } else if (cuenta.codigo?.startsWith('OLD-')) {
          if (!cuentaOld) cuentaOld = { concepto: c, codigo: cuenta.codigo };
        }
      } catch (err) {
        // getCuentaPorConcepto también rechaza el mapeo a una cuenta desactivada
        // u OLD-: eso es CUENTA_OLD (reasignar), no SIN_MAPEO (crear).
        if (!(err instanceof NotFoundException)) {
          if (!cuentaOld) {
            const mapeo = await this.prisma.cont_mapeo_cuentas.findFirst({
              where: { empresa_id: empresaId, concepto: c },
              select: { cuenta: { select: { codigo: true } } },
            });
            cuentaOld = { concepto: c, codigo: mapeo?.cuenta?.codigo ?? null };
          }
          continue;
        }
        if (!mapeoFaltante) mapeoFaltante = c;
      }
    }
    if (mapeoFaltante) {
      return ids.map((id) => ({
        id,
        motivo: 'SIN_MAPEO' as MotivoConciliacion,
        descripcion: `Falta mapeo del concepto "${mapeoFaltante}".`,
        contexto: { concepto_faltante: mapeoFaltante },
      }));
    }
    if (cuentaOld) {
      return ids.map((id) => ({
        id,
        motivo: 'CUENTA_OLD' as MotivoConciliacion,
        descripcion: `El concepto "${cuentaOld!.concepto}" está mapeado a la cuenta ${cuentaOld!.codigo ?? '(?)'}, que está desactivada. Reasignalo a una cuenta vigente en Contabilidad → Mapeo de Cuentas.`,
        contexto: cuentaOld,
      }));
    }

    // Chequeo por registro: solo la fecha del período. Traemos las fechas en un solo query.
    const model = (this.prisma as unknown as Record<string, {
      findMany: (args: unknown) => Promise<Record<string, unknown>[]>;
    }>)[entry.tabla_origen];
    if (!model) {
      return ids.map((id) => ({
        id,
        motivo: 'SIN_MOTIVO' as MotivoConciliacion,
        descripcion: 'Tabla no accesible.',
        contexto: {},
      }));
    }
    const rows = await model.findMany({
      where: { id: { in: ids }, empresa_id: empresaId },
      select: { id: true, [entry.campo_fecha]: true },
    });
    const fechasById = new Map<string, Date>(
      rows.map((r) => [String(r.id), (r[entry.campo_fecha] as Date) ?? new Date()]),
    );

    const resultados: Array<DiagnosticoResultado & { id: string }> = [];
    for (const id of ids) {
      const fecha = fechasById.get(id) ?? new Date();
      try {
        await this.periodos.resolverPeriodoPorFecha(empresaId, fecha);
        // Mismos chequeos por registro que el diagnóstico individual (estado no
        // contabilizable, movimiento de otro documento, cotización faltante,
        // cuenta/categoría sin cuenta contable). Sin esto el lote —que es lo que
        // ve la pantalla— decía "listo para regenerar" a casos que iban a fallar.
        const especifico = await this.diagnosticoEspecifico(empresaId, origenTipo, id);
        if (especifico) {
          resultados.push({ id, ...especifico });
          continue;
        }
        resultados.push({
          id,
          motivo: 'SIN_MOTIVO',
          descripcion:
            'El registro cumple los checks pero no tiene asiento. Se puede regenerar directamente.',
          contexto: {},
        });
      } catch {
        resultados.push({
          id,
          motivo: 'PERIODO_CERRADO',
          descripcion: `El período contable para la fecha ${fecha
            .toISOString()
            .substring(0, 10)} está cerrado o no existe.`,
          contexto: { fecha: fecha.toISOString().substring(0, 10) },
        });
      }
    }
    return resultados;
  }

  // ── Fase 3: Regeneración de asientos en lote ─────────────────────────────

  /**
   * Regenera el asiento de un único registro pendiente reusando el service
   * de integración declarado en el registry.
   */
  async regenerarUno(
    empresaId: string,
    origenTipo: string,
    id: string,
    userId?: string,
  ): Promise<RegenerarResultadoItem> {
    const entry = await this.prisma.cont_origen_registry.findUnique({
      where: { origen_tipo: origenTipo },
    });
    if (!entry || !entry.activo) {
      return {
        id,
        ok: false,
        motivo: 'SIN_MOTIVO',
        error: `Origen "${origenTipo}" no está registrado o está inactivo`,
      };
    }

    // Resolver el service desde el mapa de integradores inyectados en el constructor.
    // Nest resuelve providers por token de clase, no por string, así que no se puede
    // usar moduleRef.get(entry.service_name) — el DI ya se hizo en el constructor.
    const service = this.integradores[entry.service_name];
    if (!service) {
      this.logger.warn(
        `Service ${entry.service_name} no está registrado en integradores[] del ConciliacionService.`,
      );
      return {
        id,
        ok: false,
        motivo: 'SIN_MOTIVO',
        error: `Service ${entry.service_name} no está registrado en el ConciliacionService — agregar la inyección en el constructor.`,
      };
    }

    const method = service[entry.service_method];
    if (typeof method !== 'function') {
      return {
        id,
        ok: false,
        motivo: 'SIN_MOTIVO',
        error: `Método ${entry.service_method} no encontrado en ${entry.service_name}`,
      };
    }

    try {
      if (entry.arg_style === 'id_empresa') {
        await method.call(service, id, empresaId);
      } else {
        await method.call(service, id);
      }
      // Verificar que efectivamente se creó el documento contable.
      // Buscamos cualquier documento (todos los estados) para reportar mejor.
      const doc = await this.prisma.cont_documentos.findFirst({
        where: { empresa_id: empresaId, origen_tipo: entry.origen_tipo, origen_id: id },
      });
      // Si existe pero NO está confirmado, el integrador lo detecta como "ya tiene"
      // y no crea nada — pero el registro sigue pendiente. Reportar el caso.
      if (doc && doc.estado !== 'CONFIRMADO') {
        await this.audit.log({
          empresa_id: empresaId,
          user_id: userId,
          action: 'CONT_CONC_REGENERAR_FALLA',
          entity_type: entry.tabla_origen,
          entity_id: id,
          descripcion: `Documento contable existente en estado ${doc.estado} — bloquea la regeneración`,
          new_value: { origen_tipo: origenTipo, cont_documento_id: doc.id, estado: doc.estado },
        });
        return {
          id,
          ok: false,
          motivo: 'DATOS_FALTANTES',
          error: `Ya existe un documento contable para este registro en estado ${doc.estado}. El integrador lo detecta y no genera uno nuevo. Necesitás anular el documento existente (${doc.numero_documento ?? doc.id.substring(0, 8)}) desde Contabilidad → Asientos, y luego reintentar.`,
        };
      }
      if (!doc) {
        // El integrador retornó null (idempotencia) o falló silenciosamente.
        // Diagnosticar para dar motivo.
        const diag = await this.diagnosticarRegistro(empresaId, origenTipo, id);
        await this.audit.log({
          empresa_id: empresaId,
          user_id: userId,
          action: 'CONT_CONC_REGENERAR_FALLA',
          entity_type: entry.tabla_origen,
          entity_id: id,
          descripcion: `Regeneración no produjo documento — motivo: ${diag.motivo}`,
          new_value: { origen_tipo: origenTipo, motivo: diag.motivo, ...diag.contexto },
        });
        return {
          id,
          ok: false,
          motivo: diag.motivo,
          error: diag.descripcion,
        };
      }
      await this.audit.log({
        empresa_id: empresaId,
        user_id: userId,
        action: 'CONT_CONC_REGENERAR_OK',
        entity_type: entry.tabla_origen,
        entity_id: id,
        descripcion: `Asiento regenerado (doc ${doc.id.substring(0, 8)})`,
        new_value: { origen_tipo: origenTipo, cont_documento_id: doc.id },
      });
      return { id, ok: true, cont_documento_id: doc.id };
    } catch (err) {
      const msg = err instanceof Error ? err.message : String(err);
      this.logger.warn(
        `Regeneración falló para ${origenTipo}/${id}: ${msg}`,
      );
      await this.audit.log({
        empresa_id: empresaId,
        user_id: userId,
        action: 'CONT_CONC_REGENERAR_ERROR',
        entity_type: entry.tabla_origen,
        entity_id: id,
        descripcion: `Excepción en integrador: ${msg}`,
        new_value: { origen_tipo: origenTipo, error: msg },
      });
      return { id, ok: false, motivo: 'SIN_MOTIVO', error: msg };
    }
  }

  /**
   * Regenera asientos de TODOS los orígenes activos, en secuencia.
   * Devuelve un array de resultados por origen.
   *
   * Útil cuando el contador recién activó Contabilidad y quiere procesar
   * el histórico completo con un solo click.
   */
  async regenerarTodos(
    empresaId: string,
    filtros: { desde?: string; hasta?: string } = {},
    userId?: string,
  ): Promise<{ total_ok: number; total_fallidos: number; por_origen: RegenerarResultado[] }> {
    const entries = await this.listarRegistry();
    const resultados: RegenerarResultado[] = [];
    let totalOk = 0;
    let totalFallidos = 0;

    for (const entry of entries) {
      // Solo procesar si tiene pendientes en el rango.
      const resumenTipo = await this.resumen(empresaId, {
        ...filtros,
        origen_tipos: [entry.origen_tipo],
      });
      if (!resumenTipo.length || resumenTipo[0].pendientes === 0) continue;

      try {
        const r = await this.regenerarLote(
          empresaId,
          {
            origen_tipo: entry.origen_tipo,
            desde: filtros.desde,
            hasta: filtros.hasta,
          },
          userId,
        );
        resultados.push(r);
        totalOk += r.ok;
        totalFallidos += r.fallidos;
      } catch (err) {
        const msg = err instanceof Error ? err.message : String(err);
        this.logger.warn(`regenerarTodos: origen ${entry.origen_tipo} falló: ${msg}`);
        resultados.push({
          origen_tipo: entry.origen_tipo,
          total: 0,
          ok: 0,
          fallidos: 0,
          por_motivo: { SIN_MOTIVO: 1 },
          items: [],
        });
      }
    }

    await this.audit.log({
      empresa_id: empresaId,
      user_id: userId,
      action: 'CONT_CONC_REGENERAR_TODOS',
      entity_type: 'cont_origen_registry',
      entity_id: 'ALL',
      descripcion: `Regeneración global: ${totalOk} exitosos, ${totalFallidos} fallidos`,
      new_value: { desde: filtros.desde, hasta: filtros.hasta, por_origen: resultados.map((r) => ({ origen: r.origen_tipo, ok: r.ok, fallidos: r.fallidos })) },
    });

    return { total_ok: totalOk, total_fallidos: totalFallidos, por_origen: resultados };
  }

  /**
   * Regenera asientos en lote. Cada llamada al integrador va en su propio
   * try/catch — un error no aborta el resto.
   *
   * `opts` puede pasar:
   *  - `ids[]` explícitos (regenerar solo esos)
   *  - `origen_tipo` + rango (todos los pendientes de ese origen en el rango)
   *  - solo `origen_tipo` (todos los pendientes de ese origen sin filtro)
   *
   * El límite duro es 500 por batch para evitar timeouts.
   */
  async regenerarLote(
    empresaId: string,
    opts: {
      origen_tipo: string;
      ids?: string[];
      desde?: string;
      hasta?: string;
    },
    userId?: string,
  ): Promise<RegenerarResultado> {
    let idsAProcesar: string[] = opts.ids ?? [];
    if (!idsAProcesar.length) {
      // Traer todos los pendientes del origen en el rango.
      const detalle = await this.detalle(empresaId, opts.origen_tipo, {
        desde: opts.desde,
        hasta: opts.hasta,
        limit: 500,
      });
      idsAProcesar = detalle.items.map((it) => it.id);
    }
    idsAProcesar = idsAProcesar.slice(0, 500);

    const results: RegenerarResultadoItem[] = [];
    // Serial en lugar de allSettled para no golpear la BD con conexiones masivas.
    // Un batch de 500 es aceptable secuencial (~30-60s peor caso).
    for (const id of idsAProcesar) {
      const r = await this.regenerarUno(empresaId, opts.origen_tipo, id, userId);
      results.push(r);
    }

    const okCount = results.filter((r) => r.ok).length;
    const errCount = results.length - okCount;
    const porMotivo: Record<string, number> = {};
    for (const r of results) {
      if (!r.ok && r.motivo) {
        porMotivo[r.motivo] = (porMotivo[r.motivo] ?? 0) + 1;
      }
    }

    return {
      origen_tipo: opts.origen_tipo,
      total: results.length,
      ok: okCount,
      fallidos: errCount,
      por_motivo: porMotivo,
      items: results,
    };
  }

  // ── Fase 5: PDF del reporte ─────────────────────────────────────────────

  async generarPDFReporte(
    empresaId: string,
    filtros: FiltrosConciliacion = {},
  ): Promise<{ pdfBuffer: Buffer; filename: string }> {
    const resumen = await this.resumen(empresaId, filtros);
    const empresa = await this.prisma.empresas.findUnique({
      where: { id: empresaId },
      select: { razon_social: true, nombre_fantasia: true, ruc: true, dv: true, celular: true, email: true, logo: true },
    });

    const payload = {
      tipo: 'base64',
      empresa: {
        razon_social: empresa?.razon_social,
        ruc: empresa?.ruc,
        dv: empresa?.dv,
        telefono: empresa?.celular,
        email: empresa?.email,
        logo_url: empresa?.logo,
        // Nombre comercial arriba y razón social debajo en el PDF (criterio de la factura).
        nombre_fantasia: empresa?.nombre_fantasia ?? null,
      },
      desde: filtros.desde,
      hasta: filtros.hasta,
      items: resumen,
    };

    const url = `${envs.apiGeneradorPDF}/api/contabilidad/conciliacion/generate-pdf`;
    try {
      const response = await fetch(url, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(payload),
      });
      if (!response.ok) {
        const errorBody = await response.text();
        throw new BadRequestException(`Error al generar PDF: ${errorBody}`);
      }
      const json = (await response.json()) as { status: string; pdf_base64?: string; message?: string };
      if (json.status !== 'success' || !json.pdf_base64) {
        throw new BadRequestException(json.message || 'PDF no generado');
      }
      return {
        pdfBuffer: Buffer.from(json.pdf_base64, 'base64'),
        filename: `conciliacion-contable.pdf`,
      };
    } catch (error) {
      if (error instanceof BadRequestException) throw error;
      const message = error instanceof Error ? error.message : 'Error desconocido';
      throw new BadRequestException(`No se pudo generar el PDF: ${message}`);
    }
  }

  /**
   * Catálogo de motivos posibles — para tooltip/documentación en la UI.
   */
  listarMotivos(): Array<{ codigo: MotivoConciliacion; descripcion: string; resolucion: string }> {
    return [
      {
        codigo: 'MODULO_INACTIVO',
        descripcion: 'La empresa no tenía módulo Contabilidad activo cuando se creó el registro.',
        resolucion: 'Al activar Contabilidad + regenerar, se contabiliza.',
      },
      {
        codigo: 'SIN_MAPEO',
        descripcion: 'Falta el mapeo de un concepto contable requerido.',
        resolucion: 'Ir a Contabilidad → Mapeo de Cuentas y asignar el concepto faltante.',
      },
      {
        codigo: 'PERIODO_CERRADO',
        descripcion: 'La fecha del registro cae en un período contable cerrado.',
        resolucion:
          'Reabrir el período (Contabilidad → Ejercicios) o asentar como regularización manual en el período abierto.',
      },
      {
        codigo: 'CUENTA_OLD',
        descripcion: 'El mapeo apunta a una cuenta con código OLD- o inactiva.',
        resolucion: 'Reasignar el concepto a la cuenta vigente en el Mapeo de Cuentas.',
      },
      {
        codigo: 'SIN_TIPO_CAMBIO',
        descripcion: 'El registro está en moneda extranjera y no hay cotización cargada para su fecha.',
        resolucion: 'Cargar la cotización del día (o una anterior) en Contabilidad → Tipo de Cambio y volver a regenerar.',
      },
      {
        codigo: 'NO_CONTABILIZABLE',
        descripcion:
          'El registro no genera asiento por sí mismo: está anulado/rechazado, o es un movimiento de tesorería que ya se asienta con su documento origen.',
        resolucion: 'No requiere acción. Si aparece, es un dato que la detección debería excluir.',
      },
      {
        codigo: 'CATEGORIA_SIN_CUENTA',
        descripcion:
          'Movimiento de tesorería: la categoría / tipo de movimiento asociado no tiene cuenta contable asignada.',
        resolucion:
          'Ir a Tesorería → Categorías de Movimiento y asignar una cuenta contable a la categoría mencionada.',
      },
      {
        codigo: 'DATOS_FALTANTES',
        descripcion: 'Falta un dato requerido en el registro (proveedor, moneda, etc.).',
        resolucion: 'Corregir el registro origen desde su pantalla y volver a intentar.',
      },
      {
        codigo: 'SIN_MOTIVO',
        descripcion:
          'El registro cumple todos los checks pero no se generó (probablemente creado antes de que el hook contable estuviera activo).',
        resolucion: 'Regenerar directamente. Si falla, revisar AuditService para ver el error del integrador.',
      },
    ];
  }
}

export type MotivoConciliacion =
  | 'MODULO_INACTIVO'
  | 'SIN_MAPEO'
  | 'PERIODO_CERRADO'
  | 'CUENTA_OLD'
  | 'CATEGORIA_SIN_CUENTA'
  | 'DATOS_FALTANTES'
  | 'NO_CONTABILIZABLE'
  | 'SIN_TIPO_CAMBIO'
  | 'SIN_MOTIVO';

export interface DiagnosticoResultado {
  motivo: MotivoConciliacion;
  descripcion: string;
  contexto: Record<string, unknown>;
}

export interface RegenerarResultadoItem {
  id: string;
  ok: boolean;
  cont_documento_id?: string;
  motivo?: MotivoConciliacion;
  error?: string;
}

export interface RegenerarResultado {
  origen_tipo: string;
  total: number;
  ok: number;
  fallidos: number;
  por_motivo: Record<string, number>;
  items: RegenerarResultadoItem[];
}
