import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from 'src/prisma/prisma.service';
import { AiProviderService } from '../ai-dashboard/ai-provider/ai-provider.service';
import { AiUsageService } from '../ai-usage/ai-usage.service';
import { ArchivoMovimientosParserService } from './lib/archivo-movimientos.parser';
import { autoMatch, sugerirPares, MovMatchable } from './lib/match-engine';
import { construirResultado, MovVista, ResultadoConciliacion } from './lib/resultado.builder';
import { generarExcelResultado } from './lib/excel-resultado';
import { analizarResultado } from './lib/hallazgos.builder';
import { CrearSesionDto } from './dto/crear-sesion.dto';
import { OrigenMov } from './lib/tipos';

const fmtDate = (d: Date | null | undefined): string => (d ? d.toISOString().slice(0, 10) : '');

@Injectable()
export class ConciliacionBancariaIaService {
  constructor(
    private readonly prisma: PrismaService,
    private readonly parser: ArchivoMovimientosParserService,
    private readonly aiProvider: AiProviderService,
    private readonly aiUsage: AiUsageService,
  ) {}

  /** Estado de consumo de IA del mes (para el chip del módulo). */
  consumoIa(empresaId: string) {
    return this.aiUsage.estadoConsumo(empresaId);
  }

  // ── Sesiones ──────────────────────────────────────────────────────────────
  async crearSesion(empresaId: string, usuarioId: string | null, dto: CrearSesionDto) {
    const sesion = await this.prisma.conc_ia_sesiones.create({
      data: {
        empresa_id: empresaId,
        usuario_id: usuarioId,
        nombre_cuenta: dto.nombre_cuenta,
        banco: dto.banco || null,
        moneda: dto.moneda || 'PYG',
        periodo_desde: new Date(dto.periodo_desde),
        periodo_hasta: new Date(dto.periodo_hasta),
        estado: 'CREADA',
      },
    });

    // Continuidad período a período: arrastrar las partidas EN_TRANSITO de la
    // última conciliación finalizada de la MISMA cuenta (cheque no cobrado, etc.).
    let arrastradas = 0;
    if (dto.arrastrar_en_transito) {
      arrastradas = await this.arrastrarEnTransito(empresaId, sesion.id, dto.nombre_cuenta);
    }

    return { ...this.presentarSesion(sesion), partidas_arrastradas: arrastradas };
  }

  /** Copia las partidas EN_TRANSITO de la última sesión finalizada de la misma cuenta a la nueva sesión. */
  private async arrastrarEnTransito(empresaId: string, nuevaSesionId: string, nombreCuenta: string): Promise<number> {
    const anterior = await this.prisma.conc_ia_sesiones.findFirst({
      where: { empresa_id: empresaId, nombre_cuenta: nombreCuenta, id: { not: nuevaSesionId } },
      orderBy: { creado_en: 'desc' },
    });
    if (!anterior) return 0;
    const enTransito = await this.prisma.conc_ia_movimientos.findMany({
      where: { sesion_id: anterior.id, estado: 'EN_TRANSITO' },
    });
    if (!enTransito.length) return 0;
    await this.prisma.conc_ia_movimientos.createMany({
      data: enTransito.map((m) => ({
        sesion_id: nuevaSesionId,
        origen: 'PROPIO',
        fecha: m.fecha,
        concepto: `[Arrastrado] ${m.concepto}`,
        tipo: m.tipo,
        importe: m.importe,
        referencia: m.referencia,
        confianza_ia: 1,
        estado: 'PENDIENTE',
        archivo_nombre: null,
      })),
    });
    return enTransito.length;
  }

  async listarSesiones(empresaId: string) {
    const sesiones = await this.prisma.conc_ia_sesiones.findMany({
      where: { empresa_id: empresaId },
      orderBy: { creado_en: 'desc' },
    });
    return sesiones.map((s) => this.presentarSesion(s));
  }

  async eliminarSesion(empresaId: string, sesionId: string) {
    await this.getSesionOrThrow(empresaId, sesionId);
    await this.prisma.conc_ia_sesiones.delete({ where: { id: sesionId } });
    return { ok: true };
  }

  // ── Carga + interpretación de archivos ──────────────────────────────────────
  async cargarArchivo(
    empresaId: string,
    sesionId: string,
    origen: OrigenMov,
    file: Express.Multer.File,
  ) {
    const sesion = await this.getSesionOrThrow(empresaId, sesionId);
    if (!file?.buffer?.length) throw new BadRequestException('No se recibió ningún archivo.');

    const { movimientos } = await this.parser.interpretar(empresaId, file.buffer, file.originalname, sesionId);

    await this.prisma.$transaction(async (tx) => {
      // Re-subir un lado reemplaza sus movimientos e invalida los matches previos.
      await tx.conc_ia_matches.deleteMany({ where: { sesion_id: sesionId } });
      await tx.conc_ia_movimientos.deleteMany({ where: { sesion_id: sesionId, origen } });
      if (movimientos.length) {
        await tx.conc_ia_movimientos.createMany({
          data: movimientos.map((m) => ({
            sesion_id: sesionId,
            origen,
            fecha: new Date(m.fecha),
            concepto: m.concepto,
            tipo: m.tipo,
            importe: m.importe,
            referencia: m.referencia,
            confianza_ia: m.confianza_ia,
            estado: 'PENDIENTE',
            archivo_nombre: file.originalname,
          })),
        });
      }
      // Estado: ARCHIVOS_CARGADOS si falta un lado; INTERPRETADA si ya están los dos.
      const [nPropio, nBanco] = await Promise.all([
        tx.conc_ia_movimientos.count({ where: { sesion_id: sesionId, origen: 'PROPIO' } }),
        tx.conc_ia_movimientos.count({ where: { sesion_id: sesionId, origen: 'BANCO' } }),
      ]);
      await tx.conc_ia_sesiones.update({
        where: { id: sesionId },
        data: {
          estado: nPropio > 0 && nBanco > 0 ? 'INTERPRETADA' : 'ARCHIVOS_CARGADOS',
          porcentaje_conciliado: 0,
        },
      });
    });

    return this.resumenLado(sesionId, origen, sesion.moneda, file.originalname);
  }

  // ── Preview editable: corregir / borrar / agregar líneas antes de conciliar ──
  async editarMovimiento(
    empresaId: string,
    sesionId: string,
    movId: string,
    dto: { fecha?: string; concepto?: string; tipo?: string; importe?: number; referencia?: string | null },
  ) {
    const sesion = await this.getSesionOrThrow(empresaId, sesionId);
    const mov = await this.getMovimientoOrThrow(sesionId, movId);

    await this.prisma.$transaction(async (tx) => {
      // Editar una línea invalida su conciliación previa (si la tuviera) — se re-concilia luego.
      await tx.conc_ia_matches.deleteMany({
        where: { sesion_id: sesionId, OR: [{ movimiento_propio_id: movId }, { movimiento_banco_id: movId }] },
      });
      await tx.conc_ia_movimientos.update({
        where: { id: movId },
        data: {
          fecha: dto.fecha ? new Date(dto.fecha) : undefined,
          concepto: dto.concepto ?? undefined,
          tipo: dto.tipo ?? undefined,
          importe: dto.importe ?? undefined,
          referencia: dto.referencia === undefined ? undefined : dto.referencia || null,
          // Corregida a mano ⇒ confianza plena.
          confianza_ia: 1,
          estado: 'PENDIENTE',
        },
      });
    });

    return this.resumenLado(sesionId, mov.origen, sesion.moneda);
  }

  async eliminarMovimiento(empresaId: string, sesionId: string, movId: string) {
    const sesion = await this.getSesionOrThrow(empresaId, sesionId);
    const mov = await this.getMovimientoOrThrow(sesionId, movId);
    // La FK de matches es ON DELETE CASCADE, así que se limpian solos.
    await this.prisma.conc_ia_movimientos.delete({ where: { id: movId } });
    return this.resumenLado(sesionId, mov.origen, sesion.moneda);
  }

  async agregarMovimiento(
    empresaId: string,
    sesionId: string,
    dto: { origen: string; fecha: string; concepto: string; tipo: string; importe: number; referencia?: string },
  ) {
    const sesion = await this.getSesionOrThrow(empresaId, sesionId);
    await this.prisma.conc_ia_movimientos.create({
      data: {
        sesion_id: sesionId,
        origen: dto.origen,
        fecha: new Date(dto.fecha),
        concepto: dto.concepto,
        tipo: dto.tipo,
        importe: dto.importe,
        referencia: dto.referencia || null,
        confianza_ia: 1,
        estado: 'PENDIENTE',
        archivo_nombre: null,
      },
    });
    return this.resumenLado(sesionId, dto.origen, sesion.moneda);
  }

  /** Shape estándar de un lado (propio/banco): usado por cargar/editar/borrar/agregar. */
  private async resumenLado(sesionId: string, origen: string, moneda: string, archivoNombre?: string) {
    const movs = await this.prisma.conc_ia_movimientos.findMany({
      where: { sesion_id: sesionId, origen },
      orderBy: { fecha: 'asc' },
    });
    return {
      archivo_nombre: archivoNombre ?? movs[0]?.archivo_nombre ?? null,
      origen,
      total_detectadas: movs.length,
      baja_confianza: movs.filter((m) => Number(m.confianza_ia) < 0.5).length,
      movimientos: movs.map((m) => this.presentarMovimiento(m)),
      moneda,
    };
  }

  // ── Conciliación automática ─────────────────────────────────────────────────
  async conciliarAuto(
    empresaId: string,
    sesionId: string,
    opts: { toleranciaDias?: number; toleranciaMonto?: number },
  ) {
    await this.getSesionOrThrow(empresaId, sesionId);
    const { propios, banco } = await this.cargarMovimientos(sesionId);

    if (!propios.length || !banco.length) {
      throw new BadRequestException('Faltan archivos por cargar. Subí tus movimientos y el extracto del banco antes de conciliar.');
    }

    const matches = autoMatch(propios as MovMatchable[], banco as MovMatchable[], {
      toleranciaDias: opts.toleranciaDias ?? 2,
      toleranciaMonto: opts.toleranciaMonto ?? 1,
    });

    await this.prisma.$transaction(async (tx) => {
      await tx.conc_ia_matches.deleteMany({ where: { sesion_id: sesionId } });
      if (matches.length) {
        await tx.conc_ia_matches.createMany({
          data: matches.map((m) => ({
            sesion_id: sesionId,
            movimiento_propio_id: m.movimiento_propio_id,
            movimiento_banco_id: m.movimiento_banco_id,
            tipo_match: m.tipo_match,
            diferencia_importe: m.diferencia_importe,
          })),
        });
      }
      // Marcar estados de movimientos: CONCILIADO los cruzados, PENDIENTE el resto.
      const conciliadosIds = matches.flatMap((m) => [m.movimiento_propio_id, m.movimiento_banco_id]);
      await tx.conc_ia_movimientos.updateMany({
        where: { sesion_id: sesionId },
        data: { estado: 'PENDIENTE' },
      });
      if (conciliadosIds.length) {
        await tx.conc_ia_movimientos.updateMany({
          where: { id: { in: conciliadosIds } },
          data: { estado: 'CONCILIADO' },
        });
      }
      const totalLineas = propios.length + banco.length;
      const porcentaje = totalLineas ? Math.round(((matches.length * 2) / totalLineas) * 100) : 0;
      await tx.conc_ia_sesiones.update({
        where: { id: sesionId },
        data: { estado: 'FINALIZADA', porcentaje_conciliado: porcentaje },
      });
    });

    // Devolvemos el estado ya persistido (ids reales, misma forma que getResultado).
    return this.getResultado(empresaId, sesionId);
  }

  async getResultado(empresaId: string, sesionId: string) {
    await this.getSesionOrThrow(empresaId, sesionId);
    const { propios, banco, enTransito } = await this.cargarMovimientos(sesionId);
    const matches = await this.prisma.conc_ia_matches.findMany({ where: { sesion_id: sesionId } });
    return construirResultado(
      propios,
      banco,
      matches.map((m) => ({
        id: m.id,
        movimiento_propio_id: m.movimiento_propio_id,
        movimiento_banco_id: m.movimiento_banco_id,
        tipo_match: m.tipo_match,
        diferencia_importe: Number(m.diferencia_importe),
      })),
      enTransito,
    );
  }

  /**
   * Estado completo para REANUDAR el asistente: la sesión + el preview de cada
   * lado ya interpretado (propio/banco). Permite al frontend abrir el wizard en
   * el paso correcto con lo que ya se había subido, en vez de solo verlo.
   */
  async getEstadoParaReanudar(empresaId: string, sesionId: string) {
    const sesion = await this.getSesionOrThrow(empresaId, sesionId);
    const propio = await this.resumenLado(sesionId, 'PROPIO', sesion.moneda);
    const banco = await this.resumenLado(sesionId, 'BANCO', sesion.moneda);
    return {
      sesion: this.presentarSesion(sesion),
      propio: propio.total_detectadas > 0 ? propio : null,
      banco: banco.total_detectadas > 0 ? banco : null,
    };
  }

  /** Genera el .xlsx (multi-hoja) del resultado actual de la sesión. */
  async exportarExcel(empresaId: string, sesionId: string): Promise<{ buffer: Buffer; filename: string }> {
    const sesion = await this.getSesionOrThrow(empresaId, sesionId);
    const resultado = await this.getResultado(empresaId, sesionId);
    const buffer = generarExcelResultado(this.presentarSesion(sesion), resultado);
    const slug = (sesion.nombre_cuenta || 'cuenta').replace(/\W+/g, '-').toLowerCase();
    return { buffer, filename: `conciliacion-${slug}.xlsx` };
  }

  // ── Acciones sobre el resultado ─────────────────────────────────────────────
  /**
   * Registrar un pendiente del BANCO (comisión, ITF, débito no registrado): crea
   * el movimiento equivalente en "mis registros" y lo deja conciliado con la línea.
   */
  async registrarMovimientoBanco(empresaId: string, sesionId: string, movBancoId: string) {
    await this.getSesionOrThrow(empresaId, sesionId);
    const banco = await this.getMovimientoOrThrow(sesionId, movBancoId);
    if (banco.origen !== 'BANCO') {
      throw new BadRequestException('Solo se pueden registrar líneas pendientes del banco.');
    }
    const yaConciliado = await this.prisma.conc_ia_matches.findFirst({
      where: { sesion_id: sesionId, movimiento_banco_id: movBancoId },
    });
    if (yaConciliado) throw new BadRequestException('Esta línea del banco ya está conciliada.');

    await this.prisma.$transaction(async (tx) => {
      const propio = await tx.conc_ia_movimientos.create({
        data: {
          sesion_id: sesionId,
          origen: 'PROPIO',
          fecha: banco.fecha,
          concepto: banco.concepto,
          tipo: banco.tipo,
          importe: banco.importe,
          referencia: banco.referencia,
          confianza_ia: 1,
          estado: 'REGISTRADO',
          archivo_nombre: null,
        },
      });
      await tx.conc_ia_movimientos.update({ where: { id: movBancoId }, data: { estado: 'CONCILIADO' } });
      await tx.conc_ia_matches.create({
        data: {
          sesion_id: sesionId,
          movimiento_propio_id: propio.id,
          movimiento_banco_id: movBancoId,
          tipo_match: 'MANUAL',
          diferencia_importe: 0,
        },
      });
      await this.recalcularPorcentaje(tx, sesionId);
    });
    return this.getResultado(empresaId, sesionId);
  }

  /**
   * Marcar una partida propia como EN_TRANSITO (cheque no cobrado, depósito no
   * acreditado aún): sale del período actual y se vigila para el próximo extracto.
   */
  async marcarEnTransito(empresaId: string, sesionId: string, movId: string) {
    await this.getSesionOrThrow(empresaId, sesionId);
    const mov = await this.getMovimientoOrThrow(sesionId, movId);
    if (mov.origen !== 'PROPIO') {
      throw new BadRequestException('Solo las partidas propias pueden marcarse en tránsito.');
    }
    await this.prisma.$transaction(async (tx) => {
      // Si estaba conciliado, desarmar su match para que el banco vuelva a pendiente.
      await tx.conc_ia_matches.deleteMany({ where: { sesion_id: sesionId, movimiento_propio_id: movId } });
      await tx.conc_ia_movimientos.update({ where: { id: movId }, data: { estado: 'EN_TRANSITO' } });
      await this.recalcularPorcentaje(tx, sesionId);
    });
    return this.getResultado(empresaId, sesionId);
  }

  /** Registrar la diferencia de una conciliada (comisión incluida por el banco): queda sin diferencia. */
  async registrarDiferencia(empresaId: string, sesionId: string, matchId: string) {
    await this.getSesionOrThrow(empresaId, sesionId);
    const match = await this.prisma.conc_ia_matches.findFirst({
      where: { id: matchId, sesion_id: sesionId },
    });
    if (!match) throw new NotFoundException('Conciliación (match) no encontrada.');
    await this.prisma.conc_ia_matches.update({
      where: { id: matchId },
      data: { diferencia_importe: 0 },
    });
    return this.getResultado(empresaId, sesionId);
  }

  // ── Análisis: resumen ejecutivo IA + hallazgos + asiento sugerido ────────────
  async resumenEjecutivo(empresaId: string, sesionId: string) {
    const sesion = await this.getSesionOrThrow(empresaId, sesionId);
    const resultado = await this.getResultado(empresaId, sesionId);
    const analisis = analizarResultado(resultado);

    const { narrativa, generado_por } = await this.generarNarrativa(empresaId, sesionId, sesion.moneda, resultado, analisis.hallazgos);

    return {
      narrativa,
      generado_por, // 'ia' | 'sistema'
      hallazgos: analisis.hallazgos,
      asiento_sugerido: analisis.asientoSugerido,
      ahorro: analisis.ahorro,
      resumen: resultado.resumen,
    };
  }

  private async generarNarrativa(
    empresaId: string,
    sesionId: string,
    moneda: string,
    resultado: ResultadoConciliacion,
    hallazgos: ReturnType<typeof analizarResultado>['hallazgos'],
  ): Promise<{ narrativa: string; generado_por: 'ia' | 'sistema' }> {
    const r = resultado.resumen;
    const fmt = (n: number) => `${moneda} ${Math.round(n).toLocaleString('es-PY')}`;
    const bullets = hallazgos.map((h) => `- ${h.titulo}${h.monto ? ` (${fmt(h.monto)})` : ''}: ${h.detalle}`).join('\n');

    // Narrativa determinista de respaldo (siempre disponible, sin IA).
    const fallback =
      `Se conciliaron ${r.conciliadas} de ${r.total_propio + r.total_banco} movimientos (${r.porcentaje_conciliado}%). ` +
      (hallazgos.length
        ? `Puntos a revisar: ${hallazgos.map((h) => h.titulo.toLowerCase()).join('; ')}.`
        : 'No se detectaron diferencias ni pendientes relevantes.');

    const SYSTEM = 'Sos un analista contable paraguayo. Escribí un resumen ejecutivo de una conciliación bancaria para un gerente, en español, claro y breve (máx 4 oraciones). Sin markdown, sin listas. Terminá con una recomendación concreta.';
    const USER = `Datos de la conciliación (cuenta en ${moneda}):
% conciliado: ${r.porcentaje_conciliado}
Conciliadas: ${r.conciliadas} | Pendientes míos: ${r.pendientes_propio} | Pendientes banco: ${r.pendientes_banco} | Con diferencia: ${r.con_diferencia} | En tránsito: ${r.en_transito}
Hallazgos:
${bullets || '(ninguno)'}

Redactá el resumen ejecutivo.`;

    try {
      const res = await this.aiProvider.completar(empresaId, USER, SYSTEM, {
        feature: 'CONCILIACION_IA',
        referenciaId: sesionId,
      });
      const txt = (res.content || '').trim();
      if (!txt || txt.includes('[Sin API key') || txt.includes('[Proveedor')) {
        return { narrativa: fallback, generado_por: 'sistema' };
      }
      return { narrativa: txt, generado_por: 'ia' };
    } catch {
      return { narrativa: fallback, generado_por: 'sistema' };
    }
  }

  // ── Sugerencias de match (para los pendientes que "casi cruzan") ─────────────
  async sugerenciasMatch(empresaId: string, sesionId: string) {
    await this.getSesionOrThrow(empresaId, sesionId);
    const { propios, banco } = await this.cargarMovimientos(sesionId);
    const matches = await this.prisma.conc_ia_matches.findMany({ where: { sesion_id: sesionId } });
    const matchedP = new Set(matches.map((m) => m.movimiento_propio_id));
    const matchedB = new Set(matches.map((m) => m.movimiento_banco_id));
    const pendP = propios.filter((p) => !matchedP.has(p.id));
    const pendB = banco.filter((b) => !matchedB.has(b.id));

    const sugerencias = sugerirPares(pendP as MovMatchable[], pendB as MovMatchable[]);
    const byId = (arr: MovVista[], id: string) => arr.find((x) => x.id === id);
    return {
      sugerencias: sugerencias.map((s) => ({
        ...s,
        propio: byId(pendP, s.movimiento_propio_id),
        banco: byId(pendB, s.movimiento_banco_id),
      })),
    };
  }

  /** Crea un match manual (aceptar una sugerencia o cruce a mano). */
  async matchManual(empresaId: string, sesionId: string, movPropioId: string, movBancoId: string) {
    await this.getSesionOrThrow(empresaId, sesionId);
    const propio = await this.getMovimientoOrThrow(sesionId, movPropioId);
    const banco = await this.getMovimientoOrThrow(sesionId, movBancoId);
    if (propio.origen !== 'PROPIO' || banco.origen !== 'BANCO') {
      throw new BadRequestException('El match debe cruzar una partida propia con una del banco.');
    }
    const yaMatch = await this.prisma.conc_ia_matches.findFirst({
      where: { sesion_id: sesionId, OR: [{ movimiento_propio_id: movPropioId }, { movimiento_banco_id: movBancoId }] },
    });
    if (yaMatch) throw new BadRequestException('Alguno de los movimientos ya está conciliado.');

    await this.prisma.$transaction(async (tx) => {
      await tx.conc_ia_matches.create({
        data: {
          sesion_id: sesionId,
          movimiento_propio_id: movPropioId,
          movimiento_banco_id: movBancoId,
          tipo_match: 'MANUAL',
          diferencia_importe: Math.round((Number(propio.importe) - Number(banco.importe)) * 100) / 100,
        },
      });
      await tx.conc_ia_movimientos.updateMany({
        where: { id: { in: [movPropioId, movBancoId] } },
        data: { estado: 'CONCILIADO' },
      });
      await this.recalcularPorcentaje(tx, sesionId);
    });
    return this.getResultado(empresaId, sesionId);
  }

  // ── Dashboard consolidado multi-cuenta ───────────────────────────────────────
  async dashboard(empresaId: string) {
    const sesiones = await this.prisma.conc_ia_sesiones.findMany({
      where: { empresa_id: empresaId },
      orderBy: { creado_en: 'desc' },
    });

    const cuentas = sesiones.map((s) => ({
      id: s.id,
      nombre_cuenta: s.nombre_cuenta,
      banco: s.banco || '',
      moneda: s.moneda,
      estado: s.estado,
      porcentaje_conciliado: s.porcentaje_conciliado,
      periodo_desde: fmtDate(s.periodo_desde),
      periodo_hasta: fmtDate(s.periodo_hasta),
    }));

    const finalizadas = sesiones.filter((s) => s.estado === 'FINALIZADA');
    const promedio = finalizadas.length
      ? Math.round(finalizadas.reduce((t, s) => t + s.porcentaje_conciliado, 0) / finalizadas.length)
      : 0;

    return {
      total_conciliaciones: sesiones.length,
      finalizadas: finalizadas.length,
      pendientes: sesiones.length - finalizadas.length,
      promedio_conciliado: promedio,
      cuentas,
    };
  }

  // ── Helpers ─────────────────────────────────────────────────────────────────
  private async getSesionOrThrow(empresaId: string, sesionId: string) {
    const sesion = await this.prisma.conc_ia_sesiones.findFirst({
      where: { id: sesionId, empresa_id: empresaId },
    });
    if (!sesion) throw new NotFoundException('Conciliación no encontrada.');
    return sesion;
  }

  private async cargarMovimientos(
    sesionId: string,
  ): Promise<{ propios: MovVista[]; banco: MovVista[]; enTransito: MovVista[] }> {
    const movs = await this.prisma.conc_ia_movimientos.findMany({
      where: { sesion_id: sesionId },
      orderBy: { fecha: 'asc' },
    });
    const vista = movs.map((m) => this.presentarMovimiento(m));
    // Los EN_TRANSITO se excluyen de propios/banco (no cuentan en totales ni %).
    return {
      propios: vista.filter((m) => m.origen === 'PROPIO' && m.estado !== 'EN_TRANSITO'),
      banco: vista.filter((m) => m.origen === 'BANCO' && m.estado !== 'EN_TRANSITO'),
      enTransito: vista.filter((m) => m.estado === 'EN_TRANSITO'),
    };
  }

  private async getMovimientoOrThrow(sesionId: string, movId: string) {
    const mov = await this.prisma.conc_ia_movimientos.findFirst({
      where: { id: movId, sesion_id: sesionId },
    });
    if (!mov) throw new NotFoundException('Movimiento no encontrado.');
    return mov;
  }

  /** Recalcula estado/porcentaje de la sesión a partir de los movimientos y matches actuales. */
  private async recalcularPorcentaje(
    tx: Prisma.TransactionClient,
    sesionId: string,
  ): Promise<void> {
    const [nPropio, nBanco, nMatches] = await Promise.all([
      tx.conc_ia_movimientos.count({ where: { sesion_id: sesionId, origen: 'PROPIO', estado: { not: 'EN_TRANSITO' } } }),
      tx.conc_ia_movimientos.count({ where: { sesion_id: sesionId, origen: 'BANCO', estado: { not: 'EN_TRANSITO' } } }),
      tx.conc_ia_matches.count({ where: { sesion_id: sesionId } }),
    ]);
    const totalLineas = nPropio + nBanco;
    const porcentaje = totalLineas ? Math.round(((nMatches * 2) / totalLineas) * 100) : 0;
    await tx.conc_ia_sesiones.update({
      where: { id: sesionId },
      data: { porcentaje_conciliado: porcentaje },
    });
  }

  private presentarSesion(s: {
    id: string;
    nombre_cuenta: string;
    banco: string | null;
    moneda: string;
    periodo_desde: Date;
    periodo_hasta: Date;
    estado: string;
    porcentaje_conciliado: number;
    creado_en: Date;
  }) {
    return {
      id: s.id,
      nombre_cuenta: s.nombre_cuenta,
      banco: s.banco || '',
      moneda: s.moneda,
      periodo_desde: fmtDate(s.periodo_desde),
      periodo_hasta: fmtDate(s.periodo_hasta),
      estado: s.estado,
      porcentaje_conciliado: s.porcentaje_conciliado,
      creado_en: fmtDate(s.creado_en),
    };
  }

  private presentarMovimiento(m: {
    id: string;
    origen: string;
    fecha: Date;
    concepto: string;
    tipo: string;
    importe: unknown;
    referencia: string | null;
    confianza_ia: unknown;
    estado: string;
  }): MovVista {
    return {
      id: m.id,
      origen: m.origen,
      fecha: fmtDate(m.fecha),
      concepto: m.concepto,
      tipo: m.tipo,
      importe: Number(m.importe),
      referencia: m.referencia,
      confianza_ia: Number(m.confianza_ia),
      estado: m.estado,
    };
  }
}
