// src/contabilidad/services/avisos-mapeo.service.spec.ts
import { AvisosMapeoService, ACCION_FALTA_MAPEO } from './avisos-mapeo.service';

/**
 * Emula la consulta SQL de `conceptosFrenando` sobre datos en memoria: última
 * constancia por documento, sin asiento CONFIRMADO, conceptos que no están en la
 * lista de usables (primer arreglo entre los parámetros) o cuya regla de la
 * constancia sigue rota (segundo arreglo: ids), y documentos con algo pendiente.
 * Así los tests verifican comportamiento, no la forma del SQL.
 */
const emularSql = (pendientes: any[], confirmados: any[]) =>
  jest.fn().mockImplementation((_sql: TemplateStringsArray, ...valores: unknown[]) => {
    const arreglos = valores.filter((v) => Array.isArray(v)) as string[][];
    const usables = new Set(arreglos[0] ?? []);
    const rotas = new Set(arreglos[1] ?? []);
    const asentados = new Set(confirmados.map((d) => `${d.origen_tipo}:${d.origen_id}`));
    const vistos = new Set<string>();
    const filas: any[] = [];
    for (const p of pendientes) {
      const clave = `${p.new_value.origen_tipo}:${p.entity_id}`;
      if (vistos.has(clave)) continue;
      vistos.add(clave);
      if (asentados.has(clave)) continue;
      const regla = p.new_value.regla;
      const conceptos = [...new Set<string>(p.new_value.conceptos ?? [])].filter(
        (c) => !usables.has(c) || (regla && regla.concepto === c && rotas.has(regla.id)),
      );
      if (conceptos.length) filas.push({ origen_tipo: p.new_value.origen_tipo, origen_id: p.entity_id, regla_id: regla?.id ?? null, conceptos });
    }
    return Promise.resolve(filas);
  });

const crear = (over: any = {}) => {
  const s = Object.create(AvisosMapeoService.prototype) as any;
  s.logger = { warn: jest.fn(), error: jest.fn(), log: jest.fn() };
  s.alertas = { registrar: jest.fn().mockResolvedValue({}), resolver: jest.fn(), resolverPorPrefijo: jest.fn() };
  const pendientes = over.pendientes ?? [];
  s.prisma = {
    $queryRaw: emularSql(pendientes, over.confirmados ?? []),
    audit_logs: { create: jest.fn(), findMany: jest.fn().mockResolvedValue(pendientes) },
    cont_mapeo_reglas: { findMany: jest.fn().mockResolvedValue(over.reglas ?? []) },
    cont_mapeo_cuentas: {
      findMany: jest.fn().mockResolvedValue(over.mapeos ?? []),
      count: jest.fn().mockResolvedValue(over.sinConfirmar ?? 0),
    },
    cont_documentos: { findMany: jest.fn().mockResolvedValue(over.confirmados ?? []) },
    suscripcion_modulos: { findFirst: jest.fn().mockResolvedValue(over.sinModulo ? null : { id: 'm' }) },
    gasto_cab: {
      findUnique: jest.fn().mockResolvedValue(over.gasto ?? { usuario_id: 'u1' }),
      // Documentos vigentes (no anulados): por defecto, todos los pedidos.
      findMany: jest.fn().mockImplementation(({ where }: any) =>
        Promise.resolve(where.id.in.filter((id: string) => !(over.anulados ?? []).includes(id)).map((id: string) => ({ id }))),
      ),
    },
    imp_embarques: { findUnique: jest.fn().mockResolvedValue({ usuario_id: 'u1' }) },
    rendicion_adelanto: { findUnique: jest.fn().mockResolvedValue({ rendicion: { creado_por: 'u1' } }) },
    usuario: { findUnique: jest.fn().mockResolvedValue(over.usuarioExiste === false ? null : { id: 'u1' }) },
    cont_mapeo_medio_pago: { findMany: jest.fn().mockResolvedValue(over.mapeosMedio ?? []) },
  };
  return s as AvisosMapeoService & any;
};

describe('AvisosMapeoService', () => {
  describe('medio de pago con cuenta que no sirve (2026-10-14)', () => {
    const detalle = 'la cuenta 1.01.02 del medio Transferencia (al cobrar) está desactivada; elegí otra en Contabilidad › Mapeo de cuentas › Cuentas por medio de pago';
    const pendiente = { entity_id: 'g1', new_value: { origen_tipo: 'gasto_cab', conceptos: ['MEDIO_PAGO_COBRO'], motivo: 'CUENTA_INUTILIZABLE', detalles: { MEDIO_PAGO_COBRO: detalle } } };
    const roto = { medio_pago_id: 'mp', flujo: 'COBRO', cuenta_contable_id: 'c', medio_pago: { codigo: 5, descripcion: 'Transferencia' },
      cuenta: { codigo: '1.01.02', descripcion: 'Banco viejo', active: false, acepta_movimientos: true } };

    it('la constancia guarda el detalle y el aviso a quien cargó lo nombra', async () => {
      const s = crear({ pendientes: [pendiente], mapeosMedio: [roto] });
      await s.registrarFaltaMapeo({ empresaId: 'emp', origenTipo: 'gasto_cab', origenId: 'g1', conceptos: ['MEDIO_PAGO_COBRO'], motivo: 'CUENTA_INUTILIZABLE', detalles: { MEDIO_PAGO_COBRO: detalle } });

      const data = s.prisma.audit_logs.create.mock.calls[0][0].data;
      expect(data.new_value).toEqual({ origen_tipo: 'gasto_cab', conceptos: ['MEDIO_PAGO_COBRO'], motivo: 'CUENTA_INUTILIZABLE', detalles: { MEDIO_PAGO_COBRO: detalle } });
      expect(data.descripcion).toContain('la cuenta 1.01.02 del medio Transferencia');
      const personal = s.alertas.registrar.mock.calls.find((c: any) => c[0].clave.endsWith('u1'))[0];
      expect(personal.descripcion).toContain('la cuenta 1.01.02 del medio Transferencia (al cobrar) está desactivada');
    });

    it('con el medio roto y otro concepto sin mapear, el aviso nombra las dos cosas', async () => {
      const s = crear({ pendientes: [pendiente], mapeosMedio: [roto] });
      await s.registrarFaltaMapeo({ empresaId: 'emp', origenTipo: 'gasto_cab', origenId: 'g1', conceptos: ['MEDIO_PAGO_COBRO', 'CHEQUES_EN_CARTERA'], detalles: { MEDIO_PAGO_COBRO: detalle } });
      const personal = s.alertas.registrar.mock.calls.find((c: any) => c[0].clave.endsWith('u1'))[0];
      expect(personal.descripcion).toContain('la cuenta 1.01.02 del medio Transferencia');
      expect(personal.descripcion).toContain('CHEQUES_EN_CARTERA');
      expect(personal.descripcion).not.toMatch(/mapee: .*MEDIO_PAGO_COBRO/);
    });

    it('mientras el mapeo siga roto frena, y la alerta del contador lo nombra legible', async () => {
      const s = crear({ pendientes: [pendiente], mapeosMedio: [roto] });
      await s.sincronizar('emp');
      const alerta = s.alertas.registrar.mock.calls.find((c: any) => c[0].clave === 'contabilidad.falta_mapeo')[0];
      expect(alerta.datos.conceptos).toEqual({ MEDIO_PAGO_COBRO: 1 });
      expect(alerta.descripcion).toContain('cuentas por medio de pago (al cobrar) (1 documento)');
    });

    it('corregido el mapeo del medio, deja de frenar y se cierra la alerta', async () => {
      const s = crear({ pendientes: [pendiente], mapeosMedio: [{ ...roto, cuenta: { ...roto.cuenta, active: true } }] });
      await s.sincronizar('emp');
      expect(s.alertas.resolverPorPrefijo).toHaveBeenCalledWith('emp', 'contabilidad.falta_mapeo', 'auto');
    });

    it('un mapeo roto solo al pagar no frena lo que se cobró', async () => {
      const s = crear({ pendientes: [pendiente], mapeosMedio: [{ ...roto, flujo: 'PAGO' }] });
      await s.sincronizar('emp');
      expect(s.alertas.resolverPorPrefijo).toHaveBeenCalledWith('emp', 'contabilidad.falta_mapeo', 'auto');
    });
  });

  it('registra el pendiente con todos los conceptos y avisa al contador y a quien cargó [RF-1]', async () => {
    const pendiente = { entity_id: 'g1', new_value: { origen_tipo: 'gasto_cab', conceptos: ['IMPORTACION_GASTO', 'IVA_NO_DEDUCIBLE'] } };
    const s = crear({ pendientes: [pendiente] });
    await s.registrarFaltaMapeo({ empresaId: 'emp', origenTipo: 'gasto_cab', origenId: 'g1', conceptos: ['IMPORTACION_GASTO', 'IVA_NO_DEDUCIBLE'] });

    expect(s.prisma.audit_logs.create.mock.calls[0][0].data).toMatchObject({
      empresa_id: 'emp', action: ACCION_FALTA_MAPEO, entity_type: 'gasto_cab', entity_id: 'g1',
      new_value: { origen_tipo: 'gasto_cab', conceptos: ['IMPORTACION_GASTO', 'IVA_NO_DEDUCIBLE'] },
    });
    const claves = s.alertas.registrar.mock.calls.map((c: any) => c[0].clave);
    expect(claves).toContain('contabilidad.falta_mapeo');
    expect(claves).toContain('contabilidad.falta_mapeo.usuario.u1');
    const personal = s.alertas.registrar.mock.calls.find((c: any) => c[0].clave.endsWith('u1'))[0];
    expect(personal.usuarioDestinoId).toBe('u1');
    expect(personal.descripcion).toMatch(/IMPORTACION_GASTO/);
    expect(personal.descripcion).toMatch(/IVA_NO_DEDUCIBLE/);
  });

  it('[I1] la constancia guarda motivo y regla; el aviso personal nombra la regla', async () => {
    const regla = { id: 'r9', orden: 2, concepto: 'VENTAS_10' };
    const s = crear({ pendientes: [{ entity_id: 'g1', new_value: { origen_tipo: 'gasto_cab', conceptos: ['VENTAS_10'], regla } }] });
    await s.registrarFaltaMapeo({ empresaId: 'emp', origenTipo: 'gasto_cab', origenId: 'g1', conceptos: ['VENTAS_10'], motivo: 'CUENTA_INUTILIZABLE', regla });
    expect(s.prisma.audit_logs.create.mock.calls[0][0].data.new_value).toEqual({
      origen_tipo: 'gasto_cab', conceptos: ['VENTAS_10'], motivo: 'CUENTA_INUTILIZABLE', regla,
    });
    const personal = s.alertas.registrar.mock.calls.find((c: any) => c[0].clave.endsWith('u1'))[0];
    expect(personal.descripcion).toMatch(/regla n\.º 2 de VENTAS_10 apunta a una cuenta que no sirve/);
  });

  it('[I1] concepto con defecto usable pero una regla ACTIVA con cuenta que no sirve: sigue frenando y la alerta nombra la regla', async () => {
    const s = crear({
      pendientes: [{ entity_id: 'g1', new_value: { origen_tipo: 'gasto_cab', conceptos: ['VENTAS_10'] } }],
      mapeos: [{ concepto: 'VENTAS_10', cuenta_id: 'c', modo: 'CUENTA', cuenta: { active: true, acepta_movimientos: true } }],
      reglas: [{ id: 'r9', concepto: 'VENTAS_10', orden: 2, estado: 'ACTIVA', modo: 'CUENTA', cuenta_id: 'g', vigente_desde: new Date('2026-01-01'), vigente_hasta: null,
        cuenta: { codigo: '4.1', active: true, acepta_movimientos: false, empresa_id: 'emp' } }],
    });
    await s.sincronizar('emp');
    expect(s.alertas.resolverPorPrefijo).not.toHaveBeenCalled();
    const alerta = s.alertas.registrar.mock.calls.find((c: any) => c[0].clave === 'contabilidad.falta_mapeo')[0];
    expect(alerta.datos).toEqual({ conceptos: { VENTAS_10: 1 }, reglas: { VENTAS_10: [2] } });
    expect(alerta.descripcion).toMatch(/regla n\.º 2 de VENTAS_10 apunta a una cuenta que no sirve \(1 documento\)/);
    expect(s.prisma.cont_mapeo_reglas.findMany.mock.calls[0][0].where).toMatchObject({ empresa_id: 'emp' });
  });

  it('[I1] la regla de la constancia ya retirada pero vigente para el documento, con cuenta que no sirve: sigue frenando', async () => {
    const regla = { id: 'r9', orden: 2, concepto: 'VENTAS_10' };
    const s = crear({
      pendientes: [{ entity_id: 'g1', new_value: { origen_tipo: 'gasto_cab', conceptos: ['VENTAS_10'], motivo: 'CUENTA_INUTILIZABLE', regla } }],
      mapeos: [{ concepto: 'VENTAS_10', cuenta_id: 'c', modo: 'CUENTA', cuenta: { active: true, acepta_movimientos: true } }],
      reglas: [{ id: 'r9', concepto: 'VENTAS_10', orden: 2, estado: 'RETIRADA', modo: 'CUENTA', cuenta_id: 'g', vigente_desde: new Date('2026-01-01'), vigente_hasta: new Date('2026-10-14'),
        cuenta: { codigo: '4.1', active: false, acepta_movimientos: true, empresa_id: 'emp' } }],
    });
    await s.sincronizar('emp');
    const alerta = s.alertas.registrar.mock.calls.find((c: any) => c[0].clave === 'contabilidad.falta_mapeo')[0];
    expect(alerta.descripcion).toMatch(/regla n\.º 2 de VENTAS_10/);
  });

  it('[I1] la regla se arregló (retirada sin vigencia: nunca aplicó) y el defecto sirve: se cierra la alerta', async () => {
    const regla = { id: 'r9', orden: 2, concepto: 'VENTAS_10' };
    const s = crear({
      pendientes: [{ entity_id: 'g1', new_value: { origen_tipo: 'gasto_cab', conceptos: ['VENTAS_10'], motivo: 'CUENTA_INUTILIZABLE', regla } }],
      mapeos: [{ concepto: 'VENTAS_10', cuenta_id: 'c', modo: 'CUENTA', cuenta: { active: true, acepta_movimientos: true } }],
      reglas: [{ id: 'r9', concepto: 'VENTAS_10', orden: 2, estado: 'RETIRADA', modo: 'CUENTA', cuenta_id: 'g', vigente_desde: new Date('2026-10-14'), vigente_hasta: new Date('2026-10-14'),
        cuenta: { codigo: '4.1', active: true, acepta_movimientos: false, empresa_id: 'emp' } }],
    });
    await s.sincronizar('emp');
    expect(s.alertas.resolverPorPrefijo).toHaveBeenCalledWith('emp', 'contabilidad.falta_mapeo', 'auto');
  });

  it('[RF-5] sin usuario en el documento solo avisa al contador', async () => {
    const s = crear({ gasto: { usuario_id: null }, pendientes: [{ entity_id: 'g1', new_value: { origen_tipo: 'gasto_cab', conceptos: ['X'] } }] });
    await s.registrarFaltaMapeo({ empresaId: 'emp', origenTipo: 'gasto_cab', origenId: 'g1', conceptos: ['X'] });
    expect(s.alertas.registrar.mock.calls.map((c: any) => c[0].clave)).toEqual(['contabilidad.falta_mapeo']);
  });

  it('[RF-3] sincronizar cierra las alertas cuando el concepto ya se mapeó', async () => {
    const s = crear({
      pendientes: [{ entity_id: 'g1', new_value: { origen_tipo: 'gasto_cab', conceptos: ['X'] } }],
      mapeos: [{ concepto: 'X', cuenta_id: 'c', modo: 'CUENTA', cuenta: { active: true, acepta_movimientos: true } }],
    });
    await s.sincronizar('emp');
    expect(s.alertas.resolverPorPrefijo).toHaveBeenCalledWith('emp', 'contabilidad.falta_mapeo', 'auto');
  });

  it('sin el módulo Contabilidad no avisa y cierra lo abierto', async () => {
    const s = crear({ sinModulo: true });
    await s.registrarFaltaMapeo({ empresaId: 'emp', origenTipo: 'gasto_cab', origenId: 'g1', conceptos: ['X'] });
    expect(s.prisma.audit_logs.create).not.toHaveBeenCalled();
    expect(s.alertas.registrar).not.toHaveBeenCalled();
  });

  it('abre la alerta de sin confirmar mientras haya mapeos sin confirmar', async () => {
    const s = crear({ sinConfirmar: 4 });
    await s.sincronizar('emp');
    const alerta = s.alertas.registrar.mock.calls.find((c: any) => c[0].clave === 'contabilidad.mapeo_sin_confirmar')[0];
    expect(alerta.criticidad).toBe('media');
    expect(alerta.titulo).toMatch(/4/);
  });

  it('imp_cierre consulta imp_embarques.usuario_id y avisa a ese usuario', async () => {
    const s = crear({ pendientes: [{ entity_id: 'e1', new_value: { origen_tipo: 'imp_cierre', conceptos: ['X'] } }] });
    await s.registrarFaltaMapeo({ empresaId: 'emp', origenTipo: 'imp_cierre', origenId: 'e1', conceptos: ['X'] });
    expect(s.prisma.imp_embarques.findUnique).toHaveBeenCalledWith({ where: { id: 'e1' }, select: { usuario_id: true } });
    expect(s.alertas.registrar.mock.calls.map((c: any) => c[0].clave)).toContain('contabilidad.falta_mapeo.usuario.u1');
  });

  it('[RF-5] usuario inexistente: solo avisa al contador', async () => {
    const s = crear({ usuarioExiste: false, pendientes: [{ entity_id: 'g1', new_value: { origen_tipo: 'gasto_cab', conceptos: ['X'] } }] });
    await s.registrarFaltaMapeo({ empresaId: 'emp', origenTipo: 'gasto_cab', origenId: 'g1', conceptos: ['X'] });
    expect(s.alertas.registrar.mock.calls.map((c: any) => c[0].clave)).toEqual(['contabilidad.falta_mapeo']);
  });

  it('un documento ya CONFIRMADO no cuenta como frenando', async () => {
    const s = crear({
      pendientes: [{ entity_id: 'g1', new_value: { origen_tipo: 'gasto_cab', conceptos: ['X'] } }],
      confirmados: [{ origen_tipo: 'gasto_cab', origen_id: 'g1' }],
    });
    await s.sincronizar('emp');
    expect(s.alertas.resolverPorPrefijo).toHaveBeenCalledWith('emp', 'contabilidad.falta_mapeo', 'auto');
    expect(s.alertas.registrar.mock.calls.find((c: any) => c[0].clave === 'contabilidad.falta_mapeo')).toBeUndefined();
  });

  it('rendicion_adelanto avisa al creado_por de la rendición', async () => {
    const s = crear({ pendientes: [{ entity_id: 'r1', new_value: { origen_tipo: 'rendicion_adelanto', conceptos: ['X'] } }] });
    await s.registrarFaltaMapeo({ empresaId: 'emp', origenTipo: 'rendicion_adelanto', origenId: 'r1', conceptos: ['X'] });
    expect(s.prisma.rendicion_adelanto.findUnique).toHaveBeenCalledWith({ where: { id: 'r1' }, select: { rendicion: { select: { creado_por: true } } } });
    const personal = s.alertas.registrar.mock.calls.find((c: any) => c[0].clave.endsWith('u1'))[0];
    expect(personal.usuarioDestinoId).toBe('u1');
  });
  it('un concepto mapeado a una cuenta desactivada sigue frenando: la alerta del contador no se cierra', async () => {
    const s = crear({
      pendientes: [{ entity_id: 'g1', new_value: { origen_tipo: 'gasto_cab', conceptos: ['X'] } }],
      mapeos: [{ concepto: 'X', cuenta_id: 'c', modo: 'CUENTA', cuenta: { active: false, acepta_movimientos: true } }],
    });
    await s.sincronizar('emp');
    expect(s.alertas.resolverPorPrefijo).not.toHaveBeenCalled();
    const alerta = s.alertas.registrar.mock.calls.find((c: any) => c[0].clave === 'contabilidad.falta_mapeo')[0];
    expect(alerta.datos).toEqual({ conceptos: { X: 1 } });
  });

  it('un concepto mapeado a una cuenta de agrupación sigue frenando', async () => {
    const s = crear({
      pendientes: [{ entity_id: 'g1', new_value: { origen_tipo: 'gasto_cab', conceptos: ['X'] } }],
      mapeos: [{ concepto: 'X', cuenta_id: 'c', modo: 'CUENTA', cuenta: { active: true, acepta_movimientos: false } }],
    });
    await s.sincronizar('emp');
    expect(s.alertas.registrar.mock.calls.map((c: any) => c[0].clave)).toContain('contabilidad.falta_mapeo');
  });

  it('SUMAR_A_PRINCIPAL cuenta como mapeado aunque no tenga cuenta', async () => {
    const s = crear({
      pendientes: [{ entity_id: 'g1', new_value: { origen_tipo: 'gasto_cab', conceptos: ['IVA_NO_DEDUCIBLE'] } }],
      mapeos: [{ concepto: 'IVA_NO_DEDUCIBLE', cuenta_id: null, modo: 'SUMAR_A_PRINCIPAL', cuenta: null }],
    });
    await s.sincronizar('emp');
    expect(s.alertas.resolverPorPrefijo).toHaveBeenCalledWith('emp', 'contabilidad.falta_mapeo', 'auto');
  });

  it('un documento anulado no cuenta como frenando (mismo filtro que la lista "Sin asiento")', async () => {
    const s = crear({
      pendientes: [
        { entity_id: 'g1', new_value: { origen_tipo: 'gasto_cab', conceptos: ['X'] } },
        { entity_id: 'g2', new_value: { origen_tipo: 'gasto_cab', conceptos: ['X'] } },
      ],
      anulados: ['g1'],
    });
    await s.sincronizar('emp');
    const where = s.prisma.gasto_cab.findMany.mock.calls[0][0].where;
    expect(where).toMatchObject({ anulado: false, id: { in: ['g1', 'g2'] } });
    const alerta = s.alertas.registrar.mock.calls.find((c: any) => c[0].clave === 'contabilidad.falta_mapeo')[0];
    expect(alerta.datos).toEqual({ conceptos: { X: 1 } });
  });

  it('todos los documentos frenados están anulados: cierra la alerta', async () => {
    const s = crear({ pendientes: [{ entity_id: 'g1', new_value: { origen_tipo: 'gasto_cab', conceptos: ['X'] } }], anulados: ['g1'] });
    await s.sincronizar('emp');
    expect(s.alertas.resolverPorPrefijo).toHaveBeenCalledWith('emp', 'contabilidad.falta_mapeo', 'auto');
  });

  it('con miles de constancias filtra en SQL y verifica vigencia por lotes acotados (sin un OR gigante)', async () => {
    const pendientes = Array.from({ length: 2500 }, (_, i) => ({ entity_id: `g${i}`, new_value: { origen_tipo: 'gasto_cab', conceptos: ['X'] } }));
    const s = crear({ pendientes });
    await s.sincronizar('emp');
    expect(s.prisma.$queryRaw).toHaveBeenCalledTimes(1);
    // Parámetros del SQL acotados: no crecen con la cantidad de documentos.
    expect(s.prisma.$queryRaw.mock.calls[0].length).toBeLessThan(10);
    expect(s.prisma.audit_logs.findMany).not.toHaveBeenCalled();
    expect(s.prisma.cont_documentos.findMany).not.toHaveBeenCalled();
    const lotes = s.prisma.gasto_cab.findMany.mock.calls.map((c: any) => c[0].where.id.in.length);
    expect(Math.max(...lotes)).toBeLessThanOrEqual(1000);
    expect(lotes.reduce((a: number, b: number) => a + b, 0)).toBe(2500);
    const alerta = s.alertas.registrar.mock.calls.find((c: any) => c[0].clave === 'contabilidad.falta_mapeo')[0];
    expect(alerta.datos).toEqual({ conceptos: { X: 2500 } });
  });
});
