/**
 * A página de um produto, em HTML.
 *
 * Quase toda loja publica os dados do produto para o Google num bloco
 * JSON-LD (schema.org): nome, descrição, fotos, preço, código e, nas lojas
 * mais caprichadas, cada variação (ProductGroup). É a fonte principal. A
 * Nuvemshop guarda as variações com preço num atributo `data-variants` da
 * página, lido junto. Os metadados de compartilhamento (og:) são o último
 * recurso, para a página que não tem mais nada.
 */

import {
  absoluto,
  atributosDasVariantes,
  chaveDeOrigem,
  dentroDoTeto,
  linha,
  menorPreco,
  precoDoDado,
  precoEscrito,
  produtoVazio,
  semEntidades,
  semRepetir,
  TETOS,
  type ProdutoLido,
  type VarianteLida,
} from "../modelo.ts";
import { lerJson, lista, objeto, primeiroTexto, texto, type Objeto } from "./dado.ts";

/* ------------------------------------------------------------------ JSON-LD */

/*
 * A página vem de site alheio e pode ter megas montados para travar quem lê.
 * Aqui nada relê o texto: os blocos são achados por posição (indexOf), e as
 * expressões têm teto e não atravessam "<".
 */

/** O conteúdo dos blocos <script type="application/ld+json">, achados por posição. */
function scriptsJsonLd(html: string): string[] {
  const minusculo = html.toLowerCase();
  const saida: string[] = [];
  let posicao = 0;
  while (saida.length < 40) {
    const inicio = minusculo.indexOf("<script", posicao);
    if (inicio < 0) break;
    const fimDaTag = minusculo.indexOf(">", inicio);
    if (fimDaTag < 0) break;
    const fecha = minusculo.indexOf("</script", fimDaTag);
    if (fecha < 0) break;
    if (/type\s*=\s*["']?application\/ld\+json/.test(minusculo.slice(inicio, Math.min(fimDaTag, inicio + 400)))) saida.push(html.slice(fimDaTag + 1, fecha));
    posicao = fecha + 8;
  }
  return saida;
}

/** Todos os objetos dos blocos JSON-LD da página, achatados (@graph, listas). */
export function blocosJsonLd(html: string): Objeto[] {
  const saida: Objeto[] = [];
  const visitar = (no: unknown, profundidade: number) => {
    if (profundidade > 6 || saida.length >= 2000) return;
    if (Array.isArray(no)) {
      for (const item of no) visitar(item, profundidade + 1);
      return;
    }
    const dado = objeto(no);
    if (!dado) return;
    saida.push(dado);
    if (dado["@graph"]) visitar(dado["@graph"], profundidade + 1);
    if (dado.mainEntity) visitar(dado.mainEntity, profundidade + 1);
    /* A trilha de categorias costuma vir dentro do bloco da página. */
    if (dado.breadcrumb) visitar(dado.breadcrumb, profundidade + 1);
  };
  for (const conteudo of scriptsJsonLd(html)) {
    const bruto = conteudo
      .replace(/^\s*(<!--|\/\*\s*<!\[CDATA\[\s*\*\/)/, "")
      .replace(/(-->|\/\*\s*\]\]>\s*\*\/)\s*$/, "")
      .replace(/[\u0000-\u001f]+/g, " ")
      .trim();
    const lido = lerJson(bruto) ?? lerJson(bruto.replace(/,\s*([}\]])/g, "$1"));
    if (lido) visitar(lido, 0);
  }
  return saida;
}

function tipos(dado: Objeto): string[] {
  return lista(dado["@type"]).map((tipo) => texto(tipo).toLowerCase().replace(/^https?:\/\/schema\.org\//, ""));
}

const eProduto = (dado: Objeto) => tipos(dado).some((tipo) => tipo === "product" || tipo === "individualproduct" || tipo === "productmodel");
const eGrupo = (dado: Objeto) => tipos(dado).includes("productgroup");

function fotosDe(valor: unknown, base: string): string[] {
  return lista(valor)
    .map((item) => {
      const dado = objeto(item);
      return absoluto(dado ? primeiroTexto(dado.url, dado.contentUrl, dado["@id"]) : texto(item), base);
    })
    .filter((foto): foto is string => Boolean(foto));
}

const TIPOS_DE_PRECO_ANTIGO = /(listprice|strikethroughprice|msrp|srp)$/i;

type Oferta = { preco: number | null; antigo: number | null; moeda: string | null };

function lerOferta(bruta: unknown): Oferta[] {
  const dado = objeto(bruta);
  if (!dado) return [];
  if (tipos(dado).includes("aggregateoffer")) {
    const dentro = lista(dado.offers).flatMap(lerOferta);
    const menor = precoDoDado(dado.lowPrice) ?? menorPreco(dentro.map((oferta) => oferta.preco));
    return [{ preco: menor, antigo: null, moeda: primeiroTexto(dado.priceCurrency) || null }, ...dentro];
  }
  let preco = precoDoDado(dado.price) ?? precoDoDado(dado.lowPrice);
  let antigo: number | null = null;
  let moeda = primeiroTexto(dado.priceCurrency) || null;
  for (const bruto of lista(dado.priceSpecification)) {
    const especificacao = objeto(bruto);
    if (!especificacao) continue;
    const valor = precoDoDado(especificacao.price);
    moeda ??= primeiroTexto(especificacao.priceCurrency) || null;
    if (TIPOS_DE_PRECO_ANTIGO.test(texto(especificacao.priceType))) antigo = valor;
    else preco ??= valor;
  }
  return [{ preco, antigo: antigo && preco && antigo > preco ? antigo : null, moeda }];
}

function ofertasDe(produto: Objeto): Oferta[] {
  return lista(produto.offers).flatMap(lerOferta);
}

/* As propriedades de variação do schema.org, com o nome que a pessoa lê. */
const PROPRIEDADES: Record<string, string> = {
  color: "Cor",
  size: "Tamanho",
  material: "Material",
  pattern: "Estampa",
  suggestedgender: "Gênero",
  suggestedage: "Idade",
};

function nomeDaPropriedade(bruto: string): string {
  const chave = bruto.toLowerCase().replace(/^https?:\/\/schema\.org\//, "");
  return PROPRIEDADES[chave] ?? linha(bruto, 60);
}

function varianteDe(produto: Objeto, chaves: string[], base: string): VarianteLida {
  const opcoes: Record<string, string> = {};
  for (const chave of chaves) {
    const campo = chave.toLowerCase().replace(/^https?:\/\/schema\.org\//, "");
    const valorDireto = Object.entries(produto).find(([nome]) => nome.toLowerCase() === campo)?.[1];
    const valor = linha(objeto(valorDireto)?.name ?? valorDireto, 80);
    if (valor) opcoes[nomeDaPropriedade(chave)] = valor;
  }
  for (const bruto of lista(produto.additionalProperty)) {
    const propriedade = objeto(bruto);
    const nome = linha(propriedade?.name, 60);
    const valor = linha(propriedade?.value, 80);
    if (nome && valor && !opcoes[nome]) opcoes[nome] = valor;
  }
  const ofertas = ofertasDe(produto);
  const preco = menorPreco(ofertas.map((oferta) => oferta.preco));
  return {
    opcoes,
    preco,
    precoAntigo: ofertas.find((oferta) => oferta.preco === preco)?.antigo ?? null,
    foto: fotosDe(produto.image, base)[0] ?? null,
    codigo: primeiroTexto(produto.sku) || null,
  };
}

function categoriasDe(blocos: Objeto[], nomeDoProduto: string, categoria: unknown): string[] {
  const trilha = blocos.find((bloco) => tipos(bloco).includes("breadcrumblist"));
  if (trilha) {
    const nomes = lista(trilha.itemListElement)
      .map((item) => objeto(item))
      .filter((item): item is Objeto => Boolean(item))
      .sort((a, b) => Number(a.position ?? 0) - Number(b.position ?? 0))
      .map((item) => linha(objeto(item.item)?.name ?? item.name, 120))
      .filter((nome) => nome && !/^(home|in[ií]cio|p[aá]gina inicial|loja|principal)$/i.test(nome))
      .filter((nome) => nome.toLowerCase() !== nomeDoProduto.toLowerCase());
    if (nomes.length) return nomes;
  }
  const escrita = linha(objeto(categoria)?.name ?? categoria, 300);
  return escrita
    .split(/\s*(?:>|\/|»|›)\s*/)
    .map((parte) => linha(parte, 120))
    .filter(Boolean);
}

function pesoEmKg(valor: unknown): number | null {
  const dado = objeto(valor);
  const numero = precoDoDado(dado?.value ?? valor);
  if (numero === null || numero <= 0) return null;
  const unidade = texto(dado?.unitCode ?? dado?.unitText).toUpperCase();
  if (unidade === "GRM" || unidade === "G") return Math.round(numero) / 1000;
  return numero;
}

function produtoDoJsonLd(blocos: Objeto[], endereco: string): ProdutoLido | null {
  /* Página de produto costuma trazer os "relacionados" junto: o principal é o
     que aponta para esta página; sem isso, o primeiro. */
  const destaPagina = (bloco: Objeto) => {
    const alvo = chaveDeOrigem(endereco);
    return [bloco.url, bloco["@id"], ...lista(bloco.offers).map((oferta) => objeto(oferta)?.url)].some((valor) => {
      const enderecoDoBloco = absoluto(texto(valor), endereco);
      return enderecoDoBloco ? chaveDeOrigem(enderecoDoBloco) === alvo : false;
    });
  };
  const produtos = blocos.filter(eProduto);
  let grupo = blocos.filter(eGrupo).find(destaPagina) ?? blocos.find(eGrupo) ?? null;
  const solto = produtos.find(destaPagina) ?? produtos[0] ?? null;
  if (!grupo && solto) {
    const pai = objeto(solto.isVariantOf);
    if (pai && eGrupo(pai)) grupo = pai;
  }
  const principal = grupo ?? solto;
  if (!principal) return null;

  const nome = linha(principal.name ?? solto?.name, 300);
  if (!nome) return null;
  const lido = produtoVazio(endereco, nome);
  /* No dado, a descrição é texto puro, e há loja que a manda com os
     acentos codificados (às vezes duas vezes: "&amp;#128175;"). */
  const descricao = primeiroTexto(principal.description, solto?.description);
  lido.descricao = /<[a-z][\s\S]*>/i.test(descricao) ? descricao : semEntidades(semEntidades(descricao));
  lido.codigo = primeiroTexto(principal.sku, principal.productGroupID, principal.mpn, principal.productID) || null;
  lido.codigoDeBarras = primeiroTexto(principal.gtin13, principal.gtin, principal.gtin14, principal.gtin12, principal.gtin8) || null;
  lido.marca = linha(objeto(principal.brand)?.name ?? principal.brand, 120) || null;
  lido.categorias = categoriasDe(blocos, nome, principal.category);
  lido.pesoKg = pesoEmKg(principal.weight);

  const variantesDoGrupo = grupo
    ? lista(grupo.hasVariant)
        .slice(0, TETOS.variantes)
        .map((variante) => objeto(variante))
        .filter((variante): variante is Objeto => Boolean(variante))
    : [];
  const chaves = grupo ? lista(grupo.variesBy).map((chave) => texto(chave)).filter(Boolean) : [];
  const variantes = variantesDoGrupo.map((variante) => varianteDe(variante, chaves, endereco)).filter((variante) => Object.keys(variante.opcoes).length > 0);

  lido.fotos = semRepetir([
    ...fotosDe(principal.image, endereco),
    ...(solto && solto !== principal ? fotosDe(solto.image, endereco) : []),
    ...variantes.map((variante) => variante.foto).filter((foto): foto is string => Boolean(foto)),
  ]);

  const ofertas = [...ofertasDe(principal), ...(solto && solto !== principal ? ofertasDe(solto) : [])];
  lido.preco = menorPreco([...ofertas.map((oferta) => oferta.preco), ...variantes.map((variante) => variante.preco)]);
  lido.precoAntigo = ofertas.find((oferta) => oferta.preco === lido.preco)?.antigo ?? variantes.find((variante) => variante.preco === lido.preco)?.precoAntigo ?? null;
  lido.moeda = ofertas.find((oferta) => oferta.moeda)?.moeda ?? null;

  if (variantes.length > 1) {
    lido.variantes = variantes;
    lido.atributos = atributosDasVariantes(variantes);
  }
  return lido;
}

/* ---------------------------------------------------------------- Nuvemshop */

/* Os nomes das escolhas da Nuvemshop moram nos rótulos dos seletores
   (`<label for="variation_1">Cor</label>`); sem eles, "Opção 1". */
function nomesDasOpcoesDaNuvemshop(html: string): string[] {
  const nomes: string[] = [];
  for (const achado of html.matchAll(/<label\b[^<>]{0,300}\bfor\s*=\s*["']variation_(\d)["'][^<>]{0,300}>([^<]{1,120})</gi)) {
    const indice = Number(achado[1]) - 1;
    const nome = linha(achado[2], 60).replace(/:$/, "");
    if (indice >= 0 && indice < 3 && nome && !nomes[indice]) nomes[indice] = nome;
    if (nomes.filter(Boolean).length === 3) break;
  }
  return [0, 1, 2].map((indice) => nomes[indice] || `Opção ${indice + 1}`);
}

/** O valor do primeiro atributo data-variants da página (o do produto principal), achado por posição. */
function atributoDataVariants(html: string): string | null {
  const achado = /\bdata-variants\s*=\s*["']/i.exec(html);
  if (!achado) return null;
  const aspa = achado[0][achado[0].length - 1];
  const inicio = achado.index + achado[0].length;
  const fim = html.indexOf(aspa, inicio);
  if (fim < 0 || fim - inicio > 2_000_000) return null;
  return html.slice(inicio, fim);
}

export function variantesDaNuvemshop(html: string, base: string): VarianteLida[] {
  const bruto = atributoDataVariants(html);
  if (!bruto) return [];
  const dado = lerJson(semEntidades(bruto));
  const nomes = nomesDasOpcoesDaNuvemshop(html);
  return lista(dado)
    .slice(0, TETOS.variantes)
    .map((bruta) => objeto(bruta))
    .filter((variante): variante is Objeto => Boolean(variante))
    .map((variante) => {
      const opcoes: Record<string, string> = {};
      ["option0", "option1", "option2"].forEach((campo, indice) => {
        const valor = linha(variante[campo], 80);
        if (valor) opcoes[nomes[indice]] = valor;
      });
      const preco = precoDoDado(variante.price_number) ?? precoEscrito(variante.price_short);
      const antigo = precoDoDado(variante.compare_at_price_number) ?? precoEscrito(variante.compare_at_price_short);
      return {
        opcoes,
        preco: preco && preco > 0 ? preco : null,
        precoAntigo: antigo && preco && antigo > preco ? antigo : null,
        foto: absoluto(variante.image_url, base),
        codigo: primeiroTexto(variante.sku) || null,
      };
    });
}

/* ------------------------------------------------------------------ og: */

/** Os metadados da página (og:, product:, itemprop), por nome. Repetidos viram lista. */
export function metadados(html: string): Map<string, string[]> {
  const mapa = new Map<string, string[]>();
  let quantos = 0;
  for (const achado of html.matchAll(/<meta\b[^<>]{0,3000}>/gi)) {
    const marca = achado[0];
    const nome = /\b(?:property|name|itemprop)\s*=\s*["']([^"'<>]{1,100})["']/i.exec(marca)?.[1]?.toLowerCase();
    const conteudo = /\bcontent\s*=\s*(?:"([^"]{0,2000})"|'([^']{0,2000})')/i.exec(marca);
    if (!nome || !conteudo) continue;
    const valores = mapa.get(nome) ?? [];
    if (valores.length < 20) mapa.set(nome, [...valores, semEntidades(conteudo[1] ?? conteudo[2] ?? "")]);
    if (++quantos > 2000) break;
  }
  return mapa;
}

/** O <title> da página, achado sem atravessar outra tag. */
function tituloDaPagina(html: string): string {
  return /<title\b[^<>]{0,200}>([^<]{0,500})</i.exec(html)?.[1] ?? "";
}

function produtoDosMetadados(html: string, endereco: string): ProdutoLido | null {
  const meta = metadados(html);
  const um = (nome: string) => meta.get(nome)?.[0] ?? "";
  const tipo = um("og:type").toLowerCase();
  const preco = precoDoDado(um("product:price:amount") || um("og:price:amount") || um("price")) ?? precoEscrito(um("product:sale_price:amount"));
  if (!tipo.startsWith("product") && preco === null) return null;
  const nome = linha(um("og:title") || tituloDaPagina(html), 300);
  if (!nome) return null;
  const lido = produtoVazio(endereco, nome);
  lido.descricao = um("og:description") || um("description");
  lido.fotos = semRepetir([...(meta.get("og:image") ?? []), ...(meta.get("og:image:secure_url") ?? [])].map((foto) => absoluto(foto, endereco)).filter((foto): foto is string => Boolean(foto)));
  lido.preco = preco && preco > 0 ? preco : null;
  const antigo = precoDoDado(um("product:original_price:amount"));
  lido.precoAntigo = antigo && lido.preco && antigo > lido.preco ? antigo : null;
  lido.moeda = um("product:price:currency") || um("og:price:currency") || null;
  return lido;
}

/* ------------------------------------------------------------------ página */

/**
 * O produto da página, ou null quando ela não é de um produto. `endereco` é o
 * endereço final da página (depois dos redirecionamentos).
 */
export function lerPagina(html: string, endereco: string): ProdutoLido | null {
  const blocos = blocosJsonLd(html);
  const lido = produtoDoJsonLd(blocos, endereco) ?? produtoDosMetadados(html, endereco);
  const daNuvemshop = variantesDaNuvemshop(html, endereco);
  if (!lido) return null;

  if (daNuvemshop.length) {
    const comOpcoes = daNuvemshop.filter((variante) => Object.keys(variante.opcoes).length > 0);
    if (comOpcoes.length > 1) {
      lido.variantes = comOpcoes;
      lido.atributos = atributosDasVariantes(comOpcoes);
    }
    const menor = menorPreco(daNuvemshop.map((variante) => variante.preco));
    if (menor !== null) {
      lido.preco = menor;
      lido.precoAntigo = daNuvemshop.find((variante) => variante.preco === menor)?.precoAntigo ?? lido.precoAntigo;
    }
    lido.fotos = semRepetir([...lido.fotos, ...daNuvemshop.map((variante) => variante.foto).filter((foto): foto is string => Boolean(foto))]);
  }

  /* Página sem foto no dado: as do og: completam. */
  if (!lido.fotos.length) {
    lido.fotos = semRepetir((metadados(html).get("og:image") ?? []).map((foto) => absoluto(foto, endereco)).filter((foto): foto is string => Boolean(foto)));
  }
  return dentroDoTeto(lido);
}

/** Quantos produtos a página declara no JSON-LD (página de categoria declara vários). */
export function produtosDeclarados(html: string): number {
  return blocosJsonLd(html).filter((bloco) => eProduto(bloco) || eGrupo(bloco)).length;
}

/** Os endereços de produto que uma página de lista anuncia no JSON-LD (ItemList). */
export function produtosDaLista(html: string, base: string): string[] {
  const enderecos: string[] = [];
  for (const bloco of blocosJsonLd(html)) {
    if (!tipos(bloco).includes("itemlist")) continue;
    for (const bruto of lista(bloco.itemListElement)) {
      const item = objeto(bruto);
      const alvo = objeto(item?.item);
      const endereco = absoluto(primeiroTexto(item?.url, alvo?.url, alvo?.["@id"], typeof item?.item === "string" ? item.item : ""), base);
      if (endereco) enderecos.push(endereco);
    }
  }
  return semRepetir(enderecos);
}
