/**
 * O catálogo da XBZ, lido e arrumado para a Ktalogu.
 *
 * A XBZ tem duas listas de produtos. A que a integração usava
 * (`ProdutosListar?busca=`) devolve nome, foto e códigos, sem preço e sem
 * estoque, e cada busca gastava uma das 24 consultas do dia. A outra
 * (`GetListaDeProdutos`, a primeira da documentação) devolve o catálogo
 * inteiro de uma vez (11.949 itens em outubro de 2026) com o preço que a XBZ
 * cobra da gráfica, o estoque total e o do depósito principal, peso, medidas,
 * NCM, a cor e a data prevista de reposição. Uma consulta traz tudo, e a
 * busca passa a ser feita aqui, sem gastar consulta.
 *
 * Cada item da XBZ é um modelo numa cor: o código amigável (06520) é o
 * modelo, e o composto (06520-AZU) é o modelo na cor. Aqui as cores do mesmo
 * modelo viram um produto só, com a cor como variação.
 *
 * O que os dados da XBZ ensinaram (outubro de 2026):
 * - peso em gramas e medidas em centímetros (caneta: 10 g, 14 x 3,6 cm);
 *   zero quer dizer "não informado", e há medida absurda (caneta de 1.303
 *   cm), por isso o teto antes de virar medida de frete;
 * - estoque pode vir negativo (197 itens): para quem compra, é zero;
 * - 866 NCMs vêm com a letra O no lugar do zero ("420292OO");
 * - "0001-01-01" na reposição quer dizer "sem previsão";
 * - a cor da XBZ é grossa: azul claro e azul escuro são os dois "AZUL". O
 *   fim do código composto (AZC, AZE) é mais fino, e é dele que sai o nome.
 *
 * Sem dependência do resto do sistema: roda nos testes (`node --test`).
 */

export type CorDoCatalogo = {
  codigoXbz: string;
  codigoComposto: string;
  /* O código amigável: o modelo, igual em todas as cores. */
  modelo: string;
  idProduto: number | null;
  nome: string;
  descricao: string;
  siteLink: string | null;
  imagem: string | null;
  /* O nome da cor pronto para a loja ("Azul claro", "Prata e azul"). */
  cor: string;
  corPrincipal: string | null;
  corSecundaria: string | null;
  /* O que a XBZ cobra da gráfica pela unidade: é o custo de quem revende. */
  preco: number | null;
  estoque: number;
  estoquePrincipal: number;
  pesoG: number | null;
  alturaCm: number | null;
  larguraCm: number | null;
  profundidadeCm: number | null;
  ncm: string | null;
  /* "2026-11-16", ou null quando a XBZ não tem previsão. */
  reposicaoPrevista: string | null;
  confiabilidade: string | null;
  estoqueConfiavel: boolean | null;
  pontaDeEstoque: boolean;
  /* A regra de compra que a XBZ escreve na descrição ("OBS.: PEDIDOS MÍNIMO
     DE 50 PEÇAS!", "PEDIDO APENAS MÚLTIPLO DE 50 PEÇAS!"). */
  pedidoMinimo: number | null;
  multiploDe: number | null;
};

export type ModeloDoCatalogo = {
  codigo: string;
  nome: string;
  descricao: string;
  imagem: string | null;
  siteLink: string | null;
  ncm: string | null;
  precoMinimo: number | null;
  precoMaximo: number | null;
  estoque: number;
  estoquePrincipal: number;
  pontaDeEstoque: boolean;
  /* A reposição mais próxima entre as cores que têm previsão. */
  reposicaoPrevista: string | null;
  pedidoMinimo: number | null;
  multiploDe: number | null;
  cores: CorDoCatalogo[];
};

/* --------------------------------------------------------------- leitura */

type Campos = Map<string, unknown>;

/* A lista nova vem em PascalCase ("PrecoVenda") e a antiga em camelCase
   ("codigoXbz"): o nome do campo é lido sem olhar maiúscula. */
function camposDe(bruto: Record<string, unknown>): Campos {
  const campos: Campos = new Map();
  for (const [chave, valor] of Object.entries(bruto)) campos.set(chave.toLowerCase(), valor);
  return campos;
}

function texto(campos: Campos, chave: string): string {
  const valor = campos.get(chave);
  if (typeof valor === "string") return valor.replace(/\s+/g, " ").trim();
  if (typeof valor === "number" && Number.isFinite(valor)) return String(valor);
  return "";
}

export function numeroDe(valor: unknown): number | null {
  if (typeof valor === "number") return Number.isFinite(valor) ? valor : null;
  if (typeof valor !== "string") return null;
  const limpo = valor.trim();
  if (!limpo) return null;
  /* "12,56" ou "1.234,56": a vírgula é a casa decimal. */
  const normal = limpo.includes(",") ? limpo.replace(/\./g, "").replace(",", ".") : limpo;
  const numero = Number(normal);
  return Number.isFinite(numero) ? numero : null;
}

function numero(campos: Campos, chave: string): number | null {
  return numeroDe(campos.get(chave));
}

function positivo(valor: number | null): number | null {
  return valor !== null && valor > 0 ? valor : null;
}

function inteiroSemNegativo(valor: number | null): number {
  return valor !== null && valor > 0 ? Math.floor(valor) : 0;
}

function endereco(valor: string): string | null {
  if (!valor) return null;
  const comProtocolo = valor.startsWith("//") ? `https:${valor}` : valor;
  return /^https?:\/\/[^\s/]+\.[^\s/]+/i.test(comProtocolo) ? comProtocolo : null;
}

/** "420292OO" vira "42029200"; o que não fecha 8 dígitos fica de fora. */
export function normalizarNcm(valor: unknown): string | null {
  if (typeof valor !== "string" && typeof valor !== "number") return null;
  const limpo = String(valor).toUpperCase().replace(/O/g, "0").replace(/[\s.-]/g, "");
  return /^\d{8}$/.test(limpo) ? limpo : null;
}

/** "0001-01-01T00:00:00" é "sem previsão". */
export function dataDeReposicao(valor: unknown): string | null {
  if (typeof valor !== "string") return null;
  const partes = /^(\d{4})-(\d{2})-(\d{2})/.exec(valor.trim());
  if (!partes || Number(partes[1]) < 2000) return null;
  return `${partes[1]}-${partes[2]}-${partes[3]}`;
}

/* As expressões abaixo leem texto de fora: nenhuma tem repetição dentro de
   repetição nem espaço opcional encostado em outro, e o texto chega com os
   espaços já juntados. */
const PEDIDO_MINIMO = /PEDIDOS? (?:APENAS |SOMENTE )?MINIMOS? (?:E MULTIPLOS? )?(?:DE )?(\d{1,6})/;
const MULTIPLO = /MULTIPLOS? DE (\d{1,6})/;

/**
 * A regra de compra da XBZ escrita na descrição: 803 itens trazem "OBS.:
 * PEDIDOS MÍNIMO DE 50 PEÇAS!" ou "PEDIDO APENAS MÚLTIPLO DE 50 PEÇAS!".
 * Quem só aceita múltiplo de 50 também não vende menos de 50.
 */
export function regraDeQuantidade(descricao: string): { minimo: number | null; multiplo: number | null } {
  const normal = semAcento(String(descricao ?? "").slice(0, 5000)).toUpperCase().replace(/\s+/g, " ");
  const minimo = Number(PEDIDO_MINIMO.exec(normal)?.[1] ?? 0) || null;
  const multiplo = Number(MULTIPLO.exec(normal)?.[1] ?? 0) || null;
  return { minimo: minimo ?? multiplo, multiplo };
}

const OBS_DE_PEDIDO =
  /\bOBS(?:ERVA[ÇçCc][ÃãAa]O)?[ .:]{0,4}PEDIDOS? (?:APENAS |SOMENTE )?(?:M[ÍíIi]NIMOS?(?: E M[ÚúUu]LTIPLOS?)?|M[ÚúUu]LTIPLOS?)(?: DE)? \d{1,6}(?: PE[ÇçCc]AS)?[!.]?/gi;
const ENDERECO_NO_TEXTO = /\b(?:https?:\/\/|www\.)[^\s<>"]{1,300}/gi;

/**
 * A descrição que vai para a loja: sem a regra de compra (ela vira a
 * quantidade mínima do produto) e sem endereço de site (três itens da XBZ
 * têm como descrição só o link do produto no site dela).
 */
export function descricaoParaLoja(descricao: string): string {
  return String(descricao ?? "")
    .slice(0, 5000)
    .replace(/\s+/g, " ")
    .replace(OBS_DE_PEDIDO, " ")
    .replace(ENDERECO_NO_TEXTO, " ")
    .replace(/\s+/g, " ")
    .replace(/\s+([.,;!])/g, "$1")
    .trim();
}

/* ------------------------------------------------------------------ cores */

/* O fim do código composto, conferido contra a cor que a XBZ informa em cada
   item do catálogo de outubro de 2026. Só entra sigla sem dúvida; a que não
   está aqui usa a cor da XBZ ("AZUL"). */
const SIGLAS_DE_COR: Record<string, string> = {
  PRE: "Preto",
  BCO: "Branco",
  AZU: "Azul",
  VM: "Vermelho",
  VD: "Verde",
  PRA: "Prata",
  CIN: "Cinza",
  ROS: "Rosa",
  LAR: "Laranja",
  ROX: "Roxo",
  AZE: "Azul escuro",
  AZC: "Azul claro",
  AMA: "Amarelo",
  TRA: "Transparente",
  BEG: "Bege",
  MAR: "Marrom",
  COB: "Cobre",
  INO: "Inox",
  DOU: "Dourado",
  MAD: "Madeira",
  RSC: "Rosa claro",
  RSE: "Rosa escuro",
  BMB: "Bambu",
  CHU: "Chumbo",
  VDE: "Verde escuro",
  VDC: "Verde claro",
  KRA: "Kraft",
  CRE: "Creme",
  VIN: "Vinho",
  CZE: "Cinza escuro",
  CZC: "Cinza claro",
  COL: "Colorido",
  PNK: "Pink",
  FUM: "Fumê",
  LIL: "Lilás",
  CRO: "Cromado",
  BRO: "Bronze",
  TUR: "Turquesa",
  AZM: "Azul-marinho",
  MAC: "Marrom claro",
  MAE: "Marrom escuro",
  RXC: "Roxo claro",
  RXE: "Roxo escuro",
  CAF: "Café",
  GRA: "Grafite",
};

/* Acabamento, e não cor: "PRE/FOS" é preto fosco, e não "preto e fosco". */
const ACABAMENTOS: Record<string, string> = { FOS: "fosco" };

function corDaXbz(valor: string | null): string | null {
  if (!valor) return null;
  const limpo = valor.trim().toLowerCase();
  return limpo ? limpo.charAt(0).toUpperCase() + limpo.slice(1) : null;
}

function juntarCores(partes: string[]): string {
  const unicas = partes.filter((parte, indice) => partes.findIndex((outra) => outra.toLowerCase() === parte.toLowerCase()) === indice);
  if (unicas.length <= 1) return unicas[0] ?? "";
  const [primeira, ...resto] = unicas;
  const minusculas = resto.map((parte) => parte.toLowerCase());
  if (minusculas.length === 1) return `${primeira} e ${minusculas[0]}`;
  return `${primeira}, ${minusculas.slice(0, -1).join(", ")} e ${minusculas[minusculas.length - 1]}`;
}

/** O fim do código composto: "06520-AZU" → "AZU"; "00518-PRA/AZU" → "PRA/AZU". */
export function sufixoDaCor(codigoComposto: string, modelo: string): string {
  if (modelo && codigoComposto.startsWith(`${modelo}-`)) return codigoComposto.slice(modelo.length + 1);
  const traco = codigoComposto.indexOf("-");
  return traco >= 0 ? codigoComposto.slice(traco + 1) : "";
}

/** O nome da cor, pelo código composto; sem sigla conhecida, pela cor da XBZ. */
export function rotuloDaCor(codigoComposto: string, modelo: string, corPrincipal: string | null, corSecundaria: string | null): string {
  const siglas = sufixoDaCor(codigoComposto, modelo)
    .split("/")
    .map((sigla) => sigla.trim().toUpperCase())
    .filter(Boolean);

  if (siglas.length && siglas.every((sigla) => SIGLAS_DE_COR[sigla] || ACABAMENTOS[sigla])) {
    const cores = siglas.filter((sigla) => SIGLAS_DE_COR[sigla]).map((sigla) => SIGLAS_DE_COR[sigla]);
    const acabamento = siglas.filter((sigla) => ACABAMENTOS[sigla]).map((sigla) => ACABAMENTOS[sigla]);
    const nome = juntarCores(cores);
    if (nome) return acabamento.length ? `${nome} ${acabamento.join(" ")}` : nome;
  }
  /* Capacidade de pen drive e afins ("8GB") é o próprio nome da opção. */
  if (siglas.length === 1 && /^\d+\s?(GB|MB|TB)$/.test(siglas[0])) return siglas[0];

  const daXbz = juntarCores([corDaXbz(corPrincipal), corDaXbz(corSecundaria)].filter((cor): cor is string => Boolean(cor)));
  if (daXbz) return daXbz;
  return siglas.join("/") || codigoComposto || "Única";
}

/* --------------------------------------------------------------- um item */

/** Um item da lista da XBZ (a nova ou a antiga). null = sem código, não serve. */
export function lerItemDoCatalogo(bruto: unknown): CorDoCatalogo | null {
  if (!bruto || typeof bruto !== "object" || Array.isArray(bruto)) return null;
  const campos = camposDe(bruto as Record<string, unknown>);
  const codigoXbz = texto(campos, "codigoxbz");
  if (!codigoXbz) return null;
  const codigoComposto = texto(campos, "codigocomposto") || codigoXbz;
  const modelo = texto(campos, "codigoamigavel") || codigoComposto.split("-")[0] || codigoXbz;
  const corPrincipal = texto(campos, "corwebprincipal") || null;
  const corSecundaria = texto(campos, "corwebsecundaria") || null;
  const idStatus = numero(campos, "idstatusconfiabilidade");
  const webTipo = texto(campos, "webtipo").toUpperCase();
  const descricao = texto(campos, "descricao");
  const regra = regraDeQuantidade(descricao);

  return {
    codigoXbz,
    codigoComposto,
    modelo,
    idProduto: numero(campos, "idproduto") ?? numero(campos, "id"),
    nome: texto(campos, "nome"),
    descricao,
    siteLink: endereco(texto(campos, "sitelink")),
    imagem: endereco(texto(campos, "imagelink") || texto(campos, "image") || texto(campos, "imagem")),
    cor: rotuloDaCor(codigoComposto, modelo, corPrincipal, corSecundaria),
    corPrincipal,
    corSecundaria,
    preco: positivo(numero(campos, "precovenda")),
    estoque: inteiroSemNegativo(numero(campos, "quantidadedisponivel") ?? numero(campos, "estoquedisponivel")),
    estoquePrincipal: inteiroSemNegativo(numero(campos, "quantidadedisponivelestoqueprincipal")),
    pesoG: positivo(numero(campos, "peso")),
    alturaCm: positivo(numero(campos, "altura")),
    larguraCm: positivo(numero(campos, "largura")),
    profundidadeCm: positivo(numero(campos, "profundidade")),
    ncm: normalizarNcm(campos.get("ncm")),
    reposicaoPrevista: dataDeReposicao(campos.get("reposicaodataprevista")),
    confiabilidade: texto(campos, "statusconfiabilidade") || null,
    estoqueConfiavel: idStatus === null ? null : idStatus === 10,
    pontaDeEstoque: campos.get("pontadeestoque") === true || webTipo.includes("PONTA DE ESTOQUE"),
    pedidoMinimo: regra.minimo,
    multiploDe: regra.multiplo,
  };
}

/* ---------------------------------------------------------------- modelos */

function maisComum(valores: string[]): string {
  const contagem = new Map<string, number>();
  for (const valor of valores) if (valor) contagem.set(valor, (contagem.get(valor) ?? 0) + 1);
  let melhor = "";
  let vezes = 0;
  for (const [valor, quantas] of contagem) {
    if (quantas > vezes) {
      melhor = valor;
      vezes = quantas;
    }
  }
  return melhor;
}

const porNome = (a: CorDoCatalogo, b: CorDoCatalogo) => a.cor.localeCompare(b.cor, "pt-BR");

function maiorOuNulo(valores: Array<number | null>): number | null {
  const validos = valores.filter((valor): valor is number => valor !== null && valor > 0);
  return validos.length ? Math.max(...validos) : null;
}

/**
 * Junta as cores de cada modelo. Duas cores com o mesmo nome no mesmo modelo
 * (a XBZ tem "AZ1" e "AZ2", os dois azuis) ganham o código no rótulo, para
 * a loja nunca mostrar duas opções iguais.
 */
export function agruparPorModelo(cores: CorDoCatalogo[]): ModeloDoCatalogo[] {
  const porModelo = new Map<string, CorDoCatalogo[]>();
  for (const cor of cores) {
    const lista = porModelo.get(cor.modelo);
    if (lista) {
      if (!lista.some((outra) => outra.codigoXbz === cor.codigoXbz)) lista.push(cor);
    } else porModelo.set(cor.modelo, [cor]);
  }

  const modelos: ModeloDoCatalogo[] = [];
  for (const [codigo, doModelo] of porModelo) {
    const vezes = new Map<string, number>();
    for (const cor of doModelo) vezes.set(cor.cor.toLowerCase(), (vezes.get(cor.cor.toLowerCase()) ?? 0) + 1);
    const arrumadas = doModelo
      .map((cor) => {
        if ((vezes.get(cor.cor.toLowerCase()) ?? 0) < 2) return cor;
        const sufixo = sufixoDaCor(cor.codigoComposto, cor.modelo) || cor.codigoXbz;
        return { ...cor, cor: `${cor.cor} (${sufixo})` };
      })
      .sort(porNome);

    const precos = arrumadas.map((cor) => cor.preco).filter((preco): preco is number => preco !== null);
    const reposicoes = arrumadas
      .map((cor) => cor.reposicaoPrevista)
      .filter((data): data is string => Boolean(data))
      .sort();
    const comFoto = [...arrumadas].sort((a, b) => b.estoque - a.estoque).find((cor) => cor.imagem);

    modelos.push({
      codigo,
      nome: maisComum(arrumadas.map((cor) => cor.nome)) || codigo,
      descricao: maisComum(arrumadas.map((cor) => cor.descricao)),
      imagem: comFoto?.imagem ?? null,
      siteLink: arrumadas.find((cor) => cor.siteLink)?.siteLink ?? null,
      ncm: maisComum(arrumadas.map((cor) => cor.ncm ?? "")) || null,
      precoMinimo: precos.length ? Math.min(...precos) : null,
      precoMaximo: precos.length ? Math.max(...precos) : null,
      estoque: arrumadas.reduce((soma, cor) => soma + cor.estoque, 0),
      estoquePrincipal: arrumadas.reduce((soma, cor) => soma + cor.estoquePrincipal, 0),
      pontaDeEstoque: arrumadas.some((cor) => cor.pontaDeEstoque),
      reposicaoPrevista: reposicoes[0] ?? null,
      pedidoMinimo: maiorOuNulo(arrumadas.map((cor) => cor.pedidoMinimo)),
      multiploDe: maiorOuNulo(arrumadas.map((cor) => cor.multiploDe)),
      cores: arrumadas,
    });
  }
  return modelos;
}

/* ------------------------------------------------------------------ busca */

export function semAcento(valor: string): string {
  return valor
    .normalize("NFD")
    .replace(/[̀-ͯ]/g, "")
    .toLowerCase();
}

/** O texto em que a busca procura: nome, cor, códigos e descrição, sem acento. */
export function textoParaBusca(cor: CorDoCatalogo): string {
  return semAcento(
    [cor.nome, cor.cor, cor.corPrincipal, cor.corSecundaria, cor.modelo, cor.codigoComposto, cor.codigoXbz, cor.descricao]
      .filter(Boolean)
      .join(" ")
  )
    .replace(/\s+/g, " ")
    .slice(0, 1500);
}

/** As palavras da busca, sem acento e sem os curingas do banco (% e _). */
export function palavrasDaBusca(busca: string): string[] {
  const palavras = semAcento(String(busca ?? "").slice(0, 200))
    .replace(/[%_*\\(),]/g, " ")
    .split(/\s+/)
    .map((palavra) => palavra.trim())
    .filter((palavra) => palavra.length >= 2 || /\d/.test(palavra));
  return [...new Set(palavras)].slice(0, 6);
}

/**
 * Ordena os modelos achados: código igual ao digitado primeiro, depois o
 * nome que tem todas as palavras, e então quem tem mais estoque.
 */
export function ordenarAchados(modelos: ModeloDoCatalogo[], palavras: string[]): ModeloDoCatalogo[] {
  const nota = (modelo: ModeloDoCatalogo) => {
    let pontos = 0;
    const codigos = [modelo.codigo, ...modelo.cores.flatMap((cor) => [cor.codigoXbz, cor.codigoComposto])].map(semAcento);
    if (palavras.some((palavra) => codigos.includes(palavra))) pontos += 10;
    const nome = semAcento(modelo.nome);
    if (palavras.every((palavra) => nome.includes(palavra))) pontos += 3;
    if (palavras.length && nome.startsWith(palavras[0])) pontos += 1;
    return pontos;
  };
  return modelos
    .map((modelo) => ({ modelo, pontos: nota(modelo) }))
    .sort((a, b) => b.pontos - a.pontos || b.modelo.estoque - a.modelo.estoque)
    .map(({ modelo }) => modelo);
}

/* ------------------------------------------------------------------- nome */

const MINUSCULAS = new Set(["a", "o", "as", "os", "e", "de", "da", "do", "das", "dos", "com", "sem", "em", "na", "no", "nas", "nos", "para", "por", "ou", "c/", "s/", "p/"]);
const SIGLAS = new Set(["USB", "LED", "PVC", "RFID", "MDF", "EVA", "TNT", "PET", "PP", "ABS", "LCD", "UV", "PU", "QR", "NFC", "A3", "A4", "A5", "A6", "3D", "RGB", "HD", "TV", "PC", "BT", "OTG", "AAA"]);

function palavraParaLoja(palavra: string, primeira: boolean): string {
  const maiuscula = palavra.toUpperCase();
  if (SIGLAS.has(maiuscula)) return maiuscula;
  if (/^\d+([.,]\d+)?(GB|MB|TB|W|V)$/.test(maiuscula)) return maiuscula;
  if (/^\d+([.,]\d+)?MAH$/.test(maiuscula)) return maiuscula.replace("MAH", "mAh");
  if (/^\d+([.,]\d+)?L$/.test(maiuscula)) return maiuscula;
  /* "400ML" e "ML" sozinho viram minúscula; "G" e "M" sozinhos são tamanho de roupa e ficam. */
  if (/^\d+([.,]\d+)?(ML|CM|MM|KG|G|M)$/.test(maiuscula) || /^(ML|CM|MM|KG)$/.test(maiuscula)) return palavra.toLowerCase();
  const minuscula = palavra.toLowerCase();
  if (!primeira && MINUSCULAS.has(minuscula)) return minuscula;
  return minuscula.charAt(0).toUpperCase() + minuscula.slice(1);
}

/** "CANECA ACRÍLICA 400 ML COM TAMPA" → "Caneca Acrílica 400 ml com Tampa". */
export function nomeParaLoja(nome: string): string {
  return nome
    .replace(/\s+/g, " ")
    .trim()
    .split(" ")
    .map((palavra, indice) => palavraParaLoja(palavra, indice === 0))
    .join(" ");
}

/* --------------------------------------------------------------- cadastro */

/* Acima disso a medida é erro de digitação na XBZ, e não produto: frete
   calculado com caneta de 13 metros sai errado para o cliente. */
const PESO_MAXIMO_G = 50_000;
const MEDIDA_MAXIMA_CM = 200;
export const MARGEM_MAXIMA = 1000;

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

export type EscolhaDoCadastro = {
  /* Os códigos XBZ das cores que entram; vazio ou null = todas. */
  cores?: string[] | null;
  /* Quanto somar ao preço da XBZ para vender, em %. */
  margem: number;
  /* O nome que a pessoa escreveu; vazio = o da XBZ arrumado. */
  nome?: string | null;
};

export type CorDoCadastro = {
  codigoXbz: string;
  rotulo: string;
  custo: number | null;
  /* O que a cor soma ao preço do produto, já com a margem. */
  acrescimo: number;
  imagem: string | null;
};

export type ProdutoDaXbz = {
  nome: string;
  descricao: string;
  sku: string;
  custo: number | null;
  preco: number;
  sobConsulta: boolean;
  /* O pedido mínimo da XBZ: a gráfica também não consegue vender menos. */
  quantidadeMinima: number | null;
  pesoKg: number | null;
  alturaCm: number | null;
  larguraCm: number | null;
  comprimentoCm: number | null;
  ncm: string | null;
  cores: CorDoCadastro[];
};

function maiorDentroDoTeto(valores: Array<number | null>, teto: number): number | null {
  const validos = valores.filter((valor): valor is number => valor !== null && valor > 0 && valor <= teto);
  return validos.length ? Math.max(...validos) : null;
}

/**
 * O produto da Ktalogu montado a partir de um modelo da XBZ.
 *
 * O preço da XBZ é o custo de quem revende: ele vai para o custo do produto,
 * e o preço de venda é esse custo mais a margem que a pessoa escolheu. Cor
 * mais cara que a mais barata soma a diferença (com a margem) na opção.
 * Peso e medidas são os maiores entre as cores, para o frete nunca sair por
 * menos do que custa.
 */
export function montarProdutoDaXbz(modelo: ModeloDoCatalogo, escolha: EscolhaDoCadastro): ProdutoDaXbz {
  const pedidas = new Set((escolha.cores ?? []).map((codigo) => codigo.trim()).filter(Boolean));
  const cores = pedidas.size ? modelo.cores.filter((cor) => pedidas.has(cor.codigoXbz)) : modelo.cores;
  if (!cores.length) throw new Error("Escolha ao menos uma cor do produto.");

  const margem = Math.min(MARGEM_MAXIMA, Math.max(0, Number.isFinite(escolha.margem) ? escolha.margem : 0));
  const fator = 1 + margem / 100;
  const precos = cores.map((cor) => cor.preco).filter((preco): preco is number => preco !== null);
  const custo = precos.length ? Math.min(...precos) : null;

  const descricaoBase = descricaoParaLoja(maisComum(cores.map((cor) => cor.descricao)));
  const descricao = cores.length === 1 ? [descricaoBase, `Cor: ${cores[0].cor}.`].filter(Boolean).join("\n\n") : descricaoBase;
  const nomeEscolhido = String(escolha.nome ?? "").replace(/\s+/g, " ").trim();
  const peso = maiorDentroDoTeto(cores.map((cor) => cor.pesoG), PESO_MAXIMO_G);
  const altura = maiorDentroDoTeto(cores.map((cor) => cor.alturaCm), MEDIDA_MAXIMA_CM);
  const largura = maiorDentroDoTeto(cores.map((cor) => cor.larguraCm), MEDIDA_MAXIMA_CM);
  /* A XBZ manda Profundidade 0 em boa parte do catálogo (canetas, canecas,
     garrafas: peças redondas, em que a largura já é a grossura). Sem ela o
     produto não cotava frete; fica o menor lado entre altura e largura. */
  const comprimento =
    maiorDentroDoTeto(cores.map((cor) => cor.profundidadeCm), MEDIDA_MAXIMA_CM) ??
    (altura !== null && largura !== null ? Math.min(altura, largura) : null);

  return {
    nome: (nomeEscolhido || nomeParaLoja(maisComum(cores.map((cor) => cor.nome)) || modelo.nome)).slice(0, 150),
    descricao,
    sku: modelo.codigo,
    custo,
    preco: custo === null ? 0 : centavos(custo * fator),
    sobConsulta: custo === null,
    quantidadeMinima: maiorOuNulo(cores.map((cor) => cor.pedidoMinimo)),
    pesoKg: peso === null ? null : Math.round(peso) / 1000,
    alturaCm: altura,
    larguraCm: largura,
    comprimentoCm: comprimento,
    ncm: maisComum(cores.map((cor) => cor.ncm ?? "")) || null,
    cores: cores.map((cor) => ({
      codigoXbz: cor.codigoXbz,
      rotulo: cor.cor.slice(0, 80),
      custo: cor.preco,
      acrescimo: custo !== null && cor.preco !== null && cor.preco > custo ? centavos((cor.preco - custo) * fator) : 0,
      imagem: cor.imagem,
    })),
  };
}
