// EL LIBRO FISCAL. Es el corazon del modulo y la pieza que el SENIAT audita.
//
// Tres reglas que NO se negocian (Providencia SNAT/2024/000121, articulos 3 y 4):
//   1. INALTERABILIDAD: aca solo se AGREGA. No hay editar ni borrar, ni siquiera
//      para el que programa. Un error se corrige con una nota de credito nueva
//      que apunta al documento viejo; el viejo se queda como esta.
//   2. CONSECUTIVIDAD CIEGA: la numeracion la pone el libro, automatica y sin
//      saltos. Nadie de afuera puede elegir un numero ni dejar huecos.
//   3. TRAZABILIDAD: cada asiento va encadenado por hash al anterior. Si alguien
//      abre el archivo y le cambia una coma a una venta vieja, la cadena se
//      rompe y `verificar()` dice exactamente en cual linea.
//
// El archivo es JSONL (una linea por asiento) a proposito: se puede auditar con
// cualquier herramienta y exportar plano para un fiscal sin programas raros.
import { createHash } from 'node:crypto';
import { closeSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, writeSync } from 'node:fs';
import { dirname } from 'node:path';
import type { Asiento, DocumentoFiscal, TipoDocumento } from './tipos.js';

/** Lo que se firma: todo el asiento MENOS su propio hash. */
function huella(a: Omit<Asiento, 'hash'>): string {
  // Orden fijo y explicito: si el dia de mañana alguien reordena el objeto, el
  // hash no puede cambiar. Por eso NO se usa JSON.stringify del asiento entero.
  const base = [
    a.seq,
    a.serie,
    a.numero,
    a.control,
    a.tipo,
    a.sellado_en,
    a.equipo,
    a.ref ? `${a.ref.serie}|${a.ref.tipo}|${a.ref.numero}` : '',
    JSON.stringify(a.documento),
    a.hash_anterior,
  ].join(''); // separador que no aparece en texto normal
  return createHash('sha256').update(base, 'utf8').digest('hex');
}

/** Hash de arranque de la cadena: la "piedra fundacional" del libro. */
const RAIZ = '0'.repeat(64);

export class LibroFiscal {
  private readonly ruta: string;
  private readonly serie: string;
  private readonly equipo: string;
  private asientos: Asiento[] = [];

  constructor(opciones: { ruta: string; serie?: string; equipo: string }) {
    this.ruta = opciones.ruta;
    this.serie = opciones.serie ?? 'A';
    this.equipo = opciones.equipo;
    mkdirSync(dirname(this.ruta), { recursive: true });
    if (existsSync(this.ruta)) this.cargar();
  }

  /** Lee el libro de disco. Si una linea esta partida (corte de luz), avisa. */
  private cargar(): void {
    const crudo = readFileSync(this.ruta, 'utf8');
    const lineas = crudo.split('\n').filter((l) => l.trim() !== '');
    this.asientos = lineas.map((l, i) => {
      try {
        return JSON.parse(l) as Asiento;
      } catch {
        throw new Error(`El libro fiscal esta dañado en la linea ${i + 1}. No se puede seguir asentando.`);
      }
    });
  }

  /** Ultimo asiento, o null si el libro esta en blanco. */
  private ultimo(): Asiento | null {
    return this.asientos.length ? this.asientos[this.asientos.length - 1] : null;
  }

  /**
   * Asienta un documento. Es la UNICA forma de escribir en el libro.
   * Devuelve el asiento sellado (ya con su numero y su hash).
   */
  asentar(
    tipo: TipoDocumento,
    documento: DocumentoFiscal,
    ref?: { serie: string; tipo: TipoDocumento; numero: number } | null,
  ): Asiento {
    // Una venta solo se asienta una vez: si el puente reintenta, devuelve el
    // asiento que ya existe en vez de duplicar la factura.
    const yaEsta = this.asientos.find((a) => a.tipo === tipo && a.documento.origen_id === documento.origen_id);
    if (yaEsta) return yaEsta;

    const prev = this.ultimo();
    const sinHash: Omit<Asiento, 'hash'> = {
      seq: (prev?.seq ?? 0) + 1,
      serie: this.serie,
      // Correlativo por TIPO: las facturas llevan su cuenta y las notas la suya.
      numero: this.asientos.filter((a) => a.tipo === tipo).length + 1,
      // Numero de control: uno solo, continuo, para todos los documentos.
      control: (prev?.control ?? 0) + 1,
      tipo,
      documento: { ...documento, tipo },
      ref: ref ?? null,
      sellado_en: new Date().toISOString(),
      equipo: this.equipo,
      hash_anterior: prev?.hash ?? RAIZ,
    };
    const asiento: Asiento = { ...sinHash, hash: huella(sinHash) };

    // Se escribe a disco ANTES de darlo por bueno, y con fsync: si se va la luz
    // justo aqui, o quedo escrito completo o no quedo. Nunca a medias.
    const linea = JSON.stringify(asiento) + '\n';
    const fd = openSync(this.ruta, 'a');
    try {
      writeSync(fd, linea);
      fsyncSync(fd);
    } finally {
      closeSync(fd);
    }
    this.asientos.push(asiento);
    return asiento;
  }

  /**
   * Corrige un documento ya asentado. NO lo edita: crea una nota nueva que lo
   * referencia, que es la unica forma que permite la norma.
   */
  corregir(
    original: { tipo: TipoDocumento; numero: number },
    documento: DocumentoFiscal,
    tipo: 'nota_credito' | 'nota_debito' = 'nota_credito',
  ): Asiento {
    const base = this.asientos.find((a) => a.tipo === original.tipo && a.numero === original.numero);
    if (!base) throw new Error('No existe el documento que se quiere corregir');
    return this.asentar(tipo, documento, { serie: base.serie, tipo: base.tipo, numero: base.numero });
  }

  /** Todo el libro, de solo lectura (copia: nadie muta el original desde afuera). */
  todos(): Asiento[] {
    return this.asientos.map((a) => ({ ...a }));
  }

  /**
   * LEVANTA UN LIBRO DESDE UN RESPALDO. Es la maniobra del dia malo: se quemo
   * la laptop, hay una maquina nueva y hay que volver a tener el libro.
   *
   * Dos candados: no se escribe encima de un libro que ya tenga algo (eso seria
   * la forma mas facil de perder el bueno), y despues de escribir se verifica la
   * cadena completa. Si no cuadra, no se da por restaurado.
   */
  static restaurarEn(ruta: string, asientos: Asiento[], equipo: string): { escritos: number } {
    if (existsSync(ruta) && readFileSync(ruta, 'utf8').trim() !== '') {
      throw new Error('En esta carpeta ya hay un libro fiscal con documentos: no se pisa');
    }
    if (!asientos.length) throw new Error('El respaldo vino vacio');
    mkdirSync(dirname(ruta), { recursive: true });
    const orden = [...asientos].sort((a, b) => a.seq - b.seq);
    const fd = openSync(ruta, 'w');
    try {
      writeSync(fd, orden.map((a) => JSON.stringify(a)).join('\n') + '\n');
      fsyncSync(fd);
    } finally {
      closeSync(fd);
    }
    const r = new LibroFiscal({ ruta, equipo }).verificar();
    if (!r.integro) throw new Error(`El respaldo no cuadra: ${r.problema} (linea ${r.linea})`);
    return { escritos: orden.length };
  }

  /** Cuando se sello el ultimo documento. Sirve para vigilar el reloj del equipo. */
  ultimoSelladoEn(): string | null {
    return this.ultimo()?.sellado_en ?? null;
  }

  buscar(tipo: TipoDocumento, numero: number): Asiento | null {
    const a = this.asientos.find((x) => x.tipo === tipo && x.numero === numero);
    return a ? { ...a } : null;
  }

  /**
   * La fecha que cuenta para los reportes. Casi siempre es la del sellado, pero
   * una factura del talonario manual se carga despues del apagon y lo que vale
   * es cuando se emitio el papel: si no, el mes que la lleva quedaria con el
   * hueco y el mes siguiente con una venta que no fue suya.
   */
  static fechaDe(a: Asiento): string {
    return a.documento.talonario?.emitido_en ?? a.sellado_en;
  }

  /** Documentos de un rango de fechas: lo que pide un fiscal en una inspeccion. */
  entre(desde: Date, hasta: Date): Asiento[] {
    return this.asientos
      .filter((a) => {
        const t = new Date(LibroFiscal.fechaDe(a)).getTime();
        return t >= desde.getTime() && t <= hasta.getTime();
      })
      .map((a) => ({ ...a }));
  }

  /**
   * Revisa la cadena completa: que ningun asiento haya sido tocado, que la
   * numeracion no tenga saltos y que cada eslabon apunte al anterior.
   */
  verificar(): { integro: boolean; problema?: string; linea?: number } {
    let anterior = RAIZ;
    for (let i = 0; i < this.asientos.length; i++) {
      const a = this.asientos[i];
      if (a.seq !== i + 1) {
        return { integro: false, problema: `salto en el correlativo del libro (esperaba ${i + 1}, hay ${a.seq})`, linea: i + 1 };
      }
      if (a.hash_anterior !== anterior) {
        return { integro: false, problema: 'la cadena esta rota: el asiento no apunta al anterior', linea: i + 1 };
      }
      const { hash, ...resto } = a;
      if (huella(resto) !== hash) {
        return { integro: false, problema: 'un asiento fue modificado despues de sellado', linea: i + 1 };
      }
      anterior = a.hash;
    }
    return { integro: true };
  }

  /** Cuantos documentos hay de cada tipo (para el estado y los reportes). */
  resumen(): { total: number; porTipo: Record<string, number>; ultimoControl: number } {
    const porTipo: Record<string, number> = {};
    for (const a of this.asientos) porTipo[a.tipo] = (porTipo[a.tipo] ?? 0) + 1;
    return { total: this.asientos.length, porTipo, ultimoControl: this.ultimo()?.control ?? 0 };
  }
}
