import { sincronizarCuentaCobrar as sincronizarCuentaCobrarUtil, type TxSincronizacion } from '../common/utils/sincronizar-cuenta.util';
import {
  BadRequestException,
  ForbiddenException,
  Injectable,
  Logger,
  NotFoundException,
} from '@nestjs/common';
import { PrismaService } from 'src/prisma/prisma.service';
import { ContabilidadIntegracionService } from 'src/contabilidad/services/integracion.service';
import { AuditService } from 'src/audit/audit.service';
import { CreateReciboDto, AnularReciboDto } from './dto/create-recibo.dto';
import { ReciboCobroEstado } from './recibos.enums';
import type { LoginUserInfo } from 'src/auth/dto/login.dto';
import { Decimal } from '@prisma/client/runtime/library';
import { Prisma } from '@prisma/client';
import { envs } from 'src/config/envs';
import { PutObjectCommand, S3Client } from '@aws-sdk/client-s3';
import { randomUUID } from 'crypto';
import { sqlFacturaCobrable } from 'src/utils/estados-factura';
import { logoEmisor, nombreFantasiaEmisor } from 'src/utils/emisor-comprobante';
import { consumirSaldoNc, reponerSaldoNc } from './saldo-nc';

function parseFechaLocalRecibo(value?: string | null): Date | undefined {
  if (!value) return undefined;
  if (/^\d{4}-\d{2}-\d{2}$/.test(value)) return new Date(`${value}T12:00:00`);
  return new Date(value);
}

@Injectable()
export class RecibosService {
  private readonly logger = new Logger(RecibosService.name);

  constructor(
    private readonly prisma: PrismaService,
    private readonly contabilidad: ContabilidadIntegracionService,
    private readonly auditService: AuditService,
  ) {}

  // ─── Listado y consulta ────────────────────────────────────────────────────

  async listar(empresaId: string, filters: {
    cliente_id?: string;
    cobrador_id?: string;
    estado?: string;
    desde?: string;
    hasta?: string;
    search?: string;
    page?: number;
    limit?: number;
  }) {
    const page = filters.page ?? 1;
    const limit = Math.min(filters.limit ?? 20, 100);
    const skip = (page - 1) * limit;

    const where: any = { empresa_id: empresaId, modo: 'MULTI' };
    if (filters.cliente_id)  where.cliente_id  = filters.cliente_id;
    if (filters.cobrador_id) where.cobrador_id = filters.cobrador_id;
    if (filters.estado)      where.estado      = filters.estado;
    if (filters.desde || filters.hasta) {
      where.fecha_emision = {};
      if (filters.desde) where.fecha_emision.gte = new Date(filters.desde);
      if (filters.hasta) {
        const h = new Date(filters.hasta);
        if (/^\d{4}-\d{2}-\d{2}$/.test(filters.hasta)) {
          h.setUTCHours(23, 59, 59, 999);
        }
        where.fecha_emision.lte = h;
      }
    }

    const search = filters.search?.trim();
    if (search) {
      delete where.fecha_emision;
      where.OR = [
        { numero_recibo: { contains: search, mode: 'insensitive' } },
        { clientes: { personas: { ruc:           { contains: search, mode: 'insensitive' } } } },
        { clientes: { personas: { nro_documento: { contains: search, mode: 'insensitive' } } } },
        { clientes: { personas: { razon_social:  { contains: search, mode: 'insensitive' } } } },
      ];
    }

    const [total, rows] = await Promise.all([
      this.prisma.recibos_cobro.count({ where }),
      this.prisma.recibos_cobro.findMany({
        where,
        orderBy: { fecha_emision: 'desc' },
        skip,
        take: limit,
        include: {
          // Auditoría: caja/sesión donde entró el cobro (null en cobros mobile,
          // que se rinden por rendiciones).
          sesiones_caja: {
            select: {
              id: true,
              estado: true,
              fecha_apertura: true,
              fecha_cierre: true,
              cajas: { select: { id: true, descripcion: true } },
              usuario: { select: { id: true, nombres: true, apellidos: true } },
            },
          },
          rendiciones_cobranza: {
            select: { id: true, codigo: true, numero: true, estado: true, fecha_rendicion: true },
          },
          facturas_multi:     { select: { id: true, factura_cab_id: true, monto_pagado: true } },
          nc_aplicadas_multi: { select: { id: true, nota_credito_id: true, monto: true } },
          retenciones_multi:  { select: { id: true, tipo: true, monto: true } },
          medios_pago_multi:  { select: { id: true, medio: true, monto: true } },
          clientes:           { select: { personas: { select: { razon_social: true, ruc: true, nro_documento: true } } } },
        },
      }),
    ]);

    const items = rows.map((r: any) => ({
      ...this._mapReciboOut(r),
      cliente_razon_social: r.clientes?.personas?.razon_social ?? null,
      cliente_ruc:          r.clientes?.personas?.ruc ?? null,
      cliente_nro_documento: r.clientes?.personas?.nro_documento ?? null,
    }));
    const pages = Math.max(1, Math.ceil(total / limit));
    // `data` para compat con frontend, `items` para compat con clientes anteriores.
    return { total, page, limit, pages, data: items, items };
  }

  async detalle(empresaId: string, reciboId: string) {
    const recibo = await this.prisma.recibos_cobro.findFirst({
      where: { id: reciboId, empresa_id: empresaId, modo: 'MULTI' },
      include: {
        // Auditoría: caja/sesión donde entró el cobro (null en cobros mobile,
        // que se rinden por rendiciones).
        sesiones_caja: {
          select: {
            id: true,
            estado: true,
            fecha_apertura: true,
            fecha_cierre: true,
            cajas: { select: { id: true, descripcion: true } },
            usuario: { select: { id: true, nombres: true, apellidos: true } },
          },
        },
        rendiciones_cobranza: {
          select: { id: true, codigo: true, numero: true, estado: true, fecha_rendicion: true },
        },
        facturas_multi: {
          include: {
            factura: { select: { dest: true, dpunexp: true, dnumdoc: true } },
          },
        },
        nc_aplicadas_multi: {
          include: {
            nota_credito: { select: { dest: true, dpunexp: true, dnumdoc: true } },
          },
        },
        retenciones_multi:  true,
        medios_pago_multi:  { include: { moneda: true } },
        facturas_compra_aplicadas: {
          include: {
            compra: { select: { establecimiento: true, punto_expedicion: true, numero_factura: true, fecha_emision: true, total: true } },
          },
        },
        gastos_asociados: {
          include: {
            gasto: {
              select: {
                establecimiento: true, punto_expedicion: true, numero_factura: true, fecha_emision: true, total: true,
                proveedores: { select: { personas: { select: { razon_social: true } } } },
              },
            },
          },
        },
        clientes: { include: { personas: true } },
        empresas: true,
        moneda: true,
        usuario: { select: { id: true, nombres: true, apellidos: true } },
        cobrador: { select: { id: true, nombre: true, apellido: true } },
      },
    });
    if (!recibo) throw new NotFoundException('Recibo no encontrado');
    return this._mapReciboOut(recibo);
  }

  // ─── Facturas por acuerdo comercial (compras) compensables ─────────────────

  async comprasCompensablesCliente(empresaId: string, clienteId: string) {
    return this.prisma.$queryRaw<any[]>`
      SELECT
        cc.id,
        cc.establecimiento || '-' || cc.punto_expedicion || '-' || cc.numero_factura AS numero,
        cc.fecha_emision,
        cc.total,
        cc.timbrado_proveedor AS timbrado,
        m.codigo AS moneda,
        per.razon_social AS proveedor_razon_social,
        per.ruc AS proveedor_ruc,
        COALESCE((
          SELECT SUM(cp.saldo_pendiente)
          FROM cuentas_pagar cp
          WHERE cp.compra_id = cc.id
            AND COALESCE(cp.activo, true) = true
            AND COALESCE(cp.estado, 'pendiente') NOT IN ('pagada','anulada')
        ), 0) AS saldo_pendiente
      FROM compra_cab cc
      JOIN proveedores p ON p.id = cc.proveedor_id
      LEFT JOIN personas per ON per.id = p.persona_id
      LEFT JOIN moneda m ON m.id = cc.moneda_id
      WHERE cc.empresa_id = ${empresaId}::uuid
        AND COALESCE(cc.anulado, false) = false
        AND p.persona_id = (SELECT persona_id FROM clientes WHERE id = ${clienteId}::uuid)
        AND COALESCE((
          SELECT SUM(cp.saldo_pendiente)
          FROM cuentas_pagar cp
          WHERE cp.compra_id = cc.id
            AND COALESCE(cp.activo, true) = true
            AND COALESCE(cp.estado, 'pendiente') NOT IN ('pagada','anulada')
        ), 0) > 0
      ORDER BY cc.fecha_emision ASC
    `;
  }

  async gastosDisponibles(
    empresaId: string,
    opts: { moneda?: string; search?: string; limit?: number; clienteId?: string },
  ) {
    const take = Math.max(1, Math.min(100, Number(opts.limit ?? 20)));
    const search = opts.search?.trim() ?? '';
    const moneda = (opts.moneda ?? '').toUpperCase();
    const clienteId = opts.clienteId?.trim() || null;
    // `es_del_cliente`: el proveedor del gasto es la misma persona/RUC que el cliente
    // cobrado (acuerdo comercial). No filtra —trae todos— pero los marca y prioriza arriba.
    return this.prisma.$queryRaw<any[]>`
      SELECT
        gc.id                                                                      AS gasto_id,
        gc.establecimiento || '-' || gc.punto_expedicion || '-' || gc.numero_factura AS numero_documento,
        gc.timbrado,
        gc.fecha_emision,
        gc.total,
        gc.deducible,
        m.codigo                                                                   AS moneda,
        per.razon_social                                                            AS proveedor_razon_social,
        per.ruc                                                                     AS proveedor_ruc,
        cp.id                                                                       AS cuenta_pagar_id,
        cp.saldo_pendiente,
        (
          ${clienteId}::uuid IS NOT NULL
          AND prov.persona_id IS NOT NULL
          AND prov.persona_id = (SELECT persona_id FROM clientes WHERE id = ${clienteId}::uuid)
        )                                                                          AS es_del_cliente
      FROM gasto_cab gc
      LEFT JOIN proveedores prov ON prov.id = gc.proveedor_id
      LEFT JOIN personas per ON per.id = prov.persona_id
      LEFT JOIN moneda m ON m.id = gc.moneda_id
      JOIN cuentas_pagar cp
        ON cp.origen_tipo = 'gasto'
       AND cp.origen_id   = gc.id
       AND COALESCE(cp.activo, true) = true
       AND cp.estado NOT IN ('pagada', 'anulada')
       AND cp.saldo_pendiente > 0
      WHERE gc.empresa_id = ${empresaId}::uuid
        AND COALESCE(gc.anulado, false) = false
        AND NOT EXISTS (
          SELECT 1 FROM rec_multi_gastos rmg
          JOIN recibos_cobro rc ON rc.id = rmg.recibo_id
          WHERE rmg.gasto_id = gc.id
            AND rc.estado != 'anulado'
        )
        AND (${moneda} = '' OR UPPER(COALESCE(m.codigo, 'PYG')) = ${moneda})
        AND (${search} = '' OR per.razon_social ILIKE ${'%' + search + '%'} OR per.ruc ILIKE ${'%' + search + '%'} OR gc.numero_factura ILIKE ${'%' + search + '%'})
      ORDER BY es_del_cliente DESC, gc.fecha_emision ASC
      LIMIT ${take}
    `;
  }

  private _mapReciboOut(r: any) {
    const fmtDoc = (d: any) =>
      d ? `${d.dest}-${d.dpunexp}-${d.dnumdoc}` : null;
    const {
      facturas_multi,
      nc_aplicadas_multi,
      retenciones_multi,
      medios_pago_multi,
      facturas_compra_aplicadas,
      gastos_asociados,
      ...rest
    } = r;
    return {
      ...rest,
      estado: rest.estado,
      numero: rest.numero_recibo,
      fecha:  rest.fecha_emision,
      cliente_razon_social:  rest.clientes?.personas?.razon_social ?? null,
      cliente_ruc:           rest.clientes?.personas?.ruc ?? null,
      cliente_nro_documento: rest.clientes?.personas?.nro_documento ?? null,
      facturas: (facturas_multi ?? []).map((f: any) => ({
        ...f,
        factura_id: f.factura_cab_id,
        factura_numero: fmtDoc(f.factura),
      })),
      nc_aplicadas: (nc_aplicadas_multi ?? []).map((nc: any) => ({
        ...nc,
        nc_id: nc.nota_credito_id,
        nc_numero: fmtDoc(nc.nota_credito),
        monto_aplicado: nc.monto,
      })),
      retenciones: retenciones_multi ?? [],
      medios_pago: medios_pago_multi ?? [],
      facturas_compra_aplicadas: (facturas_compra_aplicadas ?? []).map((fc: any) => ({
        id: fc.id,
        compra_cab_id: fc.compra_cab_id,
        monto: fc.monto,
        observacion: fc.observacion,
        compra_numero: fc.compra
          ? [fc.compra.establecimiento, fc.compra.punto_expedicion, fc.compra.numero_factura].filter(Boolean).join('-')
          : null,
        compra_fecha: fc.compra?.fecha_emision ?? null,
      })),
      gastos_asociados: (gastos_asociados ?? []).map((g: any) => ({
        id: g.id,
        gasto_id: g.gasto_id,
        cuenta_pagar_id: g.cuenta_pagar_id,
        monto_aplicado: Number(g.monto_aplicado || 0),
        observacion: g.observacion ?? null,
        gasto_numero: g.gasto
          ? [g.gasto.establecimiento, g.gasto.punto_expedicion, g.gasto.numero_factura].filter(Boolean).join('-')
          : null,
        gasto_fecha: g.gasto?.fecha_emision ?? null,
        proveedor_razon_social: g.gasto?.proveedores?.personas?.razon_social ?? null,
      })),
    };
  }

  // ─── PDF A4 (recibo multi) ────────────────────────────────────────────────

  async pdfA4Multi(
    empresaId: string,
    reciboId: string,
    options: { tipo_retorno?: 'view' | 'base64' } = {},
  ): Promise<{ pdfBuffer?: Buffer; base64?: string; contentType: string }> {
    const { tipo_retorno = 'view' } = options;

    const recibo: any = await this.prisma.recibos_cobro.findFirst({
      where: { id: reciboId, empresa_id: empresaId, modo: 'MULTI' } as any,
      include: {
        facturas_multi: {
          include: { factura: { select: { dest: true, dpunexp: true, dnumdoc: true } } },
        } as any,
        nc_aplicadas_multi: {
          include: { nota_credito: { select: { dest: true, dpunexp: true, dnumdoc: true } } },
        } as any,
        retenciones_multi: true as any,
        medios_pago_multi: true as any,
        facturas_compra_aplicadas: {
          include: { compra: { select: { establecimiento: true, punto_expedicion: true, numero_factura: true, fecha_emision: true } } },
        } as any,
        gastos_asociados: {
          include: {
            gasto: {
              select: {
                establecimiento: true,
                punto_expedicion: true,
                numero_factura: true,
                fecha_emision: true,
                proveedores: { include: { personas: true } },
              },
            },
          },
        } as any,
        clientes: { include: { personas: true } },
        empresas: true,
        // Logo y nombre comercial salen de la sucursal que emitió el recibo.
        empresas_sucursales: true,
        moneda: true,
        usuario: { select: { id: true, nombres: true, apellidos: true } },
      } as any,
    });

    if (!recibo) throw new NotFoundException('Recibo no encontrado');

    const empresa = recibo.empresas;
    const persona = recibo.clientes?.personas;
    const moneda = recibo.moneda;

    let tipoDocumento = 'RUC';
    let documentoCliente = '';
    if (persona?.ruc) {
      documentoCliente = persona.dv ? `${persona.ruc}-${persona.dv}` : persona.ruc;
    } else if (persona?.nro_documento) {
      documentoCliente = persona.nro_documento;
      tipoDocumento = 'CI';
    }

    const fmtDoc = (d: any) => (d ? `${d.dest}-${d.dpunexp}-${d.dnumdoc}` : '-');

    const facturas = (recibo.facturas_multi ?? []).map((f: any) => ({
      factura_numero: fmtDoc(f.factura),
      monto_saldo_previo: Number(f.monto_saldo_previo || 0),
      monto_interes:      Number(f.monto_interes || 0),
      monto_nc:           Number(f.monto_nc || 0),
      monto_retencion:    Number(f.monto_retencion || 0),
      monto_pagado:       Number(f.monto_pagado || 0),
    }));

    const ncAplicadas = (recibo.nc_aplicadas_multi ?? []).map((nc: any) => ({
      nc_numero: fmtDoc(nc.nota_credito),
      monto:     Number(nc.monto || 0),
    }));

    const retenciones = (recibo.retenciones_multi ?? []).map((r: any) => ({
      tipo:               r.tipo,
      numero_comprobante: r.numero_comprobante || null,
      monto:              Number(r.monto || 0),
    }));

    const mediosPago = (recibo.medios_pago_multi ?? []).map((m: any) => ({
      medio:                    m.medio,
      monto:                    Number(m.monto || 0),
      referencia:               m.referencia || null,
      cheque_numero:            m.cheque_numero || null,
      cheque_banco:             m.cheque_banco || null,
      cheque_titular:           m.cheque_titular || null,
      cheque_fecha_emision:     m.cheque_fecha_emision || null,
      cheque_fecha_vencimiento: m.cheque_fecha_vencimiento || null,
    }));

    const payload = {
      formato: 'a4',
      modo:    'MULTI',
      tipo:    tipo_retorno,
      empresa: {
        razon_social: empresa?.razon_social || '',
        ruc:          empresa?.ruc || '',
        dv:           empresa?.dv || '',
        direccion:    '',
        telefono:     empresa?.celular || '',
        email:        empresa?.email || '',
        logo_url:     logoEmisor(recibo.empresas_sucursales, empresa),
        nombre_fantasia: nombreFantasiaEmisor(recibo.empresas_sucursales, empresa),
      },
      numero_recibo:  recibo.numero_recibo,
      fecha_emision:  recibo.fecha_emision?.toISOString(),
      estado:         recibo.estado,
      moneda: moneda ? { codigo: moneda.codigo, simbolo: moneda.simbolo, decimales: moneda.decimales }
                     : { codigo: 'PYG', simbolo: '₲', decimales: 0 },
      cotizacion:     recibo.cotizacion ? Number(recibo.cotizacion) : null,
      observacion:    recibo.observacion || null,
      cliente: {
        razon_social:   persona?.razon_social || '',
        documento:      documentoCliente,
        tipo_documento: tipoDocumento,
        direccion:      persona?.direccion || '',
        telefono:       persona?.telefono || persona?.celular || '',
        email:          persona?.email || '',
      },
      facturas,
      nc_aplicadas:    ncAplicadas,
      facturas_compra_aplicadas: (recibo.facturas_compra_aplicadas ?? []).map((fc: any) => ({
        compra_numero: fc.compra ? [fc.compra.establecimiento, fc.compra.punto_expedicion, fc.compra.numero_factura].filter(Boolean).join('-') : '-',
        compra_fecha: fc.compra?.fecha_emision ?? null,
        monto: Number(fc.monto || 0),
        observacion: fc.observacion || null,
      })),
      gastos_aplicados: (recibo.gastos_asociados ?? []).map((g: any) => ({
        gasto_numero: g.gasto ? [g.gasto.establecimiento, g.gasto.punto_expedicion, g.gasto.numero_factura].filter(Boolean).join('-') : '-',
        gasto_fecha: g.gasto?.fecha_emision ?? null,
        proveedor: g.gasto?.proveedores?.personas?.razon_social ?? null,
        monto: Number(g.monto_aplicado || 0),
        observacion: g.observacion || null,
      })),
      retenciones,
      medios_pago:     mediosPago,
      totales: {
        total_facturas:    Number(recibo.total_facturas || 0),
        total_intereses:   Number(recibo.total_intereses || 0),
        total_nc:          Number(recibo.total_nc || 0),
        total_retenciones: Number(recibo.total_retenciones || 0),
        total_facturas_compra: Number(recibo.total_facturas_compra || 0),
        total_gastos:          Number(recibo.total_gastos || 0),
        total_pagado:      Number(recibo.total_pagado || recibo.monto_total || 0),
        diferencia:        Number(recibo.diferencia || 0),
        diferencia_tipo:   recibo.diferencia_tipo || null,
        diferencia_motivo: recibo.diferencia_motivo || null,
      },
      anulacion: recibo.estado === ReciboCobroEstado.ANULADO ? {
        fecha:  recibo.fecha_anulacion?.toISOString() || null,
        motivo: recibo.motivo_anulacion || null,
      } : null,
    };

    const url = `${envs.apiGeneradorPDF}/api/v2/generate-recibo`;
    try {
      const response = await fetch(url, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(payload),
      });
      if (!response.ok) {
        const errorBody = await response.text();
        throw new BadRequestException(`Error al generar PDF: ${errorBody}`);
      }
      if (tipo_retorno === 'base64') {
        const json = (await response.json()) as any;
        if (json.status !== 'success') {
          throw new BadRequestException(json.message || 'Error al generar recibo');
        }
        return { base64: json.pdf_base64, contentType: 'application/json' };
      }
      const arrayBuffer = await response.arrayBuffer();
      return { pdfBuffer: Buffer.from(arrayBuffer), contentType: 'application/pdf' };
    } catch (error) {
      if (error instanceof BadRequestException) throw error;
      const msg = error instanceof Error ? error.message : 'Error desconocido';
      throw new BadRequestException(`No se pudo generar el PDF: ${msg}`);
    }
  }

  // ─── Datos del cliente para el wizard ─────────────────────────────────────

  async facturasPendientesCliente(empresaId: string, clienteId: string) {
    const result = await this.prisma.$queryRaw<any[]>`
      SELECT
        fc.id,
        fc.dest || '-' || fc.dpunexp || '-' || fc.dnumdoc AS numero,
        fc.dfeemide AS fecha_emision,
        fc.dfeemide AS fecha,
        fc.total_factura,
        COALESCE(
          cuotas.saldo_cuotas,
          fc.saldo_pendiente,
          fc.total_factura
        ) AS saldo_pendiente,
        fc.moneda_id,
        co.codigo AS condicion_codigo,
        fc.cobro_diferido,
        cuotas.cuotas_json AS cuotas,
        cuotas.proxima_vencimiento AS fecha_vencimiento
      FROM factura_cab fc
      LEFT JOIN condicion_operacion co ON co.id = fc.condicion_operacion_id
      LEFT JOIN (
        SELECT
          factura_cab_id,
          SUM(
            CASE
              WHEN LOWER(estado) IN ('pagado','anulado','anulada') THEN 0
              ELSE COALESCE(NULLIF(saldo_pendiente, 0), dmoncuota, 0)
            END
          ) AS saldo_cuotas,
          MIN(
            CASE
              WHEN LOWER(estado) IN ('pagado','anulado','anulada') THEN NULL
              ELSE dvenccuo
            END
          ) AS proxima_vencimiento,
          json_agg(
            json_build_object(
              'id',               id,
              'nro_cuota',        nro_cuota,
              'fecha_vencimiento',dvenccuo,
              'monto_cuota',      dmoncuota,
              'saldo_pendiente',  COALESCE(NULLIF(saldo_pendiente, 0), dmoncuota, 0),
              'estado',           estado
            ) ORDER BY nro_cuota ASC
          ) AS cuotas_json
        FROM factura_cuotas
        GROUP BY factura_cab_id
      ) cuotas ON cuotas.factura_cab_id = fc.id
      WHERE fc.empresa_id = ${empresaId}::uuid
        AND fc.cliente_id = ${clienteId}::uuid
        AND ${sqlFacturaCobrable('fc')}
        AND COALESCE(
              cuotas.saldo_cuotas,
              fc.saldo_pendiente,
              fc.total_factura
            ) > 0
      ORDER BY fc.dfeemide ASC
    `;
    return result;
  }

  /**
   * Devuelve el último recibo emitido aplicado a la factura, con detalle de
   * medios de pago y monto aplicado. Considera ambos modos (MULTI y LEGACY).
   * Retorna null si no hay pagos.
   */
  async ultimoPagoFactura(empresaId: string, facturaId: string) {
    const rows = await this.prisma.$queryRaw<any[]>`
      SELECT
        r.id                        AS recibo_id,
        r.numero_recibo,
        r.fecha_emision,
        r.fecha_registro,
        r.monto_total,
        r.observacion,
        r.modo,
        m.codigo                    AS moneda_codigo,
        r.cotizacion,
        u.nombres                   AS usuario_nombres,
        u.apellidos                 AS usuario_apellidos,
        u.username                  AS usuario_username,
        TRIM(CONCAT(COALESCE(vc.nombre, ''), ' ', COALESCE(vc.apellido, ''))) AS cobrador_nombre,
        CASE
          WHEN r.modo = 'MULTI' THEN (
            SELECT COALESCE(SUM(monto_pagado), 0)
            FROM recibo_cobro_facturas
            WHERE recibo_cobro_id = r.id AND factura_cab_id = ${facturaId}::uuid
          )
          ELSE (
            SELECT COALESCE(SUM(rcd.monto_pagado), 0)
            FROM recibo_cobro_detalle rcd
            JOIN factura_cuotas fcu ON fcu.id = rcd.factura_cuota_id
            WHERE rcd.recibo_cobro_id = r.id AND fcu.factura_cab_id = ${facturaId}::uuid
          )
        END                         AS monto_aplicado_factura
      FROM recibos_cobro r
      LEFT JOIN moneda m                  ON m.id  = r.moneda_id
      LEFT JOIN usuario u                 ON u.id  = r.usuario_id
      LEFT JOIN vendedores_cobradores vc  ON vc.id = r.cobrador_id
      WHERE r.empresa_id = ${empresaId}::uuid
        AND r.estado = 'emitido'
        AND r.id IN (
          SELECT recibo_cobro_id FROM recibo_cobro_facturas
            WHERE factura_cab_id = ${facturaId}::uuid
          UNION
          SELECT rcd.recibo_cobro_id FROM recibo_cobro_detalle rcd
            JOIN factura_cuotas fcu ON fcu.id = rcd.factura_cuota_id
            WHERE fcu.factura_cab_id = ${facturaId}::uuid
        )
      ORDER BY r.fecha_emision DESC
      LIMIT 1
    `;

    if (!rows.length) return null;
    const recibo = rows[0];

    // Medios de pago del recibo (solo modo MULTI los tiene en esta tabla)
    const mediosMulti = await this.prisma.$queryRaw<any[]>`
      SELECT
        mp.medio,
        mp.monto,
        mp.referencia,
        mp.cheque_numero,
        mp.cheque_banco,
        m.codigo AS moneda_codigo,
        ct.nombre AS cuenta_tesoreria_nombre
      FROM recibo_cobro_medios_pago mp
      LEFT JOIN moneda m       ON m.id  = mp.moneda_id
      LEFT JOIN tes_cuentas ct ON ct.id = mp.cuenta_tesoreria_id
      WHERE mp.recibo_cobro_id = ${recibo.recibo_id}::uuid
      ORDER BY mp.created_at ASC
    `;

    // Medios de pago modo LEGACY (recibo_cobro_detalle)
    const mediosLegacy = await this.prisma.$queryRaw<any[]>`
      SELECT
        mp.descripcion AS medio,
        SUM(rcd.monto_pagado) AS monto,
        rcd.numero_cheque AS cheque_numero,
        rcd.banco_emisor  AS cheque_banco,
        m.codigo AS moneda_codigo
      FROM recibo_cobro_detalle rcd
      LEFT JOIN medio_pago mp ON mp.id = rcd.medio_pago_id
      LEFT JOIN moneda m       ON m.id = rcd.moneda_id
      WHERE rcd.recibo_cobro_id = ${recibo.recibo_id}::uuid
      GROUP BY mp.descripcion, rcd.numero_cheque, rcd.banco_emisor, m.codigo
    `;

    const medios_pago = recibo.modo === 'MULTI' ? mediosMulti : mediosLegacy;

    return {
      ...recibo,
      medios_pago,
    };
  }

  /** Lista los recibos donde se aplicó esta NC, con monto y factura destino. */
  async aplicacionesDeNC(empresaId: string, notaCreditoId: string) {
    return this.prisma.$queryRaw<any[]>`
      SELECT
        nca.id,
        nca.monto,
        nca.modo,
        nca.created_at,
        r.id                   AS recibo_id,
        r.numero_recibo,
        r.fecha_emision,
        r.estado               AS recibo_estado,
        fc.id                  AS factura_id,
        fc.dest || '-' || fc.dpunexp || '-' || fc.dnumdoc AS factura_numero
      FROM recibo_cobro_nc_aplicadas nca
      JOIN recibos_cobro r          ON r.id = nca.recibo_cobro_id
      JOIN recibo_cobro_facturas rf ON rf.id = nca.recibo_factura_id
      JOIN factura_cab fc           ON fc.id = rf.factura_cab_id
      WHERE nca.nota_credito_id = ${notaCreditoId}::uuid
        AND r.empresa_id = ${empresaId}::uuid
      ORDER BY r.fecha_emision DESC
    `;
  }

  async ncDisponiblesCliente(empresaId: string, clienteId: string) {
    const result = await this.prisma.$queryRaw<any[]>`
      SELECT
        nc.id,
        nc.dest || '-' || nc.dpunexp || '-' || nc.dnumdoc AS numero,
        nc.dfeemide AS fecha,
        nc.factura_cab_id,
        CASE WHEN fc.id IS NOT NULL
          THEN fc.dest || '-' || fc.dpunexp || '-' || fc.dnumdoc
          ELSE NULL
        END AS factura_numero,
        COALESCE(nc.rec_saldo_disponible, (
          SELECT COALESCE(SUM(ns.dtotgralope), 0) FROM nota_credito_subtotal ns WHERE ns.nota_credito_cab_id = nc.id
        )) AS saldo_disponible,
        nc.rec_modo_aplicacion
      FROM nota_credito_cab nc
      LEFT JOIN factura_cab fc ON fc.id = nc.factura_cab_id
      WHERE nc.empresa_id = ${empresaId}::uuid
        AND nc.cliente_id = ${clienteId}::uuid
        AND nc.estado NOT IN ('Anulado','Anulada')
        AND COALESCE(nc.estado_sifen, 'Aprobado') NOT IN ('Rechazado','Cancelado','Inutilizado')
        AND COALESCE(nc.rec_aplicacion_reversada, FALSE) = FALSE
        AND COALESCE(nc.rec_saldo_disponible, 1) > 0
      ORDER BY nc.dfeemide ASC
    `;
    return result;
  }

  async marcarNcConsumida(empresaId: string, ncId: string) {
    const nc = await this.prisma.nota_credito_cab.findFirst({
      where: { id: ncId, empresa_id: empresaId },
      select: { id: true, rec_saldo_disponible: true },
    });
    if (!nc) throw new BadRequestException('NC no encontrada');
    await this.prisma.nota_credito_cab.update({
      where: { id: ncId },
      data: { rec_saldo_disponible: new Decimal(0) },
    });
    return { ok: true };
  }

  async saldosFavorCliente(empresaId: string, clienteId: string) {
    return this.prisma.rec_saldos_cliente.findMany({
      where: {
        empresa_id: empresaId,
        cliente_id: clienteId,
        anulado:    false,
        monto_disponible: { gt: 0 },
      },
      orderBy: { created_at: 'asc' },
    });
  }

  async listarSaldosFavor(
    empresaId: string,
    filtros: { cliente_id?: string; solo_disponibles?: boolean },
  ) {
    const rows = await this.prisma.$queryRaw<any[]>`
      SELECT
        sc.id,
        sc.cliente_id,
        per.razon_social     AS cliente_razon_social,
        per.ruc              AS cliente_ruc,
        per.nro_documento    AS cliente_nro_documento,
        sc.origen,
        sc.origen_id,
        rm.numero_recibo     AS recibo_origen_numero,
        sc.monto_original,
        sc.monto_disponible,
        sc.monto_original - sc.monto_disponible AS monto_consumido,
        sc.moneda,
        sc.anulado,
        sc.created_at,
        sc.updated_at,
        (
          SELECT json_agg(json_build_object(
            'id',         mov.id,
            'tipo',       mov.tipo,
            'monto',      mov.monto,
            'recibo_id',  mov.recibo_id,
            'recibo_numero', rmm.numero_recibo,
            'created_at', mov.created_at
          ) ORDER BY mov.created_at ASC)
          FROM rec_saldos_cliente_mov mov
          LEFT JOIN recibos_cobro rmm ON rmm.id = mov.recibo_id
          WHERE mov.saldo_id = sc.id
        ) AS movimientos
      FROM rec_saldos_cliente sc
      LEFT JOIN clientes cli ON cli.id = sc.cliente_id
      LEFT JOIN personas per ON per.id = cli.persona_id
      LEFT JOIN recibos_cobro rm ON rm.id = sc.origen_id
      WHERE sc.empresa_id = ${empresaId}::uuid
        ${filtros.cliente_id ? Prisma.sql`AND sc.cliente_id = ${filtros.cliente_id}::uuid` : Prisma.empty}
        ${filtros.solo_disponibles ? Prisma.sql`AND sc.anulado = false AND sc.monto_disponible > 0` : Prisma.empty}
      ORDER BY sc.created_at DESC
      LIMIT 500
    `;
    return rows;
  }

  // ─── Preview (cálculo sin persistir) ─────────────────────────────────────

  async preview(empresaId: string, dto: CreateReciboDto, usuarioId: string) {
    return this._calcular(empresaId, dto, usuarioId);
  }

  /**
   * Deja `cuentas_cobrar` y `clientes.saldo_pendiente` alineados con el estado real
   * de las cuotas de una factura.
   *
   * La lógica vive en common/utils/sincronizar-cuenta.util.ts, compartida con el
   * cobro, las notas de crédito, la refinanciación y la anulación de facturas:
   * este flujo era el único que lo hacía bien y el resto arrastraba el desfase.
   */
  private async sincronizarCuentaCobrar(tx: Prisma.TransactionClient, facturaId: string) {
    await sincronizarCuentaCobrarUtil(tx as unknown as TxSincronizacion, facturaId);
  }

  // ─── Crear recibo ─────────────────────────────────────────────────────────

  async crear(empresaId: string, dto: CreateReciboDto, user: LoginUserInfo) {
    if (!dto.sucursal_id) {
      const suc = await this.prisma.empresas_sucursales.findFirst({
        where: { empresa_id: empresaId, active: true },
        orderBy: { punto_establecimiento: 'asc' },
        select: { id: true },
      });
      if (!suc) {
        throw new BadRequestException('La empresa no tiene sucursales activas configuradas');
      }
      dto.sucursal_id = suc.id;
    }

    const calc = await this._calcular(empresaId, dto, user.id);

    if (calc.diferencia_tipo === 'AJUSTE_MANUAL') {
      const config = await this._getConfig(empresaId);
      const tope = Number(config?.diferencia_max_ajuste ?? 1000);
      if (Math.abs(calc.diferencia) > tope) {
        throw new BadRequestException(
          `La diferencia (${Math.abs(calc.diferencia)}) supera el tope de ajuste manual (${tope})`
        );
      }
    }

    if ((calc.gastos_aplicados ?? []).length > 0) {
      const config = await this._getConfig(empresaId);
      const topePct = config?.tope_gastos_pct != null ? Number(config.tope_gastos_pct) : null;
      if (topePct != null && calc.total_facturas > 0) {
        const pct = (calc.total_gastos / calc.total_facturas) * 100;
        if (pct > topePct) {
          throw new BadRequestException(
            `Los gastos (${pct.toFixed(1)}% del total de facturas) superan el tope configurado (${topePct}%)`
          );
        }
      }
    }

    // Resolver moneda string → moneda_id (UUID). Cacheamos por código.
    const monedaCache = new Map<string, string | null>();
    const resolveMoneda = async (codigo?: string | null): Promise<string | null> => {
      const c = (codigo ?? 'PYG').toUpperCase();
      if (monedaCache.has(c)) return monedaCache.get(c)!;
      const m = await this.prisma.moneda.findFirst({ where: { codigo: c }, select: { id: true } });
      const id = m?.id ?? null;
      monedaCache.set(c, id);
      return id;
    };

    const monedaIdRecibo = await resolveMoneda(dto.moneda);

    // Orden de prioridad para cobrador_id:
    //   1. dto.cobrador_id (override manual)
    //   2. cobrador vinculado al usuario logueado
    //   3. factura_cab.cobrador_id de alguna de las facturas del recibo
    //   4. null
    let cobradorDefault: string | null = null;
    if (!dto.cobrador_id) {
      const cobradorVinculado = await this.prisma.vendedores_cobradores.findFirst({
        where: {
          usuario_id: user.id,
          empresa_id: empresaId,
          active: true,
          tipo: { in: ['cobrador', 'ambos'] },
        },
        select: { id: true },
      });
      if (cobradorVinculado) {
        cobradorDefault = cobradorVinculado.id;
      } else if (calc.facturas.length > 0) {
        const facturaIds = calc.facturas.map((f: any) => f.factura_id);
        const facturaConCobrador = await this.prisma.factura_cab.findFirst({
          where: { id: { in: facturaIds }, cobrador_id: { not: null } },
          select: { cobrador_id: true },
        });
        cobradorDefault = facturaConCobrador?.cobrador_id ?? null;
      }
    }

    const resultado = await this.prisma.$transaction(async (tx) => {
      const tesoreriaWarnings: string[] = [];
      // Numeración correlativa unificada con recibos legacy:
      // usa numeraciones_documento (tipo_documento codigo=100) + punto de expedición.
      const numero = await this._generarNumeroTx(tx, empresaId, dto.sucursal_id);

      // a) Cabecera (en recibos_cobro con modo='MULTI')
      const recibo = await tx.recibos_cobro.create({
        data: {
          empresa_id:        empresaId,
          sucursal_id:       dto.sucursal_id,
          cliente_id:        dto.cliente_id,
          cobrador_id:       dto.cobrador_id ?? cobradorDefault,
          usuario_id:        user.id,
          numero_recibo:     numero,
          fecha_emision:     parseFechaLocalRecibo(dto.fecha_emision) ?? new Date(),
          fecha_registro:    parseFechaLocalRecibo(dto.fecha_registro) ?? null,
          moneda_id:         monedaIdRecibo,
          cotizacion:        dto.tipo_cambio ? new Decimal(dto.tipo_cambio) : null,
          monto_total:       new Decimal(calc.total_pagado),
          total_facturas:    new Decimal(calc.total_facturas),
          total_intereses:   new Decimal(calc.total_intereses),
          total_nc:          new Decimal(calc.total_nc),
          total_retenciones: new Decimal(calc.total_retenciones),
          total_facturas_compra: new Decimal(calc.total_facturas_compra ?? 0),
          total_gastos:      new Decimal(calc.total_gastos ?? 0),
          total_pagado:      new Decimal(calc.total_pagado),
          diferencia:        new Decimal(calc.diferencia),
          diferencia_tipo:   calc.diferencia_tipo,
          diferencia_motivo: dto.diferencia_motivo ?? null,
          observacion:       dto.observacion ?? null,
          // Unificado a minúscula desde 2026-06-25 (antes 'CONFIRMADO' en flujo MULTI).
          estado:            ReciboCobroEstado.EMITIDO,
          modo:              'MULTI',
        },
      });

      // b) Facturas
      const facturaRecords = await Promise.all(
        calc.facturas.map((f, idx) =>
          tx.recibo_cobro_facturas.create({
            data: {
              recibo_cobro_id:    recibo.id,
              factura_cab_id:     f.factura_id,
              cuota_id:           f.cuota_id ?? null,
              monto_saldo_previo: new Decimal(f.monto_saldo_previo),
              monto_pagado:       new Decimal(f.monto_pagado),
              monto_nc:           new Decimal(f.monto_nc),
              monto_retencion:    new Decimal(f.monto_retencion),
              monto_interes:      new Decimal(f.monto_interes),
              orden:              idx,
            },
          })
        )
      );

      const facturaMap = new Map(facturaRecords.map((f, idx) => [calc.facturas[idx].factura_id, f.id]));

      // c) NC aplicadas
      for (const nc of calc.nc_aplicadas) {
        const recibo_factura_id = facturaMap.get(nc.factura_id_destino);
        if (!recibo_factura_id) {
          throw new BadRequestException(
            `La NC ${nc.nota_credito_id} apunta a la factura ${nc.factura_id_destino}, ` +
            `que no está incluida en este recibo. ` +
            `Para NC FLEXIBLE, elegí una factura del recibo como destino. ` +
            `Para NC ESTRICTO, incluí la factura origen.`
          );
        }
        await tx.recibo_cobro_nc_aplicadas.create({
          data: {
            recibo_cobro_id:   recibo.id,
            recibo_factura_id,
            nota_credito_id:   nc.nota_credito_id,
            monto:             new Decimal(nc.monto),
            modo:              nc.modo,
          },
        });
        // En los dos modos: si no se descuenta, la misma NC vuelve a ofrecerse
        // en el próximo cobro de la factura y se acredita dos veces.
        await consumirSaldoNc(tx, nc.nota_credito_id, nc.monto);
      }

      // d) Retenciones
      for (const ret of (dto.retenciones ?? [])) {
        const recibo_factura_id = facturaMap.get(ret.factura_ref);
        if (!recibo_factura_id) continue;
        await tx.recibo_cobro_retenciones.create({
          data: {
            recibo_cobro_id:    recibo.id,
            recibo_factura_id,
            regimen_id:         ret.regimen_id ?? null,
            tipo:               ret.tipo,
            porcentaje:         ret.porcentaje ? new Decimal(ret.porcentaje) : null,
            monto:              new Decimal(ret.monto),
            numero_comprobante: ret.numero_comprobante,
            timbrado:           ret.timbrado,
            fecha_comprobante:  new Date(ret.fecha_comprobante),
            archivo_url:        ret.archivo_url ?? null,
          },
        });
      }

      // d.bis) Facturas por acuerdo comercial — compensación contra compras del cliente como proveedor
      for (const fc of (calc.facturas_compra_aplicadas ?? [])) {
        await tx.recibo_cobro_facturas_compra_aplicadas.create({
          data: {
            recibo_cobro_id: recibo.id,
            compra_cab_id:   fc.compra_cab_id,
            monto:           new Decimal(fc.monto),
            observacion:     fc.observacion ?? null,
          },
        });

        // FIFO sobre cuentas_pagar (cuotas) de esa compra
        const cuotas = await tx.cuentas_pagar.findMany({
          where: {
            compra_id: fc.compra_cab_id,
            activo: true,
            estado: { notIn: ['pagada', 'anulada'] },
          },
          orderBy: [{ fecha_vencimiento: 'asc' }, { numero_cuota: 'asc' }],
        });
        let restante = fc.monto;
        for (const c of cuotas) {
          if (restante <= 0.0001) break;
          const saldoActual = Number(c.saldo_pendiente ?? 0);
          if (saldoActual <= 0) continue;
          const aplicar = Math.min(saldoActual, restante);
          const nuevoSaldo = +(saldoActual - aplicar).toFixed(2);
          const nuevoMontoPagado = +(Number(c.monto_pagado ?? 0) + aplicar).toFixed(2);
          const nuevoEstado = nuevoSaldo <= 0.0001 ? 'pagada' : 'parcial';
          await tx.cuentas_pagar.update({
            where: { id: c.id },
            data: {
              saldo_pendiente: new Decimal(nuevoSaldo),
              monto_pagado: new Decimal(nuevoMontoPagado),
              estado: nuevoEstado,
            },
          });
          restante = +(restante - aplicar).toFixed(2);
        }

        // Actualizar estado de compra_cab si quedó totalmente pagada
        const saldoRestante = await tx.cuentas_pagar.aggregate({
          where: {
            compra_id: fc.compra_cab_id,
            activo: true,
            estado: { notIn: ['anulada'] },
          },
          _sum: { saldo_pendiente: true },
        });
        const totalPendiente = Number(saldoRestante._sum.saldo_pendiente ?? 0);
        await tx.compra_cab.update({
          where: { id: fc.compra_cab_id },
          data: { estado: totalPendiente <= 0.0001 ? 'pagada' : 'parcial' },
        });
      }

      // d.ter) Gastos descontados del cobro (comisiones, fletes, bancarios, etc.)
      for (const g of (calc.gastos_aplicados ?? [])) {
        // SELECT FOR UPDATE: evita condición de carrera con OP paralelas sobre la misma CxP
        const cxpLocked = await tx.$queryRaw<any[]>`
          SELECT id, saldo_pendiente, monto_pagado, estado
          FROM cuentas_pagar
          WHERE id = ${g.cuenta_pagar_id}::uuid
            AND COALESCE(activo, true) = true
            AND estado NOT IN ('pagada', 'anulada')
          FOR UPDATE
        `;
        if (!cxpLocked.length) {
          throw new BadRequestException(`La CxP del gasto ${g.gasto_id} ya fue saldada por otro proceso. Actualizá el recibo.`);
        }
        const cxp = cxpLocked[0];
        const saldoActual = Number(cxp.saldo_pendiente ?? 0);
        if (g.monto_aplicado > saldoActual + 0.01) {
          throw new BadRequestException(`Monto aplicado al gasto (${g.monto_aplicado}) supera el saldo actual (${saldoActual})`);
        }

        // Verificar que no entró en otro recibo activo durante la carrera
        const enRecibo = await tx.$queryRaw<any[]>`
          SELECT 1 FROM rec_multi_gastos rmg
          JOIN recibos_cobro rc ON rc.id = rmg.recibo_id
          WHERE rmg.gasto_id = ${g.gasto_id}::uuid AND rc.estado != 'anulado'
          LIMIT 1
        `;
        if (enRecibo.length) {
          throw new BadRequestException(`El gasto ${g.gasto_id} fue asociado a otro recibo. Actualizá el recibo.`);
        }

        await tx.rec_multi_gastos.create({
          data: {
            recibo_id:       recibo.id,
            gasto_id:        g.gasto_id,
            cuenta_pagar_id: g.cuenta_pagar_id,
            monto_aplicado:  new Decimal(g.monto_aplicado),
            observacion:     g.observacion ?? null,
            creado_por:      user.id,
          },
        });

        const nuevoSaldo = +(saldoActual - g.monto_aplicado).toFixed(2);
        const nuevoMontoPagado = +(Number(cxp.monto_pagado ?? 0) + g.monto_aplicado).toFixed(2);
        await tx.cuentas_pagar.update({
          where: { id: g.cuenta_pagar_id },
          data: {
            saldo_pendiente: new Decimal(nuevoSaldo),
            monto_pagado:    new Decimal(nuevoMontoPagado),
            estado:          nuevoSaldo <= 0.0001 ? 'pagada' : 'parcial',
          },
        });

        const saldoGasto = await tx.cuentas_pagar.aggregate({
          where: { origen_tipo: 'gasto', origen_id: g.gasto_id, activo: true, estado: { notIn: ['anulada'] } },
          _sum: { saldo_pendiente: true },
        });
        const totalPendienteGasto = Number(saldoGasto._sum.saldo_pendiente ?? 0);
        await tx.gasto_cab.update({
          where: { id: g.gasto_id },
          data: { estado: totalPendienteGasto <= 0.0001 ? 'pagada' : 'parcial' },
        });
      }

      // e) Medios de pago
      for (const mp of dto.medios_pago) {
        const monedaIdMP = await resolveMoneda(mp.moneda);
        const ch = mp.cheque;
        await tx.recibo_cobro_medios_pago.create({
          data: {
            recibo_cobro_id:          recibo.id,
            medio:                    mp.medio,
            monto:                    new Decimal(mp.monto),
            moneda_id:                monedaIdMP,
            tipo_cambio:              mp.tipo_cambio ? new Decimal(mp.tipo_cambio) : null,
            cuenta_tesoreria_id:      mp.cuenta_tesoreria_id ?? null,
            referencia:               mp.referencia ?? null,
            saldo_favor_id:           mp.saldo_favor_id ?? null,
            cheque_numero:            ch?.numero_cheque ?? null,
            cheque_banco:             ch?.banco_emisor ?? null,
            cheque_titular:           ch?.titular ?? null,
            cheque_cuenta:            ch?.cuenta_bancaria_origen ?? null,
            cheque_fecha_emision:     ch?.fecha_emision ? new Date(ch.fecha_emision) : null,
            cheque_fecha_vencimiento: ch?.fecha_vencimiento ? new Date(ch.fecha_vencimiento) : null,
          },
        });

        if (mp.medio !== 'SALDO_FAVOR') {
          // Preferí la cuenta elegida por el usuario; si no vino (el wizard hoy no
          // la pide) o no es válida, resolvé por la regla COBRO_CLIENTE default.
          // Si tampoco hay regla configurada, NO se bloquea el recibo — se avisa
          // y se sigue sin generar movimiento (mismo patrón que Gastos/Compras).
          let cuentaTesoreriaId: string | null = null;
          if (mp.cuenta_tesoreria_id) {
            const cuentaElegida = await tx.tes_cuentas.findFirst({
              where: { id: mp.cuenta_tesoreria_id, empresa_id: empresaId, activo: true },
              select: { id: true },
            });
            cuentaTesoreriaId = cuentaElegida?.id ?? null;
          }
          if (!cuentaTesoreriaId) {
            const regla = await tx.tes_reglas.findFirst({
              where: { empresa_id: empresaId, tipo_operacion: 'COBRO_CLIENTE', medio_pago_id: null, activo: true },
              select: { cuenta_id: true },
            });
            if (regla?.cuenta_id) {
              const cuentaRegla = await tx.tes_cuentas.findFirst({
                where: { id: regla.cuenta_id, empresa_id: empresaId, activo: true },
                select: { id: true },
              });
              cuentaTesoreriaId = cuentaRegla?.id ?? null;
            }
          }
          if (!cuentaTesoreriaId) {
            tesoreriaWarnings.push(
              `Medio de pago "${mp.medio}": no se registró en Tesorería (falta elegir cuenta o configurar la regla COBRO_CLIENTE en Tesorería > Reglas).`,
            );
            continue;
          }
          const cuentaTesoreria = { id: cuentaTesoreriaId };

          // Un cheque recibido NO es plata en la cuenta: entra a la cartera de cheques
          // y recién mueve el saldo cuando se deposita (tes-cheques.depositar crea ahí
          // su propio INGRESO). Generar el movimiento acá lo contaba dos veces e
          // inflaba el saldo del banco.
          if (mp.medio === 'CHEQUE') {
            if (mp.cheque) {
              const chData = mp.cheque;
              const fechaVenc = chData.fecha_vencimiento ? new Date(chData.fecha_vencimiento) : null;
              const esDiferido = fechaVenc && fechaVenc > new Date();
              await tx.tes_cheques.create({
                data: {
                  empresa_id:             empresaId,
                  tipo:                   'RECIBIDO',
                  estado:                 esDiferido ? 'DIFERIDO' : 'EN_CARTERA',
                  numero_cheque:          chData.numero_cheque,
                  banco_emisor:           chData.banco_emisor,
                  cuenta_bancaria_origen: chData.cuenta_bancaria_origen ?? null,
                  titular:                chData.titular,
                  monto:                  new Decimal(mp.monto),
                  moneda:                 mp.moneda ?? 'PYG',
                  fecha_emision:          new Date(chData.fecha_emision),
                  fecha_vencimiento:      fechaVenc,
                  // Cuenta donde se va a depositar. El movimiento_id queda null hasta
                  // que efectivamente se deposite; el vínculo con el recibo va por
                  // origen, que es lo que usa la anulación para encontrarlo.
                  cuenta_id:              cuentaTesoreria.id,
                  origen_tipo:            'recibos_multi',
                  origen_id:              recibo.id,
                  referencia:             mp.referencia ?? `Recibo ${numero}`,
                  registrado_por:         user.id,
                },
              });
            } else {
              tesoreriaWarnings.push(
                'Se cobró con cheque pero no se cargaron sus datos: no se registró en la cartera de cheques.',
              );
            }
            continue;
          }

          const mov = await tx.tes_movimientos.create({
            data: {
              empresa_id:     empresaId,
              cuenta_id:      cuentaTesoreria.id,
              tipo:           'INGRESO',
              estado:         'CONFIRMADO',
              // Fecha de valor = la del recibo, no la de carga. Un recibo de abril
              // cargado en julio tiene que caer en abril: si no, el extracto y la
              // conciliación bancaria del mes lo muestran donde no corresponde.
              // `confirmado_en` sí queda con el momento real de la confirmación.
              fecha:          recibo.fecha_emision ?? new Date(),
              monto:          new Decimal(mp.monto),
              descripcion:    `Recibo ${numero} — ${mp.medio}`,
              referencia:     mp.referencia ?? numero,
              origen_tipo:    'recibos_multi',
              origen_id:      recibo.id,
              confirmado_por: user.id,
              confirmado_en:  new Date(),
              created_by:     user.id,
            },
          });
          await tx.tes_cuentas.update({
            where: { id: cuentaTesoreria.id },
            data:  { saldo_actual: { increment: new Decimal(mp.monto) } },
          });

        }

        if (mp.medio === 'SALDO_FAVOR' && mp.saldo_favor_id) {
          await tx.$executeRaw`
            UPDATE rec_saldos_cliente
            SET monto_disponible = monto_disponible - ${mp.monto},
                updated_at = now()
            WHERE id = ${mp.saldo_favor_id}::uuid
          `;
          await tx.rec_saldos_cliente_mov.create({
            data: {
              saldo_id:  mp.saldo_favor_id,
              recibo_id: recibo.id,
              tipo:      'CONSUMO',
              monto:     new Decimal(mp.monto),
            },
          });
        }
      }

      // f) Saldo a favor por diferencia positiva
      if (calc.diferencia_tipo === 'SALDO_FAVOR' && calc.diferencia > 0) {
        const saldo = await tx.rec_saldos_cliente.create({
          data: {
            empresa_id:       empresaId,
            cliente_id:       dto.cliente_id,
            origen:           'RECIBO_DIFERENCIA',
            origen_id:        recibo.id,
            monto_original:   new Decimal(calc.diferencia),
            monto_disponible: new Decimal(calc.diferencia),
            moneda:           dto.moneda ?? 'PYG',
          },
        });
        await tx.rec_saldos_cliente_mov.create({
          data: {
            saldo_id:  saldo.id,
            recibo_id: recibo.id,
            tipo:      'CARGA',
            monto:     new Decimal(calc.diferencia),
          },
        });
      }

      // g) Actualizar saldo_pendiente de facturas + cuotas FIFO
      for (const f of calc.facturas) {
        // `monto_pagado` ya es el saldo BRUTO que salda la factura (buildPayload
        // manda el saldo total o el parcial elegido). NC/retención son fondeo del
        // efectivo —bajan el "total a pagar", no la deuda de la factura— así que
        // NO se suman acá: hacerlo contaba doble y sobre-saldaba la factura por el
        // monto de la NC (ej. factura 1597: 1.050.000 + NC 75.600 = 1.125.600).
        const reduccion = f.monto_pagado;
        await tx.$executeRaw`
          UPDATE factura_cab
          SET saldo_pendiente = GREATEST(0, COALESCE(saldo_pendiente, total_factura) - ${reduccion})
          WHERE id = ${f.factura_id}::uuid
        `;

        const cuotas = await tx.factura_cuotas.findMany({
          where: { factura_cab_id: f.factura_id },
          orderBy: { nro_cuota: 'asc' },
          select: { id: true, dmoncuota: true, saldo_pendiente: true, estado: true },
        });
        let restante = reduccion;
        for (const c of cuotas) {
          if (restante <= 0) break;
          const estadoLow = (c.estado ?? '').toLowerCase();
          if (estadoLow === 'pagado' || estadoLow === 'anulado' || estadoLow === 'anulada') continue;
          const saldoActual = Number(c.saldo_pendiente ?? c.dmoncuota ?? 0);
          if (saldoActual <= 0) continue;
          const aplicar = Math.min(saldoActual, restante);
          const nuevoSaldo = +(saldoActual - aplicar).toFixed(2);
          const nuevoEstado = nuevoSaldo <= 0.0001 ? 'pagado' : (c.estado ?? 'pendiente');
          await tx.factura_cuotas.update({
            where: { id: c.id },
            data:  { saldo_pendiente: new Decimal(nuevoSaldo), estado: nuevoEstado, updated_at: new Date() },
          });
          restante = +(restante - aplicar).toFixed(2);
        }

        // La cuenta a cobrar y el saldo del cliente se derivan de las cuotas.
        await this.sincronizarCuentaCobrar(tx, f.factura_id);
      }

      // h) Comisión de cobrador
      if (dto.cobrador_id) {
        const cobrador = await tx.vendedores_cobradores.findFirst({
          where: { id: dto.cobrador_id, empresa_id: empresaId, active: true },
          select: { id: true, comision_cobranza: true },
        });
        const pct = Number(cobrador?.comision_cobranza ?? 0);
        if (cobrador && pct > 0) {
          const base = Number(calc.total_pagado);
          const monto = +(base * pct / 100).toFixed(2);
          if (monto > 0) {
            await tx.comisiones.create({
              data: {
                empresa_id:           empresaId,
                vendedor_cobrador_id: cobrador.id,
                tipo:                 'cobranza',
                monto_base:           new Decimal(base),
                porcentaje_aplicado:  new Decimal(pct),
                monto_comision:       new Decimal(monto),
                estado:               'pendiente',
                observaciones:        `RECIBO_MULTI:${recibo.id}`,
              },
            });
          }
        }
      }

      const out = this._mapReciboOut(recibo);
      return tesoreriaWarnings.length ? { ...out, tesoreria_warning: tesoreriaWarnings.join(' ') } : out;
    });

    // i) Integración contable — DESPUÉS del commit. Antes se disparaba con
    // setImmediate adentro de la transacción: el integrador leía el recibo fuera
    // de ella, todavía no estaba commiteado, salía en silencio y encima se
    // auditaba "OK". Así quedó sin asiento un tercio de los recibos de DOBA.
    void this.integrarReciboMultiPostCommit(empresaId, resultado.id, resultado.numero, user.id);

    return resultado;
  }

  /**
   * Genera el asiento del recibo recién creado y audita lo que pasó de verdad:
   * OK solo si quedó un documento contable confirmado; si el integrador salió
   * sin generar nada, queda registrado como OMITIDO (con eso Conciliación puede
   * explicar por qué está pendiente).
   */
  private async integrarReciboMultiPostCommit(empresaId: string, reciboId: string, numero: string, userId: string) {
    const auditar = (resultado: 'OK' | 'OMITIDO' | 'ERROR', descripcion: string, extra: Record<string, unknown> = {}) =>
      this.auditService
        .log({
          empresa_id: empresaId,
          user_id: userId,
          action: 'UPDATE',
          entity_type: 'recibo_multi_integracion',
          entity_id: reciboId,
          descripcion,
          new_value: { recibo_id: reciboId, numero, resultado, ...extra },
        })
        .catch(() => undefined);

    try {
      await this.contabilidad.integrarReciboMulti(reciboId, empresaId);
      const doc = await this.prisma.cont_documentos.findFirst({
        where: { empresa_id: empresaId, origen_tipo: 'recibos_multi', origen_id: reciboId, estado: 'CONFIRMADO' },
        select: { id: true },
      });
      if (doc) {
        await auditar('OK', `Asiento contable generado para recibo ${numero}`);
      } else {
        await auditar('OMITIDO', `El recibo ${numero} quedó sin asiento: el integrador no generó documento (ver Contabilidad → Conciliación)`);
      }
    } catch (err) {
      const msg = err instanceof Error ? err.message : String(err);
      this.logger.warn(`Error al integrar recibo ${reciboId} contablemente: ${msg}`);
      await auditar('ERROR', `Error al generar asiento contable para recibo ${numero}: ${msg}`, { error: msg });
    }
  }

  // ─── Anular recibo ───────────────────────────────────────────────────────

  async anular(empresaId: string, reciboId: string, dto: AnularReciboDto, usuarioId: string) {
    const recibo = await this.prisma.recibos_cobro.findFirst({
      where: { id: reciboId, empresa_id: empresaId, modo: 'MULTI' },
      include: { facturas_multi: true, nc_aplicadas_multi: true, medios_pago_multi: true, facturas_compra_aplicadas: true, gastos_asociados: true },
    });
    if (!recibo)                       throw new NotFoundException('Recibo no encontrado');
    if (recibo.estado === ReciboCobroEstado.ANULADO) {
      throw new BadRequestException('El recibo ya está anulado');
    }

    const chequesNoReversibles = await this.prisma.tes_cheques.findMany({
      where: {
        empresa_id: empresaId,
        // Los cheques nuevos se vinculan por origen (ya no generan movimiento al
        // cobrarse). El OR con `movimiento` cubre los cargados antes de ese cambio.
        OR: [
          { origen_tipo: 'recibos_multi', origen_id: reciboId },
          { movimiento: { origen_tipo: 'recibos_multi', origen_id: reciboId } },
        ],
        estado: { notIn: ['EN_CARTERA', 'DIFERIDO', 'ANULADO'] },
      },
      select: { id: true, numero_cheque: true, estado: true },
    });
    if (chequesNoReversibles.length > 0) {
      const detalle = chequesNoReversibles
        .map((c) => `${c.numero_cheque} (${c.estado})`)
        .join(', ');
      throw new BadRequestException(
        `No se puede anular: hay cheques fuera de cartera. Reviértalos primero desde Tesorería: ${detalle}`,
      );
    }

    const anulacion = await this.prisma.$transaction(async (tx) => {
      // Revertir saldo_pendiente de facturas + cuotas (FIFO inverso: rellenar desde la última pagada)
      for (const f of recibo.facturas_multi) {
        // Simétrico a crear(): la factura solo se redujo por `monto_pagado`, así
        // que solo eso se restaura. Sumar NC/retención restauraría de más.
        const restaurar = Number(f.monto_pagado);
        await tx.$executeRaw`
          UPDATE factura_cab
          SET saldo_pendiente = COALESCE(saldo_pendiente, total_factura) + ${restaurar}
          WHERE id = ${f.factura_cab_id}::uuid
        `;

        const cuotas = await tx.factura_cuotas.findMany({
          where: { factura_cab_id: f.factura_cab_id },
          orderBy: { nro_cuota: 'desc' },
          select: { id: true, dmoncuota: true, saldo_pendiente: true, estado: true },
        });
        let restante = restaurar;
        for (const c of cuotas) {
          if (restante <= 0) break;
          const estadoLow = (c.estado ?? '').toLowerCase();
          if (estadoLow === 'anulado' || estadoLow === 'anulada') continue;
          const monto = Number(c.dmoncuota ?? 0);
          const saldoActual = Number(c.saldo_pendiente ?? 0);
          const espacio = +(monto - saldoActual).toFixed(2);
          if (espacio <= 0) continue;
          const aplicar = Math.min(espacio, restante);
          const nuevoSaldo = +(saldoActual + aplicar).toFixed(2);
          await tx.factura_cuotas.update({
            where: { id: c.id },
            data:  { saldo_pendiente: new Decimal(nuevoSaldo), estado: 'pendiente', updated_at: new Date() },
          });
          restante = +(restante - aplicar).toFixed(2);
        }

        // Reabrir la cuenta a cobrar y devolverle el saldo al cliente.
        await this.sincronizarCuentaCobrar(tx, f.factura_cab_id);
      }

      // Revertir saldo a favor consumido
      for (const mp of recibo.medios_pago_multi) {
        if (mp.medio === 'SALDO_FAVOR' && mp.saldo_favor_id) {
          await tx.$executeRaw`
            UPDATE rec_saldos_cliente
            SET monto_disponible = monto_disponible + ${Number(mp.monto)},
                updated_at = now()
            WHERE id = ${mp.saldo_favor_id}::uuid
          `;
          await tx.rec_saldos_cliente_mov.create({
            data: {
              saldo_id:  mp.saldo_favor_id,
              recibo_id: reciboId,
              tipo:      'REVERSA',
              monto:     mp.monto,
            },
          });
        }
      }

      // Revertir saldo a favor generado por este recibo
      const saldoGenerado = await tx.rec_saldos_cliente.findFirst({
        where: { origen: 'RECIBO_DIFERENCIA', origen_id: reciboId, anulado: false },
      });
      if (saldoGenerado) {
        const consumido = Number(saldoGenerado.monto_original) - Number(saldoGenerado.monto_disponible);
        if (consumido > 0) {
          throw new BadRequestException(
            'El saldo a favor generado por este recibo ya fue aplicado en otro recibo. Anule primero ese recibo.'
          );
        }
        await tx.rec_saldos_cliente.update({
          where: { id: saldoGenerado.id },
          data:  { anulado: true, monto_disponible: new Decimal(0) },
        });
        await tx.rec_saldos_cliente_mov.create({
          data: {
            saldo_id:  saldoGenerado.id,
            recibo_id: reciboId,
            tipo:      'REVERSA',
            monto:     saldoGenerado.monto_original,
          },
        });
      }

      // Revertir movimientos de Tesorería
      const movimientosTes = await tx.tes_movimientos.findMany({
        where: { origen_tipo: 'recibos_multi', origen_id: reciboId, estado: 'CONFIRMADO' },
      });
      for (const mov of movimientosTes) {
        await tx.tes_movimientos.update({
          where: { id: mov.id },
          data: {
            estado:           'ANULADO',
            motivo_anulacion: `Reversa por anulación de recibo ${recibo.numero_recibo}`,
            anulado_por:      usuarioId,
            anulado_en:       new Date(),
          },
        });
        const delta = mov.tipo === 'INGRESO' ? -Number(mov.monto) : Number(mov.monto);
        await tx.tes_cuentas.update({
          where: { id: mov.cuenta_id },
          data:  { saldo_actual: { increment: delta } },
        });
      }

      await tx.tes_cheques.updateMany({
        where: {
          empresa_id: empresaId,
          // Mismo criterio que el chequeo de reversibilidad: por origen para los
          // nuevos, por movimiento para los anteriores al cambio.
          OR: [
            { origen_tipo: 'recibos_multi', origen_id: reciboId },
            { movimiento_id: { in: movimientosTes.map((m) => m.id) } },
          ],
          estado: { in: ['EN_CARTERA', 'DIFERIDO'] },
        },
        data: {
          estado: 'ANULADO',
          motivo: `Anulación de recibo ${recibo.numero_recibo}`,
        },
      });

      // Revertir compensaciones por facturas de compra (acuerdos comerciales)
      for (const fc of recibo.facturas_compra_aplicadas ?? []) {
        const cuotas = await tx.cuentas_pagar.findMany({
          where: {
            compra_id: fc.compra_cab_id,
            activo: true,
            estado: { notIn: ['anulada'] },
          },
          orderBy: [{ fecha_vencimiento: 'desc' }, { numero_cuota: 'desc' }],
        });
        let restante = Number(fc.monto);
        for (const c of cuotas) {
          if (restante <= 0.0001) break;
          const original = Number(c.monto_original ?? 0);
          const saldoActual = Number(c.saldo_pendiente ?? 0);
          const pagado = Number(c.monto_pagado ?? 0);
          const espacio = +Math.min(pagado, original - saldoActual).toFixed(2);
          if (espacio <= 0) continue;
          const aplicar = Math.min(espacio, restante);
          await tx.cuentas_pagar.update({
            where: { id: c.id },
            data: {
              saldo_pendiente: new Decimal(+(saldoActual + aplicar).toFixed(2)),
              monto_pagado: new Decimal(+(pagado - aplicar).toFixed(2)),
              estado: 'pendiente',
            },
          });
          restante = +(restante - aplicar).toFixed(2);
        }
        const saldoRestante = await tx.cuentas_pagar.aggregate({
          where: { compra_id: fc.compra_cab_id, activo: true, estado: { notIn: ['anulada'] } },
          _sum: { saldo_pendiente: true, monto_pagado: true },
        });
        const totalPendiente = Number(saldoRestante._sum.saldo_pendiente ?? 0);
        const totalPagado = Number(saldoRestante._sum.monto_pagado ?? 0);
        const nuevoEstado = totalPagado <= 0.0001 ? 'pendiente' : (totalPendiente <= 0.0001 ? 'pagada' : 'parcial');
        await tx.compra_cab.update({
          where: { id: fc.compra_cab_id },
          data: { estado: nuevoEstado },
        });
      }

      // Revertir gastos descontados del cobro
      for (const g of recibo.gastos_asociados ?? []) {
        const monto = Number(g.monto_aplicado);
        const cxp = await tx.cuentas_pagar.findUnique({ where: { id: g.cuenta_pagar_id } });
        if (cxp) {
          const nuevoSaldo = +(Number(cxp.saldo_pendiente ?? 0) + monto).toFixed(2);
          const nuevoMontoPagado = +Math.max(0, Number(cxp.monto_pagado ?? 0) - monto).toFixed(2);
          await tx.cuentas_pagar.update({
            where: { id: g.cuenta_pagar_id },
            data: {
              saldo_pendiente: new Decimal(nuevoSaldo),
              monto_pagado:    new Decimal(nuevoMontoPagado),
              estado:          nuevoMontoPagado <= 0.0001 ? 'pendiente' : 'parcial',
            },
          });
        }
        const saldoGasto = await tx.cuentas_pagar.aggregate({
          where: { origen_tipo: 'gasto', origen_id: g.gasto_id, activo: true, estado: { notIn: ['anulada'] } },
          _sum: { saldo_pendiente: true, monto_pagado: true },
        });
        const spGasto = Number(saldoGasto._sum.saldo_pendiente ?? 0);
        const mpGasto = Number(saldoGasto._sum.monto_pagado ?? 0);
        const estadoGasto = mpGasto <= 0.0001 ? 'registrado' : (spGasto <= 0.0001 ? 'pagada' : 'parcial');
        await tx.gasto_cab.update({ where: { id: g.gasto_id }, data: { estado: estadoGasto } });
      }

      // Devolverle a cada NC lo que este recibo le consumió (simétrico a crear()).
      for (const nc of recibo.nc_aplicadas_multi) {
        await reponerSaldoNc(tx, nc.nota_credito_id, Number(nc.monto));
      }

      // Cancelar comisiones pendientes
      await tx.$executeRaw`
        UPDATE comisiones
        SET estado = 'cancelada', updated_at = now()
        WHERE empresa_id = ${empresaId}::uuid
          AND observaciones = ${'RECIBO_MULTI:' + reciboId}
          AND estado = 'pendiente'
      `;

      // Marcar como anulado (unificado a minúscula desde 2026-06-25).
      await tx.recibos_cobro.update({
        where: { id: reciboId },
        data: {
          estado:           ReciboCobroEstado.ANULADO,
          motivo_anulacion: dto.motivo,
          fecha_anulacion:  new Date(),
          anulado_por:      usuarioId,
        },
      });

      return { ok: true, recibo_id: reciboId };
    });

    // Revertir asiento contable — después del commit, por la misma razón que
    // en crear(): lo que corre adentro de la transacción no ve sus cambios.
    try {
      await this.contabilidad.revertirDocumento('recibos_multi', reciboId, usuarioId);
    } catch (err) {
      this.logger.warn(`Error al revertir asiento de recibo ${reciboId}: ${err.message}`);
    }

    return anulacion;
  }

  // ─── Libro de retenciones recibidas ───────────────────────────────────────

  async libroRetenciones(empresaId: string, periodo: string) {
    const [anio, mes] = periodo.split('-').map(Number);
    return this.prisma.$queryRaw<any[]>`
      SELECT
        r.id AS retencion_id,
        rm.id AS recibo_id,
        rm.numero_recibo AS recibo_numero,
        rm.fecha_emision AS recibo_fecha,
        r.tipo,
        r.monto,
        r.porcentaje,
        r.numero_comprobante,
        r.timbrado,
        r.fecha_comprobante,
        r.declarada_set,
        r.periodo_declaracion,
        rf.factura_cab_id AS factura_id,
        p.razon_social AS cliente_razon_social,
        COALESCE(p.ruc, p.nro_documento) AS cliente_ruc
      FROM recibo_cobro_retenciones r
      JOIN recibos_cobro rm ON rm.id = r.recibo_cobro_id
      JOIN recibo_cobro_facturas rf ON rf.id = r.recibo_factura_id
      JOIN clientes c ON c.id = rm.cliente_id
      JOIN personas p ON p.id = c.persona_id
      WHERE rm.empresa_id = ${empresaId}::uuid
        AND rm.modo = 'MULTI'
        AND rm.estado = 'emitido'
        AND EXTRACT(YEAR  FROM r.fecha_comprobante) = ${anio}
        AND EXTRACT(MONTH FROM r.fecha_comprobante) = ${mes}
      ORDER BY r.fecha_comprobante, rm.numero_recibo
    `;
  }

  async marcarDeclarada(empresaId: string, retencionId: string, periodo: string) {
    const ret = await this.prisma.$queryRaw<any[]>`
      SELECT r.id FROM recibo_cobro_retenciones r
      JOIN recibos_cobro rm ON rm.id = r.recibo_cobro_id
      WHERE r.id = ${retencionId}::uuid AND rm.empresa_id = ${empresaId}::uuid
    `;
    if (!ret.length) throw new NotFoundException('Retención no encontrada');

    await this.prisma.$executeRaw`
      UPDATE recibo_cobro_retenciones
      SET declarada_set = true, periodo_declaracion = ${periodo}
      WHERE id = ${retencionId}::uuid
    `;
    return { ok: true };
  }

  // ─── Configuración por empresa ───────────────────────────────────────────

  async obtenerConfig(empresaId: string) {
    const existing = await this.prisma.rec_config_empresa.findUnique({
      where: { empresa_id: empresaId },
    });
    if (existing) return existing;
    return this.prisma.rec_config_empresa.create({
      data: { empresa_id: empresaId },
    });
  }

  async actualizarConfig(
    empresaId: string,
    data: { nc_modo_default?: string; diferencia_max_ajuste?: number; numeracion_formato?: string; permitir_gastos_en_recibo?: boolean; tope_gastos_pct?: number | null },
  ) {
    const payload: any = {};
    if (data.nc_modo_default !== undefined) {
      if (!['ESTRICTO', 'FLEXIBLE'].includes(data.nc_modo_default)) {
        throw new Error('nc_modo_default debe ser ESTRICTO o FLEXIBLE');
      }
      payload.nc_modo_default = data.nc_modo_default;
    }
    if (data.diferencia_max_ajuste !== undefined) payload.diferencia_max_ajuste = data.diferencia_max_ajuste;
    if (data.numeracion_formato !== undefined) payload.numeracion_formato = data.numeracion_formato;
    if (data.permitir_gastos_en_recibo !== undefined) payload.permitir_gastos_en_recibo = data.permitir_gastos_en_recibo;
    if (data.tope_gastos_pct !== undefined) payload.tope_gastos_pct = data.tope_gastos_pct;

    return this.prisma.rec_config_empresa.upsert({
      where: { empresa_id: empresaId },
      update: payload,
      create: { empresa_id: empresaId, ...payload },
    });
  }

  // ─── Helpers privados ─────────────────────────────────────────────────────

  private async _getConfig(empresaId: string) {
    return this.prisma.rec_config_empresa.findUnique({ where: { empresa_id: empresaId } });
  }

  // Numeración unificada con recibos legacy: usa numeraciones_documento (tipo_documento codigo=100)
  // + punto de expedición de la sucursal. Formato: EST-PUNTO-NNNNNNN (ej: 001-002-0000007).
  // Debe ejecutarse DENTRO de la transacción para que el FOR UPDATE bloquee correctamente.
  private async _generarNumeroTx(
    tx: Prisma.TransactionClient,
    empresaId: string,
    sucursalId: string,
  ): Promise<string> {
    const numeraciones = await tx.$queryRaw<
      Array<{
        id: string;
        numero_actual: number;
        numero_final: number | null;
        punto_expedicion_id: string;
      }>
    >`SELECT nd.id, nd.numero_actual, nd.numero_final, nd.punto_expedicion_id
        FROM numeraciones_documento nd
        INNER JOIN tipo_documento td ON td.id = nd.tipo_documento_id
        INNER JOIN empresas_puntos_expedicion epe ON epe.id = nd.punto_expedicion_id
        WHERE td.codigo = 100
          AND nd.empresa_id = ${empresaId}::uuid
          AND epe.sucursal_id = ${sucursalId}::uuid
          AND nd.active = true
        ORDER BY nd.created_at ASC
        LIMIT 1
        FOR UPDATE`;

    if (numeraciones.length === 0) {
      throw new NotFoundException(
        'No se encontró una numeración activa para Recibo de pago. Configure una numeración con tipo de documento "Recibo de pago" (código 100) para esta sucursal.',
      );
    }
    const num = numeraciones[0];
    if (num.numero_final && num.numero_actual >= num.numero_final) {
      throw new BadRequestException('Se alcanzó el número final de la numeración de recibos');
    }

    const punto = await tx.empresas_puntos_expedicion.findUnique({
      where: { id: num.punto_expedicion_id },
      include: { empresas_sucursales: { select: { punto_establecimiento: true } } },
    });

    const dest    = punto?.empresas_sucursales?.punto_establecimiento || '001';
    const dpunexp = punto?.punto_expedicion || '001';

    // Defensa: si el contador quedó desincronizado (datos migrados, importaciones),
    // saltar hacia adelante hasta encontrar un número libre en recibos_cobro.
    let candidato = num.numero_actual;
    let numeroRecibo = `${dest}-${dpunexp}-${String(candidato).padStart(7, '0')}`;
    for (let i = 0; i < 10000; i++) {
      const existe = await tx.recibos_cobro.findFirst({
        where: { empresa_id: empresaId, numero_recibo: numeroRecibo },
        select: { id: true },
      });
      if (!existe) break;
      candidato += 1;
      if (num.numero_final && candidato >= num.numero_final) {
        throw new BadRequestException('Se alcanzó el número final de la numeración de recibos');
      }
      numeroRecibo = `${dest}-${dpunexp}-${String(candidato).padStart(7, '0')}`;
    }

    await tx.numeraciones_documento.update({
      where: { id: num.id },
      data:  { numero_actual: candidato + 1 },
    });

    return numeroRecibo;
  }

  private async _calcular(empresaId: string, dto: CreateReciboDto, _usuarioId: string) {
    const config = await this._getConfig(empresaId);
    const configMora = await this.prisma.config_mora.findUnique({
      where: { empresa_id: empresaId },
    });

    const hoy = new Date();
    const facturas = await Promise.all(
      dto.facturas.map(async (f) => {
        const row = await this.prisma.$queryRaw<any[]>`
          SELECT
            fc.dest || '-' || fc.dpunexp || '-' || fc.dnumdoc AS numero,
            COALESCE(
              NULLIF((
                SELECT SUM(
                  CASE
                    WHEN LOWER(estado) IN ('pagado','anulado','anulada') THEN 0
                    ELSE COALESCE(NULLIF(saldo_pendiente, 0), dmoncuota, 0)
                  END
                )
                FROM factura_cuotas
                WHERE factura_cab_id = fc.id
              ), 0),
              NULLIF(fc.saldo_pendiente, 0),
              fc.total_factura
            ) AS saldo
          FROM factura_cab fc
          WHERE fc.id = ${f.factura_id}::uuid AND fc.empresa_id = ${empresaId}::uuid
        `;
        if (!row.length) throw new NotFoundException(`Factura ${f.factura_id} no encontrada`);
        const saldo = Number(row[0].saldo);
        if (f.monto_pagado > saldo + 0.01) {
          throw new BadRequestException(`Monto pagado (${f.monto_pagado}) supera saldo de factura (${saldo})`);
        }

        let monto_interes = 0;
        if (f.cuota_id && configMora?.activo) {
          const cuota = await this.prisma.factura_cuotas.findUnique({
            where: { id: f.cuota_id },
            select: { dvenccuo: true, saldo_pendiente: true, dmoncuota: true },
          });
          if (cuota?.dvenccuo) {
            const venc = new Date(cuota.dvenccuo);
            const diasAtraso = Math.floor((hoy.getTime() - venc.getTime()) / 86400000);
            const diasMora = Math.max(0, diasAtraso - (configMora.periodo_gracia ?? 0));
            if (diasMora > 0) {
              const base = configMora.base_calculo === 'capital'
                ? Number(cuota.saldo_pendiente ?? cuota.dmoncuota ?? 0)
                : f.monto_pagado;
              const tasa = Number(configMora.tasa);
              const factor = configMora.tipo_calculo === 'diario' ? diasMora : 1;
              let interes = +(base * tasa / 100 * factor).toFixed(2);
              const min = Number(configMora.monto_minimo_mora ?? 0);
              const max = configMora.monto_maximo_mora ? Number(configMora.monto_maximo_mora) : null;
              if (interes < min) interes = 0;
              if (max != null && interes > max) interes = max;
              monto_interes = interes;
            }
          }
        }

        return {
          ...f,
          factura_numero: row[0].numero,
          monto_saldo_previo: saldo,
          saldo_anterior: saldo,
          monto_nc: 0,
          monto_retencion: 0,
          monto_interes,
        };
      })
    );

    const ncAplicadas: Array<{ nota_credito_id: string; factura_id_destino: string; monto: number; modo: string }> = [];
    for (const nc of dto.nc_aplicadas ?? []) {
      const ncRow = await this.prisma.$queryRaw<any[]>`
        SELECT COALESCE(nc.rec_saldo_disponible, (
          SELECT COALESCE(SUM(ns.dtotgralope), 0) FROM nota_credito_subtotal ns WHERE ns.nota_credito_cab_id = nc.id
        )) AS saldo,
        nc.factura_cab_id,
        COALESCE(nc.rec_modo_aplicacion, ${config?.nc_modo_default ?? 'ESTRICTO'}) AS modo
        FROM nota_credito_cab nc
        WHERE nc.id = ${nc.nota_credito_id}::uuid AND nc.empresa_id = ${empresaId}::uuid
      `;
      if (!ncRow.length) throw new NotFoundException(`NC ${nc.nota_credito_id} no encontrada`);
      if (Number(ncRow[0].saldo) < nc.monto - 0.01) {
        throw new BadRequestException(`NC sin saldo suficiente (disponible: ${ncRow[0].saldo})`);
      }

      const modo = ncRow[0].modo as string;
      if (modo === 'ESTRICTO' && ncRow[0].factura_cab_id !== nc.factura_id_destino) {
        throw new ForbiddenException(
          `NC en modo ESTRICTO solo puede aplicarse a su factura origen (${ncRow[0].factura_cab_id})`
        );
      }

      const facturaLinea = facturas.find((f) => f.factura_id === nc.factura_id_destino);
      if (facturaLinea) facturaLinea.monto_nc += nc.monto;

      ncAplicadas.push({ ...nc, modo });
    }

    for (const ret of dto.retenciones ?? []) {
      const facturaLinea = facturas.find((f) => f.factura_id === ret.factura_ref);
      if (facturaLinea) facturaLinea.monto_retencion += ret.monto;
    }

    // Facturas por acuerdo comercial (compras emitidas por el cliente a la empresa)
    const facturasCompra: Array<{ compra_cab_id: string; monto: number; observacion?: string; saldo_previo: number; numero: string }> = [];
    if (dto.facturas_compra_aplicadas?.length) {
      const cliente = await this.prisma.clientes.findUnique({
        where: { id: dto.cliente_id },
        select: { persona_id: true },
      });
      if (!cliente?.persona_id) {
        throw new BadRequestException('El cliente no tiene persona asociada — no se puede compensar contra facturas de compra');
      }
      for (const fc of dto.facturas_compra_aplicadas) {
        const row = await this.prisma.$queryRaw<any[]>`
          SELECT
            cc.id,
            cc.establecimiento || '-' || cc.punto_expedicion || '-' || cc.numero_factura AS numero,
            COALESCE((
              SELECT SUM(cp.saldo_pendiente)
              FROM cuentas_pagar cp
              WHERE cp.compra_id = cc.id
                AND COALESCE(cp.activo, true) = true
                AND COALESCE(cp.estado, 'pendiente') NOT IN ('pagada','anulada')
            ), 0) AS saldo
          FROM compra_cab cc
          JOIN proveedores p ON p.id = cc.proveedor_id
          WHERE cc.id = ${fc.compra_cab_id}::uuid
            AND cc.empresa_id = ${empresaId}::uuid
            AND COALESCE(cc.anulado, false) = false
            AND p.persona_id = ${cliente.persona_id}::uuid
        `;
        if (!row.length) {
          throw new BadRequestException(`Factura de compra ${fc.compra_cab_id} no existe o no pertenece al mismo titular del cliente`);
        }
        const saldo = Number(row[0].saldo);
        if (fc.monto > saldo + 0.01) {
          throw new BadRequestException(`Monto a compensar (${fc.monto}) supera saldo de la factura de compra (${saldo})`);
        }
        facturasCompra.push({ compra_cab_id: fc.compra_cab_id, monto: fc.monto, observacion: fc.observacion, saldo_previo: saldo, numero: row[0].numero });
      }
    }
    const total_facturas_compra = facturasCompra.reduce((s, f) => s + f.monto, 0);

    // Gastos a descontar del cobro (facturas de gasto con CxP pendiente)
    const gastosAplicados: Array<{ gasto_id: string; cuenta_pagar_id: string; monto_aplicado: number; observacion?: string }> = [];
    if (dto.gastos?.length) {
      if (!config?.permitir_gastos_en_recibo) {
        throw new BadRequestException('La empresa no tiene habilitada la función de gastos en recibos. Activela en Configuración → Recibos.');
      }
      const monedaRecibo = (dto.moneda ?? 'PYG').toUpperCase();
      for (const g of dto.gastos) {
        const rows = await this.prisma.$queryRaw<any[]>`
          SELECT
            gc.id                AS gasto_id,
            UPPER(COALESCE(m.codigo, 'PYG')) AS moneda,
            cp.id                AS cuenta_pagar_id,
            cp.saldo_pendiente,
            (SELECT COUNT(*) FROM rec_multi_gastos rmg
               JOIN recibos_cobro rc ON rc.id = rmg.recibo_id
             WHERE rmg.gasto_id = gc.id AND rc.estado != 'anulado') AS en_recibo_activo
          FROM gasto_cab gc
          LEFT JOIN moneda m ON m.id = gc.moneda_id
          JOIN cuentas_pagar cp
            ON cp.origen_tipo = 'gasto' AND cp.origen_id = gc.id
           AND COALESCE(cp.activo, true) = true
           AND cp.estado NOT IN ('pagada', 'anulada')
           AND cp.saldo_pendiente > 0
          WHERE gc.id = ${g.gasto_id}::uuid
            AND gc.empresa_id = ${empresaId}::uuid
            AND COALESCE(gc.anulado, false) = false
          ORDER BY cp.fecha_vencimiento ASC
          LIMIT 1
        `;
        if (!rows.length) {
          throw new BadRequestException(`Gasto ${g.gasto_id} no disponible (no existe, anulado o sin CxP pendiente)`);
        }
        const row = rows[0];
        if (Number(row.en_recibo_activo) > 0) {
          throw new BadRequestException(`El gasto ${g.gasto_id} ya está asociado a otro recibo activo`);
        }
        if (row.moneda !== monedaRecibo) {
          throw new BadRequestException(`El gasto tiene moneda ${row.moneda} distinta a la del recibo (${monedaRecibo})`);
        }
        const saldo = Number(row.saldo_pendiente ?? 0);
        if (g.monto_aplicado > saldo + 0.01) {
          throw new BadRequestException(`Monto aplicado al gasto (${g.monto_aplicado}) supera el saldo disponible (${saldo})`);
        }
        gastosAplicados.push({ gasto_id: g.gasto_id, cuenta_pagar_id: row.cuenta_pagar_id, monto_aplicado: g.monto_aplicado, observacion: g.observacion });
      }
    }
    const total_gastos = gastosAplicados.reduce((s, g) => s + g.monto_aplicado, 0);

    const total_facturas    = facturas.reduce((s, f) => s + f.monto_pagado, 0);
    const total_nc          = facturas.reduce((s, f) => s + f.monto_nc, 0);
    const total_retenciones = facturas.reduce((s, f) => s + f.monto_retencion, 0);
    const total_intereses   = facturas.reduce((s, f) => s + f.monto_interes, 0);
    const total_pagado      = dto.medios_pago.reduce((s, m) => s + m.monto, 0);
    const imputado          = total_facturas + total_intereses - total_nc - total_retenciones - total_facturas_compra - total_gastos;
    const diferencia        = total_pagado - imputado;

    let diferencia_tipo = dto.diferencia_tipo ?? 'EXACTO';
    if (!dto.diferencia_tipo) {
      if (Math.abs(diferencia) < 0.01)  diferencia_tipo = 'EXACTO';
      else if (diferencia > 0)           diferencia_tipo = 'SALDO_FAVOR';
      else                               diferencia_tipo = 'PAGO_PARCIAL';
    }

    const facturasOut = facturas.map((f) => {
      const imputado_neto = f.monto_pagado - f.monto_nc - f.monto_retencion;
      return {
        ...f,
        monto_imputado: f.monto_pagado,
        saldo_nuevo: Math.max(0, (f.monto_saldo_previo ?? 0) - imputado_neto),
      };
    });

    return {
      facturas: facturasOut,
      nc_aplicadas:   ncAplicadas,
      facturas_compra_aplicadas: facturasCompra,
      gastos_aplicados: gastosAplicados,
      total_facturas,
      total_nc,
      total_retenciones,
      total_intereses,
      total_facturas_compra,
      total_gastos,
      total_pagado,
      total_imputado: total_facturas + total_intereses,
      total_cobrado: imputado,
      genera_saldo_favor: diferencia > 0 ? diferencia : 0,
      diferencia,
      diferencia_tipo,
    };
  }

  // ─── Upload comprobante retención ───────────────────────────────────────────

  async uploadComprobanteRetencion(empresaId: string, file: Express.Multer.File) {
    const maxBytes = 5 * 1024 * 1024;
    if (file.size > maxBytes) {
      throw new BadRequestException('El archivo excede el tamaño máximo (5MB)');
    }
    const allowed = new Set([
      'application/pdf',
      'image/png',
      'image/jpeg',
      'image/jpg',
      'image/webp',
    ]);
    if (!file.mimetype || !allowed.has(file.mimetype)) {
      throw new BadRequestException('Tipo de archivo no permitido. Solo PDF, PNG, JPG o WEBP.');
    }

    const s3 = new S3Client({
      endpoint: envs.doSpacesEndpoint,
      region: envs.doSpacesRegion,
      credentials: {
        accessKeyId: envs.doSpacesKey,
        secretAccessKey: envs.doSpacesSecret,
      },
    });

    const ext = (file.originalname.split('.').pop() || 'pdf').toLowerCase();
    const key = `comprobantes-retencion/${empresaId}/${randomUUID()}.${ext}`;

    await s3.send(
      new PutObjectCommand({
        Bucket: envs.doSpacesBucket,
        Key: key,
        Body: file.buffer,
        ContentType: file.mimetype,
        ACL: 'public-read',
      }),
    );

    return { archivo_url: `${envs.doSpacesPublicUrl}/${key}` };
  }
}
