// Tests unitarios del motor de liquidación RRHH.
// Cubren los casos obligatorios del plan-rrhh.md:
//   - empleado con ingreso/egreso en mitad de mes (salario proporcional)
//   - MICROEMPRESA_80 y base IPS mínima
//   - aporta_ips=false → no genera líneas IPS
//   - anticipos APROBADOS del período → egreso
//   - cuotas de préstamo MENSUAL pendientes → egreso (las QUINCENAL_X NO en mensual)
//   - novedades AUSENCIA (1 día) y TARDANZA (por minutos)
//   - quincenales CERRADAS del período → descuento PAGO_QUINCENAL en mensual
//   - quincenal: salario por días Q1/Q2, sin IPS, sin anticipos, sin planillas
//   - falta de concepto del sistema → BadRequestException

import { BadRequestException } from '@nestjs/common';
import { RrhhEngineService } from './rrhh-engine.service';

// ── Datos de referencia ──────────────────────────────────────
const EMPRESA_ID = 'empresa-1';
const CCID = 'cc-1';

const CONCEPTOS_BASE = [
  { id: 'c-salario-base', codigo: 'SALARIO_BASE', afecta_base_ips: true, afecta_base_aguinaldo: true },
  { id: 'c-salario-prop', codigo: 'SALARIO_PROPORCIONAL', afecta_base_ips: true, afecta_base_aguinaldo: true },
  { id: 'c-ips-obrero', codigo: 'IPS_OBRERO', afecta_base_ips: false, afecta_base_aguinaldo: false },
  { id: 'c-ips-patronal', codigo: 'IPS_PATRONAL', afecta_base_ips: false, afecta_base_aguinaldo: false },
  { id: 'c-ips-admin', codigo: 'IPS_ADMIN', afecta_base_ips: false, afecta_base_aguinaldo: false },
  { id: 'c-anticipo', codigo: 'ANTICIPO_QUINCENAL', afecta_base_ips: false, afecta_base_aguinaldo: false },
  { id: 'c-cuota', codigo: 'CUOTA_PRESTAMO', afecta_base_ips: false, afecta_base_aguinaldo: false },
  { id: 'c-desc-ausencia', codigo: 'DESCUENTO_AUSENCIA', afecta_base_ips: false, afecta_base_aguinaldo: false },
  { id: 'c-desc-tardanza', codigo: 'DESCUENTO_TARDANZA', afecta_base_ips: false, afecta_base_aguinaldo: false },
  { id: 'c-pago-quincenal', codigo: 'PAGO_QUINCENAL', afecta_base_ips: false, afecta_base_aguinaldo: false },
  { id: 'c-aguinaldo', codigo: 'AGUINALDO', afecta_base_ips: false, afecta_base_aguinaldo: false },
  { id: 'c-pago-vac', codigo: 'PAGO_VACACIONES', afecta_base_ips: true, afecta_base_aguinaldo: true },
];

const PARAMETROS_BASE: Record<string, string> = {
  IPS_PORCENTAJE_OBRERO: '0.09',
  IPS_PORCENTAJE_PATRONAL: '0.165',
  IPS_PORCENTAJE_ADMIN: '0.01',
  SMLV_MENSUAL: '2800000',
  PORCENTAJE_MICROEMPRESA_BASE_IPS: '0.80',
  HORAS_LABORALES_MES: '200',
  LIMITE_DESCUENTO_SALARIO: '0.30',
};

type Empleado = {
  id: string;
  numero_empleado: string;
  centro_costo_id: string | null;
  salario_base: number;
  aporta_ips: boolean;
  regimen_ips: string;
  estado: string;
  fecha_ingreso: Date;
  fecha_egreso: Date | null;
};

const empleado = (overrides: Partial<Empleado> = {}): Empleado => ({
  id: 'emp-1',
  numero_empleado: 'EMP-001',
  centro_costo_id: CCID,
  salario_base: 3_000_000,
  aporta_ips: true,
  regimen_ips: 'GENERAL',
  estado: 'ACTIVO',
  fecha_ingreso: new Date('2020-01-01'),
  fecha_egreso: null,
  ...overrides,
});

// ── Factory de mock Prisma para el engine ────────────────────
type PrismaOverrides = {
  conceptos?: typeof CONCEPTOS_BASE;
  parametros?: Record<string, string | null>;
  empleados?: Empleado[];
  desvinculaciones?: any[];
  anticipos?: any[];
  cuotas?: any[];
  planillaDets?: any[];
  novedades?: any[];
  quincenalesCerradas?: any[];
  // Para AGUINALDO: detalles de liquidaciones MENSUALES CERRADAS del año.
  detallesLiquidacionesAnio?: any[];
  // Para VACACIONES: solicitudes APROBADAS que inician en el mes.
  solicitudesVacaciones?: any[];
};

const buildPrisma = (o: PrismaOverrides = {}) => {
  const conceptos = o.conceptos ?? CONCEPTOS_BASE;
  const parametros = { ...PARAMETROS_BASE, ...(o.parametros ?? {}) };
  return {
    rrhh_parametros_sistema: {
      findFirst: jest.fn().mockImplementation(({ where }: any) => {
        const valor = parametros[where.clave];
        if (valor == null) return Promise.resolve(null);
        return Promise.resolve({ valor });
      }),
    },
    rrhh_conceptos_liquidacion: {
      findMany: jest.fn().mockResolvedValue(conceptos),
    },
    rrhh_empleados: {
      findMany: jest.fn().mockResolvedValue(o.empleados ?? [empleado()]),
    },
    // Empleados cuyo finiquito ya liquido el salario pendiente: quedan fuera de
    // la planilla del mes para no pagar los mismos dias dos veces.
    rrhh_desvinculaciones: {
      findMany: jest.fn().mockResolvedValue(o.desvinculaciones ?? []),
    },
    rrhh_anticipos_salario: {
      findMany: jest.fn().mockResolvedValue(o.anticipos ?? []),
    },
    rrhh_prestamos_cuotas: {
      findMany: jest.fn().mockResolvedValue(o.cuotas ?? []),
    },
    rrhh_planillas_externas_det: {
      findMany: jest.fn().mockResolvedValue(o.planillaDets ?? []),
    },
    rrhh_asistencia_novedades: {
      findMany: jest.fn().mockResolvedValue(o.novedades ?? []),
    },
    rrhh_liquidaciones_cabecera: {
      findMany: jest.fn().mockResolvedValue(o.quincenalesCerradas ?? []),
    },
    rrhh_liquidaciones_detalle: {
      findMany: jest.fn().mockResolvedValue(o.detallesLiquidacionesAnio ?? []),
    },
    rrhh_vacaciones_solicitudes: {
      findMany: jest.fn().mockResolvedValue(o.solicitudesVacaciones ?? []),
    },
  };
};

const createService = (o: PrismaOverrides = {}) => {
  const prisma = buildPrisma(o);
  const service = new RrhhEngineService(prisma as any);
  return { service, prisma };
};

// ────────────────────────────────────────────────────────────
// MENSUAL
// ────────────────────────────────────────────────────────────
describe('RrhhEngineService — calcularMensual', () => {
  it('mes completo con un empleado: salario íntegro y IPS 9% / 16.5% / 1% sobre el bruto', async () => {
    const { service } = createService({ empleados: [empleado({ salario_base: 3_000_000 })] });
    const r = await service.calcularMensual(EMPRESA_ID, 2026, 5);

    expect(r.totales.cantidad_empleados).toBe(1);
    expect(r.totales.total_ingresos).toBe(3_000_000);

    const resumen = r.resumenes[0];
    expect(resumen.dias_trabajados).toBe(31); // mayo tiene 31 días
    expect(resumen.dias_mes).toBe(31);
    expect(resumen.base_ips).toBe(3_000_000);
    expect(resumen.ips_obrero).toBeCloseTo(270_000, 0);
    expect(resumen.ips_patronal).toBeCloseTo(495_000, 0);
    expect(resumen.ips_admin).toBeCloseTo(30_000, 0);
    // El neto descuenta sólo IPS obrero del empleado (patronal/admin son costo empresa, monto 0 en detalle)
    expect(resumen.neto_a_pagar).toBeCloseTo(3_000_000 - 270_000, 0);

    // Conceptos en detalle: salario_base + 3 líneas IPS
    const codigos = r.detalles.map((d) => d.concepto_codigo);
    expect(codigos).toContain('SALARIO_BASE');
    expect(codigos).toContain('IPS_OBRERO');
    expect(codigos).toContain('IPS_PATRONAL');
    expect(codigos).toContain('IPS_ADMIN');
  });

  // NOTA: usamos new Date('YYYY-MM-DD') que se parsea como UTC, exactamente
  // como Prisma devuelve las columnas @db.Date. El motor normaliza UTC → local
  // para evitar corrimientos de día en TZ != UTC.
  it('empleado con ingreso a mitad de mes: salario proporcional por días trabajados', async () => {
    const emp = empleado({ salario_base: 3_100_000, fecha_ingreso: new Date('2026-05-11') });
    const { service } = createService({ empleados: [emp] });
    const r = await service.calcularMensual(EMPRESA_ID, 2026, 5);

    const resumen = r.resumenes[0];
    // 11 al 31 inclusive = 21 días trabajados / 31 días del mes
    expect(resumen.dias_trabajados).toBe(21);
    // 3.100.000 * 21/31 ≈ 2.100.000
    expect(resumen.total_ingresos).toBeCloseTo((3_100_000 * 21) / 31, 0);

    const linea = r.detalles.find((d) => d.concepto_codigo === 'SALARIO_PROPORCIONAL');
    expect(linea).toBeDefined();
    expect(linea?.observacion).toContain('21/31 días');
  });

  it('empleado con egreso a mitad de mes: salario proporcional hasta fecha_egreso (sin corrimiento TZ)', async () => {
    const emp = empleado({ salario_base: 3_100_000, fecha_egreso: new Date('2026-05-10'), estado: 'EGRESADO' });
    const { service } = createService({ empleados: [emp] });
    const r = await service.calcularMensual(EMPRESA_ID, 2026, 5);

    const resumen = r.resumenes[0];
    // del 1 al 10 inclusive = 10 días (verificación importante: el motor debe
    // normalizar UTC para que no quede como 9 días por desfase de zona horaria)
    expect(resumen.dias_trabajados).toBe(10);
    expect(resumen.total_ingresos).toBeCloseTo((3_100_000 * 10) / 31, 0);
  });

  it('si el finiquito ya pagó el salario pendiente, el empleado NO entra a la planilla', async () => {
    // Caso real de produccion: renuncia con ultimo dia 10/05, finiquito PAGADO
    // que liquida `salario_pendiente` como SUELDOS_JORNALES, y la liquidacion
    // mensual calculada despues incluyendo al mismo empleado por esos dias.
    // Eran los mismos dias pagados dos veces.
    const emp = empleado({ salario_base: 3_100_000, fecha_egreso: new Date('2026-05-10'), estado: 'EGRESADO' });
    const { service } = createService({
      empleados: [emp],
      desvinculaciones: [{ empleado_id: emp.id }],
    });
    const r = await service.calcularMensual(EMPRESA_ID, 2026, 5);

    expect(r.resumenes).toHaveLength(0);
  });

  it('una desvinculación ANULADA no lo saca: la consulta ya filtra, el motor no la ve', async () => {
    // Control: el filtro por estado vive en el `where` de Prisma, asi que una
    // anulada simplemente no vuelve de la consulta y el empleado sigue cobrando.
    const emp = empleado({ salario_base: 3_100_000, fecha_egreso: new Date('2026-05-10'), estado: 'EGRESADO' });
    const { service } = createService({ empleados: [emp], desvinculaciones: [] });
    const r = await service.calcularMensual(EMPRESA_ID, 2026, 5);

    expect(r.resumenes).toHaveLength(1);
    expect(r.resumenes[0].dias_trabajados).toBe(10);
  });

  it('base IPS mínima = SMLV cuando los ingresos están por debajo del SMLV', async () => {
    const emp = empleado({ salario_base: 1_500_000 }); // < SMLV (2.800.000)
    const { service } = createService({ empleados: [emp] });
    const r = await service.calcularMensual(EMPRESA_ID, 2026, 5);

    const resumen = r.resumenes[0];
    expect(resumen.base_ips).toBe(2_800_000);
    expect(resumen.ips_obrero).toBeCloseTo(2_800_000 * 0.09, 0);
  });

  it('régimen MICROEMPRESA_80 aplica 80% del SMLV como base mínima', async () => {
    const emp = empleado({ salario_base: 1_500_000, regimen_ips: 'MICROEMPRESA_80' });
    const { service } = createService({ empleados: [emp] });
    const r = await service.calcularMensual(EMPRESA_ID, 2026, 5);

    const resumen = r.resumenes[0];
    expect(resumen.base_ips).toBe(2_800_000 * 0.8); // 2.240.000
    expect(resumen.ips_obrero).toBeCloseTo(2_240_000 * 0.09, 0);
  });

  it('empleado con aporta_ips=false NO genera líneas IPS ni descuento', async () => {
    const emp = empleado({ salario_base: 3_000_000, aporta_ips: false });
    const { service } = createService({ empleados: [emp] });
    const r = await service.calcularMensual(EMPRESA_ID, 2026, 5);

    const resumen = r.resumenes[0];
    expect(resumen.ips_obrero).toBe(0);
    expect(resumen.ips_patronal).toBe(0);
    expect(resumen.ips_admin).toBe(0);
    expect(resumen.neto_a_pagar).toBe(3_000_000);

    const codigos = r.detalles.map((d) => d.concepto_codigo);
    expect(codigos).not.toContain('IPS_OBRERO');
  });

  it('anticipos APROBADOS del período se descuentan como egreso', async () => {
    const emp = empleado();
    const { service } = createService({
      empleados: [emp],
      anticipos: [{ id: 'a-1', empleado_id: emp.id, monto: 500_000, observacion: 'Mitad de mes' }],
    });
    const r = await service.calcularMensual(EMPRESA_ID, 2026, 5);

    const lineaAnt = r.detalles.find((d) => d.concepto_codigo === 'ANTICIPO_QUINCENAL');
    expect(lineaAnt).toBeDefined();
    expect(lineaAnt?.tipo).toBe('EGRESO');
    expect(lineaAnt?.monto).toBe(500_000);
    expect(lineaAnt?.origen).toBe('ANTICIPO');
    expect(lineaAnt?.referencia_origen_id).toBe('a-1');

    // El anticipo reduce el neto
    const ipsObrero = r.resumenes[0].ips_obrero;
    expect(r.resumenes[0].neto_a_pagar).toBeCloseTo(3_000_000 - ipsObrero - 500_000, 0);
  });

  it('cuotas de préstamo MENSUAL pendientes se descuentan; las quincenales NO', async () => {
    // El motor mensual filtra por periodo_tipo=MENSUAL, así que sólo las que pasamos.
    const emp = empleado();
    const { service, prisma } = createService({
      empleados: [emp],
      cuotas: [
        {
          id: 'cuota-1',
          monto: 250_000,
          numero_cuota: 3,
          prestamo: { empleado_id: emp.id },
        },
      ],
    });
    const r = await service.calcularMensual(EMPRESA_ID, 2026, 5);

    // Verifica que el filtro de Prisma efectivamente pide periodo_tipo=MENSUAL
    const callArgs = (prisma.rrhh_prestamos_cuotas.findMany as jest.Mock).mock.calls[0]?.[0];
    expect(callArgs?.where?.periodo_tipo).toBe('MENSUAL');

    const linea = r.detalles.find((d) => d.concepto_codigo === 'CUOTA_PRESTAMO');
    expect(linea?.monto).toBe(250_000);
    expect(linea?.origen).toBe('PRESTAMO');
    expect(linea?.observacion).toContain('Cuota 3');
  });

  it('novedad AUSENCIA descuenta 1 día de salario', async () => {
    const emp = empleado({ salario_base: 3_100_000 }); // diario = 100.000 en mayo
    const { service, prisma } = createService({
      empleados: [emp],
      novedades: [
        {
          id: 'nov-1',
          empleado_id: emp.id,
          tipo_novedad: 'AUSENCIA',
          fecha: new Date('2026-05-12'),
          minutos_tardanza: null,
        },
      ],
    });
    const r = await service.calcularMensual(EMPRESA_ID, 2026, 5, {
      liquidacionId: 'liq-pre-1',
    });

    const linea = r.detalles.find((d) => d.concepto_codigo === 'DESCUENTO_AUSENCIA');
    expect(linea).toBeDefined();
    expect(linea?.monto).toBeCloseTo(3_100_000 / 31, 0);

    const callArgs = (prisma.rrhh_asistencia_novedades.findMany as jest.Mock).mock.calls[0]?.[0];
    expect(callArgs?.where?.OR).toEqual([
      { preliquidacion_id: null },
      { preliquidacion_id: 'liq-pre-1' },
    ]);
  });

  it('novedad TARDANZA descuenta por minutos sobre salario_base/horas_mes/60', async () => {
    const emp = empleado({ salario_base: 3_000_000 });
    const { service } = createService({
      empleados: [emp],
      novedades: [
        {
          id: 'nov-2',
          empleado_id: emp.id,
          tipo_novedad: 'TARDANZA',
          fecha: new Date('2026-05-04'),
          minutos_tardanza: 30,
        },
      ],
    });
    const r = await service.calcularMensual(EMPRESA_ID, 2026, 5);

    const linea = r.detalles.find((d) => d.concepto_codigo === 'DESCUENTO_TARDANZA');
    // 30 minutos × (3.000.000 / (200 horas × 60 min)) = 30 × 250 = 7.500
    expect(linea).toBeDefined();
    expect(linea?.monto).toBeCloseTo((3_000_000 / (200 * 60)) * 30, 0);
  });

  it('novedad AUSENCIA_BLOQUE descuenta proporcional a minutos del bloque', async () => {
    const emp = empleado({ salario_base: 3_000_000 });
    const { service } = createService({
      empleados: [emp],
      novedades: [
        {
          id: 'nov-ab',
          empleado_id: emp.id,
          tipo_novedad: 'AUSENCIA_BLOQUE',
          fecha: new Date('2026-05-06'),
          minutos_tardanza: 120,
        },
      ],
    });
    const r = await service.calcularMensual(EMPRESA_ID, 2026, 5);

    const linea = r.detalles.find((d) => d.concepto_codigo === 'DESCUENTO_AUSENCIA');
    expect(linea).toBeDefined();
    expect(linea?.monto).toBeCloseTo((3_000_000 / (200 * 60)) * 120, 0);
  });

  it('novedad SALIDA_ANTICIPADA descuenta como tardanza por minutos', async () => {
    const emp = empleado({ salario_base: 3_000_000 });
    const { service } = createService({
      empleados: [emp],
      novedades: [
        {
          id: 'nov-sa',
          empleado_id: emp.id,
          tipo_novedad: 'SALIDA_ANTICIPADA',
          fecha: new Date('2026-05-07'),
          minutos_tardanza: 45,
        },
      ],
    });
    const r = await service.calcularMensual(EMPRESA_ID, 2026, 5);

    const linea = r.detalles.find((d) => d.concepto_codigo === 'DESCUENTO_TARDANZA');
    expect(linea).toBeDefined();
    expect(linea?.monto).toBeCloseTo((3_000_000 / (200 * 60)) * 45, 0);
  });

  it('mensual sin liquidacionId solo toma novedades no reservadas en preliquidación', async () => {
    const { service, prisma } = createService();
    await service.calcularMensual(EMPRESA_ID, 2026, 5);
    const callArgs = (prisma.rrhh_asistencia_novedades.findMany as jest.Mock).mock.calls[0]?.[0];
    expect(callArgs?.where?.preliquidacion_id).toBeNull();
  });

  it('planillas externas IMPORTADAS se incluyen como ingreso/egreso según tipo', async () => {
    const emp = empleado();
    const { service } = createService({
      empleados: [emp],
      planillaDets: [
        {
          id: 'pd-1',
          empleado_id: emp.id,
          tipo: 'INGRESO',
          monto: 200_000,
          observacion: 'Comisiones',
          planilla_id: 'pl-1',
          concepto: { id: 'c-com', codigo: 'COMISION_VENTAS' },
        },
      ],
    });
    const r = await service.calcularMensual(EMPRESA_ID, 2026, 5);

    const linea = r.detalles.find((d) => d.concepto_codigo === 'COMISION_VENTAS');
    expect(linea?.tipo).toBe('INGRESO');
    expect(linea?.monto).toBe(200_000);
    expect(linea?.origen).toBe('IMPORTADO');
  });

  it('un concepto INGRESO marcado con IPS (comisión) suma a la base aportable', async () => {
    const conceptos = [
      ...CONCEPTOS_BASE,
      { id: 'c-com', codigo: 'COMISION_VENTAS', afecta_base_ips: true, afecta_base_aguinaldo: true },
    ];
    const emp = empleado({ salario_base: 3_000_000 });
    const { service } = createService({
      empleados: [emp],
      conceptos,
      planillaDets: [
        {
          id: 'pd-com',
          empleado_id: emp.id,
          tipo: 'INGRESO',
          monto: 500_000,
          observacion: 'Comisión',
          planilla_id: 'pl-1',
          concepto: { id: 'c-com', codigo: 'COMISION_VENTAS' }, // afecta_base_ips: true
        },
      ],
    });
    const r = await service.calcularMensual(EMPRESA_ID, 2026, 5);

    const resumen = r.resumenes[0];
    // 3.000.000 (salario) + 500.000 (comisión) = 3.500.000
    expect(resumen.base_ips).toBe(3_500_000);
    expect(resumen.ips_obrero).toBeCloseTo(3_500_000 * 0.09, 0);
  });

  it('un concepto EGRESO marcado con IPS resta de la base aportable', async () => {
    const conceptos = [
      ...CONCEPTOS_BASE,
      { id: 'c-viatico', codigo: 'VIATICO_GRAVADO', afecta_base_ips: true, afecta_base_aguinaldo: false },
    ];
    // Salario alto para que la resta quede por encima del piso legal (SMLV 2.800.000)
    const emp = empleado({ salario_base: 4_000_000 });
    const { service } = createService({
      empleados: [emp],
      conceptos,
      planillaDets: [
        {
          id: 'pd-via',
          empleado_id: emp.id,
          tipo: 'EGRESO',
          monto: 400_000,
          observacion: 'Viático gravado',
          planilla_id: 'pl-1',
          concepto: { id: 'c-viatico', codigo: 'VIATICO_GRAVADO' },
        },
      ],
    });
    const r = await service.calcularMensual(EMPRESA_ID, 2026, 5);

    const resumen = r.resumenes[0];
    // 4.000.000 (salario) − 400.000 (egreso marcado IPS) = 3.600.000
    expect(resumen.base_ips).toBe(3_600_000);
    expect(resumen.ips_obrero).toBeCloseTo(3_600_000 * 0.09, 0);
  });

  it('mensual resta neto de quincenales CERRADAS del período como PAGO_QUINCENAL', async () => {
    const emp = empleado();
    const { service } = createService({
      empleados: [emp],
      quincenalesCerradas: [
        {
          id: 'liq-q1',
          resumenes: [{ empleado_id: emp.id, neto_a_pagar: 800_000 }],
        },
      ],
    });
    const r = await service.calcularMensual(EMPRESA_ID, 2026, 5);

    const linea = r.detalles.find((d) => d.concepto_codigo === 'PAGO_QUINCENAL');
    expect(linea).toBeDefined();
    expect(linea?.monto).toBe(800_000);
    expect(linea?.tipo).toBe('EGRESO');
  });

  it('falta concepto del sistema → BadRequestException con código del concepto', async () => {
    const conceptos = CONCEPTOS_BASE.filter((c) => c.codigo !== 'IPS_OBRERO');
    const { service } = createService({ conceptos });
    await expect(service.calcularMensual(EMPRESA_ID, 2026, 5)).rejects.toThrow(BadRequestException);
    await expect(service.calcularMensual(EMPRESA_ID, 2026, 5)).rejects.toThrow(/IPS_OBRERO/);
  });

  it('falta concepto PAGO_QUINCENAL pero no hay quincenales cerradas: calcula sin error', async () => {
    // Regresión: el motor exigía PAGO_QUINCENAL siempre, lo que rompía la mensual
    // de empresas que nunca habían usado quincenales. Ahora es lazy.
    const conceptos = CONCEPTOS_BASE.filter((c) => c.codigo !== 'PAGO_QUINCENAL');
    const { service } = createService({ conceptos });
    const r = await service.calcularMensual(EMPRESA_ID, 2026, 5);
    expect(r.totales.cantidad_empleados).toBe(1);
    expect(r.detalles.find((d) => d.concepto_codigo === 'PAGO_QUINCENAL')).toBeUndefined();
  });

  it('falta concepto PAGO_QUINCENAL Y hay quincenales cerradas: BadRequestException claro', async () => {
    const conceptos = CONCEPTOS_BASE.filter((c) => c.codigo !== 'PAGO_QUINCENAL');
    const { service } = createService({
      conceptos,
      quincenalesCerradas: [
        { id: 'liq-q1', resumenes: [{ empleado_id: 'emp-1', neto_a_pagar: 800_000 }] },
      ],
    });
    await expect(service.calcularMensual(EMPRESA_ID, 2026, 5)).rejects.toThrow(BadRequestException);
    await expect(service.calcularMensual(EMPRESA_ID, 2026, 5)).rejects.toThrow(/PAGO_QUINCENAL/);
  });

  it('alerta_descuento se marca cuando egresos no-IPS superan el 30% del bruto', async () => {
    const emp = empleado({ salario_base: 1_000_000 });
    const { service } = createService({
      empleados: [emp],
      anticipos: [{ id: 'a-1', empleado_id: emp.id, monto: 500_000 }], // 50% del salario
    });
    const r = await service.calcularMensual(EMPRESA_ID, 2026, 5);
    expect(r.resumenes[0].alerta_descuento).toBe(true);
    expect(r.advertencias.some((a) => a.includes('descuentos'))).toBe(true);
  });

  it('sin empleados activos: devuelve totales en cero y advertencia', async () => {
    const { service } = createService({ empleados: [] });
    const r = await service.calcularMensual(EMPRESA_ID, 2026, 5);
    expect(r.totales.cantidad_empleados).toBe(0);
    expect(r.totales.total_ingresos).toBe(0);
    expect(r.advertencias[0]).toMatch(/no hay empleados activos/i);
  });
});

// ────────────────────────────────────────────────────────────
// QUINCENAL
// ────────────────────────────────────────────────────────────
describe('RrhhEngineService — calcularQuincenal', () => {
  it('Q1: paga días 1-15 prorrateados sobre el mes; sin IPS', async () => {
    const emp = empleado({ salario_base: 3_100_000 });
    const { service } = createService({ empleados: [emp] });
    const r = await service.calcularQuincenal(EMPRESA_ID, 2026, 5, 1);

    const resumen = r.resumenes[0];
    expect(resumen.dias_trabajados).toBe(15);
    expect(resumen.total_ingresos).toBeCloseTo((3_100_000 * 15) / 31, 0);
    expect(resumen.ips_obrero).toBe(0);
    expect(resumen.ips_patronal).toBe(0);
    expect(resumen.ips_admin).toBe(0);

    const linea = r.detalles.find((d) => d.concepto_codigo === 'SALARIO_PROPORCIONAL');
    expect(linea?.observacion).toMatch(/Quincena 1/);
  });

  it('Q2: paga días 16-fin de mes prorrateados sobre el mes', async () => {
    const emp = empleado({ salario_base: 3_100_000 });
    const { service } = createService({ empleados: [emp] });
    const r = await service.calcularQuincenal(EMPRESA_ID, 2026, 5, 2);

    // del 16 al 31 = 16 días
    expect(r.resumenes[0].dias_trabajados).toBe(16);
    expect(r.resumenes[0].total_ingresos).toBeCloseTo((3_100_000 * 16) / 31, 0);
  });

  it('quincenal sólo descuenta cuotas con periodo_tipo QUINCENAL_X correspondiente', async () => {
    const emp = empleado();
    const { service, prisma } = createService({
      empleados: [emp],
      cuotas: [
        {
          id: 'cq-1',
          monto: 100_000,
          numero_cuota: 4,
          prestamo: { empleado_id: emp.id },
        },
      ],
    });
    await service.calcularQuincenal(EMPRESA_ID, 2026, 5, 2);

    const callArgs = (prisma.rrhh_prestamos_cuotas.findMany as jest.Mock).mock.calls[0]?.[0];
    expect(callArgs?.where?.periodo_tipo).toBe('QUINCENAL_2');
  });

  it('quincenal sin liquidacionId solo toma novedades no reservadas', async () => {
    const { service, prisma } = createService();
    await service.calcularQuincenal(EMPRESA_ID, 2026, 5, 1);
    const callArgs = (prisma.rrhh_asistencia_novedades.findMany as jest.Mock).mock.calls[0]?.[0];
    expect(callArgs?.where?.preliquidacion_id).toBeNull();
  });

  it('quincenal con liquidacionId puede reutilizar novedades ya reservadas por la misma preliquidación', async () => {
    const { service, prisma } = createService();
    await service.calcularQuincenal(EMPRESA_ID, 2026, 5, 1, { liquidacionId: 'liq-pre-2' });
    const callArgs = (prisma.rrhh_asistencia_novedades.findMany as jest.Mock).mock.calls[0]?.[0];
    expect(callArgs?.where?.OR).toEqual([
      { preliquidacion_id: null },
      { preliquidacion_id: 'liq-pre-2' },
    ]);
  });

  it('quincenal NO consulta anticipos ni planillas externas', async () => {
    const { service, prisma } = createService();
    await service.calcularQuincenal(EMPRESA_ID, 2026, 5, 1);
    expect((prisma.rrhh_anticipos_salario.findMany as jest.Mock).mock.calls.length).toBe(0);
    expect((prisma.rrhh_planillas_externas_det.findMany as jest.Mock).mock.calls.length).toBe(0);
  });

  it('quincena inválida → BadRequestException', async () => {
    const { service } = createService();
    // @ts-expect-error: forzamos un valor inválido
    await expect(service.calcularQuincenal(EMPRESA_ID, 2026, 5, 3)).rejects.toThrow(
      BadRequestException,
    );
  });
});

// ────────────────────────────────────────────────────────────
// AGUINALDO (Art. 243 CT — 1/12 de remuneraciones brutas anuales)
// ────────────────────────────────────────────────────────────
describe('RrhhEngineService — calcularAguinaldo', () => {
  it('suma SALARIO_BASE de las mensuales CERRADAS y divide por 12', async () => {
    const emp = empleado({ id: 'emp-1', salario_base: 3_000_000 });
    // 12 meses × 3_000_000 = 36_000_000 → aguinaldo = 3_000_000
    const detalles = Array.from({ length: 12 }, () => ({
      empleado_id: 'emp-1',
      monto: 3_000_000,
    }));
    const { service } = createService({
      empleados: [emp],
      detallesLiquidacionesAnio: detalles,
    });

    const r = await service.calcularAguinaldo(EMPRESA_ID, 2026);

    expect(r.totales.cantidad_empleados).toBe(1);
    expect(r.resumenes[0].salario_bruto).toBe(3_000_000);
    expect(r.resumenes[0].neto_a_pagar).toBe(3_000_000);
    // El aguinaldo NO aporta IPS
    expect(r.resumenes[0].base_ips).toBe(0);
    expect(r.resumenes[0].ips_obrero).toBe(0);
    expect(r.totales.total_ips_obrero).toBe(0);
    expect(r.totales.total_ips_patronal).toBe(0);
    // Línea única con el concepto AGUINALDO
    const lineas = r.detalles.filter((d) => d.empleado_id === 'emp-1');
    expect(lineas).toHaveLength(1);
    expect(lineas[0].concepto_codigo).toBe('AGUINALDO');
    expect(lineas[0].base_calculo).toBe(36_000_000);
    expect(lineas[0].monto).toBe(3_000_000);
  });

  it('empleado ingresado a mitad de año: aguinaldo proporcional (sólo meses trabajados)', async () => {
    // Ingresa en julio → 6 mensuales cerradas × 3_000_000 = 18_000_000 → 1_500_000
    const emp = empleado({
      id: 'emp-1',
      salario_base: 3_000_000,
      fecha_ingreso: new Date('2026-07-01'),
    });
    const detalles = Array.from({ length: 6 }, () => ({
      empleado_id: 'emp-1',
      monto: 3_000_000,
    }));
    const { service } = createService({
      empleados: [emp],
      detallesLiquidacionesAnio: detalles,
    });
    const r = await service.calcularAguinaldo(EMPRESA_ID, 2026);
    expect(r.resumenes[0].neto_a_pagar).toBe(1_500_000);
  });

  it('empleado sin ninguna mensual cerrada → aguinaldo en 0 con advertencia', async () => {
    const { service } = createService({
      empleados: [empleado({ id: 'emp-1' })],
      detallesLiquidacionesAnio: [],
    });
    const r = await service.calcularAguinaldo(EMPRESA_ID, 2026);
    expect(r.resumenes[0].neto_a_pagar).toBe(0);
    expect(r.advertencias.some((a) => a.includes('sin remuneraciones cerradas'))).toBe(true);
  });

  it('falta concepto AGUINALDO sembrado → BadRequestException', async () => {
    const conceptosSinAguinaldo = CONCEPTOS_BASE.filter((c) => c.codigo !== 'AGUINALDO');
    const { service } = createService({ conceptos: conceptosSinAguinaldo });
    await expect(service.calcularAguinaldo(EMPRESA_ID, 2026)).rejects.toThrow(BadRequestException);
  });

  it('suma horas extras y comisiones (conceptos con afecta_base_aguinaldo=true)', async () => {
    // SALARIO_BASE (12m × 2M) + HORAS_EXTRA (5 detalles × 200k) = 24M + 1M = 25M / 12 ≈ 2_083_333.33
    const conceptosConHoraExtra = [
      ...CONCEPTOS_BASE,
      { id: 'c-hext-50', codigo: 'HORAS_EXTRA_50', afecta_base_ips: true, afecta_base_aguinaldo: true },
    ];
    const emp = empleado({ id: 'emp-1', salario_base: 2_000_000 });
    const detalles = [
      ...Array.from({ length: 12 }, () => ({ empleado_id: 'emp-1', monto: 2_000_000 })),
      ...Array.from({ length: 5 }, () => ({ empleado_id: 'emp-1', monto: 200_000 })),
    ];
    const { service } = createService({
      empleados: [emp],
      conceptos: conceptosConHoraExtra,
      detallesLiquidacionesAnio: detalles,
    });
    const r = await service.calcularAguinaldo(EMPRESA_ID, 2026);
    // 25_000_000 / 12 = 2_083_333.33 (redondeo a 2 decimales)
    expect(r.resumenes[0].neto_a_pagar).toBeCloseTo(2_083_333.33, 1);
    expect(r.detalles[0].base_calculo).toBe(25_000_000);
  });
});

// ────────────────────────────────────────────────────────────
// VACACIONES (Art. 219 CT — pago de vacaciones gozadas)
// ────────────────────────────────────────────────────────────
describe('RrhhEngineService — calcularVacaciones', () => {
  it('una solicitud aprobada con afecta_salario=true: paga días × salario_diario y aplica IPS', async () => {
    const emp = empleado({ id: 'emp-1', salario_base: 3_000_000 });
    const sol = {
      id: 'sol-1',
      empleado_id: 'emp-1',
      tipo: 'VACACION',
      estado: 'APROBADO',
      afecta_salario: true,
      dias_habiles: 12,
      fecha_inicio: new Date('2026-05-10'),
      fecha_fin: new Date('2026-05-25'),
      empleado: emp,
    };
    const { service } = createService({
      empleados: [emp],
      solicitudesVacaciones: [sol],
    });
    const r = await service.calcularVacaciones(EMPRESA_ID, 2026, 5);

    expect(r.totales.cantidad_empleados).toBe(1);
    // salario_diario = 3_000_000 / 30 = 100_000 → 12 × 100_000 = 1_200_000
    const resumen = r.resumenes[0];
    expect(resumen.total_ingresos).toBe(1_200_000);
    expect(resumen.dias_trabajados).toBe(12);
    // IPS sobre 1_200_000: 9% obrero = 108_000
    expect(resumen.base_ips).toBe(1_200_000);
    expect(resumen.ips_obrero).toBeCloseTo(108_000, 0);
    expect(resumen.ips_patronal).toBeCloseTo(198_000, 0);
    expect(resumen.ips_admin).toBeCloseTo(12_000, 0);
    expect(resumen.neto_a_pagar).toBeCloseTo(1_200_000 - 108_000, 0);
    // El detalle del concepto PAGO_VACACIONES referencia la solicitud
    const linea = r.detalles.find((d) => d.concepto_codigo === 'PAGO_VACACIONES');
    expect(linea?.referencia_origen_id).toBe('sol-1');
  });

  it('múltiples solicitudes del mismo empleado en el mes se acumulan en una sola liquidación', async () => {
    const emp = empleado({ id: 'emp-1', salario_base: 3_000_000 });
    const sols = [
      {
        id: 'sol-1', empleado_id: 'emp-1', tipo: 'VACACION', estado: 'APROBADO',
        afecta_salario: true, dias_habiles: 5,
        fecha_inicio: new Date('2026-05-05'), fecha_fin: new Date('2026-05-09'),
        empleado: emp,
      },
      {
        id: 'sol-2', empleado_id: 'emp-1', tipo: 'VACACION', estado: 'APROBADO',
        afecta_salario: true, dias_habiles: 7,
        fecha_inicio: new Date('2026-05-20'), fecha_fin: new Date('2026-05-28'),
        empleado: emp,
      },
    ];
    const { service } = createService({ empleados: [emp], solicitudesVacaciones: sols });
    const r = await service.calcularVacaciones(EMPRESA_ID, 2026, 5);
    // 12 días × 100_000 = 1_200_000
    expect(r.resumenes).toHaveLength(1);
    expect(r.resumenes[0].dias_trabajados).toBe(12);
    expect(r.resumenes[0].total_ingresos).toBe(1_200_000);
  });

  it('empleado con aporta_ips=false: paga vacaciones sin IPS', async () => {
    const emp = empleado({ id: 'emp-1', salario_base: 3_000_000, aporta_ips: false });
    const sol = {
      id: 'sol-1', empleado_id: 'emp-1', tipo: 'VACACION', estado: 'APROBADO',
      afecta_salario: true, dias_habiles: 12,
      fecha_inicio: new Date('2026-05-10'), fecha_fin: new Date('2026-05-25'),
      empleado: emp,
    };
    const { service } = createService({ empleados: [emp], solicitudesVacaciones: [sol] });
    const r = await service.calcularVacaciones(EMPRESA_ID, 2026, 5);
    expect(r.resumenes[0].base_ips).toBe(0);
    expect(r.resumenes[0].ips_obrero).toBe(0);
    expect(r.resumenes[0].neto_a_pagar).toBe(1_200_000);
  });

  it('sin solicitudes en el mes → resumenes vacíos y advertencia', async () => {
    const { service } = createService({ solicitudesVacaciones: [] });
    const r = await service.calcularVacaciones(EMPRESA_ID, 2026, 5);
    expect(r.resumenes).toHaveLength(0);
    expect(r.totales.cantidad_empleados).toBe(0);
    expect(r.advertencias.some((a) => a.toLowerCase().includes('vacaciones aprobadas'))).toBe(true);
  });

  it('falta concepto PAGO_VACACIONES → BadRequestException', async () => {
    const conceptosSinPagoVac = CONCEPTOS_BASE.filter((c) => c.codigo !== 'PAGO_VACACIONES');
    const { service } = createService({ conceptos: conceptosSinPagoVac });
    await expect(service.calcularVacaciones(EMPRESA_ID, 2026, 5)).rejects.toThrow(
      BadRequestException,
    );
  });
});

// ────────────────────────────────────────────────────────────
// calcularIpsSobreBase (reutilizado por el recálculo de ajustes manuales)
// ────────────────────────────────────────────────────────────
describe('RrhhEngineService — calcularIpsSobreBase', () => {
  const ctx = { fechaCorte: new Date(2026, 5, 0), diasTrabajados: 31, diasMes: 31 };

  it('aplica los porcentajes sobre la base cuando supera el piso legal', async () => {
    const { service } = createService();
    const ips = await service.calcularIpsSobreBase(EMPRESA_ID, { aporta_ips: true, regimen_ips: 'GENERAL' }, 3_500_000, ctx);
    expect(ips.baseIps).toBe(3_500_000);
    expect(ips.ipsObrero).toBeCloseTo(315_000, 0);
    expect(ips.ipsPatronal).toBeCloseTo(577_500, 0);
    expect(ips.ipsAdmin).toBeCloseTo(35_000, 0);
  });

  it('aplica el piso legal (SMLV) cuando la base neteada queda por debajo', async () => {
    const { service } = createService();
    const ips = await service.calcularIpsSobreBase(EMPRESA_ID, { aporta_ips: true, regimen_ips: 'GENERAL' }, 2_000_000, ctx);
    expect(ips.baseIps).toBe(2_800_000);
    expect(ips.ipsObrero).toBeCloseTo(252_000, 0);
  });

  it('empleado que no aporta IPS devuelve todo en cero', async () => {
    const { service } = createService();
    const ips = await service.calcularIpsSobreBase(EMPRESA_ID, { aporta_ips: false, regimen_ips: 'GENERAL' }, 3_000_000, ctx);
    expect(ips).toEqual({ baseIps: 0, ipsObrero: 0, ipsPatronal: 0, ipsAdmin: 0 });
  });
});
