import { createHash, randomBytes } from "node:crypto";

import { contatosNovos, recusaDeContatos } from "../contatos";
import sharp from "sharp";

import { anoEmSaoPaulo, lerOpcoes, LIMITE_DO_SUBTITULO, LIMITE_DO_TITULO } from "@/lib/catalogo-pdf/opcoes";
import { ErroDoCatalogo, resumoDaLoja } from "@/lib/catalogo-pdf/servidor/carregar";
import { FilaCheia, JaGerando, pdfDoCatalogo, pedirCatalogo, situacaoDoCatalogo } from "@/lib/catalogo-pdf/servidor/fila";
import { gerarCatalogo } from "@/lib/catalogo-pdf/servidor/gerar";
import { resolveCompanyPublicProfileId, shouldUseEmployeeUi } from "@/lib/company-context";
import {
  cadastrarDominioPersonalizado,
  conferirDominioPedido,
  getDomainKind,
  lerDominioPersonalizado,
  removerDominioPersonalizado,
  verificarDominioPersonalizado,
} from "@/lib/dominio-personalizado";
import { avisarLojaEEsperar } from "@/lib/loja/avisar-loja";
import { prazoDoLink } from "@/lib/prazo-do-link";
import { minimoDaLinhaGravada, ROTULO_DO_MINIMO } from "@/lib/adicionais";
import { normalizePublicBaseUrl } from "@/lib/public-catalog-url";

import { COMO_ENTREGAR_O_ARQUIVO, entregarArquivo, type ArquivoEntregue } from "../arquivos";
import type { ContextoDoAssistente } from "../contexto";
import { DESCRICAO_DO_CAMPO_DE_IMAGEM, receberImagem } from "../imagens";
import {
  AVISO_DE_DADOS,
  SITUACAO_DO_PEDIDO,
  baseDaLoja,
  dataBr,
  dinheiro,
  exigir,
  falha,
  nomeDoCliente,
  pode,
  recusar,
  texto,
  um,
} from "./comum";
import {
  CAMPOS_DA_APARENCIA,
  COLUNAS_DA_APARENCIA,
  FONTES_DA_LOJA,
  NOME_DO_TEMA,
  TEMAS,
  montarMudancaDaAparencia,
  nomeDaFonte,
  type Tema,
} from "./loja-e-documentos-aparencia";
import { explicarRegistros, passoAPasso, situacaoDoDominio, type RegistroDeDns } from "./loja-e-documentos-dominio";
import { htmlDaFolha, reaisDaFolha, type Folha, type ItemDaFolha } from "./loja-e-documentos-folha";
import { ChromeAusente, imprimirHtml, imprimirPaginaDaLoja, PaginaNaoAbriu } from "./loja-e-documentos-pdf";
import { linkDoOrcamento } from "./orcamentos";
import type { Ferramenta, FerramentaDeEscrita, FerramentaDeLeitura } from "./tipos";

/**
 * A loja e os documentos pelo assistente: a aparência da loja (nome, cores,
 * modelo, fontes, logo), o domínio próprio, e os PDFs do sistema (orçamento,
 * pedido e catálogo).
 *
 * Tudo mora no perfil do dono da empresa, como na tela Catálogo › Aparência.
 * A aparência grava com a sessão da pessoa (as travas do banco valem); o
 * domínio, como a rota do painel, só para dono e administrador.
 */

/* O perfil que a loja mostra: o do dono (funcionário tem perfil próprio, vazio). */
function perfilDaLoja(ctx: ContextoDoAssistente) {
  return resolveCompanyPublicProfileId(ctx.empresa, ctx.user.id);
}

const TRAVADO =
  "O banco não deixou gravar: a aparência da loja mora no perfil do dono da empresa, e para mudá-la a pessoa precisa também da permissão de configurações da empresa. Quem libera é o dono, no painel, em Funcionários.";

/* ================================================================ aparência */

async function lerAparencia(ctx: ContextoDoAssistente) {
  const { data, error } = await ctx.supabase.from("profiles").select(COLUNAS_DA_APARENCIA).eq("id", perfilDaLoja(ctx)).maybeSingle();
  if (error) falha("ler a aparência da loja", error);
  if (!data) recusar("A loja desta empresa ainda não foi criada. Ela é criada no painel, em Catálogo.");
  return data as unknown as Record<string, unknown>;
}

function linkDaLoja(ctx: ContextoDoAssistente, perfil: Record<string, unknown>) {
  return perfil.slug ? `${baseDaLoja(ctx)}/${String(perfil.slug)}` : null;
}

export const verAparenciaDaLoja: FerramentaDeLeitura = {
  nome: "ver_aparencia_da_loja",
  titulo: "Ver a aparência da loja",
  descricao:
    "Mostra como a loja virtual está hoje: nome, slogan, logo, ícone da aba (favicon), imagem de compartilhamento, modelo (tema), cores e fontes, e os textos do Google. Traz também a lista de modelos e fontes que dá para escolher. Use antes de alterar_aparencia_da_loja e trocar_logo.",
  campos: {},
  tipo: "leitura",
  permissoes: ["catalog.store.read", "catalog.store.write"],
  async executar(ctx) {
    const perfil = await lerAparencia(ctx);
    const tema: Tema = TEMAS.includes(perfil.catalog_theme as Tema) ? (perfil.catalog_theme as Tema) : "default";
    const campos: Record<string, unknown> = {};
    for (const [nome, campo] of Object.entries(CAMPOS_DA_APARENCIA)) {
      const valor = perfil[campo.coluna];
      campos[nome] = {
        o_que_e: campo.rotulo,
        valor: campo.tipo === "fonte" ? nomeDaFonte(valor as string | null) : valor || null,
        ...(campo.avancado ? { so_com_personalizacao_avancada: true } : {}),
      };
    }
    return {
      dados: {
        _observacao: AVISO_DE_DADOS,
        link_da_loja: linkDaLoja(ctx, perfil),
        logo: perfil.catalog_logo_url || perfil.logo_url || null,
        favicon: perfil.catalog_favicon_url || null,
        imagem_de_compartilhamento: perfil.catalog_og_image_url || null,
        modelo: NOME_DO_TEMA[tema],
        personalizacao_avancada: perfil.catalog_advanced_enabled === true ? "ligada" : "desligada",
        campos,
        modelos_disponiveis: TEMAS.map((t) => NOME_DO_TEMA[t]),
        fontes_disponiveis: FONTES_DA_LOJA.map(([chave, rotulo]) => ({ chave, nome: rotulo.split("—")[0].trim(), estilo: rotulo.split("—")[1]?.trim() ?? "" })),
        como_mudar:
          "Cores, nome, slogan, modelo e fontes: alterar_aparencia_da_loja. Logo, favicon e imagem de compartilhamento: trocar_logo (imagem anexada na conversa: pedir_imagem primeiro; ou o link de uma imagem).",
      },
      resumo: "Consultou a aparência da loja",
    };
  },
};

type CargaDaAparencia = {
  perfilId: string;
  colunas: Record<string, string | boolean | null>;
  antes: Record<string, string | boolean | null>;
  linhas: Array<{ campo: string; antes: string; depois: string }>;
};

/* Os mesmos argumentos que voltam a loja ao que era (o "desfazer"). */
function comoDesfazer(antes: Record<string, string | boolean | null>) {
  const argumentos: Record<string, unknown> = {};
  const voltar: string[] = [];
  for (const [coluna, valor] of Object.entries(antes)) {
    if (coluna === "catalog_theme") {
      argumentos.modelo = NOME_DO_TEMA[TEMAS.includes(valor as Tema) ? (valor as Tema) : "default"];
      continue;
    }
    if (coluna === "catalog_advanced_enabled") {
      argumentos.personalizacao_avancada = valor === true;
      continue;
    }
    const nome = Object.entries(CAMPOS_DA_APARENCIA).find(([, campo]) => campo.coluna === coluna)?.[0];
    if (!nome) continue;
    if (valor === null || valor === "") voltar.push(nome);
    else argumentos[nome] = valor;
  }
  if (voltar.length) argumentos.voltar_ao_padrao = voltar;
  return argumentos;
}

const CAMPOS_DE_TEXTO_DA_APARENCIA = Object.fromEntries(
  Object.entries(CAMPOS_DA_APARENCIA).map(([nome, campo]) => [
    nome,
    {
      tipo: "texto" as const,
      descricao:
        campo.tipo === "cor"
          ? `${campo.rotulo}: código da cor, como #1f6feb.${campo.avancado ? " Só aparece com a personalização avançada (ligada sozinha se estiver desligada)." : ""}`
          : campo.tipo === "fonte"
            ? `${campo.rotulo}: o nome ou a chave de uma fonte de ver_aparencia_da_loja. Liga a personalização avançada se estiver desligada.`
            : `${campo.rotulo}.`,
      max: campo.max ?? 60,
    },
  ])
);

export const alterarAparenciaDaLoja: FerramentaDeEscrita<CargaDaAparencia> = {
  nome: "alterar_aparencia_da_loja",
  titulo: "Alterar a aparência da loja",
  descricao:
    "Muda a cara da loja virtual: nome, slogan, modelo (Padrão, Vitrine, Vendor, Ateliê), cores (em código, como #1f6feb), fontes e os textos do Google e do compartilhamento. Mande só o que muda. Para voltar um campo ao padrão da loja, ponha o nome dele em voltar_ao_padrao. A resposta traz o que era antes e os argumentos para desfazer (chamar de novo com eles). A loja passa a mostrar na hora.",
  campos: {
    ...CAMPOS_DE_TEXTO_DA_APARENCIA,
    modelo: { tipo: "opcao", descricao: "O modelo (tema) da loja.", opcoes: TEMAS.map((t) => NOME_DO_TEMA[t]) },
    personalizacao_avancada: {
      tipo: "booleano",
      descricao: "Liga ou desliga a personalização avançada (cores do cabeçalho, menu, fundo, botões, barra de compra e fontes). Desligar volta a loja ao padrão sem apagar as escolhas.",
    },
    voltar_ao_padrao: { tipo: "textos", descricao: "Nomes dos campos que voltam ao padrão da loja (ex.: [\"cor_do_rodape\", \"slogan\"]).", max: 60, maxItens: 30 },
  },
  tipo: "escrita",
  sobrescreve: true,
  permissoes: ["catalog.store.write"],
  async preparar(ctx, args) {
    exigir(ctx, "catalog.store.write", "mudar a aparência da loja");
    const atual = await lerAparencia(ctx);
    const valores: Record<string, unknown> = {};
    for (const nome of Object.keys(CAMPOS_DA_APARENCIA)) if (args[nome] !== undefined) valores[nome] = args[nome];
    const montado = montarMudancaDaAparencia(
      {
        valores,
        voltarAoPadrao: Array.isArray(args.voltar_ao_padrao) ? (args.voltar_ao_padrao as string[]) : [],
        tema: args.modelo,
        avancada: args.personalizacao_avancada,
      },
      atual
    );
    if (!montado.ok) recusar(montado.erro);
    const { mudanca } = montado;
    /* Nome, slogan e descrições o cliente lê: o assistente não escreve link,
       telefone, e-mail ou Pix que já não estavam lá (contra instrução
       escondida em texto de cliente). */
    for (const campo of Object.values(CAMPOS_DA_APARENCIA)) {
      if (campo.tipo !== "texto" || !(campo.coluna in mudanca.colunas)) continue;
      const contatos = contatosNovos(atual[campo.coluna] as string | null, mudanca.colunas[campo.coluna] as string | null);
      if (contatos.length) recusar(recusaDeContatos(contatos, "em Catálogo › Personalização"));
    }
    return {
      previa: {
        o_que_vai_acontecer: "A aparência da loja vai mudar assim que gravar, para todos os clientes.",
        mudancas: mudanca.linhas,
        link_da_loja: linkDaLoja(ctx, atual),
      },
      resumo: `Prévia da aparência da loja (${mudanca.linhas.map((linha) => linha.campo).join(", ")})`,
      avisos: mudanca.avisos,
      carga: { perfilId: String(atual.id), colunas: mudanca.colunas, antes: mudanca.antes, linhas: mudanca.linhas },
    };
  },
  async gravar(ctx, carga) {
    const { data, error } = await ctx.supabase
      .from("profiles")
      .update({ ...carga.colunas, updated_at: new Date().toISOString() })
      .eq("id", carga.perfilId)
      .select("id");
    if (error) falha("gravar a aparência da loja", error);
    if (!(data ?? []).length) recusar(TRAVADO);
    const lojaAvisada = await avisarLojaEEsperar(carga.perfilId);
    return {
      dados: {
        alterado: true,
        mudancas: carga.linhas,
        para_desfazer: {
          comando: "alterar_aparencia_da_loja",
          argumentos: comoDesfazer(carga.antes),
        },
        ...(lojaAvisada ? {} : { observacao: "A loja pode levar até dois minutos para mostrar a mudança." }),
      },
      resumo: `Alterou a aparência da loja (${carga.linhas.map((linha) => linha.campo).join(", ")})`,
    };
  },
};

/* ------------------------------------------------------------------- logo */

const QUAIS_IMAGENS = {
  logo: { coluna: "catalog_logo_url", pasta: "logos", titulo: "Logo da loja" },
  favicon: { coluna: "catalog_favicon_url", pasta: "catalog-seo/favicon", titulo: "Ícone da aba do navegador (favicon)" },
  imagem_de_compartilhamento: { coluna: "catalog_og_image_url", pasta: "catalog-seo/og", titulo: "Imagem que aparece ao compartilhar o link" },
} as const;
type QualImagem = keyof typeof QUAIS_IMAGENS;

const FORMATOS_ACEITOS = new Set(["jpeg", "png", "webp", "gif", "avif", "heif", "tiff"]);
/* O menor lado aceito de cada imagem, em pixels. */
const MINIMO_POR_IMAGEM: Record<QualImagem, number> = { logo: 48, favicon: 16, imagem_de_compartilhamento: 200 };

type ImagemPronta = { conteudo: Buffer; tipo: string; extensao: string; largura: number; altura: number };

/** Confere e prepara a imagem: logo em WebP (até 1200 px, com transparência), favicon quadrado em PNG, compartilhamento 1200×630 em JPEG. */
export async function prepararImagemDaLoja(corpo: Buffer, qual: QualImagem): Promise<ImagemPronta> {
  let meta: sharp.Metadata;
  try {
    meta = await sharp(corpo, { limitInputPixels: 40_000_000, failOn: "error", animated: false }).metadata();
  } catch {
    recusar("O link não é de uma imagem que dê para usar. Mande o link direto de uma imagem PNG, JPG ou WebP.");
  }
  if (!meta.format || !FORMATOS_ACEITOS.has(meta.format)) {
    recusar(
      meta.format === "svg"
        ? "Imagem em SVG não é aceita por aqui. Mande a logo em PNG (de preferência com fundo transparente), JPG ou WebP."
        : "Este formato de imagem não é aceito. Mande PNG, JPG ou WebP."
    );
  }
  const largura = meta.width ?? 0;
  const altura = meta.height ?? 0;
  const minimo = MINIMO_POR_IMAGEM[qual];
  if (largura < minimo || altura < minimo) recusar(`A imagem é pequena demais (${largura} × ${altura}). Mande uma com pelo menos ${minimo} pixels de cada lado.`);

  const base = sharp(corpo, { limitInputPixels: 40_000_000, failOn: "error", animated: false }).rotate();
  let saida: { data: Buffer; info: sharp.OutputInfo };
  if (qual === "logo") {
    saida = await base.resize({ width: 1200, height: 1200, fit: "inside", withoutEnlargement: true }).webp({ quality: 90, alphaQuality: 100 }).toBuffer({ resolveWithObject: true });
    return { conteudo: saida.data, tipo: "image/webp", extensao: "webp", largura: saida.info.width, altura: saida.info.height };
  }
  if (qual === "favicon") {
    saida = await base
      .resize({ width: 512, height: 512, fit: "contain", background: { r: 0, g: 0, b: 0, alpha: 0 } })
      .png()
      .toBuffer({ resolveWithObject: true });
    return { conteudo: saida.data, tipo: "image/png", extensao: "png", largura: saida.info.width, altura: saida.info.height };
  }
  saida = await base.resize({ width: 1200, height: 630, fit: "cover" }).flatten({ background: "#ffffff" }).jpeg({ quality: 85 }).toBuffer({ resolveWithObject: true });
  return { conteudo: saida.data, tipo: "image/jpeg", extensao: "jpg", largura: saida.info.width, altura: saida.info.height };
}

type CargaDaImagem = { perfilId: string; qual: QualImagem; antes: string | null; imagem: ImagemPronta; origem: string };

export const trocarLogo: FerramentaDeEscrita<CargaDaImagem> = {
  nome: "trocar_logo",
  titulo: "Trocar a logo da loja",
  descricao:
    "Troca a logo da loja (ou o ícone da aba do navegador, ou a imagem que aparece ao compartilhar o link). A imagem vem pelo campo imagem: se a pessoa anexou o arquivo na conversa, chame pedir_imagem, mande o link para ela enviar e use o código \"envio:...\" que voltar; também vale o link direto de uma imagem na internet ou \"produto:<id>\". PNG com fundo transparente é o melhor para logo; JPG e WebP também servem; SVG não. A imagem é conferida e ajustada no tamanho certo. A resposta traz o endereço da imagem de antes.",
  campos: {
    imagem: { tipo: "texto", descricao: DESCRICAO_DO_CAMPO_DE_IMAGEM, max: 2000 },
    link_da_imagem: { tipo: "texto", descricao: "Forma antiga do campo imagem: o link direto da imagem, começando com https://. Prefira o campo imagem.", max: 2000 },
    qual: {
      tipo: "opcao",
      descricao: "Qual imagem trocar. Padrão: logo.",
      opcoes: Object.keys(QUAIS_IMAGENS),
    },
  },
  tipo: "escrita",
  sobrescreve: true,
  abertoAoMundo: true,
  /* Busca uma imagem de fora e muda a cara pública da loja: sempre com a
     aprovação da pessoa (e o link nunca leva dados da conta para fora). */
  sensivel: true,
  permissoes: ["catalog.store.write"],
  async preparar(ctx, args) {
    exigir(ctx, "catalog.store.write", "mudar a aparência da loja");
    const qual = (args.qual as QualImagem | undefined) ?? "logo";
    const definicao = QUAIS_IMAGENS[qual];
    const atual = await lerAparencia(ctx);
    /* Exatamente um dos dois: `imagem` (envio, link ou produto) ou o antigo
       `link_da_imagem`, que continua valendo para quem já chamava assim. */
    const temImagem = typeof args.imagem === "string" && args.imagem.trim() !== "";
    const temLink = typeof args.link_da_imagem === "string" && args.link_da_imagem.trim() !== "";
    if (temImagem === temLink) {
      recusar(
        temImagem
          ? "Mande a imagem só no campo imagem (link_da_imagem é a forma antiga do mesmo campo)."
          : 'Falta a imagem. Se a pessoa anexou o arquivo na conversa, chame pedir_imagem e mande o link para ela enviar; depois use o código "envio:..." no campo imagem.'
      );
    }
    const valor = String(temImagem ? args.imagem : args.link_da_imagem).trim();
    if (temLink && !/^https:\/\//i.test(valor)) recusar("Mande o link da imagem começando com https:// (endereço seguro).");
    const recebida = await receberImagem(ctx, valor, { minimo: MINIMO_POR_IMAGEM[qual] });
    const origem = recebida.origem;
    const imagem = await prepararImagemDaLoja(recebida.conteudo, qual);
    const antes = (atual[definicao.coluna] as string | null) || (qual === "logo" ? (atual.logo_url as string | null) : null) || null;
    return {
      previa: {
        o_que_vai_acontecer: `${definicao.titulo} vai ser trocada pela ${/^https:/i.test(origem) ? "imagem do link" : origem}.`,
        imagem_nova: origem,
        tamanho_final: `${imagem.largura} × ${imagem.altura} pixels`,
        imagem_de_hoje: antes ?? "nenhuma",
        /* A impressão digital da imagem pronta. A confirmação refaz a prévia e
           compara: se a pessoa trocar o arquivo no mesmo link de envio (ou o
           site trocar a imagem do link) depois da prévia, o código não vale. */
        conferencia_da_imagem: createHash("sha256").update(imagem.conteudo).digest("hex").slice(0, 12),
      },
      resumo: `Prévia da troca: ${definicao.titulo}`,
      avisos: [],
      carga: { perfilId: String(atual.id), qual, antes, imagem, origem },
    };
  },
  async gravar(ctx, carga) {
    const definicao = QUAIS_IMAGENS[carga.qual];
    const caminho = `${definicao.pasta}/${randomBytes(8).toString("hex")}_${Date.now()}.${carga.imagem.extensao}`;
    const { error: erroDoEnvio } = await ctx.supabase.storage
      .from("products")
      .upload(caminho, carga.imagem.conteudo, { contentType: carga.imagem.tipo, upsert: false });
    if (erroDoEnvio) falha("guardar a imagem", erroDoEnvio);
    const endereco = ctx.supabase.storage.from("products").getPublicUrl(caminho).data.publicUrl;

    const { data, error } = await ctx.supabase
      .from("profiles")
      .update({ [definicao.coluna]: endereco, updated_at: new Date().toISOString() })
      .eq("id", carga.perfilId)
      .select("id");
    if (error) falha("gravar a imagem da loja", error);
    if (!(data ?? []).length) {
      await ctx.supabase.storage.from("products").remove([caminho]).catch(() => undefined);
      recusar(TRAVADO);
    }
    await avisarLojaEEsperar(carga.perfilId);
    return {
      dados: {
        trocado: true,
        o_que: definicao.titulo,
        imagem_nova: endereco,
        tamanho: `${carga.imagem.largura} × ${carga.imagem.altura} pixels`,
        imagem_de_antes: carga.antes,
        para_desfazer: carga.antes
          ? `Chame trocar_logo com imagem "${carga.antes}" e qual "${carga.qual}".`
          : "Antes não havia imagem; para tirar, use o painel em Catálogo, Aparência.",
      },
      resumo: `Trocou: ${definicao.titulo}`,
    };
  },
};

/* ================================================================== domínio */

function exigirDonoOuAdmin(ctx: ContextoDoAssistente) {
  if (shouldUseEmployeeUi(ctx.empresa, ctx.user.id)) {
    recusar("Só o dono da empresa ou um administrador mexe no domínio da loja.");
  }
}

function explicarDominio(dominio: { domain?: string | null; verified?: boolean | null; domain_kind?: string; dns_records?: RegistroDeDns[]; cf_hostname_status?: unknown; cf_ssl_status?: unknown }) {
  const nome = String(dominio.domain || "");
  const registros = explicarRegistros(nome, dominio.dns_records ?? []);
  const raiz = dominio.domain_kind === "root";
  return {
    dominio: nome || null,
    funcionando: Boolean(dominio.verified),
    situacao: situacaoDoDominio({
      domain: nome || null,
      verified: dominio.verified ?? null,
      cf_hostname_status: (dominio.cf_hostname_status as string | null) ?? null,
      cf_ssl_status: (dominio.cf_ssl_status as string | null) ?? null,
    }),
    ...(nome && !dominio.verified
      ? {
          registros_para_criar: registros,
          passo_a_passo: passoAPasso(nome, registros, raiz),
        }
      : {}),
  };
}

async function lerDominio(ctx: ContextoDoAssistente) {
  try {
    return await lerDominioPersonalizado(ctx.admin, perfilDaLoja(ctx));
  } catch (erro) {
    falha("ler o domínio da loja", erro as { message?: string });
  }
}

export const verDominioPersonalizado: FerramentaDeLeitura = {
  nome: "ver_dominio_personalizado",
  titulo: "Ver o domínio próprio da loja",
  descricao:
    "Mostra se a loja tem domínio próprio (como www.minhagrafica.com.br), se já está funcionando e, se não estiver, os registros de DNS que faltam criar, com o passo a passo na Cloudflare e no Registro.br.",
  campos: {},
  tipo: "leitura",
  permissoes: ["catalog.store.read", "catalog.store.write"],
  async executar(ctx) {
    const dominio = await lerDominio(ctx);
    return {
      dados: {
        ...explicarDominio(dominio),
        pode_mexer: !shouldUseEmployeeUi(ctx.empresa, ctx.user.id),
      },
      resumo: dominio.domain ? `Consultou o domínio da loja (${dominio.domain})` : "Consultou o domínio da loja (sem domínio)",
    };
  },
};

type CargaDoDominio = { perfilId: string; dominio: string };

export const cadastrarDominio: FerramentaDeEscrita<CargaDoDominio> = {
  nome: "cadastrar_dominio_personalizado",
  titulo: "Cadastrar o domínio próprio da loja",
  descricao:
    "Liga um domínio da empresa à loja virtual (ex.: minhagrafica.com.br ou loja.minhagrafica.com.br). Se a loja já tinha outro, ele é trocado. Devolve os registros de DNS exatos (tipo, nome e valor) para a pessoa criar onde o domínio está (Cloudflare ou Registro.br), com o passo a passo; depois, use verificar_dominio. Só dono ou administrador.",
  campos: {
    dominio: { tipo: "texto", descricao: "O domínio, sem https:// (ex.: minhagrafica.com.br). O www. do começo é ignorado.", obrigatorio: true, max: 253 },
  },
  tipo: "escrita",
  sensivel: true,
  sobrescreve: true,
  permissoes: ["catalog.store.write"],
  async preparar(ctx, args) {
    exigirDonoOuAdmin(ctx);
    const conferido = conferirDominioPedido(String(args.dominio));
    if (!conferido.ok) {
      recusar(
        conferido.error === "Domínio inválido."
          ? "Este domínio não é válido. Mande só o endereço, como minhagrafica.com.br."
          : "Este domínio não pode ser usado como domínio próprio da loja."
      );
    }
    const atual = await lerDominio(ctx);
    if (atual.domain === conferido.domain) {
      recusar(`A loja já está com o domínio ${conferido.domain}. Para ver os registros e a situação, use ver_dominio_personalizado; para conferir o DNS, verificar_dominio.`);
    }
    const raiz = getDomainKind(conferido.domain) === "root";
    const avisos: string[] = [];
    if (atual.domain) avisos.push(`A loja deixa de abrir em ${atual.domain} assim que o novo domínio for cadastrado.`);
    if (raiz) {
      avisos.push(
        `${conferido.domain} é o domínio inteiro (sem nada antes). Na Cloudflare funciona normalmente; no Registro.br não dá para apontar o domínio inteiro, e aí é melhor usar algo como loja.${conferido.domain}.`
      );
    }
    return {
      previa: {
        o_que_vai_acontecer: `A loja vai passar a atender em ${conferido.domain}, depois que os registros de DNS forem criados e conferidos.`,
        dominio_novo: conferido.domain,
        dominio_de_hoje: atual.domain || "nenhum",
        depois: "A resposta traz os registros de DNS para criar e o passo a passo.",
      },
      resumo: `Prévia do domínio próprio ${conferido.domain}`,
      avisos,
      carga: { perfilId: perfilDaLoja(ctx), dominio: conferido.domain },
    };
  },
  async gravar(ctx, carga) {
    exigirDonoOuAdmin(ctx);
    let resultado: Awaited<ReturnType<typeof cadastrarDominioPersonalizado>>;
    try {
      resultado = await cadastrarDominioPersonalizado(ctx.admin, carga.perfilId, carga.dominio);
    } catch (erro) {
      falha("cadastrar o domínio", erro as { message?: string });
    }
    if (!resultado.ok) {
      if (resultado.status === 409) recusar("Este domínio já está ligado à loja de outra empresa.");
      falha("cadastrar o domínio", { message: resultado.error });
    }
    await avisarLojaEEsperar(carga.perfilId);
    return {
      dados: {
        cadastrado: true,
        ...explicarDominio(resultado.data),
        proximo_passo: "Mostre os registros e o passo a passo à pessoa. Depois que ela criar, chame verificar_dominio (o DNS pode levar algumas horas para propagar).",
      },
      resumo: `Cadastrou o domínio próprio ${carga.dominio}`,
    };
  },
};

type CargaDaVerificacao = { perfilId: string; dominio: string };

export const verificarDominio: FerramentaDeEscrita<CargaDaVerificacao> = {
  nome: "verificar_dominio",
  titulo: "Conferir o domínio próprio",
  descricao:
    "Confere se os registros de DNS do domínio próprio já foram criados e propagaram. Se sim, a loja passa a abrir no domínio. Se não, explica o que falta. Só dono ou administrador.",
  campos: {},
  tipo: "escrita",
  permissoes: ["catalog.store.write"],
  async preparar(ctx) {
    exigirDonoOuAdmin(ctx);
    const atual = await lerDominio(ctx);
    if (!atual.domain) recusar("A loja não tem domínio próprio cadastrado. Cadastre com cadastrar_dominio_personalizado.");
    return {
      previa: { o_que_vai_acontecer: `Conferir o DNS de ${atual.domain}.` },
      resumo: `Prévia da conferência do domínio ${atual.domain}`,
      avisos: [],
      carga: { perfilId: perfilDaLoja(ctx), dominio: String(atual.domain) },
    };
  },
  async gravar(ctx, carga) {
    exigirDonoOuAdmin(ctx);
    let resultado: Awaited<ReturnType<typeof verificarDominioPersonalizado>> | null = null;
    try {
      resultado = await verificarDominioPersonalizado(ctx.admin, carga.perfilId);
    } catch (erro) {
      const codigo = (erro as { code?: string })?.code ?? "";
      /* A conferência antiga (TXT) lança quando o registro ainda não existe. */
      if (!["ENOTFOUND", "ENODATA", "ESERVFAIL", "ETIMEOUT", "ECONNREFUSED"].includes(codigo)) falha("conferir o domínio", erro as { message?: string });
    }
    const funcionando = Boolean(resultado?.ok);
    if (funcionando) await avisarLojaEEsperar(carga.perfilId);
    const depois = await lerDominio(ctx);
    return {
      dados: {
        ...explicarDominio({ ...depois, verified: funcionando || depois.verified }),
        ...(funcionando
          ? { pronto: `A loja já abre em https://${carga.dominio}.` }
          : {
              o_que_falta:
                "Os registros ainda não foram encontrados, ou falta o cadeado de segurança. Confira se estão exatamente como na lista (tipo, nome e valor) e tente de novo mais tarde: a propagação pode levar algumas horas.",
            }),
      },
      resumo: funcionando ? `Domínio ${carga.dominio} conferido e funcionando` : `Domínio ${carga.dominio} ainda aguardando o DNS`,
    };
  },
};

export const removerDominio: FerramentaDeEscrita<CargaDaVerificacao> = {
  nome: "remover_dominio",
  titulo: "Remover o domínio próprio da loja",
  descricao: "Desliga o domínio próprio da loja. Ela continua abrindo pelo link da Ktalogu. Só dono ou administrador.",
  campos: {},
  tipo: "escrita",
  sensivel: true,
  sobrescreve: true,
  permissoes: ["catalog.store.write"],
  async preparar(ctx) {
    exigirDonoOuAdmin(ctx);
    const atual = await lerDominio(ctx);
    if (!atual.domain) recusar("A loja não tem domínio próprio para remover.");
    return {
      previa: {
        o_que_vai_acontecer: `A loja deixa de abrir em ${atual.domain}. Links já enviados com esse endereço param de funcionar; a loja continua no link da Ktalogu.`,
        dominio: atual.domain,
      },
      resumo: `Prévia da remoção do domínio ${atual.domain}`,
      avisos: atual.verified ? ["O domínio está funcionando hoje: clientes que usam esse endereço não vão mais achar a loja por ele."] : [],
      carga: { perfilId: perfilDaLoja(ctx), dominio: String(atual.domain) },
    };
  },
  async gravar(ctx, carga) {
    exigirDonoOuAdmin(ctx);
    let resultado: Awaited<ReturnType<typeof removerDominioPersonalizado>>;
    try {
      resultado = await removerDominioPersonalizado(ctx.admin, carga.perfilId);
    } catch (erro) {
      falha("remover o domínio", erro as { message?: string });
    }
    if (!resultado.ok) falha("remover o domínio", { message: resultado.error });
    await avisarLojaEEsperar(carga.perfilId);
    return {
      dados: { removido: true, dominio: carga.dominio, observacao: "Os registros de DNS criados para a loja podem ser apagados de onde o domínio está." },
      resumo: `Removeu o domínio próprio ${carga.dominio}`,
    };
  },
};

/* ==================================================================== PDFs */

const PDF_INDISPONIVEL = "O PDF está indisponível agora. Tente de novo mais tarde; se continuar, fale com o suporte.";

/* Onde a loja atende para o Chrome do servidor: primeiro o endereço interno
   (em produção, a loja mora na mesma máquina, porta 3001), depois o público. */
function basesDaLojaParaImprimir(ctx: ContextoDoAssistente) {
  const bases: string[] = [];
  if (process.env.NODE_ENV === "production") bases.push("http://127.0.0.1:3001");
  const configurado = String(process.env.PUBLIC_CATALOG_BASE_URL || process.env.NEXT_PUBLIC_PUBLIC_CATALOG_BASE_URL || "").trim();
  if (configurado) bases.push(configurado.replace(/\/+$/, ""));
  bases.push(baseDaLoja(ctx));
  /* Na máquina de quem programa a loja às vezes roda na 3005. */
  if (process.env.NODE_ENV !== "production") bases.push("http://localhost:3005");
  return [...new Set(bases)];
}

async function perfilDoDocumento(ctx: ContextoDoAssistente) {
  const { data, error } = await ctx.supabase
    .from("profiles")
    .select("id,slug,catalog_name,store_name,cnpj,address,company_phone,phone,company_whatsapp,company_email,catalog_logo_url,logo_url")
    .eq("id", perfilDaLoja(ctx))
    .maybeSingle();
  if (error) falha("ler os dados da empresa", error);
  return (data ?? {}) as Record<string, string | null>;
}

function empresaDaFolha(perfil: Record<string, string | null>): Folha["empresa"] {
  const contatos = [perfil.company_phone || perfil.phone, perfil.company_whatsapp && perfil.company_whatsapp !== (perfil.company_phone || perfil.phone) ? `WhatsApp ${perfil.company_whatsapp}` : null, perfil.company_email]
    .filter(Boolean)
    .join("  ·  ");
  return {
    nome: perfil.catalog_name || perfil.store_name || "Empresa",
    cnpj: perfil.cnpj,
    endereco: perfil.address,
    contatos: contatos || null,
    logo: perfil.logo_url || perfil.catalog_logo_url,
  };
}

type ClienteDoc = { name?: string | null; company_name?: string | null; whatsapp?: string | null; email?: string | null; document?: string | null };

function clienteDaFolha(cliente: ClienteDoc | null): Folha["cliente"] {
  if (!cliente) return null;
  const contatos = [cliente.document, cliente.whatsapp ? `WhatsApp ${cliente.whatsapp}` : null, cliente.email].filter(Boolean).join("  ·  ");
  return { nome: nomeDoCliente(cliente) || "Cliente", contatos: contatos || null };
}

type ItemDoc = {
  title: string | null;
  description: string | null;
  quantity: number | null;
  unit_price: number | null;
  line_total?: number | null;
  total_price?: number | null;
  unit_type: string | null;
  width_m: number | null;
  height_m: number | null;
  length_m: number | null;
  valor_minimo_aplicado?: number | null;
  valor_sem_minimo?: number | null;
};

function itemDaFolha(item: ItemDoc): ItemDaFolha {
  const minimo = minimoDaLinhaGravada(item);
  const medidas =
    item.unit_type === "square_meter" && item.width_m && item.height_m
      ? `${item.width_m} m × ${item.height_m} m`
      : (item.unit_type === "linear_meter" || item.unit_type === "meter") && item.length_m
        ? `${item.length_m} m`
        : null;
  return {
    titulo: item.title || "Item",
    descricao: minimo
      ? [texto(item.description, 800), ROTULO_DO_MINIMO].filter(Boolean).join("\n")
      : texto(item.description, 800),
    medidas,
    quantidade: Number(item.quantity ?? 0),
    unitario: dinheiro(item.unit_price),
    total: dinheiro(item.line_total ?? item.total_price),
  };
}

/* Imprime o link público, e cai na folha montada aqui se ele não abrir. */
async function gerarPdf(ctx: ContextoDoAssistente, caminhoPublico: string | null, folha: () => Promise<string>) {
  try {
    if (caminhoPublico) {
      try {
        return { pdf: await imprimirPaginaDaLoja(basesDaLojaParaImprimir(ctx).map((base) => `${base}${caminhoPublico}`), ".documento"), deOnde: "link" as const };
      } catch (erro) {
        if (!(erro instanceof PaginaNaoAbriu)) throw erro;
      }
    }
    return { pdf: await imprimirHtml(await folha()), deOnde: "folha" as const };
  } catch (erro) {
    if (erro instanceof ChromeAusente) recusar(PDF_INDISPONIVEL);
    if (erro instanceof PaginaNaoAbriu) recusar("O PDF demorou demais para ficar pronto. Tente de novo em instantes.");
    console.error("[mcp] pdf", erro instanceof Error ? erro.message : erro);
    recusar("Não foi possível gerar o PDF agora. Tente de novo em instantes.");
  }
}

function linkPublicoValido(token: unknown, ligado: unknown, venceEm: unknown) {
  const limpo = String(token || "").trim();
  if (!limpo || ligado === false) return null;
  if (venceEm && new Date(String(venceEm)).getTime() <= Date.now()) return null;
  return limpo;
}

/* O mesmo que o botão Compartilhar do orçamento no painel: mantém o código
   que já existir, liga o link e grava o prazo que a empresa escolheu. */
async function ligarLinkDoOrcamento(ctx: ContextoDoAssistente, orcamento: Record<string, unknown>): Promise<string> {
  const numero = orcamento.number;
  if (!ctx.chave.escopos.includes("escrita") || !pode(ctx, "quotes.write")) {
    recusar(
      `O link do orçamento nº ${numero} ainda não foi ligado (ou venceu), e o orçamento oficial sai dele. Ligar o link pede permissão de alterar orçamentos: peça à pessoa para abrir o orçamento no painel e tocar em Compartilhar, ou use uma chave que possa alterar.`
    );
  }
  const { data: prazo } = await ctx.supabase
    .from("profiles")
    .select("quote_share_link_expires_enabled,quote_share_link_expires_days")
    .eq("id", perfilDaLoja(ctx))
    .maybeSingle();
  const configuracao = (prazo ?? {}) as { quote_share_link_expires_enabled?: boolean | null; quote_share_link_expires_days?: number | null };
  let token = typeof orcamento.share_token === "string" && orcamento.share_token ? orcamento.share_token : randomBytes(24).toString("hex");
  for (let tentativa = 0; tentativa < 2; tentativa++) {
    const { error } = await ctx.supabase
      .from("quotes")
      .update({
        share_enabled: true,
        share_token: token,
        share_expires_at: prazoDoLink(configuracao.quote_share_link_expires_enabled, configuracao.quote_share_link_expires_days),
      })
      .eq("id", String(orcamento.id))
      .in("user_id", ctx.tenantIds);
    if (!error) return token;
    if ((error as { code?: string }).code !== "23505") falha("ligar o link do orçamento", error);
    token = randomBytes(24).toString("hex");
  }
  return recusar("Não foi possível ligar o link do orçamento agora. Tente de novo em instantes.");
}

export const pdfDoOrcamento: FerramentaDeLeitura = {
  nome: "pdf_do_orcamento",
  titulo: "PDF do orçamento",
  descricao:
    "Devolve o orçamento oficial da Ktalogu: o link do orçamento (a página que o cliente abre, com o botão de baixar o PDF) e o próprio PDF dessa página para baixar (vale 24 horas). Se o orçamento nunca foi compartilhado, liga o link dele, como o botão Compartilhar do painel. Informe o id ou o número. Nunca monte um PDF por conta própria.",
  campos: {
    orcamento_id: { tipo: "id", descricao: "Id do orçamento, devolvido por listar_orcamentos." },
    numero: { tipo: "inteiro", descricao: "Número do orçamento, como aparece no painel.", min: 1 },
  },
  tipo: "leitura",
  permissoes: ["quotes.read"],
  async executar(ctx, args) {
    exigir(ctx, "quotes.read", "ver os orçamentos");
    if (!args.orcamento_id && !args.numero) recusar("Informe orcamento_id ou numero.");
    let consulta = ctx.supabase
      .from("quotes")
      .select("id,number,share_enabled,share_token,share_expires_at")
      .in("user_id", ctx.tenantIds)
      .order("created_at", { ascending: false })
      .limit(1);
    consulta = args.orcamento_id ? consulta.eq("id", String(args.orcamento_id)) : consulta.eq("number", Number(args.numero));
    const { data, error } = await consulta;
    if (error) falha("ler o orçamento", error);
    const orcamento = ((data ?? []) as unknown as Array<Record<string, unknown>>)[0];
    if (!orcamento) recusar("Orçamento não encontrado. Use listar_orcamentos para achar o id ou o número certo.");

    /* Só o orçamento oficial: a mesma página que o cliente abre. Folha montada
       aqui saía diferente da Ktalogu, e a pessoa não reconhecia o documento. */
    const perfil = await perfilDoDocumento(ctx);
    let token = linkPublicoValido(orcamento.share_token, orcamento.share_enabled, orcamento.share_expires_at);
    /* Orçamento que nunca foi compartilhado (ou com o link vencido): liga o
       link como o botão Compartilhar do painel faz, com o prazo da empresa. */
    if (!token) token = await ligarLinkDoOrcamento(ctx, orcamento);
    const caminho = caminhoDoLink(perfil.slug, token);
    if (!caminho) recusar("A loja ainda não tem endereço (slug), e o orçamento oficial sai do link da loja. Configure o endereço em Catálogo e peça de novo.");
    const link = await linkDoOrcamento(ctx, token, true);

    let pdf: Buffer | Uint8Array;
    try {
      pdf = await imprimirPaginaDaLoja(basesDaLojaParaImprimir(ctx).map((base) => `${base}${caminho}`), ".documento");
    } catch (erro) {
      if (erro instanceof ChromeAusente || erro instanceof PaginaNaoAbriu) {
        /* Sem o arquivo, o link do orçamento já resolve: lá tem o botão de baixar o PDF. */
        if (link) {
          return {
            dados: {
              orcamento: orcamento.number,
              link_do_orcamento: link,
              como_entregar:
                "O arquivo não ficou pronto agora. Mostre o link_do_orcamento exatamente como veio: é o orçamento oficial, e nele tem o botão de baixar o PDF. Não monte um PDF por conta própria.",
            },
            resumo: `Mostrou o link do orçamento nº ${orcamento.number}`,
          };
        }
        recusar(erro instanceof ChromeAusente ? PDF_INDISPONIVEL : "O PDF demorou demais para ficar pronto. Tente de novo em instantes.");
      }
      console.error("[mcp] pdf do orçamento", erro instanceof Error ? erro.message : erro);
      recusar("Não foi possível gerar o PDF agora. Tente de novo em instantes.");
    }

    const entregue = await entregarArquivo(ctx, { conteudo: pdf, nome: `orcamento-${orcamento.number}.pdf`, tipo: "application/pdf" });
    return {
      dados: {
        orcamento: orcamento.number,
        ...(link ? { link_do_orcamento: link } : {}),
        pdf: entregue,
        como_entregar: `${link ? "Mostre primeiro o link_do_orcamento (a página oficial do orçamento, com o botão de baixar o PDF) e depois o link do pdf. " : ""}${COMO_ENTREGAR_O_ARQUIVO}`,
      },
      resumo: `Entregou o orçamento nº ${orcamento.number} (link e PDF oficiais)`,
    };
  },
};

export const pdfDoPedido: FerramentaDeLeitura = {
  nome: "pdf_do_pedido",
  titulo: "PDF do pedido",
  descricao: "Gera o PDF de um pedido (empresa, cliente, itens, valores, situação) e devolve um link para baixar (vale 24 horas). Informe o id ou o número.",
  campos: {
    pedido_id: { tipo: "id", descricao: "Id do pedido, devolvido por listar_pedidos." },
    numero: { tipo: "inteiro", descricao: "Número do pedido, como aparece no painel.", min: 1 },
  },
  tipo: "leitura",
  permissoes: ["orders.read"],
  async executar(ctx, args) {
    exigir(ctx, "orders.read", "ver os pedidos");
    if (!args.pedido_id && !args.numero) recusar("Informe pedido_id ou numero.");
    let consulta = ctx.supabase
      .from("orders")
      .select(
        "id,number,status,total_amount,paid_amount,created_at,delivery_date,notes,clients(name,company_name,whatsapp,email,document),quotes(id,number,title,share_enabled,share_token,share_expires_at)"
      )
      .in("user_id", ctx.tenantIds)
      .order("created_at", { ascending: false })
      .limit(1);
    consulta = args.pedido_id ? consulta.eq("id", String(args.pedido_id)) : consulta.eq("number", Number(args.numero));
    const { data, error } = await consulta;
    if (error) falha("ler o pedido", error);
    const pedido = ((data ?? []) as unknown as Array<Record<string, unknown>>)[0];
    if (!pedido) recusar("Pedido não encontrado. Use listar_pedidos para achar o id ou o número certo.");

    const perfil = await perfilDoDocumento(ctx);
    const orcamento = um(pedido.quotes as Record<string, unknown> | Record<string, unknown>[] | null);
    /* Pedido que nasceu de orçamento: o link do orçamento já mostra a folha do PEDIDO, com os itens e o valor do pedido. */
    const token = orcamento ? linkPublicoValido(orcamento.share_token, orcamento.share_enabled, orcamento.share_expires_at) : null;
    const caminho = caminhoDoLink(perfil.slug, token);

    const { pdf } = await gerarPdf(ctx, caminho, async () => {
      const { data: itens, error: erroDosItens } = await ctx.supabase
        .from("order_items")
        .select("title,description,quantity,unit_price,total_price,unit_type,width_m,height_m,length_m,valor_minimo_aplicado,valor_sem_minimo")
        .eq("order_id", String(pedido.id))
        .order("created_at");
      if (erroDosItens) falha("ler os itens do pedido", erroDosItens);
      const total = dinheiro(pedido.total_amount);
      const pago = dinheiro(pedido.paid_amount);
      return htmlDaFolha({
        rotulo: "PEDIDO",
        numero: String(pedido.number),
        emitidoEm: pedido.created_at ? dataBr(String(pedido.created_at)) : null,
        empresa: empresaDaFolha(perfil),
        cliente: clienteDaFolha(um(pedido.clients as ClienteDoc | ClienteDoc[] | null)),
        detalhes: [
          ["Situação", (SITUACAO_DO_PEDIDO[String(pedido.status)] ?? "em andamento").replace(/^./, (letra) => letra.toUpperCase())],
          ["Orçamento de origem", orcamento?.number ? `#${orcamento.number}` : ""],
          ["Previsão de entrega", pedido.delivery_date ? dataBr(String(pedido.delivery_date)) : ""],
        ],
        titulo: (orcamento?.title as string | null) || null,
        itens: ((itens ?? []) as ItemDoc[]).map(itemDaFolha),
        totais: [
          ["Total", reaisDaFolha(total), true],
          ...(pago > 0 ? [["Pago", reaisDaFolha(pago)] as [string, string]] : []),
          ...(pago > 0 && total - pago > 0.004 ? [["Falta pagar", reaisDaFolha(total - pago)] as [string, string]] : []),
        ],
        secoes: [["Observações", String(pedido.notes || "")]],
      });
    });

    const entregue = await entregarArquivo(ctx, { conteudo: pdf, nome: `pedido-${pedido.number}.pdf`, tipo: "application/pdf" });
    return {
      dados: { ...entregue, pedido: pedido.number, como_entregar: COMO_ENTREGAR_O_ARQUIVO },
      resumo: `Gerou o PDF do pedido nº ${pedido.number}`,
    };
  },
};

/* ------------------------------------------------------------------ catálogo */

const ESPERA_DO_CATALOGO_MS = 25_000;

declare global {
  var __catalogosEntreguesPeloAssistente: Map<string, ArquivoEntregue> | undefined;
}

function entregues() {
  globalThis.__catalogosEntreguesPeloAssistente ??= new Map();
  return globalThis.__catalogosEntreguesPeloAssistente;
}

const ETAPA: Record<string, string> = {
  fila: "na fila",
  loja: "lendo a loja",
  fotos: "preparando as fotos",
  paginas: "montando as páginas",
  pdf: "imprimindo o PDF",
};

/* Espera o catálogo ficar pronto (até o limite) e entrega o PDF, ou diz como está. */
async function acompanharCatalogo(ctx: ContextoDoAssistente, id: string, esperaMs: number) {
  const empresa = ctx.empresa.companyId;
  const ate = Date.now() + esperaMs;
  for (;;) {
    const situacao = situacaoDoCatalogo(id, empresa);
    if (!situacao) recusar("Este catálogo não está mais disponível (ele some 30 minutos depois de pronto). Peça outro com catalogo_em_pdf.");
    if (situacao.etapa === "erro") recusar(situacao.erro || "Não foi possível gerar o catálogo agora. Tente de novo em instantes.");
    if (situacao.etapa === "pronto" && situacao.pronto) {
      const lembrado = entregues().get(id);
      if (lembrado && Date.parse(lembrado.vale_ate) > Date.now() + 60_000) return { pronto: true as const, arquivo: lembrado, paginas: situacao.pronto.paginas };
      const arquivo = await pdfDoCatalogo(id, empresa);
      if (!arquivo) recusar("Este catálogo não está mais disponível. Peça outro com catalogo_em_pdf.");
      const entregue = await entregarArquivo(ctx, { conteudo: arquivo.conteudo, nome: arquivo.nome, tipo: "application/pdf" });
      if (entregues().size > 200) entregues().clear();
      entregues().set(id, entregue);
      return { pronto: true as const, arquivo: entregue, paginas: situacao.pronto.paginas };
    }
    if (Date.now() >= ate) {
      return {
        pronto: false as const,
        etapa: ETAPA[situacao.etapa] ?? situacao.etapa,
        progresso: situacao.total ? `${situacao.feito} de ${situacao.total}` : null,
        na_frente: situacao.naFrente,
      };
    }
    await new Promise((pronto) => setTimeout(pronto, 1000));
  }
}

function respostaDoCatalogo(id: string, acompanhado: Awaited<ReturnType<typeof acompanharCatalogo>>) {
  if (acompanhado.pronto) {
    return { ...acompanhado.arquivo, paginas: acompanhado.paginas, catalogo_id: id, como_entregar: COMO_ENTREGAR_O_ARQUIVO };
  }
  return {
    pronto: false,
    catalogo_id: id,
    etapa: acompanhado.etapa,
    ...(acompanhado.progresso ? { progresso: acompanhado.progresso } : {}),
    ...(acompanhado.na_frente ? { catalogos_na_frente: acompanhado.na_frente } : {}),
    proximo_passo: "O catálogo ainda está sendo feito. Avise a pessoa e chame ver_catalogo_em_pdf com este catalogo_id em alguns segundos.",
  };
}

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

export const catalogoEmPdf: FerramentaDeLeitura = {
  nome: "catalogo_em_pdf",
  titulo: "Catálogo da loja em PDF",
  descricao:
    "Gera o catálogo da loja em PDF (o mesmo da tela Catálogo em PDF), bom para mandar no WhatsApp. Leva de segundos a alguns minutos: se ficar pronto em até 25 segundos, já volta com o link para baixar; se não, volta com um catalogo_id para buscar depois com ver_catalogo_em_pdf. Todas as opções são opcionais.",
  campos: {
    formato: { tipo: "opcao", descricao: "celular (padrão, para tela) ou a4 (para imprimir).", opcoes: ["celular", "a4"] },
    layout: { tipo: "opcao", descricao: "Só no celular: vitrine (4 por página, padrão) ou destaque (1 por página, até 80 produtos).", opcoes: ["vitrine", "destaque"] },
    categorias: { tipo: "textos", descricao: "Nomes das categorias que entram. Vazio = a loja inteira.", max: 120, maxItens: 200 },
    mostrar_preco: { tipo: "booleano", descricao: "Mostrar os preços. Padrão: sim." },
    mostrar_tabela_de_quantidade: { tipo: "booleano", descricao: "Mostrar a tabela \"quanto mais, menos paga\". Padrão: sim." },
    botao: { tipo: "opcao", descricao: "Para onde o toque no produto leva: loja (padrão), whatsapp ou nenhum.", opcoes: ["loja", "whatsapp", "nenhum"] },
    titulo: { tipo: "texto", descricao: `O selo da capa (padrão: "Catálogo ${new Date().getFullYear()}").`, max: LIMITE_DO_TITULO },
    subtitulo: { tipo: "texto", descricao: "A frase grande da capa. Vazio = o slogan da loja.", max: LIMITE_DO_SUBTITULO },
    indice: { tipo: "booleano", descricao: "Página de índice. Padrão: sim." },
    paginas_de_abertura: { tipo: "booleano", descricao: "Página de abertura em cada categoria grande. Padrão: sim." },
    so_com_foto: { tipo: "booleano", descricao: "Deixar de fora os produtos sem foto. Padrão: não." },
  },
  tipo: "leitura",
  permissoes: ["catalog.products.read"],
  async executar(ctx, args) {
    exigir(ctx, "catalog.products.read", "gerar o catálogo da loja");
    /* A capa vai às mãos dos clientes: nada de contato que a pessoa não escreveu. */
    const contatosDaCapa = contatosNovos(null, [args.titulo, args.subtitulo].filter((t) => typeof t === "string").join("\n"));
    if (contatosDaCapa.length) recusar(recusaDeContatos(contatosDaCapa, "em Catálogo › Catálogo em PDF"));
    const dono = perfilDaLoja(ctx);
    const origemPublica = normalizePublicBaseUrl(ctx.origemDoAdmin);

    let categorias: string[] | undefined;
    const pedidas = Array.isArray(args.categorias) ? (args.categorias as string[]) : [];
    if (pedidas.length) {
      let resumo: Awaited<ReturnType<typeof resumoDaLoja>>;
      try {
        resumo = await resumoDaLoja(ctx.admin, dono, origemPublica);
      } catch (erro) {
        if (erro instanceof ErroDoCatalogo) recusar(erro.message);
        falha("ler as categorias da loja", erro as { message?: string });
      }
      const naoAchadas: string[] = [];
      categorias = [];
      for (const nome of pedidas) {
        const achada = resumo.categorias.find((cat) => semAcento(cat.nome) === semAcento(nome));
        if (achada) categorias.push(achada.id);
        else naoAchadas.push(nome);
      }
      if (naoAchadas.length) {
        recusar(`Categoria não encontrada na loja: ${naoAchadas.join(", ")}. As categorias com produtos são: ${resumo.categorias.map((cat) => cat.nome).join(", ")}.`);
      }
    }

    const opcoes = lerOpcoes(
      {
        formato: args.formato,
        layout: args.layout,
        categorias,
        mostrarPreco: args.mostrar_preco,
        mostrarEscada: args.mostrar_tabela_de_quantidade,
        botao: args.botao,
        titulo: args.titulo,
        subtitulo: args.subtitulo,
        indice: args.indice,
        divisores: args.paginas_de_abertura,
        soComFoto: args.so_com_foto,
      },
      anoEmSaoPaulo()
    );

    let id: string;
    try {
      id = await pedirCatalogo(ctx.empresa.companyId, (aoProgredir) => gerarCatalogo({ admin: ctx.admin, donoId: dono, origemPublica, opcoes, aoProgredir }));
    } catch (erro) {
      if (erro instanceof JaGerando) id = erro.id;
      else if (erro instanceof FilaCheia) recusar("Muitos catálogos sendo gerados agora. Tente de novo em um minuto.");
      else falha("começar o catálogo", erro as { message?: string });
    }

    const acompanhado = await acompanharCatalogo(ctx, id, ESPERA_DO_CATALOGO_MS);
    return {
      dados: respostaDoCatalogo(id, acompanhado),
      resumo: acompanhado.pronto ? "Gerou o catálogo da loja em PDF" : "Pediu o catálogo da loja em PDF (ainda gerando)",
    };
  },
};

export const verCatalogoEmPdf: FerramentaDeLeitura = {
  nome: "ver_catalogo_em_pdf",
  titulo: "Buscar o catálogo em PDF",
  descricao: "Busca o catálogo pedido com catalogo_em_pdf: se ficou pronto, devolve o link para baixar; se não, diz em que etapa está.",
  campos: {
    catalogo_id: { tipo: "id", descricao: "O catalogo_id devolvido por catalogo_em_pdf.", obrigatorio: true },
  },
  tipo: "leitura",
  permissoes: ["catalog.products.read"],
  async executar(ctx, args) {
    exigir(ctx, "catalog.products.read", "gerar o catálogo da loja");
    const id = String(args.catalogo_id);
    const acompanhado = await acompanharCatalogo(ctx, id, 20_000);
    return {
      dados: respostaDoCatalogo(id, acompanhado),
      resumo: acompanhado.pronto ? "Baixou o catálogo da loja em PDF" : "Consultou o catálogo em PDF (ainda gerando)",
    };
  },
};

/* Registrado em index.ts. */
/* O caminho do link público para o Chrome imprimir: slug e token conferidos
   (o token é gravado pelo navegador; nada de "../" levando a outra página). */
function caminhoDoLink(slug: unknown, token: unknown): string | null {
  const s = typeof slug === "string" ? slug : "";
  const t = typeof token === "string" ? token : "";
  if (!/^[a-z0-9][a-z0-9-]{0,80}$/i.test(s) || !/^[A-Za-z0-9_-]{8,128}$/.test(t)) return null;
  return `/${encodeURIComponent(s)}/o/${encodeURIComponent(t)}`;
}

export const FERRAMENTAS_DE_LOJA_E_DOCUMENTOS: Ferramenta[] = [
  verAparenciaDaLoja,
  alterarAparenciaDaLoja as Ferramenta,
  trocarLogo as Ferramenta,
  verDominioPersonalizado,
  cadastrarDominio as Ferramenta,
  verificarDominio as Ferramenta,
  removerDominio as Ferramenta,
  pdfDoOrcamento,
  pdfDoPedido,
  catalogoEmPdf,
  verCatalogoEmPdf,
];
