/**
 * Validação de env vars com Zod.
 *
 * Chamada implicitamente no startup do Next via import. Se variável crítica
 * está faltando, lança erro com mensagem clara antes do app subir.
 *
 * Uso: import { env } from "@/lib/env";
 */

import { z } from "zod";

import { RETENCAO_TETO_DIAS } from "@/lib/retencao/politica";

const isProd = process.env.NODE_ENV === "production";

/**
 * Durante `next build` (NEXT_PHASE=phase-production-build) os segredos de runtime
 * ainda não existem — só as NEXT_PUBLIC_* são embutidas no bundle. Nessa fase
 * afrouxamos a validação (via seed de placeholders no parse abaixo) pra gerar a
 * imagem Docker (self-host) sem passar segredos como ARG, que vazariam nas
 * camadas. O boot real (sem essa fase) cobra os valores verdadeiros.
 *
 * A leniência é feita SÓ no parse — os validadores continuam com tipos Zod
 * estáveis, senão `z.infer` degrada `env.*` pra `{}` (uniões quebram `.url()`).
 */
const isBuildPhase = process.env.NEXT_PHASE === "phase-production-build";

/**
 * Em produção exigimos todas as vars críticas. Em dev, algumas são opcionais
 * pra permitir setup parcial (ex: dev sem WAHA quando trabalhando só na UI).
 */
const required = (name: string) =>
  isProd
    ? z.string().min(1, `${name} é obrigatória em produção`)
    : z.string().default("");

const requiredAlways = (name: string) => z.string().min(1, `${name} é obrigatória`);

/**
 * Knob de retenção em dias: NUNCA derruba o app.
 *
 * `z.coerce.number().int().positive()` lança para `=0`, que é justamente o que
 * o operador da VPS escreve quando quer desligar a poda — e `lib/env.ts` roda
 * no import do Next, então o throw vira 500 em TODAS as telas, com o contêiner
 * `healthy` e nada dizendo o porquê. Falha fechada na AÇÃO (o valor inválido
 * não vale) e aberta na INFORMAÇÃO (o app sobe e diz alto o que ignorou).
 *
 * Desligar a poda não é isto: se tiver de existir, é decisão de produto e vem
 * com nome próprio, não com um zero que o schema recusa.
 *
 * ─── E o teto ──────────────────────────────────────────────────────────────
 *
 * Sem teto, um `WEBHOOK_LOG_BODY_RETENTION_DAYS=9999999` (medido na #2612)
 * vira `new Date(Date.now() - 9999999 * 86_400_000)` em `limiteEm`, ou seja,
 * um corte no ano −25353 indo ao PostgREST; um `1e9` estoura a faixa de
 * `Date` e lança `RangeError: Invalid time value` antes de chegar lá. Por isso
 * o valor lido aqui passa por `RETENCAO_TETO_DIAS` (`lib/retencao/politica.ts`
 * — a MESMA constante dos demais prazos, importada, nunca reimplementada).
 * Acima do teto: o teto vale e o aviso sai AQUI, no lugar em que o valor é
 * lido, uma vez só no boot — no formato `chave=valor está acima do teto de N
 * dias — usando N`, o mesmo do teto de `interpretarRetencao` (#2603). Dentro
 * do intervalo, o número passa intacto, sem aviso.
 */
const diasDeRetencao = (nome: string, padrao: number) =>
  z.coerce
    .number()
    .int()
    .positive()
    .default(padrao)
    .catch(({ error }) => {
      console.warn(
        `[env] ${nome} inválida (${JSON.stringify(process.env[nome])}) — usando o padrão ${padrao} dias. ` +
          `Só número inteiro maior que zero vale aqui; "0" não desliga a poda. ` +
          `(${error.issues[0]?.message ?? "valor recusado"})`,
      );
      return padrao;
    })
    .transform((valor) => {
      // O teto é aplicado DEPOIS do catch: `catch` devolve o padrão só para
      // valor inválido, e um `9999999` é um número VÁLIDO demais — ele passaria
      // pelo catch como estava. Transform é o único ponto em que os dois
      // desfechos (inválido → padrão, gigante → teto) convivem sem um comer o
      // outro, e ambos com aviso.
      if (valor > RETENCAO_TETO_DIAS) {
        console.warn(
          `[env] ${nome}=${valor} está acima do teto de ${RETENCAO_TETO_DIAS} dias — usando ${RETENCAO_TETO_DIAS}.`,
        );
        return RETENCAO_TETO_DIAS;
      }
      return valor;
    });

const schema = z.object({
  // Node
  NODE_ENV: z.enum(["development", "test", "production"]).default("development"),

  // Supabase — obrigatórias sempre (até pra dev local)
  NEXT_PUBLIC_SUPABASE_URL: requiredAlways("NEXT_PUBLIC_SUPABASE_URL").url(),
  NEXT_PUBLIC_SUPABASE_ANON_KEY: requiredAlways("NEXT_PUBLIC_SUPABASE_ANON_KEY"),
  SUPABASE_SERVICE_ROLE_KEY: requiredAlways("SUPABASE_SERVICE_ROLE_KEY"),
  /**
   * Endereço do Supabase PARA O SERVIDOR, e só para ele (issue #1082).
   *
   * Numa instalação com o Supabase na mesma rede (Kong/self-host, `http://kong:8000`),
   * o caminho curto existe e não precisa sair para a internet — mas colocá-lo na
   * `NEXT_PUBLIC_*` o publicaria para o navegador. Esta variável é a bifurcação:
   * preenchida, o servidor fala com o endereço interno; vazia, vale a pública, e
   * é o que toda instalação existente já faz.
   *
   * `z.string()` cru, NUNCA `.url()` e NUNCA `required()`, pelo motivo escrito ao
   * lado de `APP_ACCENT_HEX` e `SIGNUP_MODE` mais abaixo: `lib/env.ts` lança na
   * IMPORTAÇÃO do módulo, que no Next é a primeira requisição, e o healthcheck do
   * contêiner é probe TCP — um `.url()` aqui transformaria um `.env` com espaço
   * sobrando no derrubador do produto inteiro, com o Docker mostrando `healthy` e
   * 100% das requisições em 500. Quem interpreta (recusa o que não é endereço,
   * tira barra final e avisa) é `urlDoSupabaseNoServidor`, em
   * `lib/supabase/url-do-servidor.ts`.
   */
  SUPABASE_SERVER_URL: z.string().optional().default(""),

  // Cron / interno
  INTERNAL_SECRET: required("INTERNAL_SECRET"),
  /** Optional dedicated secret for cron endpoints (S-06.07 onwards). */
  INTERNAL_CRON_SECRET: z.string().optional().default(""),
  /**
   * Segredo do DONO DA INSTALAÇÃO para `POST /api/v1/tenants/provision` (um
   * sistema externo cria organizações). Vazio por padrão = a rota não existe
   * (404); com menos de 32 caracteres também fica desligada.
   */
  TENANT_PROVISIONING_SECRET: z.string().optional().default(""),

  // Laboratório local de extensões: origem HTTP exata em 127.0.0.1. O cliente
  // recusa a exceção se a URL do app não for loopback. Vazio mantém HTTPS público.
  EXTENSIONS_LOCAL_CATALOG_ORIGIN: z.string().optional().default(""),

  /**
   * Retenção do arquivo do corpo cru dos webhooks (`webhook_events_log`).
   *
   * O default de 7 dias não é gosto: numa instalação real esse arquivo era 86%
   * do banco (468 MB de 545 MB) e crescia ~23 MB/dia, contra os 500 MB do plano
   * gratuito do Supabase — onde a maioria dos clones vive. Com 7 dias o regime
   * estável fica em ~160 MB de corpo mais ~11 MB de índice forense; com 14 já
   * não cabe. Quem tem plano pago sobe o número e fica com mais corpo à mão.
   */
  WEBHOOK_LOG_BODY_RETENTION_DAYS: diasDeRetencao("WEBHOOK_LOG_BODY_RETENTION_DAYS", 7),
  /**
   * Quando a LINHA some, e não só o corpo. Horizonte longo de propósito: até
   * aqui a linha custa ~200 B e ainda responde "quantos eventos de que tipo
   * chegaram, quando, e a assinatura conferia?", que é a pergunta de depois do
   * incidente.
   */
  WEBHOOK_LOG_ROW_RETENTION_DAYS: diasDeRetencao("WEBHOOK_LOG_ROW_RETENTION_DAYS", 90),
  /**
   * Retenção do HISTÓRICO de leads captados (`webhook_lead_captures`).
   *
   * Horizonte muito mais longo que o do arquivo forense acima, e a diferença é
   * de natureza: lá a linha é despejo de depuração, aqui ela É o produto — é o
   * que a aba "Leads recebidos" mostra quando alguém pergunta de qual campanha
   * vieram os clientes que fecharam. Uma linha custa ~1 kB, então 300
   * leads/dia por um ano dão ~110 MB; o ano fiscal cabe.
   *
   * Entra como `z.string()` — e não pelo `diasDeRetencao` acima — porque quem
   * a interpreta é `lib/retencao/politica.ts`, o mesmo módulo da poda da fila e
   * do expurgo da auditoria. Ele resolve lixo para o lado seguro E devolve a
   * frase de aviso, que é o que faz o operador saber que o número dele foi
   * elevado ao piso de 30 dias, em vez de descobrir pela ausência de efeito.
   */
  LEAD_CAPTURE_RETENTION_DAYS: z.string().optional().default(""),

  // Encryption keys (pgcrypto)
  CPF_ENCRYPTION_KEY: required("CPF_ENCRYPTION_KEY"),
  // Opcional (template genérico) — só necessária ao ligar NUVEMSHOP_ENABLED.
  NUVEMSHOP_OAUTH_ENCRYPTION_KEY: z.string().optional().default(""),
  WAHA_BYO_ENCRYPTION_KEY: required("WAHA_BYO_ENCRYPTION_KEY"),
  /**
   * AES-256-GCM key (32 bytes em base64) usada pra cifrar API keys em
   * `ai_provider_credentials`. Em produção é obrigatória; em dev a default vazia
   * é tolerada — `lib/crypto/aes_gcm.ts` lança se a key não bate em runtime.
   */
  AI_CRED_AES_KEY: required("AI_CRED_AES_KEY"),

  // Postgres direto do Supabase (Settings → Database) — só as rotas de skills
  // instaláveis (import/install) usam `pg` cru (mesmo pool do agent-engine).
  SUPABASE_DB_URL: required("SUPABASE_DB_URL"),
  /**
   * A conexão de DDL do KIT (install.sh/update.sh/backup.sh), não do app.
   * O `docker-compose.prod.yml` entrega o `.env` inteiro ao app, ao worker e
   * ao voice-agent (`env_file`), e desde o #1680 sobrescreve esta chave com
   * vazio no `environment:` deles — no processo ela chega vazia. A declaração
   * fica porque quem roda fora desse compose (dev local, `next start` à mão)
   * ainda a recebe do `.env`, e uma chave que chega ao processo merece estar no
   * contrato em vez de ser um desconhecido tolerado.
   *
   * NENHUM código de app pode lê-la: ela é o DONO do banco quando a instalação
   * é num Supabase próprio, e `SUPABASE_DB_URL` é a role menor de propósito
   * (issue #192). Vigiado por `tests/unit/env-ddl-fora-do-app.test.ts`.
   */
  SUPABASE_DB_ADMIN_URL: z.string().optional().default(""),

  // WAHA
  WAHA_API_BASE_URL: required("WAHA_API_BASE_URL"),
  WAHA_API_KEY: required("WAHA_API_KEY"),
  WAHA_WEBHOOK_BASE_URL: required("WAHA_WEBHOOK_BASE_URL"),
  // Segredo com que o WAHA assina os webhooks. O compose já o entrega ao
  // contêiner do WAHA; o app precisa dele para CONFERIR a assinatura — e não o
  // declarava aqui, então nunca teve como verificar nada.
  WAHA_HMAC_SECRET: z.string().optional().default(""),
  // "true" exige assinatura válida em todo webhook do WAHA. Fica desligado por
  // padrão porque sem a variável certa o WAHA não assina (medido: 2026.7.2
  // mandava os eventos sem header porque o compose entregava WHATSAPP_HOOK_HMAC,
  // nome que não existe na doc dele — o certo é WHATSAPP_HOOK_HMAC_KEY), e exigir
  // derrubaria a ingestão de mensagens. Ligue quando o WAHA estiver assinando
  // (WHATSAPP_HOOK_HMAC_KEY no compose) ou houver um proxy que assine — aí a
  // verificação passa a ser obrigatória.
  WAHA_WEBHOOK_REQUIRE_SIGNATURE: z.string().optional().default("false"),

  // ─── Chamada de voz WhatsApp (WaCalls, spec 18) ───
  //
  // NUNCA `required()`: o serviço `wacalls` vive num profile do compose que
  // nasce DESLIGADO, então a instalação normal não o tem. Exigir aqui
  // derrubaria o boot de todo self-host que não usa a feature.
  //
  // Vazio = a instalação não oferece a chamada de voz. É essa string vazia que
  // `instalacaoOfereceVoz()` lê para dizer à tela que nenhum clique resolve.
  WACALLS_API_BASE_URL: z.string().optional().default(""),
  // O upstream autenticado NÃO tem modo aberto: sem este Bearer, a API só é
  // alcançável pelo cookie de login do navegador, e um processo
  // server-to-server não tem cookie. URL sem token dá um cliente que constrói e
  // devolve 401 em toda chamada — por isso `getWacallsClient()` exige os dois.
  WACALLS_API_TOKEN: z.string().optional().default(""),

  // ─── Prospecção: engine de scraping self-hosted (local-http, #2942) ───────
  //
  // Mesmo desenho de WACALLS_API_BASE_URL: NUNCA `required()`. Vazio = a
  // instalação não oferece o engine de scraping self-hosted, e a organização
  // que escolher `local-http` em `settings.prospecting.provider` cai no erro
  // FECHADO de sempre ("Informe a URL do engine de busca local.").
  //
  // A URL é da INSTALAÇÃO — e não da organização, como a #2942 propôs: é isso
  // que faz a liberação de destino interno valer (`lib/automation/
  // destinos-internos-autorizados.ts`, regra 2 — endereço escolhido por uma
  // organização nunca alcança a rede de dentro). Quem lê é
  // `baseDoEngineDaInstalacao()` em `lib/prospecting/provedor-local-http.ts`,
  // que ainda passa o endereço pela régua de saída antes de chamar o engine.
  PROSPECTING_LOCAL_HTTP_URL: z.string().optional().default(""),

  // ─── Videochamada (Jitsi Meet, #2440) — OPCIONAL, DESLIGADA POR PADRÃO ───
  //
  // Mesmo desenho de WACALLS_API_BASE_URL: NUNCA `required()`. Vazio = a
  // instalação não oferece videochamada e o botão "Vídeo" não aparece no
  // header da conversa (esconde, nunca erro). Quem lê é `servidorDeVideo()`
  // em `lib/video/jitsi.ts`.
  //
  // `https://meet.jit.si` (público; desde 24/08/2023 quem abre a sala entra
  // com conta Google/GitHub/Facebook, o convidado não) ou o servidor próprio em
  // Docker/consórcio — a URL é a ORIGEM da aba de videochamada.
  //
  // Validada como URL http(s) desde o review do #2441: depois de trocarmos o
  // iframe por aba nova, este valor vira `href` num `<a>`, então um `javascript:`
  // escrito no `.env` seria código executando no clique do operador. A segunda
  // triagem (`EH_HTTP`, em `lib/video/jitsi.ts`) fica no lado do navegador,
  // que lê o payload injetado e não passa por aqui de novo.
  //
  // O formato é `.refine().catch()` e não `.url()` puro, pelo motivo que a
  // nota de META_GRAPH_BASE_URL registra: validação que DERRUBA roda no import
  // do Next e derruba TODAS as telas com o contêiner `healthy`. Aqui a ação
  // falha fechada — URL fora de http(s) vira `""`, o botão some, a feature
  // desliga — e a informação sobe em alto e bom som no log (padrão
  // `diasDeRetencao`, lá em cima).
  JITSI_SERVER_URL: z
    .string()
    .optional()
    .default("")
    .refine((v) => v.trim() === "" || /^https?:\/\/\S+$/i.test(v.trim()), {
      message: "precisa ser uma URL http(s) como https://meet.jit.si",
    })
    .catch(({ error }) => {
      console.warn(
        `[env] JITSI_SERVER_URL inválida (${JSON.stringify(process.env.JITSI_SERVER_URL)}) — videochamada DESLIGADA. ` +
          `Ela vira o link da sala no botão "Vídeo", então só http(s) vale (ex.: https://meet.jit.si). ` +
          `(${error.issues[0]?.message ?? "valor recusado"})`,
      );
      return "";
    }),

  // ─── Canal Datafy (recorte do #1130) — OPCIONAL, DESLIGADO POR PADRÃO ───
  //
  // Só `true` liga (decisão do dono, doc 54). Vazio = a instalação não oferece o
  // canal: sem aba em Conexões, rota de conexão 404, webhook recusado. Quem lê
  // é `canalGraphParceiroLigado()` em `lib/channels/graph-parceiro/credentials.ts`.
  DATAFY_ENABLED: z.string().optional().default(""),

  // ─── Número oficial com um clique (migration 9093) — OPCIONAL ───
  //
  // Chave da conta do provedor parceiro DA INSTALAÇÃO. Vazia = a tela de
  // Conexões oferece só a conexão por chave própria, como antes. É semente: a
  // tela Painel › Configuração vence este valor. Quem lê é `chaveDaInstalacao()`
  // em `lib/channels/zernio/credentials.ts`, pelo resolvedor da instalação, a
  // cada chamada — nunca por `env.`, para a troca na tela valer na hora.
  ZERNIO_PLATFORM_API_KEY: z.string().optional().default(""),

  // Upstash Redis
  UPSTASH_REDIS_REST_URL: required("UPSTASH_REDIS_REST_URL"),
  UPSTASH_REDIS_REST_TOKEN: required("UPSTASH_REDIS_REST_TOKEN"),

  // AI providers — env-gated. Worker no-ops with skip="ai_gateway_key_missing"
  // when AI_GATEWAY_API_KEY is absent, so production boot must not be fatal.
  AI_GATEWAY_API_KEY: z.string().optional().default(""),
  AI_GATEWAY_BASE_URL: z.string().optional().default(""),
  // OpenRouter: alternativa ao gateway da Vercel, compatível com a API da
  // OpenAI. Opcional — sem ela nada muda; com ela o chat passa a ser roteado
  // por lá. Ver resolveLanguageModel() em lib/ai/gateway.ts.
  OPENROUTER_API_KEY: z.string().optional().default(""),
  OPENROUTER_BASE_URL: z.string().optional().default(""),
  // Atribuição OPCIONAL da OpenRouter (`HTTP-Referer` / `X-Title`): identifica a
  // instalação no painel e no ranking público DELES. A doc da OpenRouter chama
  // os dois de opcionais e a chamada funciona sem — por isso default vazio e
  // nenhum header enviado quando não preenchidos. Quem lê é
  // `cabecalhosDeAtribuicaoOpenRouter()`, em edge/llm/providers.ts.
  OPENROUTER_APP_URL: z.string().optional().default(""),
  OPENROUTER_APP_TITLE: z.string().optional().default(""),
  VERCEL_AI_GATEWAY_URL: z.string().optional().default(""),
  ANTHROPIC_API_KEY: z.string().optional().default(""),
  OPENAI_API_KEY: z.string().optional().default(""),
  // Esforço de raciocínio dos modelos da OpenAI (o*, gpt-5*, gpt-6*). `z.string()`
  // e NUNCA `z.enum` (motivo mais abaixo, em AGENT_DISPATCH_CONSUMER): quem valida
  // a grafia é o boot do worker, em `lib/agent-engine/env.ts`.
  OPENAI_REASONING_EFFORT: z.string().optional().default(""),
  // Cache de prompt nos Claude pela OpenRouter (ligado por padrão; `off` desliga).
  // `z.string()` pelo mesmo motivo do knob acima: quem valida a grafia é o boot
  // do worker, e quem lê em runtime é `createDefaultRegistry`, via process.env.
  OPENROUTER_PROMPT_CACHE: z.string().optional().default(""),
  // Transcrição de áudio num serviço COMPATÍVEL com o da OpenAI (Groq, um
  // Whisper próprio): a chave vale só para `/audio/transcriptions` — a conversa
  // com o cliente e a leitura de imagem continuam no provedor do ponto.
  // Vazio é ausente, como no resto do arquivo: sem `TRANSCRIPTION_API_KEY` a
  // transcrição usa a `OPENAI_API_KEY` acima, que é o comportamento de sempre.
  // Quem lê é o worker de derivação de mídia (`workers/media-derive-worker.ts`).
  TRANSCRIPTION_API_KEY: z.string().optional().default(""),
  TRANSCRIPTION_BASE_URL: z.string().optional().default(""),
  TRANSCRIPTION_MODEL: z.string().optional().default(""),
  // Idiomas esperados no áudio, ISO-639-1 separados por vírgula ("es" ou
  // "pt,es"). Vazio = o serviço detecta sozinho. Vale com a chave acima E com a
  // da OpenAI da organização — assim como `TRANSCRIPTION_MODEL`. Leitura
  // tolerante em `idiomasDaTranscricao` (grafia errada não derruba o worker).
  TRANSCRIPTION_LANGUAGES: z.string().optional().default(""),
  // Foto só passa pela visão (paga) quando alguém da empresa vai ler a
  // descrição. `defer` (padrão) liga a trava: numa empresa sem leitor — nenhum
  // agente com "ver as fotos" ligado (ai_agent_versions.image_vision_enabled),
  // nenhuma integração (token, anúncio, modo external, venda pelo canal,
  // inscrição de follow-up sem agente), nenhum caso aberto de agente fora do ar
  // — a foto é guardada sem descrição, e o dreno ou o cron
  // `media-leitura-adiada` a descrevem quando aparecer quem leia. `always` é a
  // válvula do operador: descreve toda foto e reabre as
  // adiadas. Só `always` desliga — vazio ou grafia torta ficam no padrão. Quem
  // lê é `adiarSemLeitorLigado`, em lib/messaging/media/leitura-adiada.ts.
  // `z.string()` e nunca `z.enum`, pelo motivo de AI_BUDGET_ENFORCEMENT abaixo.
  MEDIA_VISION_WITHOUT_READER: z.string().optional().default("defer"),
  // Endereço da API do Jev (TypeSafe AI). Vazio é ausente: vale
  // https://api.typesafe.ai. Existe para o dublê do e2e — a CHAVE nunca vem
  // daqui, é por organização (BYOK). Quem lê é `baseDaApiDoJev()`, em
  // lib/ai/decisao/cliente.ts.
  JEV_API_BASE_URL: z.string().optional().default(""),
  // Endereço de TESTE da API do provedor de cobrança. Existe só para o dublê
  // do e2e (tests/e2e/fixtures/provedor-de-cobranca.ts, porta 3995). Quem lê é
  // `baseDeTesteDaCobranca()` (lib/cobranca/provedores/base-de-teste.ts), que
  // só a aceita em loopback E com o próprio app em loopback: numa VPS ela é
  // ignorada, com log. Vazio = as URLs oficiais do provedor.
  COBRANCA_API_BASE_URL_TESTE: z.string().optional().default(""),
  // Destinos internos que o DONO DA INSTALAÇÃO autoriza (decisão 22-d, #1004):
  // IPv4 e faixas CIDR IPv4 que a saída pode alcançar mesmo sendo rede interna,
  // e só para destinos que a própria INSTALAÇÃO configura (nunca o endereço que
  // uma organização escolhe). O BANCO ESTÁ ACIMA DISTO: a lista vive em
  // `platform_settings.internal_destinations`, editada em
  // `/admin/destinos-internos`; esta variável é só o PISO, que vale enquanto a
  // tela nunca foi usada. Vazio é ausente: sem ela, nada passa. Quem lê é
  // `lib/automation/destinos-internos-autorizados.ts`.
  IA_DESTINOS_INTERNOS_PERMITIDOS: z.string().optional().default(""),

  // Fusão (Fase 4): DONO ÚNICO dos eventos ai_agent.dispatch_requested.
  // 'engine' (default) = o worker agent-engine é o único consumidor (o cron
  // agent-dispatcher vira no-op mecânico); 'native' = o dispatcher EPIC-13
  // consome (deploy sem worker). NUNCA os dois — dois consumidores = turno
  // duplicado ou perdido (bug real da fusão).
  AGENT_DISPATCH_CONSUMER: z.enum(["engine", "native"]).optional().default("engine"),

  // Há um worker drenando o `event_log` em laço ao lado deste app? Quem declara
  // é o compose que sobe os dois (`docker-compose.prod.yml`), não o `.env`:
  // vazio é "não", e o webhook de mensagem segue drenando a fila inteira dentro
  // da própria requisição. `1`/`true` troca isso por um dreno só da organização
  // e dos gatilhos de follow-up — `lib/dev/kick-local-pipeline.ts`.
  // `z.string()` e não `z.enum`: valor torto aqui não pode derrubar o app.
  EVENT_LOG_WORKER_DRAINS: z.string().optional().default(""),

  /**
   * Kill switch do teto de gasto de IA — a alavanca que o operador da VPS puxa
   * às 2h da manhã quando a IA parou e ele não sabe SQL.
   *
   * `on` (default) NÃO LIGA NADA: significa "respeite o que cada organização
   * escolheu na tela". A chave só sabe AFROUXAR — `avisar` rebaixa qualquer
   * bloqueio a aviso, e `off|false|0|no|nao|não|disabled` cala a proteção
   * inteira. É essa monotonicidade que a torna um kill switch de verdade.
   *
   * ⚠️ `z.string()` E JAMAIS `z.enum`, e o motivo é o modo de falha deste
   * arquivo: `safeParse` abaixo LANÇA quando o schema recusa, e no Next isso
   * acontece na primeira requisição — com healthcheck TCP puro, o contêiner
   * fica `healthy` com 100% das requisições em 500. Um `z.enum` transformaria a
   * alavanca de EMERGÊNCIA no derrubador do app inteiro no dia em que o
   * operador escrevesse `false`. A normalização (que aceita as grafias falsas
   * comuns como desligado, e resolve lixo para o lado seguro) mora em
   * `normalizarChaveDeOrcamento`, em lib/agent-engine/edge/llm/orcamento.ts.
   * Mesmo raciocínio de APP_ACCENT_HEX, algumas linhas abaixo.
   */
  AI_BUDGET_ENFORCEMENT: z.string().optional().default("on"),

  // As duas chaves do MOTOR que também são comportamento da INSTALAÇÃO (issue
  // #1034): o modo do portão de disclosure do atendimento e a camada semântica
  // de promessa. Existem em `lib/agent-engine/env.ts` (é lá que o worker as
  // lê); entram aqui para a tela de admin poder mostrar o PISO que o `.env`
  // desta instalação declara, em vez de supor o default do produto.
  //
  // `z.string()` cru e NUNCA `z.enum`, pelo mesmo motivo da linha acima: um
  // `z.enum` que lança no import derruba o processo, e derrubar o processo é o
  // oposto do que um kill switch faz. Quem normaliza é o leitor de cada um:
  // `pisoDaInstalacao()`, em lib/instalacao/comportamento-servidor.ts.
  DISCLOSURE_MODE: z.string().optional(),
  PROMISE_SEMANTIC_ENABLED: z.string().optional(),

  // `EVENT_LOG_WORKER_ENABLED` viveu aqui até 2026-08-25 e NUNCA teve leitor: o
  // campo era declarado, documentado no `.env.example` com `false` e lido por
  // ninguém (medido: zero ocorrências fora da própria declaração). Saiu junto
  // com a chegada do laço de verdade (`lib/event-log/drain-loop.ts`), e o ritmo
  // dele agora mora nos `EVENT_LOG_DRAIN_*` de `lib/agent-engine/env.ts` — o
  // schema do processo que roda o laço, e não o do app.
  //
  // Não virou o liga/desliga do laço novo de propósito: o default publicado era
  // `false`, então respeitá-lo faria o conserto não chegar a NENHUMA instalação
  // já existente — que é o item 15 do Definition of Done ("a mudança chega a
  // quem já instalou"). O worker existe para rodar laços; este liga sempre.

  // O endpoint :test devolve um trace fake quando esta flag = 'true'.
  // Default 'false' desde que a S-13.08 landou: `callInternalRuntime` executa
  // o `runAgent` real, então quem instala do zero testa o agente de verdade.
  // Ligue 'true' só para exercitar o render da UI sem gastar token.
  INTERNAL_AGENT_RUN_STUB: z
    .enum(["true", "false"])
    .optional()
    .default("false")
    .transform((v) => v === "true"),

  // Sentry
  SENTRY_DSN: z.string().optional().default(""),

  /**
   * Resend — o transporte de TODO e-mail transacional (convite, LGPD, alarme).
   *
   * Estavam lidas de `process.env` CRU dentro de `lib/email/resend.ts`, fora do
   * Zod e fora do `.env.example` (medido: `grep -n RESEND lib/env.ts` → nada;
   * `grep -c -i resend .env.example` → 0). Duas consequências que só apareciam
   * na VPS: o `env-example-sync` nunca cobrou a documentação da chave, e o
   * `install.sh` não a gravava — como o `.env` é escrito com truncamento
   * (`} > .env`), a chave posta à mão era DESCARTADA na instalação seguinte,
   * num script que o README vende como idempotente.
   *
   * `RESEND_FROM_EMAIL` vazio NÃO cai num domínio nosso: ver `fromAddress()`.
   */
  RESEND_API_KEY: z.string().optional().default(""),
  RESEND_FROM_EMAIL: z.string().optional().default(""),

  /**
   * SMTP — o SEGUNDO transporte de e-mail, ao lado da Resend, nunca no lugar
   * dela. Quem já roda com Resend não mexe em nada; quem instala numa VPS e não
   * quer criar conta em serviço externo preenche estas sete e o envio sai pelo
   * servidor dele. Qual dos dois atende cada envio é decidido em
   * `lib/email/roteador.ts` — SMTP quando há SMTP, Resend quando não há.
   *
   * O banco (`platform_smtp_settings`, pela tela /admin/email) PREVALECE sobre
   * estas variáveis; elas existem para provisionar uma VPS sem abrir interface,
   * e são o piso de rollback. Todas `optional().default()`: `.env` antigo não
   * quebra ao atualizar.
   */
  CADENCIA_LIMITE_EMAILS_POR_DIA: z.coerce.number().int().min(1).max(10_000).optional().default(60),
  SMTP_HOST: z.string().optional().default(""),
  SMTP_PORT: z.coerce.number().int().min(1).max(65535).optional().default(587),
  SMTP_SECURITY: z.enum(["starttls", "tls", "none"]).optional().default("starttls"),
  SMTP_USERNAME: z.string().optional().default(""),
  SMTP_PASSWORD: z.string().optional().default(""),
  SMTP_FROM_EMAIL: z.string().optional().default(""),
  SMTP_FROM_NAME: z.string().optional().default(""),

  /**
   * E-mail de suporte que a instalação mostra ao CLIENTE FINAL (tela de conta
   * suspensa, tela de cobrança).
   *
   * Vazio = a tela não mostra endereço nenhum. É deliberado: cair no nosso
   * endereço numa tela de suspensão manda o cliente do revendedor escrever
   * para quem não suspendeu a conta dele e não tem como resolvê-la.
   */
  SUPPORT_EMAIL: z.string().optional().default(""),

  // EPIC-11 Impersonate cookie HMAC secret. Optional at boot (route returns
  // 503 at runtime if missing/short); required in prod for the feature to
  // function. Min 32 chars when present is enforced at use site.
  IMPERSONATE_COOKIE_SECRET: z.string().optional().default(""),

  /**
   * Retenção do histórico que o cron `data-retention` poda (issue #261).
   *
   * As DUAS entram como `z.string()` e nunca como `z.coerce.number()`, pelo
   * mesmo motivo de `AI_BUDGET_ENFORCEMENT` algumas linhas acima: o `safeParse`
   * deste arquivo LANÇA quando o schema recusa, e no Next isso derruba toda
   * requisição com 500 num contêiner que segue `healthy` (o healthcheck é probe
   * TCP). Quem digita `noventa` às 2h da manhã tentando liberar espaço não pode
   * derrubar o produto. A interpretação — com padrão, piso e AVISO quando o
   * valor não vale como escrito — mora em `lib/retencao/politica.ts`.
   *
   * Ausentes = o comportamento default (90 dias de fila, 5 anos de auditoria).
   * Nenhuma instalação precisa editar `.env` para a poda funcionar.
   */
  JOB_QUEUE_RETENTION_DAYS: z.string().optional().default(""),
  AUDIT_LOG_RETENTION_DAYS: z.string().optional().default(""),
  /**
   * Conversa da equipe com a IA sobre um caso (migration 0281). `z.string()`
   * pela MESMA razão das duas acima: `lib/env.ts` lança na primeira requisição
   * e o healthcheck é TCP — um `z.coerce.number()` aqui transformaria
   * `CASE_CHAT_RETENTION_DAYS=noventa` no derrubador do produto inteiro, com o
   * contêiner marcado `healthy`. Quem interpreta é `lib/retencao/politica.ts`,
   * onde lixo resolve para o lado seguro e o operador vê o aviso no log.
   */
  CASE_CHAT_RETENTION_DAYS: z.string().optional().default(""),
  /**
   * Passagem do atendimento para uma pessoa (migration 0291). `z.string()` pela
   * MESMA razão das três acima — quem interpreta é `lib/retencao/politica.ts`,
   * onde lixo resolve para o lado seguro e o operador vê o aviso no log, em vez
   * de o contêiner ficar `healthy` respondendo 500 a tudo.
   */
  PASSAGEM_RETENTION_DAYS: z.string().optional().default(""),
  /**
   * Registro de entrega do aviso de caso no WhatsApp da equipe (migration
   * 0292). `z.string()` pela MESMA razão das quatro acima — quem interpreta é
   * `lib/retencao/politica.ts`, onde lixo resolve para o lado seguro e o
   * operador vê o aviso no log, em vez de o contêiner ficar `healthy`
   * respondendo 500 a tudo.
   */
  CASE_ALERT_RETENTION_DAYS: z.string().optional().default(""),
  /**
   * Candidato da prospecção nativa vencido (migration 0408, issue #1313).
   * `z.string()` pela MESMA razão das cinco acima — quem interpreta é
   * `lib/retencao/politica.ts`, onde lixo resolve para o lado seguro e o
   * operador vê o aviso no log, em vez de o contêiner ficar `healthy`
   * respondendo 500 a tudo. Padrão 365, piso 90, decisão do dono (PR #1577).
   */
  PROSPECCAO_RETENTION_DAYS: z.string().optional().default(""),
  /**
   * Observações do Jev (migration 0421): o par Jev × mecanismo de hoje que o
   * cartão compara, sem texto de cliente. `z.string()` pela MESMA razão das
   * irmãs acima — quem interpreta é `lib/retencao/politica.ts`. Padrão 90, piso
   * 30 (a janela da concordância).
   */
  JEV_OBSERVACOES_RETENTION_DAYS: z.string().optional().default(""),
  /**
   * Rascunho sugerido por integração JÁ VENCIDO (`conversation_drafts`,
   * migration 0419, issue #1686). `z.string()` pela MESMA razão das irmãs
   * acima — quem interpreta é `lib/retencao/politica.ts`, onde lixo resolve
   * para o lado seguro. Padrão 30, piso 7, contados do `expires_at` (a linha
   * só responde enquanto a janela dela está aberta).
   */
  DRAFT_RETENTION_DAYS: z.string().optional().default(""),
  /**
   * Candidatos ao golden set (migration 0428, issue #1695): rótulo de near-miss
   * e de divergência, sem texto de cliente. `z.string()` pela MESMA razão das
   * irmãs acima — quem interpreta é `lib/retencao/politica.ts`. Padrão 90, piso
   * 30 (a janela em que um near-miss ainda é curável).
   */
  GOLDEN_CANDIDATES_RETENTION_DAYS: z.string().optional().default(""),
  /**
   * As tabelas append-only da IA (migration 0587). `z.string()` pela MESMA razão
   * das irmãs — quem interpreta é `lib/retencao/politica.ts`, onde lixo cai no
   * padrão em vez de derrubar o app. Padrões/pisos: telemetria 400/100, ritmo de
   * envio 2/2, cópias enviadas 30/7, checkpoints superados 180/30.
   */
  AI_TELEMETRY_RETENTION_DAYS: z.string().optional().default(""),
  PACING_LEDGER_RETENTION_DAYS: z.string().optional().default(""),
  OUTBOUND_COPIES_RETENTION_DAYS: z.string().optional().default(""),
  LEAD_CHECKPOINT_RETENTION_DAYS: z.string().optional().default(""),

  // LGPD export (S-08.04)
  LGPD_SIGNING_KEY: z.string().optional().default(""),
  LGPD_EXPORT_EXPIRES_HOURS: z.string().optional().default("72"),
  LGPD_DPO_EMAIL: z.string().optional().default(""),

  // Google Agenda — opcional, e é a DECISÃO 3.1 em forma de schema. Sem as
  // duas, o módulo de agenda funciona INTEIRO: some o botão "Conectar Google" e
  // a tela explica em uma linha o que falta e onde obter. É o estado real de um
  // primeiro deploy, e é onde moram os piores bugs de primeira impressão.
  //
  // NÃO há flag de "enabled" de propósito. Estar configurado É ter as duas
  // chaves; uma flag seria um terceiro estado para o operador errar — e
  // `z.enum` sobre valor que ele digita transforma a alavanca em derrubador do
  // app inteiro no dia em que alguém escrever `TRUE`.
  GOOGLE_CALENDAR_CLIENT_ID: z.string().optional().default(""),
  GOOGLE_CALENDAR_CLIENT_SECRET: z.string().optional().default(""),

  // Google Ads — credencial da INSTALAÇÃO, não da organização (migration 0307).
  // O developer token pertence a quem construiu o software, não à conta de
  // anúncios de cada cliente: uma instalação usa o MESMO token pra reportar
  // conversão em contas diferentes, cada uma com seu próprio refresh token
  // (esse sim por organização, em ad_platform_connections). Sem tela de
  // configuração ainda — env-only, como o app OAuth do Google era antes da 0201 —
  // porque só a instalação PRECISA desta credencial existir; cada organização só
  // precisa AUTORIZAR (OAuth), nunca ver nem digitar o developer token.
  GOOGLE_ADS_DEVELOPER_TOKEN: z.string().optional().default(""),
  GOOGLE_ADS_OAUTH_CLIENT_ID: z.string().optional().default(""),
  GOOGLE_ADS_OAUTH_CLIENT_SECRET: z.string().optional().default(""),

  // Nuvemshop — opcional (template genérico open-source). Só exigidas quando
  // NUVEMSHOP_ENABLED=true; o runtime já degrada via getConfig()==null.
  NUVEMSHOP_APP_ID: z.string().optional().default(""),
  NUVEMSHOP_CLIENT_ID: z.string().optional().default(""),
  NUVEMSHOP_CLIENT_SECRET: z.string().optional().default(""),
  NUVEMSHOP_ENABLED: z
    .enum(["true", "false"])
    .optional()
    .default("false")
    .transform((v) => v === "true"),

  // App URLs
  NEXT_PUBLIC_APP_URL: z
    .string()
    .url()
    .default("http://localhost:3000"),
  NEXT_PUBLIC_ADMIN_URL: z
    .string()
    .url()
    .default("http://localhost:3000"),
  /**
   * URL pública opcional para os webhooks da Meta (WhatsApp Cloud API / canais oficiais).
   * Quando definida, é usada no lugar de NEXT_PUBLIC_APP_URL para compor a URL de callback
   * dos webhooks da Meta (#1426), permitindo isolar a interface interna/VPN da URL pública.
   */
  META_WEBHOOK_BASE_URL: z.string().optional().default(""),
  /**
   * Base (host) da Graph API do canal oficial, e a do eixo de anúncio (#817).
   *
   * `optional().default("")` e NÃO validada como URL aqui, por dois motivos
   * medidos. Primeiro: o valor recusável é o que fecha a fenda, e a validação
   * deste arquivo acontece no IMPORT do Next — um `z.string().url()` reprovaria
   * TODAS as telas, com o contêner `healthy` e nada dizendo o porquê, exatamente
   * o modo de falha que `diasDeRetencao` (lá em cima) registra e evita. Segundo:
   * a decisão é do MANTENEDOR, não do boot — quem escreve no `.env` de uma VPS
   * pode trocar o binário, então o que importa é que o erro de digitação não
   * derrube o envio, e é o que `graph-base.ts` faz (host real + aviso no log).
   */
  META_GRAPH_BASE_URL: z.string().optional().default(""),
  META_ADS_GRAPH_BASE_URL: z.string().optional().default(""),

  // Marca da instalação (white-label) — ver lib/branding.ts.
  // Sem prefixo NEXT_PUBLIC_ de propósito: essas seriam queimadas no bundle
  // durante o build da imagem, e o self-hoster roda uma imagem pré-buildada.
  // O <PublicEnvScript/> injeta os valores em runtime.
  APP_NAME: z.string().optional().default(""),
  APP_LOGO_URL: z.string().optional().default(""),
  /**
   * Cor da marca — um hex (`#506d48`), do qual `lib/branding/` deriva a rampa
   * inteira. Vazio = o produto se pinta com a cor dele.
   *
   * `optional().default("")` e NUNCA `required()`, e o motivo é o modo de falha,
   * não a preguiça: `lib/env.ts` lança no import do módulo, que no Next
   * acontece na PRIMEIRA REQUISIÇÃO, não no boot. E o healthcheck do contêiner é
   * um probe TCP puro (`docker-compose.prod.yml:44`, deliberadamente — /health
   * derrubaria o app quando o WAHA cai). Somando os dois: o Docker mostraria
   * `healthy` com 100% das requisições em 500. Uma var de COR não pode ter esse
   * poder; a validação do valor é do resolvedor, que degrada e diz o motivo.
   */
  APP_ACCENT_HEX: z.string().optional().default(""),

  /**
   * Com o que a instalação NASCE quanto a cadastro: `aberto` (padrão),
   * `com_aprovacao` ou `so_convite`. Vazio = `aberto`, que é como o produto
   * sempre funcionou.
   *
   * O BANCO ESTÁ ACIMA DISTO. Havendo linha em `platform_settings` — o que
   * acontece assim que alguém usa a tela em `/admin/cadastro` —, é ela que
   * manda. Esta variável responde nas duas situações em que o banco não tem o
   * que dizer: instalação que nunca abriu a tela, e app que subiu e ainda não
   * conseguiu ler o banco. A segunda é o motivo de ela existir: sem um piso
   * declarado, uma instalação deliberadamente fechada abriria nessa janela.
   *
   * `z.string()` e NÃO `z.enum`, pelo mesmo motivo escrito ao lado de
   * `APP_ACCENT_HEX`: um enum lançaria no import do módulo, que no Next é a
   * PRIMEIRA REQUISIÇÃO — e com healthcheck de probe TCP o Docker mostraria
   * `healthy` com 100% das requisições em 500. Valor irreconhecível degrada em
   * `lib/auth/politica-de-cadastro.ts`, com erro no log.
   */
  SIGNUP_MODE: z.string().optional().default(""),

  /**
   * Par VAPID do Web Push. Opcionais: sem elas a bandeja só funciona com a aba
   * viva (Notification API + SW local). O kit gera o par sozinho no install e na
   * atualização; fora dele, `npx web-push generate-vapid-keys`. Nunca troque um par
   * existente: trocar desliga os avisos de todo aparelho já inscrito.
   */
  VAPID_PUBLIC_KEY: z.string().optional().default(""),
  VAPID_PRIVATE_KEY: z.string().optional().default(""),
});

let parsed = schema.safeParse(process.env);

// Na fase de build da imagem Docker, semeia placeholders pras vars que faltam
// (URL válida, passa .url()/.min(1)) e revalida — permite `next build` sem os
// segredos de runtime. NUNCA acontece em runtime: lá process.env está completo
// e este bloco não roda, então o boot real continua cobrando tudo.
if (!parsed.success && isBuildPhase) {
  const seeded: Record<string, string | undefined> = { ...process.env };
  for (const key of Object.keys(parsed.error.flatten().fieldErrors)) {
    if (!seeded[key]) seeded[key] = "https://build-placeholder.invalid";
  }
  parsed = schema.safeParse(seeded);
}

if (!parsed.success) {
  // Log estruturado pra debug. Sentry capturaria via uncaught.
  console.error("[env] Falha de validação de variáveis de ambiente:");
  console.error(parsed.error.flatten().fieldErrors);
  throw new Error(
    "Variáveis de ambiente inválidas. Veja o erro acima e ajuste o .env da instalação (ou .env.local, em dev).",
  );
}

export const env = parsed.data;

if (env.NODE_ENV === "production") {
  const vercelCron = process.env.CRON_SECRET?.trim();
  if (vercelCron) {
    // Agendador externo que injeta `CRON_SECRET` (é o nome de mercado) chama as
    // rotas com `Bearer $CRON_SECRET`, e `lib/auth/cron-auth.ts` só confere o
    // Bearer contra INTERNAL_CRON_SECRET e INTERNAL_SECRET — sem esta cópia, quem
    // agenda por esse caminho leva 401/403 em toda rodada. O caminho oficial do
    // produto não passa por aqui: o `crond` do serviço `scheduler` manda
    // `Bearer $INTERNAL_SECRET` (`docker/scheduler/entrypoint.sh`). A cópia é
    // vigiada por `tests/unit/cron-routes-scheduled.test.ts`.
    env.INTERNAL_CRON_SECRET = vercelCron;
  }
}

// Este processo só conhece as chaves do AMBIENTE. As credenciais cadastradas em
// IA › Credenciais moram no banco e são resolvidas mais tarde, no contexto da
// organização; por isso ausência aqui nunca pode virar diagnóstico de "IA muda".
// `OPENROUTER_API_KEY` entra na condição porque `resolveLanguageModel`
// (lib/ai/gateway.ts) a trata como configuração válida no ambiente, assim como
// gateway e Anthropic.
// `OPENAI_API_KEY` entra pelo mesmo motivo, com a diferença que o aviso não
// precisa esconder: ela atende os pontos do provedor que a ORGANIZAÇÃO escolheu
// (é o último degrau de `resolverModeloDoPonto`, lib/ai/gateway-binding.ts).
// Sem esta linha, uma instalação que responde pelo OpenAI lia no boot que
// "nenhuma chave de IA" estava configurada — issue #1181.
if (
  !env.AI_GATEWAY_API_KEY &&
  !env.ANTHROPIC_API_KEY &&
  !env.OPENROUTER_API_KEY &&
  !env.OPENAI_API_KEY
) {
  console.warn(
    "[env] Nenhuma chave de IA configurada no ambiente " +
      "(AI_GATEWAY_API_KEY, ANTHROPIC_API_KEY, OPENROUTER_API_KEY ou OPENAI_API_KEY). " +
      "Isto não prova que o agente está sem credencial: cada organização pode ter uma chave " +
      "cadastrada em IA › Credenciais. A falta real só é conhecida quando a resolução completa " +
      "do turno não encontra chave em nenhum degrau.",
  );
}
// Este aviso ANUNCIAVA UM DESFECHO que o boot não tem como saber, e a correção
// aqui é a mesma que o bloco de cima já pagou uma vez. Ele dizia "RAG embedding
// unavailable" e "voice-note transcription is off" — as duas afirmações são
// falsas numa instalação onde a organização cadastrou a chave PELA TELA:
// o preparo de material resolve a chave por uma escada (ponto de IA →
// credencial da organização → gateway → este env — `lib/ai/embeddings/chave.ts`),
// e a transcrição já caía na credencial da org antes disso
// (`workers/media-derive-worker.ts`).
//
// Quem lê um aviso de boot não consegue conferir o desfecho; ele acredita. E
// acreditar em "está desligado" quando está ligado faz a pessoa ir cadastrar
// uma chave que ela já tem — ou, pior, desistir do recurso. Então o aviso passou
// a dizer só o que ESTE processo sabe: o que a variável é, e o que fazer se não
// houver chave em lugar nenhum.
if (!env.OPENAI_API_KEY) {
  console.warn(
    "[env] OPENAI_API_KEY ausente — ela é o ÚLTIMO degrau da escada de chave da OpenAI " +
      "(preparo de material do acervo e transcrição de áudio). Se alguma organização já " +
      "cadastrou a chave em IA › Credenciais, os dois seguem funcionando por ela; se não " +
      "cadastrou nenhuma, ambos ficam parados até que alguém cadastre — pela tela ou aqui.",
  );
}
if (!env.IMPERSONATE_COOKIE_SECRET || env.IMPERSONATE_COOKIE_SECRET.length < 32) {
  console.warn(
    "[env] IMPERSONATE_COOKIE_SECRET not set or shorter than 32 chars — impersonate flow will return 503 at runtime.",
  );
}

export type Env = typeof env;
