import { BadRequestException, Injectable, Logger } from '@nestjs/common';
import { AuditService } from 'src/audit/audit.service';
import { AsientosService } from 'src/contabilidad/services/asientos.service';
import { ContabilidadIntegracionService } from 'src/contabilidad/services/integracion.service';
import { TipoCambioService } from 'src/contabilidad/services/tipo-cambio.service';
import { PrismaService } from 'src/prisma/prisma.service';
import { RendicionViaticosContabilidadService } from 'src/rendicion-viaticos/rendicion-viaticos-contabilidad.service';
import {
  type AsientoSimulado,
  crearColectorAsientos,
  conFaltaDeMapeoComoError,
  crearIntegradorSombra,
  crearPrismaSombra,
} from './integrador-sombra';
import { cuentasQueCambian, describirAsiento, fotoAsiento, fotoAsientoDeDocumento, textoCambios } from './foto-asiento';

/**
 * Recontabilización: rehacer los asientos automáticos con el mapeo y el código
 * de hoy.
 *
 * Conciliación encuentra los documentos SIN asiento. Esto encuentra los que
 * tienen un asiento que hoy saldría distinto ("desactualizado"): porque se
 * corrigió el mapeo, una categoría de tesorería o un integrador. Es la forma de
 * arreglar un período entero sin asientos manuales de ajuste.
 *
 * Dos pasos, siempre en este orden:
 *  1. `simular`: corre el integrador real sobre un Prisma que no escribe (ver
 *     integrador-sombra) y compara cuenta por cuenta con el asiento guardado.
 *  2. `aplicar`: para los documentos elegidos, el asiento viejo pasa a
 *     REEMPLAZADO (queda guardado, los reportes no lo suman), su documento se
 *     desvincula del origen y el integrador real genera el nuevo. Si no lo
 *     genera, se restaura el viejo.
 *
 * Solo toca asientos automáticos confirmados de períodos abiertos: nunca los
 * manuales (sin documento de origen), las reversiones ni los documentos
 * anulados.
 */

const TOLERANCIA_GS = 1;

/**
 * Orígenes cuyo integrador guarda el vínculo con el asiento en el propio
 * documento (`cont_documento_id`): al correrlo de nuevo dice "ya tiene" y no
 * genera nada, así que no se pueden simular ni rehacer con este mecanismo.
 */
const NO_RECONTABILIZABLES: Record<string, string> = {
  rendicion_adelanto: 'Las rendiciones de viáticos guardan su asiento en la propia rendición; no se recontabilizan desde acá.',
  rendicion_devolucion: 'Las rendiciones de viáticos guardan su asiento en la propia rendición; no se recontabilizan desde acá.',
};

/**
 * IGUAL: el asiento guardado es el que saldría hoy.
 * CAMBIA: hoy saldría con otras cuentas, montos o fecha.
 * SOBRA: tiene asiento pero hoy no le corresponde ninguno (típico: factura o NC
 *        anulada cuya anulación no revirtió el asiento).
 * ERROR: hoy el integrador falla (falta un mapeo, cuenta desactivada…).
 */
export type EstadoSimulacion = 'IGUAL' | 'CAMBIA' | 'SOBRA' | 'ERROR';

export interface LineaComparada {
  cuenta_id: string;
  codigo: string;
  descripcion: string;
  debe: number;
  haber: number;
}

export interface DocumentoSimulado {
  origen_id: string;
  numero: string | null;
  fecha: string;
  estado: EstadoSimulacion;
  error?: string;
  actual: LineaComparada[];
  nuevo: LineaComparada[];
}

export interface ImpactoCuenta {
  cuenta_id: string;
  codigo: string;
  descripcion: string;
  /** Saldo deudor actual de la cuenta (debe − haber), todos los asientos confirmados. */
  saldo_actual: number;
  /** Cambio que produciría aplicar los documentos que cambian de este origen. */
  diferencia: number;
}

export interface ResultadoSimulacion {
  origen_tipo: string;
  descripcion: string;
  analizados: number;
  iguales: number;
  cambian: number;
  errores: number;
  sobran: number;
  /** Si el origen no se puede recontabilizar, por qué (y todo lo demás en 0). */
  no_soportado?: string;
  /** Solo los que no quedan iguales; los iguales no aportan nada a revisar. */
  documentos: DocumentoSimulado[];
  impacto: ImpactoCuenta[];
}

export interface ResultadoAplicacionItem {
  origen_id: string;
  ok: boolean;
  error?: string;
  doc_nuevo_id?: string;
  /** Se quitó el asiento sin reemplazo (estaba SOBRA). */
  quitado?: boolean;
}

export interface OpcionesSimulacion {
  ids?: string[];
  ultimos?: number;
  incluirBorradores?: boolean;
  incluirIguales?: boolean;
}

type Integrador = Record<string, (...args: unknown[]) => Promise<unknown>>;

@Injectable()
export class RecontabilizacionService {
  private readonly logger = new Logger(RecontabilizacionService.name);
  private readonly integradores: Record<string, object>;

  constructor(
    private readonly prisma: PrismaService,
    private readonly asientos: AsientosService,
    private readonly tipoCambio: TipoCambioService,
    private readonly audit: AuditService,
    contabilidadIntegracion: ContabilidadIntegracionService,
    viaticosContabilidad: RendicionViaticosContabilidadService,
  ) {
    // Mismo mapa que ContabilidadConciliacionService.integradores.
    this.integradores = {
      ContabilidadIntegracionService: contabilidadIntegracion,
      RendicionViaticosContabilidadService: viaticosContabilidad,
    };
  }

  private async entrada(origenTipo: string) {
    if (NO_RECONTABILIZABLES[origenTipo]) throw new BadRequestException(NO_RECONTABILIZABLES[origenTipo]);
    const entry = await this.prisma.cont_origen_registry.findUnique({ where: { origen_tipo: origenTipo } });
    if (!entry || !entry.activo) {
      throw new BadRequestException(`Origen "${origenTipo}" no está registrado o está inactivo`);
    }
    const servicio = this.integradores[entry.service_name] as Integrador | undefined;
    if (!servicio || typeof servicio[entry.service_method] !== 'function') {
      throw new BadRequestException(`El origen "${origenTipo}" no tiene integrador disponible para recontabilizar`);
    }
    return { entry, servicio };
  }

  private invocar(servicio: object, metodo: string, argStyle: string | null, origenId: string, empresaId: string) {
    const fn = (servicio as Integrador)[metodo];
    return argStyle === 'id_empresa' ? fn.call(servicio, origenId, empresaId) : fn.call(servicio, origenId);
  }

  /**
   * Documentos con asiento automático vigente: documento y asiento confirmados,
   * con origen, que no son una reversión, de un período abierto, y con fecha de
   * asiento en el rango.
   */
  private candidatos(
    empresaId: string,
    origenTipo: string,
    filtros: { desde?: string; hasta?: string },
    ids?: string[],
    ultimos?: number,
  ) {
    const fecha: { gte?: Date; lte?: Date } = {};
    if (filtros.desde) fecha.gte = new Date(filtros.desde);
    if (filtros.hasta) fecha.lte = new Date(filtros.hasta);
    return this.prisma.cont_documentos.findMany({
      where: {
        empresa_id: empresaId,
        origen_tipo: origenTipo,
        estado: 'CONFIRMADO',
        reversion_de_id: null,
        origen_id: ids ? { in: ids } : { not: null },
        asiento: {
          estado: 'CONFIRMADO',
          periodo: { estado: 'ABIERTO' },
          ...(filtros.desde || filtros.hasta ? { fecha } : {}),
        },
      },
      select: {
        id: true,
        origen_id: true,
        numero_documento: true,
        asiento: {
          select: {
            id: true,
            fecha: true,
            lineas: { select: { cuenta_id: true, debe_pyg: true, haber_pyg: true } },
          },
        },
      },
      // "Probar contra los últimos N": los más recientes primero.
      orderBy: { created_at: ultimos ? 'desc' : 'asc' },
      take: ultimos,
    });
  }

  /**
   * `opciones` (vista previa de reglas): `ids` limita a esos documentos,
   * `ultimos` toma los N más recientes, `incluirBorradores` aplica también las
   * reglas en borrador e `incluirIguales` devuelve también los que no cambian.
   */
  async simular(
    empresaId: string,
    origenTipo: string,
    filtros: { desde?: string; hasta?: string } = {},
    opciones: OpcionesSimulacion = {},
  ): Promise<ResultadoSimulacion> {
    if (NO_RECONTABILIZABLES[origenTipo]) {
      const registro = await this.prisma.cont_origen_registry.findUnique({ where: { origen_tipo: origenTipo } });
      return {
        origen_tipo: origenTipo,
        descripcion: registro?.descripcion ?? origenTipo,
        analizados: 0,
        iguales: 0,
        cambian: 0,
        errores: 0,
        sobran: 0,
        no_soportado: NO_RECONTABILIZABLES[origenTipo],
        documentos: [],
        impacto: [],
      };
    }
    const { entry, servicio } = await this.entrada(origenTipo);
    const docs = await this.candidatos(empresaId, origenTipo, filtros, opciones.ids, opciones.ultimos);

    const prismaSombra = crearPrismaSombra(this.prisma);

    const crudos: Array<{
      origen_id: string;
      numero: string | null;
      fechaActual: string;
      actual: Map<string, { debe: number; haber: number }>;
      estado: EstadoSimulacion;
      error?: string;
      nuevo?: AsientoSimulado;
    }> = [];

    for (const doc of docs) {
      const origenId = doc.origen_id as string;
      const actual = agrupar(
        (doc.asiento?.lineas ?? []).map((l) => ({ cuenta_id: l.cuenta_id, debe_pyg: Number(l.debe_pyg), haber_pyg: Number(l.haber_pyg) })),
      );
      const fechaActual = doc.asiento ? doc.asiento.fecha.toISOString().substring(0, 10) : '';
      const { nuevo, error } = await this.correrSombra(entry, servicio, prismaSombra, origenId, empresaId, opciones);
      if (error !== undefined) {
        crudos.push({ origen_id: origenId, numero: doc.numero_documento, fechaActual, actual, estado: 'ERROR', error });
        continue;
      }
      if (!nuevo) {
        crudos.push({ origen_id: origenId, numero: doc.numero_documento, fechaActual, actual, estado: 'SOBRA' });
        continue;
      }
      const igual = mismasLineas(actual, agrupar(nuevo.lineas)) && nuevo.fecha.substring(0, 10) === fechaActual;
      crudos.push({ origen_id: origenId, numero: doc.numero_documento, fechaActual, actual, estado: igual ? 'IGUAL' : 'CAMBIA', nuevo });
    }

    // Cuentas involucradas, para mostrar código y descripción.
    const cuentaIds = new Set<string>();
    for (const c of crudos) {
      if (c.estado === 'IGUAL' && !opciones.incluirIguales) continue;
      c.actual.forEach((_v, k) => cuentaIds.add(k));
      c.nuevo?.lineas.forEach((l) => cuentaIds.add(l.cuenta_id));
    }
    const cuentas = cuentaIds.size
      ? await this.prisma.cont_plan_cuentas.findMany({
          where: { id: { in: [...cuentaIds] } },
          select: { id: true, codigo: true, descripcion: true },
        })
      : [];
    const cuentaPorId = new Map(cuentas.map((c) => [c.id, c]));
    const aLineas = (m: Map<string, { debe: number; haber: number }>): LineaComparada[] =>
      [...m.entries()]
        .map(([cuenta_id, v]) => ({
          cuenta_id,
          codigo: cuentaPorId.get(cuenta_id)?.codigo ?? '?',
          descripcion: cuentaPorId.get(cuenta_id)?.descripcion ?? '',
          debe: v.debe,
          haber: v.haber,
        }))
        .sort((a, b) => b.debe - a.debe || a.codigo.localeCompare(b.codigo));

    // Impacto por cuenta: los que cambian y los que sobran (son los que se aplicarían).
    const diferencias = new Map<string, number>();
    for (const c of crudos) {
      if (c.estado !== 'CAMBIA' && c.estado !== 'SOBRA') continue;
      if (c.nuevo) agrupar(c.nuevo.lineas).forEach((v, k) => diferencias.set(k, (diferencias.get(k) ?? 0) + v.debe - v.haber));
      c.actual.forEach((v, k) => diferencias.set(k, (diferencias.get(k) ?? 0) - (v.debe - v.haber)));
    }
    const conCambio = [...diferencias.entries()].filter(([, d]) => Math.abs(d) >= TOLERANCIA_GS);
    const saldos = conCambio.length
      ? await this.prisma.cont_asientos_det.groupBy({
          by: ['cuenta_id'],
          where: {
            cuenta_id: { in: conCambio.map(([k]) => k) },
            asiento: { empresa_id: empresaId, estado: 'CONFIRMADO' },
          },
          _sum: { debe_pyg: true, haber_pyg: true },
        })
      : [];
    const saldoPorCuenta = new Map(
      saldos.map((s) => [s.cuenta_id, Number(s._sum.debe_pyg ?? 0) - Number(s._sum.haber_pyg ?? 0)]),
    );

    const documentos: DocumentoSimulado[] = crudos
      .filter((c) => c.estado !== 'IGUAL' || opciones.incluirIguales)
      .map((c) => ({
        origen_id: c.origen_id,
        numero: c.numero,
        fecha: c.fechaActual,
        estado: c.estado,
        ...(c.error ? { error: c.error } : {}),
        actual: aLineas(c.actual),
        nuevo: c.nuevo ? aLineas(agrupar(c.nuevo.lineas)) : [],
      }));

    return {
      origen_tipo: origenTipo,
      descripcion: entry.descripcion,
      analizados: crudos.length,
      iguales: crudos.filter((c) => c.estado === 'IGUAL').length,
      cambian: crudos.filter((c) => c.estado === 'CAMBIA').length,
      errores: crudos.filter((c) => c.estado === 'ERROR').length,
      sobran: crudos.filter((c) => c.estado === 'SOBRA').length,
      documentos,
      impacto: conCambio
        .map(([cuenta_id, diferencia]) => ({
          cuenta_id,
          codigo: cuentaPorId.get(cuenta_id)?.codigo ?? '?',
          descripcion: cuentaPorId.get(cuenta_id)?.descripcion ?? '',
          saldo_actual: redondear(saldoPorCuenta.get(cuenta_id) ?? 0),
          diferencia: redondear(diferencia),
        }))
        .sort((a, b) => a.codigo.localeCompare(b.codigo)),
    };
  }

  /**
   * Para la vista previa de reglas: el origen tiene que estar registrado y
   * tener un integrador que se pueda correr en sombra. Los que no están en el
   * registro (transferencias, Bancard, CxP de empleado) todavía no se simulan.
   */
  async origenSimulable(origenTipo: string) {
    const registro = await this.prisma.cont_origen_registry.findUnique({ where: { origen_tipo: origenTipo } });
    if (!registro || !registro.activo) throw new BadRequestException('Este tipo de documento no se puede simular todavía.');
    return this.entrada(origenTipo);
  }

  /**
   * Tipos de documento que se pueden previsualizar ("Probar con un documento"):
   * los del registro activos, con integrador para correr en sombra y que no
   * estén entre los que no se recontabilizan.
   */
  async origenesSimulables() {
    const registro = await this.prisma.cont_origen_registry.findMany({ where: { activo: true }, orderBy: [{ orden: 'asc' }, { descripcion: 'asc' }] });
    return registro
      .filter((r) => !NO_RECONTABILIZABLES[r.origen_tipo])
      .filter((r) => typeof (this.integradores[r.service_name] as Integrador | undefined)?.[r.service_method] === 'function')
      .map((r) => ({ origen_tipo: r.origen_tipo, descripcion: r.descripcion, modulo: r.modulo }));
  }

  /**
   * El asiento que el integrador generaría hoy para un documento, con la traza
   * de cada línea, sin escribir nada. A diferencia de `simular`, no pide que el
   * documento tenga asiento vigente: sirve también para uno sin asiento.
   */
  async simularDocumento(
    empresaId: string,
    origenTipo: string,
    origenId: string,
    opciones: { incluirBorradores?: boolean } = {},
  ): Promise<{ nuevo: AsientoSimulado | null; error?: string }> {
    const { entry, servicio } = await this.origenSimulable(origenTipo);
    return this.correrSombra(entry, servicio, crearPrismaSombra(this.prisma), origenId, empresaId, opciones);
  }

  /** Corre el integrador de un documento en sombra: el asiento capturado (o null si no genera) o el error. */
  private async correrSombra(
    entry: { service_method: string; arg_style: string | null },
    servicio: object,
    prismaSombra: unknown,
    origenId: string,
    empresaId: string,
    opciones: { incluirBorradores?: boolean },
  ): Promise<{ nuevo: AsientoSimulado | null; error?: string }> {
    const asientosReal = this.asientos as unknown as {
      validarPartidaDoble: (d: number, h: number) => void;
      validarLineas: (e: string, l: Array<Record<string, unknown>>) => Promise<void>;
    };
    const colector = crearColectorAsientos({
      convertirAPYG: (e, m, mon, f) => this.tipoCambio.convertirAPYG(e, m, mon, f),
      validarPartidaDoble: (d, h) => asientosReal.validarPartidaDoble.call(this.asientos, d, h),
      validarLineas: (e, l) => asientosReal.validarLineas.call(this.asientos, e, l),
    });
    const sombra = crearIntegradorSombra(servicio, prismaSombra, colector, {
      opcionesMapeo: opciones.incluirBorradores ? { incluirBorradores: true } : undefined,
    });
    try {
      await this.invocar(sombra, entry.service_method, entry.arg_style, origenId, empresaId);
      return { nuevo: colector.capturados[0] ?? null };
    } catch (err) {
      return { nuevo: null, error: err instanceof Error ? err.message : String(err) };
    }
  }

  /**
   * Reemplaza el asiento de cada documento por el que genera hoy el integrador.
   * Uno por uno: un error no frena el resto, y el documento que falla queda con
   * su asiento anterior.
   */
  async aplicar(
    empresaId: string,
    origenTipo: string,
    ids: string[],
    userId?: string,
    opciones: { quitarSobrantes?: boolean } = {},
  ): Promise<{ origen_tipo: string; ok: number; fallidos: number; items: ResultadoAplicacionItem[] }> {
    if (!ids?.length) throw new BadRequestException('Indicá qué documentos recontabilizar');
    if (ids.length > 500) throw new BadRequestException('Máximo 500 documentos por llamada');
    const { entry, servicio } = await this.entrada(origenTipo);
    const docs = await this.candidatos(empresaId, origenTipo, {}, ids);
    const porOrigen = new Map(docs.map((d) => [d.origen_id as string, d]));

    const items: ResultadoAplicacionItem[] = [];
    for (const origenId of ids) {
      const doc = porOrigen.get(origenId);
      if (!doc?.asiento) {
        items.push({ origen_id: origenId, ok: false, error: 'No tiene un asiento automático vigente en un período abierto' });
        continue;
      }
      items.push(
        await this.reemplazar(empresaId, entry, servicio, origenId, doc.id, doc.asiento.id, userId, opciones.quitarSobrantes === true),
      );
    }

    const ok = items.filter((i) => i.ok).length;
    await this.audit.log({
      empresa_id: empresaId,
      user_id: userId,
      action: 'CONT_RECONTABILIZAR',
      entity_type: entry.tabla_origen,
      entity_id: origenTipo,
      descripcion: `Recontabilización de ${entry.descripcion}: ${items.filter((i) => i.ok && !i.quitado).length} rehechos, ${items.filter((i) => i.quitado).length} quitados (anulados), ${items.length - ok} fallidos. El detalle de cada documento está en los registros CONT_RECONTABILIZAR_*`,
      new_value: {
        origen_tipo: origenTipo,
        ok,
        fallidos: items.length - ok,
        quitar_sobrantes: opciones.quitarSobrantes === true,
        fallidos_detalle: items.filter((i) => !i.ok).map((i) => ({ origen_id: i.origen_id, error: i.error })),
      },
    });
    return { origen_tipo: origenTipo, ok, fallidos: items.length - ok, items };
  }

  private async reemplazar(
    empresaId: string,
    entry: { origen_tipo: string; service_method: string; arg_style: string | null; tabla_origen: string; descripcion: string },
    servicio: object,
    origenId: string,
    docAnteriorId: string,
    asientoAnteriorId: string,
    userId?: string,
    quitarSobrante = false,
  ): Promise<ResultadoAplicacionItem> {
    // Foto del asiento vigente para la auditoría (antes de tocar nada).
    const antes = await fotoAsiento(this.prisma, asientoAnteriorId);
    const doc = antes?.documento ? `${antes.documento}` : '';
    const auditar = (action: string, descripcion: string, nuevo: Record<string, unknown> | undefined) =>
      this.audit.log({
        empresa_id: empresaId,
        user_id: userId,
        action,
        entity_type: entry.tabla_origen,
        entity_id: origenId,
        descripcion: `${`${entry.descripcion} ${doc}`.trim()}: ${descripcion}`,
        old_value: { asiento: antes as unknown as Record<string, unknown> },
        new_value: nuevo,
      });

    // 1. Apartar el asiento vigente: el documento sale del par (origen_tipo,
    //    origen_id) para que el integrador no lo encuentre y genere el nuevo.
    await this.prisma.$transaction([
      this.prisma.cont_documentos.update({
        where: { id: docAnteriorId },
        data: { estado: 'REEMPLAZADO', origen_id: null, updated_at: new Date() },
      }),
      this.prisma.cont_asientos.update({
        where: { id: asientoAnteriorId },
        data: { estado: 'REEMPLAZADO', updated_at: new Date() },
      }),
    ]);

    let error: string | undefined;
    try {
      // Con la falta de mapeo como error: si no, el integrador se la traga, no
      // genera nada y el asiento vigente se quitaría como sobrante.
      await this.invocar(conFaltaDeMapeoComoError(servicio), entry.service_method, entry.arg_style, origenId, empresaId);
    } catch (err) {
      error = err instanceof Error ? err.message : String(err);
    }

    const nuevo = await this.prisma.cont_documentos.findFirst({
      where: { empresa_id: empresaId, origen_tipo: entry.origen_tipo, origen_id: origenId, estado: 'CONFIRMADO' },
      select: { id: true },
    });

    if (!nuevo && !error && quitarSobrante) {
      // 2a. El integrador corrió bien y no generó nada: al documento hoy no le
      //     corresponde asiento (anulado). Se deja el viejo REEMPLAZADO, salvo
      //     que haya quedado un borrador ocupando el par (falta de mapeo).
      const borrador = await this.prisma.cont_documentos.findFirst({
        where: { empresa_id: empresaId, origen_tipo: entry.origen_tipo, origen_id: origenId },
        select: { id: true },
      });
      if (!borrador) {
        await this.prisma.cont_recontabilizaciones.create({
          data: {
            empresa_id: empresaId,
            origen_tipo: entry.origen_tipo,
            origen_id: origenId,
            doc_anterior_id: docAnteriorId,
            doc_nuevo_id: null,
            usuario_id: userId ?? null,
          },
        });
        await auditar(
          'CONT_RECONTABILIZAR_QUITADO',
          `se quitó el asiento ${describirAsiento(antes)}: hoy al documento no le corresponde asiento (está anulado). Queda guardado como reemplazado.`,
          { asiento: null, quitado: true },
        );
        return { origen_id: origenId, ok: true, quitado: true };
      }
    }

    if (!nuevo) {
      // 2b. No se generó: volver al asiento anterior. Un integrador puede haber
      //     dejado un documento en BORRADOR ocupando el par; se borra primero.
      await this.prisma.$transaction(async (tx) => {
        const restos = await tx.cont_documentos.findMany({
          where: { empresa_id: empresaId, origen_tipo: entry.origen_tipo, origen_id: origenId },
          select: { id: true },
        });
        if (restos.length) {
          await tx.cont_asientos.deleteMany({ where: { documento_id: { in: restos.map((r) => r.id) } } });
          await tx.cont_documentos.deleteMany({ where: { id: { in: restos.map((r) => r.id) } } });
        }
        await tx.cont_documentos.update({
          where: { id: docAnteriorId },
          data: { estado: 'CONFIRMADO', origen_id: origenId, updated_at: new Date() },
        });
        await tx.cont_asientos.update({
          where: { id: asientoAnteriorId },
          data: { estado: 'CONFIRMADO', updated_at: new Date() },
        });
      });
      const motivo = error ?? 'El integrador no generó asiento (puede faltar un mapeo o el período estar cerrado)';
      this.logger.warn(`Recontabilización ${entry.origen_tipo}/${origenId} no aplicada: ${motivo}`);
      await auditar(
        'CONT_RECONTABILIZAR_FALLA',
        `no se rehízo (${motivo}). Se conservó el asiento ${describirAsiento(antes)} sin cambios.`,
        { error: motivo },
      );
      return { origen_id: origenId, ok: false, error: motivo };
    }

    await this.prisma.cont_recontabilizaciones.create({
      data: {
        empresa_id: empresaId,
        origen_tipo: entry.origen_tipo,
        origen_id: origenId,
        doc_anterior_id: docAnteriorId,
        doc_nuevo_id: nuevo.id,
        usuario_id: userId ?? null,
      },
    });
    const despues = await fotoAsientoDeDocumento(this.prisma, nuevo.id);
    const cambios = cuentasQueCambian(antes, despues);
    await auditar(
      'CONT_RECONTABILIZAR_DOC',
      `asiento ${describirAsiento(antes)} reemplazado por ${describirAsiento(despues)}. ${textoCambios(cambios)}`,
      { asiento: despues as unknown as Record<string, unknown>, cuentas_que_cambian: cambios },
    );
    return { origen_id: origenId, ok: true, doc_nuevo_id: nuevo.id };
  }
}

function redondear(n: number) {
  return Math.round(n);
}

/** Suma por cuenta, en guaraníes enteros. */
export function agrupar(lineas: Array<{ cuenta_id: string; debe_pyg: number; haber_pyg: number }>) {
  const m = new Map<string, { debe: number; haber: number }>();
  for (const l of lineas) {
    const v = m.get(l.cuenta_id) ?? { debe: 0, haber: 0 };
    v.debe += Number(l.debe_pyg);
    v.haber += Number(l.haber_pyg);
    m.set(l.cuenta_id, v);
  }
  m.forEach((v) => {
    v.debe = redondear(v.debe);
    v.haber = redondear(v.haber);
  });
  return m;
}

/** Dos asientos son iguales si cada cuenta tiene el mismo debe y haber (±1 Gs). */
export function mismasLineas(
  a: Map<string, { debe: number; haber: number }>,
  b: Map<string, { debe: number; haber: number }>,
) {
  const claves = new Set([...a.keys(), ...b.keys()]);
  for (const k of claves) {
    const x = a.get(k) ?? { debe: 0, haber: 0 };
    const y = b.get(k) ?? { debe: 0, haber: 0 };
    if (Math.abs(x.debe - y.debe) > TOLERANCIA_GS || Math.abs(x.haber - y.haber) > TOLERANCIA_GS) return false;
  }
  return true;
}
