// CLAVES Y ROLES DE LA ESTACION FISCAL.
//
// Tres puertas distintas, y NO se mezclan:
//   - DUEÑO: configura la estacion, anula con nota de credito, pone las claves
//     de los demas y lee la bitacora. Todo.
//   - CAJERO: ve como va la cosa y puede empujar lo que quedo por transmitir.
//     NO configura y NO anula. Que el cajero pueda anular sin que nadie se
//     entere es justo lo que un fiscal busca al llegar.
//   - AUDITOR: la clave que se le entrega al SENIAT (Providencia 000121,
//     articulo 3, numeral 8). Solo consulta y exporta.
//
// Ademas hay una LLAVE DE MAQUINA, que no es de ninguna persona: es la que usa
// el programa del negocio (Gustito, el Cerebrito) para entregar la venta por
// /api/venta. Esa puerta escribe en el libro fiscal, asi que tampoco puede
// quedar abierta a cualquier programa que corra en el equipo.
//
// Las claves se guardan con scrypt y su sal, nunca en texto plano: si alguien
// se lleva el archivo, no se lleva las claves.
import { randomBytes, scryptSync, timingSafeEqual } from 'node:crypto';
import { closeSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, renameSync, writeSync } from 'node:fs';
import { dirname } from 'node:path';

export type Rol = 'dueno' | 'cajero' | 'auditor';

/** Lo que se puede hacer en la estacion. Cada rol tiene lo suyo. */
export type Accion = 'ver' | 'reintentar' | 'anular' | 'configurar' | 'claves' | 'bitacora' | 'exportar' | 'retenciones';

const PERMISOS: Record<Rol, Accion[]> = {
  // `exportar` es llevarse el periodo completo en un archivo (con las
  // identificaciones de los clientes y los totales del negocio). Es mas que
  // `ver`, que solo alcanza para mirar los ultimos documentos en pantalla, y por
  // eso el cajero no lo tiene: mira el dia a dia, pero no se lleva el libro.
  // `retenciones` es registrar y anular comprobantes de retencion recibidos:
  // credito fiscal del periodo (Providencia 000054). Es plata del mes en la
  // declaracion, no caja del dia, y por eso tampoco lo tiene el cajero.
  dueno: ['ver', 'reintentar', 'anular', 'configurar', 'claves', 'bitacora', 'exportar', 'retenciones'],
  cajero: ['ver', 'reintentar'],
  // El del SENIAT entra por su propia puerta (/fiscal), que es de solo lectura.
  auditor: [],
};

export const NOMBRE_ROL: Record<Rol, string> = {
  dueno: 'Dueño',
  cajero: 'Cajero',
  auditor: 'SENIAT',
};

export function puede(rol: Rol, accion: Accion): boolean {
  return PERMISOS[rol].includes(accion);
}

interface ClaveGuardada {
  sal: string;
  hash: string;
  cambiada_en: string;
}

interface ArchivoAcceso {
  dueno?: ClaveGuardada;
  cajero?: ClaveGuardada;
  auditor?: ClaveGuardada;
  /** Como se llamaba antes el rol del dueño. Se sigue leyendo por si acaso. */
  negocio?: ClaveGuardada;
  /** La llave que usan los programas del negocio para entregar la venta. */
  llave_maquina?: string;
  /** Intentos fallidos por rol, para frenar a quien anda probando. */
  fallidos?: Record<string, { veces: number; ultimo?: string; hasta?: string }>;
}

const MAX_FALLIDOS = 5;
const CASTIGO_MIN = 15;

function derivar(clave: string, sal: string): string {
  return scryptSync(clave, sal, 32).toString('hex');
}

export class Acceso {
  private readonly ruta: string;
  private datos: ArchivoAcceso = {};

  constructor(ruta: string) {
    this.ruta = ruta;
    mkdirSync(dirname(this.ruta), { recursive: true });
    this.recargar();
  }

  /**
   * Vuelve a leer el archivo. Se llama antes de cada comprobacion porque la
   * clave puede cambiarse desde otra pantalla o desde otro programa mientras la
   * estacion esta corriendo: si nos quedaramos con lo que habia al arrancar, el
   * SENIAT llegaria con su clave nueva y la estacion le diria que no.
   */
  private recargar(): void {
    if (!existsSync(this.ruta)) {
      this.datos = {};
      return;
    }
    try {
      this.datos = JSON.parse(readFileSync(this.ruta, 'utf8')) as ArchivoAcceso;
    } catch {
      this.datos = {};
    }
    // Instalaciones viejas guardaban al dueño con el nombre 'negocio'.
    if (!this.datos.dueno && this.datos.negocio) this.datos.dueno = this.datos.negocio;
  }

  private guardar(): void {
    const tmp = this.ruta + '.tmp';
    const fd = openSync(tmp, 'w');
    try {
      writeSync(fd, JSON.stringify(this.datos, null, 2));
      fsyncSync(fd);
    } finally {
      closeSync(fd);
    }
    renameSync(tmp, this.ruta);
  }

  tieneClave(rol: Rol): boolean {
    this.recargar();
    return !!this.datos[rol];
  }

  /** ¿Ya paso el primer uso? Sin clave de dueño la estacion no tiene portero. */
  yaTienePortero(): boolean {
    return this.tieneClave('dueno');
  }

  /** Pone o cambia la clave de un rol. La del auditor es la que se le da al SENIAT. */
  fijarClave(rol: Rol, clave: string): void {
    if (clave.length < 8) throw new Error('La clave debe tener al menos 8 caracteres');
    this.recargar();
    const sal = randomBytes(16).toString('hex');
    this.datos[rol] = { sal, hash: derivar(clave, sal), cambiada_en: new Date().toISOString() };
    if (rol === 'dueno') delete this.datos.negocio; // ya quedo con el nombre nuevo
    // Cambiar la clave destraba: el dueño que la cambia es el dueño. Tambien
    // destranca la puerta unica: es la salida de emergencia si alguien la dejo
    // trancada a punta de intentos malos (solo el dueño con sesion abierta
    // puede cambiar claves, asi que el atacante no se destranca solo).
    if (this.datos.fallidos) {
      delete this.datos.fallidos[rol];
      if (rol !== 'auditor') delete this.datos.fallidos.puerta;
    }
    this.guardar();
  }

  /** Quita la clave de un rol (por ejemplo, el negocio que no usa cajeros). */
  quitarClave(rol: Rol): void {
    if (rol === 'dueno') throw new Error('La clave del dueño no se puede quitar');
    this.recargar();
    delete this.datos[rol];
    this.guardar();
  }

  cambiadaEn(rol: Rol): string | null {
    this.recargar();
    return this.datos[rol]?.cambiada_en ?? null;
  }

  /**
   * La llave de los programas del negocio. Se crea sola la primera vez que se
   * pide y no cambia, salvo que la roten a proposito.
   */
  llaveMaquina(): string {
    this.recargar();
    if (!this.datos.llave_maquina) {
      this.datos.llave_maquina = 'efm_' + randomBytes(24).toString('hex');
      this.guardar();
    }
    return this.datos.llave_maquina;
  }

  rotarLlaveMaquina(): string {
    this.recargar();
    this.datos.llave_maquina = 'efm_' + randomBytes(24).toString('hex');
    this.guardar();
    return this.datos.llave_maquina;
  }

  /** Compara la llave de maquina sin dar pistas por el tiempo de respuesta. */
  esLlaveMaquina(llave: string): boolean {
    const buena = Buffer.from(this.llaveMaquina(), 'utf8');
    const dada = Buffer.from(llave ?? '', 'utf8');
    return buena.length === dada.length && timingSafeEqual(buena, dada);
  }

  /**
   * ¿Esa clave es la misma que ya tiene otro rol? Se usa para no dejar que la
   * del SENIAT sea la misma del negocio: la norma pide una clave PARA EL
   * SENIAT, y si es la misma del dueño, darsela es entregarle la estacion
   * completa. No toca el contador de intentos: no es un intento de entrar.
   */
  esLaMismaClaveDe(rol: Rol, clave: string): boolean {
    this.recargar();
    const g = this.datos[rol];
    if (!g || !clave) return false;
    const calculado = Buffer.from(derivar(clave, g.sal), 'hex');
    const guardado = Buffer.from(g.hash, 'hex');
    return calculado.length === guardado.length && timingSafeEqual(calculado, guardado);
  }

  /** ¿Esta frenado por intentos fallidos? */
  frenado(rol: Rol): boolean {
    const f = this.datos.fallidos?.[rol];
    if (!f?.hasta) return false;
    return new Date(f.hasta).getTime() > Date.now();
  }

  /** Cuantos segundos faltan para poder volver a probar. */
  esperaSegundos(rol: Rol): number {
    const f = this.datos.fallidos?.[rol];
    if (!f?.hasta) return 0;
    return Math.max(0, Math.ceil((new Date(f.hasta).getTime() - Date.now()) / 1000));
  }

  /**
   * UNA SOLA PUERTA: la clave dice quien es. Como una clave no puede repetirse
   * entre roles, cada clave identifica a uno solo. El SENIAT no entra por aca
   * (tiene su puerta de solo lectura en /fiscal).
   *
   * Los intentos fallidos van a UNA bolsa ('puerta'): probar una clave mala no
   * castiga dos veces (una por rol) el mismo dedazo.
   */
  entrarConClave(clave: string): Rol | null {
    this.recargar();
    const f = this.datos.fallidos?.puerta;
    if (f?.hasta && new Date(f.hasta).getTime() > Date.now()) {
      throw new Error('Demasiados intentos. Espera unos minutos antes de volver a probar.');
    }
    for (const rol of ['dueno', 'cajero'] as Rol[]) {
      if (this.datos[rol] && this.esLaMismaClaveDe(rol, clave)) {
        this.datos.fallidos = this.datos.fallidos ?? {};
        delete this.datos.fallidos.puerta;
        this.guardar();
        return rol;
      }
    }
    this.datos.fallidos = this.datos.fallidos ?? {};
    const previo = this.datos.fallidos.puerta;
    const frescos = previo?.ultimo && Date.now() - new Date(previo.ultimo).getTime() < CASTIGO_MIN * 60000;
    const veces = (frescos ? previo?.veces ?? 0 : 0) + 1;
    this.datos.fallidos.puerta = {
      veces,
      ultimo: new Date().toISOString(),
      hasta: veces >= MAX_FALLIDOS ? new Date(Date.now() + CASTIGO_MIN * 60000).toISOString() : undefined,
    };
    this.guardar();
    return null;
  }

  /**
   * Revisa la clave. Compara en tiempo constante para no filtrar informacion
   * por lo que tarda en responder.
   */
  entrar(rol: Rol, clave: string): boolean {
    this.recargar();
    if (this.frenado(rol)) throw new Error('Demasiados intentos. Espera unos minutos antes de volver a probar.');
    const g = this.datos[rol];
    if (!g) return false;
    const calculado = Buffer.from(derivar(clave, g.sal), 'hex');
    const guardado = Buffer.from(g.hash, 'hex');
    const ok = calculado.length === guardado.length && timingSafeEqual(calculado, guardado);

    this.datos.fallidos = this.datos.fallidos ?? {};
    if (ok) {
      delete this.datos.fallidos[rol];
    } else {
      const previo = this.datos.fallidos[rol];
      // Los fallos viejos no cuentan: si el ultimo fue hace mas del castigo, se
      // empieza a contar de nuevo. Si no, cinco dedazos repartidos en un año
      // dejarian al SENIAT trancado afuera el dia que llegue.
      const frescos = previo?.ultimo && Date.now() - new Date(previo.ultimo).getTime() < CASTIGO_MIN * 60000;
      const veces = (frescos ? previo?.veces ?? 0 : 0) + 1;
      this.datos.fallidos[rol] = {
        veces,
        ultimo: new Date().toISOString(),
        hasta: veces >= MAX_FALLIDOS ? new Date(Date.now() + CASTIGO_MIN * 60000).toISOString() : undefined,
      };
    }
    this.guardar();
    return ok;
  }
}

interface Sesion {
  rol: Rol;
  hasta: number;
}

/**
 * SESIONES DEL NAVEGADOR. Viven en la memoria del programa a proposito: si la
 * estacion se reinicia, todo el mundo vuelve a entrar con su clave. Una sesion
 * guardada en disco es una sesion que sobrevive a un robo del equipo.
 */
export class Sesiones {
  private readonly vivas = new Map<string, Sesion>();
  private readonly horas: number;

  constructor(horas = 8) {
    this.horas = horas;
  }

  abrir(rol: Rol): string {
    const token = randomBytes(24).toString('hex');
    this.vivas.set(token, { rol, hasta: Date.now() + this.horas * 3600_000 });
    return token;
  }

  ver(token: string | null): Rol | null {
    if (!token) return null;
    const s = this.vivas.get(token);
    if (!s) return null;
    if (s.hasta < Date.now()) {
      this.vivas.delete(token);
      return null;
    }
    return s.rol;
  }

  cerrar(token: string | null): void {
    if (token) this.vivas.delete(token);
  }

  /**
   * Cierra las sesiones abiertas de un rol. Se usa al cambiarle la clave: si
   * al cajero le cambian la suya, la sesion que dejo abierta en la maquina no
   * puede seguir viva. Al dueño no se le tumba la suya por eso.
   */
  cerrarRol(rol: Rol): void {
    for (const [token, s] of this.vivas) if (s.rol === rol) this.vivas.delete(token);
  }
}
