/**
 * O produto como ele sai de qualquer loja antiga.
 *
 * Cada tipo de loja (Shopify, WooCommerce, VTEX, Nuvemshop, uma página com
 * dados para o Google) escreve o produto de um jeito. Os leitores traduzem
 * todos para este formato, e só ele chega à importação. Ainda não é o formato
 * da Ktalogu: preço de variação, foto e categoria são acertados depois, num
 * lugar só.
 *
 * Só regra, sem rede e sem banco, para os testes rodarem sozinhos.
 */

export type VarianteLida = {
  /* { "Tamanho": "P", "Cor": "Azul" } */
  opcoes: Record<string, string>;
  preco: number | null;
  precoAntigo?: number | null;
  foto?: string | null;
  codigo?: string | null;
};

export type AtributoLido = { nome: string; valores: string[] };

export type ProdutoLido = {
  /* Como achar o produto de novo na loja antiga: o endereço da página dele. */
  referencia: string;
  nome: string;
  /* HTML como veio; a limpeza é feita na importação. */
  descricao: string;
  /* O menor preço de venda. null = a loja não mostra preço. */
  preco: number | null;
  /* O "de" do "de/por", quando existe. */
  precoAntigo: number | null;
  moeda: string | null;
  fotos: string[];
  /* Do mais geral ao mais específico: ["Camisetas", "Masculinas"]. */
  categorias: string[];
  codigo: string | null;
  codigoDeBarras: string | null;
  marca: string | null;
  pesoKg: number | null;
  atributos: AtributoLido[];
  variantes: VarianteLida[];
};

export function produtoVazio(referencia: string, nome: string): ProdutoLido {
  return {
    referencia,
    nome,
    descricao: "",
    preco: null,
    precoAntigo: null,
    moeda: null,
    fotos: [],
    categorias: [],
    codigo: null,
    codigoDeBarras: null,
    marca: null,
    pesoKg: null,
    atributos: [],
    variantes: [],
  };
}

/* ------------------------------------------------------------------ preços */

const arredondar = (valor: number) => Math.round(valor * 100) / 100;

/**
 * Preço escrito em dado (JSON, schema.org): ponto é a casa decimal, sem
 * separador de milhar. "59.90", 59.9, "1234.5".
 */
export function precoDoDado(valor: unknown): number | null {
  if (typeof valor === "number") return Number.isFinite(valor) && valor >= 0 ? arredondar(valor) : null;
  if (typeof valor !== "string") return null;
  const limpo = valor.trim().replace(/[^\d.,-]/g, "");
  if (!limpo) return null;
  /* Há loja que escreve "59,90" até no dado: vírgula sozinha é decimal. */
  const normalizado = limpo.includes(",") && !limpo.includes(".") ? limpo.replace(",", ".") : limpo.replace(/,/g, "");
  const numero = Number(normalizado);
  return Number.isFinite(numero) && numero >= 0 ? arredondar(numero) : null;
}

/**
 * Preço escrito para gente ler: "R$ 1.234,56", "1.234,56", "59,90", e o
 * formato de fora, "1,234.56". O último separador com uma ou duas casas
 * depois dele é o decimal.
 */
export function precoEscrito(texto: unknown): number | null {
  if (typeof texto === "number") return precoDoDado(texto);
  if (typeof texto !== "string") return null;
  const numero = /\d[\d.,\s]*/.exec(texto.replace(/ /g, " "))?.[0]?.replace(/\s/g, "") ?? "";
  if (!numero) return null;
  const decimal = /[.,](\d{1,2})$/.exec(numero);
  const inteiro = (decimal ? numero.slice(0, -decimal[0].length) : numero).replace(/[.,]/g, "");
  const valor = Number(`${inteiro}.${decimal?.[1] ?? "0"}`);
  return Number.isFinite(valor) ? arredondar(valor) : null;
}

/** Preço em centavos, como a API de lojas do WooCommerce entrega ("1990", 2 casas). */
export function precoEmCentavos(valor: unknown, casas: unknown): number | null {
  const bruto = typeof valor === "number" ? valor : typeof valor === "string" && /^\d+$/.test(valor.trim()) ? Number(valor) : NaN;
  if (!Number.isFinite(bruto)) return null;
  const divisor = 10 ** (Number.isInteger(Number(casas)) ? Number(casas) : 2);
  return arredondar(bruto / divisor);
}

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

/** Endereço absoluto e limpo, ou null. "//cdn.x/foto.jpg" vira https. */
export function absoluto(valor: unknown, base: string): string | null {
  if (typeof valor !== "string") return null;
  const bruto = valor.trim();
  if (!bruto || bruto.startsWith("data:")) return null;
  try {
    const url = new URL(bruto.startsWith("//") ? `https:${bruto}` : bruto, base);
    if (url.protocol !== "https:" && url.protocol !== "http:") return null;
    url.hash = "";
    return url.toString();
  } catch {
    return null;
  }
}

/**
 * A chave que diz se dois endereços são o mesmo produto: sem protocolo, sem
 * www, sem barra no fim, sem as marcações de campanha. É o que impede trazer
 * o mesmo produto duas vezes.
 */
export function chaveDeOrigem(endereco: string): string {
  try {
    const url = new URL(endereco);
    for (const nome of [...url.searchParams.keys()]) {
      if (/^(utm_|gclid|fbclid|ref$|srsltid|_pos|_sid|_ss|variant$)/i.test(nome)) url.searchParams.delete(nome);
    }
    url.searchParams.sort();
    const busca = url.searchParams.toString();
    const caminho = decodeURI(url.pathname).replace(/\/+$/, "").toLowerCase();
    /* A porta só aparece quando não é a padrão (aí ela faz parte do endereço). */
    const porta = url.port ? `:${url.port}` : "";
    return `${url.hostname.toLowerCase().replace(/^www\./, "")}${porta}${caminho}${busca ? `?${busca}` : ""}`.slice(0, 2000);
  } catch {
    return endereco.trim().toLowerCase().slice(0, 2000);
  }
}

/** Só o domínio, para mostrar: "lojaantiga.com.br". */
export function dominioDe(endereco: string): string {
  try {
    return new URL(endereco).hostname.toLowerCase().replace(/^www\./, "");
  } catch {
    return endereco;
  }
}

/* ------------------------------------------------------------------ textos */

const ENTIDADES: Record<string, string> = {
  amp: "&",
  lt: "<",
  gt: ">",
  quot: '"',
  apos: "'",
  nbsp: " ",
  ndash: "–",
  mdash: "—",
  hellip: "…",
  laquo: "«",
  raquo: "»",
  rsquo: "’",
  lsquo: "‘",
  rdquo: "”",
  ldquo: "“",
  ordm: "º",
  ordf: "ª",
  deg: "°",
  reg: "®",
  copy: "©",
  trade: "™",
  times: "×",
};

/* As letras acentuadas do português (e as que aparecem junto), nas duas caixas. */
for (const [nome, letra] of Object.entries({
  aacute: "á",
  agrave: "à",
  acirc: "â",
  atilde: "ã",
  auml: "ä",
  eacute: "é",
  egrave: "è",
  ecirc: "ê",
  iacute: "í",
  icirc: "î",
  oacute: "ó",
  ocirc: "ô",
  otilde: "õ",
  ouml: "ö",
  uacute: "ú",
  ucirc: "û",
  uuml: "ü",
  ccedil: "ç",
  ntilde: "ñ",
})) {
  ENTIDADES[nome] = letra;
  ENTIDADES[nome[0].toUpperCase() + nome.slice(1)] = letra.toLocaleUpperCase("pt-BR");
}

/** Troca &amp;, &#8211; e companhia pelo caractere. */
export function semEntidades(texto: string): string {
  return texto.replace(/&(#x[0-9a-f]{1,6}|#\d{1,7}|[a-z]{2,10});/gi, (inteiro, nome: string) => {
    if (nome[0] === "#") {
      const codigo = nome[1] === "x" || nome[1] === "X" ? parseInt(nome.slice(2), 16) : parseInt(nome.slice(1), 10);
      return Number.isFinite(codigo) && codigo > 0 && codigo < 0x110000 ? String.fromCodePoint(codigo) : inteiro;
    }
    return ENTIDADES[nome] ?? ENTIDADES[nome.toLowerCase()] ?? inteiro;
  });
}

/*
 * Texto que vem de site alheio pode ter megas e ser montado para travar quem
 * lê. Toda expressão daqui para baixo tem teto e não atravessa "<": nenhuma
 * relê o texto, e o tempo cresce junto com o tamanho, nunca ao quadrado.
 */

/** Texto de uma linha só: sem tags, sem entidades, espaços juntos. */
export function linha(valor: unknown, maximo = 300): string {
  if (typeof valor !== "string" && typeof valor !== "number") return "";
  /* Corta antes de mexer: só o começo importa. */
  const bruto = String(valor).slice(0, maximo * 4 + 400);
  return semEntidades(bruto.replace(/<[^<>]{0,500}>/g, " ").replace(/<[^<>]{0,500}$/, " "))
    .replace(/\s+/g, " ")
    .trim()
    .slice(0, maximo);
}

/** O HTML sem os blocos de um tipo (script, style), achados por posição. */
function semBlocos(html: string, nome: string): string {
  const minusculo = html.toLowerCase();
  const pedacos: string[] = [];
  let posicao = 0;
  for (;;) {
    const inicio = minusculo.indexOf(`<${nome}`, posicao);
    if (inicio < 0) break;
    pedacos.push(html.slice(posicao, inicio), " ");
    const fim = minusculo.indexOf(`</${nome}`, inicio);
    const fecha = fim < 0 ? -1 : minusculo.indexOf(">", fim);
    posicao = fecha < 0 ? html.length : fecha + 1;
  }
  pedacos.push(html.slice(posicao));
  return pedacos.join("");
}

/** O texto corrido de um HTML, para resumo. */
export function textoCorrido(html: string, maximo = 600): string {
  const bruto = semBlocos(semBlocos(html.slice(0, 200_000), "script"), "style");
  const texto = semEntidades(bruto.replace(/<(?:br|\/p|\/div|\/li|\/h\d)\b[^<>]{0,200}>/gi, "\n").replace(/<[^<>]{0,2000}>/g, " "))
    .replace(/[ \t]+/g, " ")
    .replace(/ ?\n[ \n]*/g, "\n")
    .trim();
  return texto.length > maximo ? `${texto.slice(0, maximo).trimEnd()}…` : texto;
}

/*
 * O que um produto lido pode ter, no máximo. Ninguém vende cinco mil
 * variações de uma camiseta, e um site montado de propósito não enche a
 * memória do servidor (o que foi lido fica lembrado por 15 minutos).
 */
export const TETOS = { descricao: 100_000, fotos: 60, variantes: 1000, atributos: 10, valores: 200, categorias: 6 } as const;

export function dentroDoTeto(produto: ProdutoLido): ProdutoLido {
  return {
    ...produto,
    descricao: produto.descricao.slice(0, TETOS.descricao),
    fotos: produto.fotos.slice(0, TETOS.fotos),
    categorias: produto.categorias.slice(0, TETOS.categorias),
    atributos: produto.atributos.slice(0, TETOS.atributos).map((atributo) => ({ ...atributo, valores: atributo.valores.slice(0, TETOS.valores) })),
    variantes: produto.variantes.slice(0, TETOS.variantes),
  };
}

/** Nome a partir do endereço, quando a loja só entrega o link: "caneca-azul-325ml" vira "Caneca azul 325ml". */
export function nomeDoEndereco(endereco: string): string {
  try {
    const partes = new URL(endereco).pathname.split("/").filter(Boolean);
    const ultima = partes.filter((parte) => !/^(p|produto|produtos|product|products)$/i.test(parte)).pop() ?? "";
    const nome = decodeURIComponent(ultima).replace(/\.(html?|php|aspx?)$/i, "").replace(/[-_]+/g, " ").trim();
    return nome ? nome[0].toLocaleUpperCase("pt-BR") + nome.slice(1) : endereco;
  } catch {
    return endereco;
  }
}

/** Lista sem repetidos, na ordem em que apareceu. */
export function semRepetir<T>(lista: Iterable<T>, chave: (item: T) => string = (item) => String(item)): T[] {
  const vistos = new Set<string>();
  const saida: T[] = [];
  for (const item of lista) {
    const marca = chave(item);
    if (!marca || vistos.has(marca)) continue;
    vistos.add(marca);
    saida.push(item);
  }
  return saida;
}

/** O menor preço maior que zero de uma lista, ou null. */
export function menorPreco(valores: Array<number | null | undefined>): number | null {
  const validos = valores.filter((valor): valor is number => typeof valor === "number" && Number.isFinite(valor) && valor > 0);
  return validos.length ? Math.min(...validos) : null;
}

/** Os atributos que as variantes usam, na ordem em que aparecem. */
export function atributosDasVariantes(variantes: VarianteLida[]): AtributoLido[] {
  const mapa = new Map<string, string[]>();
  for (const variante of variantes) {
    for (const [nome, valor] of Object.entries(variante.opcoes)) {
      if (!nome || !valor) continue;
      const valores = mapa.get(nome) ?? [];
      if (!valores.includes(valor)) valores.push(valor);
      mapa.set(nome, valores);
    }
  }
  return [...mapa].map(([nome, valores]) => ({ nome, valores }));
}
