import { BadRequestException } from '@nestjs/common';
import { FaltaMapeoError } from './falta-mapeo.error';
import { MapeoCuentasService } from './mapeo-cuentas.service';
import { CONCEPTOS_CON_MODO, CONCEPTOS_NUEVOS, RECOMENDACIONES } from './recomendaciones-mapeo';
import { MAPEO_DEFAULT, PLAN_CUENTAS_PARAGUAY } from '../seeds/plan-cuentas-paraguay.seed';

const servicio = (mapeo: any) => {
  const s = Object.create(MapeoCuentasService.prototype) as any;
  s.prisma = { cont_mapeo_cuentas: { findUnique: jest.fn().mockResolvedValue(mapeo) } };
  return s as MapeoCuentasService;
};

describe('getCuentaPorConcepto sin respaldo', () => {
  it('sin mapeo lanza FaltaMapeoError con empresa y concepto', async () => {
    const err = await servicio(null).getCuentaPorConcepto('emp', 'CHEQUES_EN_CARTERA').catch((e) => e);
    expect(err).toBeInstanceOf(FaltaMapeoError);
    expect(err.empresaId).toBe('emp');
    expect(err.conceptos).toEqual(['CHEQUES_EN_CARTERA']);
    expect(err.message).toMatch(/^No hay cuenta mapeada para el concepto/);
    expect(err.motivo).toBe('SIN_MAPEO');
  });

  it('[RF-2] cuenta desactivada también es falta de mapeo', async () => {
    const err = await servicio({ cuenta_id: 'c1', modo: 'CUENTA', cuenta: { codigo: 'X', active: false, acepta_movimientos: true } })
      .getCuentaPorConcepto('emp', 'INVENTARIO')
      .catch((e) => e);
    expect(err).toBeInstanceOf(FaltaMapeoError);
    expect(err.message).toMatch(/desactivada/);
    expect(err.motivo).toBe('CUENTA_INUTILIZABLE');
    expect(err.cuentaCodigo).toBe('X');
  });

  it('cuenta de agrupación es CUENTA_INUTILIZABLE', async () => {
    const err = await servicio({ cuenta_id: 'c1', modo: 'CUENTA', cuenta: { codigo: 'Y', active: true, acepta_movimientos: false } })
      .getCuentaPorConcepto('emp', 'INVENTARIO')
      .catch((e) => e);
    expect(err).toBeInstanceOf(FaltaMapeoError);
    expect(err.message).toMatch(/agrupación/);
    expect(err.motivo).toBe('CUENTA_INUTILIZABLE');
    expect(err.cuentaCodigo).toBe('Y');
  });
});

describe('resolverConcepto', () => {
  it('devuelve el modo sumar a la principal', async () => {
    const r = await servicio({ cuenta_id: null, modo: 'SUMAR_A_PRINCIPAL', cuenta: null }).resolverConcepto('emp', 'IVA_NO_DEDUCIBLE');
    expect(r).toMatchObject({ modo: 'SUMAR_A_PRINCIPAL' });
  });
  it('devuelve la cuenta', async () => {
    const r = await servicio({ cuenta_id: 'c1', modo: 'CUENTA', cuenta: { codigo: 'X', active: true, acepta_movimientos: true } }).resolverConcepto('emp', 'IMPORTACION_GASTO');
    expect(r).toMatchObject({ modo: 'CUENTA', cuentaId: 'c1' });
  });
  it('sin mapeo lanza FaltaMapeoError', async () => {
    await expect(servicio(null).resolverConcepto('emp', 'IMPORTACION_GASTO')).rejects.toBeInstanceOf(FaltaMapeoError);
  });
});

describe('recomendaciones', () => {
  it('cada concepto nuevo existe en MAPEO_DEFAULT y tiene recomendación', () => {
    for (const c of CONCEPTOS_NUEVOS) {
      expect(Object.keys(MAPEO_DEFAULT)).toContain(c);
      expect(RECOMENDACIONES[c]).toBeDefined();
    }
  });
  it('solo los conceptos con modo recomiendan sumar a la principal', () => {
    for (const [c, r] of Object.entries(RECOMENDACIONES)) {
      if ('modo' in r) expect(CONCEPTOS_CON_MODO.has(c)).toBe(true);
    }
  });
  it('compra y pago al proveedor de una importación no recomiendan el mismo concepto (evita el doble ingreso)', () => {
    const compra = RECOMENDACIONES.IMPORTACION_COMPRA as { concepto: string };
    const pago = RECOMENDACIONES.IMPORTACION_PAGO_PROVEEDOR as { concepto: string };
    expect(compra.concepto).toBe('IMPORTACIONES_EN_CURSO');
    expect(pago.concepto).toBe('PROVEEDORES_EXTERIOR');
    expect(pago.concepto).not.toBe(compra.concepto);
  });
  it('empresas nuevas: compra y pago al proveedor de una importación no comparten cuenta en MAPEO_DEFAULT', () => {
    expect(MAPEO_DEFAULT.IMPORTACION_PAGO_PROVEEDOR).not.toBe(MAPEO_DEFAULT.IMPORTACION_COMPRA);
    // Alineado con la recomendación: la cuenta de proveedores del exterior del plan estándar.
    expect(MAPEO_DEFAULT.IMPORTACION_PAGO_PROVEEDOR).toBe(MAPEO_DEFAULT.PROVEEDORES_EXTERIOR);
    const cuenta = PLAN_CUENTAS_PARAGUAY.find((c) => c.codigo === MAPEO_DEFAULT.IMPORTACION_PAGO_PROVEEDOR);
    expect(cuenta?.acepta_movimientos).toBe(true);
  });
});

describe('constancia del mapeo', () => {
  const crear = () => {
    const s = Object.create(MapeoCuentasService.prototype) as any;
    s.logger = { warn: jest.fn(), error: jest.fn(), log: jest.fn() };
    s.avisos = { sincronizar: jest.fn() };
    s.prisma = {
      cont_plan_cuentas: { findFirst: jest.fn().mockResolvedValue({ id: 'c1', codigo: '1.01', active: true, acepta_movimientos: true }) },
      cont_mapeo_cuentas: {
        findUnique: jest.fn().mockResolvedValue({ cuenta_id: 'c0', modo: 'CUENTA', cuenta: { codigo: '9.99' } }),
        findMany: jest.fn().mockResolvedValue([{ concepto: 'IMPORTACIONES_EN_CURSO', cuenta: { codigo: '1.01.04.11', descripcion: 'Imp. en curso' } }]),
        upsert: jest.fn().mockResolvedValue({}),
        updateMany: jest.fn().mockResolvedValue({ count: 5 }),
      },
      audit_logs: { create: jest.fn(), createMany: jest.fn() },
    };
    return s;
  };

  it('guardar marca CONTADOR, quién y cuándo, la recomendación vigente y deja audit', async () => {
    const s = crear();
    await s.actualizarBulk('emp', 'u1', [{ concepto: 'IMPORTACION_COMPRA', cuenta_id: 'c1' }]);
    const data = s.prisma.cont_mapeo_cuentas.upsert.mock.calls[0][0];
    expect(data.update).toMatchObject({ cuenta_id: 'c1', modo: 'CUENTA', origen: 'CONTADOR', confirmado_por: 'u1', cuenta_recomendada_codigo: '1.01.04.11' });
    expect(data.update.confirmado_at).toBeInstanceOf(Date);
    expect(s.prisma.audit_logs.create.mock.calls[0][0].data).toMatchObject({
      action: 'CONT_MAPEO_CAMBIO', entity_type: 'cont_mapeo_cuentas',
      old_value: { cuenta_codigo: '9.99', modo: 'CUENTA' },
      new_value: { concepto: 'IMPORTACION_COMPRA', cuenta_codigo: '1.01', modo: 'CUENTA', recomendado: '1.01.04.11' },
    });
    expect(s.avisos.sincronizar).toHaveBeenCalledWith('emp');
  });

  it('el modo sumar solo se acepta en los conceptos que lo admiten', async () => {
    const s = crear();
    await expect(s.actualizarBulk('emp', 'u1', [{ concepto: 'INVENTARIO', cuenta_id: null, modo: 'SUMAR_A_PRINCIPAL' }])).rejects.toThrow(/no admite/);
  });

  it('confirmar todo marca cada pendiente con quién, cuándo y la recomendación vigente, y deja la lista en el audit', async () => {
    const s = crear();
    s.prisma.$transaction = jest.fn().mockImplementation((fn: any) => fn(s.prisma));
    s.prisma.cont_mapeo_cuentas.updateMany = jest.fn().mockResolvedValue({ count: 1 });
    s.prisma.cont_mapeo_cuentas.findMany = jest.fn().mockImplementation(({ where }: any) =>
      Promise.resolve(
        where.confirmado_at === null
          ? [
              { id: 'm1', concepto: 'IMPORTACION_COMPRA', modo: 'CUENTA', cuenta: { codigo: '1.01.04.11' } },
              { id: 'm2', concepto: 'IVA_NO_DEDUCIBLE', modo: 'SUMAR_A_PRINCIPAL', cuenta: null },
            ]
          : [{ concepto: 'IMPORTACIONES_EN_CURSO', cuenta: { codigo: '1.01.04.11', descripcion: 'Imp. en curso' } }],
      ),
    );
    const r = await s.confirmarTodo('emp', 'u1');
    expect(r).toEqual({ confirmados: 2 });

    const updates = s.prisma.cont_mapeo_cuentas.updateMany.mock.calls.map((c: any) => c[0]);
    expect(updates).toHaveLength(2);
    expect(updates[0]).toMatchObject({
      where: { id: 'm1', confirmado_at: null },
      data: { confirmado_por: 'u1', cuenta_recomendada_codigo: '1.01.04.11' },
    });
    expect(updates[0].data.confirmado_at).toBeInstanceOf(Date);
    expect(updates[1]).toMatchObject({ where: { id: 'm2', confirmado_at: null }, data: { cuenta_recomendada_codigo: null } });

    const audit = s.prisma.audit_logs.create.mock.calls[0][0].data;
    expect(audit).toMatchObject({ action: 'CONT_MAPEO_CONFIRMAR_TODO', user_id: 'u1' });
    expect(audit.new_value).toEqual({
      confirmados: 2,
      conceptos: [
        { concepto: 'IMPORTACION_COMPRA', cuenta_codigo: '1.01.04.11', modo: 'CUENTA', recomendado: '1.01.04.11' },
        { concepto: 'IVA_NO_DEDUCIBLE', cuenta_codigo: null, modo: 'SUMAR_A_PRINCIPAL', recomendado: null },
      ],
    });
    expect(s.prisma.$transaction).toHaveBeenCalled();
    expect(s.avisos.sincronizar).toHaveBeenCalledWith('emp');
  });

  it('confirmar todo sin pendientes no escribe nada pero sincroniza', async () => {
    const s = crear();
    s.prisma.$transaction = jest.fn().mockImplementation((fn: any) => fn(s.prisma));
    s.prisma.cont_mapeo_cuentas.findMany = jest.fn().mockResolvedValue([]);
    const r = await s.confirmarTodo('emp', 'u1');
    expect(r).toEqual({ confirmados: 0 });
    expect(s.prisma.cont_mapeo_cuentas.updateMany).not.toHaveBeenCalled();
    expect(s.prisma.audit_logs.create).not.toHaveBeenCalled();
    expect(s.avisos.sincronizar).toHaveBeenCalledWith('emp');
  });

  it('si sincronizar falla después de guardar, devuelve lo guardado y lo loguea (sin 500)', async () => {
    const s = crear();
    s.avisos.sincronizar = jest.fn().mockRejectedValue(new Error('alertas caídas'));
    await expect(s.actualizarBulk('emp', 'u1', [{ concepto: 'IMPORTACION_COMPRA', cuenta_id: 'c1' }])).resolves.toHaveLength(1);
    expect(s.logger.error).toHaveBeenCalledWith(expect.stringMatching(/alertas caídas/));
  });

  it('si falla un item y también sincronizar, sale el error del item, no el de la sincronización', async () => {
    const s = crear();
    s.avisos.sincronizar = jest.fn().mockRejectedValue(new Error('alertas caídas'));
    s.prisma.cont_plan_cuentas.findFirst = jest.fn().mockResolvedValue(null);
    await expect(s.actualizarBulk('emp', 'u1', [{ concepto: 'VENTAS', cuenta_id: 'zz' }])).rejects.toThrow(/no existe/);
  });

  it.each([
    [{ concepto: 'INVENTARIO', cuenta_id: 'c1', modo: 'OTRO' }, /modo/i],
    [{ concepto: '', cuenta_id: 'c1' }, /concepto/i],
    [{ concepto: '   ', cuenta_id: 'c1' }, /concepto/i],
    [{ concepto: 123, cuenta_id: 'c1' }, /concepto/i],
  ])('item inválido %j es BadRequest (no 500 por el CHECK)', async (item, msg) => {
    const s = crear();
    const p = s.actualizarBulk('emp', 'u1', [item]);
    await expect(p).rejects.toBeInstanceOf(BadRequestException);
    await expect(s.actualizarBulk('emp', 'u1', [item])).rejects.toThrow(msg);
    expect(s.prisma.cont_mapeo_cuentas.upsert).not.toHaveBeenCalled();
  });

  it('recomendaciones devuelve la de cada concepto, también sin fila', async () => {
    const s = crear();
    const r = await s.recomendaciones('emp');
    expect(Object.keys(r).sort()).toEqual(Object.keys(RECOMENDACIONES).sort());
    expect(r.IMPORTACION_COMPRA).toEqual({ texto: '1.01.04.11 Imp. en curso (la de IMPORTACIONES_EN_CURSO)', codigo: '1.01.04.11' });
    expect(r.IMPORTACION_ANTICIPO_IRE.codigo).toBeNull();
    expect(r.IVA_NO_DEDUCIBLE.codigo).toBeNull();
  });

  it('listar trae estado, quién confirmó y si difiere de la recomendación', async () => {
    const s = crear();
    s.prisma.cont_mapeo_cuentas.findMany = jest.fn()
      .mockResolvedValueOnce([
        { concepto: 'IMPORTACIONES_EN_CURSO', modo: 'CUENTA', confirmado_por: null, cuenta: { codigo: '1.01.04.11', descripcion: 'Imp. en curso' } },
        { concepto: 'IMPORTACION_COMPRA', modo: 'CUENTA', confirmado_por: 'u1', cuenta: { codigo: '9.99', descripcion: 'Otra' } },
      ]);
    s.prisma.usuario = { findMany: jest.fn().mockResolvedValue([{ id: 'u1', nombres: 'Ana', apellidos: 'Paz' }]) };
    const filas = await s.listar('emp');
    expect(filas[0].distinto_de_recomendacion).toBe(false);
    expect(filas[0].recomendacion).toBeNull();
    expect(filas[1]).toMatchObject({ confirmado_por_nombre: 'Ana Paz', distinto_de_recomendacion: true });
  });

  it('listar: recomendación que apunta a un concepto sin mapear no cuenta como distinta', async () => {
    const s = crear();
    s.prisma.cont_mapeo_cuentas.findMany = jest.fn().mockResolvedValueOnce([
      { concepto: 'IMPORTACION_COMPRA', modo: 'CUENTA', confirmado_por: null, cuenta: { codigo: '9.99', descripcion: 'Otra' } },
      { concepto: 'IVA_NO_DEDUCIBLE', modo: 'CUENTA', confirmado_por: null, cuenta: { codigo: '5.01', descripcion: 'Gasto' } },
      { concepto: 'IMPORTACION_GASTO', modo: 'SUMAR_A_PRINCIPAL', confirmado_por: null, cuenta: null },
    ]);
    s.prisma.usuario = { findMany: jest.fn().mockResolvedValue([]) };
    const filas = await s.listar('emp');
    expect(filas[0].recomendacion.codigo).toBeNull();
    expect(filas[0].distinto_de_recomendacion).toBe(false);
    expect(filas[1].distinto_de_recomendacion).toBe(true);
    expect(filas[2].distinto_de_recomendacion).toBe(false);
  });

  it('guardar SUMAR_A_PRINCIPAL en concepto permitido deja cuenta_id null', async () => {
    const s = crear();
    await s.actualizarBulk('emp', 'u1', [{ concepto: 'IVA_NO_DEDUCIBLE', cuenta_id: null, modo: 'SUMAR_A_PRINCIPAL' }]);
    expect(s.prisma.cont_mapeo_cuentas.upsert.mock.calls[0][0].update).toMatchObject({ cuenta_id: null, modo: 'SUMAR_A_PRINCIPAL', origen: 'CONTADOR' });
  });

  it('cuenta_id null con modo CUENTA es BadRequest', async () => {
    const s = crear();
    await expect(s.actualizarBulk('emp', 'u1', [{ concepto: 'INVENTARIO', cuenta_id: null }])).rejects.toBeInstanceOf(BadRequestException);
  });

  it('la recomendación refleja lo guardado antes en el mismo bulk', async () => {
    const s = crear();
    s.prisma.cont_plan_cuentas.findFirst = jest.fn()
      .mockResolvedValueOnce({ id: 'cA', codigo: '1.01.04.99', descripcion: 'Nueva en curso' })
      .mockResolvedValueOnce({ id: 'cB', codigo: '1.01', descripcion: 'X' });
    await s.actualizarBulk('emp', 'u1', [
      { concepto: 'IMPORTACIONES_EN_CURSO', cuenta_id: 'cA' },
      { concepto: 'IMPORTACION_COMPRA', cuenta_id: 'cB' },
    ]);
    expect(s.prisma.cont_mapeo_cuentas.upsert.mock.calls[1][0].update.cuenta_recomendada_codigo).toBe('1.01.04.99');
  });

  it('sincroniza las alertas aunque falle un item a mitad del bulk', async () => {
    const s = crear();
    s.prisma.cont_plan_cuentas.findFirst = jest.fn().mockResolvedValueOnce({ id: 'c1', codigo: '1.01', descripcion: 'X' }).mockResolvedValueOnce(null);
    await expect(s.actualizarBulk('emp', 'u1', [
      { concepto: 'INVENTARIO', cuenta_id: 'c1' },
      { concepto: 'VENTAS', cuenta_id: 'zz' },
    ])).rejects.toThrow();
    expect(s.prisma.cont_mapeo_cuentas.upsert).toHaveBeenCalledTimes(1);
    expect(s.avisos.sincronizar).toHaveBeenCalledWith('emp');
  });
});
