import { urlDaSpedy, type AmbienteFiscal } from "../fiscal/ambiente.ts";

/**
 * A conversa HTTP com a Spedy, num lugar só.
 *
 * Três regras da documentação moram aqui para ninguém esquecer delas:
 *
 * - 429 é limite (5 por segundo, 100 por minuto, por chave): espera e tenta de
 *   novo, com espera crescente. Nunca na hora.
 * - 400 é definitivo: o dado está errado, repetir dá o mesmo erro.
 * - 403 é chave errada ou de outro ambiente (sandbox × produção).
 *
 * Erro de rede, timeout e 5xx só são repetidos quando a chamada é segura de
 * repetir: leitura, ou emissão com `integrationId` (a Spedy não duplica).
 */

export class ErroDaSpedy extends Error {
  status: number;
  corpo: unknown;
  /** Vale tentar de novo mais tarde (rede, limite, instabilidade). */
  transitorio: boolean;

  constructor(mensagem: string, status: number, corpo: unknown, transitorio: boolean) {
    super(mensagem);
    this.name = "ErroDaSpedy";
    this.status = status;
    this.corpo = corpo;
    this.transitorio = transitorio;
  }
}

export type OpcoesDaChamada = {
  chave: string;
  ambiente?: AmbienteFiscal;
  metodo?: "GET" | "POST" | "PUT" | "DELETE";
  corpo?: unknown;
  formulario?: FormData;
  /** Pode repetir em erro de rede/5xx sem risco de duplicar. */
  repetivel?: boolean;
  tentativas?: number;
  timeoutMs?: number;
  binario?: boolean;
};

const espera = (ms: number) => new Promise((resolver) => setTimeout(resolver, ms));

/** Tira a mensagem de um corpo de erro que não tem formato padronizado. */
export function mensagemDoErro(corpo: unknown, status: number): string {
  const textoDe = (valor: unknown): string => (typeof valor === "string" ? valor.trim() : "");
  if (typeof corpo === "string" && corpo.trim()) return corpo.trim().slice(0, 500);
  if (corpo && typeof corpo === "object") {
    const objeto = corpo as Record<string, unknown>;
    const erros = objeto.errors;
    const listados: string[] = [];
    if (Array.isArray(erros)) {
      for (const item of erros) {
        if (typeof item === "string") listados.push(item);
        else if (item && typeof item === "object") {
          const registro = item as Record<string, unknown>;
          const texto = textoDe(registro.message) || textoDe(registro.description) || textoDe(registro.detail);
          if (texto) listados.push(texto);
        }
      }
    } else if (erros && typeof erros === "object") {
      for (const [campo, valor] of Object.entries(erros as Record<string, unknown>)) {
        const mensagens = Array.isArray(valor) ? valor.filter((v) => typeof v === "string") : [textoDe(valor)];
        for (const mensagem of mensagens) if (mensagem) listados.push(`${campo}: ${mensagem}`);
      }
    }
    const principal =
      textoDe(objeto.message) || textoDe(objeto.detail) || textoDe(objeto.title) || textoDe(objeto.error) || textoDe(objeto.errorMessage);
    const juntas = [principal, ...listados].filter(Boolean);
    if (juntas.length) return Array.from(new Set(juntas)).join(" · ").slice(0, 800);
  }
  if (status === 403) return "A emissão de notas está indisponível no momento. Fale com o suporte do Ktalogu.";
  if (status === 429) return "Muitas notas ao mesmo tempo. Tente de novo em instantes.";
  if (status >= 500) return "O serviço de notas está instável agora. Tente de novo em instantes.";
  return `O serviço de notas não aceitou o pedido (erro ${status}).`;
}

export async function chamarSpedy<T>(caminho: string, opcoes: OpcoesDaChamada): Promise<T> {
  const metodo = opcoes.metodo ?? "GET";
  const repetivel = opcoes.repetivel ?? metodo === "GET";
  const maximo = Math.max(1, opcoes.tentativas ?? (repetivel ? 4 : 3));
  const url = `${urlDaSpedy(opcoes.ambiente)}${caminho}`;
  let atraso = 1000;
  let ultimoErro: ErroDaSpedy | null = null;

  for (let tentativa = 1; tentativa <= maximo; tentativa += 1) {
    const controle = new AbortController();
    const relogio = setTimeout(() => controle.abort(), opcoes.timeoutMs ?? 30000);
    try {
      const headers: Record<string, string> = { "X-Api-Key": opcoes.chave, Accept: opcoes.binario ? "*/*" : "application/json" };
      let body: BodyInit | undefined;
      if (opcoes.formulario) {
        body = opcoes.formulario;
      } else if (opcoes.corpo !== undefined) {
        headers["Content-Type"] = "application/json";
        body = JSON.stringify(opcoes.corpo);
      }

      const resposta = await fetch(url, { method: metodo, headers, body, signal: controle.signal, cache: "no-store" });

      if (resposta.ok) {
        if (opcoes.binario) return (await resposta.arrayBuffer()) as T;
        const texto = await resposta.text();
        if (!texto) return undefined as T;
        try {
          return JSON.parse(texto) as T;
        } catch {
          return texto as T;
        }
      }

      const bruto = await resposta.text().catch(() => "");
      let corpo: unknown = bruto;
      try {
        corpo = bruto ? JSON.parse(bruto) : null;
      } catch {
        corpo = bruto;
      }
      const mensagem = mensagemDoErro(corpo, resposta.status);

      if (resposta.status === 429) {
        ultimoErro = new ErroDaSpedy(mensagem, 429, corpo, true);
        /* Respeita o Retry-After quando vier (sincronização de notas recebidas). */
        const depois = Number(resposta.headers.get("retry-after"));
        if (tentativa < maximo) {
          await espera(Number.isFinite(depois) && depois > 0 ? Math.min(depois * 1000, 15000) : atraso);
          atraso *= 2;
          continue;
        }
        throw ultimoErro;
      }

      if (resposta.status >= 500) {
        ultimoErro = new ErroDaSpedy(mensagem, resposta.status, corpo, true);
        if (repetivel && tentativa < maximo) {
          await espera(atraso);
          atraso *= 2;
          continue;
        }
        throw ultimoErro;
      }

      throw new ErroDaSpedy(mensagem, resposta.status, corpo, false);
    } catch (erro) {
      if (erro instanceof ErroDaSpedy) throw erro;
      const abortado = erro instanceof Error && erro.name === "AbortError";
      ultimoErro = new ErroDaSpedy(
        abortado ? "O serviço de notas demorou para responder." : "Não foi possível falar com o serviço de notas.",
        0,
        null,
        true
      );
      if (repetivel && tentativa < maximo) {
        await espera(atraso);
        atraso *= 2;
        continue;
      }
      throw ultimoErro;
    } finally {
      clearTimeout(relogio);
    }
  }

  throw ultimoErro ?? new ErroDaSpedy("Não foi possível falar com o serviço de notas.", 0, null, true);
}
