#!/usr/bin/env bun
/**
 * orquestra-viva.ts — servidor local (Bun.serve, 127.0.0.1 apenas) que expõe
 * o estado da fila de jobs (scripts/fila.ts) como JSON + serve a cena 3D.
 * ORQUESTRA-LOCAL v1 — lê o mesmo disco do fila.ts (~/.egos/fila/<agente>/).
 *
 * REFATORACAO-ORGANICA-001 (04/09, incremental/golden-a-cada-passo — R-REFACTOR-ORG-001):
 * este arquivo tinha 1667L (rotas + coletores + spawn misturados). Ficou só servidor +
 * roteador + /estado; cada domínio virou módulo em scripts/orquestra-viva/:
 *   nucleo.ts               — fila/agentes/consts compartilhadas (BASE/REPO_DIR/montarEstado)
 *   rotas-reuniao.ts        — reunião ao vivo + crônica
 *   coletores-integracoes.ts — sondas (whatsapp/pulse/vps/federação/aceites/orquestra/crônica)
 *   coletores-agentes.ts    — telemetria de agentes + time em campo + espelho da sessão
 *   rotas-catalogo.ts       — catálogo/item/federação
 *   rotas-app4.ts           — banda/gerar-html/observabilidade/guard-brasil/leaderboard
 * Zero mudança de comportamento — só onde o código mora (ver diff do HTML servido no relatório).
 */
import { existsSync } from "node:fs";
import { join } from "node:path";
import { decideAcesso, validarConfigRede } from "./lib/rede-guarda";
import { montarAgenda } from "./agenda-unificada";
import {
  agenteValido,
  gitCabeca,
  montarEstado,
  REPO_DIR,
} from "./orquestra-viva/nucleo";
import {
  estadoReuniao,
  lerCronica,
  tratarCronicaGerar,
  tratarReuniaoComando,
  tratarReuniaoDownload,
} from "./orquestra-viva/rotas-reuniao";
import { montarIntegracoes } from "./orquestra-viva/coletores-integracoes";
import { medirConexoes, medirWhatsappAgora } from "./orquestra-viva/coletores-conexoes";
import { CAPACIDADES, sincronizar } from "./orquestra-viva/agenda-sincronizar";
import { tratarPerfisGet, tratarPerfisUsar } from "./orquestra-viva/rotas-perfis";
import { medirMcp } from "./orquestra-viva/coletores-mcp";
import {
  lerEspelhoDaSessao, listarSessoes,
  montarAgentes,
  montarTimeEmCampo,
} from "./orquestra-viva/coletores-agentes";
import { detalharItemCatalogo, montarCatalogo } from "./orquestra-viva/rotas-catalogo";
import {
  listarMdRecentes,
  montarGuardBrasil,
  montarLeaderboardApi,
  montarObservabilidade,
  tratarBanda,
  tratarGerarHtml,
} from "./orquestra-viva/rotas-app4";
import {
  montarDocumentosApi,
  montarRaizesApi,
  tratarAbrirDocumento,
} from "./orquestra-viva/rotas-documentos";
import {
  tratarEstadoGet,
  tratarEstadoPost,
  tratarVerDocumento,
} from "./orquestra-viva/rotas-documento-vivo";
import { tratarPerfilPost } from "./orquestra-viva/rotas-perfil";
import { tratarNotificacoesGet } from "./orquestra-viva/rotas-notificacoes";

const PORT = Number(process.env.EGOS_ORQUESTRA_PORT ?? 4599);
const SUBIU_EM = new Date().toISOString();
const HTML_PATH = join(import.meta.dir, "orquestra-viva.html");
// VOCABULARIO-LEIGO-001 (05/09): o vocabulário é DADO (config/vocabulario.json), servido como
// prelúdio da MESMA concatenação de /app.js — nunca rota nova. Três motivos medidos:
// (a) o service worker só cacheia ESQUELETO (ver ESQUELETO abaixo) e uma rota nova falharia
//     offline, sumindo com os rótulos humanos;
// (b) fetch assíncrono corre contra app-catalogo/app-layout, que renderizam antes;
// (c) .json servido por rota não entra na colagem que os goldens estáticos grepam.
const VOCAB_JSON_PATH = join(import.meta.dir, "..", "config", "vocabulario.json");
// REFATORACAO-ORGANICA-001 (04/09): CSS/JS que viviam inline no HTML (2473L) saíram
// para arquivos próprios — zero mudança de comportamento, só arquivo menor.
const APP_CSS_PATH = join(import.meta.dir, "orquestra-viva", "app.css");
// REFATORACAO-ORGANICA-001 passo 2/3 (04/09): app.js (1436L) dividido por domínio em 3
// arquivos — servidos concatenados NESTA ORDEM em /app.js (1 script só, escolhido sobre
// 3 <script src> porque mantém intactos os goldens que fazem grep no HTML servido — ver
// scripts/orquestra-viva.test.sh). O split PRESERVA o conteúdo: o único delta em relação
// ao app.js anterior são as ~47 linhas da feature declarada no mesmo commit (filtro por
// domínio do perfil) — o resto é movimentação. NÃO existe golden de byte-identidade; a
// garantia é comportamental (suíte verde). Corrigido 05/09: dizia "byte-idêntica (diff vazio)".
const APP_JS_PARTS_PATHS = [
  join(import.meta.dir, "orquestra-viva", "app-nucleo.js"),
  join(import.meta.dir, "orquestra-viva", "app-gavetas.js"),
  join(import.meta.dir, "orquestra-viva", "app-catalogo.js"),
  // HISTORICO-DOCUMENTOS-001 (05/09): módulo Documentos — vem antes do app-agentes.js
  // por pedido explícito da task; só usa (nunca declara) IDs/funções dos anteriores.
  join(import.meta.dir, "orquestra-viva", "app-documentos.js"),
  // DV-1-VISUALIZADOR-ESTADO-COMENTARIO-001: visualizador embutido — vem logo depois de
  // app-documentos.js (só usa, nunca declara, escaparHtml/horaLocal/abrirGaveta/fecharGaveta
  // dele e dos anteriores).
  join(import.meta.dir, "orquestra-viva", "app-documento-vivo.js"),
  // PEDIDO-AGENTE-NOMEADO-001 (PCA-55, 05/09): 4º arquivo — detalhe do papel + campo de
  // pedido na gaveta "time em campo". Vem por último: só usa (nunca declara) IDs/funções
  // dos 3 anteriores; ordem não muda comportamento dos demais (concatenação continua 1 script).
  join(import.meta.dir, "orquestra-viva", "app-agentes.js"),
  // MODOS-TELA-25-50-100-001 (05/09): 5º arquivo — seletor de modo/ponte com a casca
  // nativa/layout por modo/editor de layout. Vem por ÚLTIMO: só usa (nunca declara)
  // IDs/funções dos anteriores (MODULOS_CONHECIDOS, abrirGaveta/fecharGaveta, aplicarPerfilNaHome).
  join(import.meta.dir, "orquestra-viva", "app-layout.js"),
  // EGOS-APP-CONTA-E-VERSAO-001 fatia 3 (06/09): gaveta "conexões" — vem por último: só usa
  // (nunca declara) abrirGaveta/fecharGaveta/escaparHtml/GAVETAS dos anteriores.
  join(import.meta.dir, "orquestra-viva", "app-conexoes.js"),
  // EGOS-APP-GAVETA-MCP-001 (07/09): gaveta "MCP" — vem por ÚLTIMO: só usa (nunca declara)
  // abrirGaveta/fecharGaveta/escaparHtml dos anteriores; GAVETAS["mcp-overlay"] é registrada
  // de forma ESTÁTICA em app-gavetas.js (não aqui — pedido explícito da task).
  join(import.meta.dir, "orquestra-viva", "app-mcp.js"),
  // SISTEMA-NERVOSO-DO-APP-001 (08/09): badge + toast de notificações — vem por ÚLTIMO:
  // só usa (nunca declara) abrirGaveta/fecharGaveta/estadoAtual dos anteriores.
  join(import.meta.dir, "orquestra-viva", "app-notificacoes.js"),
  // RV-7-RESPOSTA-CLICAVEL-E-LEIGA-001 (08/09): cartaoBlocos/linkarCaminhos MOVIDOS de
  // app-nucleo.js (que já estava acima do teto de 600L) + cartão de PCA/manchete leiga/
  // clique-para-copiar de SHA — novos nesta fatia. Vem por ÚLTIMO: só usa (nunca declara)
  // escaparHtml/horaLocal de app-nucleo.js; hoisting de `function` cobre renderConversa()
  // (em app-nucleo.js, servido ANTES) chamar cartaoBlocos() daqui — é 1 script concatenado.
  join(import.meta.dir, "orquestra-viva", "app-resposta.js"),
];
const APP_CSS_PARTS_PATHS = [
  APP_CSS_PATH,
  join(import.meta.dir, "orquestra-viva", "app-documentos.css"),
  join(import.meta.dir, "orquestra-viva", "app-documento-vivo.css"),
  // MODOS-TELA-25-50-100-001 (05/09): seletor de modo + destaque/"+N" + editor de layout.
  join(import.meta.dir, "orquestra-viva", "app-layout.css"),
  // SISTEMA-NERVOSO-DO-APP-001 (08/09): badge + toast de notificações.
  join(import.meta.dir, "orquestra-viva", "app-notificacoes.css"),
];

async function tratarComando(req: Request): Promise<Response> {
  let corpo: { acao?: string; agente?: string; titulo?: string; corpo?: string };
  try {
    corpo = await req.json();
  } catch {
    return Response.json({ ok: false, erro: "corpo não é JSON válido" }, { status: 400 });
  }
  const { acao, agente } = corpo;
  if (!agente || !agenteValido(agente)) {
    return Response.json({ ok: false, erro: `agente inexistente: ${agente ?? "(vazio)"}` }, { status: 400 });
  }
  if (acao !== "ping" && acao !== "mensagem") {
    return Response.json({ ok: false, erro: `ação desconhecida: ${acao ?? "(vazia)"}` }, { status: 400 });
  }
  const titulo = acao === "ping" ? "PING do painel vivo" : (corpo.titulo || "").trim();
  if (acao === "mensagem" && !titulo) {
    return Response.json({ ok: false, erro: "mensagem exige título" }, { status: 400 });
  }
  const args = ["scripts/fila.ts", "postar", agente, titulo, "--de", "egos-app"];
  if (corpo.corpo) args.push("--corpo", corpo.corpo);

  try {
    const proc = Bun.spawn([process.execPath, ...args], { cwd: REPO_DIR, stdout: "pipe", stderr: "pipe" });
    const out = (await new Response(proc.stdout).text()).trim();
    const err = (await new Response(proc.stderr).text()).trim();
    const code = await proc.exited;
    if (code !== 0) {
      return Response.json({ ok: false, erro: err || `fila.ts saiu com código ${code}` }, { status: 500 });
    }
    const m = out.match(/postado:\s*(.+)/);
    return Response.json({ ok: true, caminho: m ? m[1] : out });
  } catch (e) {
    return Response.json({ ok: false, erro: `spawn falhou: ${(e as Error).message}` }, { status: 500 });
  }
}

const configRede = validarConfigRede(process.env);
if (configRede.erro) console.log(`⚪ rede: ${configRede.erro}`);

// APP-MULTIDISPOSITIVO-001 (corte Enio 03/09: "pode ser usado como app no Android, tablet,
// smartphone, televisão, óculos VR que tenha display e capacidade de instalar aplicativos").
// O que o instalador de cada plataforma exige: `display: standalone` (janela própria),
// `start_url` que responda sozinha, e ícone `maskable` (sem ele o Android recorta o logo num
// círculo e come as bordas). `display_override` deixa a TV/óculos abrir sem barra do navegador.
const MANIFEST = JSON.stringify({
  id: "egos-app",
  name: "EGOS APP",
  short_name: "EGOS",
  description: "Painel do sistema EGOS: fila de agentes, catálogo, observabilidade e segunda opinião.",
  lang: "pt-BR",
  start_url: "/",
  scope: "/",
  display: "standalone",
  display_override: ["window-controls-overlay", "standalone", "fullscreen"],
  orientation: "any",
  background_color: "#0b0e14",
  theme_color: "#0b0e14",
  icons: [
    { src: "/icone.svg", sizes: "any", type: "image/svg+xml", purpose: "any" },
    { src: "/icone-maskable.svg", sizes: "any", type: "image/svg+xml", purpose: "maskable" },
  ],
});
const ICONE_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 96 96"><rect width="96" height="96" rx="20" fill="#0b0e14"/><text x="48" y="62" font-family="sans-serif" font-size="40" font-weight="700" fill="#4ade80" text-anchor="middle">E5</text></svg>`;
// maskable: o desenho vive dentro do círculo seguro (80% central) porque o Android recorta a borda
const ICONE_MASKABLE_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 96 96"><rect width="96" height="96" fill="#0b0e14"/><text x="48" y="59" font-family="sans-serif" font-size="30" font-weight="700" fill="#4ade80" text-anchor="middle">E5</text></svg>`;

// Casca offline (APP-MULTIDISPOSITIVO-001). Regra que o R13-c impõe: rota de DADO é rede-primeiro
// e, sem rede, devolve erro dito — cache velho servido como "estado de agora" seria o silêncio
// que a constituição proíbe. Só o esqueleto (HTML, ícone, manifest) é cache-primeiro.
const SW_JS = `// REDE_PRIMEIRO: /estado, /api/*, /time, /reuniao, /integracoes, /agentes, /cronica, /agenda
const CASCA = "egos-app-casca-__VERSAO__"; // preso à versão do git: casca nova = cache novo, o velho é apagado no activate
const ESQUELETO = ["/", "/manifest.webmanifest", "/icone.svg", "/app.css", "/app.js"];
const REDE_PRIMEIRO = /^\\/(estado|api|time|reuniao|integracoes|agentes|cronica|comando|agenda|documentos)/;

self.addEventListener("install", (e) => {
  e.waitUntil(caches.open(CASCA).then((c) => c.addAll(ESQUELETO)).then(() => self.skipWaiting()));
});
self.addEventListener("activate", (e) => {
  e.waitUntil(
    caches.keys().then((ks) => Promise.all(ks.filter((k) => k !== CASCA).map((k) => caches.delete(k))))
      .then(() => self.clients.claim()),
  );
});
self.addEventListener("fetch", (e) => {
  const url = new URL(e.request.url);
  if (e.request.method !== "GET" || url.origin !== self.location.origin) return;
  if (REDE_PRIMEIRO.test(url.pathname)) return; // dado nunca sai do cache: sem rede, o erro aparece
  // CASCA: REDE PRIMEIRO, cache só como reserva sem rede (CASCA-CACHE-PRIMEIRO-001, 06/09: o print do
  // Enio mostrava HTML de dias atrás — "Motores e workflows", "git · pulse" — com dados de agora; o
  // cache-first com nome fixo v1 congelava a tela para sempre depois da 1ª instalação).
  e.respondWith(
    fetch(e.request).then((r) => {
      if (r.ok) caches.open(CASCA).then((c) => c.put(e.request, r.clone()));
      return r;
    }).catch(() => caches.match(e.request).then((hit) => hit || new Response("⚪ sem rede e sem cópia da casca", { status: 503 }))),
  );
});
`;

const server = Bun.serve({
  hostname: configRede.bind,
  port: PORT,
  async fetch(req, srv) {
    const url = new URL(req.url);
    const cookieToken = (req.headers.get("cookie") ?? "").match(/egos_token=([^;]+)/)?.[1] ?? null;
    const acesso = decideAcesso({
      ipRemoto: srv.requestIP(req)?.address ?? null,
      caminho: url.pathname,
      tokenQuery: url.searchParams.get("t"),
      tokenCookie: cookieToken,
      tokenEsperado: (process.env.EGOS_APP_TOKEN ?? "").trim(),
    });
    if (!acesso.ok) return new Response("acesso negado", { status: 403 });
    if (acesso.gravarCookie) {
      url.searchParams.delete("t");
      return new Response(null, {
        status: 302,
        headers: {
          location: url.pathname + (url.search || ""),
          "set-cookie": `egos_token=${(process.env.EGOS_APP_TOKEN ?? "").trim()}; Path=/; HttpOnly; SameSite=Strict; Max-Age=2592000`,
        },
      });
    }
    if (url.pathname === "/manifest.webmanifest" && req.method === "GET") {
      return new Response(MANIFEST, { headers: { "content-type": "application/manifest+json" } });
    }
    if (url.pathname === "/icone-maskable.svg" && req.method === "GET") {
      return new Response(ICONE_MASKABLE_SVG, { headers: { "content-type": "image/svg+xml; charset=utf-8" } });
    }
    if (url.pathname === "/sw.js" && req.method === "GET") {
      // sem no-cache o navegador serve o SW antigo por até 24h e a correção não chega
      const versaoSw = (await gitCabeca()).head || "sem-git";
      return new Response(SW_JS.replace("__VERSAO__", versaoSw), { headers: { "content-type": "text/javascript; charset=utf-8", "cache-control": "no-store" } });
    }
    if (url.pathname === "/icone.svg" && req.method === "GET") {
      return new Response(ICONE_SVG, { headers: { "content-type": "image/svg+xml" } });
    }
    if (url.pathname === "/app.css" && req.method === "GET") {
      if (APP_CSS_PARTS_PATHS.some((p) => !existsSync(p))) {
        return new Response("app.css (partes) não encontrado", { status: 500 });
      }
      const partesCss = await Promise.all(APP_CSS_PARTS_PATHS.map((p) => Bun.file(p).text()));
      return new Response(partesCss.join("\n"), { headers: { "content-type": "text/css; charset=utf-8", "cache-control": "no-store" } });
    }
    if (url.pathname === "/app.js" && req.method === "GET") {
      if (APP_JS_PARTS_PATHS.some((p) => !existsSync(p)) || !existsSync(VOCAB_JSON_PATH)) {
        return new Response("app.js (partes) não encontrado", { status: 500 });
      }
      const [vocabTexto, ...partes] = await Promise.all([
        Bun.file(VOCAB_JSON_PATH).text(),
        ...APP_JS_PARTS_PATHS.map((p) => Bun.file(p).text()),
      ]);
      const preludio = `window.VOCAB=${vocabTexto};\n`;
      return new Response(preludio + partes.join("\n"), { headers: { "content-type": "text/javascript; charset=utf-8", "cache-control": "no-store" } });
    }
    if (url.pathname === "/" && req.method === "GET") {
      if (!existsSync(HTML_PATH)) {
        return new Response("orquestra-viva.html não encontrado", { status: 500 });
      }
      return new Response(Bun.file(HTML_PATH), { headers: { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" } });
    }
    if (url.pathname === "/estado" && req.method === "GET") {
      const estado = await montarEstado();
      return Response.json(estado);
    }
    if (url.pathname === "/comando" && req.method === "POST") {
      return tratarComando(req);
    }
    if (url.pathname === "/cronica" && req.method === "GET") {
      return Response.json(lerCronica());
    }
    if (url.pathname === "/cronica/gerar" && req.method === "POST") {
      return tratarCronicaGerar(req);
    }
    if (url.pathname === "/integracoes" && req.method === "GET") {
      return Response.json(await montarIntegracoes());
    }
    if (url.pathname === "/agentes" && req.method === "GET") {
      return Response.json(await montarAgentes());
    }
    if (url.pathname === "/time" && req.method === "GET") {
      return Response.json(await montarTimeEmCampo());
    }
    if (url.pathname === "/agenda" && req.method === "GET") {
      return Response.json(montarAgenda());
    }
    if (url.pathname === "/reuniao" && req.method === "GET") {
      return Response.json(estadoReuniao());
    }
    if (url.pathname === "/reuniao/comando" && req.method === "POST") {
      return tratarReuniaoComando(req);
    }
    if (url.pathname === "/reuniao/download" && req.method === "GET") {
      return tratarReuniaoDownload(req);
    }
    if (url.pathname === "/api/sessao" && req.method === "GET") {
      const n = Math.min(400, Math.max(1, Number(url.searchParams.get("n") ?? 60) || 60));
      const id = (url.searchParams.get("sessao") ?? "").trim().replace(/[^a-zA-Z0-9-]/g, "");
      return Response.json(lerEspelhoDaSessao(n, id || undefined));
    }
    if (url.pathname === "/api/sobre" && req.method === "GET") {
      // gaveta "sobre": o que este app É — versão medida do git, de onde serve, há quanto tempo
      const git = await gitCabeca();
      return Response.json({ ok: true, git, repo: REPO_DIR, porta: PORT, subiuEm: SUBIU_EM, medidoEm: new Date().toISOString() });
    }
    if (url.pathname === "/api/sessoes" && req.method === "GET") {
      const sessoes = listarSessoes(12);
      return Response.json({ ok: true, total: sessoes.length, sessoes, medidoEm: new Date().toISOString() });
    }
    if (url.pathname === "/api/catalogo" && req.method === "GET") {
      const dominiosCsv = (url.searchParams.get("dominios") ?? "").trim();
      const dominiosPerfil = dominiosCsv ? dominiosCsv.split(",").map((d) => d.trim()).filter(Boolean) : [];
      return Response.json(await montarCatalogo(url.searchParams.get("busca") ?? "", url.searchParams.get("tipo") ?? "", url.searchParams.get("categoria") ?? "", dominiosPerfil));
    }
    if (url.pathname === "/api/catalogo/item" && req.method === "GET") {
      return detalharItemCatalogo(url.searchParams.get("id") ?? "");
    }
    if (url.pathname === "/api/apresentacoes" && req.method === "GET") {
      return Response.json({ md: listarMdRecentes() });
    }
    if (url.pathname === "/api/gerar-html" && req.method === "POST") {
      return tratarGerarHtml(req);
    }
    if (url.pathname === "/api/observabilidade" && req.method === "GET") {
      return Response.json(await montarObservabilidade());
    }
    if (url.pathname === "/api/guard-brasil" && req.method === "GET") {
      return Response.json(await montarGuardBrasil());
    }
    if (url.pathname === "/api/leaderboard" && req.method === "GET") {
      return Response.json(await montarLeaderboardApi());
    }
    if (url.pathname === "/api/banda" && req.method === "POST") {
      return tratarBanda(req);
    }
    if (url.pathname === "/api/documentos" && req.method === "GET") {
      return Response.json(await montarDocumentosApi(url.searchParams));
    }
    if (url.pathname === "/api/documentos/raizes" && req.method === "GET") {
      return Response.json(montarRaizesApi());
    }
    if (url.pathname === "/api/documentos/abrir" && req.method === "POST") {
      return tratarAbrirDocumento(req);
    }
    // DV-1-VISUALIZADOR-ESTADO-COMENTARIO-001: /ver serve o conteúdo p/ iframe same-origin;
    // /estado é o ledger append-only (fora do git) de finalizado/critério/destino/status.
    if (url.pathname === "/api/documentos/ver" && req.method === "GET") {
      return tratarVerDocumento(url.searchParams);
    }
    if (url.pathname === "/api/documentos/estado" && req.method === "GET") {
      return tratarEstadoGet(url.searchParams);
    }
    if (url.pathname === "/api/documentos/estado" && req.method === "POST") {
      return tratarEstadoPost(req);
    }
    if (url.pathname === "/api/perfil" && req.method === "POST") {
      return tratarPerfilPost(req, REPO_DIR);
    }
    if (url.pathname === "/api/notificacoes" && req.method === "GET") {
      return tratarNotificacoesGet(REPO_DIR);
    }
    if (url.pathname === "/api/conexoes" && req.method === "GET") {
      const conexoes = await medirConexoes();
      return Response.json({ ok: true, conexoes, medidoEm: new Date().toISOString() });
    }
    // AGENDA-FONTE-ACIONAVEL-001 (corte Enio 09/09): a fonte da agenda deixa de ser um aviso
    // e vira algo que se aciona. GET devolve o que cada fonte é e sabe fazer; POST manda
    // sincronizar de verdade e devolve a falha INTEIRA quando falha (=R13).
    // BIBLIOTECA-DE-PERFIS-001 (corte Enio 09/09): a troca de perfil sai da variável de
    // ambiente e vira botão — sem isso, "compartilhar o EGOS APP" entrega um app que só o
    // dono sabe configurar.
    if (url.pathname === "/api/perfis" && req.method === "GET") {
      return tratarPerfisGet(REPO_DIR);
    }
    if (url.pathname === "/api/perfis/usar" && req.method === "POST") {
      return tratarPerfisUsar(req, REPO_DIR);
    }
    if (url.pathname === "/api/agenda/fontes" && req.method === "GET") {
      // o `comando` fica no servidor: a tela precisa saber SE dá para sincronizar, não com
      // qual binário — caminho de máquina não tem por que atravessar para o navegador.
      const capacidades = Object.fromEntries(
        Object.entries(CAPACIDADES).map(([k, v]) => [k, { ...v, comando: undefined }]),
      );
      return Response.json({ ok: true, capacidades });
    }
    if (url.pathname === "/api/agenda/sincronizar" && req.method === "POST") {
      const corpo = (await req.json().catch(() => ({}))) as { fonte?: string };
      const r = await sincronizar(String(corpo.fonte ?? ""));
      return Response.json(r, { status: r.ok ? 200 : 200 }); // falha é DADO, não erro de HTTP
    }
    if (url.pathname === "/api/conexoes/whatsapp/medir" && req.method === "POST") {
      const conexao = await medirWhatsappAgora();
      return Response.json({ ok: true, conexao });
    }
    // EGOS-APP-GAVETA-MCP-001 (07/09): ?medir=1 dispara o handshake real (stdio, 6s/servidor);
    // sem o parâmetro é leitura leve (só existe_no_disco) — nunca sonda tudo sem pedido explícito.
    if (url.pathname === "/api/mcp" && req.method === "GET") {
      const medir = url.searchParams.get("medir") === "1";
      const resposta = await medirMcp({ medir });
      return Response.json(resposta);
    }
    return new Response("não encontrado", { status: 404 });
  },
});

console.log(
  `orquestra-viva: ouvindo em http://${configRede.bind}:${server.port}` +
    (configRede.bind === "127.0.0.1" ? "" : " (rede: token exigido fora do localhost)"),
);
