/**
 * Buscar página e foto de outro site, para a importação de produtos.
 *
 * Quem escolhe o endereço é o assistente, e o assistente lê páginas da
 * internet que podem tentar dar ordens a ele. Então este servidor nunca vai a
 * um endereço de dentro (ele mesmo, a rede da hospedagem, o serviço de
 * metadados da nuvem), nem quando o nome parece de fora e aponta para dentro:
 * o IP é conferido depois de resolvido, na hora de conectar, e a conexão é
 * feita nesse mesmo IP. Um nome que troca de IP entre a conferência e a
 * conexão não passa, porque não há uma segunda consulta. Cada
 * redirecionamento é conferido de novo.
 *
 * Só http e https, só nas portas de sempre, sem usuário e senha no endereço,
 * com limite de tamanho (contado depois de descompactar) e de tempo.
 *
 * Na máquina de quem programa, `ASSISTENTES_IMPORTACAO_LOCAL=1` deixa buscar
 * no próprio computador (127.0.0.1), para o teste com uma loja de mentira.
 * Em produção isso nunca vale: a variável é ignorada.
 */

import { lookup, type LookupAddress } from "node:dns";
import http from "node:http";
import https from "node:https";
import { BlockList, isIP } from "node:net";
import zlib from "node:zlib";

export type MotivoDaFalha = "endereco" | "bloqueado" | "nao_existe" | "tempo" | "grande" | "conexao" | "certificado" | "redirecionamentos";

export class ErroDeRede extends Error {
  readonly motivo: MotivoDaFalha;
  constructor(motivo: MotivoDaFalha, mensagem: string) {
    super(mensagem);
    this.motivo = motivo;
  }
}

export type Resposta = {
  status: number;
  /* O content-type como veio, em minúsculas. */
  tipo: string;
  corpo: Buffer;
  /* Onde a resposta veio de fato, depois dos redirecionamentos. */
  endereco: string;
  cabecalhos: Record<string, string>;
};

export type Pedido = {
  /* Bytes, depois de descompactar. */
  limite: number;
  tempoMs: number;
  aceita: string;
};

const SALTOS = 5;
const AGENTE = "Mozilla/5.0 (compatible; KtaloguImportador/1.0; +https://ktalogu.com.br)";

/* ---------------------------------------------------------------- endereços */

/* O que um IP de fora nunca é. IPv4 explícito; no IPv6 só vale o espaço global
   (2000::/3), tirando os pedaços dele que também não são de ninguém. Endereço
   IPv4 escrito como IPv6 (::ffff:127.0.0.1) cai nas regras do IPv4. */
const PROIBIDOS = new BlockList();
for (const [rede, prefixo] of [
  ["0.0.0.0", 8],
  ["10.0.0.0", 8],
  ["100.64.0.0", 10],
  ["127.0.0.0", 8],
  ["169.254.0.0", 16],
  ["172.16.0.0", 12],
  ["192.0.0.0", 24],
  ["192.0.2.0", 24],
  ["192.88.99.0", 24],
  ["192.168.0.0", 16],
  ["198.18.0.0", 15],
  ["198.51.100.0", 24],
  ["203.0.113.0", 24],
  ["224.0.0.0", 4],
  ["240.0.0.0", 4],
] as const) {
  PROIBIDOS.addSubnet(rede, prefixo, "ipv4");
}
for (const [rede, prefixo] of [
  ["2001::", 23],
  ["2001:db8::", 32],
  ["2002::", 16],
  ["3fff::", 20],
] as const) {
  PROIBIDOS.addSubnet(rede, prefixo, "ipv6");
}
const GLOBAL_V6 = new BlockList();
GLOBAL_V6.addSubnet("2000::", 3, "ipv6");

const LOCAL = new BlockList();
LOCAL.addSubnet("127.0.0.0", 8, "ipv4");
LOCAL.addAddress("::1", "ipv6");

/** Só na máquina de quem programa, e só com a variável ligada. */
export function permiteLocal(ambiente: Record<string, string | undefined> = process.env) {
  return ambiente.NODE_ENV !== "production" && ambiente.ASSISTENTES_IMPORTACAO_LOCAL === "1";
}

/** O IP é de dentro (ou de ninguém)? `permitirLocal` libera só o próprio computador. */
export function ipProibido(ip: string, permitirLocal = false): boolean {
  const limpo = ip.replace(/^\[|\]$/g, "").replace(/%.*$/, "");
  const versao = isIP(limpo);
  if (versao === 0) return true;
  if (permitirLocal && LOCAL.check(limpo, versao === 4 ? "ipv4" : "ipv6")) return false;
  if (versao === 4) return PROIBIDOS.check(limpo, "ipv4");
  if (PROIBIDOS.check(limpo, "ipv6")) return true;
  return !GLOBAL_V6.check(limpo, "ipv6");
}

const NOMES_DE_DENTRO = /(^|\.)(localhost|local|internal|intranet|lan|home|corp|localdomain|home\.arpa)$/;

/**
 * O endereço pode ser buscado? Devolve a URL conferida, ou recusa com o motivo.
 * Nome sem ponto, nome de rede interna e IP escrito direto ficam de fora: loja
 * mora em domínio.
 */
export function conferirEndereco(bruto: string, permitirLocal = false): URL {
  let url: URL;
  try {
    url = new URL(String(bruto).trim());
  } catch {
    throw new ErroDeRede("endereco", "Este endereço não é um link válido. Mande o endereço completo, começando com https://.");
  }
  if (url.protocol !== "https:" && url.protocol !== "http:") {
    throw new ErroDeRede("endereco", "Só dá para ler endereços que começam com http:// ou https://.");
  }
  if (url.username || url.password) {
    throw new ErroDeRede("endereco", "O endereço não pode ter usuário e senha.");
  }
  const host = url.hostname.toLowerCase().replace(/\.$/, "").replace(/^\[|\]$/g, "");
  /* A loja de mentira do teste roda numa porta qualquer do próprio computador. */
  const ehLocal = host === "localhost" || (isIP(host) !== 0 && LOCAL.check(host, isIP(host) === 4 ? "ipv4" : "ipv6"));
  if (permitirLocal && ehLocal) return url;
  if (isIP(host) !== 0) {
    throw new ErroDeRede("bloqueado", "Mande o endereço pelo nome do site (como www.loja.com.br), e não pelo número do IP.");
  }
  if (!host || !host.includes(".") || NOMES_DE_DENTRO.test(host)) {
    throw new ErroDeRede("bloqueado", "Este endereço é de uma rede interna, e a Ktalogu não busca nada lá.");
  }
  const portaPadrao = url.protocol === "https:" ? "443" : "80";
  if (url.port && url.port !== portaPadrao) {
    throw new ErroDeRede("endereco", "Só dá para ler sites nas portas de sempre (sem :porta no endereço).");
  }
  return url;
}

/* A consulta de nome que confere o IP antes de conectar. Recusa o nome
   inteiro se QUALQUER IP dele for de dentro: escolher só os bons deixaria o
   dono do nome decidir para onde a gente vai. */
type Retorno = (erro: NodeJS.ErrnoException | null, endereco?: string | LookupAddress[], familia?: number) => void;

export function consultaSegura(permitirLocal: boolean) {
  return (nome: string, opcoes: { all?: boolean; family?: number | string }, retorno: Retorno) => {
    lookup(nome, { all: true, verbatim: true, family: typeof opcoes.family === "number" ? opcoes.family : 0 }, (erro, enderecos) => {
      if (erro) {
        retorno(erro);
        return;
      }
      const lista = (enderecos as LookupAddress[]) ?? [];
      if (!lista.length || lista.some((item) => ipProibido(item.address, permitirLocal))) {
        const recusa = new Error("endereço de rede interna") as NodeJS.ErrnoException;
        recusa.code = "EKTALOGUBLOQUEADO";
        retorno(recusa);
        return;
      }
      if (opcoes.all) retorno(null, lista);
      else retorno(null, lista[0].address, lista[0].family);
    });
  };
}

/* ------------------------------------------------------------------ buscar */

function traduzir(erro: unknown): ErroDeRede {
  if (erro instanceof ErroDeRede) return erro;
  const codigo = (erro as NodeJS.ErrnoException)?.code ?? "";
  if (codigo === "EKTALOGUBLOQUEADO") return new ErroDeRede("bloqueado", "Este endereço aponta para uma rede interna, e a Ktalogu não busca nada lá.");
  if (codigo === "ENOTFOUND" || codigo === "EAI_AGAIN" || codigo === "ENODATA") return new ErroDeRede("nao_existe", "Este site não foi encontrado. Confira se o endereço está escrito certo.");
  if (/CERT|SSL|TLS/i.test(codigo) || codigo === "DEPTH_ZERO_SELF_SIGNED_CERT" || codigo === "ERR_TLS_CERT_ALTNAME_INVALID") {
    return new ErroDeRede("certificado", "O certificado de segurança deste site não confere, e a Ktalogu não lê site assim.");
  }
  /* O código técnico fica no registro do servidor; a pessoa lê a frase. */
  console.warn("[importacao] falha de rede:", codigo || (erro instanceof Error ? erro.message : String(erro)).slice(0, 160));
  return new ErroDeRede("conexao", "O site não respondeu. Tente de novo em instantes, ou confira o endereço.");
}

type Volta = Resposta & { proximo?: string };

function umPedido(url: URL, pedido: Pedido, ate: number, permitirLocal: boolean): Promise<Volta> {
  return new Promise((resolver, rejeitar) => {
    const restante = ate - Date.now();
    if (restante <= 0) {
      rejeitar(new ErroDeRede("tempo", "O site demorou demais para responder."));
      return;
    }
    let acabou = false;
    const terminar = (erro: ErroDeRede | null, volta?: Volta) => {
      if (acabou) return;
      acabou = true;
      clearTimeout(relogio);
      if (erro) rejeitar(erro);
      else resolver(volta as Volta);
    };

    const modulo = url.protocol === "https:" ? https : http;
    const requisicao = modulo.request(url, {
      method: "GET",
      agent: false,
      lookup: consultaSegura(permitirLocal) as unknown as http.RequestOptions["lookup"],
      headers: {
        "user-agent": AGENTE,
        accept: pedido.aceita,
        "accept-language": "pt-BR,pt;q=0.9,en;q=0.6",
        "accept-encoding": "gzip, deflate, br",
      },
    });
    const relogio = setTimeout(() => {
      requisicao.destroy();
      terminar(new ErroDeRede("tempo", "O site demorou demais para responder."));
    }, restante);

    requisicao.on("error", (erro) => terminar(traduzir(erro)));
    requisicao.on("response", (resposta) => {
      const status = resposta.statusCode ?? 0;
      const cabecalhos: Record<string, string> = {};
      for (const [nome, valor] of Object.entries(resposta.headers)) {
        if (valor !== undefined) cabecalhos[nome.toLowerCase()] = Array.isArray(valor) ? valor.join(", ") : String(valor);
      }
      const tipo = (cabecalhos["content-type"] ?? "").toLowerCase();

      if (status >= 300 && status < 400 && cabecalhos.location) {
        resposta.resume();
        terminar(null, { status, tipo, corpo: Buffer.alloc(0), endereco: url.toString(), cabecalhos, proximo: cabecalhos.location });
        return;
      }

      const declarado = Number(cabecalhos["content-length"] || 0);
      if (declarado > pedido.limite) {
        resposta.destroy();
        terminar(new ErroDeRede("grande", "O arquivo é grande demais para ser trazido."));
        return;
      }

      const codificacao = (cabecalhos["content-encoding"] ?? "").trim().toLowerCase();
      let fluxo: NodeJS.ReadableStream = resposta;
      if (codificacao === "gzip" || codificacao === "x-gzip") fluxo = resposta.pipe(zlib.createGunzip());
      else if (codificacao === "deflate") fluxo = resposta.pipe(zlib.createInflate());
      else if (codificacao === "br") fluxo = resposta.pipe(zlib.createBrotliDecompress());

      const pedacos: Buffer[] = [];
      let total = 0;
      fluxo.on("data", (pedaco: Buffer) => {
        if (acabou) return;
        total += pedaco.length;
        if (total > pedido.limite) {
          resposta.destroy();
          (fluxo as unknown as { destroy?: () => void }).destroy?.();
          terminar(new ErroDeRede("grande", "O arquivo é grande demais para ser trazido."));
          return;
        }
        pedacos.push(pedaco);
      });
      fluxo.on("end", () => terminar(null, { status, tipo, corpo: Buffer.concat(pedacos), endereco: url.toString(), cabecalhos }));
      fluxo.on("error", () => terminar(new ErroDeRede("conexao", "A resposta do site veio quebrada.")));
      resposta.on("error", () => terminar(new ErroDeRede("conexao", "O site cortou a resposta no meio.")));
    });
    requisicao.end();
  });
}

/**
 * Busca um endereço de fora, seguindo até 5 redirecionamentos (cada um
 * conferido). Devolve a resposta final, seja qual for o status: quem chama
 * decide o que fazer com um 404.
 */
export async function buscar(endereco: string, pedido: Pedido, permitirLocal = permiteLocal()): Promise<Resposta> {
  const ate = Date.now() + pedido.tempoMs;
  let atual = endereco;
  for (let salto = 0; salto <= SALTOS; salto += 1) {
    const url = conferirEndereco(atual, permitirLocal);
    const volta = await umPedido(url, pedido, ate, permitirLocal);
    if (!volta.proximo) {
      const { proximo: _semProximo, ...resposta } = volta;
      void _semProximo;
      return resposta;
    }
    try {
      atual = new URL(volta.proximo, url).toString();
    } catch {
      throw new ErroDeRede("endereco", "O site mandou para um endereço inválido.");
    }
  }
  throw new ErroDeRede("redirecionamentos", "O site fica mandando de um endereço para outro, sem chegar a uma página.");
}

/* ------------------------------------------------------------------- texto */

function charsetDe(tipo: string, inicio: string): string {
  const doCabecalho = /charset=["']?([\w-]+)/i.exec(tipo)?.[1];
  if (doCabecalho) return doCabecalho.toLowerCase();
  const daPagina = /<meta[^>]+charset=["']?([\w-]+)/i.exec(inicio)?.[1];
  return (daPagina ?? "utf-8").toLowerCase();
}

/** O corpo como texto, na codificação que o site declarou (muito site antigo é latin1). */
export function textoDaResposta(resposta: Pick<Resposta, "tipo" | "corpo">): string {
  const charset = charsetDe(resposta.tipo, resposta.corpo.subarray(0, 4096).toString("latin1"));
  const nome = charset === "iso-8859-1" || charset === "latin1" ? "windows-1252" : charset;
  try {
    return new TextDecoder(nome).decode(resposta.corpo);
  } catch {
    return new TextDecoder("utf-8").decode(resposta.corpo);
  }
}
