import { spawnSync } from "node:child_process";
import { readFile } from "node:fs/promises";
import path from "node:path";

import sharp from "sharp";

import { ChromeAusente } from "@/lib/catalogo-pdf/servidor/imprimir";
import { resolveCompanyPublicProfileId } from "@/lib/company-context";
import { ArquivoRecusado, arquivoDaArte, nomeDoArquivo } from "@/lib/banner-editor/arquivo-da-arte";
import { audiosDoProjeto, comAudios, duracaoDaPagina, paginaNoTempo, temposDoProjeto } from "@/lib/banner-editor/cena";
import { CHAVES_DOS_ICONES, FONTES, documentoDasPaginas, documentoLimpo, novoId, paginasDoDocumento, type AudioDoProjeto, type DocumentoDoBanner, type PecaDeTexto } from "@/lib/banner-editor/documento";
import { RECADO_DO_BETA, designLiberado } from "@/lib/banner-editor/liberacao";
import { podeUsar } from "@/lib/banner-editor/limite-de-uso";
import { linkPublicoNaLoja } from "@/lib/banner-editor/link-publico";
import { DEPOSITO_DA_BIBLIOTECA, buscarNaBiblioteca, juntarIndices, type IndiceDaBiblioteca } from "@/lib/banner-editor/biblioteca";
import { FONTES_COM_GLIFOS, registrarFontesComGlifos } from "@/lib/banner-editor/fontes-com-glifos";
import { DEPOSITO_DAS_FIGURINHAS } from "@/lib/banner-editor/pecas-novas";
import { glifosDaFonte, glifosDoArquivo } from "@/lib/banner-editor/glifos-do-servidor";
import { TIPOS_DE_MODELO } from "@/lib/banner-editor/miniaturas";
import { MODELOS_DO_EDITOR } from "@/lib/banner-editor/modelos";
import { ESTILOS_DA_LEGENDA, JEITOS_DE_FALAR, MAXIMO_DA_NARRACAO, VOZES, alinharNoSom, comLegenda } from "@/lib/banner-editor/narracao";
import { ImagemRecusada, baixarDoDeposito, doDeposito, htmlComAsImagens } from "@/lib/banner-editor/servidor";
import { gerarVoz } from "@/lib/banner-editor/servidor-de-fala";
import { encolherTexto, naoCabe, textoDeEnfeite } from "@/components/pagina-inicial/editor-de-banner/texto-que-cabe";

import { COMO_ENTREGAR_O_ARQUIVO, entregarArquivo } from "../arquivos";
import type { ContextoDoAssistente } from "../contexto";
import { guardarImagemDaLoja, receberImagem } from "../imagens";
import { falha, recusar } from "./comum";
import {
  CATALOGO_DE_SONS,
  ErroNaArte,
  FORMATOS,
  GRUPOS_DE_ELEMENTOS,
  OPERACOES,
  aplicarOperacoes,
  comEnderecos,
  descricaoDoElemento,
  documentoDoAssistente,
  enderecoDaCasa,
  enderecosDeFora,
  pedidoDoModelo,
  poseNoInstante,
  resumoDaArte,
  tamanhoDaArte,
} from "./editor-de-artes-documento";
import { PARTES_DO_GUIA, guiaDoEditor, type ParteDoGuia } from "./editor-de-artes-guia";
import { BannerDemorou, GeradorOcupado, fotografarEMedirFolhas } from "./gerador-de-banner";
import { ErroDaFerramenta, type Ferramenta, type FerramentaDeEscrita, type FerramentaDeLeitura, type Saida } from "./tipos";

/*
 * O editor de artes (KtaloguDesign) inteiro pelo assistente: o guia (o que o
 * editor faz e como escrever uma arte), a busca na biblioteca (ícones,
 * figurinhas, fotos, elementos, modelos, sons, fontes), criar e alterar a
 * arte, ver (com a FOTO de cada página e da animação em vários instantes),
 * narrar (voz + legenda no tempo da fala) e baixar. Toda arte criada aqui
 * fica nos designs da empresa (artes_do_editor), como se tivesse sido feita
 * na tela, e volta com dois links: o público (ver, tocar e baixar, como o
 * "compartilhar" do Canva) e o do editor (editar, só logado na Ktalogu).
 *
 * A conta da arte (o jeito curto, as operações, o resumo) mora em
 * editor-de-artes-documento.ts; o guia em editor-de-artes-guia.ts.
 */

const PERMISSOES: Ferramenta["permissoes"] = ["catalog.banners.write", "marketing.manage"];
const PASTA_DAS_IMAGENS = "catalog/sections/pagina-inicial/editor/imagens";
const PASTA_DOS_AUDIOS = "catalog/sections/pagina-inicial/editor/audios";
/* A tabela aceita até 6.000 px de lado (migration 222). */
const MAIOR_LADO_GUARDADO = 6000;
const PAGINAS_NA_FOTO = 6;

/* ============================================================ comuns */

const donoDasArtes = (ctx: ContextoDoAssistente) => resolveCompanyPublicProfileId(ctx.empresa, ctx.user.id);
const linkDoEditor = (ctx: ContextoDoAssistente, id: string) => `${ctx.origemDoAdmin.replace(/\/$/, "")}/dashboard/design?arte=${id}`;
/* O link público sai no endereço da loja (domínio próprio ou o grátis); o do editor, no painel. */
const linksDaArte = async (ctx: ContextoDoAssistente, id: string) => ({ link_publico: await linkPublicoNaLoja(ctx.origemDoAdmin, donoDasArtes(ctx), id), link_do_editor: linkDoEditor(ctx, id) });
const ehId = (v: unknown) => typeof v === "string" && /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(v.trim());
const ehObjeto = (v: unknown): v is Record<string, unknown> => Boolean(v) && typeof v === "object" && !Array.isArray(v);

type LinhaDaArte = { id: string; nome: string; largura: number; altura: number; formato: string | null; documento: unknown; updated_at: string };

async function lerArte(ctx: ContextoDoAssistente, id: unknown): Promise<LinhaDaArte & { doc: DocumentoDoBanner }> {
  if (!ehId(id)) recusar("Diga o arte_id (o id que voltou de criar_arte ou de listar_artes).");
  const { data, error } = await ctx.supabase.from("artes_do_editor").select("id,nome,largura,altura,formato,documento,updated_at").eq("id", String(id).trim()).eq("user_id", donoDasArtes(ctx)).maybeSingle();
  if (error) falha("abrir a arte", error);
  if (!data) recusar("Essa arte não foi encontrada nos designs desta empresa. Veja as artes com listar_artes.");
  const linha = data as LinhaDaArte;
  return { ...linha, doc: documentoLimpo(linha.documento, linha.largura, linha.altura) };
}

/* A cor e o logo da loja (para os modelos nascerem com a cara dela). */
async function marcaDaLoja(ctx: ContextoDoAssistente) {
  const { data } = await ctx.supabase.from("profiles").select("primary_color,catalog_logo_url,logo_url").eq("id", donoDasArtes(ctx)).maybeSingle();
  const p = (data ?? {}) as { primary_color?: string | null; catalog_logo_url?: string | null; logo_url?: string | null };
  const logo = p.catalog_logo_url || p.logo_url || null;
  let proporcaoDoLogo: number | null = null;
  if (logo && doDeposito(logo)) {
    const bytes = await baixarDoDeposito(logo).catch(() => null);
    const meta = bytes ? await sharp(bytes).metadata().catch(() => null) : null;
    if (meta?.width && meta.height) proporcaoDoLogo = Math.round((meta.width / meta.height) * 100) / 100;
  }
  return { cor: /^#[0-9a-f]{6}$/i.test(p.primary_color || "") ? String(p.primary_color) : "#7C3AED", logo: logo && doDeposito(logo) ? logo : null, proporcaoDoLogo };
}

/* -------------------------------------------- ícones e imagens de fora */

/* Os 1.544 ícones Lucide do editor (public/editor/icones-lucide.json), lidos uma vez. */
let lucide: Promise<Map<string, string>> | null = null;
function iconesLucide() {
  lucide ??= (async () => {
    for (const base of [path.join(process.cwd(), "public"), path.join(process.cwd(), "apps", "admin", "public")]) {
      try {
        /* Uma lista de [nome, desenho]. */
        const lido = JSON.parse(await readFile(path.join(base, "editor", "icones-lucide.json"), "utf8")) as Array<[string, string]> | { lista: Array<[string, string]> };
        return new Map(Array.isArray(lido) ? lido : lido.lista);
      } catch {
        continue;
      }
    }
    return new Map<string, string>();
  })();
  return lucide;
}
/* A biblioteca de recortes sem fundo e ícones 3D (public/editor/biblioteca.json), lida uma vez. */
let biblioteca: Promise<IndiceDaBiblioteca> | null = null;
function indiceDaBiblioteca() {
  biblioteca ??= (async () => {
    const ler = async (arquivo: string) => {
      for (const base of [path.join(process.cwd(), "public"), path.join(process.cwd(), "apps", "admin", "public")]) {
        try {
          return JSON.parse(await readFile(path.join(base, "editor", arquivo), "utf8")) as IndiceDaBiblioteca;
        } catch {
          continue;
        }
      }
      return null;
    };
    return juntarIndices(await ler("biblioteca.json"), await ler("vetores.json"));
  })();
  return biblioteca;
}
let figurinhas: Promise<Array<{ c: string; n: string; g: string; t: string; p: number }>> | null = null;
function listaDeFigurinhas() {
  figurinhas ??= (async () => {
    for (const base of [path.join(process.cwd(), "public"), path.join(process.cwd(), "apps", "admin", "public")]) {
      try {
        return (JSON.parse(await readFile(path.join(base, "editor", "figurinhas.json"), "utf8")) as { lista: Array<{ c: string; n: string; g: string; t: string; p: number }> }).lista;
      } catch {
        continue;
      }
    }
    return [];
  })();
  return figurinhas;
}

/* Ícone pelo nome: os da casa ficam; "lucide-<nome>" (ou um nome que só o Lucide tem) ganha o desenho. */
async function comDesenhosDosIcones(v: unknown): Promise<unknown> {
  const mapa = await iconesLucide();
  const casa = new Set<string>(CHAVES_DOS_ICONES);
  const andar = (x: unknown): unknown => {
    if (Array.isArray(x)) return x.map(andar);
    if (!ehObjeto(x)) return x;
    const novo = Object.fromEntries(Object.entries(x).map(([k, y]) => [k, andar(y)]));
    if (novo.tipo === "icone" && typeof novo.icone === "string" && typeof novo.desenho !== "string" && !casa.has(novo.icone)) {
      const nome = novo.icone.replace(/^lucide-/, "");
      const desenho = mapa.get(nome);
      if (!desenho) throw new ErroNaArte(`O ícone "${novo.icone}" não existe. Procure com procurar_no_editor (tipo "icones").`);
      return { ...novo, icone: `lucide-${nome}`, desenho };
    }
    return novo;
  };
  return andar(v);
}

/* As imagens que ainda não estão no depósito (envio:, produto:, links de fora) entram nele; as da casa (foto:, figurinha:) viram o endereço delas. */
async function comImagensNoDeposito(ctx: ContextoDoAssistente, v: unknown, avisos: string[]): Promise<unknown> {
  const fora = enderecosDeFora(v, doDeposito);
  if (!fora.size) return v;
  if (fora.size > 20) recusar("Até 20 imagens novas por vez. Mande o resto numa alteração depois.");
  const mapa = new Map<string, string>();
  for (const valor of fora) {
    const daCasa = enderecoDaCasa(valor);
    if (daCasa) {
      mapa.set(valor, daCasa);
      continue;
    }
    const recebida = await receberImagem(ctx, valor, { minimo: 16 });
    let webp: Buffer;
    try {
      webp = await sharp(recebida.conteudo, { limitInputPixels: 60_000_000 }).rotate().resize(2400, 2400, { fit: "inside", withoutEnlargement: true }).webp({ quality: 88 }).toBuffer();
    } catch {
      recusar(`Não deu para usar a imagem ${recebida.origem}. Tente outra (PNG, JPG ou WebP).`);
    }
    mapa.set(valor, await guardarImagemDaLoja(ctx, { conteudo: webp, tipo: "image/webp", extensao: "webp" }, PASTA_DAS_IMAGENS));
    if (Math.max(recebida.largura, recebida.altura) < 500) avisos.push(`A imagem ${recebida.origem} é pequena (${recebida.largura} × ${recebida.altura}) e pode ficar borrada se ocupar muito da arte.`);
  }
  return comEnderecos(v, mapa);
}

function conferirTamanhoDoDocumento(doc: DocumentoDoBanner) {
  if (JSON.stringify(doc).length > 850_000) recusar("A arte ficou grande demais para guardar (texto ou peças demais). Tire algumas peças ou divida em outra arte.");
}

/* -------------------------------------------- fotografar e acertar */

type Foto = { dados: string; tipo: string };

function errosDoChrome(erro: unknown): never {
  if (erro instanceof ErroDaFerramenta) throw erro;
  if (erro instanceof ImagemRecusada) recusar(erro.message);
  if (erro instanceof ChromeAusente) recusar("O gerador de artes não está disponível neste servidor agora. Avise o suporte da Ktalogu.");
  if (erro instanceof BannerDemorou) recusar("A arte demorou demais para ficar pronta. Tente de novo em instantes.");
  if (erro instanceof GeradorOcupado) recusar("O gerador de artes está ocupado com outros pedidos. Tente de novo em um minuto.");
  console.error("[mcp] editor de artes: foto", erro instanceof Error ? erro.message : erro);
  recusar("Não deu para desenhar a arte agora. Tente de novo em instantes.");
}

/* As páginas fotografadas (PNG no tamanho da arte) e o fator da letra que cabe em cada texto. */
async function fotografar(paginas: DocumentoDoBanner[]) {
  const maior = Math.max(...paginas.map((p) => Math.max(p.largura, p.altura)));
  try {
    const html = await htmlComAsImagens(paginas, { escala: Math.min(1, 1400 / maior) });
    return await fotografarEMedirFolhas(html, paginas.map((p) => ({ largura: p.largura, altura: p.altura, escala: 1 })));
  } catch (erro) {
    errosDoChrome(erro);
  }
}

const paraVer = async (png: Buffer, lado = 900): Promise<Foto> => ({ dados: (await sharp(png).resize(lado, lado, { fit: "inside", withoutEnlargement: true }).flatten({ background: "#FFFFFF" }).jpeg({ quality: 78 }).toBuffer()).toString("base64"), tipo: "image/jpeg" });

/**
 * Fotografa a arte e, se algum texto não cabe na caixa, encolhe a letra até
 * caber (a mesma conta do "Diminuir a letra" do editor) e fotografa de novo.
 */
async function acertarEFotografar(doc: DocumentoDoBanner): Promise<{ doc: DocumentoDoBanner; fotos: Buffer[]; encolhidos: string[] }> {
  let paginas = paginasDoDocumento(doc);
  const encolhidos: string[] = [];
  for (let volta = 0; volta < 2; volta += 1) {
    const { fotos, faltas } = await fotografar(paginas);
    const textos = new Map(paginas.flatMap((p, i) => p.pecas.filter((x): x is PecaDeTexto => x.tipo === "texto").map((x) => [x.id, { peca: x, pagina: i + 1 }] as const)));
    const encolher = new Map(
      faltas
        .filter((f) => {
          const t = textos.get(f.id);
          return t && f.fator < 0.995 && !textoDeEnfeite(t.peca) && naoCabe(t.peca, f);
        })
        .map((f) => [f.id, f.fator * 0.98] as const),
    );
    if (!encolher.size || volta === 1) return { doc: documentoLimpo(documentoDasPaginas(paginas), doc.largura, doc.altura), fotos, encolhidos };
    for (const [id, fator] of encolher) {
      const t = textos.get(id)!;
      encolhidos.push(`"${t.peca.texto.replace(/\s+/g, " ").slice(0, 40)}" (página ${t.pagina}): letra de ${Math.round(t.peca.tamanho)} para ${Math.round(Math.max(6, t.peca.tamanho * fator))} px`);
    }
    paginas = paginas.map((p) => ({ ...p, pecas: p.pecas.map((x) => (x.tipo === "texto" && encolher.has(x.id) ? { ...x, ...encolherTexto(x, encolher.get(x.id)!) } : x)) }));
  }
  return { doc, fotos: [], encolhidos };
}

/* A miniatura da galeria (a mesma pasta da /api/artes/miniatura): o design aparece com foto e o link público ganha a imagem no WhatsApp. */
async function guardarMiniatura(ctx: ContextoDoAssistente, id: string, atualizada: string, png: Buffer | undefined) {
  if (!png) return;
  try {
    const pasta = `editor-artes/${id}`;
    const nome = `${Date.parse(atualizada) || 0}.webp`;
    const deposito = ctx.admin.storage.from("products");
    const webp = await sharp(png).resize(640, 640, { fit: "inside", withoutEnlargement: true }).webp({ quality: 80 }).toBuffer();
    await deposito.upload(`${pasta}/${nome}`, webp, { contentType: "image/webp", upsert: true, cacheControl: "31536000" });
    const { data: existentes } = await deposito.list(pasta, { limit: 20 });
    const velhas = (existentes || []).map((f) => f.name).filter((n) => n !== nome);
    if (velhas.length) await deposito.remove(velhas.map((n) => `${pasta}/${n}`));
  } catch (erro) {
    console.warn("[mcp] editor de artes: miniatura", erro instanceof Error ? erro.message : erro);
  }
}

/* O que volta ao assistente depois de criar ou alterar: links, resumo e as fotos. */
async function saidaDaArte(ctx: ContextoDoAssistente, linha: { id: string; nome: string }, doc: DocumentoDoBanner, fotos: Buffer[], extra: Record<string, unknown>, resumo: string): Promise<Saida> {
  const paginas = paginasDoDocumento(doc);
  const imagens = await Promise.all(fotos.slice(0, PAGINAS_NA_FOTO).map((f) => paraVer(f)));
  const animada = paginas.some((p) => p.animacao || p.pecas.some((x) => x.animacao)) || audiosDoProjeto(paginas).length > 0;
  return {
    dados: {
      arte_id: linha.id,
      nome: linha.nome,
      ...(await linksDaArte(ctx, linha.id)),
      ...extra,
      arte: resumoDaArte(doc),
      fotos: `${imagens.length} foto(s) junto desta resposta (página ${imagens.length > 1 ? `1 a ${imagens.length}` : "1"}${paginas.length > imagens.length ? `; a arte tem ${paginas.length}, veja as outras com ver_arte` : ""})${animada ? ". A foto é a arte parada (como o PNG); para conferir a animação, use ver_arte com instantes" : ""}.`,
      proximo_passo:
        "OLHE as fotos: texto cortado ou apertado, peça fora do lugar, contraste fraco, foto mal cortada, espaço vazio sobrando. Corrija com alterar_arte. Quando estiver bom, mande à pessoa o link_publico (ela vê, toca e baixa em PNG, JPG, PDF ou MP4, e pode compartilhar) e o link_do_editor (para ela mesma ajustar, logada na Ktalogu). Mostre os links exatamente como vieram.",
    },
    resumo,
    imagens,
  };
}

/* ============================================================ guia e busca */

const guiaDoEditorFerramenta: FerramentaDeLeitura = {
  tipo: "leitura",
  nome: "guia_do_editor",
  titulo: "Guia do editor de artes",
  descricao:
    'O manual do KtaloguDesign, o editor de artes da Ktalogu (posts, stories, reels animados, carrosséis, banners, flyers, impressão). LEIA "comeco", "direcao", "efeitos" e "biblioteca" ANTES da primeira arte da conversa: o fluxo, a direção de arte (camadas, composição, a lista para conferir a foto), todos os efeitos (sombra, brilho, contorno, transparência, mistura, powerclip, retícula, 3D, ajustes de foto, duotone, adesivo, caneca, troca de cor do SVG) e os recursos (recortes sem fundo, ícones 3D, ornamentos, figurinhas, fontes com floreio). As outras: "pecas" (campos de cada peça), "animacao", "som", "modelos", "elementos", "fontes" e "receitas" (artes completas para adaptar). Arte com cara de página de site (forma chapada, texto no meio, fundo liso, sem imagem) não serve.',
  campos: {
    parte: { tipo: "opcao", opcoes: PARTES_DO_GUIA, descricao: 'A parte do guia. Padrão: "comeco".' },
  },
  permissoes: PERMISSOES,
  async executar(_ctx, args) {
    const parte = (PARTES_DO_GUIA as readonly string[]).includes(String(args.parte ?? "comeco")) ? (String(args.parte ?? "comeco") as ParteDoGuia) : "comeco";
    return { dados: { parte, guia: guiaDoEditor(parte), outras_partes: PARTES_DO_GUIA.filter((p) => p !== parte) }, resumo: `Leu o guia do editor de artes (${parte})` };
  },
};

const TIPOS_DA_BUSCA = ["icones", "figurinhas", "recortes", "fotos", "elementos", "modelos", "sons", "fontes", "glifos"] as const;
const semAcento = (s: string) =>
  s
    .normalize("NFD")
    .replace(/[̀-ͯ]/g, "")
    .toLowerCase();
const casa = (palavras: string[], ...campos: Array<string | null | undefined>) => {
  const alvo = semAcento(campos.filter(Boolean).join(" "));
  return palavras.every((p) => alvo.includes(p));
};

/* As fotos da casa (products/editor-modelos/ramos), lembradas por 10 minutos. */
let fotosDaCasa: { ate: number; lista: string[] } | null = null;
async function listaDeFotosDaCasa(ctx: ContextoDoAssistente) {
  if (fotosDaCasa && fotosDaCasa.ate > Date.now()) return fotosDaCasa.lista;
  const { data } = await ctx.admin.storage.from("products").list("editor-modelos/ramos", { limit: 1000 });
  const lista = (data || []).map((f) => f.name).filter((n) => n.endsWith(".webp")).map((n) => n.replace(/\.webp$/, ""));
  fotosDaCasa = { ate: Date.now() + 10 * 60 * 1000, lista };
  return lista;
}

const procurarNoEditor: FerramentaDeLeitura = {
  tipo: "leitura",
  nome: "procurar_no_editor",
  titulo: "Procurar no editor de artes",
  descricao:
    'Procura na biblioteca do editor de artes, por palavra: "icones" (mais de 1.500; os da casa e os Lucide, nomes em inglês: rocket, heart, shopping-cart), "figurinhas" (desenhos coloridos sem fundo: bichos, comidas, festas, carinhas), "recortes" (fotos recortadas sem fundo em PNG, com busca em português: pessoas, comida, animais, fogo, fumaça, roupas, monumentos; e ícones 3D em 4 cores e 3 ângulos, de Natal, Halloween e redes sociais), "fotos" (fotos da casa, de produtos personalizados e de gráfica), "glifos" (busca = a chave de UMA fonte: as letras com floreio e os enfeites que ela tem), "elementos" (prontos: telas de celular, notificação, cursor, selos, cupons, partículas, datas), "modelos" (artes prontas), "sons" (músicas e efeitos livres) e "fontes". Devolve a chave exata para usar na arte (ex.: "figurinha:gato", "recorte:comida-chocolate-cake", "recorte:3d-geral-gym/iso_gradient", "foto:caneca-roxo", "lucide-rocket", "v-celular").',
  campos: {
    tipo: { tipo: "opcao", opcoes: TIPOS_DA_BUSCA, obrigatorio: true, descricao: "O que procurar." },
    busca: { tipo: "texto", max: 80, descricao: 'Palavras para filtrar (ex.: "caneca", "natal", "celular", "energia"). Vazio = lista tudo (até o limite).' },
    limite: { tipo: "inteiro", min: 1, max: 100, descricao: "Quantos resultados (padrão 30)." },
  },
  permissoes: PERMISSOES,
  async executar(ctx, args) {
    const tipo = String(args.tipo ?? "").trim().toLowerCase();
    if (!(TIPOS_DA_BUSCA as readonly string[]).includes(tipo)) recusar(`Tipo "${tipo}" não existe. Use: ${TIPOS_DA_BUSCA.join(", ")}.`);
    const palavras = semAcento(String(args.busca ?? "")).split(/\s+/).filter(Boolean);
    const limite = Number(args.limite) || 30;
    let lista: Array<Record<string, unknown>> = [];
    if (tipo === "icones") {
      const daCasa = CHAVES_DOS_ICONES.filter((c) => casa(palavras, c)).map((c) => ({ icone: c, de: "casa" }));
      const lucideLista = [...(await iconesLucide()).keys()].filter((n) => casa(palavras, n.replace(/-/g, " "))).map((n) => ({ icone: `lucide-${n}`, de: "lucide" }));
      lista = [...daCasa, ...lucideLista];
    } else if (tipo === "figurinhas") {
      lista = (await listaDeFigurinhas()).filter((f) => casa(palavras, f.c, f.n, f.g, f.t)).map((f) => ({ imagem: `figurinha:${f.c}`, nome: f.n, grupo: f.g, proporcao: f.p, dica: 'peça "imagem" com "ajuste": "conter" e a caixa na proporção (largura ÷ altura)' }));
    } else if (tipo === "recortes") {
      const indice = await indiceDaBiblioteca();
      const achados = palavras.length ? buscarNaBiblioteca(indice, palavras.join(" ")) : indice.lista;
      lista = achados.map((i) => ({
        imagem: `recorte:${i.c}`,
        nome: i.n,
        grupo: indice.grupos[i.g] ?? i.g,
        proporcao: i.p,
        ...(i.v ? { icone_3d: true, variacoes: i.v.map((v) => `recorte:${i.c}/${v}`) } : {}),
        ...(i.k ? { svg: true, cores: i.k, troca_de_cor: 'na peça: "vetor": { "cores": [as cores], "trocas": { "#000000": "#C9A227" } }' } : {}),
        dica: 'peça "imagem" com "ajuste": "conter" e a caixa na proporção (largura ÷ altura); recorte sem fundo, sem sombra nem canto',
      }));
    } else if (tipo === "glifos") {
      /* As letras com floreio e os enfeites de UMA fonte (a busca é a chave dela). */
      const chave = String(args.busca ?? "").trim().toLowerCase();
      if (!chave) recusar('Em "glifos", a busca é a chave da fonte (ex.: "kit-birthstonebounce", "great-vibes").');
      registrarFontesComGlifos();
      const daMarca = FONTES_COM_GLIFOS.find((f) => f.chave === chave);
      const leitura = daMarca ? await glifosDoArquivo(daMarca.url) : await glifosDaFonte(chave, 400);
      if (!leitura) recusar(`A fonte "${chave}" não está na lista. Veja as chaves no guia_do_editor (parte "fontes" e "biblioteca").`);
      const letras = Object.entries(leitura!.alternativas)
        .filter(([c]) => /^[A-Za-z]$/.test(String.fromCodePoint(Number(c))))
        .map(([c, recursos]) => ({ letra: String.fromCodePoint(Number(c)), recursos }));
      const enfeites = leitura!.caracteres.filter((c) => c >= 0xe000 && c <= 0xf8fe).map((c) => String.fromCodePoint(c));
      lista = [
        {
          fonte: chave,
          letras_com_floreio: letras,
          enfeites_soltos: enfeites,
          recursos_da_palavra_toda: leitura!.recursos.filter((r) => /^(swsh|cswh|salt|dlig|liga|calt|init|fina|smcp|onum|frac|ss\d\d)$/.test(r)),
          como_usar: `letra com floreio: um trecho no "html" com o recurso, ex.: "Marin<span style=\\"font-feature-settings:'fina' 1\\">a</span>"; enfeite solto: o caractere direto no "texto" (numa peça própria, com a mesma fonte, para usar como traço decorativo).`,
        },
      ];
    } else if (tipo === "fotos") {
      lista = (await listaDeFotosDaCasa(ctx)).filter((n) => casa(palavras, n.replace(/-/g, " "))).map((n) => ({ imagem: `foto:${n}`, recorte_sem_fundo: /recorte|sem-fundo|png/.test(n) }));
    } else if (tipo === "elementos") {
      lista = GRUPOS_DE_ELEMENTOS.flatMap((g) => g.lista.map((e) => ({ elemento: e.chave, grupo: g.nome, descricao: descricaoDoElemento(e as never) }))).filter((e) => casa(palavras, e.elemento, e.grupo, e.descricao));
    } else if (tipo === "modelos") {
      lista = MODELOS_DO_EDITOR.filter((m) => m.chave !== "branco" && casa(palavras, m.chave, m.nome, m.descricao, m.ramo, m.feitoPara)).map((m) => ({
        modelo: m.chave,
        nome: m.nome,
        descricao: m.descricao,
        feito_para: m.feitoPara ?? null,
        formato: TIPOS_DE_MODELO.find((t) => t.filtro(m))?.formato ?? null,
        ...(m.paginas ? { paginas: m.paginas } : {}),
        ...(m.animado ? { animado: true } : {}),
      }));
    } else if (tipo === "sons") {
      lista = CATALOGO_DE_SONS.filter((s) => {
        const extra = s as unknown as { grupo?: string; etiquetas?: string[] | string };
        return casa(palavras, s.chave, s.nome, s.tipo, extra.grupo, Array.isArray(extra.etiquetas) ? extra.etiquetas.join(" ") : extra.etiquetas);
      }).map((s) => ({ som: s.chave, nome: s.nome, tipo: s.tipo, grupo: (s as unknown as { grupo?: string }).grupo ?? null, segundos: Math.round(s.duracao * 10) / 10 }));
    } else {
      lista = FONTES.filter((f) => casa(palavras, f.chave, f.familia, f.tipo)).map((f) => ({ fonte: f.chave, familia: f.familia, tipo: f.tipo, pesos: f.pesos }));
    }
    /* Recortes e figurinhas voltam também como uma PRANCHA numerada: você vê o que está escolhendo (o número é a posição na lista). */
    const vistos = lista.slice(0, limite);
    const miniaturas = vistos.map((x) => miniaturaDoAchado(x)).filter((u): u is string => Boolean(u)).slice(0, 36);
    const prancha = miniaturas.length && (tipo === "recortes" || tipo === "figurinhas") ? await pranchaDosAchados(miniaturas).catch(() => null) : null;
    return {
      dados: { tipo, achados: lista.length, lista: vistos, ...(prancha ? { prancha: "A imagem mostra os primeiros achados numerados na ordem da lista: escolha olhando." } : {}), ...(lista.length > limite ? { aviso: `Mostrando ${limite} de ${lista.length}. Use palavras mais específicas.` } : {}) },
      resumo: `Procurou ${tipo} no editor de artes${palavras.length ? ` ("${palavras.join(" ")}")` : ""}`,
      ...(prancha ? { imagens: [prancha] } : {}),
    };
  },
};

/* A miniatura de um achado (recorte, ícone 3D, ornamento ou figurinha), para a prancha. */
function miniaturaDoAchado(x: Record<string, unknown>): string | null {
  const imagem = String(x.imagem ?? "");
  if (imagem.startsWith("figurinha:")) return `${DEPOSITO_DAS_FIGURINHAS}/mini/${imagem.slice(10)}.webp`;
  if (!imagem.startsWith("recorte:")) return null;
  const c = imagem.slice(8);
  if (x.svg) return `${DEPOSITO_DA_BIBLIOTECA}/vetores/mini/${c}.webp`;
  if (x.icone_3d) {
    const variantes = (x.variacoes as string[] | undefined)?.map((v) => v.split("/").pop() ?? "") ?? [];
    return `${DEPOSITO_DA_BIBLIOTECA}/${c}/mini-${variantes.includes("dynamic_color") ? "dynamic_color" : variantes[0]}.webp`;
  }
  return `${DEPOSITO_DA_BIBLIOTECA}/mini/${c}.webp`;
}

/* As miniaturas numeradas numa grade (6 por linha, fundo xadrez claro para ver o recorte). */
async function pranchaDosAchados(urls: string[]): Promise<Foto> {
  const LADO = 170;
  const COLUNAS = 6;
  const baixadas = await Promise.all(
    urls.map(async (u) => {
      const r = await fetch(u, { signal: AbortSignal.timeout(8000) }).catch(() => null);
      return r?.ok ? Buffer.from(await r.arrayBuffer()) : null;
    }),
  );
  const fundo = Buffer.from(`<svg xmlns="http://www.w3.org/2000/svg" width="${LADO}" height="${LADO}"><rect width="100%" height="100%" fill="#F4F2F9"/><rect x="0" y="0" width="${LADO}" height="${LADO}" fill="none" stroke="#E2DEEC"/></svg>`);
  const partes: sharp.OverlayOptions[] = [];
  for (const [i, b] of baixadas.entries()) {
    const x = (i % COLUNAS) * LADO;
    const y = Math.floor(i / COLUNAS) * LADO;
    partes.push({ input: fundo, left: x, top: y });
    if (b) {
      const mini = await sharp(b).resize(LADO - 30, LADO - 30, { fit: "inside" }).png().toBuffer().catch(() => null);
      if (mini) partes.push({ input: mini, left: x + 15, top: y + 18 });
    }
    partes.push({ input: Buffer.from(`<svg xmlns="http://www.w3.org/2000/svg" width="34" height="22"><rect width="34" height="22" rx="6" fill="#1F1B2E"/><text x="17" y="16" font-family="Arial" font-size="13" font-weight="700" fill="#FFFFFF" text-anchor="middle">${i + 1}</text></svg>`), left: x + 4, top: y + 4 });
  }
  const linhas = Math.ceil(urls.length / COLUNAS);
  const png = await sharp({ create: { width: COLUNAS * LADO, height: linhas * LADO, channels: 3, background: "#FFFFFF" } }).composite(partes).png().toBuffer();
  return paraVer(png, 1100);
}

/* ============================================================ listar e ver */

const listarArtes: FerramentaDeLeitura = {
  tipo: "leitura",
  nome: "listar_artes",
  titulo: "Listar artes",
  descricao: "Lista os designs da empresa no KtaloguDesign (os mais recentes primeiro), com o id, o tamanho e os dois links de cada um (o público, para ver e baixar, e o do editor).",
  campos: {
    busca: { tipo: "texto", max: 80, descricao: "Parte do nome da arte (opcional)." },
    limite: { tipo: "inteiro", min: 1, max: 60, descricao: "Quantas (padrão 20)." },
  },
  permissoes: PERMISSOES,
  async executar(ctx, args) {
    let consulta = ctx.supabase.from("artes_do_editor").select("id,nome,largura,altura,formato,updated_at").eq("user_id", donoDasArtes(ctx)).order("updated_at", { ascending: false }).limit(Number(args.limite) || 20);
    const busca = String(args.busca ?? "").replace(/[%_,()]/g, " ").trim();
    if (busca) consulta = consulta.ilike("nome", `%${busca}%`);
    const { data, error } = await consulta;
    if (error) falha("listar as artes", error);
    const artes = await Promise.all(((data || []) as Array<Omit<LinhaDaArte, "documento">>).map(async (a) => ({ arte_id: a.id, nome: a.nome, tamanho: `${a.largura} × ${a.altura}`, formato: a.formato, atualizada_em: a.updated_at, ...(await linksDaArte(ctx, a.id)) })));
    return { dados: { artes }, resumo: `Listou ${artes.length} arte(s) do editor` };
  },
};

/* A prancha dos instantes da animação: uma foto por instante, lado a lado, com o segundo escrito. */
async function pranchaDosInstantes(fotos: Buffer[], rotulos: string[]): Promise<Buffer> {
  const meta = await sharp(fotos[0]).metadata();
  const r = (meta.width || 1) / (meta.height || 1);
  const colunas = fotos.length <= 2 ? fotos.length : r < 0.8 ? Math.min(4, fotos.length) : Math.min(3, fotos.length);
  const linhas = Math.ceil(fotos.length / colunas);
  const largura = Math.round(Math.min(1500, 380 * colunas) / colunas);
  const altura = Math.round(largura / r);
  const vao = 12;
  const azulejos = await Promise.all(
    fotos.map(async (f, i) => {
      const rotulo = Buffer.from(`<svg xmlns="http://www.w3.org/2000/svg" width="${largura}" height="${altura}"><rect x="8" y="8" rx="10" width="${16 + rotulos[i].length * 13}" height="34" fill="#000" fill-opacity="0.72"/><text x="${16}" y="31" font-family="Arial, sans-serif" font-size="20" font-weight="700" fill="#fff">${rotulos[i]}</text></svg>`);
      const base = await sharp(f).resize(largura, altura, { fit: "fill" }).flatten({ background: "#FFFFFF" }).png().toBuffer();
      return { input: await sharp(base).composite([{ input: rotulo }]).png().toBuffer(), left: (i % colunas) * (largura + vao), top: Math.floor(i / colunas) * (altura + vao) };
    }),
  );
  return sharp({ create: { width: colunas * largura + (colunas - 1) * vao, height: linhas * altura + (linhas - 1) * vao, channels: 3, background: "#E9E6F0" } })
    .composite(azulejos)
    .png()
    .toBuffer();
}

const verArte: FerramentaDeLeitura = {
  tipo: "leitura",
  nome: "ver_arte",
  titulo: "Ver arte",
  descricao:
    'Mostra uma arte do editor: o resumo (cada página com as peças, ids, textos, caixas e animações, e a trilha de áudio), os links e a FOTO de cada página. Com "instantes" (segundos do vídeo inteiro, ex.: [0.3, 1, 2.5, 4]), devolve uma prancha com a arte parada em cada instante, para conferir a coreografia da animação. Com com_documento, devolve também o documento completo (para mexer em campos que o jeito curto não cobre, via alterar_arte "trocar_documento").',
  campos: {
    arte_id: { tipo: "id", obrigatorio: true, descricao: "O id da arte (de criar_arte ou listar_artes)." },
    paginas: { tipo: "livre", max: 400, descricao: "As páginas a fotografar (números a partir de 1, ex.: [1, 3]). Padrão: as primeiras 6." },
    instantes: { tipo: "livre", max: 400, descricao: "Segundos do vídeo para fotografar a animação (até 8, ex.: [0.2, 0.8, 1.6, 3]). Em vez das fotos das páginas." },
    sem_foto: { tipo: "booleano", descricao: "Só o resumo, sem fotos (mais rápido)." },
    com_documento: { tipo: "booleano", descricao: "Junta o documento completo da arte (JSON do editor)." },
  },
  permissoes: PERMISSOES,
  async executar(ctx, args) {
    const arte = await lerArte(ctx, args.arte_id);
    const paginas = paginasDoDocumento(arte.doc);
    let imagens: Foto[] = [];
    let sobreAsFotos = "";
    if (args.sem_foto !== true) {
      if (Array.isArray(args.instantes) && args.instantes.length) {
        const { total } = temposDoProjeto(paginas);
        const instantes = (args.instantes as unknown[]).map(Number).filter((t) => Number.isFinite(t) && t >= 0).slice(0, 8).map((t) => Math.min(t, Math.max(0, total - 0.01)));
        if (!instantes.length) recusar('"instantes" precisa de números em segundos, ex.: [0.5, 1.5, 3].');
        const poses = instantes.map((t) => {
          const { indice, local } = paginaNoTempo(paginas, t);
          return poseNoInstante(paginas[indice], local);
        });
        const { fotos } = await fotografar(poses);
        imagens = [await paraVer(await pranchaDosInstantes(fotos, instantes.map((t) => `${t.toFixed(1).replace(".", ",")} s`)), 1500)];
        sobreAsFotos = `Uma prancha com a arte em ${instantes.length} instante(s): ${instantes.map((t) => `${t.toFixed(1)} s`).join(", ")} (o 3D, as máscaras e as palavras aparecem simplificados; o ritmo e as posições são os do vídeo).`;
      } else {
        const pedidas = Array.isArray(args.paginas) ? [...new Set((args.paginas as unknown[]).map((n) => Math.round(Number(n)) - 1).filter((i) => i >= 0 && i < paginas.length))].slice(0, PAGINAS_NA_FOTO) : paginas.slice(0, PAGINAS_NA_FOTO).map((_, i) => i);
        if (!pedidas.length) recusar(`A arte tem ${paginas.length} página(s).`);
        const { fotos } = await fotografar(pedidas.map((i) => paginas[i]));
        imagens = await Promise.all(fotos.map((f) => paraVer(f)));
        sobreAsFotos = `Foto(s) da(s) página(s) ${pedidas.map((i) => i + 1).join(", ")}, na ordem.`;
      }
    }
    const documento = args.com_documento === true ? arte.doc : undefined;
    const tamanhoDoDocumento = documento ? JSON.stringify(documento).length : 0;
    return {
      dados: {
        arte_id: arte.id,
        nome: arte.nome,
        formato: arte.formato,
        atualizada_em: arte.updated_at,
        ...(await linksDaArte(ctx, arte.id)),
        arte: resumoDaArte(arte.doc),
        ...(sobreAsFotos ? { fotos: sobreAsFotos } : {}),
        ...(documento ? (tamanhoDoDocumento <= 150_000 ? { documento } : { documento: null, aviso: `O documento tem ${Math.round(tamanhoDoDocumento / 1000)} mil letras, grande demais para mandar. Use o resumo e alterar_arte com as operações.` }) : {}),
      },
      resumo: `Viu a arte "${arte.nome}"`,
      imagens,
    };
  },
};

/* ============================================================ criar */

const CAMPO_DO_DOCUMENTO = 'A arte no jeito curto: { "paginas": [ { "fundo": ..., "duracao": 3, "movimento": ..., "transicao": ..., "pecas": [ ... ] } ], "audios": [ { "som": "<chave>", "inicio": 0 } ] }. Cada peça: { "tipo": "texto" | "forma" | "imagem" | "icone" | "textura" | "traco" | "qr", x, y, w, h, ... } ou { "elemento": "<chave>", "caixa": {x,y,w,h} }. Os campos de cada tipo, os efeitos, a biblioteca, a animação e o som estão no guia_do_editor (partes "direcao", "efeitos", "biblioteca", "pecas", "animacao", "som", "receitas").';

type CargaDaCriacao = { nome: string; largura: number; altura: number; formato: string | null; modelo: string | null; documento: unknown; operacoes: Array<Record<string, unknown>>; foto: string | null };

const criarArte: FerramentaDeEscrita<CargaDaCriacao> = {
  tipo: "escrita",
  nome: "criar_arte",
  titulo: "Criar arte",
  descricao:
    'Cria uma arte no KtaloguDesign (o editor de artes da Ktalogu, como o Canva + CapCut): post, story, reels animado com música e narração, carrossel, banner, flyer, impressão. Leia antes o guia_do_editor ("comeco", "direcao", "efeitos" e "biblioteca") e junte os recursos com procurar_no_editor (recortes, ícones 3D, ornamentos, figurinhas, fotos, produto). Monte do zero com "documento" ou parta de um "modelo" pronto (com a cor e o logo da loja; "operacoes" ajusta na hora). O servidor guarda as imagens, encolhe o texto que não cabe e devolve a FOTO de cada página (olhe e corrija com alterar_arte), o link_publico (ver, tocar e baixar em PNG, JPG, PDF e MP4, para mandar a quem quiser) e o link_do_editor (para a pessoa editar, logada).',
  campos: {
    nome: { tipo: "texto", obrigatorio: true, max: 120, descricao: 'O nome da arte nos designs da empresa (ex.: "Post promoção caneca").' },
    formato: { tipo: "texto", max: 40, descricao: `O tamanho pronto: ${FORMATOS.join(", ")} (detalhes no guia "comeco"). Ou largura e altura.` },
    largura: { tipo: "inteiro", min: 100, max: MAIOR_LADO_GUARDADO, descricao: "Largura em px (sem formato)." },
    altura: { tipo: "inteiro", min: 100, max: MAIOR_LADO_GUARDADO, descricao: "Altura em px (sem formato)." },
    modelo: { tipo: "texto", max: 60, descricao: 'A chave de um modelo pronto (procurar_no_editor tipo "modelos" ou guia "modelos"). Sem formato, a arte nasce no tamanho do modelo.' },
    foto: { tipo: "texto", max: 2000, descricao: 'Só com modelo: a foto que entra na moldura principal do modelo ("envio:...", "produto:<id>", "foto:<nome>" ou link https://).' },
    documento: { tipo: "livre", max: 400_000, descricao: CAMPO_DO_DOCUMENTO },
    operacoes: { tipo: "livre", max: 200_000, descricao: 'Ajustes logo depois de montar (as mesmas operações de alterar_arte), útil com modelo: ex.: [{ "op": "alterar_peca", "pagina": 1, "id": "<id>", "campos": { "texto": "NOVO TÍTULO" } }]. Os ids do modelo: criar sem operações e ver o resumo, ou ver_arte.' },
  },
  permissoes: PERMISSOES,
  abertoAoMundo: true,
  async preparar(ctx, args) {
    const modelo = typeof args.modelo === "string" && args.modelo.trim() ? MODELOS_DO_EDITOR.find((m) => m.chave === String(args.modelo).trim()) : null;
    if (args.modelo !== undefined && String(args.modelo).trim() && !modelo) recusar(`O modelo "${String(args.modelo)}" não existe. Procure com procurar_no_editor (tipo "modelos").`);
    if (!modelo && args.documento === undefined) recusar('Mande "documento" (as páginas com as peças) ou escolha um "modelo".');
    const formatoDoModelo = modelo ? (TIPOS_DE_MODELO.find((t) => t.filtro(modelo))?.formato ?? null) : null;
    const tamanho = tamanhoDaArte(args.formato ?? (args.largura === undefined ? formatoDoModelo : undefined), args.largura, args.altura);
    if (Math.max(tamanho.largura, tamanho.altura) > MAIOR_LADO_GUARDADO) recusar(`O editor guarda artes de até ${MAIOR_LADO_GUARDADO} px de lado. Para impressão grande, use um formato de impressão (a4, a3...).`);
    const operacoes = args.operacoes === undefined ? [] : Array.isArray(args.operacoes) ? (args.operacoes as Array<Record<string, unknown>>) : recusar('"operacoes" precisa ser uma lista.');
    /* Confere a arte já aqui (o jeito curto e as operações), com as imagens ainda por buscar. */
    const base = modelo ? modelo.montar(pedidoDoModelo(tamanho, { cor: "#7C3AED", logo: null })) : null;
    let doc = documentoDoAssistente(ehObjeto(args.documento) ? args.documento : {}, tamanho, base);
    if (operacoes.length) doc = aplicarOperacoes(doc, operacoes).doc;
    const paginas = paginasDoDocumento(doc);
    const textos = paginas.flatMap((p) => p.pecas.filter((x): x is PecaDeTexto => x.tipo === "texto").map((x) => x.texto.replace(/\s+/g, " ").slice(0, 80)));
    return {
      previa: {
        nome: String(args.nome),
        tamanho: `${tamanho.largura} × ${tamanho.altura} px${tamanho.formato ? ` (${tamanho.formato})` : ""}`,
        ...(modelo ? { modelo: modelo.nome } : {}),
        paginas: paginas.length,
        pecas: paginas.reduce((s, p) => s + p.pecas.length, 0),
        textos: textos.slice(0, 20),
        audios: audiosDoProjeto(paginas).length,
        duracao_do_video: Math.round(temposDoProjeto(paginas).total * 10) / 10,
      },
      resumo: `Prévia da arte "${String(args.nome)}"`,
      avisos: [],
      carga: { nome: String(args.nome).trim(), largura: tamanho.largura, altura: tamanho.altura, formato: tamanho.formato, modelo: modelo?.chave ?? null, documento: args.documento ?? null, operacoes, foto: typeof args.foto === "string" && args.foto.trim() ? args.foto.trim() : null },
    };
  },
  async gravar(ctx, carga) {
    const avisos: string[] = [];
    const m = { largura: carga.largura, altura: carga.altura };
    const modelo = carga.modelo ? MODELOS_DO_EDITOR.find((x) => x.chave === carga.modelo) ?? null : null;
    let base: DocumentoDoBanner | null = null;
    if (modelo) {
      const marca = await marcaDaLoja(ctx);
      const foto = carga.foto ? String(((await comImagensNoDeposito(ctx, { imagem: carga.foto }, avisos)) as { imagem: string }).imagem) : null;
      base = modelo.montar({ ...pedidoDoModelo(m, marca), proporcaoDoLogo: marca.proporcaoDoLogo, foto });
    }
    const documento = await comImagensNoDeposito(ctx, await comDesenhosDosIcones(carga.documento ?? {}), avisos);
    const operacoes = (await comImagensNoDeposito(ctx, await comDesenhosDosIcones(carga.operacoes), avisos)) as Array<Record<string, unknown>>;
    let doc = documentoDoAssistente(ehObjeto(documento) ? documento : {}, m, base);
    const feitos = operacoes.length ? aplicarOperacoes(doc, operacoes) : null;
    if (feitos) doc = feitos.doc;
    const acertada = await acertarEFotografar(doc);
    doc = acertada.doc;
    conferirTamanhoDoDocumento(doc);

    const { data, error } = await ctx.supabase.from("artes_do_editor").insert({ user_id: donoDasArtes(ctx), nome: carga.nome.slice(0, 120) || "Arte sem nome", largura: carga.largura, altura: carga.altura, formato: carga.formato, documento: doc }).select("id,nome,updated_at").single();
    if (error?.message?.includes("grande demais")) recusar(error.message);
    if (error || !data) falha("guardar a arte", error);
    const linha = data as { id: string; nome: string; updated_at: string };
    await guardarMiniatura(ctx, linha.id, linha.updated_at, acertada.fotos[0]);
    return saidaDaArte(
      ctx,
      linha,
      doc,
      acertada.fotos,
      {
        ...(feitos ? { operacoes_feitas: feitos.feitos } : {}),
        ...(acertada.encolhidos.length ? { textos_encolhidos: acertada.encolhidos } : {}),
        ...(avisos.length ? { avisos } : {}),
      },
      `Criou a arte "${linha.nome}" no editor (${carga.largura} × ${carga.altura})`,
    );
  },
};

/* ============================================================ alterar */

type CargaDaAlteracao = { id: string; operacoes: Array<Record<string, unknown>>; nome: string | null };

const alterarArte: FerramentaDeEscrita<CargaDaAlteracao> = {
  tipo: "escrita",
  nome: "alterar_arte",
  titulo: "Alterar arte",
  descricao: `Muda uma arte do editor com operações, em ordem (páginas contam de 1; os ids das peças vêm de ver_arte ou de criar_arte): ${OPERACOES.join(", ")}. "alterar_peca" aceita o jeito curto (cor, degrade, sombra, borda, imagem, destaque, fundo do texto, giro3d, animacao) e qualquer campo da peça. Devolve a foto nova de cada página e os links.`,
  campos: {
    arte_id: { tipo: "id", obrigatorio: true, descricao: "O id da arte (de criar_arte ou listar_artes)." },
    operacoes: {
      tipo: "livre",
      max: 400_000,
      descricao:
        'A lista de operações. Ex.: [{ "op": "alterar_peca", "pagina": 1, "id": "tx1", "campos": { "texto": "50% OFF", "cor": "#FFD400" } }, { "op": "acrescentar_pecas", "pagina": 2, "pecas": [ ... ] }, { "op": "remover_pecas", "pagina": 1, "ids": ["fm3"] }, { "op": "ordenar_peca", "pagina": 1, "id": "im2", "para": "frente" }, { "op": "acrescentar_pagina", "pagina_nova": { "fundo": "#000", "pecas": [ ... ] }, "depois_de": 1 }, { "op": "duplicar_pagina", "pagina": 1 }, { "op": "alterar_pagina", "pagina": 2, "fundo": {...}, "duracao": 3, "transicao": "circulo" }, { "op": "animar_pagina", "pagina": 1, "estilo": "pop" }, { "op": "acrescentar_audio", "som": "<chave>", "inicio": 0 }, { "op": "trocar_documento", "documento": { "paginas": [...] } }].',
    },
    nome: { tipo: "texto", max: 120, descricao: "Um nome novo para a arte (opcional)." },
  },
  permissoes: PERMISSOES,
  sobrescreve: true,
  abertoAoMundo: true,
  async preparar(ctx, args) {
    const arte = await lerArte(ctx, args.arte_id);
    const operacoes = Array.isArray(args.operacoes) ? (args.operacoes as Array<Record<string, unknown>>) : recusar('"operacoes" precisa ser uma lista de operações.');
    if (!operacoes.length && args.nome === undefined) recusar("Mande ao menos uma operação (ou um nome novo).");
    if (operacoes.length > 200) recusar("Até 200 operações por vez.");
    /* Confere as operações já na prévia (o que falhar volta antes de gravar). */
    aplicarOperacoes(arte.doc, operacoes);
    return {
      previa: { arte: arte.nome, operacoes: operacoes.map((o) => String(o.op)), ...(args.nome !== undefined ? { nome_novo: String(args.nome) } : {}) },
      resumo: `Prévia da alteração da arte "${arte.nome}"`,
      avisos: [],
      carga: { id: arte.id, operacoes, nome: args.nome !== undefined ? String(args.nome).trim().slice(0, 120) || null : null },
    };
  },
  async gravar(ctx, carga) {
    const avisos: string[] = [];
    const arte = await lerArte(ctx, carga.id);
    const operacoes = (await comImagensNoDeposito(ctx, await comDesenhosDosIcones(carga.operacoes), avisos)) as Array<Record<string, unknown>>;
    const { doc: mexida, feitos } = aplicarOperacoes(arte.doc, operacoes);
    const acertada = await acertarEFotografar(mexida);
    conferirTamanhoDoDocumento(acertada.doc);
    const { data, error } = await ctx.supabase
      .from("artes_do_editor")
      .update({ documento: acertada.doc, ...(carga.nome ? { nome: carga.nome } : {}) })
      .eq("id", arte.id)
      .eq("user_id", donoDasArtes(ctx))
      .select("id,nome,updated_at")
      .single();
    if (error?.message?.includes("grande demais")) recusar(error.message);
    if (error || !data) falha("guardar a arte", error);
    const linha = data as { id: string; nome: string; updated_at: string };
    await guardarMiniatura(ctx, linha.id, linha.updated_at, acertada.fotos[0]);
    return saidaDaArte(ctx, linha, acertada.doc, acertada.fotos, { feito: feitos, ...(acertada.encolhidos.length ? { textos_encolhidos: acertada.encolhidos } : {}), ...(avisos.length ? { avisos } : {}) }, `Alterou a arte "${linha.nome}" (${feitos.length} mudança(s))`);
  },
};

/* ============================================================ narrar */

/* As amostras do áudio (mono, de -1 a 1): o WAV do Gemini direto; o resto pelo ffmpeg, se o servidor tiver. */
function amostrasDoAudio(bruto: Buffer): { amostras: Float32Array; taxa: number } | null {
  if (bruto.toString("ascii", 0, 4) === "RIFF" && bruto.toString("ascii", 8, 12) === "WAVE") {
    let pos = 12;
    let taxa = 0;
    let canais = 1;
    let bits = 16;
    while (pos + 8 <= bruto.length) {
      const id = bruto.toString("ascii", pos, pos + 4);
      const tamanho = bruto.readUInt32LE(pos + 4);
      const corpo = pos + 8;
      if (id === "fmt ") {
        canais = bruto.readUInt16LE(corpo + 2) || 1;
        taxa = bruto.readUInt32LE(corpo + 4);
        bits = bruto.readUInt16LE(corpo + 14);
      } else if (id === "data") {
        if (bits !== 16 || !taxa) return null;
        const dados = bruto.subarray(corpo, Math.min(bruto.length, corpo + tamanho));
        const n = Math.floor(dados.length / 2 / canais);
        const amostras = new Float32Array(n);
        for (let i = 0; i < n; i += 1) amostras[i] = dados.readInt16LE(i * 2 * canais) / 32768;
        return { amostras, taxa };
      }
      pos = corpo + tamanho + (tamanho % 2);
    }
    return null;
  }
  const taxa = 24000;
  const saida = spawnSync("ffmpeg", ["-loglevel", "error", "-i", "pipe:0", "-f", "f32le", "-ac", "1", "-ar", String(taxa), "pipe:1"], { input: bruto, maxBuffer: 256 * 1024 * 1024 });
  if (saida.status !== 0 || !saida.stdout?.length) return null;
  const copia = Buffer.from(saida.stdout);
  return { amostras: new Float32Array(copia.buffer, copia.byteOffset, Math.floor(copia.length / 4)), taxa };
}

/* A onda da trilha (20 alturas por segundo, de 0 a 100), a mesma conta do editor (onda-do-audio.ts). */
function ondaDas(amostras: Float32Array, taxa: number) {
  const duracao = amostras.length / taxa;
  const pontos = Math.max(40, Math.min(1200, Math.round(duracao * 20)));
  const passo = Math.max(1, Math.floor(amostras.length / pontos));
  const picos = Array.from({ length: pontos }, (_, k) => {
    let p = 0;
    for (let i = k * passo; i < Math.min(amostras.length, (k + 1) * passo); i += 8) p = Math.max(p, Math.abs(amostras[i]));
    return p;
  });
  const maior = Math.max(...picos, 1e-6);
  return { duracao: Math.round(duracao * 100) / 100, onda: picos.map((p) => Math.round((p / maior) * 100)) };
}

type CargaDaNarracao = { id: string; texto: string; voz: (typeof VOZES)[number]["chave"]; jeito: (typeof JEITOS_DE_FALAR)[number]["chave"]; legenda: (typeof ESTILOS_DA_LEGENDA)[number]["chave"] | null; inicio: number };

const narrarArte: FerramentaDeEscrita<CargaDaNarracao> = {
  tipo: "escrita",
  nome: "narrar_arte",
  titulo: "Narrar arte",
  descricao: `Põe uma narração (voz gerada) na trilha do vídeo de uma arte e, se quiser, a legenda automática: cada palavra aparece no tempo exato da fala. Vozes: ${VOZES.map((v) => `"${v.chave}" (${v.nome}, ${v.detalhe})`).join(", ")}. Jeitos: ${JEITOS_DE_FALAR.map((j) => `"${j.chave}" (${j.nome})`).join(", ")}. Legendas: ${ESTILOS_DA_LEGENDA.map((e) => `"${e.chave}" (${e.nome})`).join(", ")} ou "nenhuma". A música baixa sozinha para a voz aparecer, e a última cena estica se a fala passar do vídeo. Até 40 por dia.`,
  campos: {
    arte_id: { tipo: "id", obrigatorio: true, descricao: "O id da arte (de criar_arte ou listar_artes)." },
    texto: { tipo: "texto", obrigatorio: true, max: MAXIMO_DA_NARRACAO, descricao: "O que a voz fala (frases curtas e naturais, com pontuação; números por extenso quando a leitura puder confundir)." },
    voz: { tipo: "opcao", opcoes: VOZES.map((v) => v.chave), descricao: 'A voz (padrão "nova").' },
    jeito: { tipo: "opcao", opcoes: JEITOS_DE_FALAR.map((j) => j.chave), descricao: 'O jeito de falar (padrão "anuncio").' },
    legenda: { tipo: "opcao", opcoes: [...ESTILOS_DA_LEGENDA.map((e) => e.chave), "nenhuma"], descricao: 'O estilo da legenda (padrão "palavras"; "nenhuma" = sem legenda).' },
    inicio: { tipo: "numero", min: 0, max: 600, descricao: "Em que segundo do vídeo a fala começa (padrão 0)." },
  },
  permissoes: PERMISSOES,
  async preparar(ctx, args) {
    const arte = await lerArte(ctx, args.arte_id);
    const texto = String(args.texto ?? "").replace(/\s+/g, " ").trim();
    if (!texto) recusar("Escreva o que a voz vai falar.");
    const voz = VOZES.find((v) => v.chave === args.voz)?.chave ?? (args.voz === undefined ? "nova" : recusar(`Voz "${String(args.voz)}" não existe. Use: ${VOZES.map((v) => v.chave).join(", ")}.`));
    const jeito = JEITOS_DE_FALAR.find((j) => j.chave === args.jeito)?.chave ?? (args.jeito === undefined ? "anuncio" : recusar(`Jeito "${String(args.jeito)}" não existe. Use: ${JEITOS_DE_FALAR.map((j) => j.chave).join(", ")}.`));
    const legenda = args.legenda === "nenhuma" ? null : (ESTILOS_DA_LEGENDA.find((e) => e.chave === args.legenda)?.chave ?? (args.legenda === undefined ? "palavras" : recusar(`Legenda "${String(args.legenda)}" não existe. Use: ${ESTILOS_DA_LEGENDA.map((e) => e.chave).join(", ")} ou "nenhuma".`)));
    return {
      previa: { arte: arte.nome, texto, voz, jeito, legenda: legenda ?? "nenhuma", comeca_em: Number(args.inicio) || 0 },
      resumo: `Prévia da narração da arte "${arte.nome}"`,
      avisos: [],
      carga: { id: arte.id, texto, voz, jeito, legenda, inicio: Math.max(0, Number(args.inicio) || 0) },
    };
  },
  async gravar(ctx, carga) {
    if (!podeUsar(`narracao:${ctx.user.id}`, 40)) recusar("Já foram geradas muitas narrações hoje por esta pessoa. Tente de novo amanhã.");
    const arte = await lerArte(ctx, carga.id);
    let gerada: { audio: Buffer; tipo: string };
    try {
      gerada = await gerarVoz(carga.texto, carga.voz, carga.jeito);
    } catch (erro) {
      console.error("[mcp] narração", erro instanceof Error ? erro.message : erro);
      recusar("A voz não saiu agora. Tente de novo em um minuto.");
    }
    const som = amostrasDoAudio(gerada.audio);
    if (!som || !som.amostras.length) recusar("A voz veio num formato que o servidor não conseguiu ler. Tente de novo; se repetir, faça a narração pelo editor.");
    const { duracao, onda } = ondaDas(som.amostras, som.taxa);
    const extensao = gerada.tipo.includes("wav") ? "wav" : "mp3";
    const caminho = `${PASTA_DOS_AUDIOS}/narracao_${Math.random().toString(36).slice(2, 12)}_${Date.now()}.${extensao}`;
    const { error: erroDoEnvio } = await ctx.supabase.storage.from("products").upload(caminho, gerada.audio, { contentType: extensao === "wav" ? "audio/wav" : "audio/mpeg", cacheControl: "31536000" });
    if (erroDoEnvio) falha("guardar a voz", erroDoEnvio);
    const src = ctx.supabase.storage.from("products").getPublicUrl(caminho).data.publicUrl;

    let paginas = paginasDoDocumento(arte.doc);
    const { total } = temposDoProjeto(paginas);
    const inicio = carga.inicio >= total - 0.3 ? 0 : carga.inicio;
    const nomeDaVoz = VOZES.find((v) => v.chave === carga.voz)?.nome ?? "Voz";
    const novo: AudioDoProjeto = { id: novoId("a"), nome: `Narração (${nomeDaVoz})`, src, tipo: "enviado", duracao, inicio, corte: 0, dura: duracao, volume: 1, suave: false, fadeDeEntrada: 0, fadeDeSaida: 0.2, onda };
    /* A fala inteira cabe: a última cena estica. A música baixa para a voz aparecer. */
    const falta = inicio + duracao + 0.4 - total;
    if (falta > 0) paginas = paginas.map((p, i) => (i === paginas.length - 1 ? { ...p, animacao: { fundo: p.animacao?.fundo ?? "parado", duracao: Math.min(60, Math.round((duracaoDaPagina(p) + falta) * 10) / 10) } } : p));
    const baixadas: string[] = [];
    const audios = audiosDoProjeto(paginas).map((a) => {
      if (a.tipo !== "musica" || a.volume <= 0.25) return a;
      baixadas.push(a.nome);
      return { ...a, volume: 0.22 };
    });
    paginas = comAudios(paginas, [...audios, novo]);
    if (carga.legenda) paginas = comLegenda(paginas, alinharNoSom(carga.texto.split(/\s+/), som.amostras, som.taxa), inicio, 0, carga.legenda);
    const doc = documentoLimpo(documentoDasPaginas(paginas), arte.largura, arte.altura);
    conferirTamanhoDoDocumento(doc);
    const { data, error } = await ctx.supabase.from("artes_do_editor").update({ documento: doc }).eq("id", arte.id).eq("user_id", donoDasArtes(ctx)).select("id,nome,updated_at").single();
    if (error || !data) falha("guardar a arte", error);
    const linha = data as { id: string; nome: string; updated_at: string };

    /* A prova: a arte em quatro instantes da fala (com a legenda aparecendo). */
    const finais = paginasDoDocumento(doc);
    const instantes = [0.15, 0.4, 0.65, 0.9].map((f) => Math.round((inicio + duracao * f) * 10) / 10);
    const { fotos } = await fotografar(
      instantes.map((t) => {
        const { indice, local } = paginaNoTempo(finais, t);
        return poseNoInstante(finais[indice], local);
      }),
    );
    const prancha = await paraVer(await pranchaDosInstantes(fotos, instantes.map((t) => `${t.toFixed(1).replace(".", ",")} s`)), 1500);
    return {
      dados: {
        arte_id: linha.id,
        nome: linha.nome,
        ...(await linksDaArte(ctx, linha.id)),
        narracao: { voz: nomeDaVoz, segundos: duracao, comeca_em: inicio, audio_id: novo.id, legenda: carga.legenda ?? "nenhuma" },
        ...(falta > 0 ? { aviso: `A fala passou do vídeo: a última cena ficou ${Math.round(falta * 10) / 10} s mais longa.` } : {}),
        ...(baixadas.length ? { musica_baixada: `Volume de ${baixadas.join(", ")} baixou para 22% para a voz aparecer.` } : {}),
        arte: resumoDaArte(doc),
        fotos: `Uma prancha com a arte em ${instantes.map((t) => `${t} s`).join(", ")} (a legenda aparecendo no tempo da fala).`,
        proximo_passo: "Confira a prancha (a legenda não pode cobrir o título nem o produto; se cobrir, mova as peças da legenda com alterar_arte). Depois mande o link_publico à pessoa: lá ela toca o vídeo com o som e baixa o MP4.",
      },
      resumo: `Narrou a arte "${linha.nome}" (${duracao} s${carga.legenda ? ", com legenda" : ""})`,
      imagens: [prancha],
    };
  },
};

/* ============================================================ baixar */

const baixarArte: FerramentaDeLeitura = {
  tipo: "leitura",
  nome: "baixar_arte",
  titulo: "Baixar arte",
  descricao:
    'Gera o arquivo de uma arte do editor e devolve o link (vale 24 horas): "png" (alta qualidade; "transparente" tira o fundo), "jpg" (leve) ou "pdf" (todas as páginas; "grafica" põe sangria de 3 mm e marcas de corte). PNG e JPG são de uma página. O vídeo MP4 sai pelo link_publico da arte (botão Baixar), que a pessoa abre no navegador.',
  campos: {
    arte_id: { tipo: "id", obrigatorio: true, descricao: "O id da arte (de criar_arte ou listar_artes)." },
    formato: { tipo: "opcao", opcoes: ["png", "jpg", "pdf"], descricao: 'O arquivo (padrão "png").' },
    pagina: { tipo: "inteiro", min: 1, max: 30, descricao: "PNG e JPG: a página (padrão 1)." },
    qualidade: { tipo: "inteiro", min: 1, max: 3, descricao: "Multiplica os pixels: 1, 2 ou 3 (padrão 2; o PDF é sempre 2 ou mais)." },
    transparente: { tipo: "booleano", descricao: "PNG sem o fundo da arte." },
    grafica: { tipo: "booleano", descricao: "PDF para gráfica: sangria de 3 mm e marcas de corte." },
  },
  permissoes: PERMISSOES,
  async executar(ctx, args) {
    const arte = await lerArte(ctx, args.arte_id);
    const formato = args.formato === "jpg" || args.formato === "pdf" ? args.formato : args.formato === undefined || args.formato === "png" ? "png" : recusar('Formato: "png", "jpg" ou "pdf". O MP4 sai pelo link_publico da arte.');
    let arquivo: { conteudo: Buffer; tipo: string; escala: number };
    try {
      arquivo = await arquivoDaArte(arte.doc, { formato, pagina: (Number(args.pagina) || 1) - 1, escala: Number(args.qualidade) || 2, transparente: args.transparente === true, grafica: args.grafica === true });
    } catch (erro) {
      if (erro instanceof ArquivoRecusado) recusar(erro.message);
      throw erro;
    }
    const entregue = await entregarArquivo(ctx, { conteudo: arquivo.conteudo, nome: nomeDoArquivo(arte.nome, formato), tipo: arquivo.tipo });
    return {
      dados: { arquivo: entregue.link, vale_ate: entregue.vale_ate, tamanho_kb: entregue.tamanho_kb, formato, escala: arquivo.escala, ...(await linksDaArte(ctx, arte.id)), como_entregar: COMO_ENTREGAR_O_ARQUIVO },
      resumo: `Baixou a arte "${arte.nome}" em ${formato.toUpperCase()}`,
    };
  },
};

/*
 * O KtaloguDesign está em beta (lib/banner-editor/liberacao.ts): fora das
 * empresas liberadas, toda ferramenta daqui responde que ainda não abriu (e
 * aponta o gerar_banner, que segue para todas).
 */
function exigirDesign(ctx: ContextoDoAssistente) {
  if (!designLiberado([ctx.empresa.companyId, ctx.empresa.ownerId, ctx.chave.companyId, ctx.donoDosDados])) {
    recusar(`${RECADO_DO_BETA} Para um banner simples da loja, use gerar_banner.`);
  }
}

function soComDesign(f: Ferramenta): Ferramenta {
  if (f.tipo === "leitura") {
    return {
      ...f,
      executar: async (ctx, args) => {
        exigirDesign(ctx);
        return f.executar(ctx, args);
      },
    };
  }
  return {
    ...f,
    preparar: async (ctx, args) => {
      exigirDesign(ctx);
      return f.preparar(ctx, args);
    },
    gravar: async (ctx, carga) => {
      exigirDesign(ctx);
      return f.gravar(ctx, carga);
    },
  };
}

export const FERRAMENTAS_DO_EDITOR_DE_ARTES: Ferramenta[] = ([guiaDoEditorFerramenta, procurarNoEditor, listarArtes, verArte, criarArte, alterarArte, narrarArte, baixarArte] as Ferramenta[]).map(soComDesign);
