/*
  O Asaas visto da loja.

  Segundo provedor de pagamento do sistema, irmão de `mercado-pago.ts`. O
  lojista conecta a conta dele e o dinheiro cai na conta dele: não há split,
  não há fatia da Ktalogu, não há intermediação.

  TRÊS COISAS QUE O ASAAS FAZ DIFERENTE DO MERCADO PAGO, e que explicam quase
  todo o formato deste arquivo:

    1. A chave vai no cabeçalho `access_token`, não em `Authorization`. E a
       base muda com o ambiente, em vez de o ambiente viver dentro da chave.
    2. Cobrança exige cliente cadastrado. Não dá para cobrar de um e-mail
       solto: primeiro se procura o cliente pelo documento, e só se cria
       quando não existe.
    3. O QR do Pix não vem junto da cobrança. É uma segunda chamada.

  E uma que não muda: NÚMERO DE CARTÃO NÃO PASSA POR AQUI. O Asaas não tem
  tokenização no navegador como o Bricks do Mercado Pago, então a única forma
  de aceitar cartão por ele sem colocar a Ktalogu no escopo do PCI DSS é a
  fatura hospedada — o comprador digita o cartão numa página do próprio Asaas
  e volta para a loja. É o que `billingTypeDoMeio` devolve para `cartao`, e é
  por isso que não existe campo de cartão em lugar nenhum deste arquivo.
*/

type LinhaDaIntegracao = {
  id: string;
  user_id: string;
  provider: string;
  token: string | null;
  refresh_token: string | null;
  settings: Record<string, unknown> | null;
  active: boolean | null;
};

export type AmbienteAsaas = "sandbox" | "production";

/* A forma gravada em `integrations.settings`. Os nomes em inglês são os que já
   existem na integração do Mercado Pago e valem para os dois provedores; os em
   português são só do Asaas. */
export type ConfiguracaoAsaas = {
  environment: AmbienteAsaas;
  checkout_enabled: boolean;
  auto_create_order_on_approved_payment: boolean;
  bank_account_id: string | null;
  sandbox_api_key: string;
  production_api_key: string;
  aceita_pix: boolean;
  aceita_boleto: boolean;
  aceita_cartao: boolean;
  dias_para_vencimento: number;
  webhook_token: string;
};

export type CredenciaisAsaas = {
  ambiente: AmbienteAsaas;
  apiKey: string;
};

export type ConfiguracaoPublicaDoAsaas = {
  integrationId: string;
  ambiente: AmbienteAsaas;
  checkoutEnabled: boolean;
  autoCreateOrderOnApprovedPayment: boolean;
  aceitaPix: boolean;
  aceitaBoleto: boolean;
  aceitaCartao: boolean;
};

export type EstadoDoPagamento =
  | "approved"
  | "rejected"
  | "cancelled"
  | "refunded"
  | "charged_back"
  | "pending";

/* Sandbox e produção são dois mundos separados no Asaas: contas diferentes,
   dados diferentes, chaves que não se atravessam. */
export const BASE_DO_ASAAS: Record<AmbienteAsaas, string> = {
  production: "https://api.asaas.com/v3",
  sandbox: "https://api-sandbox.asaas.com/v3",
};

function texto(valor: unknown) {
  return String(valor ?? "").trim();
}

function booleano(valor: unknown, padrao: boolean) {
  if (typeof valor === "boolean") return valor;
  if (typeof valor === "string") {
    const normalizado = valor.trim().toLowerCase();
    if (normalizado === "true") return true;
    if (normalizado === "false") return false;
  }
  return padrao;
}

function objeto(valor: unknown) {
  return valor && typeof valor === "object" && !Array.isArray(valor) ? (valor as Record<string, unknown>) : null;
}

export function lerConfiguracaoDoAsaas(bruto: unknown): ConfiguracaoAsaas {
  const dados = objeto(bruto) || {};
  const dias = Number(dados.dias_para_vencimento);

  return {
    environment: dados.environment === "production" ? "production" : "sandbox",
    checkout_enabled: booleano(dados.checkout_enabled, true),
    auto_create_order_on_approved_payment: booleano(dados.auto_create_order_on_approved_payment, true),
    bank_account_id: texto(dados.bank_account_id) || null,
    sandbox_api_key: texto(dados.sandbox_api_key),
    production_api_key: texto(dados.production_api_key),
    /*
      Pix e boleto nascem ligados; cartão, não.

      Não é preferência: é que os dois primeiros acontecem dentro da loja, do
      jeito que o cliente já conhece. O cartão pelo Asaas leva o comprador para
      uma página deles e o traz de volta, e mandar alguém para fora da loja sem
      o lojista ter pedido seria decidir no lugar dele.
    */
    aceita_pix: booleano(dados.aceita_pix, true),
    aceita_boleto: booleano(dados.aceita_boleto, true),
    aceita_cartao: booleano(dados.aceita_cartao, false),
    dias_para_vencimento: Number.isFinite(dias) && dias >= 0 && dias <= 90 ? Math.trunc(dias) : 1,
    webhook_token: texto(dados.webhook_token),
  };
}

/* A credencial do ambiente escolhido, ou nada. O Asaas usa uma chave só por
   ambiente, então aqui não existe o caso de "meia credencial" que o Mercado
   Pago tem com chave pública e privada. */
export function credenciaisAtivas(config: ConfiguracaoAsaas): CredenciaisAsaas | null {
  if (config.environment === "production") {
    return config.production_api_key ? { ambiente: "production", apiKey: config.production_api_key } : null;
  }
  return config.sandbox_api_key ? { ambiente: "sandbox", apiKey: config.sandbox_api_key } : null;
}

export function configuracaoPublicaDoAsaas(
  linha: LinhaDaIntegracao | null | undefined
): ConfiguracaoPublicaDoAsaas | null {
  if (!linha || linha.active === false) return null;

  const config = lerConfiguracaoDoAsaas(linha.settings);
  if (!credenciaisAtivas(config) || !config.checkout_enabled) return null;

  return {
    integrationId: linha.id,
    ambiente: config.environment,
    checkoutEnabled: config.checkout_enabled,
    autoCreateOrderOnApprovedPayment: config.auto_create_order_on_approved_payment,
    aceitaPix: config.aceita_pix,
    aceitaBoleto: config.aceita_boleto,
    aceitaCartao: config.aceita_cartao,
  };
}

/*
  A tradução dos estados do Asaas para os seis que o banco conhece.

  Vale saber a diferença entre dois deles. `CONFIRMED` é pago com o dinheiro
  ainda indisponível na conta; `RECEIVED` é pago e disponível. Para liberar o
  pedido do cliente, confirmado basta — segurar a entrega até o dinheiro estar
  sacável seria punir quem já pagou. O estado bruto continua gravado em
  `provider_status`, então quem precisa da diferença acha na auditoria.
*/
export function estadoDoPagamento(valor: unknown): EstadoDoPagamento {
  const estado = texto(valor).toUpperCase();

  if (estado === "CONFIRMED" || estado === "RECEIVED" || estado === "RECEIVED_IN_CASH") return "approved";
  if (estado === "REFUNDED" || estado === "PARTIALLY_REFUNDED" || estado === "REFUND_REQUESTED") return "refunded";
  if (
    estado === "CHARGEBACK_REQUESTED" ||
    estado === "CHARGEBACK_DISPUTE" ||
    estado === "AWAITING_CHARGEBACK_REVERSAL"
  ) {
    return "charged_back";
  }
  if (estado === "DELETED") return "cancelled";
  if (estado === "REPROVED_BY_RISK_ANALYSIS") return "rejected";

  /* `OVERDUE` cai aqui de propósito: boleto vencido continua pagável, e tratar
     como recusado apagaria da tela um pagamento que ainda vai acontecer. */
  return "pending";
}

/* Os valores devolvidos são os que a restrição de `payment_method` em
   `catalog_order_payments` já aceita desde a migration 82. */
export function metodoDoPagamento(billingType: unknown): string {
  const tipo = texto(billingType).toUpperCase();
  if (tipo === "PIX") return "pix";
  if (tipo === "BOLETO") return "ticket";
  if (tipo === "CREDIT_CARD") return "credit_card";
  if (tipo === "DEBIT_CARD") return "debit_card";
  return "unknown";
}

/* O nome que o Asaas dá a cada meio, em `billingType`. */
export function billingTypeDoMeio(meio: "pix" | "boleto" | "cartao") {
  if (meio === "pix") return "PIX";
  if (meio === "boleto") return "BOLETO";
  return "CREDIT_CARD";
}

/*
  A linha digitável do boleto, que não vem junto da cobrança.

  Mesma história do QR do Pix: o `POST /payments` devolve o PDF e a fatura, mas
  o número que a pessoa digita no aplicativo do banco sai de outra chamada. Sem
  ele, quem não quer baixar PDF no celular fica sem caminho.

  Falhar aqui não pode derrubar a compra: o boleto já existe e o `bankSlipUrl`
  continua valendo. Por isso devolve `null` em vez de estourar.
*/
export async function linhaDigitavelDoBoleto(credenciais: CredenciaisAsaas, idDaCobranca: string) {
  try {
    const dados = await chamarAsaas<{ identificationField?: unknown; barCode?: unknown; nossoNumero?: unknown }>(
      `/payments/${encodeURIComponent(idDaCobranca)}/identificationField`,
      credenciais,
      { method: "GET" }
    );
    return {
      linhaDigitavel: texto(dados?.identificationField) || null,
      codigoDeBarras: texto(dados?.barCode) || null,
      nossoNumero: texto(dados?.nossoNumero) || null,
    };
  } catch {
    return null;
  }
}

/*
  A data de hoje no Brasil, no formato que o Asaas aceita em `dueDate`.

  Precisa ser a data de Brasília, e não a do servidor. A VPS trabalha em UTC:
  às 22h de um dia aqui já é 1h do dia seguinte lá, e uma cobrança de balcão
  nasceria vencendo amanhã sem ninguém entender por quê. O Brasil não tem
  horário de verão desde 2019, então o deslocamento é fixo.
*/
export function vencimentoNoBrasil(diasAFrente = 0, agora = new Date()) {
  const emBrasilia = new Date(agora.getTime() - 3 * 60 * 60 * 1000 + diasAFrente * 24 * 60 * 60 * 1000);
  return emBrasilia.toISOString().slice(0, 10);
}

/*
  O segredo que o Asaas devolve para nós em cada aviso.

  O Asaas exige de 32 a 255 caracteres, sem espaços e sem sequência simples.
  São 24 bytes sorteados virando 48 caracteres hexadecimais. Um por loja: o
  endereço do webhook é o mesmo para todo mundo, e é este token que diz de qual
  conta o aviso veio.
*/
export function gerarTokenDeWebhook() {
  const bytes = new Uint8Array(24);
  crypto.getRandomValues(bytes);
  return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
}

/*
  Comparação de tokens que não vaza o tamanho do acerto pelo tempo de resposta.

  Comparar com `===` sai no primeiro caractere diferente, e a diferença de
  microssegundos entre "errou na primeira letra" e "errou na última" é
  suficiente para descobrir o segredo caractere por caractere. Aqui todos os
  caracteres são sempre percorridos.
*/
export function tokensIguais(a: string, b: string) {
  const esperado = texto(a);
  const recebido = texto(b);
  if (!esperado || !recebido) return false;

  let diferenca = esperado.length ^ recebido.length;
  const limite = Math.max(esperado.length, recebido.length);
  for (let i = 0; i < limite; i += 1) {
    diferenca |= (esperado.charCodeAt(i) || 0) ^ (recebido.charCodeAt(i) || 0);
  }
  return diferenca === 0;
}

/*
  O erro do Asaas vem em `errors[]`, cada um com `code` e `description`.

  Sem desempacotar isso, o lojista lê "não foi possível falar com o Asaas"
  quando a resposta real era "CPF inválido" — e aí ele abre chamado em vez de
  corrigir o campo.
*/
export function mensagemDoErro(corpo: unknown, padrao = "Não foi possível falar com o Asaas.") {
  const erros = objeto(corpo)?.errors;
  if (Array.isArray(erros)) {
    const descricoes = erros
      .map((item) => texto(objeto(item)?.description))
      .filter(Boolean)
      .join(" ");
    if (descricoes) return descricoes;
  }
  return texto(objeto(corpo)?.message) || padrao;
}

export async function chamarAsaas<T>(
  caminho: string,
  credenciais: CredenciaisAsaas,
  init?: RequestInit
): Promise<T> {
  const headers = new Headers(init?.headers || {});
  headers.set("access_token", credenciais.apiKey);
  headers.set("Content-Type", "application/json");
  /* O Asaas pede identificação do integrador nos cabeçalhos e usa isso no
     suporte deles para achar a origem da chamada. */
  headers.set("User-Agent", "Ktalogu");

  const resposta = await fetch(`${BASE_DO_ASAAS[credenciais.ambiente]}${caminho}`, {
    ...init,
    headers,
    cache: "no-store",
  });

  const corpo = (await resposta.json().catch(() => ({}))) as T;
  if (!resposta.ok) throw new Error(mensagemDoErro(corpo));

  return corpo;
}

/*
  O instante do pagamento, e nao so o dia.

  O Asaas devolve `confirmedDate`, `paymentDate` e `clientPaymentDate` como DATA
  PURA: "2026-09-14", sem hora. Guardar isso numa coluna de instante vira
  meia-noite UTC — que no Brasil e 21h do DIA ANTERIOR. Um Pix pago no dia 14 ia
  para o fluxo de caixa como dia 13, e o fechamento do dia nunca batia.

  Sem hora de verdade, o menos errado e o meio-dia de Brasilia: fica dentro do
  dia certo em qualquer fuso que alguem use para olhar, e nao inventa precisao
  que o provedor nao deu.
*/
export function instanteDoPagamento(valor: unknown): string | null {
  const bruto = texto(valor);
  if (!bruto) return null;
  if (/^\d{4}-\d{2}-\d{2}$/.test(bruto)) return `${bruto}T15:00:00.000Z`;
  const instante = new Date(bruto);
  return Number.isNaN(instante.getTime()) ? null : instante.toISOString();
}

type QrDoPix = { payload?: unknown; encodedImage?: unknown; expirationDate?: unknown };

/*
  O QR do Pix de uma cobranca — com uma segunda tentativa que nao e paranoia.

  A PRIMEIRA COBRANCA DE UMA CONTA NOVA NASCIA SEM QR, e levou um teste real
  para descobrir por que. Conta Asaas recem-criada nao tem chave Pix. Quando se
  pede o QR, o Asaas cria a chave naquele instante — e a requisicao que provocou
  a criacao volta sem o codigo. A prova esta no relogio: a chave da conta de
  teste foi criada as 08:31:50, dois segundos depois da cobranca das 08:31:48.

  Quem pagaria o preco disso e exatamente o primeiro cliente de cada loja que
  conecta o Asaas: ele veria "Pix gerado" e nenhum codigo na tela.

  Da segunda vez a chave ja existe e o QR vem. Por isso: tenta, espera um pouco,
  tenta de novo. Em conta que ja roda, a primeira tentativa acerta e a segunda
  nunca acontece.
*/
export async function qrDoPix(credenciais: CredenciaisAsaas, idDaCobranca: string) {
  const caminho = `/payments/${encodeURIComponent(idDaCobranca)}/pixQrCode`;

  for (let tentativa = 0; tentativa < 2; tentativa += 1) {
    if (tentativa > 0) await new Promise((pronto) => setTimeout(pronto, 1200));
    try {
      const qr = await chamarAsaas<QrDoPix>(caminho, credenciais, { method: "GET" });
      const copiaECola = texto(qr?.payload);
      if (!copiaECola) continue;
      return {
        copiaECola,
        imagemBase64: texto(qr?.encodedImage) || null,
        expiraEm: texto(qr?.expirationDate) || null,
      };
    } catch {
      /* Primeira tentativa falhando e o caso esperado da conta sem chave. */
    }
  }

  return null;
}

/*
  A COBRANÇA, COM O RETORNO À LOJA SENDO OPCIONAL.

  O `callback.successUrl` é o que traz o comprador de volta depois de pagar o
  cartão na fatura do Asaas. Só que o Asaas RECUSA a cobrança inteira, com erro
  400, quando a conta do lojista não tem um site cadastrado em Minha Conta —
  e conta nova não tem. Sem tratar isso, o cartão simplesmente não funcionaria
  para quem acabou de conectar, com uma mensagem que fala de um campo que ele
  nunca viu.

  Então o retorno é tentado, e se for ele o motivo da recusa, a cobrança é
  criada sem ele. O comprador paga do mesmo jeito e fica na página do Asaas
  com o comprovante em vez de voltar sozinho; o pedido é confirmado pelo aviso
  automático, que não depende disto. Perder o retorno é um degrau a menos de
  conforto. Perder a cobrança seria perder a venda.
*/
export async function cobrancaComRetornoOpcional<T>(
  credenciais: CredenciaisAsaas,
  corpo: Record<string, unknown>,
  retorno: string | null
): Promise<T> {
  const criar = (extras: Record<string, unknown>) =>
    chamarAsaas<T>("/payments", credenciais, { method: "POST", body: JSON.stringify({ ...corpo, ...extras }) });

  if (!retorno) return criar({});

  try {
    return await criar({ callback: { successUrl: retorno, autoRedirect: true } });
  } catch (erro) {
    const motivo = erro instanceof Error ? erro.message.toLowerCase() : "";
    /* O texto vem do Asaas em português e fala de domínio e de site. Qualquer
       outra recusa — valor inválido, cliente inexistente — tem que continuar
       subindo, porque aí o problema é de verdade. */
    const eEndereco = motivo.includes("domínio") || motivo.includes("dominio") || motivo.includes("site");
    if (!eEndereco) throw erro;
    return criar({});
  }
}

type ClienteDoAsaas = { id?: unknown };
type ListaDoAsaas<T> = { data?: T[] };

/*
  O identificador do comprador dentro da conta do lojista.

  O Asaas não cobra de um e-mail solto: toda cobrança aponta para um cliente
  cadastrado. Como o mesmo comprador volta, procurar antes de criar evita
  encher a agenda do lojista de cópias da mesma pessoa. O documento é a chave
  porque é o único campo que o Asaas também trata como identidade.

  Não existe tabela nossa guardando esse vínculo de propósito: a busca por
  documento já é o índice, e um cadastro a menos é um lugar a menos para o
  dado envelhecer torto.
*/
export async function clienteDoAsaas(
  credenciais: CredenciaisAsaas,
  dados: { documento: string; nome: string; email?: string; telefone?: string; referencia?: string }
): Promise<string> {
  const documento = texto(dados.documento).replace(/\D/g, "");
  if (!documento) throw new Error("Informe um CPF ou CNPJ para cobrar.");

  const encontrados = await chamarAsaas<ListaDoAsaas<ClienteDoAsaas>>(
    `/customers?cpfCnpj=${encodeURIComponent(documento)}&limit=1`,
    credenciais
  );

  const existente = texto(encontrados?.data?.[0]?.id);
  if (existente) return existente;

  const criado = await chamarAsaas<ClienteDoAsaas>("/customers", credenciais, {
    method: "POST",
    body: JSON.stringify({
      name: texto(dados.nome) || "Cliente",
      cpfCnpj: documento,
      email: texto(dados.email) || undefined,
      mobilePhone: texto(dados.telefone).replace(/\D/g, "") || undefined,
      externalReference: texto(dados.referencia) || undefined,
    }),
  });

  const id = texto(criado?.id);
  if (!id) throw new Error("O Asaas não devolveu o cliente criado.");
  return id;
}

export async function integracaoDoAsaas(
  admin: {
    from: (table: string) => {
      select: (columns: string) => {
        eq: (column: string, value: string) => {
          eq: (column: string, value: string) => {
            maybeSingle: () => Promise<{ data: LinhaDaIntegracao | null; error: { message?: string } | null }>;
          };
        };
      };
    };
  },
  userId: string
) {
  const { data, error } = await admin
    .from("integrations")
    .select("id,user_id,provider,token,refresh_token,settings,active")
    .eq("provider", "asaas")
    .eq("user_id", userId)
    .maybeSingle();

  if (error) throw new Error(error.message || "Não foi possível carregar a integração do Asaas.");
  return data;
}
