// RESPALDO CIFRADO DEL LIBRO FISCAL.
//
// Dos cosas hay que cuidar a la vez y se pelean entre si:
//   - Que un disco quemado no se lleve la contabilidad fiscal del negocio.
//   - Que ese respaldo no sea una copia de las facturas de los clientes tirada
//     por ahi (la 000102 art. 25 obliga a confidencialidad y a no divulgar los
//     documentos procesados).
// Por eso el respaldo va CIFRADO, y cifrado asiento por asiento: asi sigue
// siendo un archivo que SOLO AGREGA, igual que el libro. No hay que descifrar y
// volver a escribir todo cada vez que se vende algo, que seria justo la ventana
// por donde se pierde o se altera la informacion.
//
// La frase de recuperacion NO se guarda junto al respaldo (seria como dejar la
// llave pegada en la puerta): la guarda el negocio aparte.
import { createCipheriv, createDecipheriv, randomBytes, randomInt, scryptSync } from 'node:crypto';
import { closeSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, writeSync } from 'node:fs';
import { dirname, join } from 'node:path';
import type { Asiento } from './tipos.js';

/** Donde vive el respaldo cifrado del libro, dentro de la carpeta del programa. */
export function rutaRespaldo(base: string): string {
  return join(base, 'estado', 'respaldo.jsonl');
}

const PALABRAS = [
  'arepa', 'budare', 'cachapa', 'canela', 'cilantro', 'cocuy', 'guayaba', 'hallaca',
  'jojoto', 'lechosa', 'maiz', 'merey', 'name', 'ocumo', 'papelon', 'parchita',
  'pernil', 'platano', 'queso', 'sancocho', 'tequeno', 'topocho', 'vainilla', 'yuca',
  'araure', 'acarigua', 'guanare', 'portuguesa', 'turen', 'ospino', 'biscucuy', 'agua',
  'llano', 'garza', 'turpial', 'araguaney', 'samana', 'cardenal', 'morrocoy', 'danto',
];

/**
 * LA FRASE DE RECUPERACION. Sin ella el respaldo no se abre, ni por nosotros ni
 * por la imprenta que lo guarda.
 *
 * Se genera sola la primera vez y se guarda en el equipo, porque la Estacion
 * tiene que poder respaldar sin que nadie escriba nada cada vez que arranca. Que
 * viva aca no es un descuido: en esta misma computadora esta el libro en claro,
 * asi que la frase no protege contra quien se lleva la maquina. Protege la copia
 * que sale de aca, que es la que anda por el mundo.
 *
 * ⚠ Por eso mismo el dueño TIENE que apuntarla aparte: si se quema la laptop se
 * quema la frase, y sin frase el respaldo de la nube no sirve de nada.
 */
function rutaFrase(base: string): string {
  return join(base, 'estado', 'respaldo-frase.json');
}

/** Deja escrita la frase en el equipo (la que se usa al restaurar en otra maquina). */
export function guardarFrase(base: string, frase: string): void {
  if (!frase || frase.length < 12) throw new Error('La frase de recuperacion debe tener al menos 12 caracteres');
  const ruta = rutaFrase(base);
  mkdirSync(dirname(ruta), { recursive: true });
  const fd = openSync(ruta, 'w');
  try {
    writeSync(fd, JSON.stringify({ frase, creada_en: new Date().toISOString() }, null, 2));
    fsyncSync(fd);
  } finally {
    closeSync(fd);
  }
}

export function fraseDeRecuperacion(base: string): string {
  const ruta = rutaFrase(base);
  if (existsSync(ruta)) {
    try {
      const d = JSON.parse(readFileSync(ruta, 'utf8')) as { frase?: string };
      if (d.frase && d.frase.length >= 12) return d.frase;
    } catch {
      /* si el archivo esta dañado se hace una nueva abajo */
    }
  }
  const frase = Array.from({ length: 6 }, () => PALABRAS[randomInt(PALABRAS.length)]).join('-');
  guardarFrase(base, frase);
  return frase;
}

interface Cabecera {
  version: 1;
  sal: string;
  creado_en: string;
}

export interface LineaCifrada {
  seq: number;
  iv: string;
  tag: string;
  datos: string;
}

/**
 * La cabecera viaja como la linea CERO. La sal no es secreta (su trabajo es que
 * dos negocios con la misma frase no produzcan la misma llave), pero sin ella no
 * se puede volver a derivar la llave en otra computadora, asi que tiene que ir
 * junto al respaldo. La frase, esa no viaja nunca.
 */
const SEQ_CABECERA = 0;
const SIN_CIFRAR = '-';

export class RespaldoCifrado {
  private readonly ruta: string;
  private readonly clave: Buffer;
  private cabecera: Cabecera;
  private guardados = new Set<number>();

  /**
   * @param frase Frase de recuperacion del negocio. Sin ella el respaldo no se
   * puede leer, ni por nosotros.
   */
  constructor(ruta: string, frase: string) {
    if (!frase || frase.length < 12) {
      throw new Error('La frase de recuperacion debe tener al menos 12 caracteres');
    }
    this.ruta = ruta;
    mkdirSync(dirname(ruta), { recursive: true });

    if (existsSync(ruta)) {
      const lineas = readFileSync(ruta, 'utf8').split('\n').filter((l) => l.trim() !== '');
      this.cabecera = JSON.parse(lineas[0]) as Cabecera;
      this.clave = scryptSync(frase, this.cabecera.sal, 32);
      for (const l of lineas.slice(1)) {
        this.guardados.add((JSON.parse(l) as LineaCifrada).seq);
      }
    } else {
      this.cabecera = { version: 1, sal: randomBytes(16).toString('hex'), creado_en: new Date().toISOString() };
      this.clave = scryptSync(frase, this.cabecera.sal, 32);
      this.escribir(JSON.stringify(this.cabecera));
    }
  }

  private escribir(linea: string): void {
    const fd = openSync(this.ruta, 'a');
    try {
      writeSync(fd, linea + '\n');
      fsyncSync(fd);
    } finally {
      closeSync(fd);
    }
  }

  /** Agrega un asiento al respaldo. Si ya estaba, no hace nada. */
  agregar(asiento: Asiento): void {
    if (this.guardados.has(asiento.seq)) return;
    const iv = randomBytes(12);
    const cifrador = createCipheriv('aes-256-gcm', this.clave, iv);
    const datos = Buffer.concat([cifrador.update(JSON.stringify(asiento), 'utf8'), cifrador.final()]);
    const linea: LineaCifrada = {
      seq: asiento.seq,
      iv: iv.toString('hex'),
      tag: cifrador.getAuthTag().toString('hex'),
      datos: datos.toString('base64'),
    };
    this.escribir(JSON.stringify(linea));
    this.guardados.add(asiento.seq);
  }

  /** Mete al respaldo todo lo que falte del libro. */
  ponerseAlDia(asientos: Asiento[]): number {
    let nuevos = 0;
    for (const a of asientos) {
      if (!this.guardados.has(a.seq)) {
        this.agregar(a);
        nuevos += 1;
      }
    }
    return nuevos;
  }

  /**
   * Descifra el respaldo completo. Si la frase esta mala o alguien toco el
   * archivo, falla: el sello de AES-GCM no cuadra y se sabe.
   */
  restaurar(): Asiento[] {
    const lineas = readFileSync(this.ruta, 'utf8').split('\n').filter((l) => l.trim() !== '');
    const asientos: Asiento[] = [];
    for (const l of lineas.slice(1)) {
      const c = JSON.parse(l) as LineaCifrada;
      try {
        const descifrador = createDecipheriv('aes-256-gcm', this.clave, Buffer.from(c.iv, 'hex'));
        descifrador.setAuthTag(Buffer.from(c.tag, 'hex'));
        const claro = Buffer.concat([descifrador.update(Buffer.from(c.datos, 'base64')), descifrador.final()]);
        asientos.push(JSON.parse(claro.toString('utf8')) as Asiento);
      } catch {
        throw new Error(
          `No se pudo abrir el respaldo en el asiento ${c.seq}: la frase de recuperacion no corresponde o el archivo fue alterado`,
        );
      }
    }
    return asientos.sort((a, b) => a.seq - b.seq);
  }

  /** ¿El respaldo tiene todo lo que tiene el libro? */
  comparar(asientos: Asiento[]): { alDia: boolean; faltan: number[] } {
    const faltan = asientos.filter((a) => !this.guardados.has(a.seq)).map((a) => a.seq);
    return { alDia: faltan.length === 0, faltan };
  }

  cuantos(): number {
    return this.guardados.size;
  }

  /**
   * Las lineas tal cual estan, CIFRADAS, listas para mandarlas afuera. Nunca se
   * descifra para subir: lo que sale del equipo sale cerrado.
   *
   * Con `desde` menor a cero incluye la cabecera (la linea cero), que es lo que
   * hace falta la primera vez y para poder abrir el respaldo en otra maquina.
   */
  paraSubir(desde: number): LineaCifrada[] {
    const lineas: LineaCifrada[] = [];
    if (desde < SEQ_CABECERA) {
      lineas.push({ seq: SEQ_CABECERA, iv: SIN_CIFRAR, tag: SIN_CIFRAR, datos: this.cabecera.sal });
    }
    const crudas = readFileSync(this.ruta, 'utf8').split('\n').filter((l) => l.trim() !== '');
    for (const l of crudas.slice(1)) {
      const c = JSON.parse(l) as LineaCifrada;
      if (c.seq > desde) lineas.push(c);
    }
    return lineas.sort((a, b) => a.seq - b.seq);
  }

  /**
   * Arma el archivo de respaldo con lo que se bajo de afuera. Es el primer paso
   * para levantar el libro en una maquina nueva: escribe, y despues se abre con
   * la frase para ver si de verdad se puede leer.
   *
   * No pisa un respaldo que ya exista: eso seria justo la forma de perder el
   * libro de la maquina donde uno esta parado.
   */
  static escribirDesdeLineas(ruta: string, lineas: LineaCifrada[]): void {
    if (existsSync(ruta)) throw new Error('Ya hay un respaldo en esta carpeta: no se pisa');
    const cabecera = lineas.find((l) => l.seq === SEQ_CABECERA);
    if (!cabecera) throw new Error('El respaldo vino sin su cabecera: no se puede abrir');
    mkdirSync(dirname(ruta), { recursive: true });
    const cuerpo = lineas
      .filter((l) => l.seq !== SEQ_CABECERA)
      .sort((a, b) => a.seq - b.seq)
      .map((l) => JSON.stringify(l));
    const cab: Cabecera = { version: 1, sal: cabecera.datos, creado_en: new Date().toISOString() };
    const fd = openSync(ruta, 'w');
    try {
      writeSync(fd, [JSON.stringify(cab), ...cuerpo].join('\n') + '\n');
      fsyncSync(fd);
    } finally {
      closeSync(fd);
    }
  }
}
