// LA ESTACION FISCAL: la pieza que junta todo y la unica puerta de entrada.
//
// Gustito Xpress (o el Cerebrito) le entrega una VENTA YA CERRADA y recibe de
// vuelta el numero del documento. Eso es todo lo que sabe Gustito: no calcula
// impuestos, no numera, no guarda libros. Esa es la frontera.
//
// Orden de las cosas, y el orden importa:
//   1. La venta entra al BUZON en disco antes de tocar nada mas. Si el programa
//      se cae en el segundo siguiente, la venta no se perdio.
//   2. Se arma el documento y se SELLA en el libro (esto no falla por internet).
//   3. Se manda a transmitir. Si no hay señal, queda en la cola y el negocio
//      sigue vendiendo.
import { closeSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, writeSync } from 'node:fs';
import { join } from 'node:path';
import { Bitacora } from './bitacora.js';
import { ColaTransmision } from './cola.js';
import { LibroFiscal } from './libro.js';
import { Auditor } from './auditor.js';
import { armarDocumento, armarFacturaDeTalonario, armarNotaCredito, type FacturaDeTalonario } from './armador.js';
import { crearImprenta, type Imprenta } from './imprenta.js';
import { Reloj } from './reloj.js';
import { Retenciones, type DatosRetencion, type RetencionRecibida } from './retenciones.js';
import { RespaldoCifrado, fraseDeRecuperacion, guardarFrase, rutaRespaldo, type LineaCifrada } from './respaldo.js';
import { guardarSerial, imprentaActiva, leerConfig, leerSerial, puedeOperar, revisar, type ConfigFiscal } from './config.js';
import { EnlaceNube, aVentaEntrante, type AnulacionDeNube, type VentaDeNube } from './nube.js';
import type { Asiento, Evento, VentaEntrante } from './tipos.js';

export interface ResultadoVenta {
  /** ¿Quedo sellada? Si la estacion no esta configurada, no. */
  sellada: boolean;
  serie?: string;
  numero?: number;
  /** El que asigna la imprenta. Si todavia no salio, viene vacio. */
  numeroControl?: string;
  /** Direccion del sello para el QR, si la imprenta ya la devolvio. */
  sello?: string;
  transmision?: 'enviado' | 'pendiente' | 'rechazado';
  problema?: string;
}

/** Resultado de una ronda contra Gustito en la nube. */
export interface ResultadoRonda {
  halada: number;
  sellada: number;
  fallada: number;
  /** Solicitudes de anulacion atendidas (nota de credito emitida o ya existente). */
  anulada: number;
  /** Solicitudes de anulacion que no se pudieron atender en esta ronda. */
  anulacionFallada: number;
  pendientesEnNube: number;
  /** Cada cuanto volver a preguntar. Lo manda la nube, no lo decide la Estacion. */
  volverEnSegundos: number;
  problema?: string;
}

export class EstacionFiscal {
  private readonly base: string;
  private readonly cfg: ConfigFiscal;
  private readonly libro: LibroFiscal;
  private readonly bitacora: Bitacora;
  private readonly cola: ColaTransmision;
  private readonly imprenta: Imprenta;
  private readonly reloj: Reloj;
  private readonly respaldo: RespaldoCifrado;
  private readonly retenciones: Retenciones;
  private readonly rutaBuzon: string;

  private constructor(op: {
    base: string;
    cfg: ConfigFiscal;
    libro: LibroFiscal;
    bitacora: Bitacora;
    cola: ColaTransmision;
    imprenta: Imprenta;
    reloj: Reloj;
    respaldo: RespaldoCifrado;
  }) {
    this.base = op.base;
    this.cfg = op.cfg;
    this.libro = op.libro;
    this.bitacora = op.bitacora;
    this.cola = op.cola;
    this.imprenta = op.imprenta;
    this.reloj = op.reloj;
    this.respaldo = op.respaldo;
    this.retenciones = new Retenciones(op.base);
    this.rutaBuzon = join(op.base, 'estado', 'entradas.jsonl');
    mkdirSync(join(op.base, 'estado'), { recursive: true });
  }

  /** Abre la estacion con lo que haya en el equipo. Si falta configurar, avisa. */
  static async abrir(base: string): Promise<EstacionFiscal> {
    const cfg = leerConfig(base);
    if (!puedeOperar(cfg)) {
      const faltan = revisar(cfg).map((f) => f.problema).join('; ');
      throw new Error(`La estacion fiscal no esta lista: ${faltan}`);
    }
    const c = cfg as ConfigFiscal;
    const libro = new LibroFiscal({ ruta: join(base, 'estado', 'libro.jsonl'), serie: c.serie, equipo: c.equipo });
    const bitacora = new Bitacora(join(base, 'estado', 'bitacora.jsonl'));
    // La marca del negocio (modelo de factura) acompaña cada emision: asi el
    // PDF que la imprenta manda por correo sale con la cara del negocio.
    const imprenta = await crearImprenta(imprentaActiva(c), c.modelo_factura ?? null);
    const cola = new ColaTransmision({
      ruta: join(base, 'estado', 'transmision.jsonl'),
      imprenta,
      log: (m) => bitacora.anotar('estacion', 'local', 'transmision', { mensaje: m }),
    });
    const reloj = new Reloj({ ruta: join(base, 'estado', 'reloj.json'), url: c.hora_url });
    // El respaldo cifrado se pone al dia solo con lo que haya en el libro: si la
    // estacion venia trabajando sin respaldo, al abrir se lo mete todo.
    const respaldo = new RespaldoCifrado(rutaRespaldo(base), fraseDeRecuperacion(base));
    const metidos = respaldo.ponerseAlDia(libro.todos());
    if (metidos > 0) bitacora.anotar('estacion', 'local', 'respaldo_al_dia', { asientos: metidos });
    // Lo que quedo sin transmitir de la corrida anterior vuelve a la fila.
    const quedaron = cola.recuperar(libro.todos());
    if (quedaron > 0) bitacora.anotar('estacion', 'local', 'arranque_con_pendientes', { pendientes: quedaron });
    return new EstacionFiscal({ base, cfg: c, libro, bitacora, cola, imprenta, reloj, respaldo });
  }

  /** Guarda la venta cruda apenas llega, antes de procesarla. */
  private guardarEnBuzon(venta: VentaEntrante): void {
    const fd = openSync(this.rutaBuzon, 'a');
    try {
      writeSync(fd, JSON.stringify({ en: new Date().toISOString(), venta }) + '\n');
      fsyncSync(fd);
    } finally {
      closeSync(fd);
    }
  }

  /**
   * LA PUERTA. Gustito llama esto con la venta ya cobrada.
   * Nunca lanza por problemas de internet: sella igual y transmite despues.
   */
  async recibirVenta(venta: VentaEntrante, quien = 'gustito'): Promise<ResultadoVenta> {
    this.guardarEnBuzon(venta);
    // La venta queda guardada en el buzon ANTES de esto: si el reloj esta
    // corrido no se sella, pero la venta no se pierde y se sella apenas se
    // corrija la hora.
    const reloj = this.reloj.aprueba(this.libro.ultimoSelladoEn());
    if (!reloj.ok) {
      this.bitacora.anotar(quien, 'local', 'venta_no_sellada', {
        origen_id: venta.origen_id,
        problema: reloj.problema,
      });
      return { sellada: false, problema: reloj.problema };
    }
    try {
      const doc = armarDocumento(venta, {
        rif: this.cfg.emisor.rif,
        razon_social: this.cfg.emisor.razon_social,
        domicilio_fiscal: this.cfg.emisor.domicilio_fiscal,
        precios_con_iva: this.cfg.emisor.precios_con_iva,
        igtf_activo: this.cfg.emisor.igtf_activo === true,
      });
      const asiento = this.libro.asentar('factura', doc);
      // Al respaldo apenas se sella, no cada tanto: lo que no esta respaldado es
      // justo lo que se pierde cuando se va la luz.
      this.respaldo.agregar(asiento);
      this.programarSubidaRespaldo();
      this.bitacora.anotar(quien, 'local', 'documento_sellado', {
        tipo: 'factura',
        numero: asiento.numero,
        total_usd: doc.total_usd,
        canal: venta.canal,
      });
      const envio = await this.cola.encolar(asiento);
      return {
        sellada: true,
        serie: asiento.serie,
        numero: asiento.numero,
        numeroControl: envio.numeroControl,
        sello: envio.sello,
        transmision: envio.estado,
      };
    } catch (e) {
      const problema = e instanceof Error ? e.message : String(e);
      this.bitacora.anotar(quien, 'local', 'venta_no_sellada', { origen_id: venta.origen_id, problema });
      return { sellada: false, problema };
    }
  }

  /**
   * LA MISMA PUERTA, pero con la venta CRUDA de Gustito (la forma exacta que
   * viaja por la cola de la nube). El Cerebrito la entrega asi cuando la caja
   * cobra, directo por 127.0.0.1, sin esperar internet.
   *
   * La conversion pasa por el MISMO aVentaEntrante de la via de la nube: la
   * alicuota la pone la Estacion (el programa del negocio no sabe de impuestos)
   * y el origen_id queda IDENTICO por las dos vias (pedido:<uuid> /
   * cuenta:<uuid>), asi el libro deduplica solo cuando la misma venta llega por
   * el Cerebrito Y despues por la cola: el segundo recibe el documento que ya
   * existe, no un documento nuevo.
   */
  async recibirVentaDeGustito(item: Omit<VentaDeNube, 'cola_id'>, quien = 'cerebrito'): Promise<ResultadoVenta> {
    return this.recibirVenta(
      aVentaEntrante({ ...item, cola_id: '' }, this.cfg.emisor.alicuota_general),
      quien,
    );
  }

  /**
   * Anula una venta que ya se sello: nota de credito, nunca borrado.
   * Devuelve el numero de la nota.
   */
  async anular(numeroFactura: number, motivo: string, quien = 'negocio'): Promise<ResultadoVenta> {
    const original = this.libro.buscar('factura', numeroFactura);
    if (!original) return { sellada: false, problema: 'Esa factura no existe en el libro' };
    if (!motivo.trim()) return { sellada: false, problema: 'Hay que escribir el motivo de la anulacion' };
    const reloj = this.reloj.aprueba(this.libro.ultimoSelladoEn());
    if (!reloj.ok) return { sellada: false, problema: reloj.problema };
    const nota = this.libro.corregir({ tipo: 'factura', numero: numeroFactura }, armarNotaCredito(original.documento, motivo.trim()));
    this.respaldo.agregar(nota);
    this.programarSubidaRespaldo();
    this.bitacora.anotar(quien, 'local', 'nota_credito', { corrige: numeroFactura, nota: nota.numero, motivo });
    const envio = await this.cola.encolar(nota);
    return {
      sellada: true,
      serie: nota.serie,
      numero: nota.numero,
      numeroControl: envio.numeroControl,
      transmision: envio.estado,
    };
  }

  /**
   * ANULA POR ORIGEN, para las solicitudes que llegan del portal o de una
   * cancelacion: el portal no conoce el numero de factura de la Estacion, solo
   * la venta (`pedido:<uuid>` / `cuenta:<uuid>`). Idempotente: si la factura ya
   * tiene su nota de credito, responde con la que existe en vez de duplicar.
   */
  async anularPorOrigen(
    origen: string,
    motivo: string,
    quien: string,
  ): Promise<{ hecha: boolean; nota?: number; yaEstaba?: boolean; problema?: string }> {
    const factura = this.libro
      .todos()
      .find((a) => a.tipo === 'factura' && a.documento.origen_id === origen);
    if (!factura) {
      return { hecha: false, problema: 'Esa venta no esta en el libro de esta estacion' };
    }
    const notaExistente = this.libro
      .todos()
      .find((a) => a.tipo === 'nota_credito' && a.ref?.tipo === 'factura' && a.ref.numero === factura.numero);
    if (notaExistente) {
      return { hecha: true, nota: notaExistente.numero, yaEstaba: true };
    }
    const r = await this.anular(factura.numero, motivo, quien);
    if (!r.sellada) return { hecha: false, problema: r.problema };
    return { hecha: true, nota: r.numero };
  }

  /**
   * CARGA UNA FACTURA DEL TALONARIO MANUAL (contingencia).
   *
   * Cuando se cae todo (luz, internet, la computadora), el negocio factura a
   * mano en su talonario, que es lo que manda la norma. El problema viene
   * despues: si esas facturas no se anotan, el reporte del mes queda con un
   * hueco del tamaño del apagon, y ese hueco lo pregunta el fiscal.
   *
   * Ojo con lo que NO hace esto: no le pone numero de la estacion (el papel ya
   * trae el suyo impreso) y no lo manda a la imprenta (no lo emitio este
   * sistema). Solo lo anota para que las cuentas cuadren con la realidad.
   */
  async cargarTalonario(f: FacturaDeTalonario, quien = 'dueño'): Promise<ResultadoVenta> {
    const numero = (f.numero ?? '').trim();
    if (!numero) return { sellada: false, problema: 'Falta el numero que trae impreso el papel' };
    if (!f.emitido_en || Number.isNaN(Date.parse(f.emitido_en))) {
      return { sellada: false, problema: 'Falta la fecha en que se emitio la factura del talonario' };
    }
    if (Date.parse(f.emitido_en) > Date.now() + 60_000) {
      return { sellada: false, problema: 'Esa factura tiene fecha del futuro: revisa el dato' };
    }
    if (!(f.total_usd > 0)) return { sellada: false, problema: 'El total tiene que ser mayor que cero' };
    if (![0, 8, 16, 31].includes(Number(f.alicuota))) {
      return { sellada: false, problema: 'La alicuota debe ser 16, 8, 31 o 0' };
    }
    if (!f.cliente?.nombre?.trim()) return { sellada: false, problema: 'Falta a nombre de quien salio' };

    const doc = armarFacturaDeTalonario({ ...f, numero }, {
      rif: this.cfg.emisor.rif,
      razon_social: this.cfg.emisor.razon_social,
      domicilio_fiscal: this.cfg.emisor.domicilio_fiscal,
      precios_con_iva: this.cfg.emisor.precios_con_iva,
      igtf_activo: this.cfg.emisor.igtf_activo === true,
    });
    const yaEstaba = this.libro.todos().some((a) => a.documento.origen_id === doc.origen_id);
    const asiento = this.libro.asentar('factura_manual', doc);
    if (yaEstaba) return { sellada: false, problema: `La factura ${numero} del talonario ya estaba cargada` };
    this.respaldo.agregar(asiento);
    this.programarSubidaRespaldo();
    this.bitacora.anotar(quien, 'local', 'talonario_cargado', {
      numero,
      emitido_en: f.emitido_en,
      total_usd: doc.total_usd,
    });
    return { sellada: true, serie: asiento.serie, numero: asiento.numero, transmision: 'pendiente' };
  }

  /** Las facturas del talonario que ya se cargaron. */
  talonarioCargado(): { numero: string; emitido_en: string; total_usd: number }[] {
    return this.libro
      .todos()
      .filter((a) => a.tipo === 'factura_manual' && a.documento.talonario)
      .map((a) => ({
        numero: a.documento.talonario?.numero ?? '',
        emitido_en: a.documento.talonario?.emitido_en ?? a.sellado_en,
        total_usd: a.documento.total_usd,
      }))
      .sort((x, y) => y.emitido_en.localeCompare(x.emitido_en));
  }

  // ===== COMPROBANTES DE RETENCION RECIBIDOS (Providencia SNAT/2025/000054) =====
  //
  // El sistema no los emite (eso lo hace el agente que retiene): los REGISTRA.
  // Son credito contra la cuota del periodo en la declaracion de IVA, y todo
  // movimiento queda en la bitacora con el nombre de quien lo hizo.

  registrarRetencion(d: DatosRetencion, quien: string): RetencionRecibida {
    const r = this.retenciones.registrar(d, quien);
    this.bitacora.anotar(quien, 'local', 'retencion_registrada', {
      numero: r.numero,
      agente_rif: r.agente.rif,
      periodo: r.periodo,
      iva_retenido_bs: r.iva_retenido_bs,
      porcentaje: r.porcentaje,
    });
    return r;
  }

  /** Anula por contramarca: el registro original queda, con su porque al lado. */
  anularRetencion(numero: string, motivo: string, quien: string): void {
    this.retenciones.anular(numero, motivo, quien);
    this.bitacora.anotar(quien, 'local', 'retencion_anulada', { numero, motivo });
  }

  /** Los comprobantes vigentes, del mas nuevo al mas viejo. */
  retencionesRecibidas(periodo?: string): (RetencionRecibida & { anulado: false })[] {
    return this.retenciones.listar(periodo);
  }

  /** El credito del periodo: lo que va contra la cuota en la declaracion. */
  resumenRetenciones(periodo: string): { periodo: string; comprobantes: number; iva_retenido_bs: number } {
    return this.retenciones.resumen(periodo);
  }

  /** Empuja lo que quedo pendiente de transmitir. Se llama cada tanto. */
  async reintentar(): Promise<{ enviados: number; pendientes: number }> {
    return this.cola.procesar();
  }

  /**
   * UNA RONDA CONTRA GUSTITO: hala las ventas cerradas que faltan por sellar,
   * las sella y le avisa a la nube. Se llama sola cada tanto.
   *
   * Si el negocio no tiene el enlace configurado, no hace nada (una Estacion
   * puede trabajar sola recibiendo por /api/venta).
   *
   * Nunca lanza: un problema de internet no puede tumbar la estacion.
   */
  async sincronizarConNube(): Promise<ResultadoRonda> {
    const vacia: ResultadoRonda = {
      halada: 0, sellada: 0, fallada: 0, anulada: 0, anulacionFallada: 0,
      pendientesEnNube: 0, volverEnSegundos: 60,
    };
    if (!this.cfg.nube?.url || !this.cfg.nube?.llave) return vacia;

    // Con el reloj corrido NI SE HALA. Si se halaran las ventas, cada una se
    // devolveria como fallida a la nube y quedarian marcadas con error por algo
    // que no tiene nada que ver con ellas. Se quedan quietas en la cola hasta
    // que se corrija la hora.
    const reloj = this.reloj.aprueba(this.libro.ultimoSelladoEn());
    if (!reloj.ok) return { ...vacia, volverEnSegundos: 60, problema: reloj.problema };

    const enlace = new EnlaceNube({
      url: this.cfg.nube.url,
      llave: this.cfg.nube.llave,
      rif: this.cfg.emisor.rif,
    });

    const r = { ...vacia };
    try {
      // Primero el latido, que es la llamada barata: dice si hay algo y cada
      // cuanto volver. Solo si hay trabajo se hace la llamada pesada. Con cien
      // negocios conectados, la diferencia entre preguntar asi y halar a ciegas
      // cada 20 segundos es la diferencia entre una base tranquila y una base
      // ahogada.
      const est = this.estado();
      const latido = await enlace.latido({
        nombre: `Estacion ${this.cfg.sucursal}`,
        equipo: this.cfg.equipo,
        version: process.env.FISCAL_VERSION ?? 'dev',
        pendientes: est.pendientes,
        ultimo_documento: est.documentos,
        libro_integro: est.libro_integro,
      });
      r.pendientesEnNube = latido.pendientes_en_nube;
      r.volverEnSegundos = latido.volverEnSegundos;
      // El serial lo asigna la imprenta y la estacion lo aprende sola del
      // latido. Si algun dia cambia, queda escrito: no se pisa en silencio.
      if (latido.serial && latido.serial !== leerSerial(this.base)) {
        const habia = leerSerial(this.base);
        guardarSerial(this.base, latido.serial);
        this.bitacora.anotar('estacion', 'nube', habia ? 'serial_actualizado' : 'serial_recibido', {
          serial: latido.serial,
          ...(habia ? { antes: habia } : {}),
        });
      }
      if (latido.pendientes_en_nube === 0 && latido.anulaciones_en_nube === 0) return r;

      if (latido.pendientes_en_nube > 0) {
        const ventas = await enlace.halar();
        r.halada = ventas.length;

        for (const item of ventas) {
          const res = await this.recibirVenta(
            aVentaEntrante(item, this.cfg.emisor.alicuota_general),
            'gustito-nube',
          );
          if (res.sellada) {
            // Se confirma aunque la transmision quede pendiente: la venta YA esta
            // en el libro, que es lo que manda. El numero de control llega cuando
            // la imprenta responda.
            await enlace.confirmar({
              cola_id: item.cola_id,
              numero_documento: res.numero as number,
              numero_control: res.numeroControl ?? null,
              token: res.sello?.split('/v/')[1] ?? null,
            });
            r.sellada += 1;
          } else {
            await enlace.reportarError(item.cola_id, res.problema ?? 'no se pudo sellar');
            r.fallada += 1;
          }
        }
      }

      // Las solicitudes de anulacion van DESPUES de las ventas a proposito: si
      // en la misma ronda llegan la venta y su anulacion, primero se sella la
      // factura y de una vez sale su nota de credito.
      if (latido.anulaciones_en_nube > 0) {
        const solicitudes = await enlace.anulaciones();
        for (const s of solicitudes) {
          const res = await this.anularPorOrigen(
            `${s.tipo}:${s.origen_id}`,
            s.motivo,
            `portal: ${s.pedida_por}`,
          );
          if (res.hecha) {
            await enlace.anulacionResultado({
              anulacion_id: s.anulacion_id,
              ok: true,
              numero_nota: res.nota ?? null,
            });
            r.anulada += 1;
          } else {
            await enlace.anulacionResultado({
              anulacion_id: s.anulacion_id,
              ok: false,
              error: res.problema ?? 'no se pudo anular',
            });
            r.anulacionFallada += 1;
          }
        }
      }
    } catch (e) {
      r.problema = e instanceof Error ? e.message : String(e);
      this.bitacora.anotar('estacion', 'nube', 'ronda_fallida', { problema: r.problema });
    }
    return r;
  }

  /**
   * Anota en la bitacora algo que hizo una persona (configurar, cambiar una
   * clave). Va por aca y no creando otra Bitacora aparte a proposito: dos
   * escritores sobre el mismo archivo romperian la cadena de hashes.
   */
  anotarEvento(quien: string, accion: string, detalle?: Record<string, unknown>): void {
    this.bitacora.anotar(quien, 'local', accion, detalle);
  }

  /** Ultimos movimientos, para que el dueño vea quien hizo que. */
  movimientos(cuantos = 50): Evento[] {
    const todos = this.bitacora.todos();
    return todos.slice(Math.max(0, todos.length - cuantos));
  }

  /** La puerta del fiscal, con su nombre y desde donde entra. */
  auditor(quien: string, desde: string): Auditor {
    return new Auditor({ libro: this.libro, bitacora: this.bitacora, cola: this.cola, quien, desde });
  }

  /** El enlace con la imprenta, si el negocio lo tiene configurado. */
  private enlace(): EnlaceNube | null {
    if (!this.cfg.nube?.url || !this.cfg.nube?.llave) return null;
    return new EnlaceNube({ url: this.cfg.nube.url, llave: this.cfg.nube.llave, rif: this.cfg.emisor.rif });
  }

  private get rutaEstadoRespaldo(): string {
    return join(this.base, 'estado', 'respaldo-nube.json');
  }

  private timerRespaldo: ReturnType<typeof setTimeout> | null = null;

  /**
   * LA VENTANA DE PERDIDA SE ACHICA SOLA: apenas algo entra al libro, el
   * respaldo sale del equipo en segundos, sin esperar la vuelta de los diez
   * minutos (que sigue existiendo como red de seguridad). Varios sellados
   * seguidos se agrupan en una sola subida, y el temporizador no aguanta vivo
   * al proceso (unref): es un empujon, no un ancla.
   */
  private programarSubidaRespaldo(): void {
    if (!this.cfg.nube?.url || !this.cfg.nube?.llave) return;
    if (this.timerRespaldo) return;
    this.timerRespaldo = setTimeout(() => {
      this.timerRespaldo = null;
      void this.subirRespaldo();
    }, 3_000);
    this.timerRespaldo.unref?.();
  }

  /**
   * MANDA EL LIBRO AFUERA. Sube al deposito de la imprenta las lineas cifradas
   * que le falten, y solo esas: primero pregunta hasta donde tiene ella.
   *
   * Nunca lanza: quedarse sin internet no puede tumbar la estacion.
   */
  async subirRespaldo(): Promise<{ subidas: number; afuera: number; problema?: string }> {
    const enlace = this.enlace();
    if (!enlace) return { subidas: 0, afuera: 0, problema: 'Esta estacion no tiene enlace con la imprenta' };
    try {
      const tiene = await enlace.respaldoHasta();
      // Si alla no hay nada, se manda tambien la cabecera (la linea cero), que
      // es la que permite abrir el respaldo en otra computadora.
      let desde = tiene.lineas === 0 ? -1 : tiene.ultimo_seq;
      let subidas = 0;
      let afuera = tiene.ultimo_seq;
      // De a poco: un libro de años no se manda en una sola bocanada.
      for (let vuelta = 0; vuelta < 50; vuelta++) {
        const lote = this.respaldo.paraSubir(desde).slice(0, 200);
        if (!lote.length) break;
        const r = await enlace.respaldoGuardar(lote);
        subidas += r.guardadas;
        afuera = r.ultimo_seq;
        desde = lote[lote.length - 1].seq;
      }
      const estado = { ultimo_seq: afuera, subido_en: new Date().toISOString(), problema: null };
      this.escribirEstadoRespaldo(estado);
      if (subidas > 0) this.bitacora.anotar('estacion', 'nube', 'respaldo_subido', { lineas: subidas, hasta: afuera });
      return { subidas, afuera };
    } catch (e) {
      const problema = e instanceof Error ? e.message : String(e);
      this.escribirEstadoRespaldo({ ...this.leerEstadoRespaldo(), problema });
      return { subidas: 0, afuera: this.leerEstadoRespaldo().ultimo_seq, problema };
    }
  }

  private escribirEstadoRespaldo(e: { ultimo_seq: number; subido_en?: string | null; problema: string | null }): void {
    mkdirSync(join(this.base, 'estado'), { recursive: true });
    const fd = openSync(this.rutaEstadoRespaldo, 'w');
    try {
      writeSync(fd, JSON.stringify(e, null, 2));
      fsyncSync(fd);
    } finally {
      closeSync(fd);
    }
  }

  private leerEstadoRespaldo(): { ultimo_seq: number; subido_en?: string | null; problema: string | null } {
    if (!existsSync(this.rutaEstadoRespaldo)) return { ultimo_seq: 0, subido_en: null, problema: null };
    try {
      return JSON.parse(readFileSync(this.rutaEstadoRespaldo, 'utf8')) as {
        ultimo_seq: number;
        subido_en?: string | null;
        problema: string | null;
      };
    } catch {
      return { ultimo_seq: 0, subido_en: null, problema: null };
    }
  }

  /**
   * EL DIA MALO: se quemo la laptop y hay una maquina nueva. Baja el respaldo
   * de la imprenta, lo abre con la frase que el dueño tenia apuntada y levanta
   * el libro.
   *
   * Solo funciona con el libro vacio: no existe forma de que esto pise un libro
   * con documentos, ni por error ni a proposito.
   */
  async traerRespaldoDeLaNube(frase: string): Promise<{ documentos: number }> {
    if (this.libro.resumen().total > 0) {
      throw new Error('Esta estacion ya tiene libro. Restaurar solo se hace en una instalacion nueva.');
    }
    const enlace = this.enlace();
    if (!enlace) throw new Error('Esta estacion no tiene enlace con la imprenta');

    const lineas: LineaCifrada[] = [];
    let desde = -1;
    for (let vuelta = 0; vuelta < 200; vuelta++) {
      const lote = await enlace.respaldoBajar(desde, 200);
      if (!lote.length) break;
      lineas.push(...lote);
      desde = lote[lote.length - 1].seq;
    }
    if (lineas.length < 2) throw new Error('La imprenta no tiene respaldo guardado de este RIF');

    // Se arma primero en una carpeta aparte y se prueba a abrirlo ahi: si la
    // frase esta mala, no se toco nada de la instalacion.
    const aparte = join(this.base, 'estado', 'restaurando');
    rmSync(aparte, { recursive: true, force: true });
    mkdirSync(aparte, { recursive: true });
    const rutaPrueba = join(aparte, 'respaldo.jsonl');
    RespaldoCifrado.escribirDesdeLineas(rutaPrueba, lineas);
    const asientos = new RespaldoCifrado(rutaPrueba, frase).restaurar();

    LibroFiscal.restaurarEn(join(this.base, 'estado', 'libro.jsonl'), asientos, this.cfg.equipo);
    // El respaldo local pasa a ser el que se bajo, y la frase, la que lo abre.
    rmSync(rutaRespaldo(this.base), { force: true });
    renameSync(rutaPrueba, rutaRespaldo(this.base));
    guardarFrase(this.base, frase);
    rmSync(aparte, { recursive: true, force: true });
    this.bitacora.anotar('dueño', 'local', 'libro_restaurado', { documentos: asientos.length });
    return { documentos: asientos.length };
  }

  /** La frase con la que se abre el respaldo. La ve el dueño y la apunta aparte. */
  fraseDelRespaldo(): string {
    return fraseDeRecuperacion(this.base);
  }

  /** El serial que la imprenta le asigno a esta estacion (null en practica). */
  serial(): string | null {
    return leerSerial(this.base);
  }

  /** Como va el respaldo: cuanto hay aca y cuanto llego afuera. */
  estadoRespaldo(): { aqui: number; afuera: number; al_dia: boolean; subido_en: string | null; problema: string | null } {
    const nube = this.leerEstadoRespaldo();
    const aqui = this.libro.resumen().total;
    return {
      aqui,
      afuera: nube.ultimo_seq,
      al_dia: nube.ultimo_seq >= aqui,
      subido_en: nube.subido_en ?? null,
      problema: nube.problema ?? null,
    };
  }

  /** Compara el reloj del equipo con la nube y deja el resultado anotado. */
  async revisarReloj(quien = 'estacion'): Promise<{ texto: string; bien: boolean }> {
    const antes = this.reloj.estado();
    const ahora = await this.reloj.revisar();
    // Solo se anota cuando cambia el veredicto: si no, la bitacora se llenaria
    // de "el reloj esta bien" cada quince minutos.
    if (antes.corrido !== ahora.corrido) {
      this.bitacora.anotar(quien, 'local', ahora.corrido ? 'reloj_corrido' : 'reloj_corregido', {
        desfase_seg: ahora.desfase_seg,
      });
    }
    return this.reloj.resumen();
  }

  /** Lo que muestra la pantalla de estado. */
  estado(): {
    negocio: string;
    rif: string;
    imprenta: string;
    imprenta_real: boolean;
    documentos: number;
    pendientes: number;
    rechazados: number;
    libro_integro: boolean;
    reloj: string;
    reloj_bien: boolean;
    puede_sellar: boolean;
  } {
    const r = this.libro.resumen();
    const reloj = this.reloj.resumen();
    return {
      negocio: this.cfg.emisor.razon_social,
      rif: this.cfg.emisor.rif,
      imprenta: this.imprenta.nombre,
      imprenta_real: this.imprenta.real,
      documentos: r.total,
      pendientes: this.cola.cuantosPendientes(),
      rechazados: this.cola.rechazados().length,
      libro_integro: this.libro.verificar().integro,
      reloj: reloj.texto,
      reloj_bien: reloj.bien,
      puede_sellar: this.reloj.aprueba(this.libro.ultimoSelladoEn()).ok,
    };
  }

  /** Ventas que llegaron al buzon (para revisar si algo no se sello). */
  buzon(): { en: string; venta: VentaEntrante }[] {
    if (!existsSync(this.rutaBuzon)) return [];
    return readFileSync(this.rutaBuzon, 'utf8')
      .split('\n')
      .filter((l) => l.trim() !== '')
      .map((l) => JSON.parse(l) as { en: string; venta: VentaEntrante });
  }

  /** Documentos sellados, para la pantalla del negocio. */
  documentos(): Asiento[] {
    return this.libro.todos();
  }
}
