import { Injectable, NotFoundException, BadRequestException, Logger } from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
import { TesExtractoEstado, TesMovimientoEstado, TesMovimientoTipo } from '../tes.enums';
import { AiProviderService } from '../../ai-dashboard/ai-provider/ai-provider.service';
import { extraerMensajeError, mensajeAmigableIA } from '../../ai-dashboard/shared/ai-error.util';
import { ContabilidadIntegracionService } from '../../contabilidad/services/integracion.service';
import { emparejarLineasConMovimientos } from './conciliacion-matching';
// eslint-disable-next-line @typescript-eslint/no-require-imports
const pdfParse = require('pdf-parse/lib/pdf-parse');

interface TransaccionIA {
  fecha: string;
  concepto: string;
  referencia?: string;
  importe: number;
  tipo: 'DEBITO' | 'CREDITO';
}

interface RespuestaIA {
  banco?: string;
  periodo?: { desde?: string; hasta?: string };
  saldo_inicial?: number;
  saldo_final?: number;
  transacciones: TransaccionIA[];
}

type ClasificacionLineaConciliacion = 'COMISION_BANCARIA' | 'ITF' | 'DIFERENCIA' | 'OTRO';

type AutoConciliarOptions = {
  tolerancia_dias?: number;
  tolerancia_monto?: number;
  diferencia_maxima?: number;
  auto_crear_comisiones?: boolean;
  auto_crear_itf?: boolean;
  auto_ajustar_diferencias?: boolean;
  marcar_sin_correspondencia?: boolean;
};

@Injectable()
export class TesConciliacionService {
  private readonly logger = new Logger(TesConciliacionService.name);

  constructor(
    private readonly prisma: PrismaService,
    private readonly aiProvider: AiProviderService,
    private readonly contabilidad: ContabilidadIntegracionService,
  ) {}

  // ── Listar extractos de una cuenta ─────────────────────────────────────────

  async getExtractos(empresaId: string, cuentaId?: string) {
    const extractos = await this.prisma.tes_extracto_bancario.findMany({
      where: { empresa_id: empresaId, ...(cuentaId ? { cuenta_id: cuentaId } : {}) },
      include: { cuenta: { select: { nombre: true, moneda: true } } },
      orderBy: { created_at: 'desc' },
    });
    return extractos.map(e => ({
      ...e,
      saldo_inicial: Number(e.saldo_inicial),
      saldo_final: Number(e.saldo_final),
    }));
  }

  // ── Importar extracto CSV ───────────────────────────────────────────────────
  // Formato esperado: fecha;descripcion;debito;credito;referencia (primera fila = cabecera)
  // Montos separados: debito=salida de dinero, credito=entrada de dinero

  async importarExtracto(
    empresaId: string,
    data: {
      cuenta_id: string;
      periodo_desde?: string;
      periodo_hasta?: string;
      saldo_inicial: number;
      saldo_final: number;
      archivo_nombre?: string;
      csv_content: string;
    },
    userId?: string,
  ) {
    const cuenta = await this.prisma.tes_cuentas.findFirst({
      where: { id: data.cuenta_id, empresa_id: empresaId },
    });
    if (!cuenta) throw new NotFoundException('Cuenta no encontrada');

    const lineas = this.parseCsv(data.csv_content);
    if (!lineas.length) throw new BadRequestException('El CSV no contiene líneas válidas');

    // Período: si no vino del form, inferirlo de las fechas de las líneas del propio CSV
    // (igual criterio que la importación por PDF con IA).
    const periodoDesde = data.periodo_desde || this.inferirFechaMinima(lineas);
    const periodoHasta = data.periodo_hasta || this.inferirFechaMaxima(lineas);

    const extracto = await this.prisma.tes_extracto_bancario.create({
      data: {
        empresa_id: empresaId,
        cuenta_id: data.cuenta_id,
        periodo_desde: new Date(periodoDesde + 'T00:00:00.000Z'),
        periodo_hasta: new Date(periodoHasta + 'T00:00:00.000Z'),
        saldo_inicial: data.saldo_inicial,
        saldo_final: data.saldo_final,
        archivo_nombre: data.archivo_nombre,
        estado: TesExtractoEstado.PENDIENTE,
        created_by: userId,
      },
    });

    // Crear líneas
    await this.prisma.tes_extracto_lineas.createMany({
      data: lineas.map(l => ({
        extracto_id: extracto.id,
        fecha: new Date(l.fecha + 'T00:00:00.000Z'),
        descripcion: l.descripcion,
        referencia: l.referencia,
        debito: l.debito,
        credito: l.credito,
        saldo: l.saldo,
        conciliado: false,
      })),
    });

    // Auto-match: intentar cruzar cada línea con movimientos del sistema
    await this.autoMatch(empresaId, data.cuenta_id, extracto.id);

    return this.getExtractoDetalle(empresaId, extracto.id);
  }

  // ── Detalle de extracto con sus líneas ─────────────────────────────────────

  async getExtractoDetalle(empresaId: string, extractoId: string) {
    const extracto = await this.prisma.tes_extracto_bancario.findFirst({
      where: { id: extractoId, empresa_id: empresaId },
      include: {
        cuenta: { select: { nombre: true, moneda: true } },
        lineas: {
          include: { movimiento: { select: { id: true, fecha: true, descripcion: true, monto: true, tipo: true } } },
          orderBy: { fecha: 'asc' },
        },
      },
    });
    if (!extracto) throw new NotFoundException('Extracto no encontrado');

    const total = extracto.lineas.length;
    const conciliadas = extracto.lineas.filter(l => l.conciliado).length;
    const sinCorrespondencia = extracto.lineas.filter(l => l.sin_correspondencia && !l.conciliado).length;

    return {
      ...extracto,
      saldo_inicial: Number(extracto.saldo_inicial),
      saldo_final: Number(extracto.saldo_final),
      stats: { total, conciliadas, sin_correspondencia: sinCorrespondencia, pendientes: total - conciliadas - sinCorrespondencia },
      lineas: extracto.lineas.map(l => ({
        ...l,
        debito: Number(l.debito),
        credito: Number(l.credito),
        saldo: l.saldo !== null ? Number(l.saldo) : null,
        movimiento: l.movimiento ? { ...l.movimiento, monto: Number(l.movimiento.monto) } : null,
      })),
    };
  }

  // ── Auto-conciliación bancaria completa ────────────────────────────────────

  async autoConciliarExtracto(
    empresaId: string,
    extractoId: string,
    options?: AutoConciliarOptions,
    userId?: string,
  ) {
    const extracto = await this.prisma.tes_extracto_bancario.findFirst({
      where: { id: extractoId, empresa_id: empresaId },
      select: {
        id: true,
        empresa_id: true,
        cuenta_id: true,
        periodo_desde: true,
        periodo_hasta: true,
        cuenta: { select: { moneda: true } },
      },
    });
    if (!extracto) throw new NotFoundException('Extracto no encontrado');

    const monedaCuenta = extracto.cuenta?.moneda ?? 'PYG';
    const defaults = this.getToleranciasPorMoneda(monedaCuenta);
    const config = {
      tolerancia_dias: Math.max(0, Number(options?.tolerancia_dias ?? 2)),
      tolerancia_monto: Math.max(0, Number(options?.tolerancia_monto ?? defaults.tolerancia_monto)),
      diferencia_maxima: Math.max(0, Number(options?.diferencia_maxima ?? defaults.diferencia_maxima)),
      auto_crear_comisiones: options?.auto_crear_comisiones !== false,
      auto_crear_itf: options?.auto_crear_itf !== false,
      auto_ajustar_diferencias: options?.auto_ajustar_diferencias !== false,
      marcar_sin_correspondencia: options?.marcar_sin_correspondencia !== false,
    };

    const lineasPendientes = await this.prisma.tes_extracto_lineas.findMany({
      where: {
        extracto_id: extractoId,
        conciliado: false,
        sin_correspondencia: false,
      },
      orderBy: [{ fecha: 'asc' }, { created_at: 'asc' }],
    });

    if (!lineasPendientes.length) {
      return {
        ok: true,
        resumen: {
          total_procesadas: 0,
          conciliadas_exactas: 0,
          conciliadas_con_ajuste: 0,
          auto_creadas_comision: 0,
          auto_creadas_itf: 0,
          sin_correspondencia: 0,
          pendientes: 0,
        },
        detalle_extracto: await this.getExtractoDetalle(empresaId, extractoId),
      };
    }

    const desde = new Date((extracto.periodo_desde as Date).getTime() - (config.tolerancia_dias + 3) * 86_400_000);
    const hasta = new Date((extracto.periodo_hasta as Date).getTime() + (config.tolerancia_dias + 4) * 86_400_000);

    const [movimientos, conciliados] = await Promise.all([
      this.prisma.tes_movimientos.findMany({
        where: {
          empresa_id: empresaId,
          cuenta_id: extracto.cuenta_id,
          estado: TesMovimientoEstado.CONFIRMADO,
          fecha: { gte: desde, lt: hasta },
        },
        orderBy: [{ fecha: 'asc' }, { created_at: 'asc' }],
      }),
      this.prisma.tes_extracto_lineas.findMany({
        where: {
          conciliado: true,
          movimiento_id: { not: null },
          extracto: { empresa_id: empresaId, cuenta_id: extracto.cuenta_id },
        },
        select: { movimiento_id: true },
      }),
    ]);

    const usedMovIds = new Set(
      conciliados
        .map((item) => item.movimiento_id)
        .filter((id): id is string => Boolean(id)),
    );

    // Pasada previa por comprobante/referencia sobre TODAS las líneas: resuelve
    // los casos con varios movimientos del mismo importe (donde emparejar de a
    // una, por cercanía de fecha, cruza las líneas entre sí).
    const emparejadas = emparejarLineasConMovimientos(
      lineasPendientes.map((l) => ({
        id: l.id,
        fecha: l.fecha,
        monto: this.getMontoLinea(l),
        tipo: Number(l.debito) > 0 ? 'EGRESO' : 'INGRESO',
        referencia: l.referencia,
        descripcion: l.descripcion,
      })),
      movimientos
        .filter((m) => !usedMovIds.has(m.id))
        .map((m) => ({
          id: m.id,
          fecha: m.fecha,
          monto: Number(m.monto),
          tipo: String(m.tipo) as 'INGRESO' | 'EGRESO',
          referencia: m.referencia,
          descripcion: m.descripcion,
        })),
      { toleranciaDias: config.tolerancia_dias, toleranciaMonto: config.tolerancia_monto },
    );
    const parPorReferencia = new Map(
      emparejadas.pares.filter((p) => p.criterio === 'REFERENCIA').map((p) => [p.lineaId, p.movimientoId]),
    );

    const resumen = {
      total_procesadas: lineasPendientes.length,
      conciliadas_exactas: 0,
      conciliadas_con_ajuste: 0,
      auto_creadas_comision: 0,
      auto_creadas_itf: 0,
      sin_correspondencia: 0,
      pendientes: 0,
      diferencias: [] as Array<{
        linea_id: string;
        movimiento_id: string;
        monto_linea: number;
        monto_movimiento: number;
        diferencia: number;
      }>,
    };

    for (const linea of lineasPendientes) {
      const montoLinea = this.getMontoLinea(linea);
      const tipoEsperado = Number(linea.debito) > 0 ? TesMovimientoTipo.EGRESO : TesMovimientoTipo.INGRESO;
      const clasificacion = this.clasificarLineaConciliacion(linea.descripcion, linea.referencia);

      if (montoLinea <= 0) {
        if (config.marcar_sin_correspondencia) {
          await this.prisma.tes_extracto_lineas.update({
            where: { id: linea.id },
            data: { sin_correspondencia: true },
          });
          resumen.sin_correspondencia += 1;
        } else {
          resumen.pendientes += 1;
        }
        continue;
      }

      // El comprobante del banco manda sobre la cercanía de fechas.
      const porReferencia = parPorReferencia.get(linea.id);
      const exacto = porReferencia
        ? (movimientos.find((m) => m.id === porReferencia && !usedMovIds.has(m.id)) ?? null)
        : this.findBestMovimiento({
            linea,
            movimientos,
            usedMovIds,
            tipoEsperado,
            toleranciaDias: config.tolerancia_dias,
            minDiffMonto: 0,
            maxDiffMonto: config.tolerancia_monto,
          });

      if (exacto) {
        usedMovIds.add(exacto.id);
        await this.prisma.tes_extracto_lineas.update({
          where: { id: linea.id },
          data: { conciliado: true, movimiento_id: exacto.id, sin_correspondencia: false },
        });
        resumen.conciliadas_exactas += 1;
        continue;
      }

      if (config.auto_ajustar_diferencias) {
        const candidatoDiff = this.findBestMovimiento({
          linea,
          movimientos,
          usedMovIds,
          tipoEsperado,
          toleranciaDias: config.tolerancia_dias,
          minDiffMonto: config.tolerancia_monto + 0.000001,
          maxDiffMonto: config.diferencia_maxima,
          priorizarReferenciaDescripcion: true,
        });

        if (candidatoDiff) {
          const montoMovimiento = Number(candidatoDiff.monto);
          const diferencia = this.round(montoLinea - montoMovimiento, 2);
          if (Math.abs(diferencia) > config.tolerancia_monto) {
            const tipoAjuste = this.getTipoAjustePorDiferencia(tipoEsperado, diferencia);
            await this.crearMovimientoAjusteConciliacion({
              empresaId,
              cuentaId: extracto.cuenta_id,
              linea,
              tipo: tipoAjuste,
              monto: Math.abs(diferencia),
              clasificacion,
              userId,
            });

            usedMovIds.add(candidatoDiff.id);
            await this.prisma.tes_extracto_lineas.update({
              where: { id: linea.id },
              data: { conciliado: true, movimiento_id: candidatoDiff.id, sin_correspondencia: false },
            });

            resumen.conciliadas_con_ajuste += 1;
            resumen.diferencias.push({
              linea_id: linea.id,
              movimiento_id: candidatoDiff.id,
              monto_linea: montoLinea,
              monto_movimiento: montoMovimiento,
              diferencia,
            });
            continue;
          }
        }
      }

      const puedeAutoCrearComision =
        config.auto_crear_comisiones &&
        clasificacion === 'COMISION_BANCARIA' &&
        tipoEsperado === TesMovimientoTipo.EGRESO;
      const puedeAutoCrearItf =
        config.auto_crear_itf &&
        clasificacion === 'ITF' &&
        tipoEsperado === TesMovimientoTipo.EGRESO;

      if (puedeAutoCrearComision || puedeAutoCrearItf) {
        const movimientoId = await this.crearMovimientoDesdeLineaAuto({
          empresaId,
          cuentaId: extracto.cuenta_id,
          linea,
          tipo: tipoEsperado,
          clasificacion,
          userId,
        });

        await this.prisma.tes_extracto_lineas.update({
          where: { id: linea.id },
          data: { conciliado: true, movimiento_id: movimientoId, sin_correspondencia: false },
        });

        if (puedeAutoCrearComision) resumen.auto_creadas_comision += 1;
        if (puedeAutoCrearItf) resumen.auto_creadas_itf += 1;
        continue;
      }

      if (config.marcar_sin_correspondencia) {
        await this.prisma.tes_extracto_lineas.update({
          where: { id: linea.id },
          data: { sin_correspondencia: true },
        });
        resumen.sin_correspondencia += 1;
      } else {
        resumen.pendientes += 1;
      }
    }

    await this.actualizarEstadoExtracto(extractoId);

    return {
      ok: true,
      resumen,
      detalle_extracto: await this.getExtractoDetalle(empresaId, extractoId),
    };
  }

  // ── Movimientos del sistema no conciliados en el período ───────────────────

  async getMovimientosSinConciliar(
    empresaId: string,
    cuentaId: string,
    fechaDesde: string,
    fechaHasta: string,
  ) {
    const desde = new Date(fechaDesde + 'T00:00:00.000Z');
    const hasta = new Date(new Date(fechaHasta + 'T00:00:00.000Z').getTime() + 86_400_000);

    // IDs de movimientos ya conciliados en esta cuenta
    const conciliadosIds = await this.prisma.tes_extracto_lineas.findMany({
      where: {
        extracto: { cuenta_id: cuentaId, empresa_id: empresaId },
        conciliado: true,
        movimiento_id: { not: null },
      },
      select: { movimiento_id: true },
    });
    const excluir = conciliadosIds.map(c => c.movimiento_id as string).filter(Boolean);

    const movimientos = await this.prisma.tes_movimientos.findMany({
      where: {
        empresa_id: empresaId,
        cuenta_id: cuentaId,
        estado: TesMovimientoEstado.CONFIRMADO,
        fecha: { gte: desde, lt: hasta },
        ...(excluir.length ? { id: { notIn: excluir } } : {}),
      },
      orderBy: { fecha: 'asc' },
    });

    return movimientos.map(m => ({
      id: m.id,
      fecha: m.fecha,
      tipo: m.tipo,
      monto: Number(m.monto),
      descripcion: m.descripcion,
      referencia: m.referencia,
    }));
  }

  // ── Conciliar: vincular una línea con un movimiento ────────────────────────

  async conciliarLinea(empresaId: string, lineaId: string, movimientoId: string) {
    const linea = await this.prisma.tes_extracto_lineas.findFirst({
      where: { id: lineaId, extracto: { empresa_id: empresaId } },
      include: { extracto: { select: { cuenta_id: true } } },
    });
    if (!linea) throw new NotFoundException('Línea no encontrada');
    if (linea.conciliado) throw new BadRequestException('La línea ya está conciliada');

    const montoLinea = Number(linea.debito) > 0 ? Number(linea.debito) : Number(linea.credito);
    const tipoEsperado = Number(linea.debito) > 0 ? TesMovimientoTipo.EGRESO : TesMovimientoTipo.INGRESO;
    if (montoLinea <= 0) {
      throw new BadRequestException('La línea de extracto no tiene monto conciliable');
    }

    const movimiento = await this.prisma.tes_movimientos.findFirst({
      where: { id: movimientoId, empresa_id: empresaId, estado: TesMovimientoEstado.CONFIRMADO },
    });
    if (!movimiento) throw new NotFoundException('Movimiento no encontrado');

    if (movimiento.cuenta_id !== linea.extracto.cuenta_id) {
      throw new BadRequestException('El movimiento pertenece a una cuenta distinta al extracto');
    }
    if ((movimiento.tipo as TesMovimientoTipo) !== tipoEsperado) {
      throw new BadRequestException('El tipo de movimiento no coincide con la línea del extracto');
    }
    if (Math.abs(Number(movimiento.monto) - montoLinea) > 0.01) {
      throw new BadRequestException('El monto del movimiento no coincide con la línea del extracto');
    }

    const movimientoYaConciliado = await this.prisma.tes_extracto_lineas.findFirst({
      where: {
        movimiento_id: movimientoId,
        conciliado: true,
        id: { not: lineaId },
      },
      select: { id: true, extracto_id: true },
    });
    if (movimientoYaConciliado) {
      throw new BadRequestException('El movimiento ya está conciliado en otra línea');
    }

    await this.prisma.tes_extracto_lineas.update({
      where: { id: lineaId },
      data: { conciliado: true, movimiento_id: movimientoId, sin_correspondencia: false },
    });

    await this.actualizarEstadoExtracto(linea.extracto_id);
    return { ok: true };
  }

  // ── Desconciliar: quitar vínculo de una línea ──────────────────────────────

  async desconciliarLinea(empresaId: string, lineaId: string) {
    const linea = await this.prisma.tes_extracto_lineas.findFirst({
      where: { id: lineaId, extracto: { empresa_id: empresaId } },
    });
    if (!linea) throw new NotFoundException('Línea no encontrada');

    await this.prisma.tes_extracto_lineas.update({
      where: { id: lineaId },
      data: { conciliado: false, movimiento_id: null },
    });

    await this.actualizarEstadoExtracto(linea.extracto_id);
    return { ok: true };
  }

  // ── Eliminar extracto ───────────────────────────────────────────────────────

  async eliminarExtracto(empresaId: string, extractoId: string) {
    const extracto = await this.prisma.tes_extracto_bancario.findFirst({
      where: { id: extractoId, empresa_id: empresaId },
    });
    if (!extracto) throw new NotFoundException('Extracto no encontrado');
    await this.prisma.tes_extracto_bancario.delete({ where: { id: extractoId } });
    return { ok: true };
  }

  // ── Importar extracto PDF via IA ───────────────────────────────────────────

  async importarExtractoPdf(
    empresaId: string,
    pdfBuffer: Buffer,
    data: {
      cuenta_id: string;
      periodo_desde?: string;
      periodo_hasta?: string;
      saldo_inicial?: number;
      saldo_final?: number;
      archivo_nombre?: string;
    },
    userId?: string,
  ) {
    const cuenta = await this.prisma.tes_cuentas.findFirst({
      where: { id: data.cuenta_id, empresa_id: empresaId },
    });
    if (!cuenta) throw new NotFoundException('Cuenta no encontrada');

    // Extraer texto del PDF
    let textoCrudo: string;
    try {
      const parsed = await pdfParse(pdfBuffer);
      textoCrudo = parsed.text;
    } catch {
      throw new BadRequestException('No se pudo leer el PDF. Verificá que no esté protegido con contraseña.');
    }

    if (!textoCrudo || textoCrudo.trim().length < 50) {
      throw new BadRequestException('El PDF no contiene texto extraíble. Intentá con un CSV.');
    }

    // Llamar a IA para extraer transacciones
    const respuestaIA = await this.extraerTransaccionesConIA(empresaId, textoCrudo, data.archivo_nombre);

    if (!respuestaIA.transacciones?.length) {
      throw new BadRequestException('La IA no encontró transacciones en el PDF. Verificá el archivo o usá importación CSV.');
    }

    // Determinar período: priorizar los que trajo la IA si no vienen del frontend
    const periodoDesde = data.periodo_desde || respuestaIA.periodo?.desde || this.inferirFechaMinima(respuestaIA.transacciones);
    const periodoHasta = data.periodo_hasta || respuestaIA.periodo?.hasta || this.inferirFechaMaxima(respuestaIA.transacciones);

    const extracto = await this.prisma.tes_extracto_bancario.create({
      data: {
        empresa_id: empresaId,
        cuenta_id: data.cuenta_id,
        periodo_desde: new Date(periodoDesde + 'T00:00:00.000Z'),
        periodo_hasta: new Date(periodoHasta + 'T00:00:00.000Z'),
        saldo_inicial: data.saldo_inicial ?? this.normalizarMonto(respuestaIA.saldo_inicial) ?? 0,
        saldo_final: data.saldo_final ?? this.normalizarMonto(respuestaIA.saldo_final) ?? 0,
        archivo_nombre: data.archivo_nombre,
        estado: TesExtractoEstado.PENDIENTE,
        created_by: userId,
      },
    });

    await this.prisma.tes_extracto_lineas.createMany({
      data: respuestaIA.transacciones.map(t => ({
        extracto_id: extracto.id,
        fecha: new Date(t.fecha + 'T00:00:00.000Z'),
        descripcion: t.concepto,
        referencia: t.referencia ?? null,
        debito: t.tipo === 'DEBITO' ? t.importe : 0,
        credito: t.tipo === 'CREDITO' ? t.importe : 0,
        saldo: null,
        conciliado: false,
      })),
    });

    await this.autoMatch(empresaId, data.cuenta_id, extracto.id);

    const detalle = await this.getExtractoDetalle(empresaId, extracto.id);
    return {
      ...detalle,
      ia_meta: {
        banco: respuestaIA.banco,
        periodo_detectado: respuestaIA.periodo,
        saldo_inicial_detectado: this.normalizarMonto(respuestaIA.saldo_inicial),
        saldo_final_detectado: this.normalizarMonto(respuestaIA.saldo_final),
        total_detectadas: respuestaIA.transacciones.length,
      },
    };
  }

  private async extraerTransaccionesConIA(empresaId: string, textoCrudo: string, archivoNombre?: string): Promise<RespuestaIA> {
    const SYSTEM_PROMPT = `Sos un extractor de datos bancarios experto en extractos de bancos de Paraguay.
Tu única tarea es devolver un JSON válido con las transacciones del extracto.
No incluyas texto extra, markdown, ni explicaciones. Solo el JSON puro.`;

    const USER_PROMPT = `Extraé todas las transacciones del siguiente extracto bancario de Paraguay.

Reglas:
- Para cada transacción identificá: fecha (formato YYYY-MM-DD), concepto (descripción de la operación), referencia (número de comprobante/referencia si existe, sino omitir), importe (número positivo siempre, sin puntos ni comas de miles), tipo (DEBITO si sale dinero/cargo/debe, CREDITO si entra dinero/abono/haber)
- Ignorá filas de totales, saldos, encabezados, textos legales y publicidad
- Si una fecha tiene solo día (ej: "06"), inferí el mes/año del contexto del extracto
- Los montos en guaraníes paraguayos usan punto como separador de miles: "18.500.000" = 18500000
- Buscá el saldo inicial (saldo anterior, saldo al inicio del período) y el saldo final (saldo al cierre, saldo al fin del período) del extracto. Si no aparecen, omitir esos campos.

Devolvé exactamente este JSON:
{
  "banco": "nombre del banco detectado",
  "periodo": { "desde": "YYYY-MM-DD", "hasta": "YYYY-MM-DD" },
  "saldo_inicial": 1500000,
  "saldo_final": 2300000,
  "transacciones": [
    { "fecha": "YYYY-MM-DD", "concepto": "descripción", "referencia": "nro opcional", "importe": 400000, "tipo": "CREDITO" }
  ]
}

EXTRACTO${archivoNombre ? ` (${archivoNombre})` : ''}:
${textoCrudo.substring(0, 6000)}`;

    let respuesta: RespuestaIA = { transacciones: [] };

    this.logger.log(`PDF texto extraído (primeros 300 chars): ${textoCrudo.substring(0, 300)}`);

    try {
      const resultado = await this.aiProvider.completar(empresaId, USER_PROMPT, SYSTEM_PROMPT, {
        feature: 'TESORERIA',
      });
      this.logger.log(`Respuesta IA (primeros 500 chars): ${resultado.content.substring(0, 500)}`);

      if (!resultado.content || resultado.content.includes('[Sin API key') || resultado.content.includes('[Proveedor')) {
        throw new BadRequestException('No hay API key de IA configurada. Configurá una en Ajustes → IA o usá importación CSV.');
      }

      const jsonText = resultado.content.trim().replace(/^```json?\n?/, '').replace(/\n?```$/, '');
      respuesta = JSON.parse(jsonText);
    } catch (err) {
      if (err instanceof NotFoundException) throw new BadRequestException('No hay configuración de IA. Configurá una API key en Ajustes → IA o usá importación CSV.');
      if (err instanceof BadRequestException) throw err;
      this.logger.warn('Primer intento de IA falló, reintentando con prompt simplificado', err);
      try {
        const retry = await this.aiProvider.completar(
          empresaId,
          `Del siguiente texto de extracto bancario, extraé SOLO las transacciones como JSON array. Formato: [{"fecha":"YYYY-MM-DD","concepto":"texto","importe":1000,"tipo":"DEBITO o CREDITO"}]. Sin texto extra.\n\n${textoCrudo.substring(0, 4000)}`,
          'Devolvé solo JSON válido, sin markdown.',
          { feature: 'TESORERIA' },
        );
        this.logger.log(`Respuesta IA retry (primeros 500 chars): ${retry.content.substring(0, 500)}`);
        const jsonText = retry.content.trim().replace(/^```json?\n?/, '').replace(/\n?```$/, '');
        const parsed = JSON.parse(jsonText);
        respuesta = Array.isArray(parsed) ? { transacciones: parsed } : parsed;
      } catch (retryErr) {
        if (retryErr instanceof NotFoundException) throw new BadRequestException('No hay configuración de IA. Configurá una API key en Ajustes → IA o usá importación CSV.');
        if (retryErr instanceof BadRequestException) throw retryErr;
        // Error real del proveedor (key inválida, límite de tasa, sin saldo, timeout…): es
        // sistémico (falló en ambos intentos) → no tragarlo como "0 transacciones", porque
        // eso hace pensar al usuario que el problema es el archivo cuando no lo es.
        this.logger.error('IA no pudo extraer transacciones del PDF', retryErr);
        throw new BadRequestException(mensajeAmigableIA(extraerMensajeError(retryErr)));
      }
    }

    // Normalizar importes que puedan venir como string
    if (respuesta.transacciones) {
      respuesta.transacciones = respuesta.transacciones
        .filter(t => t.fecha && t.importe)
        .map(t => ({
          ...t,
          importe: typeof t.importe === 'string'
            ? parseFloat(String(t.importe).replace(/\./g, '').replace(',', '.')) || 0
            : Number(t.importe),
        }))
        .filter(t => t.importe > 0);
    }

    return respuesta;
  }

  private normalizarMonto(v: unknown): number | undefined {
    if (v === null || v === undefined) return undefined;
    const n = typeof v === 'string' ? parseFloat(v.replace(/\./g, '').replace(',', '.')) : Number(v);
    return isNaN(n) ? undefined : n;
  }

  private inferirFechaMinima(transacciones: { fecha: string }[]): string {
    const fechas = transacciones.map(t => t.fecha).filter(Boolean).sort();
    return fechas[0] || new Date().toISOString().substring(0, 10);
  }

  private inferirFechaMaxima(transacciones: { fecha: string }[]): string {
    const fechas = transacciones.map(t => t.fecha).filter(Boolean).sort();
    return fechas[fechas.length - 1] || new Date().toISOString().substring(0, 10);
  }

  // ── Helpers privados ────────────────────────────────────────────────────────

  private parseCsv(csv: string) {
    let rawLines = csv.split('\n').map(l => l.trim()).filter(Boolean);
    if (rawLines.length < 2) return [];

    const sep = rawLines[0].includes(';') ? ';' : ',';
    // Algunos bancos (Familiar) exportan con ";" como separador real pero agregan una
    // "," suelta al final de casi todas las líneas (encabezado y filas, de forma
    // inconsistente) — no es una columna, es ruido del export. Sacarla evita que la
    // última columna quede con basura (ej. header "Saldo ," no matchea "saldo").
    if (sep === ';') rawLines = rawLines.map(l => l.replace(/,\s*$/, ''));

    // Nombres de columna reconocidos — nombres usados por Continental, BNF, Itaú, Sudameris, genérico.
    const NOMBRES_FECHA = ['fecha', 'date', 'día hora', 'dia hora', 'dia/hora', 'fecha valor', 'fecha movimiento', 'fecha transaccion', 'fecha operacion', 'fecha operación', 'fecha proceso'];
    const NOMBRES_DESC  = ['descripcion', 'descripción', 'description', 'concepto', 'detalle', 'movimiento', 'obs', 'observacion', 'transaccion', 'transacción'];
    const NOMBRES_DEBITO  = ['debito', 'débito', 'debit', 'cargo', 'salida', 'debe', 'monto débito', 'monto debito', 'débitos', 'debitos'];
    const NOMBRES_CREDITO = ['credito', 'crédito', 'credit', 'abono', 'entrada', 'haber', 'monto crédito', 'monto credito', 'créditos', 'creditos'];
    const NOMBRES_IMPORTE = ['importe', 'monto', 'amount', 'valor'];
    const NOMBRES_REF   = ['referencia', 'reference', 'nro', 'numero', 'número', 'comprobante', 'nro. comprobante', 'nro comprobante'];
    const NOMBRES_SALDO = ['saldo', 'balance', 'saldo actual'];

    // Matchea por igualdad exacta. Nunca se relaja para todas las columnas: aliases
    // sueltos como "monto" o "saldo" colisionarían por prefijo con columnas ya resueltas
    // por su propio nombre exacto (ej. "monto débito").
    const buscarCol = (headerRow: string[], names: string[]) =>
      names.reduce<number>((found, n) => (found >= 0 ? found : headerRow.indexOf(n)), -1);

    // Solo fecha admite match por prefijo ("fecha operación", "fecha proceso"…): es la
    // única columna donde un banco real (Banco Familiar) usa variantes así, y "fecha" no
    // colisiona con ninguna otra columna esperada.
    const buscarColFecha = (headerRow: string[]) =>
      NOMBRES_FECHA.reduce<number>(
        (found, n) => (found >= 0 ? found : headerRow.findIndex(h => h === n || h.startsWith(n + ' '))),
        -1,
      );

    // Buscar fila de encabezado real: algunos bancos insertan filas de metadata antes
    // (ej. "Fecha: ;31/08/26;...") que contienen la palabra "fecha" sin ser el header de
    // columnas — por eso no alcanza con buscar la primera línea que la contenga: hay que
    // confirmar que esa fila realmente resuelve una columna de fecha real.
    let headerIdx = 0;
    for (let i = 0; i < Math.min(rawLines.length, 15); i++) {
      const candidato = rawLines[i].split(sep).map(h => h.trim().toLowerCase().replace(/"/g, ''));
      if (buscarColFecha(candidato) >= 0) {
        headerIdx = i;
        break;
      }
    }

    const header = rawLines[headerIdx].split(sep).map(h => h.trim().toLowerCase().replace(/"/g, ''));
    const iFecha = buscarColFecha(header);
    const iDesc = buscarCol(header, NOMBRES_DESC);
    const iDebito = buscarCol(header, NOMBRES_DEBITO);
    const iCredito = buscarCol(header, NOMBRES_CREDITO);
    // BNF y algunos bancos locales usan una columna única con signo +/-
    const iImporte = buscarCol(header, NOMBRES_IMPORTE);
    const iRef = buscarCol(header, NOMBRES_REF);
    const iSaldo = buscarCol(header, NOMBRES_SALDO);

    if (iFecha < 0) return [];

    // El separador de miles cambia según el banco: Continental usa "1.234.567,89"
    // y Banco Familiar "1,234,567". Asumir uno solo rompía el otro: con el
    // criterio viejo "14,694,339" se leía como 14,69. Se decide mirando cuál es
    // el último separador y cuántos dígitos deja detrás.
    const parseMonto = (v: string) => {
      if (!v) return 0;
      const s = String(v).trim().replace(/\s/g, '').replace(/[^\d.,-]/g, '');
      if (!s) return 0;
      const lastComma = s.lastIndexOf(',');
      const lastDot = s.lastIndexOf('.');
      let decSep = '';
      if (lastComma >= 0 && lastDot >= 0) {
        decSep = lastComma > lastDot ? ',' : '.';
      } else if (lastComma >= 0) {
        const parts = s.split(',');
        decSep = parts.length === 2 && parts[1].length <= 2 ? ',' : '';
      } else if (lastDot >= 0) {
        const parts = s.split('.');
        decSep = parts.length === 2 && parts[1].length <= 2 ? '.' : '';
      }
      const limpio = decSep
        ? s.split(decSep === ',' ? '.' : ',').join('').replace(decSep, '.')
        : s.replace(/[.,]/g, '');
      return Math.abs(parseFloat(limpio) || 0);
    };

    const result: Array<{ fecha: string; descripcion?: string; referencia?: string; debito: number; credito: number; saldo?: number }> = [];

    for (let i = headerIdx + 1; i < rawLines.length; i++) {
      const cols = rawLines[i].split(sep).map(c => c.trim().replace(/"/g, ''));
      if (cols.length <= 1) continue;

      // Algunos bancos incluyen "DD/MM/YYYY HH:MM:SS" — extraer solo la parte de fecha
      const rawFecha = cols[iFecha] || '';
      const fecha = this.parseDate(rawFecha.split(' ')[0].split('T')[0]);
      if (!fecha) continue;

      let debito = 0;
      let credito = 0;

      if (iImporte >= 0 && iDebito < 0 && iCredito < 0) {
        // Columna importe único con signo positivo/negativo (BNF, Itaú online, etc.)
        const raw = cols[iImporte] || '';
        const magnitud = parseMonto(raw);
        const esNegativo = /-/.test(raw) || /^\(.*\)$/.test(raw.trim());
        if (esNegativo) debito = magnitud;
        else credito = magnitud;
      } else {
        debito  = iDebito  >= 0 ? parseMonto(cols[iDebito])  : 0;
        credito = iCredito >= 0 ? parseMonto(cols[iCredito]) : 0;
      }

      if (debito === 0 && credito === 0) continue;

      result.push({
        fecha,
        descripcion: iDesc >= 0 ? cols[iDesc] : undefined,
        referencia:  iRef  >= 0 ? cols[iRef]  : undefined,
        debito,
        credito,
        saldo: iSaldo >= 0 ? parseMonto(cols[iSaldo]) : undefined,
      });
    }

    return result;
  }

  private parseDate(raw: string): string | null {
    if (!raw) return null;
    // DD/MM/YYYY, DD-MM-YYYY o DD/MM/YY (Banco Familiar y otros exportan año de 2 dígitos)
    const dmyMatch = raw.match(/^(\d{1,2})[\/\-](\d{1,2})[\/\-](\d{2}|\d{4})$/);
    if (dmyMatch) {
      const anio = dmyMatch[3].length === 2 ? `20${dmyMatch[3]}` : dmyMatch[3];
      return `${anio}-${dmyMatch[2].padStart(2, '0')}-${dmyMatch[1].padStart(2, '0')}`;
    }
    // YYYY-MM-DD
    if (/^\d{4}-\d{2}-\d{2}$/.test(raw)) return raw;
    return null;
  }

  private getMontoLinea(linea: { debito: any; credito: any }): number {
    const debito = Number(linea.debito || 0);
    const credito = Number(linea.credito || 0);
    return debito > 0 ? debito : credito;
  }

  private clasificarLineaConciliacion(
    descripcion?: string | null,
    referencia?: string | null,
  ): ClasificacionLineaConciliacion {
    const text = this.normalizeText(`${descripcion || ''} ${referencia || ''}`);

    if (
      text.includes('itf') ||
      text.includes('impuesto transacciones financieras') ||
      text.includes('imp transacc') ||
      text.includes('imp. transacc')
    ) {
      return 'ITF';
    }

    if (
      text.includes('comision') ||
      text.includes('gasto bancario') ||
      text.includes('cargo bancario') ||
      text.includes('arancel') ||
      text.includes('mantenimiento cuenta')
    ) {
      return 'COMISION_BANCARIA';
    }

    if (
      text.includes('ajuste') ||
      text.includes('diferencia') ||
      text.includes('redondeo')
    ) {
      return 'DIFERENCIA';
    }

    return 'OTRO';
  }

  private normalizeText(value: string): string {
    return String(value || '')
      .normalize('NFD')
      .replace(/[\u0300-\u036f]/g, '')
      .toLowerCase()
      .trim();
  }

  private findBestMovimiento(params: {
    linea: { fecha: Date; descripcion?: string | null; referencia?: string | null; debito: any; credito: any };
    movimientos: Array<{ id: string; fecha: Date; tipo: string; monto: any; descripcion?: string | null; referencia?: string | null }>;
    usedMovIds: Set<string>;
    tipoEsperado: TesMovimientoTipo;
    toleranciaDias: number;
    minDiffMonto: number;
    maxDiffMonto: number;
    priorizarReferenciaDescripcion?: boolean;
  }) {
    const montoLinea = this.getMontoLinea(params.linea);
    const fechaLineaMs = new Date(params.linea.fecha).getTime();
    const refLinea = this.normalizeText(params.linea.referencia || '');
    const descLinea = this.normalizeText(params.linea.descripcion || '');

    const candidatos = params.movimientos
      .filter((mov) => !params.usedMovIds.has(mov.id))
      .filter((mov) => String(mov.tipo) === params.tipoEsperado)
      .map((mov) => {
        const montoMov = Number(mov.monto || 0);
        const diffMonto = Math.abs(montoMov - montoLinea);
        const diffDias = Math.abs(new Date(mov.fecha).getTime() - fechaLineaMs) / 86_400_000;
        const refMov = this.normalizeText(mov.referencia || '');
        const descMov = this.normalizeText(mov.descripcion || '');
        const refIgual = refLinea && refMov && refLinea === refMov;
        const descCoincide =
          Boolean(descLinea && descMov) &&
          (descLinea.includes(descMov.slice(0, 12)) || descMov.includes(descLinea.slice(0, 12)));

        return {
          mov,
          diffMonto,
          diffDias,
          refIgual,
          descCoincide,
        };
      })
      .filter((item) => item.diffDias <= params.toleranciaDias)
      .filter((item) => item.diffMonto >= params.minDiffMonto && item.diffMonto <= params.maxDiffMonto);

    if (!candidatos.length) return null;

    candidatos.sort((a, b) => {
      const aScore = this.scoreCandidato(a, Boolean(params.priorizarReferenciaDescripcion));
      const bScore = this.scoreCandidato(b, Boolean(params.priorizarReferenciaDescripcion));
      if (aScore !== bScore) return aScore - bScore;
      return a.diffDias - b.diffDias;
    });

    return candidatos[0].mov;
  }

  private scoreCandidato(
    candidato: { diffMonto: number; diffDias: number; refIgual: boolean; descCoincide: boolean },
    priorizarTexto: boolean,
  ): number {
    let score = candidato.diffMonto * 1000 + candidato.diffDias * 10;
    if (candidato.refIgual) score -= priorizarTexto ? 50 : 10;
    if (candidato.descCoincide) score -= priorizarTexto ? 20 : 5;
    return score;
  }

  private getTipoAjustePorDiferencia(tipoEsperado: TesMovimientoTipo, diferencia: number): TesMovimientoTipo {
    // diferencia = monto_linea - monto_movimiento
    if (diferencia > 0) return tipoEsperado;
    return tipoEsperado === TesMovimientoTipo.EGRESO ? TesMovimientoTipo.INGRESO : TesMovimientoTipo.EGRESO;
  }

  private async crearMovimientoDesdeLineaAuto(params: {
    empresaId: string;
    cuentaId: string;
    linea: { id: string; fecha: Date; descripcion?: string | null; referencia?: string | null; debito: any; credito: any };
    tipo: TesMovimientoTipo;
    clasificacion: ClasificacionLineaConciliacion;
    userId?: string;
  }): Promise<string> {
    const monto = this.getMontoLinea(params.linea);
    const categoriaId = await this.getCategoriaConciliacion(
      params.empresaId,
      params.tipo,
      params.clasificacion,
    );

    const descripcion = params.linea.descripcion || `Movimiento auto conciliado (${params.clasificacion})`;
    const referencia = params.linea.referencia || `AUTO-CONC-${params.linea.id.slice(0, 8)}`;

    const movimiento = await this.prisma.$transaction(async (tx) => {
      const mov = await tx.tes_movimientos.create({
        data: {
          empresa_id: params.empresaId,
          cuenta_id: params.cuentaId,
          tipo: params.tipo,
          estado: TesMovimientoEstado.CONFIRMADO,
          fecha: params.linea.fecha,
          monto,
          descripcion,
          referencia,
          origen_tipo: 'CONCILIACION_AUTO',
          created_by: params.userId || null,
          confirmado_por: params.userId || null,
          confirmado_en: new Date(),
        },
      });

      await tx.tes_movimiento_det.create({
        data: {
          movimiento_id: mov.id,
          categoria_id: categoriaId,
          monto,
          descripcion,
        },
      });

      await tx.tes_cuentas.update({
        where: { id: params.cuentaId },
        data: {
          saldo_actual:
            params.tipo === TesMovimientoTipo.INGRESO
              ? { increment: monto }
              : { decrement: monto },
        },
      });

      return mov;
    });

    this.contabilidad.integrarMovimientoTesoreria(movimiento.id).catch((e) =>
      this.logger.error(`Error al contabilizar movimiento auto conciliado: ${e.message}`),
    );

    return movimiento.id;
  }

  private async crearMovimientoAjusteConciliacion(params: {
    empresaId: string;
    cuentaId: string;
    linea: { id: string; fecha: Date; descripcion?: string | null; referencia?: string | null };
    tipo: TesMovimientoTipo;
    monto: number;
    clasificacion: ClasificacionLineaConciliacion;
    userId?: string;
  }): Promise<string> {
    const categoriaId = await this.getCategoriaConciliacion(
      params.empresaId,
      params.tipo,
      params.clasificacion === 'ITF' ? 'ITF' : 'DIFERENCIA',
    );

    const descripcion = `Ajuste auto conciliación (${params.clasificacion})`;
    const referencia = `AUTO-AJUSTE-${params.linea.id.slice(0, 8)}`;

    const movimiento = await this.prisma.$transaction(async (tx) => {
      const mov = await tx.tes_movimientos.create({
        data: {
          empresa_id: params.empresaId,
          cuenta_id: params.cuentaId,
          tipo: params.tipo,
          estado: TesMovimientoEstado.CONFIRMADO,
          fecha: params.linea.fecha,
          monto: params.monto,
          descripcion,
          referencia,
          origen_tipo: 'CONCILIACION_AJUSTE',
          created_by: params.userId || null,
          confirmado_por: params.userId || null,
          confirmado_en: new Date(),
        },
      });

      await tx.tes_movimiento_det.create({
        data: {
          movimiento_id: mov.id,
          categoria_id: categoriaId,
          monto: params.monto,
          descripcion,
        },
      });

      await tx.tes_cuentas.update({
        where: { id: params.cuentaId },
        data: {
          saldo_actual:
            params.tipo === TesMovimientoTipo.INGRESO
              ? { increment: params.monto }
              : { decrement: params.monto },
        },
      });

      return mov;
    });

    this.contabilidad.integrarMovimientoTesoreria(movimiento.id).catch((e) =>
      this.logger.error(`Error al contabilizar ajuste de conciliación: ${e.message}`),
    );

    return movimiento.id;
  }

  private async getCategoriaConciliacion(
    empresaId: string,
    tipo: TesMovimientoTipo,
    clasificacion: ClasificacionLineaConciliacion,
  ): Promise<string> {
    const codigoPreferido = (() => {
      if (clasificacion === 'COMISION_BANCARIA') return 'GASTO_BANCARIO';
      if (clasificacion === 'ITF') return 'PAGO_IMPUESTO';
      if (tipo === TesMovimientoTipo.INGRESO) return 'INGRESO_OTRO';
      return 'EGRESO_OTRO';
    })();

    const categoriaSistema = await this.prisma.tes_categorias.findFirst({
      where: {
        codigo_interno: codigoPreferido,
        is_sistema: true,
        tipo,
      },
      select: { id: true },
    });
    if (categoriaSistema) return categoriaSistema.id;

    const categoriaSistemaSinTipo = await this.prisma.tes_categorias.findFirst({
      where: {
        codigo_interno: codigoPreferido,
        is_sistema: true,
      },
      select: { id: true },
    });
    if (categoriaSistemaSinTipo) return categoriaSistemaSinTipo.id;

    const nombre = (() => {
      if (clasificacion === 'COMISION_BANCARIA') return 'Gasto Bancario / Comisión';
      if (clasificacion === 'ITF') return 'Pago ITF';
      return tipo === TesMovimientoTipo.INGRESO ? 'Otro Ingreso (Conciliación)' : 'Otro Egreso (Conciliación)';
    })();

    const existenteEmpresa = await this.prisma.tes_categorias.findFirst({
      where: {
        empresa_id: empresaId,
        nombre,
        tipo,
      },
      select: { id: true },
    });
    if (existenteEmpresa) return existenteEmpresa.id;

    const creada = await this.prisma.tes_categorias.create({
      data: {
        empresa_id: empresaId,
        nombre,
        tipo,
        descripcion: 'Categoría creada automáticamente por conciliación bancaria',
        requiere_comprobante: false,
        is_sistema: false,
        activo: true,
      },
      select: { id: true },
    });
    return creada.id;
  }

  private round(value: number, decimals = 2): number {
    const factor = 10 ** decimals;
    return Math.round((value + Number.EPSILON) * factor) / factor;
  }

  private getToleranciasPorMoneda(moneda: string): {
    tolerancia_monto: number;
    diferencia_maxima: number;
  } {
    const codigo = (moneda ?? 'PYG').toUpperCase();
    if (codigo === 'PYG') {
      // PYG no tiene decimales operativos: tolerancia exacta y diferencia para comisiones/ITF típicas.
      return { tolerancia_monto: 1, diferencia_maxima: 50000 };
    }
    // Monedas con decimales (USD, EUR, BRL, ARS, etc.).
    return { tolerancia_monto: 0.01, diferencia_maxima: 10 };
  }

  private async autoMatch(empresaId: string, cuentaId: string, extractoId: string) {
    const lineas = await this.prisma.tes_extracto_lineas.findMany({ where: { extracto_id: extractoId, conciliado: false } });
    if (!lineas.length) return;

    const minFecha = lineas.reduce((m, l) => (l.fecha < m ? l.fecha : m), lineas[0].fecha);
    const maxFecha = lineas.reduce((m, l) => (l.fecha > m ? l.fecha : m), lineas[0].fecha);

    const desde = new Date(minFecha.getTime() - 3 * 86_400_000);
    const hasta = new Date(maxFecha.getTime() + 3 * 86_400_000 + 86_400_000);

    const movimientos = await this.prisma.tes_movimientos.findMany({
      where: {
        empresa_id: empresaId,
        cuenta_id: cuentaId,
        estado: TesMovimientoEstado.CONFIRMADO,
        fecha: { gte: desde, lt: hasta },
      },
    });

    // Movimientos ya conciliados con otro extracto de la cuenta: no se reusan.
    const yaConciliados = await this.prisma.tes_extracto_lineas.findMany({
      where: { conciliado: true, movimiento_id: { not: null }, extracto: { empresa_id: empresaId, cuenta_id: cuentaId } },
      select: { movimiento_id: true },
    });
    const usados = new Set(yaConciliados.map((c) => c.movimiento_id).filter((id): id is string => !!id));

    const { pares } = emparejarLineasConMovimientos(
      lineas.map((l) => ({
        id: l.id,
        fecha: l.fecha,
        monto: this.getMontoLinea(l),
        tipo: Number(l.debito) > 0 ? 'EGRESO' : 'INGRESO',
        referencia: l.referencia,
        descripcion: l.descripcion,
      })),
      movimientos
        .filter((m) => !usados.has(m.id))
        .map((m) => ({
          id: m.id,
          fecha: m.fecha,
          monto: Number(m.monto),
          tipo: String(m.tipo) as 'INGRESO' | 'EGRESO',
          referencia: m.referencia,
          descripcion: m.descripcion,
        })),
      { toleranciaDias: 2, toleranciaMonto: 1 },
    );

    for (const par of pares) {
      await this.prisma.tes_extracto_lineas.update({
        where: { id: par.lineaId },
        data: { conciliado: true, movimiento_id: par.movimientoId },
      });
    }

    await this.actualizarEstadoExtracto(extractoId);
  }

  private async actualizarEstadoExtracto(extractoId: string) {
    const lineas = await this.prisma.tes_extracto_lineas.findMany({
      where: { extracto_id: extractoId },
      select: { conciliado: true, sin_correspondencia: true },
    });
    const total = lineas.length;
    const cerradas = lineas.filter(l => l.conciliado || l.sin_correspondencia).length;
    const estado =
      cerradas === 0
        ? TesExtractoEstado.PENDIENTE
        : cerradas === total
        ? TesExtractoEstado.CONCILIADO
        : TesExtractoEstado.PARCIAL;

    await this.prisma.tes_extracto_bancario.update({ where: { id: extractoId }, data: { estado } });
  }

  // ── Marcar línea sin correspondencia ─────────────────────────────────────────

  async marcarSinCorrespondencia(empresaId: string, lineaId: string) {
    const linea = await this.prisma.tes_extracto_lineas.findFirst({
      where: { id: lineaId, extracto: { empresa_id: empresaId } },
    });
    if (!linea) throw new NotFoundException('Línea no encontrada');
    if (linea.conciliado) throw new BadRequestException('La línea ya está conciliada');

    await this.prisma.tes_extracto_lineas.update({
      where: { id: lineaId },
      data: { sin_correspondencia: true },
    });
    await this.actualizarEstadoExtracto(linea.extracto_id);
    return { ok: true };
  }

  async desmarcarSinCorrespondencia(empresaId: string, lineaId: string) {
    const linea = await this.prisma.tes_extracto_lineas.findFirst({
      where: { id: lineaId, extracto: { empresa_id: empresaId } },
    });
    if (!linea) throw new NotFoundException('Línea no encontrada');

    await this.prisma.tes_extracto_lineas.update({
      where: { id: lineaId },
      data: { sin_correspondencia: false },
    });
    await this.actualizarEstadoExtracto(linea.extracto_id);
    return { ok: true };
  }

  // ── Crear movimiento desde línea y conciliar automáticamente ─────────────────

  async crearMovimientoDesdeLinea(
    empresaId: string,
    lineaId: string,
    data: { categoria_id: string; descripcion?: string },
    userId?: string,
  ) {
    const linea = await this.prisma.tes_extracto_lineas.findFirst({
      where: { id: lineaId, extracto: { empresa_id: empresaId } },
      include: { extracto: { select: { cuenta_id: true, empresa_id: true } } },
    });
    if (!linea) throw new NotFoundException('Línea no encontrada');
    if (linea.conciliado) throw new BadRequestException('La línea ya está conciliada');

    const monto = Number(linea.debito) > 0 ? Number(linea.debito) : Number(linea.credito);
    const tipo = Number(linea.debito) > 0 ? 'EGRESO' : 'INGRESO';

    const descripcion = data.descripcion || linea.descripcion || 'Movimiento desde conciliación';

    const movimientoId = await this.prisma.$transaction(async (tx) => {
      const mov = await tx.tes_movimientos.create({
        data: {
          empresa_id: empresaId,
          cuenta_id: linea.extracto.cuenta_id,
          tipo,
          monto,
          fecha: linea.fecha,
          descripcion,
          estado: 'CONFIRMADO',
          created_by: userId,
        },
      });

      await tx.tes_movimiento_det.create({
        data: {
          movimiento_id: mov.id,
          categoria_id: data.categoria_id,
          monto,
          descripcion,
        },
      });

      await tx.tes_cuentas.update({
        where: { id: linea.extracto.cuenta_id },
        data: { saldo_actual: tipo === 'INGRESO' ? { increment: monto } : { decrement: monto } },
      });

      await tx.tes_extracto_lineas.update({
        where: { id: lineaId },
        data: { conciliado: true, movimiento_id: mov.id },
      });

      return mov.id;
    });

    await this.actualizarEstadoExtracto(linea.extracto_id);
    this.contabilidad.integrarMovimientoTesoreria(movimientoId).catch((e) =>
      this.logger.error(`Error al contabilizar movimiento desde conciliación: ${e.message}`),
    );
    return { ok: true, movimiento_id: movimientoId };
  }

  // ── Editar una línea pendiente (preview editable) ─────────────────────────────

  async actualizarLinea(
    empresaId: string,
    lineaId: string,
    data: { fecha?: string; debito?: number; credito?: number; descripcion?: string },
  ) {
    const linea = await this.prisma.tes_extracto_lineas.findFirst({
      where: { id: lineaId, extracto: { empresa_id: empresaId } },
    });
    if (!linea) throw new NotFoundException('Línea no encontrada');
    if (linea.conciliado) throw new BadRequestException('No se puede editar una línea conciliada');

    if (
      data.fecha === undefined &&
      data.descripcion === undefined &&
      data.debito === undefined &&
      data.credito === undefined
    ) {
      throw new BadRequestException('Debe enviar al menos un campo para actualizar');
    }
    if (data.debito !== undefined && data.credito !== undefined) {
      throw new BadRequestException('No se puede enviar débito y crédito al mismo tiempo');
    }
    if (data.debito !== undefined && data.debito < 0) {
      throw new BadRequestException('El débito no puede ser negativo');
    }
    if (data.credito !== undefined && data.credito < 0) {
      throw new BadRequestException('El crédito no puede ser negativo');
    }

    const update: Record<string, unknown> = {};
    if (data.fecha) update.fecha = new Date(data.fecha + 'T00:00:00.000Z');
    if (data.descripcion !== undefined) update.descripcion = data.descripcion;
    if (data.debito !== undefined) { update.debito = data.debito; update.credito = 0; }
    else if (data.credito !== undefined) { update.credito = data.credito; update.debito = 0; }

    return this.prisma.tes_extracto_lineas.update({ where: { id: lineaId }, data: update });
  }

  // ── Reporte de diferencias / partidas en tránsito ────────────────────────────

  async getDiferencias(empresaId: string, extractoId: string) {
    const extracto = await this.prisma.tes_extracto_bancario.findFirst({
      where: { id: extractoId, empresa_id: empresaId },
    });
    if (!extracto) throw new NotFoundException('Extracto no encontrado');

    const enBanco = await this.prisma.tes_extracto_lineas.findMany({
      where: { extracto_id: extractoId, conciliado: false, sin_correspondencia: false },
      orderBy: { fecha: 'asc' },
    });

    const enSistema = await this.prisma.tes_movimientos.findMany({
      where: {
        empresa_id: empresaId,
        cuenta_id: extracto.cuenta_id,
        estado: TesMovimientoEstado.CONFIRMADO,
        fecha: {
          gte: extracto.periodo_desde,
          lte: new Date((extracto.periodo_hasta as Date).getTime() + 86_400_000),
        },
        NOT: { extracto_lineas: { some: {} } },
      },
      include: { detalles: { include: { categoria: { select: { nombre: true } } } } },
      orderBy: { fecha: 'asc' },
    });

    return {
      en_banco: enBanco.map(l => ({
        ...l,
        debito: Number(l.debito),
        credito: Number(l.credito),
        saldo: l.saldo ? Number(l.saldo) : null,
        clasificacion: this.clasificarLineaConciliacion(l.descripcion, l.referencia),
      })),
      en_sistema: enSistema.map(m => ({ ...m, monto: Number(m.monto) })),
    };
  }
}
