/**
 * Ler a loja antiga: o servidor da Ktalogu busca e organiza, e o assistente
 * só pede.
 *
 * Shopify, WooCommerce e VTEX entregam o catálogo em dados, de 50 em 50, com
 * preços e variações; as outras lojas são achadas pelo mapa do site (ou pelos
 * links da página) e cada produto é lido na página dele.
 *
 * Tudo o que foi lido fica lembrado por 15 minutos. É o que faz a prévia da
 * importação e a confirmação (que refaz a prévia) verem exatamente os mesmos
 * dados, sem ler a loja de novo, e o que deixa a lista de uma página virar a
 * importação daqueles produtos sem uma segunda leitura.
 */

import zlib from "node:zlib";

import { lerJson } from "./leitores/dado.ts";
import { lerPagina, produtosDaLista, produtosDeclarados } from "./leitores/pagina.ts";
import { detectarPlataforma, type Plataforma } from "./leitores/plataforma.ts";
import { handleDoShopify, lerShopify } from "./leitores/shopify.ts";
import { lerSitemap, linksDeProduto, mapaDeProdutos, pareceProduto } from "./leitores/sitemap.ts";
import { apelidoDaVtex, lerVtex, totalDaVtex } from "./leitores/vtex.ts";
import { lerVariacoesDoWoo, lerWooCommerce, produtosComVariacaoNoWoo, type VariacaoDoWoo } from "./leitores/woocommerce.ts";
import { absoluto, chaveDeOrigem, dominioDe, nomeDoEndereco, produtoVazio, semEntidades, semRepetir, type ProdutoLido } from "./modelo.ts";
import { buscar, conferirEndereco, ErroDeRede, permiteLocal, textoDaResposta, type Resposta } from "./rede.ts";

export const POR_PAGINA = 50;
/* Há loja com a página inicial de 5 MB (o catálogo inteiro embutido no HTML). */
const LIMITE_DA_PAGINA = 8 * 1024 * 1024;
const LIMITE_DOS_DADOS = 10 * 1024 * 1024;
const TEMPO_MS = 20_000;
const LEMBRA_POR_MS = 15 * 60 * 1000;
const LEMBRANCAS = 1500;
const ENDERECOS_DO_MAPA = 5000;
const PRODUTOS_DE_UMA_VEZ = 4;
const FALHA_LEMBRADA_POR_MS = 10 * 60 * 1000;

/** Quem chama conta cada busca (o freio por empresa mora na ferramenta). */
export type Contador = () => void;

/* ---------------------------------------------------------------- memória */

type Lembranca = { ate: number; valor: unknown };
declare global {
  var __leiturasDaImportacao: Map<string, Lembranca> | undefined;
}

function memoria() {
  globalThis.__leiturasDaImportacao ??= new Map();
  return globalThis.__leiturasDaImportacao;
}

function lembrar<T>(chave: string, valor: T, porMs = LEMBRA_POR_MS): T {
  const mapa = memoria();
  if (mapa.size >= LEMBRANCAS) {
    const agora = Date.now();
    for (const [antiga, item] of mapa) if (item.ate < agora) mapa.delete(antiga);
    while (mapa.size >= LEMBRANCAS) {
      const primeira = mapa.keys().next().value;
      if (primeira === undefined) break;
      mapa.delete(primeira);
    }
  }
  mapa.set(chave, { ate: Date.now() + porMs, valor });
  return valor;
}

function lembrado<T>(chave: string): T | undefined {
  const item = memoria().get(chave);
  if (!item) return undefined;
  if (item.ate < Date.now()) {
    memoria().delete(chave);
    return undefined;
  }
  return item.valor as T;
}

const lembrarProduto = (produto: ProdutoLido) => lembrar(`produto:${chaveDeOrigem(produto.referencia)}`, produto);

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

async function buscarPagina(endereco: string, contar: Contador): Promise<Resposta> {
  contar();
  return buscar(endereco, { limite: LIMITE_DA_PAGINA, tempoMs: TEMPO_MS, aceita: "text/html,application/xhtml+xml;q=0.9,*/*;q=0.5" });
}

async function buscarDados(endereco: string, contar: Contador): Promise<{ resposta: Resposta; dado: unknown } | null> {
  contar();
  try {
    const resposta = await buscar(endereco, { limite: LIMITE_DOS_DADOS, tempoMs: TEMPO_MS, aceita: "application/json,*/*;q=0.5" });
    /* A busca da VTEX responde 206 (um pedaço da lista): é resposta boa. */
    if (resposta.status !== 200 && resposta.status !== 206) return null;
    const dado = lerJson(textoDaResposta(resposta));
    return dado === null ? null : { resposta, dado };
  } catch (erro) {
    if (erro instanceof ErroDeRede && erro.motivo === "bloqueado") throw erro;
    return null;
  }
}

async function buscarMapa(endereco: string, contar: Contador): Promise<string | null> {
  contar();
  try {
    const resposta = await buscar(endereco, { limite: LIMITE_DOS_DADOS, tempoMs: TEMPO_MS, aceita: "application/xml,text/xml,*/*;q=0.5" });
    if (resposta.status !== 200) return null;
    let corpo = resposta.corpo;
    /* sitemap.xml.gz: o arquivo em si é compactado (não a resposta). */
    if (corpo[0] === 0x1f && corpo[1] === 0x8b) corpo = zlib.gunzipSync(corpo, { maxOutputLength: LIMITE_DOS_DADOS });
    const texto = textoDaResposta({ tipo: resposta.tipo, corpo });
    return /<(urlset|sitemapindex)\b/i.test(texto) ? texto : null;
  } catch (erro) {
    if (erro instanceof ErroDeRede && erro.motivo === "bloqueado") throw erro;
    return null;
  }
}

function recusarStatus(resposta: Resposta): never {
  if (resposta.status === 404 || resposta.status === 410) throw new ErroDeRede("conexao", "Esta página não existe na loja (erro 404). Confira o endereço.");
  if (resposta.status === 401 || resposta.status === 403) {
    throw new ErroDeRede("conexao", "A loja antiga não deixou a Ktalogu ler esta página (ela bloqueia leitura automática). Leia a página com o seu navegador e mande os produtos em importar_produtos, no campo produtos.");
  }
  if (resposta.status === 429) throw new ErroDeRede("conexao", "A loja antiga pediu para ir mais devagar. Espere um minuto e continue.");
  throw new ErroDeRede("conexao", `A loja antiga respondeu com erro (${resposta.status}). Tente de novo em instantes.`);
}

async function emParalelo<T, R>(itens: T[], quantos: number, fazer: (item: T) => Promise<R>): Promise<R[]> {
  const saida: R[] = new Array(itens.length);
  let proximo = 0;
  await Promise.all(
    Array.from({ length: Math.min(quantos, itens.length) }, async () => {
      while (proximo < itens.length) {
        const indice = proximo;
        proximo += 1;
        saida[indice] = await fazer(itens[indice]);
      }
    })
  );
  return saida;
}

/* ---------------------------------------------------------------- listas */

export type Listagem = {
  modo: "lista";
  loja: string;
  plataforma: Plataforma;
  pagina: number;
  temMais: boolean;
  total: number | null;
  /* Completos: vieram com preço, foto e variação. Senão, só o endereço (a
     importação lê cada página). */
  completos: boolean;
  produtos: ProdutoLido[];
};

export type LeituraDeProduto = { modo: "produto"; loja: string; plataforma: Plataforma; produto: ProdutoLido };
export type Leitura = Listagem | LeituraDeProduto;

async function listarShopify(origem: string, pagina: number, contar: Contador): Promise<Omit<Listagem, "modo" | "plataforma" | "loja"> | null> {
  const lido = await buscarDados(`${origem}/products.json?limit=${POR_PAGINA}&page=${pagina}`, contar);
  const brutos = (lido?.dado as { products?: unknown[] } | null)?.products;
  if (!Array.isArray(brutos)) return null;
  const produtos = lerShopify(lido?.dado, origem);
  return { pagina, temMais: brutos.length >= POR_PAGINA, total: null, completos: true, produtos };
}

/**
 * Onde mora a API de loja do WooCommerce. A loja instalada numa subpasta
 * (site.com.br/loja/) tem a API lá dentro, e o WordPress anuncia o endereço
 * na página (`<link rel="https://api.w.org/">`). Só vale se for do mesmo site.
 */
export function apiDoWoo(html: string, origem: string): string {
  const anunciada =
    /<link\b[^<>]{0,300}rel=["']https:\/\/api\.w\.org\/["'][^<>]{0,300}href=["']([^"'<>]{1,500})["']/i.exec(html)?.[1] ??
    /<link\b[^<>]{0,300}href=["']([^"'<>]{1,500})["'][^<>]{0,300}rel=["']https:\/\/api\.w\.org\/["']/i.exec(html)?.[1];
  const raiz = anunciada ? absoluto(semEntidades(anunciada), origem) : null;
  const base = raiz && !raiz.includes("rest_route") && dominioDe(raiz) === dominioDe(origem) ? raiz.replace(/\/+$/, "") : `${origem}/wp-json`;
  return `${base}/wc/store/v1`;
}

async function variacoesDoWoo(api: string, origem: string, ids: string[], contar: Contador): Promise<VariacaoDoWoo[]> {
  const saida: VariacaoDoWoo[] = [];
  for (let pagina = 1; pagina <= 5; pagina += 1) {
    const pais = ids.map((id) => `parent[]=${encodeURIComponent(id)}`).join("&");
    const lido = await buscarDados(`${api}/products?type=variation&${pais}&per_page=100&page=${pagina}`, contar);
    const lista = Array.isArray(lido?.dado) ? (lido?.dado as unknown[]) : [];
    saida.push(...lerVariacoesDoWoo(lista, origem));
    if (lista.length < 100) break;
  }
  return saida;
}

async function listarWoo(api: string, origem: string, pagina: number, contar: Contador): Promise<Omit<Listagem, "modo" | "plataforma" | "loja"> | null> {
  const lido = await buscarDados(`${api}/products?per_page=${POR_PAGINA}&page=${pagina}`, contar);
  if (!lido || !Array.isArray(lido.dado)) return null;
  const ids = produtosComVariacaoNoWoo(lido.dado);
  const variacoes = ids.length ? await variacoesDoWoo(api, origem, ids, contar) : [];
  const produtos = lerWooCommerce(lido.dado, origem, variacoes);
  const paginas = Number(lido.resposta.cabecalhos["x-wp-totalpages"]) || null;
  const total = Number(lido.resposta.cabecalhos["x-wp-total"]) || null;
  return { pagina, temMais: paginas ? pagina < paginas : lido.dado.length >= POR_PAGINA, total, completos: true, produtos };
}

async function listarVtex(origem: string, pagina: number, contar: Contador): Promise<Omit<Listagem, "modo" | "plataforma" | "loja"> | null> {
  const de = (pagina - 1) * POR_PAGINA;
  const lido = await buscarDados(`${origem}/api/catalog_system/pub/products/search?_from=${de}&_to=${de + POR_PAGINA - 1}`, contar);
  if (!lido || !Array.isArray(lido.dado)) return null;
  const total = totalDaVtex(lido.resposta.cabecalhos.resources);
  return { pagina, temMais: total !== null ? de + POR_PAGINA < total : lido.dado.length >= POR_PAGINA, total, completos: true, produtos: lerVtex(lido.dado, origem) };
}

/** Os endereços de produto do mapa do site (com índice, até 8 mapas). */
async function enderecosDoMapa(origem: string, contar: Contador): Promise<string[]> {
  const guardados = lembrado<string[]>(`mapa:${origem}`);
  if (guardados) return guardados;

  const candidatos: string[] = [];
  contar();
  try {
    const robots = await buscar(`${origem}/robots.txt`, { limite: 512 * 1024, tempoMs: 10_000, aceita: "text/plain,*/*;q=0.5" });
    if (robots.status === 200) {
      /* [ \t] e não \s: \s atravessa as linhas e relê o arquivo inteiro a cada uma. */
      for (const achado of textoDaResposta(robots).matchAll(/^[ \t]*sitemap:[ \t]*(\S{1,2000})/gim)) {
        if (candidatos.length >= 20) break;
        const endereco = absoluto(achado[1], origem);
        if (endereco) candidatos.push(endereco);
      }
    }
  } catch (erro) {
    if (erro instanceof ErroDeRede && erro.motivo === "bloqueado") throw erro;
  }
  candidatos.push(`${origem}/sitemap.xml`, `${origem}/sitemap_index.xml`);

  const enderecos: string[] = [];
  const visitados = new Set<string>();
  const fila = semRepetir(candidatos);
  let lidos = 0;
  while (fila.length && lidos < 10 && enderecos.length < ENDERECOS_DO_MAPA) {
    const mapa = fila.shift() as string;
    if (visitados.has(mapa)) continue;
    visitados.add(mapa);
    const xml = await buscarMapa(mapa, contar);
    lidos += 1;
    if (!xml) continue;
    const lido = lerSitemap(xml, origem);
    fila.unshift(...lido.mapas.filter((filho) => !visitados.has(filho)).slice(0, 8));
    const soProdutos = mapaDeProdutos(mapa);
    enderecos.push(...lido.paginas.filter((pagina) => soProdutos || pareceProduto(pagina)));
  }
  return lembrar(`mapa:${origem}`, semRepetir(enderecos).slice(0, ENDERECOS_DO_MAPA));
}

async function listarPeloMapa(origem: string, pagina: number, html: string, final: string, contar: Contador): Promise<Omit<Listagem, "modo" | "plataforma" | "loja">> {
  let enderecos = await enderecosDoMapa(origem, contar);
  /* Sem mapa: os produtos que a própria página mostra. */
  if (!enderecos.length) enderecos = semRepetir([...produtosDaLista(html, final), ...linksDeProduto(html, final)]);
  const inicio = (pagina - 1) * POR_PAGINA;
  const daPagina = enderecos.slice(inicio, inicio + POR_PAGINA);
  return {
    pagina,
    temMais: enderecos.length > inicio + POR_PAGINA,
    total: enderecos.length,
    completos: false,
    produtos: daPagina.map((endereco) => lembrado<ProdutoLido>(`produto:${chaveDeOrigem(endereco)}`) ?? produtoVazio(endereco, nomeDoEndereco(endereco))),
  };
}

/* --------------------------------------------------------------- produtos */

async function lerUmProduto(referencia: string, contar: Contador): Promise<ProdutoLido> {
  const guardado = lembrado<ProdutoLido>(`produto:${chaveDeOrigem(referencia)}`);
  if (guardado && (guardado.preco !== null || guardado.fotos.length || guardado.descricao)) return guardado;

  const url = conferirEndereco(referencia, permiteLocal());
  const origem = url.origin;

  /* Loja Shopify: o produto em dados traz todas as variações. */
  const handle = handleDoShopify(referencia);
  if (handle) {
    const lido = await buscarDados(`${origem}/products/${encodeURIComponent(handle)}.json`, contar);
    const [produto] = lido ? lerShopify(lido.dado, origem) : [];
    if (produto) return lembrarProduto({ ...produto, moeda: produto.moeda ?? lembrado<string>(`moeda:${origem}`) ?? null });
  }
  /* Loja VTEX: a busca pelo apelido do produto. */
  const apelido = apelidoDaVtex(referencia);
  if (apelido) {
    const lido = await buscarDados(`${origem}/api/catalog_system/pub/products/search/${encodeURIComponent(apelido)}/p`, contar);
    const [produto] = lido ? lerVtex(lido.dado, origem) : [];
    if (produto) return lembrarProduto(produto);
  }

  const resposta = await buscarPagina(url.toString(), contar);
  if (resposta.status !== 200) recusarStatus(resposta);
  const html = textoDaResposta(resposta);

  /* Loja WooCommerce: a API de loja pelo apelido, que traz o preço de cada variação. */
  if (detectarPlataforma(html, resposta.cabecalhos) === "woocommerce") {
    const apelidoWoo = new URL(resposta.endereco).pathname.split("/").filter(Boolean).pop();
    if (apelidoWoo) {
      const api = apiDoWoo(html, origem);
      const lido = await buscarDados(`${api}/products?slug=${encodeURIComponent(apelidoWoo)}`, contar);
      if (lido && Array.isArray(lido.dado) && lido.dado.length) {
        const ids = produtosComVariacaoNoWoo(lido.dado);
        const variacoes = ids.length ? await variacoesDoWoo(api, origem, ids, contar) : [];
        const [produto] = lerWooCommerce(lido.dado, origem, variacoes);
        if (produto) return lembrarProduto({ ...produto, referencia });
      }
    }
  }

  const produto = lerPagina(html, resposta.endereco);
  if (!produto) throw new ErroDeRede("conexao", "Esta página não tem os dados de um produto (nome e preço) que a Ktalogu consiga ler.");
  return lembrarProduto({ ...produto, referencia });
}

/**
 * Lê vários produtos, de quatro em quatro. Os que falham voltam com o motivo,
 * sem derrubar os outros. Referência de outro site é recusada: a importação
 * é da loja que a pessoa indicou.
 */
export async function lerProdutos(loja: string, referencias: string[], contar: Contador): Promise<{ produtos: ProdutoLido[]; falhas: Array<{ referencia: string; motivo: string }> }> {
  const base = conferirEndereco(loja, permiteLocal());
  const dominio = dominioDe(base.toString());
  const falhas: Array<{ referencia: string; motivo: string }> = [];
  const validas: string[] = [];
  for (const bruta of semRepetir(referencias, (referencia) => chaveDeOrigem(absoluto(referencia, base.origin) ?? referencia))) {
    const referencia = absoluto(bruta, base.origin);
    if (!referencia) {
      falhas.push({ referencia: bruta, motivo: "não é um endereço" });
      continue;
    }
    if (dominioDe(referencia) !== dominio) {
      falhas.push({ referencia, motivo: `é de outro site (a loja indicada é ${dominio})` });
      continue;
    }
    validas.push(referencia);
  }

  /* A falha também é lembrada (pelo tempo do código de confirmação): a
     confirmação refaz a prévia, e um site instável não pode fazer as duas
     verem listas diferentes. */
  const lidos = await emParalelo(validas, PRODUTOS_DE_UMA_VEZ, async (referencia) => {
    const falhou = lembrado<string>(`falha:${chaveDeOrigem(referencia)}`);
    if (falhou) return { referencia, motivo: falhou };
    try {
      return { referencia, produto: await lerUmProduto(referencia, contar) };
    } catch (erro) {
      if (erro instanceof ErroDeRede) return { referencia, motivo: lembrar(`falha:${chaveDeOrigem(referencia)}`, erro.message, FALHA_LEMBRADA_POR_MS) };
      if (erro instanceof LimiteDeLeitura) throw erro;
      console.error("[importacao] falha ao ler produto:", referencia, erro instanceof Error ? erro.message : erro);
      return { referencia, motivo: "não foi possível ler esta página" };
    }
  });
  const produtos: ProdutoLido[] = [];
  for (const lido of lidos) {
    if ("produto" in lido && lido.produto) produtos.push(lido.produto);
    else falhas.push({ referencia: lido.referencia, motivo: "motivo" in lido ? (lido.motivo ?? "não lido") : "não lido" });
  }
  return { produtos, falhas };
}

/** A plataforma de uma loja já lida nos últimos minutos, para o registro da importação. */
export function plataformaLembrada(loja: string): Plataforma | null {
  try {
    return lembrado<Plataforma>(`plataforma:${new URL(loja).origin}`) ?? null;
  } catch {
    return null;
  }
}

/** Recusa do freio de leitura (passa direto pelas falhas de cada produto). */
export class LimiteDeLeitura extends Error {}

/* ------------------------------------------------------------------ loja */

/**
 * Lê o endereço que a pessoa mandou: se é a página de um produto, devolve o
 * produto; se é a loja (ou uma categoria), devolve a página `pagina` da lista.
 */
export async function lerLoja(endereco: string, pagina: number, contar: Contador): Promise<Leitura> {
  const url = conferirEndereco(endereco, permiteLocal());
  const origem = url.origin;

  /* Endereço de produto Shopify: os dados dele, direto. */
  if (handleDoShopify(url.toString())) {
    try {
      const produto = await lerUmProduto(url.toString(), contar);
      return { modo: "produto", loja: origem, plataforma: lembrar(`plataforma:${origem}`, "shopify" as Plataforma), produto };
    } catch (erro) {
      if (!(erro instanceof ErroDeRede) || erro.motivo === "bloqueado") throw erro;
    }
  }

  const resposta = await buscarPagina(url.toString(), contar);
  if (resposta.status !== 200) recusarStatus(resposta);
  const html = textoDaResposta(resposta);
  const plataforma = lembrar(`plataforma:${origem}`, detectarPlataforma(html, resposta.cabecalhos));
  const final = resposta.endereco;
  /* Loja fechada ("em breve", com senha): Shopify e Nuvemshop mandam tudo para /password. */
  if (/^\/password\/?$/i.test(new URL(final).pathname)) {
    throw new ErroDeRede(
      "conexao",
      "A loja está fechada com senha (a página de \"em breve\"). A Ktalogu só lê loja aberta ao público: peça para a pessoa abrir a loja por um momento, ou monte os produtos com o seu navegador e mande em importar_produtos, no campo produtos."
    );
  }
  /* A Shopify não diz a moeda na lista dos produtos; a página inicial diz. */
  if (plataforma === "shopify") {
    const moeda = /Shopify\.currency\s{0,5}=\s{0,5}\{[^}]{0,300}?"active"\s{0,5}:\s{0,5}"([A-Z]{3})"/.exec(html)?.[1];
    if (moeda) lembrar(`moeda:${origem}`, moeda);
  }
  const naRaiz = new URL(final).pathname.replace(/\/+$/, "") === "";

  if (!naRaiz) {
    const produto = lerPagina(html, final);
    /* Página de produto traz às vezes os "relacionados" junto (a Nuvemshop
       traz oito): endereço de produto vale mais que a contagem. */
    const ehDeProduto = pareceProduto(final) || produtosDeclarados(html) <= 1;
    if (produto && ehDeProduto && (produto.preco !== null || produto.variantes.length || pareceProduto(final))) {
      /* A página é de um produto. Nas plataformas com dados, a versão
         completa (com o preço de cada variação) vem pela leitura do produto. */
      const completo = plataforma === "woocommerce" || apelidoDaVtex(final) ? await lerUmProduto(final, contar).catch(() => produto) : produto;
      return { modo: "produto", loja: origem, plataforma, produto: lembrarProduto({ ...completo, referencia: completo.referencia || final }) };
    }
  }

  const porDados =
    plataforma === "shopify"
      ? await listarShopify(origem, pagina, contar)
      : plataforma === "woocommerce"
        ? await listarWoo(apiDoWoo(html, origem), origem, pagina, contar)
        : plataforma === "vtex"
          ? await listarVtex(origem, pagina, contar)
          : null;
  const listagem = porDados && (porDados.produtos.length || pagina > 1) ? porDados : await listarPeloMapa(origem, pagina, html, final, contar);
  const moeda = lembrado<string>(`moeda:${origem}`);
  for (const produto of listagem.produtos) {
    if (moeda && !produto.moeda) produto.moeda = moeda;
    if (listagem.completos) lembrarProduto(produto);
  }
  return { modo: "lista", loja: origem, plataforma, ...listagem };
}
