import {
  BadRequestException,
  ConflictException,
  Injectable,
  Logger,
  NotFoundException,
  Optional,
} from '@nestjs/common';
import { Prisma } from '@prisma/client';
import * as XLSX from 'xlsx';
import { AuditService } from '../../audit/audit.service';
import { ContabilidadIntegracionService } from '../../contabilidad/services/integracion.service';
import { PrismaService } from '../../prisma/prisma.service';
import {
  AccionLiquidacionDto,
  AgregarLineaManualDto,
  CreateLiquidacionDto,
  QueryLiquidacionesDto,
} from '../dto/liquidaciones.dto';
import { RrhhEngineService } from '../engine/rrhh-engine.service';
import { RrhhComisionesService } from '../comisiones/rrhh-comisiones.service';

const r2 = (n: number) => Math.round((n + Number.EPSILON) * 100) / 100;
const num = (v: unknown, fb = 0) => {
  const n = Number(v);
  return Number.isFinite(n) ? n : fb;
};

@Injectable()
export class LiquidacionesService {
  private readonly contabilidadLogger = new Logger('LiquidacionesService.contabilidad');

  constructor(
    private readonly prisma: PrismaService,
    private readonly engine: RrhhEngineService,
    private readonly auditService: AuditService,
    private readonly contabilidadIntegracion: ContabilidadIntegracionService,
    @Optional() private readonly comisiones?: RrhhComisionesService,
  ) {}

  private async getParametroVigenteRaw(
    empresaId: string,
    clave: string,
    fecha: Date,
  ): Promise<string | null> {
    const p = await this.prisma.rrhh_parametros_sistema.findFirst({
      where: {
        empresa_id: empresaId,
        clave,
        vigencia_desde: { lte: fecha },
        OR: [{ vigencia_hasta: null }, { vigencia_hasta: { gte: fecha } }],
      },
      orderBy: { vigencia_desde: 'desc' },
    });
    return p?.valor ?? null;
  }

  private async getParametroVigente(
    empresaId: string,
    clave: string,
    fecha: Date,
  ): Promise<number | null> {
    const raw = await this.getParametroVigenteRaw(empresaId, clave, fecha);
    if (raw == null) return null;
    const v = Number(raw);
    return Number.isFinite(v) ? v : null;
  }

  private async getParametroBooleanoVigente(
    empresaId: string,
    clave: string,
    fecha: Date,
  ): Promise<boolean | null> {
    const raw = await this.getParametroVigenteRaw(empresaId, clave, fecha);
    if (raw == null) return null;
    const value = raw.trim().toLowerCase();
    if (['1', 'true', 'si', 'sí', 'yes'].includes(value)) return true;
    if (['0', 'false', 'no'].includes(value)) return false;
    return null;
  }

  private isEmbargoConcepto(concepto?: {
    codigo?: string | null;
    nombre?: string | null;
    categoria?: string | null;
  }): boolean {
    const codigo = (concepto?.codigo ?? '').toUpperCase();
    const nombre = (concepto?.nombre ?? '').toUpperCase();
    const categoria = (concepto?.categoria ?? '').toUpperCase();
    return categoria === 'EMBARGO' || codigo.includes('EMBARGO') || nombre.includes('EMBARGO');
  }

  private isIpsConcepto(concepto?: { codigo?: string | null; categoria?: string | null }): boolean {
    const codigo = (concepto?.codigo ?? '').toUpperCase();
    const categoria = (concepto?.categoria ?? '').toUpperCase();
    return categoria === 'IPS' || codigo.startsWith('IPS_');
  }

  private async validarLimitesDescuentoEmbargo(params: {
    liquidacionId: string;
    empresaId: string;
    periodoAnio: number;
    periodoMes: number;
    resumenes: Array<{
      empleado_id: string;
      total_ingresos: Prisma.Decimal | number | string | null;
      empleado?: { numero_empleado?: string | null } | null;
    }>;
  }) {
    const { liquidacionId, empresaId, periodoAnio, periodoMes, resumenes } = params;
    const fechaCorte = new Date(periodoAnio, periodoMes, 0);
    const limiteDescuento =
      (await this.getParametroVigente(empresaId, 'LIMITE_DESCUENTO_SALARIO', fechaCorte)) ?? 0.3;
    const limiteEmbargo =
      (await this.getParametroVigente(empresaId, 'LIMITE_EMBARGO_JUDICIAL', fechaCorte)) ?? 0.5;

    const detalles = await this.prisma.rrhh_liquidaciones_detalle.findMany({
      where: { liquidacion_id: liquidacionId, tipo: 'EGRESO', monto: { gt: 0 } },
      select: {
        empleado_id: true,
        monto: true,
        concepto: { select: { codigo: true, nombre: true, categoria: true } },
      },
    });

    const lineasPorEmpleado = new Map<string, typeof detalles>();
    for (const d of detalles) {
      const arr = lineasPorEmpleado.get(d.empleado_id) ?? [];
      arr.push(d);
      lineasPorEmpleado.set(d.empleado_id, arr);
    }

    const errores: string[] = [];
    for (const rs of resumenes) {
      const ingresos = num(rs.total_ingresos);
      if (ingresos <= 0) continue;

      const lineas = lineasPorEmpleado.get(rs.empleado_id) ?? [];
      const totalEmbargo = r2(
        lineas
          .filter((l) => this.isEmbargoConcepto(l.concepto))
          .reduce((acc, l) => acc + num(l.monto), 0),
      );
      const totalNoEmbargo = r2(
        lineas
          .filter((l) => !this.isEmbargoConcepto(l.concepto) && !this.isIpsConcepto(l.concepto))
          .reduce((acc, l) => acc + num(l.monto), 0),
      );

      const maxDescuento = r2(ingresos * limiteDescuento);
      const maxEmbargo = r2(ingresos * limiteEmbargo);
      const numero = rs.empleado?.numero_empleado ?? rs.empleado_id;

      if (totalNoEmbargo > maxDescuento) {
        errores.push(
          `Empleado ${numero}: descuentos no-embargo ${totalNoEmbargo.toLocaleString(
            'es-PY',
          )} superan ${maxDescuento.toLocaleString('es-PY')} (${(limiteDescuento * 100).toFixed(
            0,
          )}% del ingreso).`,
        );
      }
      if (totalEmbargo > maxEmbargo) {
        errores.push(
          `Empleado ${numero}: embargos ${totalEmbargo.toLocaleString(
            'es-PY',
          )} superan ${maxEmbargo.toLocaleString('es-PY')} (${(limiteEmbargo * 100).toFixed(
            0,
          )}% del ingreso).`,
        );
      }
    }

    if (errores.length > 0) {
      throw new BadRequestException(
        `No se puede cerrar la liquidación por límites legales de descuentos/embargos. ${errores
          .slice(0, 8)
          .join(' ')}`,
      );
    }
  }

  /**
   * Devuelve los datos de una liquidación en formato **pivot** para
   * "planilla de control": una fila por empleado y una columna por concepto,
   * más columnas con totales (ingresos/egresos/neto). Disponible para
   * liquidaciones en estado PRE_LIQUIDACION o CERRADA (en BORRADOR aún no hay
   * cálculo; en ANULADA los datos se borraron).
   */
  async obtenerPlanillaControl(id: string, empresaId: string) {
    const liq = await this.prisma.rrhh_liquidaciones_cabecera.findFirst({
      where: { id, empresa_id: empresaId },
      include: {
        sucursal: { select: { id: true, descripcion: true } },
        tipo_empleado: { select: { id: true, codigo: true, nombre: true } },
      },
    });
    if (!liq) throw new NotFoundException('Liquidación no encontrada');
    if (liq.estado !== 'PRE_LIQUIDACION' && liq.estado !== 'CERRADA') {
      throw new ConflictException(
        `La planilla de control sólo está disponible en PRE_LIQUIDACION o CERRADA (actual: ${liq.estado}).`,
      );
    }

    const [empresa, resumenes, detalleDb] = await Promise.all([
      this.prisma.empresas.findUnique({
        where: { id: empresaId },
        select: { id: true, razon_social: true, ruc: true },
      }),
      this.prisma.rrhh_liq_empleado_resumen.findMany({
        where: { liquidacion_id: id },
        include: {
          empleado: {
            select: {
              id: true,
              numero_empleado: true,
              nombres: true,
              apellidos: true,
              cedula_identidad: true,
              cuenta_bancaria: true,
              banco: { select: { codigo: true, descripcion: true } },
            },
          },
        },
        orderBy: [{ empleado: { apellidos: 'asc' } }, { empleado: { nombres: 'asc' } }],
      }),
      this.prisma.rrhh_liquidaciones_detalle.findMany({
        where: { liquidacion_id: id },
        select: {
          empleado_id: true,
          tipo: true,
          monto: true,
          concepto: {
            select: { id: true, codigo: true, nombre: true, orden_visualizacion: true },
          },
        },
      }),
    ]);

    // Mapas de conceptos por tipo (manteniendo orden de visualización + alfabético).
    type ConceptoCol = { id: string; codigo: string; nombre: string; orden: number };
    const ingresosMap = new Map<string, ConceptoCol>();
    const egresosMap = new Map<string, ConceptoCol>();
    // matriz[empleado_id][concepto_id] = monto (sumando si hay líneas duplicadas)
    const matriz = new Map<string, Map<string, number>>();
    for (const d of detalleDb) {
      if (!d.concepto) continue;
      const target = d.tipo === 'INGRESO' ? ingresosMap : egresosMap;
      if (!target.has(d.concepto.id)) {
        target.set(d.concepto.id, {
          id: d.concepto.id,
          codigo: d.concepto.codigo,
          nombre: d.concepto.nombre,
          orden: d.concepto.orden_visualizacion ?? 0,
        });
      }
      const fila = matriz.get(d.empleado_id) ?? new Map<string, number>();
      fila.set(d.concepto.id, r2((fila.get(d.concepto.id) ?? 0) + num(d.monto)));
      matriz.set(d.empleado_id, fila);
    }
    const ordenarConceptos = (m: Map<string, ConceptoCol>) =>
      [...m.values()].sort(
        (a, b) => a.orden - b.orden || a.codigo.localeCompare(b.codigo, 'es'),
      );
    const conceptos_ingreso = ordenarConceptos(ingresosMap);
    const conceptos_egreso = ordenarConceptos(egresosMap);

    const empleados = resumenes.map((rs) => {
      const fila = matriz.get(rs.empleado_id) ?? new Map<string, number>();
      const ingresos: Record<string, number> = {};
      const egresos: Record<string, number> = {};
      for (const c of conceptos_ingreso) ingresos[c.codigo] = r2(fila.get(c.id) ?? 0);
      for (const c of conceptos_egreso) egresos[c.codigo] = r2(fila.get(c.id) ?? 0);
      const banco = rs.empleado?.banco
        ? [rs.empleado.banco.codigo, rs.empleado.banco.descripcion].filter(Boolean).join(' — ')
        : null;
      return {
        empleado_id: rs.empleado_id,
        numero_empleado: rs.empleado?.numero_empleado ?? '',
        nombres: rs.empleado?.nombres ?? '',
        apellidos: rs.empleado?.apellidos ?? '',
        cedula_identidad: rs.empleado?.cedula_identidad ?? '',
        banco,
        cuenta_bancaria: rs.empleado?.cuenta_bancaria ?? null,
        ingresos,
        egresos,
        total_ingresos: r2(num(rs.total_ingresos)),
        total_egresos: r2(num(rs.total_egresos)),
        neto_a_pagar: r2(num(rs.neto_a_pagar)),
      };
    });

    // Totales por concepto (sumando la columna) + totales generales.
    const totalesPorConcepto: Record<string, number> = {};
    for (const c of [...conceptos_ingreso, ...conceptos_egreso]) {
      totalesPorConcepto[c.codigo] = r2(
        empleados.reduce(
          (acc, e) => acc + (e.ingresos[c.codigo] ?? e.egresos[c.codigo] ?? 0),
          0,
        ),
      );
    }
    const totales = {
      ingresos: r2(empleados.reduce((a, e) => a + e.total_ingresos, 0)),
      egresos: r2(empleados.reduce((a, e) => a + e.total_egresos, 0)),
      neto: r2(empleados.reduce((a, e) => a + e.neto_a_pagar, 0)),
      por_concepto: totalesPorConcepto,
    };

    return {
      liquidacion: {
        id: liq.id,
        tipo: liq.tipo,
        estado: liq.estado,
        periodo_anio: liq.periodo_anio,
        periodo_mes: liq.periodo_mes,
        quincena: liq.quincena,
        fecha_calculo: liq.fecha_calculo,
        fecha_cierre: liq.fecha_cierre,
        sucursal_id: liq.sucursal_id,
        sucursal: liq.sucursal,
      },
      metadata: {
        empresa: {
          id: empresaId,
          razon_social: empresa?.razon_social ?? null,
          ruc: empresa?.ruc ?? null,
        },
        periodo: {
          anio: liq.periodo_anio,
          mes: liq.periodo_mes,
          etiqueta: `${String(liq.periodo_mes).padStart(2, '0')}/${liq.periodo_anio}`,
        },
        sucursal: liq.sucursal?.descripcion ?? 'Todas las sucursales',
        cantidad_empleados: empleados.length,
      },
      conceptos_ingreso: conceptos_ingreso.map(({ codigo, nombre }) => ({ codigo, nombre })),
      conceptos_egreso: conceptos_egreso.map(({ codigo, nombre }) => ({ codigo, nombre })),
      empleados,
      totales,
    };
  }

  async generarPlanillaControlXlsx(
    id: string,
    empresaId: string,
  ): Promise<{ buffer: Buffer; filename: string }> {
    const p = await this.obtenerPlanillaControl(id, empresaId);

    const codigosIngreso = p.conceptos_ingreso.map((c) => c.codigo);
    const codigosEgreso = p.conceptos_egreso.map((c) => c.codigo);

    // Header: 2 filas — primera con grupos, segunda con códigos.
    const fila0: (string | number)[] = ['', '', '', ''];
    const fila1: (string | number)[] = ['N°', 'Cédula', 'Apellidos', 'Nombres'];
    fila0.push(...codigosIngreso.map(() => 'INGRESOS'));
    fila1.push(...codigosIngreso);
    fila0.push(...codigosEgreso.map(() => 'EGRESOS'));
    fila1.push(...codigosEgreso);
    fila0.push('', '', '');
    fila1.push('Total ingresos', 'Total egresos', 'Neto a acreditar');

    const filasEmpleados = p.empleados.map((e) => [
      e.numero_empleado,
      e.cedula_identidad,
      e.apellidos,
      e.nombres,
      ...codigosIngreso.map((c) => e.ingresos[c] ?? 0),
      ...codigosEgreso.map((c) => e.egresos[c] ?? 0),
      e.total_ingresos,
      e.total_egresos,
      e.neto_a_pagar,
    ]);

    const filaTotales = [
      '',
      '',
      '',
      'TOTALES',
      ...codigosIngreso.map((c) => p.totales.por_concepto[c] ?? 0),
      ...codigosEgreso.map((c) => p.totales.por_concepto[c] ?? 0),
      p.totales.ingresos,
      p.totales.egresos,
      p.totales.neto,
    ];

    const wb = XLSX.utils.book_new();
    const ws = XLSX.utils.aoa_to_sheet([fila0, fila1, ...filasEmpleados, [], filaTotales]);
    // Anchos básicos
    const cols = [
      { wch: 8 }, { wch: 14 }, { wch: 20 }, { wch: 20 },
      ...codigosIngreso.map(() => ({ wch: 14 })),
      ...codigosEgreso.map(() => ({ wch: 14 })),
      { wch: 14 }, { wch: 14 }, { wch: 16 },
    ];
    ws['!cols'] = cols;
    XLSX.utils.book_append_sheet(wb, ws, 'Planilla de control');

    // Hoja meta
    const meta = [
      ['Empresa', p.metadata.empresa.razon_social ?? '—'],
      ['RUC', p.metadata.empresa.ruc ?? '—'],
      ['Período', p.metadata.periodo.etiqueta],
      ['Sucursal', p.metadata.sucursal],
      ['Tipo de liquidación', p.liquidacion.tipo],
      ['Estado', p.liquidacion.estado],
      ['Cantidad empleados', p.metadata.cantidad_empleados],
      ['Generado', new Date().toISOString()],
    ];
    const wsMeta = XLSX.utils.aoa_to_sheet(meta);
    wsMeta['!cols'] = [{ wch: 24 }, { wch: 40 }];
    XLSX.utils.book_append_sheet(wb, wsMeta, 'Metadatos');

    const buffer = XLSX.write(wb, { type: 'buffer', bookType: 'xlsx' }) as Buffer;
    const anio = p.metadata.periodo.anio;
    const mes = String(p.metadata.periodo.mes).padStart(2, '0');
    return {
      buffer,
      filename: `planilla_control_${anio}-${mes}_${p.liquidacion.estado.toLowerCase()}.xlsx`,
    };
  }

  private async getLiquidacionMensualCerradaOrThrow(id: string, empresaId: string) {
    const liq = await this.prisma.rrhh_liquidaciones_cabecera.findFirst({
      where: { id, empresa_id: empresaId },
      include: {
        sucursal: { select: { id: true, descripcion: true } },
        tipo_empleado: { select: { id: true, codigo: true, nombre: true } },
      },
    });
    if (!liq) throw new NotFoundException('Liquidación no encontrada');
    if (liq.tipo !== 'MENSUAL') {
      throw new ConflictException(
        `La planilla consolidada solo aplica a liquidaciones MENSUALES (actual: ${liq.tipo}).`,
      );
    }
    if (liq.estado !== 'CERRADA') {
      throw new ConflictException(
        `La planilla consolidada solo está disponible para liquidaciones CERRADAS (actual: ${liq.estado}).`,
      );
    }
    return liq;
  }

  async obtenerPlanillaConsolidada(id: string, empresaId: string) {
    const liq = await this.getLiquidacionMensualCerradaOrThrow(id, empresaId);
    const [empresa, detalleDb, aggTotales, usuarioCierre] = await Promise.all([
      this.prisma.empresas.findUnique({
        where: { id: empresaId },
        select: { id: true, razon_social: true, ruc: true },
      }),
      this.prisma.rrhh_liquidaciones_detalle.findMany({
        where: { liquidacion_id: id },
        include: {
          empleado: {
            select: {
              id: true,
              numero_empleado: true,
              nombres: true,
              apellidos: true,
              cedula_identidad: true,
            },
          },
          concepto: { select: { id: true, codigo: true, nombre: true, tipo: true } },
          centro_costo: { select: { codigo: true, descripcion: true } },
        },
      }),
      this.prisma.rrhh_liq_empleado_resumen.aggregate({
        where: { liquidacion_id: id },
        _sum: { total_ingresos: true, total_egresos: true, neto_a_pagar: true },
      }),
      liq.cerrado_por
        ? this.prisma.usuario.findFirst({
            where: { id: liq.cerrado_por },
            select: { id: true, nombres: true, apellidos: true, username: true, email: true },
          })
        : Promise.resolve(null),
    ]);

    const detalle = [...detalleDb]
      .map((d) => {
        const empNombre = `${d.empleado?.apellidos ?? ''}, ${d.empleado?.nombres ?? ''}`
          .trim()
          .replace(/^,\s*/, '');
        return {
          empleado_id: d.empleado_id,
          numero_empleado: d.empleado?.numero_empleado ?? '',
          empleado: empNombre || d.empleado_id,
          cedula_identidad: d.empleado?.cedula_identidad ?? '',
          concepto_codigo: d.concepto?.codigo ?? '',
          concepto_nombre: d.concepto?.nombre ?? '',
          tipo: d.tipo,
          origen: d.origen,
          centro_costo: d.centro_costo
            ? [d.centro_costo.codigo, d.centro_costo.descripcion].filter(Boolean).join(' - ')
            : '',
          monto: num(d.monto),
        };
      })
      .sort((a, b) => {
        const empCmp = a.empleado.localeCompare(b.empleado, 'es');
        if (empCmp !== 0) return empCmp;
        return a.concepto_codigo.localeCompare(b.concepto_codigo, 'es');
      });

    const resumenMap = new Map<
      string,
      { codigo: string; nombre: string; tipo: string; total_monto: number; cantidad_lineas: number }
    >();
    for (const d of detalleDb) {
      const key = d.concepto_id;
      const actual = resumenMap.get(key) ?? {
        codigo: d.concepto?.codigo ?? '',
        nombre: d.concepto?.nombre ?? '',
        tipo: d.tipo,
        total_monto: 0,
        cantidad_lineas: 0,
      };
      actual.total_monto = r2(actual.total_monto + num(d.monto));
      actual.cantidad_lineas += 1;
      resumenMap.set(key, actual);
    }
    const resumen_conceptos = [...resumenMap.values()].sort((a, b) => {
      const tipoCmp = a.tipo.localeCompare(b.tipo, 'es');
      if (tipoCmp !== 0) return tipoCmp;
      return a.codigo.localeCompare(b.codigo, 'es');
    });

    const nombreCierre = [usuarioCierre?.nombres, usuarioCierre?.apellidos]
      .filter((v) => (v ?? '').trim().length > 0)
      .join(' ')
      .trim();

    return {
      liquidacion: {
        id: liq.id,
        tipo: liq.tipo,
        estado: liq.estado,
        periodo_anio: liq.periodo_anio,
        periodo_mes: liq.periodo_mes,
        sucursal_id: liq.sucursal_id,
        sucursal: liq.sucursal ? { id: liq.sucursal.id, descripcion: liq.sucursal.descripcion } : null,
        tipo_empleado_id: liq.tipo_empleado_id,
        tipo_empleado: liq.tipo_empleado ? { id: liq.tipo_empleado.id, codigo: liq.tipo_empleado.codigo, nombre: liq.tipo_empleado.nombre } : null,
        fecha_cierre: liq.fecha_cierre,
        cerrado_por: liq.cerrado_por,
      },
      metadata: {
        empresa: {
          id: empresaId,
          razon_social: empresa?.razon_social ?? null,
          ruc: empresa?.ruc ?? null,
        },
        periodo: {
          anio: liq.periodo_anio,
          mes: liq.periodo_mes,
          etiqueta: `${String(liq.periodo_mes).padStart(2, '0')}/${liq.periodo_anio}`,
        },
        sucursal: liq.sucursal?.descripcion ?? 'Todas las sucursales',
        tipo_empleado: liq.tipo_empleado?.nombre ?? 'Todos los tipos',
        fecha_cierre: liq.fecha_cierre,
        usuario_cierre: liq.cerrado_por
          ? {
              id: liq.cerrado_por,
              nombre: nombreCierre || usuarioCierre?.username || usuarioCierre?.email || liq.cerrado_por,
              username: usuarioCierre?.username ?? null,
              email: usuarioCierre?.email ?? null,
            }
          : null,
      },
      resumen_conceptos,
      detalle,
      totales: {
        ingresos: r2(num(aggTotales._sum.total_ingresos)),
        egresos: r2(num(aggTotales._sum.total_egresos)),
        neto: r2(num(aggTotales._sum.neto_a_pagar)),
      },
    };
  }

  async generarPlanillaConsolidadaXlsx(
    id: string,
    empresaId: string,
  ): Promise<{ buffer: Buffer; filename: string }> {
    const planilla = await this.obtenerPlanillaConsolidada(id, empresaId);

    const wb = XLSX.utils.book_new();

    const resumenHeader = ['Código', 'Concepto', 'Tipo', 'Cantidad líneas', 'Total monto'];
    const resumenRows = [
      resumenHeader,
      ...(planilla.resumen_conceptos.length
        ? planilla.resumen_conceptos.map((r) => [
            r.codigo,
            r.nombre,
            r.tipo,
            r.cantidad_lineas,
            r.total_monto,
          ])
        : [['', 'Sin datos', '', 0, 0]]),
      [],
      ['Empresa', planilla.metadata.empresa.razon_social ?? '—'],
      ['RUC', planilla.metadata.empresa.ruc ?? '—'],
      ['Período', planilla.metadata.periodo.etiqueta],
      ['Sucursal', planilla.metadata.sucursal],
      [
        'Fecha cierre',
        planilla.metadata.fecha_cierre
          ? new Date(planilla.metadata.fecha_cierre).toLocaleString('es-PY')
          : '—',
      ],
      ['Usuario cierre', planilla.metadata.usuario_cierre?.nombre ?? '—'],
      [],
      ['Total ingresos', planilla.totales.ingresos],
      ['Total egresos', planilla.totales.egresos],
      ['Total neto', planilla.totales.neto],
    ];
    const wsResumen = XLSX.utils.aoa_to_sheet(resumenRows);
    wsResumen['!cols'] = [{ wch: 16 }, { wch: 38 }, { wch: 12 }, { wch: 16 }, { wch: 16 }];
    XLSX.utils.book_append_sheet(wb, wsResumen, 'Resumen conceptos');

    const detalleHeader = [
      'N° empleado',
      'Empleado',
      'Cédula',
      'Concepto código',
      'Concepto nombre',
      'Tipo',
      'Origen',
      'Centro costo',
      'Monto',
    ];
    const detalleRows = [
      detalleHeader,
      ...(planilla.detalle.length
        ? planilla.detalle.map((d) => [
            d.numero_empleado,
            d.empleado,
            d.cedula_identidad,
            d.concepto_codigo,
            d.concepto_nombre,
            d.tipo,
            d.origen,
            d.centro_costo,
            d.monto,
          ])
        : [['', 'Sin datos', '', '', '', '', '', '', 0]]),
    ];
    const wsDetalle = XLSX.utils.aoa_to_sheet(detalleRows);
    wsDetalle['!cols'] = [
      { wch: 14 },
      { wch: 30 },
      { wch: 16 },
      { wch: 16 },
      { wch: 32 },
      { wch: 12 },
      { wch: 14 },
      { wch: 26 },
      { wch: 16 },
    ];
    XLSX.utils.book_append_sheet(wb, wsDetalle, 'Detalle empleados');

    const buffer = XLSX.write(wb, { type: 'buffer', bookType: 'xlsx' }) as Buffer;
    const filename = `planilla_rrhh_${planilla.metadata.periodo.anio}-${String(
      planilla.metadata.periodo.mes,
    ).padStart(2, '0')}_liq_${id.slice(0, 8)}.xlsx`;
    return { buffer, filename };
  }

  // ── Listado / detalle ──────────────────────────────────
  async listar(empresaId: string, query: QueryLiquidacionesDto) {
    const page = query.page ?? 1;
    const limit = query.limit ?? 20;
    const skip = (page - 1) * limit;

    const where: Prisma.rrhh_liquidaciones_cabeceraWhereInput = { empresa_id: empresaId };
    if (query.estado) where.estado = query.estado;
    if (query.periodo_anio) where.periodo_anio = query.periodo_anio;
    if (query.periodo_mes) where.periodo_mes = query.periodo_mes;

    const [data, total] = await this.prisma.$transaction([
      this.prisma.rrhh_liquidaciones_cabecera.findMany({
        where,
        skip,
        take: limit,
        orderBy: [{ periodo_anio: 'desc' }, { periodo_mes: 'desc' }, { created_at: 'desc' }],
        include: {
        sucursal: { select: { id: true, descripcion: true } },
        tipo_empleado: { select: { id: true, codigo: true, nombre: true } },
      },
      }),
      this.prisma.rrhh_liquidaciones_cabecera.count({ where }),
    ]);

    return { data, total, page, limit };
  }

  async obtener(id: string, empresaId: string) {
    const liq = await this.prisma.rrhh_liquidaciones_cabecera.findFirst({
      where: { id, empresa_id: empresaId },
      include: {
        sucursal: { select: { id: true, descripcion: true } },
        tipo_empleado: { select: { id: true, codigo: true, nombre: true } },
        resumenes: {
          orderBy: { empleado: { apellidos: 'asc' } },
          include: {
            empleado: {
              select: {
                id: true,
                numero_empleado: true,
                nombres: true,
                apellidos: true,
                cedula_identidad: true,
              },
            },
          },
        },
      },
    });
    if (!liq) throw new NotFoundException('Liquidación no encontrada');
    return liq;
  }

  async obtenerAsientoContable(id: string, empresaId: string) {
    const liq = await this.prisma.rrhh_liquidaciones_cabecera.findFirst({
      where: { id, empresa_id: empresaId },
      select: { id: true, estado: true },
    });
    if (!liq) throw new NotFoundException('Liquidación no encontrada');
    const asiento = await this.contabilidadIntegracion.findAsientoPorLiquidacion(empresaId, id);
    return { liquidacion_id: id, estado_liquidacion: liq.estado, asiento };
  }

  async reintegrarContabilidad(id: string, empresaId: string) {
    const liq = await this.prisma.rrhh_liquidaciones_cabecera.findFirst({
      where: { id, empresa_id: empresaId },
      select: { id: true, estado: true },
    });
    if (!liq) throw new NotFoundException('Liquidación no encontrada');
    if (liq.estado !== 'CERRADA') {
      throw new ConflictException(
        `Sólo se puede integrar contablemente una liquidación CERRADA (actual: ${liq.estado}).`,
      );
    }
    await this.contabilidadIntegracion.integrarLiquidacionRrhh(id);
    return this.obtenerAsientoContable(id, empresaId);
  }

  async obtenerDetalleEmpleado(liquidacionId: string, empleadoId: string, empresaId: string) {
    const liq = await this.prisma.rrhh_liquidaciones_cabecera.findFirst({
      where: { id: liquidacionId, empresa_id: empresaId },
      select: {
        id: true,
        periodo_anio: true,
        periodo_mes: true,
        tipo: true,
        quincena: true,
        estado: true,
        fecha_cierre: true,
        fecha_pago_programada: true,
      },
    });
    if (!liq) throw new NotFoundException('Liquidación no encontrada');

    const [resumen, lineas] = await Promise.all([
      this.prisma.rrhh_liq_empleado_resumen.findFirst({
        where: { liquidacion_id: liquidacionId, empleado_id: empleadoId },
        include: {
          empleado: {
            select: {
              id: true,
              numero_empleado: true,
              nombres: true,
              apellidos: true,
              cedula_identidad: true,
              salario_base: true,
              fecha_ingreso: true,
              numero_ips: true,
              cuenta_bancaria: true,
              tipo_cuenta: true,
              cargo: { select: { id: true, codigo: true, nombre: true } },
              departamento: { select: { id: true, codigo: true, nombre: true } },
              sucursal: { select: { id: true, descripcion: true } },
              banco: { select: { id: true, codigo: true, descripcion: true } },
            },
          },
        },
      }),
      this.prisma.rrhh_liquidaciones_detalle.findMany({
        where: { liquidacion_id: liquidacionId, empleado_id: empleadoId },
        orderBy: [{ tipo: 'asc' }, { created_at: 'asc' }],
        include: { concepto: { select: { id: true, codigo: true, nombre: true, categoria: true } } },
      }),
    ]);
    if (!resumen) throw new NotFoundException('El empleado no figura en esta liquidación');
    return { liquidacion: liq, resumen, lineas };
  }

  // ── Crear (BORRADOR) ───────────────────────────────────
  async crear(empresaId: string, dto: CreateLiquidacionDto, userId?: string) {
    if (dto.sucursal_id) {
      const suc = await this.prisma.empresas_sucursales.findFirst({
        where: { id: dto.sucursal_id, empresa_id: empresaId },
      });
      if (!suc) throw new BadRequestException('Sucursal inválida para esta empresa');
    }

    if (dto.tipo_empleado_id) {
      const tipoEmp = await this.prisma.rrhh_tipos_empleado.findFirst({
        where: { id: dto.tipo_empleado_id, empresa_id: empresaId },
      });
      if (!tipoEmp) throw new BadRequestException('Tipo de empleado inválido para esta empresa');
    }

    const tipo = dto.tipo ?? 'MENSUAL';
    let quincena: number | null = null;
    let periodoMes = dto.periodo_mes;

    if (tipo === 'MENSUAL' || tipo === 'QUINCENAL' || tipo === 'VACACIONES') {
      if (periodoMes == null || periodoMes < 1 || periodoMes > 12) {
        throw new BadRequestException(`Para una liquidación ${tipo.toLowerCase()} indicá periodo_mes (1-12)`);
      }
    }
    if (tipo === 'AGUINALDO') {
      // El aguinaldo se materializa siempre en diciembre del año (mes = 12).
      periodoMes = 12;
    }

    if (tipo === 'QUINCENAL') {
      if (dto.quincena !== 1 && dto.quincena !== 2) {
        throw new BadRequestException('Para una liquidación quincenal indicá quincena = 1 o 2');
      }
      quincena = dto.quincena;
      // No se puede crear quincenal si la mensual del período ya está cerrada
      const mensualCerrada = await this.prisma.rrhh_liquidaciones_cabecera.findFirst({
        where: {
          empresa_id: empresaId,
          periodo_anio: dto.periodo_anio,
          periodo_mes: periodoMes,
          tipo: 'MENSUAL',
          sucursal_id: dto.sucursal_id ?? null,
          estado: 'CERRADA',
        },
      });
      if (mensualCerrada) {
        throw new ConflictException(
          'No se puede crear una quincenal: la liquidación mensual del período ya está cerrada.',
        );
      }
    }

    const dup = await this.prisma.rrhh_liquidaciones_cabecera.findFirst({
      where: {
        empresa_id: empresaId,
        periodo_anio: dto.periodo_anio,
        periodo_mes: periodoMes,
        tipo,
        quincena,
        sucursal_id: dto.sucursal_id ?? null,
        tipo_empleado_id: dto.tipo_empleado_id ?? null,
        estado: { not: 'ANULADA' },
      },
    });
    if (dup) {
      let etiqueta: string;
      if (tipo === 'QUINCENAL') {
        etiqueta = `quincenal ${quincena}ª (${String(periodoMes).padStart(2, '0')}/${dto.periodo_anio})`;
      } else if (tipo === 'AGUINALDO') {
        etiqueta = `aguinaldo ${dto.periodo_anio}`;
      } else if (tipo === 'VACACIONES') {
        etiqueta = `vacaciones ${String(periodoMes).padStart(2, '0')}/${dto.periodo_anio}`;
      } else {
        etiqueta = `mensual ${String(periodoMes).padStart(2, '0')}/${dto.periodo_anio}`;
      }
      const sufijoDup =
        (dto.sucursal_id ? ' para esa sucursal' : '') +
        (dto.tipo_empleado_id ? ' y ese tipo de empleado' : '');
      throw new ConflictException(
        `Ya existe una liquidación ${etiqueta}${sufijoDup} en estado ${dup.estado}.`,
      );
    }

    return this.prisma.rrhh_liquidaciones_cabecera.create({
      data: {
        empresa_id: empresaId,
        sucursal_id: dto.sucursal_id ?? null,
        tipo_empleado_id: dto.tipo_empleado_id ?? null,
        periodo_anio: dto.periodo_anio,
        periodo_mes: periodoMes!,
        quincena,
        tipo,
        estado: 'BORRADOR',
        fecha_pago_programada: dto.fecha_pago_programada ? new Date(dto.fecha_pago_programada) : null,
        observaciones: dto.observaciones,
        creado_por: userId,
      },
    });
  }

  // ── Calcular / recalcular (→ PRE_LIQUIDACION) ──────────
  async calcular(id: string, empresaId: string) {
    const liq = await this.prisma.rrhh_liquidaciones_cabecera.findFirst({
      where: { id, empresa_id: empresaId },
    });
    if (!liq) throw new NotFoundException('Liquidación no encontrada');
    if (liq.estado === 'CERRADA') throw new ConflictException('La liquidación está cerrada y no puede recalcularse');
    if (liq.estado === 'ANULADA') throw new ConflictException('La liquidación está anulada');

    const tipoEmpleadoId = liq.tipo_empleado_id ?? undefined;
    let resultado;
    switch (liq.tipo) {
      case 'QUINCENAL':
        resultado = await this.engine.calcularQuincenal(
          empresaId,
          liq.periodo_anio,
          liq.periodo_mes,
          liq.quincena === 2 ? 2 : 1,
          { sucursalId: liq.sucursal_id, tipoEmpleadoId, liquidacionId: id },
        );
        break;
      case 'AGUINALDO':
        resultado = await this.engine.calcularAguinaldo(empresaId, liq.periodo_anio, {
          sucursalId: liq.sucursal_id,
          tipoEmpleadoId,
        });
        break;
      case 'VACACIONES':
        resultado = await this.engine.calcularVacaciones(
          empresaId,
          liq.periodo_anio,
          liq.periodo_mes,
          { sucursalId: liq.sucursal_id, tipoEmpleadoId },
        );
        break;
      default:
        resultado = await this.engine.calcularMensual(empresaId, liq.periodo_anio, liq.periodo_mes, {
          sucursalId: liq.sucursal_id,
          tipoEmpleadoId,
          liquidacionId: id,
        });
    }

    await this.prisma.$transaction(async (tx) => {
      // Conserva ajustes manuales y regenera únicamente líneas automáticas/importadas.
      const manualesPrevios = await tx.rrhh_liquidaciones_detalle.findMany({
        where: { liquidacion_id: id, origen: 'MANUAL' },
        select: { empleado_id: true },
      });
      const empleadosConManual = new Set(manualesPrevios.map((m) => m.empleado_id));

      await tx.rrhh_liquidaciones_detalle.deleteMany({
        where: { liquidacion_id: id, NOT: { origen: 'MANUAL' } },
      });
      await tx.rrhh_liq_empleado_resumen.deleteMany({ where: { liquidacion_id: id } });

      if (resultado.detalles.length) {
        await tx.rrhh_liquidaciones_detalle.createMany({
          data: resultado.detalles.map((d) => ({
            liquidacion_id: id,
            empleado_id: d.empleado_id,
            concepto_id: d.concepto_id,
            tipo: d.tipo,
            monto: d.monto,
            base_calculo: d.base_calculo ?? null,
            porcentaje_aplicado: d.porcentaje_aplicado ?? null,
            origen: d.origen,
            referencia_origen_id: d.referencia_origen_id ?? null,
            centro_costo_id: d.centro_costo_id ?? null,
            observacion: d.observacion ?? null,
          })),
        });
      }
      if (resultado.resumenes.length) {
        await tx.rrhh_liq_empleado_resumen.createMany({
          data: resultado.resumenes.map((rs) => ({
            liquidacion_id: id,
            empleado_id: rs.empleado_id,
            salario_bruto: rs.salario_bruto,
            total_ingresos: rs.total_ingresos,
            total_egresos: rs.total_egresos,
            base_ips: rs.base_ips,
            ips_obrero: rs.ips_obrero,
            ips_patronal: rs.ips_patronal,
            ips_admin: rs.ips_admin,
            neto_a_pagar: rs.neto_a_pagar,
            dias_trabajados: rs.dias_trabajados,
            dias_mes: rs.dias_mes,
            alerta_descuento: rs.alerta_descuento,
          })),
        });
      }

      const puedeHeredarManuales =
        liq.estado === 'BORRADOR' &&
        manualesPrevios.length === 0 &&
        (liq.tipo === 'MENSUAL' || liq.tipo === 'QUINCENAL');
      if (puedeHeredarManuales) {
        const whereAnterior: Prisma.rrhh_liquidaciones_cabeceraWhereInput = {
          empresa_id: empresaId,
          id: { not: id },
          tipo: liq.tipo,
          sucursal_id: liq.sucursal_id ?? null,
          estado: { in: ['PRE_LIQUIDACION', 'CERRADA'] },
          OR: [
            { periodo_anio: { lt: liq.periodo_anio } },
            { periodo_anio: liq.periodo_anio, periodo_mes: { lt: liq.periodo_mes } },
          ],
        };
        if (liq.tipo === 'QUINCENAL') whereAnterior.quincena = liq.quincena;

        const anterior = await tx.rrhh_liquidaciones_cabecera.findFirst({
          where: whereAnterior,
          orderBy: [
            { periodo_anio: 'desc' },
            { periodo_mes: 'desc' },
            { updated_at: 'desc' },
          ],
          select: { id: true, estado: true },
        });

        // Regla de negocio (configurable por empresa): por defecto, si el período anterior
        // ya está CERRADO, no se arrastran sus descuentos manuales — sólo el salario + IPS
        // (que el motor recalcula siempre como AUTOMATICO). Los manuales sólo se heredan de
        // un período previo aún abierto (PRE_LIQUIDACION), para no repetir descuentos ya
        // consolidados en una planilla cerrada. La empresa puede activar el parámetro
        // HEREDAR_DESCUENTOS_MANUALES_MES_CERRADO para restaurar el arrastre desde cerradas.
        const fechaCorteHerencia = new Date(liq.periodo_anio, liq.periodo_mes, 0);
        const heredarDeCerrada =
          (await this.getParametroBooleanoVigente(
            empresaId,
            'HEREDAR_DESCUENTOS_MANUALES_MES_CERRADO',
            fechaCorteHerencia,
          )) === true;
        if (anterior && (heredarDeCerrada || anterior.estado !== 'CERRADA')) {
          const lineasManualesAnterior = await tx.rrhh_liquidaciones_detalle.findMany({
            where: { liquidacion_id: anterior.id, origen: 'MANUAL' },
            select: {
              empleado_id: true,
              concepto_id: true,
              tipo: true,
              monto: true,
              base_calculo: true,
              porcentaje_aplicado: true,
              referencia_origen_id: true,
              centro_costo_id: true,
              observacion: true,
            },
          });

          const empleadosCalculados = new Set(resultado.resumenes.map((rs) => rs.empleado_id));
          const lineasHeredadas = lineasManualesAnterior.filter((l) =>
            empleadosCalculados.has(l.empleado_id),
          );
          if (lineasHeredadas.length) {
            await tx.rrhh_liquidaciones_detalle.createMany({
              data: lineasHeredadas.map((l) => ({
                liquidacion_id: id,
                empleado_id: l.empleado_id,
                concepto_id: l.concepto_id,
                tipo: l.tipo as 'INGRESO' | 'EGRESO',
                monto: l.monto,
                base_calculo: l.base_calculo,
                porcentaje_aplicado: l.porcentaje_aplicado,
                origen: 'MANUAL',
                referencia_origen_id: l.referencia_origen_id,
                centro_costo_id: l.centro_costo_id,
                observacion: l.observacion,
              })),
            });
            for (const l of lineasHeredadas) empleadosConManual.add(l.empleado_id);
          }
        }
      }

      const novedadesAplicadas: string[] = [];
      for (const d of resultado.detalles) {
        if (d.origen === 'NOVEDAD' && typeof d.referencia_origen_id === 'string') {
          novedadesAplicadas.push(d.referencia_origen_id);
        }
      }
      const novedadesAplicadasUnicas = [...new Set(novedadesAplicadas)];
      const whereLiberarReservas: Prisma.rrhh_asistencia_novedadesWhereInput = {
        preliquidacion_id: id,
        liquidacion_id: null,
      };
      if (novedadesAplicadasUnicas.length) {
        whereLiberarReservas.id = { notIn: novedadesAplicadasUnicas };
      }
      await tx.rrhh_asistencia_novedades.updateMany({
        where: whereLiberarReservas,
        data: { preliquidacion_id: null, updated_at: new Date() },
      });
      if (novedadesAplicadasUnicas.length) {
        await tx.rrhh_asistencia_novedades.updateMany({
          where: {
            id: { in: novedadesAplicadasUnicas },
            liquidacion_id: null,
            OR: [{ preliquidacion_id: null }, { preliquidacion_id: id }],
          },
          data: { preliquidacion_id: id, updated_at: new Date() },
        });
      }
      await tx.rrhh_liquidaciones_cabecera.update({
        where: { id },
        data: {
          estado: 'PRE_LIQUIDACION',
          total_ingresos: resultado.totales.total_ingresos,
          total_egresos: resultado.totales.total_egresos,
          total_neto: resultado.totales.total_neto,
          total_ips_obrero: resultado.totales.total_ips_obrero,
          total_ips_patronal: resultado.totales.total_ips_patronal,
          total_ips_admin: resultado.totales.total_ips_admin,
          cantidad_empleados: resultado.totales.cantidad_empleados,
          fecha_calculo: new Date(),
          numero_recalculos: liq.estado === 'PRE_LIQUIDACION' ? liq.numero_recalculos + 1 : 0,
          recalculo_pendiente: false,
          updated_at: new Date(),
        },
      });

      // Reaplica impacto de líneas MANUAL sobre resumen/totales tras el recálculo.
      for (const empleadoId of empleadosConManual) {
        await this.recomputarTotales(tx, liq, empleadoId);
      }
    });

    return {
      ok: true,
      totales: resultado.totales,
      advertencias: resultado.advertencias,
    };
  }

  // ── Líneas manuales (ajustes en PRE_LIQUIDACION) ───────
  private async recomputarTotales(
    tx: Prisma.TransactionClient,
    liq: { id: string; tipo: string; periodo_anio: number; periodo_mes: number; empresa_id: string },
    empleadoId: string,
  ) {
    const liquidacionId = liq.id;
    const lineas = await tx.rrhh_liquidaciones_detalle.findMany({
      where: { liquidacion_id: liquidacionId, empleado_id: empleadoId },
      include: { concepto: { select: { afecta_base_ips: true, codigo: true } } },
    });
    const resumen = await tx.rrhh_liq_empleado_resumen.findFirst({
      where: { liquidacion_id: liquidacionId, empleado_id: empleadoId },
    });
    if (!resumen) return;

    // Recalcular el IPS: todos los conceptos marcados con IPS afectan la base
    // aportable (los INGRESO suman, los EGRESO restan). Sólo aplica a
    // liquidaciones que aportan IPS (mensual / vacaciones) y empleados que aportan.
    let baseIps = num(resumen.base_ips);
    let ipsObrero = num(resumen.ips_obrero);
    let ipsPatronal = num(resumen.ips_patronal);
    let ipsAdmin = num(resumen.ips_admin);
    const aplicaIps = liq.tipo === 'MENSUAL' || liq.tipo === 'VACACIONES';
    const lineaIpsObrero = lineas.find((l) => l.concepto?.codigo === 'IPS_OBRERO');
    if (aplicaIps && lineaIpsObrero) {
      const empleado = await tx.rrhh_empleados.findFirst({
        where: { id: empleadoId, empresa_id: liq.empresa_id },
        select: { aporta_ips: true, regimen_ips: true },
      });
      if (empleado?.aporta_ips) {
        const baseSigned = lineas.reduce(
          (acc, l) => acc + (l.concepto?.afecta_base_ips ? (l.tipo === 'INGRESO' ? num(l.monto) : -num(l.monto)) : 0),
          0,
        );
        const diasMes = resumen.dias_mes || new Date(liq.periodo_anio, liq.periodo_mes, 0).getDate();
        const ips = await this.engine.calcularIpsSobreBase(liq.empresa_id, empleado, baseSigned, {
          fechaCorte: new Date(liq.periodo_anio, liq.periodo_mes, 0),
          diasTrabajados: resumen.dias_trabajados ?? diasMes,
          diasMes,
        });
        baseIps = ips.baseIps;
        ipsObrero = ips.ipsObrero;
        ipsPatronal = ips.ipsPatronal;
        ipsAdmin = ips.ipsAdmin;
        // El aporte obrero descuenta del neto → se refleja en la línea de detalle.
        // Patronal/admin son costo empresa (monto 0), sólo se actualiza su base.
        await tx.rrhh_liquidaciones_detalle.update({
          where: { id: lineaIpsObrero.id },
          data: { monto: ipsObrero, base_calculo: baseIps },
        });
        (lineaIpsObrero as unknown as { monto: number }).monto = ipsObrero;
        for (const cod of ['IPS_PATRONAL', 'IPS_ADMIN'] as const) {
          const l = lineas.find((x) => x.concepto?.codigo === cod);
          if (l) await tx.rrhh_liquidaciones_detalle.update({ where: { id: l.id }, data: { base_calculo: baseIps } });
        }
      }
    }

    const ingresos = r2(lineas.filter((l) => l.tipo === 'INGRESO').reduce((a, l) => a + num(l.monto), 0));
    const egresos = r2(lineas.filter((l) => l.tipo === 'EGRESO').reduce((a, l) => a + num(l.monto), 0));
    const neto = r2(ingresos - egresos);

    await tx.rrhh_liq_empleado_resumen.update({
      where: { id: resumen.id },
      data: {
        total_ingresos: ingresos,
        total_egresos: egresos,
        salario_bruto: ingresos,
        neto_a_pagar: neto,
        base_ips: baseIps,
        ips_obrero: ipsObrero,
        ips_patronal: ipsPatronal,
        ips_admin: ipsAdmin,
        updated_at: new Date(),
      },
    });

    // Recompute cabecera
    const agg = await tx.rrhh_liq_empleado_resumen.aggregate({
      where: { liquidacion_id: liquidacionId },
      _sum: { total_ingresos: true, total_egresos: true, neto_a_pagar: true, ips_obrero: true, ips_patronal: true, ips_admin: true },
      _count: true,
    });
    await tx.rrhh_liquidaciones_cabecera.update({
      where: { id: liquidacionId },
      data: {
        total_ingresos: num(agg._sum.total_ingresos),
        total_egresos: num(agg._sum.total_egresos),
        total_neto: num(agg._sum.neto_a_pagar),
        total_ips_obrero: num(agg._sum.ips_obrero),
        total_ips_patronal: num(agg._sum.ips_patronal),
        total_ips_admin: num(agg._sum.ips_admin),
        cantidad_empleados: agg._count,
        updated_at: new Date(),
      },
    });
  }

  async agregarLineaManual(id: string, empresaId: string, dto: AgregarLineaManualDto) {
    const liq = await this.prisma.rrhh_liquidaciones_cabecera.findFirst({
      where: { id, empresa_id: empresaId },
    });
    if (!liq) throw new NotFoundException('Liquidación no encontrada');
    if (liq.estado !== 'PRE_LIQUIDACION') {
      throw new ConflictException(
        `Solo se pueden agregar ajustes en estado PRE_LIQUIDACION (actual: ${liq.estado}). Calculá primero.`,
      );
    }

    const [empleado, concepto, resumen] = await Promise.all([
      this.prisma.rrhh_empleados.findFirst({ where: { id: dto.empleado_id, empresa_id: empresaId } }),
      this.prisma.rrhh_conceptos_liquidacion.findFirst({ where: { id: dto.concepto_id, empresa_id: empresaId } }),
      this.prisma.rrhh_liq_empleado_resumen.findFirst({
        where: { liquidacion_id: id, empleado_id: dto.empleado_id },
      }),
    ]);
    if (!empleado) throw new BadRequestException('Empleado inválido para esta empresa');
    if (!concepto) throw new BadRequestException('Concepto inválido para esta empresa');
    if (!resumen) {
      throw new BadRequestException('El empleado no figura en la liquidación. Recalculá si lo agregaste recién.');
    }

    return this.prisma.$transaction(async (tx) => {
      await tx.rrhh_liquidaciones_detalle.create({
        data: {
          liquidacion_id: id,
          empleado_id: dto.empleado_id,
          concepto_id: dto.concepto_id,
          tipo: dto.tipo,
          monto: dto.monto,
          origen: 'MANUAL',
          centro_costo_id: empleado.centro_costo_id,
          observacion: dto.observacion ?? null,
        },
      });
      await this.recomputarTotales(tx, liq, dto.empleado_id);
      return { ok: true };
    });
  }

  async eliminarLineaManual(id: string, lineaId: string, empresaId: string) {
    const liq = await this.prisma.rrhh_liquidaciones_cabecera.findFirst({
      where: { id, empresa_id: empresaId },
    });
    if (!liq) throw new NotFoundException('Liquidación no encontrada');
    if (liq.estado !== 'PRE_LIQUIDACION') {
      throw new ConflictException('Solo se pueden quitar ajustes en estado PRE_LIQUIDACION');
    }
    const linea = await this.prisma.rrhh_liquidaciones_detalle.findFirst({
      where: { id: lineaId, liquidacion_id: id },
    });
    if (!linea) throw new NotFoundException('Línea no encontrada');
    if (linea.origen !== 'MANUAL') {
      throw new BadRequestException('Solo se pueden quitar líneas de origen MANUAL. Recalculá para regenerar las automáticas.');
    }

    return this.prisma.$transaction(async (tx) => {
      await tx.rrhh_liquidaciones_detalle.delete({ where: { id: lineaId } });
      await this.recomputarTotales(tx, liq, linea.empleado_id);
      return { ok: true };
    });
  }

  // ── Cerrar (→ CERRADA, inmutable) ──────────────────────
  async cerrar(id: string, empresaId: string, dto: AccionLiquidacionDto, userId?: string) {
    const liq = await this.prisma.rrhh_liquidaciones_cabecera.findFirst({
      where: { id, empresa_id: empresaId },
    });
    if (!liq) throw new NotFoundException('Liquidación no encontrada');
    if (liq.estado === 'CERRADA') throw new ConflictException('La liquidación ya está cerrada');
    if (liq.estado !== 'PRE_LIQUIDACION') {
      throw new ConflictException(`Solo se puede cerrar una liquidación PRE_LIQUIDACION (actual: ${liq.estado}). Calculá primero.`);
    }
    if (liq.recalculo_pendiente) {
      throw new ConflictException(
        'La liquidación tiene novedades modificadas y requiere recálculo antes del cierre.',
      );
    }

    // Regla: no cerrar la MENSUAL si hay quincenal del mismo período sin cerrar.
    if (liq.tipo === 'MENSUAL') {
      const quincenalPendiente = await this.prisma.rrhh_liquidaciones_cabecera.findFirst({
        where: {
          empresa_id: empresaId,
          periodo_anio: liq.periodo_anio,
          periodo_mes: liq.periodo_mes,
          tipo: 'QUINCENAL',
          ...(liq.sucursal_id ? { sucursal_id: liq.sucursal_id } : {}),
          estado: { in: ['BORRADOR', 'PRE_LIQUIDACION'] },
        },
      });
      if (quincenalPendiente) {
        throw new ConflictException(
          'No se puede cerrar la mensual: existe una liquidación quincenal del mismo período sin cerrar.',
        );
      }
    }

    const resumenes = await this.prisma.rrhh_liq_empleado_resumen.findMany({
      where: { liquidacion_id: id },
      include: { empleado: { select: { id: true, numero_empleado: true } } },
    });
    if (resumenes.length === 0) {
      throw new BadRequestException('La liquidación no tiene empleados calculados.');
    }

    await this.validarLimitesDescuentoEmbargo({
      liquidacionId: id,
      empresaId,
      periodoAnio: liq.periodo_anio,
      periodoMes: liq.periodo_mes,
      resumenes,
    });

    const netoNegativo = resumenes.find((r) => num(r.neto_a_pagar) < 0);
    if (netoNegativo) {
      const fechaCorte = new Date(liq.periodo_anio, liq.periodo_mes, 0);
      const habilitaExcepcion =
        (await this.getParametroBooleanoVigente(
          empresaId,
          'PERMITIR_NETO_NEGATIVO_EXCEPCION',
          fechaCorte,
        )) ?? false;

      if (!habilitaExcepcion) {
        throw new BadRequestException(
          'Hay empleados con neto a pagar negativo. Ajustá la liquidación antes de cerrarla.',
        );
      }

      const confirmado = dto.confirmar_excepcion_neto_negativo === true;
      const motivo = dto.motivo_excepcion_neto_negativo?.trim();
      if (!confirmado || !motivo || motivo.length < 10) {
        throw new BadRequestException(
          'El cierre con neto negativo requiere confirmación explícita y motivo (mínimo 10 caracteres).',
        );
      }

      const afectados = resumenes
        .filter((r) => num(r.neto_a_pagar) < 0)
        .map((r) => ({
          empleado_id: r.empleado_id,
          numero_empleado: r.empleado?.numero_empleado ?? null,
          neto_a_pagar: num(r.neto_a_pagar),
        }));

      await this.auditService.log({
        empresa_id: empresaId,
        user_id: userId,
        action: 'EXCEPTION_APPROVED',
        entity_type: 'rrhh_liquidacion',
        entity_id: id,
        descripcion: `Excepción aprobada para cerrar liquidación con neto negativo (${afectados.length} empleado/s). Motivo: ${motivo}`,
        new_value: {
          confirmada: true,
          motivo,
          parametro: 'PERMITIR_NETO_NEGATIVO_EXCEPCION',
          periodo_anio: liq.periodo_anio,
          periodo_mes: liq.periodo_mes,
          empleados_afectados: afectados,
        },
      });
    }

    // Referencias a marcar: las líneas de detalle con origen ANTICIPO/PRESTAMO/IMPORTADO/NOVEDAD
    const detalles = await this.prisma.rrhh_liquidaciones_detalle.findMany({
      where: { liquidacion_id: id, origen: { in: ['ANTICIPO', 'PRESTAMO', 'IMPORTADO', 'NOVEDAD'] } },
      select: { origen: true, referencia_origen_id: true },
    });
    type DetalleReferencia = (typeof detalles)[number] & { referencia_origen_id: string };
    const refIds = (origen: 'ANTICIPO' | 'PRESTAMO' | 'IMPORTADO' | 'NOVEDAD') =>
      [
        ...new Set(
          detalles
            .filter(
              (d): d is DetalleReferencia =>
                d.origen === origen && typeof d.referencia_origen_id === 'string',
            )
            .map((d) => d.referencia_origen_id),
        ),
      ];
    const anticipoIds = refIds('ANTICIPO');
    const cuotaIds = refIds('PRESTAMO');
    const planillaDetIds = refIds('IMPORTADO');
    const novedadIds = refIds('NOVEDAD');

    const cerrada = await this.prisma.$transaction(async (tx) => {
      // 1) Anticipos → DESCONTADO
      if (anticipoIds.length) {
        await tx.rrhh_anticipos_salario.updateMany({
          where: { id: { in: anticipoIds }, estado: 'APROBADO' },
          data: { estado: 'DESCONTADO', liquidacion_mensual_id: id, updated_at: new Date() },
        });
      }

      // 2) Cuotas de préstamo → DESCONTADO + actualizar saldo de cada préstamo
      if (cuotaIds.length) {
        const cuotas = await tx.rrhh_prestamos_cuotas.findMany({
          where: { id: { in: cuotaIds }, estado: 'PENDIENTE' },
        });
        for (const c of cuotas) {
          await tx.rrhh_prestamos_cuotas.update({
            where: { id: c.id },
            data: { estado: 'DESCONTADO', liquidacion_id: id, fecha_descuento: new Date(), updated_at: new Date() },
          });
        }
        // Recalcular saldo + estado de los préstamos afectados
        const prestamoIds = [...new Set(cuotas.map((c) => c.prestamo_id))];
        for (const pid of prestamoIds) {
          const prestamo = await tx.rrhh_prestamos.findUnique({ where: { id: pid } });
          if (!prestamo) continue;
          const descontadasMonto = await tx.rrhh_prestamos_cuotas.aggregate({
            where: { prestamo_id: pid, estado: 'DESCONTADO' },
            _sum: { monto: true },
          });
          const nuevoSaldo = r2(num(prestamo.monto_total) - num(descontadasMonto._sum.monto));
          const quedanPendientes = await tx.rrhh_prestamos_cuotas.count({
            where: { prestamo_id: pid, estado: 'PENDIENTE' },
          });
          await tx.rrhh_prestamos.update({
            where: { id: pid },
            data: {
              saldo_pendiente: Math.max(nuevoSaldo, 0),
              estado: quedanPendientes === 0 && nuevoSaldo <= 0 ? 'CANCELADO' : prestamo.estado,
              updated_at: new Date(),
            },
          });
        }
      }

      // 3) Detalles de planilla → aplicado=true; planillas con todos sus detalles aplicados → APLICADA
      if (planillaDetIds.length) {
        await tx.rrhh_planillas_externas_det.updateMany({
          where: { id: { in: planillaDetIds } },
          data: { aplicado: true },
        });
        const planillaIds = [
          ...new Set(
            (
              await tx.rrhh_planillas_externas_det.findMany({
                where: { id: { in: planillaDetIds } },
                select: { planilla_id: true },
              })
            ).map((d) => d.planilla_id),
          ),
        ];
        for (const pid of planillaIds) {
          const pendientes = await tx.rrhh_planillas_externas_det.count({
            where: { planilla_id: pid, aplicado: false },
          });
          if (pendientes === 0) {
            await tx.rrhh_planillas_externas.update({
              where: { id: pid },
              data: { estado: 'APLICADA', aplicada_en_liquidacion_id: id, fecha_aplicacion: new Date(), updated_at: new Date() },
            });
          }
        }
      }

      // 4) Novedades → linkear a la liquidación (quedan "consumidas")
      if (novedadIds.length) {
        await tx.rrhh_asistencia_novedades.updateMany({
          where: { id: { in: novedadIds } },
          data: { liquidacion_id: id, preliquidacion_id: null, updated_at: new Date() },
        });
      }

      // 5) Cerrar cabecera
      return tx.rrhh_liquidaciones_cabecera.update({
        where: { id },
        data: {
          estado: 'CERRADA',
          fecha_cierre: new Date(),
          cerrado_por: userId,
          observaciones: dto.comentario
            ? liq.observaciones
              ? `${liq.observaciones}\n[CIERRE] ${dto.comentario}`
              : `[CIERRE] ${dto.comentario}`
            : liq.observaciones,
          updated_at: new Date(),
        },
      });
    });

    // 6) Marcar las comisiones incluidas como INCLUIDA_EN_LIQUIDACION (inmutables tras el cierre).
    if (this.comisiones) {
      await this.comisiones.marcarIncluidas(id, empresaId).catch((err) => {
        this.contabilidadLogger.error(
          `No se pudieron marcar comisiones incluidas para liquidación ${id}: ${(err as Error).message}`,
        );
      });
    }

    // 7) Integración contable (asíncrono, no bloquea el cierre).
    //    Si la empresa no tiene Contabilidad o faltan mapeos, se loguea y sigue.
    this.contabilidadIntegracion
      .integrarLiquidacionRrhh(id)
      .catch((err) => {
        this.contabilidadLogger.error(
          `Integración contable falló para liquidación ${id}: ${(err as Error).message}`,
        );
      });

    return cerrada;
  }

  // ── Anular (solo BORRADOR / PRE_LIQUIDACION) ───────────
  async anular(id: string, empresaId: string, dto: AccionLiquidacionDto, userId?: string) {
    const liq = await this.prisma.rrhh_liquidaciones_cabecera.findFirst({
      where: { id, empresa_id: empresaId },
    });
    if (!liq) throw new NotFoundException('Liquidación no encontrada');
    if (liq.estado === 'CERRADA') {
      throw new ConflictException('Una liquidación cerrada no puede anularse (es inmutable).');
    }
    if (liq.estado === 'ANULADA') throw new ConflictException('La liquidación ya está anulada');

    return this.prisma.$transaction(async (tx) => {
      await tx.rrhh_asistencia_novedades.updateMany({
        where: { preliquidacion_id: id, liquidacion_id: null },
        data: { preliquidacion_id: null, updated_at: new Date() },
      });
      await tx.rrhh_liquidaciones_detalle.deleteMany({ where: { liquidacion_id: id } });
      await tx.rrhh_liq_empleado_resumen.deleteMany({ where: { liquidacion_id: id } });
      return tx.rrhh_liquidaciones_cabecera.update({
        where: { id },
        data: {
          estado: 'ANULADA',
          anulado_por: userId,
          fecha_anulacion: new Date(),
          total_ingresos: 0,
          total_egresos: 0,
          total_neto: 0,
          total_ips_obrero: 0,
          total_ips_patronal: 0,
          total_ips_admin: 0,
          cantidad_empleados: 0,
          recalculo_pendiente: false,
          observaciones: dto.comentario
            ? liq.observaciones
              ? `${liq.observaciones}\n[ANULACIÓN] ${dto.comentario}`
              : `[ANULACIÓN] ${dto.comentario}`
            : liq.observaciones,
          updated_at: new Date(),
        },
      });
    });
  }
}
