import { Injectable } from '@nestjs/common';
import { PrismaService } from 'src/prisma/prisma.service';
import { MonedasService } from 'src/monedas/monedas.service';
import {
  alcanceSucursalUsuario,
  filtroFiscalPorSucursal,
  filtroSucursalOperativo,
} from 'src/utils/alcance-sucursal';
import { ymdInAsuncion } from 'src/utils/utilidades';
import { ViajeDocumentosService } from './viaje-documentos.service';
import { ViajesService } from './viajes.service';
import { CONDICION_COBRO, MEDIO_COBRO, TIPO_DOC_VIAJE } from './viajes.constants';

/** Ver la nota de MONEDA_BASE en viaje-calculos.service.ts: va como constante de módulo. */
const MONEDA_BASE = 'PYG';
const ESTADOS_FACTURA_BAJA = ['Anulado', 'ANULADO', 'anulado', 'Rechazado', 'RECHAZADO', 'rechazado'];
const RECIBO_ANULADO = 'anulado';
const RENDICION_APROBADA = 'APROBADO';
const RENDICION_RECHAZADA = 'RECHAZADO';

/** Columna `@db.Date`: Prisma la trae a medianoche UTC, así que el día es el ISO tal cual. */
const ymdDate = (d: Date | null | undefined) => (d ? new Date(d).toISOString().slice(0, 10) : null);

/** Lunes de la semana de `ymd` (YYYY-MM-DD), para agrupar los cheques diferidos. */
const lunesDe = (ymd: string) => {
  const d = new Date(`${ymd}T00:00:00Z`);
  const dia = d.getUTCDay(); // 0 = domingo
  d.setUTCDate(d.getUTCDate() - (dia === 0 ? 6 : dia - 1));
  return d.toISOString().slice(0, 10);
};

/**
 * Saldo vivo de una factura. Con cuotas manda la suma de las cuotas: los recibos
 * LEGACY ponen en cero la cuota pero no `factura_cab.saldo_pendiente` (en DOBA,
 * 48 de 63 pagos legacy dejaron la factura con el saldo original).
 */
const saldoFactura = (f: any) => {
  const cuotas = f.factura_cuotas ?? [];
  const saldo = cuotas.length
    ? cuotas.reduce((acc: number, c: any) => acc + Number(c.saldo_pendiente ?? 0), 0)
    : Number(f.saldo_pendiente ?? 0);
  return Math.max(0, saldo);
};

const numeroFactura = (f: any) => `${f.dest}-${f.dpunexp}-${f.dnumdoc}`;
const nombreCliente = (d: any) => d?.clientes?.personas?.razon_social ?? 'Sin nombre';

/** La forma de pago de la caja viene como texto libre del catálogo `medio_pago`. */
const medioDeCaja = (descripcion?: string | null) => {
  const t = (descripcion ?? '').toLowerCase();
  if (t.includes('transf')) return MEDIO_COBRO.TRANSFERENCIA;
  if (t.includes('cheq')) return MEDIO_COBRO.CHEQUE_AL_DIA;
  if (t.includes('tarj') || t.includes('débito') || t.includes('debito') || t.includes('crédito') || t.includes('credito'))
    return MEDIO_COBRO.TARJETA;
  if (t.includes('efect') || !t) return MEDIO_COBRO.EFECTIVO;
  return MEDIO_COBRO.OTRO;
};

const medioDeRecibo = (medio?: string | null) => {
  switch ((medio ?? '').toUpperCase()) {
    case 'EFECTIVO': return MEDIO_COBRO.EFECTIVO;
    case 'TRANSFERENCIA': return MEDIO_COBRO.TRANSFERENCIA;
    case 'TARJETA': return MEDIO_COBRO.TARJETA;
    default: return MEDIO_COBRO.OTRO;
  }
};

type Cliente = {
  id: string;
  nombre: string;
  facturas: string[];
  condiciones: Set<string>;
  vencimiento: string | null;
  vendido_base: number;
  cobrado_caja_base: number;
  cobrado_viaje_base: number;
  deuda_anterior_cobrada_base: number;
  saldo_base: number;
  saldo_vencido: boolean;
  deuda_anterior_abierta_base: number;
  cobrado_sin_enganchar_base: number;
  medios: Set<string>;
};

/**
 * Cobranza del viaje (spec 2026-10-01-viajes-cobranza-rentabilidad-design.md).
 *
 * Lo cobrado se mira por dos lados que NO se cruzan:
 *  - lo CANCELADO de cada factura (`recibo_cobro_facturas.monto_pagado`, bruto),
 *    que dice si se pagó algo de este viaje o deuda anterior;
 *  - lo RECIBIDO por cada medio (`recibo_cobro_medios_pago.monto`, neto), que
 *    dice cuánto es plata en mano y cuánto cheque a futuro.
 * Repartir un recibo mixto entre los dos obligaría a inventar un prorrateo. La
 * diferencia entre ambos son ajustes: retenciones, NC aplicadas, saldo a favor
 * e intereses.
 *
 * Lo pendiente sale del saldo vivo de cada factura, no de vendido − cobrado:
 * así no depende de que el recibo esté enganchado al viaje.
 */
@Injectable()
export class ViajeCobranzaService {
  constructor(
    private readonly prisma: PrismaService,
    private readonly documentos: ViajeDocumentosService,
    private readonly viajes: ViajesService,
    private readonly monedas: MonedasService,
  ) {}

  /** Día de hoy en Asunción; separado para que los tests lo fijen. */
  private hoy() {
    return ymdInAsuncion();
  }

  private async alcance(usuarioId: string | undefined, empresaId: string) {
    const alcance = await alcanceSucursalUsuario(this.prisma, usuarioId, empresaId);
    return {
      fiscal: await filtroFiscalPorSucursal(this.prisma, alcance),
      operativo: filtroSucursalOperativo(alcance),
    };
  }

  private async aBase(
    monto: number,
    codigo: string,
    tasaDoc: unknown,
    empresaId: string,
    marca: { estimada: boolean },
    cache: Map<string, number>,
  ) {
    if (!monto || codigo === MONEDA_BASE) return monto;
    const tasa = Number(tasaDoc);
    if (tasa > 0) return monto * tasa;
    marca.estimada = true;
    if (!cache.has(codigo)) {
      const v = await this.monedas.getCotizacionVigente(empresaId, codigo).catch(() => null);
      cache.set(codigo, Number(v?.tasa) > 0 ? Number(v?.tasa) : 0);
    }
    return monto * (cache.get(codigo) as number);
  }

  async cobranza(viajeId: string, empresaId: string, usuarioId?: string) {
    await this.viajes.findOne(viajeId, empresaId);
    const hoy = this.hoy();
    const marca = { estimada: false };
    const cache = new Map<string, number>();
    const base = (monto: number, codigo: string | undefined, tasa: unknown) =>
      this.aBase(monto, codigo ?? MONEDA_BASE, tasa, empresaId, marca, cache);

    const [facturaIds, notaIds, reciboIds] = await Promise.all([
      this.documentos.idsVigentes(viajeId, TIPO_DOC_VIAJE.FACTURA),
      this.documentos.idsVigentes(viajeId, TIPO_DOC_VIAJE.NOTA_CREDITO),
      this.documentos.idsVigentes(viajeId, TIPO_DOC_VIAJE.RECIBO),
    ]);

    const [facturas, notas, recibos] = await Promise.all([
      facturaIds.length
        ? this.prisma.factura_cab.findMany({
            where: { id: { in: facturaIds } },
            select: {
              id: true, dest: true, dpunexp: true, dnumdoc: true, dfeemide: true,
              cliente_id: true, cobro_diferido: true, dvencpag: true, saldo_pendiente: true, dticam: true,
              estado: true,
              moneda: { select: { codigo: true } },
              condicion_operacion: { select: { descripcion: true } },
              clientes: { select: { personas: { select: { razon_social: true } } } },
              factura_subtotales: { select: { dtotgralope: true } },
              factura_cuotas: { select: { id: true, dvenccuo: true, saldo_pendiente: true }, orderBy: { dvenccuo: 'asc' } },
              factura_forma_pagos: { select: { dmontipag: true, medio_pago: { select: { descripcion: true } } } },
            },
          })
        : Promise.resolve([] as any[]),
      notaIds.length
        ? this.prisma.nota_credito_cab.findMany({
            where: { id: { in: notaIds } },
            select: {
              id: true, cliente_id: true, dticam: true,
              moneda: { select: { codigo: true } },
              nota_credito_subtotal: { select: { dtotgralope: true } },
            },
          })
        : Promise.resolve([] as any[]),
      reciboIds.length
        ? this.prisma.recibos_cobro.findMany({
            where: { id: { in: reciboIds } },
            select: {
              id: true, numero_recibo: true, fecha_emision: true, cliente_id: true, estado: true,
              monto_total: true, cotizacion: true, cobrador_id: true, rendicion_id: true,
              moneda: { select: { codigo: true } },
              clientes: { select: { personas: { select: { razon_social: true } } } },
              rendiciones_cobranza: {
                select: {
                  id: true, codigo: true, estado: true, total_declarado: true, total_verificado: true, diferencia: true,
                },
              },
              facturas_multi: { select: { factura_cab_id: true, monto_pagado: true } },
              medios_pago_multi: {
                select: {
                  medio: true, monto: true, tipo_cambio: true, moneda: { select: { codigo: true } },
                  cheque_numero: true, cheque_banco: true, cheque_titular: true, cheque_fecha_vencimiento: true,
                },
              },
              // Recibos LEGACY: factura (vía cuota) y medio en la misma línea.
              recibo_cobro_detalle: {
                select: {
                  monto_pagado: true, cotizacion: true, numero_cheque: true, banco_emisor: true,
                  moneda: { select: { codigo: true } },
                  medio_pago: { select: { descripcion: true } },
                  factura_cuotas: { select: { factura_cab_id: true } },
                },
              },
            },
          })
        : Promise.resolve([] as any[]),
    ]);

    const facturasVigentes = facturas.filter((f: any) => !ESTADOS_FACTURA_BAJA.includes(f.estado));
    const idsFacturasViaje = new Set(facturasVigentes.map((f: any) => f.id));
    const topes = await this.topesPorNc(facturasVigentes);

    const clientes = new Map<string, Cliente>();
    const cliente = (id: string | null | undefined, nombre: string): Cliente => {
      const key = id ?? 'sin-cliente';
      if (!clientes.has(key)) {
        clientes.set(key, {
          id: key, nombre, facturas: [], condiciones: new Set(), vencimiento: null,
          vendido_base: 0, cobrado_caja_base: 0, cobrado_viaje_base: 0, deuda_anterior_cobrada_base: 0,
          saldo_base: 0, saldo_vencido: false, deuda_anterior_abierta_base: 0, cobrado_sin_enganchar_base: 0,
          medios: new Set(),
        });
      }
      return clientes.get(key)!;
    };

    const tot = {
      facturado_base: 0,
      notas_credito_base: 0,
      vendido_base: 0,
      cobrado_caja_base: 0,
      cancelado_viaje_base: 0,
      deuda_anterior_cobrada_base: 0,
      cancelado_sin_enganchar_base: 0,
      nc_sin_aplicar_base: 0,
      recibido_base: 0,
      por_cobrar_base: 0,
      por_cobrar_vencido_base: 0,
    };
    const vendidoPorMoneda: Record<string, number> = {};
    const cobradoPorMoneda: Record<string, number> = {};
    const porMedio = new Map<string, number>();
    const sumarMedio = (medio: string, monto: number) => porMedio.set(medio, (porMedio.get(medio) ?? 0) + monto);
    const control = {
      rendido_base: 0,
      en_rendicion_base: 0,
      sin_rendir_base: 0,
      cobrado_oficina_base: 0,
      cobro_ruta_sin_registrar_base: 0,
      sin_rendir: [] as any[],
      cobro_ruta_sin_registrar: [] as any[],
      rendiciones: new Map<string, any>(),
    };

    // ── Facturas: vendido, cobrado en caja y saldo ──
    for (const f of facturasVigentes) {
      const codigo = f.moneda?.codigo ?? MONEDA_BASE;
      const total = Number(f.factura_subtotales?.[0]?.dtotgralope ?? 0);
      const saldoRegistrado = saldoFactura(f);
      const saldo = this.aplicarTope(saldoRegistrado, topes.get(f.id));
      const totalBase = await base(total, codigo, f.dticam);
      const saldoBase = await base(saldo, codigo, f.dticam);
      tot.nc_sin_aplicar_base += await base(saldoRegistrado - saldo, codigo, f.dticam);
      const esCredito = /cr[eé]dito/i.test(f.condicion_operacion?.descripcion ?? '');
      const condicion = esCredito
        ? CONDICION_COBRO.CREDITO
        : f.cobro_diferido ? CONDICION_COBRO.CONTADO_RUTA : CONDICION_COBRO.CONTADO;

      // Vence con la primera cuota que todavía debe; sin cuotas, con dvencpag; un
      // contado vence el día que se emitió.
      const cuotaAbierta = (f.factura_cuotas ?? []).find((c: any) => Number(c.saldo_pendiente ?? 0) > 0);
      const vencimiento =
        ymdDate(cuotaAbierta?.dvenccuo) ?? ymdDate(f.dvencpag) ?? (f.dfeemide ? ymdInAsuncion(new Date(f.dfeemide)) : null);
      const vencido = saldo > 0 && vencimiento != null && vencimiento < hoy;

      const c = cliente(f.cliente_id, nombreCliente(f));
      c.facturas.push(numeroFactura(f));
      c.condiciones.add(condicion);
      c.vendido_base += totalBase;
      c.saldo_base += saldoBase;
      if (vencido) c.saldo_vencido = true;
      if (saldo > 0 && vencimiento && (!c.vencimiento || vencimiento < c.vencimiento)) c.vencimiento = vencimiento;

      vendidoPorMoneda[codigo] = (vendidoPorMoneda[codigo] ?? 0) + total;
      tot.facturado_base += totalBase;
      tot.vendido_base += totalBase;
      tot.por_cobrar_base += saldoBase;
      if (vencido) tot.por_cobrar_vencido_base += saldoBase;

      if (condicion === CONDICION_COBRO.CONTADO) {
        // Se cobró en la caja al emitir: lo cobrado es el total menos lo que quedó.
        const cobrado = Math.max(0, total - saldo);
        const cobradoBase = totalBase - saldoBase;
        tot.cobrado_caja_base += cobradoBase;
        c.cobrado_caja_base += cobradoBase;
        cobradoPorMoneda[codigo] = (cobradoPorMoneda[codigo] ?? 0) + cobrado;
        const medio = medioDeCaja(f.factura_forma_pagos?.[0]?.medio_pago?.descripcion);
        sumarMedio(medio, cobradoBase);
        if (cobradoBase > 0) c.medios.add(medio);
      }

      if (condicion === CONDICION_COBRO.CONTADO_RUTA && saldo > 0) {
        control.cobro_ruta_sin_registrar_base += saldoBase;
        control.cobro_ruta_sin_registrar.push({
          id: f.id, numero: numeroFactura(f), cliente: nombreCliente(f), saldo, moneda: codigo, saldo_base: saldoBase,
        });
      }
    }

    for (const n of notas) {
      const codigo = n.moneda?.codigo ?? MONEDA_BASE;
      const monto = Number(n.nota_credito_subtotal?.[0]?.dtotgralope ?? 0);
      const montoBase = await base(monto, codigo, n.dticam);
      vendidoPorMoneda[codigo] = (vendidoPorMoneda[codigo] ?? 0) - monto;
      tot.vendido_base -= montoBase;
      tot.notas_credito_base += montoBase;
      cliente(n.cliente_id, clientes.get(n.cliente_id)?.nombre ?? 'Sin nombre').vendido_base -= montoBase;
    }

    // ── Recibos: cancelado por destino, recibido por medio, control de rendición ──
    const cheques: any[] = [];
    for (const r of recibos) {
      if ((r.estado ?? '').toLowerCase() === RECIBO_ANULADO) continue;
      const codigo = r.moneda?.codigo ?? MONEDA_BASE;
      const fechaRecibo = r.fecha_emision ? ymdInAsuncion(new Date(r.fecha_emision)) : hoy;
      const c = cliente(r.cliente_id, nombreCliente(r));

      for (const l of r.facturas_multi ?? []) {
        const monto = Number(l.monto_pagado ?? 0);
        const montoBase = await base(monto, codigo, r.cotizacion);
        if (idsFacturasViaje.has(l.factura_cab_id)) {
          tot.cancelado_viaje_base += montoBase;
          c.cobrado_viaje_base += montoBase;
          cobradoPorMoneda[codigo] = (cobradoPorMoneda[codigo] ?? 0) + monto;
        } else {
          tot.deuda_anterior_cobrada_base += montoBase;
          c.deuda_anterior_cobrada_base += montoBase;
        }
      }

      let recibidoBase = 0;
      const legacy = r.recibo_cobro_detalle ?? [];
      for (const l of legacy) {
        const codigoLinea = l.moneda?.codigo ?? codigo;
        const tasa = Number(l.cotizacion) > 0 ? l.cotizacion : r.cotizacion;
        const monto = Number(l.monto_pagado ?? 0);
        const montoBase = await base(monto, codigoLinea, tasa);
        if (idsFacturasViaje.has(l.factura_cuotas?.factura_cab_id)) {
          tot.cancelado_viaje_base += montoBase;
          c.cobrado_viaje_base += montoBase;
          cobradoPorMoneda[codigoLinea] = (cobradoPorMoneda[codigoLinea] ?? 0) + monto;
        } else {
          tot.deuda_anterior_cobrada_base += montoBase;
          c.deuda_anterior_cobrada_base += montoBase;
        }
        // En legacy lo cancelado y lo recibido son la misma línea.
        recibidoBase += montoBase;
        let medio: string = medioDeCaja(l.medio_pago?.descripcion);
        if (medio === MEDIO_COBRO.CHEQUE_AL_DIA) {
          // El detalle legacy no guarda vencimiento: no se puede saber si es diferido.
          medio = MEDIO_COBRO.CHEQUE_SIN_FECHA;
          cheques.push({
            recibo_id: r.id, recibo: r.numero_recibo, cliente: nombreCliente(r),
            numero: l.numero_cheque ?? null, banco: l.banco_emisor ?? null, titular: null,
            vencimiento: null, diferido: false, monto, moneda: codigoLinea, monto_base: montoBase,
          });
        }
        sumarMedio(medio, montoBase);
        c.medios.add(medio);
      }

      const medios = r.medios_pago_multi ?? [];
      if (medios.length === 0 && legacy.length === 0) {
        // Recibos viejos sin el detalle de medios: el total va sin clasificar.
        recibidoBase = await base(Number(r.monto_total ?? 0), codigo, r.cotizacion);
        sumarMedio(MEDIO_COBRO.SIN_DETALLE, recibidoBase);
        c.medios.add(MEDIO_COBRO.SIN_DETALLE);
      }
      for (const m of medios) {
        const codigoMedio = m.moneda?.codigo ?? codigo;
        const monto = Number(m.monto ?? 0);
        const montoBase = await base(monto, codigoMedio, Number(m.tipo_cambio) > 0 ? m.tipo_cambio : r.cotizacion);
        recibidoBase += montoBase;
        let medio: string = medioDeRecibo(m.medio);
        if ((m.medio ?? '').toUpperCase() === 'CHEQUE') {
          const vence = ymdDate(m.cheque_fecha_vencimiento);
          const diferido = vence != null && vence > fechaRecibo;
          medio = vence == null
            ? MEDIO_COBRO.CHEQUE_SIN_FECHA
            : diferido ? MEDIO_COBRO.CHEQUE_DIFERIDO : MEDIO_COBRO.CHEQUE_AL_DIA;
          cheques.push({
            recibo_id: r.id, recibo: r.numero_recibo, cliente: nombreCliente(r),
            numero: m.cheque_numero ?? null, banco: m.cheque_banco ?? null, titular: m.cheque_titular ?? null,
            vencimiento: vence, diferido, monto, moneda: codigoMedio, monto_base: montoBase,
          });
        }
        sumarMedio(medio, montoBase);
        c.medios.add(medio);
      }
      tot.recibido_base += recibidoBase;

      // Control de rendición.
      const rend = r.rendiciones_cobranza;
      const fila = { id: r.id, numero: r.numero_recibo, cliente: nombreCliente(r), fecha: fechaRecibo, monto_base: recibidoBase };
      if (rend && rend.estado === RENDICION_APROBADA) {
        control.rendido_base += recibidoBase;
      } else if (rend && rend.estado !== RENDICION_RECHAZADA) {
        control.en_rendicion_base += recibidoBase;
      } else if (r.cobrador_id || rend) {
        // Rechazada: hay que volver a rendirla, así que cuenta como sin rendir.
        control.sin_rendir_base += recibidoBase;
        control.sin_rendir.push({ ...fila, rendicion: rend?.codigo ?? null });
      } else {
        control.cobrado_oficina_base += recibidoBase;
      }
      if (rend && !control.rendiciones.has(rend.id)) {
        // La diferencia es la que registró la rendición. `total_verificado` solo suma
        // los medios verificados uno a uno, así que declarado − verificado da
        // faltantes que no existen (REN-000004 de DOBA: 37 M "de diferencia" con 0 real).
        control.rendiciones.set(rend.id, {
          id: rend.id, codigo: rend.codigo, estado: rend.estado,
          declarado: Number(rend.total_declarado ?? 0),
          verificado: rend.total_verificado != null ? Number(rend.total_verificado) : null,
          diferencia: rend.diferencia != null ? Number(rend.diferencia) : null,
        });
      }
    }

    // ── Recibos que pagan facturas del viaje y no están enganchados ──
    const [recibosSueltos, asignados, deudaAnterior] = await Promise.all([
      idsFacturasViaje.size
        ? this.prisma.recibos_cobro.findMany({
            where: {
              empresa_id: empresaId,
              estado: { not: RECIBO_ANULADO },
              ...(reciboIds.length && { id: { notIn: reciboIds } }),
              OR: [
                { facturas_multi: { some: { factura_cab_id: { in: [...idsFacturasViaje] as string[] } } } },
                { recibo_cobro_detalle: { some: { factura_cuotas: { factura_cab_id: { in: [...idsFacturasViaje] as string[] } } } } },
              ],
            },
            select: {
              id: true, numero_recibo: true, fecha_emision: true, monto_total: true, cotizacion: true, cliente_id: true,
              moneda: { select: { codigo: true } },
              clientes: { select: { personas: { select: { razon_social: true } } } },
              // Solo las líneas que pagan facturas del viaje: es lo que falta sumar.
              facturas_multi: {
                where: { factura_cab_id: { in: [...idsFacturasViaje] as string[] } },
                select: { monto_pagado: true },
              },
              recibo_cobro_detalle: {
                where: { factura_cuotas: { factura_cab_id: { in: [...idsFacturasViaje] as string[] } } },
                select: { monto_pagado: true },
              },
            },
            orderBy: { fecha_emision: 'asc' },
          })
        : Promise.resolve([] as any[]),
      this.prisma.viaje_documentos.findMany({
        where: { tipo: TIPO_DOC_VIAJE.RECIBO, viaje: { empresa_id: empresaId }, viaje_id: { not: viajeId } },
        select: { documento_id: true },
      }),
      this.deudaAnterior(empresaId, usuarioId, [...clientes.keys()], [...idsFacturasViaje] as string[]),
    ]);
    const enOtroViaje = new Set(asignados.map((a: any) => a.documento_id));
    // Lo que estos recibos cancelaron de facturas del viaje se suma aparte: así
    // vendido = cobrado + sin enganchar + pendiente cierra aunque no se enganchen
    // (en DOBA es lo común: los recibos se emiten días después del retorno).
    const recibosSinEnganchar: any[] = [];
    for (const r of recibosSueltos.filter((x: any) => !enOtroViaje.has(x.id))) {
      const codigo = r.moneda?.codigo ?? MONEDA_BASE;
      const aplicado = [...(r.facturas_multi ?? []), ...(r.recibo_cobro_detalle ?? [])].reduce(
        (acc: number, l: any) => acc + Number(l.monto_pagado ?? 0),
        0,
      );
      const aplicadoBase = await base(aplicado, codigo, r.cotizacion);
      tot.cancelado_sin_enganchar_base += aplicadoBase;
      const c = clientes.get(r.cliente_id);
      if (c) c.cobrado_sin_enganchar_base += aplicadoBase;
      recibosSinEnganchar.push({
        id: r.id, numero: r.numero_recibo,
        fecha: r.fecha_emision ? ymdInAsuncion(new Date(r.fecha_emision)) : null,
        cliente: nombreCliente(r), total: Number(r.monto_total ?? 0), aplicado_viaje: aplicado,
        moneda: codigo,
      });
    }

    const topesAnterior = await this.topesPorNc(deudaAnterior);
    for (const d of deudaAnterior) {
      const c = clientes.get(d.cliente_id);
      if (!c) continue;
      const saldo = this.aplicarTope(saldoFactura(d), topesAnterior.get(d.id));
      c.deuda_anterior_abierta_base += await base(saldo, d.moneda?.codigo, d.dticam);
    }

    const semanas = new Map<string, { semana: string; cantidad: number; monto_base: number }>();
    for (const ch of cheques) {
      if (!ch.diferido) continue;
      const s = lunesDe(ch.vencimiento);
      const acc = semanas.get(s) ?? { semana: s, cantidad: 0, monto_base: 0 };
      acc.cantidad += 1;
      acc.monto_base += ch.monto_base;
      semanas.set(s, acc);
    }

    const canceladoTotal = tot.cancelado_viaje_base + tot.deuda_anterior_cobrada_base;
    return {
      moneda_base: MONEDA_BASE,
      hoy,
      cotizacion_estimada: marca.estimada,
      vendido: vendidoPorMoneda,
      cobrado: cobradoPorMoneda,
      totales: {
        ...tot,
        // Lo cobrado de ESTE viaje: lo de caja y lo que los recibos cancelaron de sus facturas.
        cobrado_base: tot.cobrado_caja_base + tot.cancelado_viaje_base,
        // Cancelado (bruto) − recibido (neto). Positivo: retenciones, NC aplicadas o
        // saldo a favor; negativo: intereses cobrados.
        ajustes_base: canceladoTotal - tot.recibido_base,
      },
      por_medio: [...porMedio.entries()]
        .filter(([, v]) => v !== 0)
        .map(([medio, monto_base]) => ({ medio, monto_base })),
      cheques: cheques.sort((a, b) => (a.vencimiento ?? '').localeCompare(b.vencimiento ?? '')),
      cheques_por_semana: [...semanas.values()].sort((a, b) => a.semana.localeCompare(b.semana)),
      control: { ...control, rendiciones: [...control.rendiciones.values()] },
      recibos_sin_enganchar: recibosSinEnganchar,
      clientes: [...clientes.values()]
        .map((c) => ({ ...c, condiciones: [...c.condiciones], medios: [...c.medios] }))
        .sort((a, b) => a.nombre.localeCompare(b.nombre, 'es')),
    };
  }

  private aplicarTope(saldo: number, tope: number | undefined) {
    return tope == null ? saldo : Math.min(saldo, Math.max(0, tope));
  }

  /**
   * Tope del saldo por notas de crédito: total − NC que la referencian − lo
   * cobrado en plata. En DOBA, de 69 facturas con NC sin aplicación registrada,
   * 31 conservaron el saldo completo (la NC se emitió y nunca se aplicó) y 38 ya
   * lo tenían rebajado. Restar la NC a secas descontaría dos veces en esas 38;
   * usarla como tope solo corrige las que quedaron altas.
   *
   * Lo cobrado con recibos MULTI va sin la parte de NC (`monto_pagado` − `monto_nc`),
   * porque esa NC ya está en el total de NC.
   */
  private async topesPorNc(facturas: any[]): Promise<Map<string, number>> {
    const topes = new Map<string, number>();
    const ids = facturas.map((f) => f.id).filter(Boolean);
    if (ids.length === 0) return topes;

    const ncs = await this.prisma.nota_credito_cab.findMany({
      where: { factura_cab_id: { in: ids }, estado: { notIn: ESTADOS_FACTURA_BAJA } },
      select: { factura_cab_id: true, nota_credito_subtotal: { select: { dtotgralope: true } } },
    });
    if (ncs.length === 0) return topes;

    const ncPorFactura = new Map<string, number>();
    for (const n of ncs) {
      const monto = Number(n.nota_credito_subtotal?.[0]?.dtotgralope ?? 0);
      ncPorFactura.set(n.factura_cab_id as string, (ncPorFactura.get(n.factura_cab_id as string) ?? 0) + monto);
    }
    const conNc = [...ncPorFactura.keys()];
    const facturaDeCuota = new Map<string, string>();
    for (const f of facturas) {
      if (!ncPorFactura.has(f.id)) continue;
      for (const c of f.factura_cuotas ?? []) if (c.id) facturaDeCuota.set(c.id, f.id);
    }

    const [multi, legacy] = await Promise.all([
      this.prisma.recibo_cobro_facturas.groupBy({
        by: ['factura_cab_id'],
        where: { factura_cab_id: { in: conNc }, recibo: { estado: { not: RECIBO_ANULADO } } },
        _sum: { monto_pagado: true, monto_nc: true },
      }),
      facturaDeCuota.size
        ? this.prisma.recibo_cobro_detalle.groupBy({
            by: ['factura_cuota_id'],
            where: { factura_cuota_id: { in: [...facturaDeCuota.keys()] }, recibos_cobro: { estado: { not: RECIBO_ANULADO } } },
            _sum: { monto_pagado: true },
          })
        : Promise.resolve([] as any[]),
    ]);

    const cobrado = new Map<string, number>();
    for (const m of multi as any[]) {
      const plata = Number(m._sum?.monto_pagado ?? 0) - Number(m._sum?.monto_nc ?? 0);
      cobrado.set(m.factura_cab_id, (cobrado.get(m.factura_cab_id) ?? 0) + plata);
    }
    for (const l of legacy as any[]) {
      const fid = facturaDeCuota.get(l.factura_cuota_id);
      if (fid) cobrado.set(fid, (cobrado.get(fid) ?? 0) + Number(l._sum?.monto_pagado ?? 0));
    }

    for (const f of facturas) {
      if (!ncPorFactura.has(f.id)) continue;
      const total = Number(f.factura_subtotales?.[0]?.dtotgralope ?? 0);
      topes.set(f.id, total - (ncPorFactura.get(f.id) ?? 0) - (cobrado.get(f.id) ?? 0));
    }
    return topes;
  }

  /**
   * Saldo abierto de los clientes del viaje en facturas que NO son del viaje.
   * Es una foto de hoy, no del día del viaje (la UI lo rotula así).
   */
  private async deudaAnterior(empresaId: string, usuarioId: string | undefined, clienteIds: string[], excluir: string[]) {
    const ids = clienteIds.filter((id) => id !== 'sin-cliente');
    if (ids.length === 0) return [];
    const { fiscal } = await this.alcance(usuarioId, empresaId);
    return this.prisma.factura_cab.findMany({
      where: {
        empresa_id: empresaId,
        cliente_id: { in: ids },
        id: { notIn: excluir.length ? excluir : ['00000000-0000-0000-0000-000000000000'] },
        // Saldo en la factura o en alguna cuota: el filtro fino lo hace saldoFactura.
        OR: [{ saldo_pendiente: { gt: 0 } }, { factura_cuotas: { some: { saldo_pendiente: { gt: 0 } } } }],
        estado: { notIn: ESTADOS_FACTURA_BAJA },
        ...(fiscal && { dest: fiscal.dest }),
      },
      select: {
        id: true, cliente_id: true, saldo_pendiente: true, dticam: true,
        moneda: { select: { codigo: true } },
        factura_subtotales: { select: { dtotgralope: true } },
        factura_cuotas: { select: { id: true, saldo_pendiente: true } },
      },
    });
  }
}
