/* ============================================================
 * Copyright (c) 2026 RAVAPI Soluções. www.ravapi.com
 * Canonicalização do payload e hash encadeado
 *
 * É o ponto mais delicado do sistema. Se duas execuções serializarem
 * o mesmo documento de formas diferentes, a cadeia inteira passa a
 * acusar falso positivo. Por isso: função isolada, versionada em
 * VERSAO_PAYLOAD, e teste de regressão com vetor fixo.
 *
 * Regras da serialização canônica:
 *  - chaves ordenadas por code point (não por locale)
 *  - strings normalizadas em Unicode NFC
 *  - sem espaço em branco entre tokens
 *  - undefined e null são omitidos (ausência é ausência)
 *  - inteiros como número; nunca float em valor monetário
 *  - datas sempre ISO-8601 em UTC
 * ============================================================ */

import { createHash, randomBytes } from 'node:crypto';

export const VERSAO_PAYLOAD = 1;
export const BLOCO_GENESE = '0'.repeat(64);

/** Campos que entram no hash, na v1 do payload. */
export interface PayloadRecibo {
  direcao: 'RECEBIMENTO' | 'PAGAMENTO';
  numero_formatado: string;
  valor_centavos: number;
  descricao: string;
  natureza?: string | null;
  forma_pagamento: string;
  data_pagamento: string;          // YYYY-MM-DD
  local_pagamento: string;
  referencia?: string | null;
  credor_snapshot: Record<string, unknown>;
  devedor_snapshot: Record<string, unknown>;
  emitente_papel: 'CREDOR' | 'DEVEDOR';
  logo_sha256?: string | null;
  assinatura_sha256?: string | null;
  signatario_nome?: string | null;
  external_source?: string | null;
  external_ref?: string | null;
  emitido_por: string;
  emitido_em: string;              // ISO-8601 UTC
}

/** Ordem fixa dos campos. Mudar esta lista exige subir VERSAO_PAYLOAD. */
const CAMPOS_V1: Array<keyof PayloadRecibo> = [
  'direcao', 'numero_formatado', 'valor_centavos', 'descricao', 'natureza',
  'forma_pagamento', 'data_pagamento', 'local_pagamento', 'referencia',
  'credor_snapshot', 'devedor_snapshot', 'emitente_papel',
  'logo_sha256', 'assinatura_sha256', 'signatario_nome',
  'external_source', 'external_ref', 'emitido_por', 'emitido_em',
];

function serializar(valor: unknown): string {
  if (valor === null || valor === undefined) return 'null';

  if (typeof valor === 'string') return JSON.stringify(valor.normalize('NFC'));

  if (typeof valor === 'number') {
    if (!Number.isFinite(valor)) throw new TypeError('número não finito no payload');
    if (!Number.isInteger(valor)) {
      throw new TypeError(
        `float no payload canônico (${valor}). Valores monetários são inteiros em centavos.`,
      );
    }
    return String(valor);
  }

  if (typeof valor === 'boolean') return valor ? 'true' : 'false';

  if (Array.isArray(valor)) return `[${valor.map(serializar).join(',')}]`;

  if (typeof valor === 'object') {
    const obj = valor as Record<string, unknown>;
    const chaves = Object.keys(obj)
      .filter((k) => obj[k] !== undefined)
      .sort();                                   // ordem por code point
    const pares = chaves.map(
      (k) => `${JSON.stringify(k.normalize('NFC'))}:${serializar(obj[k])}`,
    );
    return `{${pares.join(',')}}`;
  }

  throw new TypeError(`tipo não serializável no payload: ${typeof valor}`);
}

/** Gera a string canônica determinística do documento. */
export function canonicalizar(p: PayloadRecibo): string {
  const pares = CAMPOS_V1.map((campo) => {
    const v = p[campo];
    return `${JSON.stringify(campo)}:${serializar(v ?? null)}`;
  });
  return `{${pares.join(',')}}`;
}

/** hash_documento = SHA256(canônico || hash_anterior) */
export function calcularHash(canonico: string, hashAnterior: string): string {
  if (!/^[0-9a-f]{64}$/.test(hashAnterior)) {
    throw new TypeError(`hash_anterior inválido: ${hashAnterior}`);
  }
  return createHash('sha256').update(canonico + hashAnterior, 'utf8').digest('hex');
}

/**
 * Percorre UMA cadeia recalculando cada elo. É a verificação completa:
 * detecta adulteração de campo, que a checagem do banco não pega.
 *
 * A cadeia é por série. Passar documentos de séries diferentes na mesma
 * lista acusa falso positivo, porque cada série começa no próprio bloco
 * gênese e tem sua própria contagem de elos.
 */
export function verificarCadeia(
  docs: Array<{ numero_formatado: string; payload: PayloadRecibo; hash_documento: string; hash_anterior: string }>,
): Array<{ numero: string; problema: string }> {
  const problemas: Array<{ numero: string; problema: string }> = [];
  let esperado = BLOCO_GENESE;

  for (const doc of docs) {
    if (doc.hash_anterior !== esperado) {
      problemas.push({
        numero: doc.numero_formatado,
        problema: `hash_anterior não confere (esperado ${esperado.slice(0, 12)}…, achou ${doc.hash_anterior.slice(0, 12)}…)`,
      });
    }
    const recalculado = calcularHash(canonicalizar(doc.payload), doc.hash_anterior);
    if (recalculado !== doc.hash_documento) {
      problemas.push({
        numero: doc.numero_formatado,
        problema: 'conteúdo adulterado: o hash recalculado não bate com o gravado',
      });
    }
    esperado = doc.hash_documento;
  }
  return problemas;
}

/**
 * Código de verificação pública. Crockford Base32, sem I/L/O/U para não
 * confundir na leitura ao telefone. Aleatório de propósito: se fosse
 * derivado do número sequencial, daria para varrer a base pública inteira.
 */
const ALFABETO = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';

export function gerarCodigoVerificacao(): string {
  const bytes = randomBytes(8);
  let n = 0n;
  for (const b of bytes) n = (n << 8n) | BigInt(b);
  let saida = '';
  for (let i = 0; i < 12; i++) {
    saida = ALFABETO[Number(n % 32n)] + saida;
    n /= 32n;
  }
  return `${saida.slice(0, 4)}-${saida.slice(4, 8)}-${saida.slice(8, 12)}`;
}

/** Formata o número do recibo: série + sequencial com 6 dígitos. */
export function formatarNumero(serie: string, numero: number): string {
  return `${serie}-${String(numero).padStart(6, '0')}`;
}

/**
 * Nome mascarado para a página pública (escopo §13): o primeiro nome fica
 * inteiro — é o que identifica a pessoa sem expor o resto — e cada nome
 * seguinte vira só a inicial.
 */
export function mascararNome(nomeCompleto: string): string {
  const partes = nomeCompleto.trim().split(/\s+/).filter(Boolean);
  if (partes.length <= 1) {
    const p = partes[0] ?? '';
    return p.length <= 2 ? p : `${p[0]}${'*'.repeat(p.length - 1)}`;
  }
  const [primeiro, ...resto] = partes;
  return [primeiro, ...resto.map((p) => `${p[0]}${'*'.repeat(Math.max(p.length - 1, 1))}`)].join(' ');
}

/**
 * CPF/CNPJ mascarado para a página pública — mesma convenção da Receita:
 * esconde as pontas (as que mais identificam), mostra os blocos do meio.
 */
export function mascararDocumento(documento: string): string {
  const d = documento.replace(/\D/g, '');
  if (d.length === 11) return `***.${d.slice(3, 6)}.${d.slice(6, 9)}-**`;
  if (d.length === 14) return `**.${d.slice(2, 5)}.${d.slice(5, 8)}/${d.slice(8, 12)}-**`;
  return '***';
}
