import assert from "node:assert/strict";
import { randomBytes } from "node:crypto";
import { describe, it } from "node:test";

import { cifrar, cifrarObjeto, decifrar, decifrarObjeto } from "./cofre.ts";
import { arredondar, centavos, ratear, reais } from "./dinheiro.ts";
import { problemaNaIdentificacaoDoCsc, problemaNoCsc } from "./emissor.ts";
import { problemaNaInscricaoEstadual } from "./montar.ts";
import { explicarRejeicao } from "./rejeicoes.ts";
import { podeMudarStatus, statusDaSpedyParaLocal } from "./status.ts";
import { assinaturaEsperada, webhookValido } from "../spedy/webhook.ts";
import { mensagemDoErro } from "../spedy/cliente.ts";

const chave = randomBytes(32).toString("base64");

describe("cofre", () => {
  it("cifra e decifra, com IV diferente a cada vez", () => {
    const a = cifrar("chave-da-spedy", chave);
    const b = cifrar("chave-da-spedy", chave);
    assert.notEqual(a, b);
    assert.equal(decifrar(a, chave), "chave-da-spedy");
  });

  it("recusa conteúdo adulterado e chave errada", () => {
    const cifrado = cifrar("segredo", chave);
    const partes = cifrado.split(".");
    const dados = Buffer.from(partes[3], "base64url");
    dados[0] = dados[0] ^ 1;
    partes[3] = dados.toString("base64url");
    assert.throws(() => decifrar(partes.join("."), chave));
    assert.throws(() => decifrar(cifrado, randomBytes(32).toString("base64")));
  });

  it("guarda objetos pequenos sem os vazios", () => {
    const cifrado = cifrarObjeto({ cscProducao: "ABC", vazio: "", nulo: null }, chave);
    assert.deepEqual(decifrarObjeto(cifrado, chave), { cscProducao: "ABC" });
    assert.deepEqual(decifrarObjeto(null, chave), {});
  });
});

describe("dinheiro", () => {
  it("centavos e reais sem erro de ponto flutuante", () => {
    assert.equal(centavos(0.1 + 0.2), 30);
    assert.equal(centavos("12,34"), 1234);
    assert.equal(reais(1005), 10.05);
    assert.equal(arredondar(1.005, 2), 1.01);
  });

  it("rateio fecha no centavo, com sinal e pesos zerados", () => {
    const partes = ratear(100, [1, 1, 1]);
    assert.equal(partes.reduce((a, b) => a + b, 0), 100);
    assert.deepEqual(partes, [34, 33, 33]);
    assert.deepEqual(ratear(-10, [1, 3]), [-2, -8]);
    assert.equal(ratear(-10, [1, 3]).reduce((a, b) => a + b, 0), -10);
    assert.deepEqual(ratear(5, [0, 0]), [3, 2]);
    assert.deepEqual(ratear(0, [1, 2]), [0, 0]);
  });
});

describe("status", () => {
  it("traduz o status da Spedy", () => {
    assert.equal(statusDaSpedyParaLocal("authorized"), "autorizada");
    assert.equal(statusDaSpedyParaLocal("created", true), "rascunho");
    assert.equal(statusDaSpedyParaLocal("created"), "processando");
    assert.equal(statusDaSpedyParaLocal("disabled"), "removida");
  });

  it("não deixa um aviso atrasado desfazer a autorização", () => {
    assert.equal(podeMudarStatus("autorizada", "processando"), false);
    assert.equal(podeMudarStatus("autorizada", "cancelada"), true);
    assert.equal(podeMudarStatus("rejeitada", "processando"), true);
    assert.equal(podeMudarStatus("cancelada", "autorizada"), false);
    assert.equal(podeMudarStatus("cancelando", "autorizada"), true);
  });
});

describe("rejeições", () => {
  it("explica código conhecido e cai no texto quando não conhece", () => {
    assert.equal(explicarRejeicao("778", null).onde, "produto");
    assert.equal(explicarRejeicao("SPD999", null).aguardar, true);
    assert.equal(explicarRejeicao("9999", "Rejeicao: certificado vencido").onde, "certificado");
    assert.equal(explicarRejeicao(null, null).onde, null);
  });

  it("a recusa da DSF sem regra de imposto manda conferir o código e a atividade", () => {
    /* A mensagem exata que a MK5 recebeu em 21/09/2026 (código L999, genérico da DSF). */
    const explicacao = explicarRejeicao(
      "L999",
      'Cannot invoke "br.com.dsfnet.corporativo.tipo.IncidenciaType.isEstabelecimentoPrestador()" because "this.tipoIncidencia" is null',
      { modelo: "nfse" }
    );
    assert.match(explicacao.titulo, /regra do imposto/);
    assert.match(explicacao.dica, /código do serviço na prefeitura e a atividade/);
    assert.match(explicacao.dica, /Corrigi, reenviar/);
    /* Não promete que já está resolvido: é cadastro, e só a empresa corrige. */
    assert.doesNotMatch(explicacao.dica, /já foi corrigido/);
    assert.equal(explicacao.onde, "tributacao");
    /* Outro L999 da DSF não ganha essa explicação: o código sozinho não diz nada. */
    assert.equal(explicarRejeicao("L999", "Erro no processamento do lote").titulo, "A nota foi recusada");
  });

  it("a prefeitura sem os dados do cliente manda completar o cadastro", () => {
    /* A mensagem exata que a MK5 recebeu em 21/09/2026. */
    const explicacao = explicarRejeicao(
      "L999",
      "CPF/CNPJ Tomador: É de preenchimento obrigatório., Nome/Nome Empresarial Tomador: É de preenchimento obrigatório., CEP Tomador: É de preenchimento obrigatório., Logradouro Tomador: É de preenchimento obrigatório., Bairro Tomador: É de preenchimento obrigatório., UF Tomador: É de preenchimento obrigatório., Campo CPF/CNPJ Pessoa é de preenchimento obrigatório",
      { modelo: "nfse" }
    );
    assert.match(explicacao.titulo, /dados do cliente/);
    assert.equal(explicacao.onde, "cliente");
    assert.match(explicacao.dica, /CEP, rua, número, bairro, cidade e UF/);
  });

  it("recusa de formato em inglês vira explicação simples", () => {
    const numero = explicarRejeicao("SPD003", "The 'http://www.portalfiscal.inf.br/nfe:nNF' element is invalid - The value '0' is invalid");
    assert.match(numero.titulo, /numeração/);
    const formato = explicarRejeicao("SPD003", "The 'Id' attribute is invalid - The Pattern constraint failed.");
    assert.match(formato.titulo, /formato/);
  });

  it("na nota de teste a inscrição recusada é a da empresa", () => {
    assert.equal(explicarRejeicao("233", "Rejeição: IE do destinatário não cadastrada", { teste: true }).onde, "empresa");
    assert.equal(explicarRejeicao("233", "Rejeição: IE do destinatário não cadastrada").onde, "cliente");
    assert.equal(explicarRejeicao("232", "Rejeição: IE do destinatário não informada").onde, "cliente");
  });

  it("serviço de notas fora do ar não é contado como recusa", () => {
    for (const [codigo, mensagem] of [
      ["500", "O serviço de notas está instável agora. Tente de novo em instantes."],
      ["0", "O serviço de notas demorou para responder."],
      ["429", "Muitas notas ao mesmo tempo. Tente de novo em instantes."],
    ]) {
      const explicacao = explicarRejeicao(codigo, mensagem);
      assert.equal(explicacao.titulo, "O serviço de notas não respondeu");
      assert.doesNotMatch(explicacao.dica, /recus/i);
    }
    /* Código 503 da SEFAZ continua sendo a recusa dela. */
    assert.notEqual(explicarRejeicao("503", "Rejeição: Série utilizada fora da faixa permitida").titulo, "O serviço de notas não respondeu");
  });
});

describe("CSC do cupom fiscal", () => {
  it("aceita de 16 a 36 caracteres, com traços, e aponta o colado pela metade", () => {
    assert.equal(problemaNoCsc("A1B2C3D4E5F6G7H8"), null);
    assert.equal(problemaNoCsc("  A1B2C3D4 E5F6G7H8 "), null, "espaço da cópia não conta");
    assert.equal(problemaNoCsc("1234567890ABCDEF1234567890ABCDEF1234"), null);
    /* Pernambuco (e outros estados) entrega o CSC assim, com traços: vale como está. */
    assert.equal(problemaNoCsc("9D76E5F6-2EAC-442A-9CF5-7806FF74A3DC"), null);
    assert.equal(problemaNoCsc(""), null, "vazio é cobrado por quem chama");
    assert.match(String(problemaNoCsc("ABC123DEF456", "CSC de teste")), /CSC de teste tem de 16 a 36 caracteres, e o informado tem 12/);
    assert.match(String(problemaNoCsc("1234567890ABCDEF1234567890ABCDEF12345")), /tem 37/);
    assert.match(String(problemaNoCsc("A1B2C3D4E5F6G7H8ção")), /caracteres estranhos/);
  });

  it("identificação é um número de até 6 dígitos", () => {
    assert.equal(problemaNaIdentificacaoDoCsc("000001"), null);
    assert.equal(problemaNaIdentificacaoDoCsc("1"), null);
    assert.equal(problemaNaIdentificacaoDoCsc(null), null);
    assert.match(String(problemaNaIdentificacaoDoCsc("0000001")), /até 6 dígitos/);
    assert.match(String(problemaNaIdentificacaoDoCsc("ABC")), /até 6 dígitos/);
  });

  it("resposta não reconhecida no cupom manda conferir credenciamento e CSC, e fora dele não inventa causa", () => {
    const mensagem = "Mensagem de retorno da Sefaz não reconhecida. Cód: 180";
    const cupom = explicarRejeicao("SPD003", mensagem, { teste: true, modelo: "nfce" });
    assert.equal(cupom.onde, "csc");
    assert.match(cupom.dica, /credenciada para emitir cupom fiscal/);
    assert.match(cupom.dica, /CSC/);
    assert.match(cupom.dica, /suporte do Ktalogu/);
    const nota = explicarRejeicao("SPD003", mensagem, { modelo: "nfe" });
    assert.equal(nota.onde, "suporte");
    assert.doesNotMatch(nota.dica, /CSC/);
  });

  it("duplicata em venda à vista (853) diz que é só reenviar, venha o código limpo ou escondido no texto", () => {
    const direto = explicarRejeicao("853", "Rejeição: Dados de cobrança nao devem ser informados para pagamento a vista", { modelo: "nfe" });
    assert.match(direto.titulo, /duplicata/);
    assert.match(direto.dica, /Corrigi, reenviar/);
    assert.equal(direto.onde, null);
    /* Foi assim que chegou para um cliente em produção: o serviço de notas
       não traduziu o código e a tela dizia "respondeu de um jeito inesperado". */
    const escondido = explicarRejeicao("SPD003", "Mensagem de retorno da Sefaz não reconhecida. Cód: 853", { modelo: "nfe" });
    assert.deepEqual(escondido, direto);
  });
});

describe("inscrição estadual", () => {
  it("aponta o zero perdido pelo tamanho mínimo do estado", () => {
    assert.match(String(problemaNaInscricaoEstadual("86549219", "PE")), /pelo menos 9/);
    assert.equal(problemaNaInscricaoEstadual("086549219", "PE"), null);
    assert.equal(problemaNaInscricaoEstadual("12345678", "RJ"), null);
    assert.equal(problemaNaInscricaoEstadual("", "SP"), null);
    assert.equal(problemaNaInscricaoEstadual("123", "XX"), null);
  });
});

describe("webhook da Spedy", () => {
  const segredo = Buffer.from("segredo-do-webhook-de-teste").toString("base64");
  const corpo = JSON.stringify({ event: "invoice.status_changed", data: { id: "1" } });
  const agora = new Date("2026-09-16T12:00:00Z");
  const agoraS = Math.floor(agora.getTime() / 1000);
  const timestamp = String(agoraS);

  it("aceita assinatura certa, inclusive entre várias", () => {
    const assinatura = `v1,${assinaturaEsperada(segredo, "msg_1", timestamp, corpo)}`;
    assert.equal(webhookValido([segredo], { id: "msg_1", timestamp, assinatura }, corpo, agoraS), true);
    assert.equal(webhookValido(["outro", segredo], { id: "msg_1", timestamp, assinatura: `v1,xxx ${assinatura}` }, corpo, agoraS), true);
  });

  it("recusa corpo alterado, id trocado e horário velho", () => {
    const assinatura = `v1,${assinaturaEsperada(segredo, "msg_1", timestamp, corpo)}`;
    assert.equal(webhookValido([segredo], { id: "msg_1", timestamp, assinatura }, corpo + " ", agoraS), false);
    assert.equal(webhookValido([segredo], { id: "msg_2", timestamp, assinatura }, corpo, agoraS), false);
    const depois = agoraS + 10 * 60;
    assert.equal(webhookValido([segredo], { id: "msg_1", timestamp, assinatura }, corpo, depois), false);
  });

  it("aceita o segredo com o prefixo whsec_", () => {
    const assinatura = `v1,${assinaturaEsperada(segredo, "msg_1", timestamp, corpo)}`;
    assert.equal(webhookValido([`whsec_${segredo}`], { id: "msg_1", timestamp, assinatura }, corpo, agoraS), true);
  });
});

describe("erro da Spedy", () => {
  it("tira a mensagem de corpos diferentes", () => {
    assert.equal(mensagemDoErro({ message: "Chave inválida" }, 403), "Chave inválida");
    assert.match(mensagemDoErro({ errors: [{ message: "NCM inválido" }] }, 400), /NCM inválido/);
    assert.match(mensagemDoErro({ errors: { "items[0].ncm": ["obrigatório"] } }, 400), /obrigatório/);
  });
});
