/**
 * Emparejado de líneas de extracto con movimientos del sistema.
 *
 * Dos pasadas, porque el orden importa:
 *  1) Por **comprobante/referencia**: si el número del banco aparece en la
 *     referencia o en la descripción del movimiento, es el mismo hecho, sin
 *     importar que la fecha difiera (el banco confirma en otra fecha).
 *  2) Por **monto + fecha**, del par más cercano al más lejano.
 *
 * El defecto que corrige: antes se tomaba el primer movimiento que cumpliera
 * monto ±1 y ±2 días, en el orden en que venían. Con varios movimientos del
 * mismo importe —tres créditos de 150.000 el mismo día en Banco Familiar— cada
 * línea se llevaba el movimiento de otra: los totales cerraban, pero cada línea
 * quedaba pegada al movimiento equivocado.
 */

export type TipoMovimiento = 'INGRESO' | 'EGRESO';

export interface LineaExtracto {
  id: string;
  fecha: Date;
  monto: number;
  tipo: TipoMovimiento;
  referencia?: string | null;
  descripcion?: string | null;
}

export interface MovimientoSistema {
  id: string;
  fecha: Date;
  monto: number;
  tipo: TipoMovimiento;
  referencia?: string | null;
  descripcion?: string | null;
}

export type CriterioMatch = 'REFERENCIA' | 'MONTO_FECHA';

export interface ParConciliado {
  lineaId: string;
  movimientoId: string;
  criterio: CriterioMatch;
  diffDias: number;
  diffMonto: number;
}

export interface OpcionesMatching {
  toleranciaDias: number;
  toleranciaMonto: number;
  /**
   * Ventana de días cuando el comprobante coincide. Es mucho más amplia porque
   * el número identifica el hecho: un cheque se emite y el banco lo paga por
   * cámara días después. Sin esto, esos cheques nunca se concilian solos.
   */
  toleranciaDiasReferencia?: number;
}

const TOLERANCIA_DIAS_REFERENCIA_DEFAULT = 45;

const DIA_MS = 86_400_000;

const normalizar = (v?: string | null) =>
  String(v ?? '')
    .normalize('NFD')
    .replace(/[̀-ͯ]/g, '')
    .toLowerCase()
    .trim();

/** Un comprobante sirve para identificar si es lo bastante largo para no ser casual. */
const LARGO_MIN_REFERENCIA = 5;

/**
 * ¿La referencia del banco aparece en el movimiento? Se busca en su referencia y
 * en su descripción: los movimientos importados del extracto guardan el número
 * de comprobante como descripción.
 */
function movimientoTieneReferencia(mov: MovimientoSistema, refLinea: string): boolean {
  if (refLinea.length < LARGO_MIN_REFERENCIA) return false;
  const campos = [normalizar(mov.referencia), normalizar(mov.descripcion)];
  return campos.some((campo) => campo === refLinea || (campo.length > refLinea.length && campo.includes(refLinea)));
}

/**
 * Número de cheque que declara el movimiento, si lo declara ("Débito cheque
 * Nº 25864498 — ..."). Sirve para no emparejar cheques distintos de igual
 * importe: si el banco pagó el 25864498 y el movimiento es del 25681727, no es
 * el mismo hecho por más que coincidan monto y fecha.
 */
function numeroChequeDeclarado(texto?: string | null): string | null {
  const m = normalizar(texto).match(/cheque\s*(?:n[o°º]\.?\s*)?(\d{5,})/);
  return m ? m[1] : null;
}

function declaranChequesDistintos(linea: LineaExtracto, mov: MovimientoSistema): boolean {
  const chequeMov = numeroChequeDeclarado(mov.descripcion) ?? numeroChequeDeclarado(mov.referencia);
  if (!chequeMov) return false;
  const refLinea = normalizar(linea.referencia);
  const chequeLinea = numeroChequeDeclarado(linea.descripcion) ?? (refLinea.length >= LARGO_MIN_REFERENCIA ? refLinea : null);
  if (!chequeLinea) return false;
  return chequeMov !== chequeLinea;
}

function compatible(
  linea: LineaExtracto,
  mov: MovimientoSistema,
  opts: OpcionesMatching,
  porReferencia = false,
) {
  if (mov.tipo !== linea.tipo) return null;
  if (!porReferencia && declaranChequesDistintos(linea, mov)) return null;
  const diffMonto = Math.abs(Number(mov.monto) - Number(linea.monto));
  if (diffMonto > opts.toleranciaMonto) return null;
  const diffDias = Math.abs(mov.fecha.getTime() - linea.fecha.getTime()) / DIA_MS;
  const maxDias = porReferencia
    ? (opts.toleranciaDiasReferencia ?? TOLERANCIA_DIAS_REFERENCIA_DEFAULT)
    : opts.toleranciaDias;
  if (diffDias > maxDias) return null;
  return { diffMonto, diffDias };
}

export function emparejarLineasConMovimientos(
  lineas: LineaExtracto[],
  movimientos: MovimientoSistema[],
  opts: OpcionesMatching,
): { pares: ParConciliado[]; sinPareja: string[] } {
  const pares: ParConciliado[] = [];
  const movUsados = new Set<string>();
  const lineasPendientes = new Set(lineas.map((l) => l.id));

  // 1) Por comprobante: la fecha no desempata, el número manda.
  for (const linea of lineas) {
    const refLinea = normalizar(linea.referencia);
    if (refLinea.length < LARGO_MIN_REFERENCIA) continue;

    const candidato = movimientos
      .filter((mov) => !movUsados.has(mov.id) && movimientoTieneReferencia(mov, refLinea))
      .map((mov) => ({ mov, cmp: compatible(linea, mov, opts, true) }))
      .filter((c): c is { mov: MovimientoSistema; cmp: { diffMonto: number; diffDias: number } } => c.cmp !== null)
      .sort((a, b) => a.cmp.diffDias - b.cmp.diffDias)[0];

    if (candidato) {
      movUsados.add(candidato.mov.id);
      lineasPendientes.delete(linea.id);
      pares.push({
        lineaId: linea.id,
        movimientoId: candidato.mov.id,
        criterio: 'REFERENCIA',
        diffDias: candidato.cmp.diffDias,
        diffMonto: candidato.cmp.diffMonto,
      });
    }
  }

  // 2) Resto por monto + fecha, resolviendo primero los pares más cercanos para
  //    que un empate no se decida por el orden de lectura del archivo.
  const combinaciones: Array<{ linea: LineaExtracto; mov: MovimientoSistema; diffDias: number; diffMonto: number }> = [];
  for (const linea of lineas) {
    if (!lineasPendientes.has(linea.id)) continue;
    for (const mov of movimientos) {
      if (movUsados.has(mov.id)) continue;
      const cmp = compatible(linea, mov, opts);
      if (cmp) combinaciones.push({ linea, mov, ...cmp });
    }
  }
  combinaciones.sort(
    (a, b) => a.diffDias - b.diffDias || a.diffMonto - b.diffMonto || a.linea.fecha.getTime() - b.linea.fecha.getTime(),
  );

  for (const c of combinaciones) {
    if (!lineasPendientes.has(c.linea.id) || movUsados.has(c.mov.id)) continue;
    movUsados.add(c.mov.id);
    lineasPendientes.delete(c.linea.id);
    pares.push({
      lineaId: c.linea.id,
      movimientoId: c.mov.id,
      criterio: 'MONTO_FECHA',
      diffDias: c.diffDias,
      diffMonto: c.diffMonto,
    });
  }

  return { pares, sinPareja: [...lineasPendientes] };
}
