/**
 * ai-sentiment-worker — classifies the sentiment of inbound messages.
 *
 * Consumes `message.received` events (parallel to ai-response-worker).
 * Uses `anthropic/claude-haiku-4-5` via Vercel AI Gateway with generateObject
 * and a strict Zod schema so the result is always typed.
 *
 * Com o Jev (System One) ligado em `organizations.settings.jev`, ele mede
 * primeiro — ver o bloco "O Jev primeiro" no meio do arquivo. E, na mesma
 * mensagem, pergunta a ele pelos pedidos do cliente (uma pessoa, parar de
 * receber mensagens) onde a regra de hoje não os pegou — ver "Os pedidos do
 * cliente", logo antes, e `./ai-sentiment-worker.pedidos.ts`.
 *
 * Design principles (CLAUDE.md):
 * - Service-role admin client bypasses RLS → EVERY query filters `organization_id`
 *   programmatically from the trusted event_log row, never from user input.
 * - Any failure is swallowed (try/catch global) so the bot path in
 *   ai-response-worker keeps running unaffected.
 * - `console.log` is forbidden — only `console.warn`/`console.error` with prefix.
 */

import { generateObject, type LanguageModel } from "ai";
import { z } from "zod";

import { costCents } from "@/lib/agent-engine/edge/llm/pricing";
import { resolverAgenteDaConversa } from "@/lib/ai/agents/agente-da-conversa";
import { agenteAtende, precisaRecuperarLegado } from "@/lib/ai/agents/no-ar";
import { podeGastarComIa } from "@/lib/ai/budget/pode-gastar";
import { computeCost } from "@/lib/ai/cost";
import { avisarNaCentral, fecharAvisoDoJev } from "@/lib/ai/decisao/aviso";
import { MODELO_DO_JEV } from "@/lib/ai/decisao/cliente";
import { medirClima, type ClimaMedido } from "@/lib/ai/decisao/clima";
import { lerConfigDoJev } from "@/lib/ai/decisao/config";
import { falhasSeguidas } from "@/lib/ai/decisao/disjuntor";
import { CHAVES_DO_CLIMA, type MotorDoClima } from "@/lib/ai/decisao/metadados-do-clima";
import { avisarAEquipe, TAREFAS_DOS_PEDIDOS, type PedidosObservados } from "@/lib/ai/decisao/pedidos";
import { estadoEfetivoDaTarefa, TAREFA_DO_CLIMA } from "@/lib/ai/decisao/tarefas";
import { codigoDoErroDoJev } from "@/lib/ai/decisao/textos";
import { decidirElegibilidadeDaConversaViaSupabase } from "@/lib/ai/elegibilidade/consulta-supabase";
import {
  ttlDaAutorizacaoMs,
  type DecisaoDaConversa,
} from "@/lib/ai/elegibilidade/gate";
import { DEFAULT_CLASSIFIER_MODEL } from "@/lib/ai/gateway";
import { resolverModeloDoPonto } from "@/lib/ai/gateway-binding";
import { logInvocation, type LogInvocationInput } from "@/lib/ai/log-invocation";
import { DEFAULT_SENTIMENT_THRESHOLD, SENTIMENT_SYSTEM_PROMPT } from "@/lib/ai/prompts/sentiment";
import type { EventRow } from "@/lib/event-log/dispatcher";
import { normalizarIdioma } from "@/lib/i18n/idiomas";
import { MENSAGEM_REDIGIDA } from "@/lib/lgpd/cascata";
import { logger } from "@/lib/logger";
import { aiDispatchModeSchema } from "@/lib/schemas/settings";
import { createAdminClient } from "@/lib/supabase/admin";
import { aConversaAgora, perguntarOsPedidosDoCliente } from "@/workers/ai-sentiment-worker.pedidos";

const SENTIMENT_MODEL = DEFAULT_CLASSIFIER_MODEL; // "anthropic/claude-haiku-4-5"
const CLASSIFY_TIMEOUT_MS = 5_000;

// As descrições NÃO são decoração: viram o JSON Schema da ferramenta que o
// provider manda ao modelo. Sem elas o `.max(100)` existia só no validador — o
// modelo nunca ficava sabendo do limite e escrevia 223, 297, 340 caracteres
// (medido com mensagens reais desta instalação). Com a descrição, o mesmo
// conjunto caiu para 59–102.
//
// O teto do Zod é FOLGADO de propósito. Modelo não conta caractere: mesmo
// avisado, uma amostra bateu 102. Reprovar a classificação inteira por 2
// caracteres a mais seria péssimo negócio — ainda mais porque
// `reasoning_short` é DESCARTADO (só `sentiment_score` e a latência vão para
// messages.metadata). Ele existe para o modelo raciocinar antes de pontuar,
// não para ser guardado. A descrição segura a verbosidade (e o custo); o teto
// só impede resposta absurda.
const sentimentSchema = z.object({
  sentiment_score: z
    .number()
    .min(0)
    .max(1)
    .describe("0 = muito negativo, 0.5 = neutro, 1 = muito positivo"),
  reasoning_short: z
    .string()
    .max(280)
    .describe("Justificativa curta da nota, em NO MÁXIMO 100 caracteres"),
});

export interface SentimentResult {
  skipped: boolean;
  reason?: string;
  sentiment_score?: number;
}

export async function processSentiment(event: EventRow): Promise<SentimentResult> {
  try {
    const messageId =
      (event.payload?.["message_id"] as string | undefined) ?? event.entity_id ?? null;
    const conversationId = (event.payload?.["conversation_id"] as string | undefined) ?? null;
    if (!messageId) {
      return { skipped: true, reason: "missing_message_id" };
    }

    const admin = createAdminClient();

    // ── O Jev nesta tarefa ────────────────────────────────────────────────
    // Lido aqui, além de dentro do ponto (`chaveDaOrganizacao`, que com a
    // tarefa desligada não manda nada), porque o ESTADO do clima muda quem
    // decide. Leitura que falha vale como desligada (`lerConfigDoJev`
    // nunca lança). Sem `tarefas.clima` gravado, o estado é o `modo` da onda 1.
    const { data: org } = await admin
      .from("organizations")
      .select("settings, locale")
      .eq("id", event.organization_id)
      .maybeSingle();
    const daOrg = org as { settings?: unknown; locale?: string | null } | null;
    const configDoJev = lerConfigDoJev(daOrg?.settings);
    const climaDoJev = estadoEfetivoDaTarefa(configDoJev, TAREFA_DO_CLIMA);
    // Os pedidos do cliente não dependem do clima: rodam com ele pausado.
    const pedidosRodam = TAREFAS_DOS_PEDIDOS.some((t) => estadoEfetivoDaTarefa(configDoJev, t) !== "desligada");

    // ── Load message (programmatic org filter) ────────────────────────────
    const { data: message, error: msgErr } = await admin
      .from("messages")
      .select("id, body, direction, conversation_id, organization_id, metadata, created_at")
      .eq("id", messageId)
      .eq("organization_id", event.organization_id)
      .maybeSingle();

    if (msgErr || !message) {
      return { skipped: true, reason: "message_not_found" };
    }

    // ── Guard: inbound only ───────────────────────────────────────────────
    if (message.direction !== "inbound") {
      return { skipped: true, reason: "not_inbound" };
    }

    // ── Guard: non-empty body ─────────────────────────────────────────────
    // Os pedidos do cliente também ficam de fora aqui: um pedido por ÁUDIO chega
    // com o corpo vazio, e a transcrição (que o turno lê) só é gravada depois
    // (`workers/media-derive-worker.ts`). O Jev não é perguntado sobre ele.
    const body = (message.body ?? "").trim();
    if (!body) {
      return { skipped: true, reason: "empty_body" };
    }

    // ── Guard: elegibilidade da IA ────────────────────────────────────────
    // Os efeitos deste worker são o handoff por sentimento (`ai.sentiment_alert`
    // → `triggerHandoff`) e os pedidos do cliente. Com `!permite` os dois são
    // nada: `triggerHandoff` recusa pela MESMA régua (orchestrator.ts, "GATE DE
    // ELEGIBILIDADE") e `perguntarOsPedidosDoCliente` devolve `null` sem
    // `iaPodeResponder`. Classificar seria pagar o modelo por um alerta que
    // ninguém consome — conversa com uma pessoa, silenciada, contato passado a
    // humano, trava da lista. Fail-closed: erro de leitura → pula.
    const convIdParaGate = conversationId ?? (message.conversation_id as string | null);
    let elegib: DecisaoDaConversa | null = null;
    if (convIdParaGate) {
      try {
        elegib = await decidirElegibilidadeDaConversaViaSupabase(admin, {
          organizationId: event.organization_id,
          conversationId: convIdParaGate,
          agora: new Date(),
          ttlMs: ttlDaAutorizacaoMs(process.env),
        });
        if (elegib !== null && !elegib.permite) {
          return { skipped: true, reason: `nao_elegivel_para_ia:${elegib.motivo}` };
        }
      } catch {
        return { skipped: true, reason: "elegibilidade_indeterminada" };
      }
    }

    // ── Guard: contato da EQUIPE (#3534) ──────────────────────────────────
    //
    // A régua de equipe (`settings.crm.team_contact_tags`) significa
    // "atende, mas não repassa sozinho". Ela NÃO entra no `!permite` acima: o
    // contato é elegível de propósito (o agente fala com o sócio como colega, e
    // isso já é o comportamento do turno). O que este worker não pode fazer
    // para ele é ABRIR PASSAGEM — `ai.sentiment_alert` vira
    // `triggerHandoff('low_sentiment')`, que silencia a conversa em
    // `bot_silenced_until='infinity'` e manda "já acionei o time" para quem É o
    // time. Medir e registrar continua valendo: a nota entra em
    // `messages.metadata` como para qualquer conversa, e o cartão do clima
    // segue medindo. Só o repasse automático não sai.
    const contatoDaEquipe = elegib?.contatoDaEquipe === true;
    // Os pedidos do cliente também não se aplicam a quem É a equipe: "pediu
    // para falar com uma pessoa" e "pare de me mandar mensagens" são pedidos de
    // CLIENTE, e o aviso resultante seria ruído sobre o próprio dono. Não é
    // repasse (os pedidos abrem item na Central, não silenciam), mas é o mesmo
    // princípio: para a equipe, o medidor não chama ninguém.
    const pedidosParaEsteContato = pedidosRodam && !contatoDaEquipe;

    // ── Qual agente atende ESTA conversa? ─────────────────────────────────
    //
    // Antes, a resposta era "o primeiro da organização que atende", ordenado por
    // `is_default` e depois `created_at` — e a conversa que disparou o evento não
    // entrava na consulta em lugar nenhum. Com um agente só, certo por acidente.
    // Com dois, o limiar em vigor passava a depender da ORDEM DE CRIAÇÃO: numa
    // clínica, cliente triste é sinal de problema; numa assistência técnica, é o
    // cliente normal. O mesmo limiar erra nos dois sentidos, e quem configurou o
    // campo do agente B ficava vendo o comportamento do agente A sem pista
    // nenhuma na tela — os dois campos existem, os dois aceitam valor, e um
    // deles não fazia nada. (issue #486)
    const { data: conversa } = await admin
      .from("conversations")
      .select("id, channel_session_id, active_ai_agent_id, contact_id, is_group")
      .eq("id", message.conversation_id)
      .eq("organization_id", event.organization_id)
      .maybeSingle();

    // As versões PUBLICADAS ligadas ao número em que a conversa acontece — é
    // quem de fato responde ao cliente por aquela sessão. `null` (não consegui
    // consultar) e `[]` (consultei, não há) levam ao mesmo desfecho na régua,
    // mas quem lê o log precisa distinguir os dois.
    let versoesPublicadasNaSessao: string[] | null = null;
    if (conversa?.channel_session_id) {
      const { data: versoes } = await admin
        .from("ai_agent_versions")
        .select("id, channel_session_id, status")
        .eq("organization_id", event.organization_id)
        .eq("channel_session_id", conversa.channel_session_id)
        .eq("status", "published");
      versoesPublicadasNaSessao = (versoes ?? []).map((v) => v.id as string);
    }

    const { data: candidatos } = await admin
      .from("ai_agents")
      .select(
        "id, config, kind, is_active, paused_at, published_version_id, archived_at, priority, created_at",
      )
      .eq("organization_id", event.organization_id)
      .is("archived_at", null);

    // ── Guard: há automático no ar? ───────────────────────────────────────
    // Sem nenhum agente atendendo (todos pausados/despublicados), não há o que
    // passar de bot para humano: o handoff por sentimento mandava ao cliente
    // "não há atendente disponível… sua conversa entrou na fila" enquanto a
    // equipe já respondia pelo celular. Medido numa VPS em set/2026: 3 avisos
    // assim com os três agentes pausados, e o classificador seguia cobrando
    // cada mensagem recebida. Mesma régua de `resolverAgenteDaConversa`.
    if (!(candidatos ?? []).some((c) => agenteAtende(c) || precisaRecuperarLegado(c))) {
      return { skipped: true, reason: "nenhum_agente_no_ar" };
    }

    // ── O modelo do clima, depois das guardas baratas ─────────────────────
    // Três leituras e uma decifragem: fica atrás de tudo que pula sem ele.
    //
    // Sem portão de `.env` na frente: `isAiGatewayConfigured()` só olhava três
    // variáveis de ambiente e barrava a chave colada pela tela (IA ›
    // Credenciais) e a OpenAI. Quem sabe se existe modelo é o resolver abaixo,
    // que devolve `null` quando nada existe.
    //
    // Passar SENTIMENT_MODEL como string cai no gateway da Vercel mesmo sem
    // chave (plano anônimo) e devolve "Unauthenticated ... Configure
    // AI_GATEWAY_API_KEY" — o que quebrava este worker em toda instalação que
    // só tem ANTHROPIC_API_KEY, ou seja, o padrão do install.sh. O resolver
    // devolve o provider certo para a chave que existir.
    // O painel de provedores manda AQUI também. Sem esta linha, a tela
    // oferecia "Medir o clima da conversa", aceitava a escolha e dizia
    // "salvo" — e este worker seguia usando o modelo padrão. Botão que não
    // controla nada é pior que botão ausente: gasta a confiança de quem clicou.
    // O id padrão é da Anthropic: numa empresa que atende pela OpenAI, Google
    // ou DeepSeek sem modelo escolhido para o clima, ninguém o executa, e o
    // clima ficava mudo enquanto o painel dizia "Usando o padrão da
    // organização". A queda para o padrão da organização é o que torna isso
    // verdade. A rota do cartão do Jev faz a MESMA pergunta.
    const resolvido = await resolverModeloDoPonto(
      "sentiment_classify",
      event.organization_id,
      SENTIMENT_MODEL,
      { naFaltaUsarOPadraoDaOrganizacao: true },
    );

    // Sem IA de linguagem e sem o Jev, não há quem meça nem o que perguntar.
    // Para contato da equipe, `pedidosParaEsteContato` já é `false`: nada a
    // fazer neste worker.
    if (!resolvido && climaDoJev === "desligada" && !pedidosParaEsteContato) {
      return { skipped: true, reason: "ai_gateway_key_missing" };
    }

    const { agente: agent, motivo: motivoDoAgente } = resolverAgenteDaConversa(
      candidatos ?? [],
      conversa
        ? {
            active_ai_agent_id: conversa.active_ai_agent_id as string | null,
            versoesPublicadasNaSessao,
          }
        : null,
    );

    // Sem agente resolvido, o padrão do PRODUTO — nunca o limiar do vizinho.
    // Chutar a configuração de outro agente é o defeito de novo, agora com cara
    // de configuração deliberada.
    const agentConfig = (agent?.config as Record<string, unknown> | null) ?? {};
    const threshold =
      typeof agentConfig["sentiment_threshold"] === "number"
        ? agentConfig["sentiment_threshold"]
        : DEFAULT_SENTIMENT_THRESHOLD;

    const comum = {
      organization_id: event.organization_id,
      // `null`, não `""` (issue #160): o worker roda mesmo sem agente ativo — lê
      // o agente só para o threshold e cai no default —, e string vazia numa
      // coluna uuid fazia o insert de auditoria falhar em silêncio. O custo
      // existe; a linha precisa entrar.
      agent_id: agent?.id ?? null,
      conversation_id: conversationId ?? message.conversation_id ?? null,
      message_id: messageId,
      invocation_kind: "sentiment_classify",
    } satisfies Partial<LogInvocationInput>;

    // ── O teto de gasto de IA, ANTES de qualquer chamada paga ─────────────
    //
    // Este worker roda a cada mensagem recebida e não passa pelo seam do engine
    // nem pelo guard do caminho legado — os dois lugares onde o teto vinculava.
    // Com a organização em "parar a IA" e o teto estourado, o turno ia para a
    // fila humana e o clima seguia cobrando cada mensagem. A pergunta é a MESMA
    // decisão pura dos outros dois (`podeGastarComIa` → `decidirOrcamento`):
    // "só avisar" e "desligado" seguem; só o veredito `bloquear` pula.
    //
    // Aqui, e não mais abaixo, porque daqui para baixo TUDO é pago: o Jev do
    // clima, o Jev dos pedidos e o LLM de reserva. E depois dos guards baratos
    // (mensagem, direção, elegibilidade, agente no ar), para a leitura do
    // orçamento não rodar à toa em mensagem que nem seria classificada.
    // Pular não deixa linha em `llm_calls` — não houve chamada —, e o motivo
    // fica no resultado do consumidor do event_log e no log.
    const orcamento = await podeGastarComIa(event.organization_id, "sentiment_classify");
    if (!orcamento.pode) {
      logger.info("[ai-sentiment-worker] clima não medido: teto de gasto de IA atingido", {
        organization_id: event.organization_id,
        message_id: messageId,
        gasto_cents: orcamento.gastoCents,
        teto_cents: orcamento.tetoCents,
      });
      return { skipped: true, reason: "orcamento_de_ia_estourado" };
    }

    // ── Os pedidos do cliente, ao lado do clima ────────────────────────────
    //
    // Numa chamada PRÓPRIA ao Jev, começada aqui e esperada só no fim
    // (`finally`): corre EM PARALELO à do clima, nunca muda o desfecho dele, e o
    // dreno não segue com ela ainda no ar. Nunca rejeita. O aviso de "Avisar a
    // equipe" sai no `finally`, depois de se saber o que o clima fez e de reler
    // a conversa (`aConversaAgora`).
    const pedidos: Promise<PedidosObservados | null> = pedidosParaEsteContato
      ? perguntarOsPedidosDoCliente(admin, {
          organizationId: event.organization_id,
          messageId,
          conversationId: conversa?.id ?? null,
          sessaoId: (conversa?.channel_session_id as string | null | undefined) ?? null,
          contactId: conversa?.contact_id ?? null,
          grupo: conversa?.is_group === true,
          iaPodeResponder: elegib?.permite === true,
          // O dreno descarta o turno do modo externo antes de tudo (spec 14), e
          // a regra de hoje não roda. O turno cedido ao follow-up de retorno
          // (`deveCederTurnoAoRetorno`) fica declarado, não espelhado — ver a cola.
          atendimentoExterno:
            aiDispatchModeSchema.parse((daOrg?.settings as { ai_dispatch_mode?: unknown } | null | undefined)?.ai_dispatch_mode) ===
            "external",
          agentId: agent?.id ?? null,
          config: configDoJev,
          mensagem: body,
          idioma: normalizarIdioma(daOrg?.locale ?? null),
        })
      : Promise.resolve(null);
    // O clima desta mensagem chamou uma pessoa: o pedido de pessoa que o Jev
    // perceber nela não vira aviso — a conversa já está indo para a equipe.
    let climaChamouUmaPessoa = false;
    try {
      // O Jev ligado só para os pedidos, sem IA de linguagem: o clima não tem
      // quem o meça.
      if (!resolvido && climaDoJev === "desligada") {
        return { skipped: true, reason: "ai_gateway_key_missing" };
      }

      // ── O Jev primeiro, quando ligado ──────────────────────────────────────
      //
      // Três desfechos, e o estado do clima (`./tarefas`) só pesa no primeiro:
      //  - mediu: em "decide" (ou sem IA de linguagem para comparar) a nota dele
      //    vale e o LLM nem roda; em "observacao" o LLM roda e decide, e as duas
      //    notas ficam em `messages.metadata` para o cartão medir a concordância
      //    (se o LLM falhar, a nota do Jev vale);
      //  - falhou na rede: a IA de sempre mede como reserva, e a linha dela diz
      //    isso (`reserva_do_jev`). Linha de erro do Jev só quando NINGUÉM mediu —
      //    uma falha que a reserva cobriu não é erro para quem opera;
      //  - não tentou (sem chave, disjuntor aberto): nada sai para a rede, e sem
      //    chave o caminho é exatamente o de antes do Jev.
      // Falha que não passa sozinha (chave recusada, sem crédito, pergunta nossa
      // recusada) abre aviso na Central, com ou sem reserva — DEPOIS de se saber
      // se a reserva mediu, porque é isso que o aviso afirma. O aviso se fecha
      // sozinho quando o Jev volta a medir.
      const clima: ClimaMedido | null = climaDoJev !== "desligada"
        ? await medirClima({ organizationId: event.organization_id, mensagem: body })
        : null;

      if (clima?.ok) {
        await fecharAvisoDoJev(admin, event.organization_id);
      }
      /**
       * A linha da medição do Jev sai DEPOIS da decisão, com a origem do que
       * aconteceu: "jev" quando a nota dele decidiu, "jev_observacao" quando a IA
       * de sempre decidiu. Antes da decisão a linha não sabia — em observação com a
       * IA de sempre caída, é a nota do Jev que decide.
       */
      const registrarMedicaoDoJev = (decidiu: boolean): void => {
        if (!clima?.ok) return;
        logInvocation({
          ...comum,
          provider: "typesafe",
          model: `typesafe/${clima.modelo}`,
          origem_da_escolha: decidiu ? "jev" : "jev_observacao",
          prompt_tokens: clima.tokensDeEntrada,
          completion_tokens: clima.tokensDeSaida,
          latency_ms: clima.latenciaMs,
          // Fracionário, sem o `Math.ceil` de `computeCost`: a centavo por
          // chamada, o Jev custaria ~600x o preço real (D4). Versão sem preço na
          // tabela sai `null`, nunca o preço de outra.
          cost_cents: costCents(clima.modelo, {
            inputTokens: clima.tokensDeEntrada,
            outputTokens: clima.tokensDeSaida,
            cacheReadTokens: 0,
            cacheWriteTokens: 0,
          }),
          finish_reason: null,
        });
      };

      const jevFalhouNaRede = clima !== null && !clima.ok && clima.tentouRede ? clima : null;
      /** `reservaMediu` é o que o corpo do aviso afirma — por isso só se sabe no fim. */
      const avisarSeExigeAcao = async (reservaMediu: boolean): Promise<void> => {
        if (!jevFalhouNaRede?.exigeAcao) return;
        await avisarNaCentral(admin, {
          organizationId: event.organization_id,
          idioma: normalizarIdioma(daOrg?.locale ?? null),
          motivo: jevFalhouNaRede.motivo,
          temReserva: reservaMediu,
        });
      };
      const registrarFalhaDoJev = (): void => {
        if (jevFalhouNaRede === null) return;
        logInvocation({
          ...comum,
          provider: "typesafe",
          model: `typesafe/${MODELO_DO_JEV}`,
          origem_da_escolha: "jev",
          prompt_tokens: 0,
          completion_tokens: 0,
          latency_ms: jevFalhouNaRede.latenciaMs,
          cost_cents: 0,
          finish_reason: "error",
          error_code: codigoDoErroDoJev(jevFalhouNaRede.motivo),
          error_payload: { motivo: jevFalhouNaRede.motivo },
        });
      };

      let decisao: { score: number; engine: MotorDoClima; latenciaMs: number };
      if (clima?.ok && (climaDoJev === "decidindo" || resolvido === null)) {
        decisao = { score: clima.score01, engine: "jev", latenciaMs: clima.latenciaMs };
      } else if (resolvido === null) {
        registrarFalhaDoJev();
        if (jevFalhouNaRede) {
          await avisarSeExigeAcao(false);
          // Sem IA de linguagem, a falha que "passa sozinha" (fora do ar, lento,
          // ilegível) deixa o clima sem medição do mesmo jeito — e enquanto ela
          // não passa, ninguém sabe. Várias seguidas é queda, não tropeço: vira o
          // MESMO aviso (mesmo título, mesmo dedupe, fecha no próximo sucesso).
          if (
            !jevFalhouNaRede.exigeAcao &&
            falhasSeguidas({ organizationId: event.organization_id, tarefa: TAREFA_DO_CLIMA.id }) >=
              FALHAS_SEGUIDAS_PARA_AVISAR_SEM_RESERVA
          ) {
            await avisarNaCentral(admin, {
              organizationId: event.organization_id,
              idioma: normalizarIdioma(daOrg?.locale ?? null),
              motivo: jevFalhouNaRede.motivo,
              temReserva: false,
              quedaSustentada: true,
            });
          }
          return { skipped: true, reason: "jev_falhou_sem_reserva" };
        }
        // O Jev está ligado aqui (desligado e sem IA de linguagem, o worker já saiu
        // lá em cima), então o motivo é o dele: `ai_gateway_key_missing` mandava
        // quem lê o log caçar uma chave que não falta quando o disjuntor só segura.
        return {
          skipped: true,
          reason: clima?.ok === false ? `jev_${clima.motivo}` : "ai_gateway_key_missing",
        };
      } else {
        // O Jev ligado, com chave, e sem resposta: quem mede é a reserva.
        const reserva = clima !== null && !clima.ok && clima.motivo !== "sem_credencial";
        const origem = reserva ? ({ origem_da_escolha: "reserva_do_jev" } as const) : {};
        const inicio = Date.now();
        let medido: Awaited<ReturnType<typeof classificarComLlm>> | null = null;
        let erroDaIa: unknown = null;
        try {
          medido = await classificarComLlm(resolvido.model, body);
        } catch (err) {
          erroDaIa = err;
          // A FALHA também vira linha em `llm_calls`. A 0128 fez isso para o seam do
          // agent-engine, e este worker não passa por lá — então, até aqui, escolher
          // no painel um modelo que não existe fazia toda classificação falhar sem
          // deixar rastro nenhum: a tela de Execuções, cuja razão de existir é
          // responder "por que falhou", não mostrava nada para este ponto, com o
          // painel dizendo que estava configurado.
          //
          // A linha de ERRO não leva `reserva_do_jev`: essa origem diz "a IA de
          // sempre mediu no lugar dele", e aqui ela não mediu. Quando o Jev já
          // tinha medido (observação), leva `jev_cobriu` — a tela não mostra a
          // consequência de um clima que foi medido.
          logInvocation({
            ...comum,
            ...(clima?.ok ? ({ origem_da_escolha: "jev_cobriu" } as const) : {}),
            // O provedor EFETIVO do binding — sem ele, o palpite por barra
            // carimbaria `openrouter` num id de modelo de provedor personalizado
            // (issue #3098).
            provider: resolvido.provider,
            model: resolvido.modelId,
            prompt_tokens: 0,
            completion_tokens: 0,
            latency_ms: Date.now() - inicio,
            cost_cents: 0,
            finish_reason: "error",
            error_payload: { message: err instanceof Error ? err.message : String(err) },
          });
        }
        if (medido !== null) {
          const latenciaMs = Date.now() - inicio;
          logInvocation({
            ...comum,
            ...origem,
            // O provedor EFETIVO do binding (issue #3098): o id do modelo pode
            // trazer barra sem ser da OpenRouter — o provedor personalizado da
            // empresa serve `qwen/qwen3.8-27b`, e o palpite diria `openrouter`.
            provider: resolvido.provider,
            model: resolvido.modelId,
            prompt_tokens: medido.promptTokens,
            completion_tokens: medido.completionTokens,
            latency_ms: latenciaMs,
            cost_cents: await computeCost({
              model: resolvido.modelId,
              promptTokens: medido.promptTokens,
              completionTokens: medido.completionTokens,
            }),
            finish_reason: null,
          });
          decisao = { score: medido.score, engine: "llm", latenciaMs };
          await avisarSeExigeAcao(true);
        } else if (clima?.ok) {
          // Observação com a IA de sempre caída: o Jev já mediu, e a nota dele é a
          // única que existe. Descartá-la deixaria o cliente irritado passar sem
          // ninguém ser chamado, com a medição na mão.
          decisao = { score: clima.score01, engine: "jev", latenciaMs: clima.latenciaMs };
        } else {
          // Ninguém mediu. O `throw` mantém o desfecho de antes — quem decide o
          // retorno continua sendo o catch global, que nunca derruba o bot.
          registrarFalhaDoJev();
          await avisarSeExigeAcao(false);
          throw erroDaIa;
        }
      }

      registrarMedicaoDoJev(decisao.engine === "jev");

      // ── Merge sentiment into messages.metadata ────────────────────────────
      const existingMetadata = (message.metadata as Record<string, unknown> | null) ?? {};
      const updatedMetadata = {
        ...existingMetadata,
        [CHAVES_DO_CLIMA.nota]: decisao.score,
        sentiment_latency_ms: decisao.latenciaMs,
        [CHAVES_DO_CLIMA.motor]: decisao.engine,
        // O limiar COM O QUAL esta nota foi cortada (#2219): é o do agente da
        // conversa, e não o default. A concordância do cartão do Jev lê daqui —
        // sem gravá-lo, um agente em 0,1 tinha a conta dele medida contra 0,3.
        // Vai junto com a nota (não só no alerta) porque as mensagens ACIMA do
        // limiar também entram na concordância, e são justamente as que o
        // alerta não emite.
        [CHAVES_DO_CLIMA.limiar]: threshold,
        ...(clima?.ok
          ? { [CHAVES_DO_CLIMA.notaDoJev]: clima.score01, [CHAVES_DO_CLIMA.modeloDoJev]: clima.modelo }
          : {}),
      };

      const { error: updateErr } = await admin
        .from("messages")
        .update({ metadata: updatedMetadata })
        .eq("id", messageId)
        .eq("organization_id", event.organization_id)
        // Mesma guarda LGPD do media-derive-worker (#1991): `updatedMetadata`
        // parte da foto lida antes da classificação, e a anonimização que rodar
        // nesse meio tempo zera `metadata` — regravá-la devolveria o dado
        // apagado, mais a nota derivada, a uma mensagem já redigida.
        .filter("body", "isdistinct", MENSAGEM_REDIGIDA);

      if (updateErr) {
        console.warn("[ai-sentiment-worker] metadata update failed", {
          message_id: messageId,
          error: updateErr.message,
        });
      }

      // ── Emit alert if below threshold ────────────────────────────────────
      // #3534: o contato da equipe é medido e registrado, mas o alerta não sai
      // — é ele que dispara o handoff e o silêncio. O limiar não muda; o que
      // muda é que, para este contato, ninguém é "acionado".
      if (decisao.score < threshold && !contatoDaEquipe) {
        const { error: emitErr } = await admin.rpc(
          "emit_event" as never,
          {
            p_event_type: "ai.sentiment_alert",
            p_entity_kind: "message",
            p_entity_id: messageId,
            p_payload: {
              message_id: messageId,
              conversation_id: conversationId ?? message.conversation_id ?? null,
              [CHAVES_DO_CLIMA.nota]: decisao.score,
              // Qual motor mediu: a passagem para humano marca "(percebido pelo
              // Jev)" na linha do tempo da equipe (D11).
              [CHAVES_DO_CLIMA.motor]: decisao.engine,
            },
            // `agent_id` e `motivo` viajam com o alerta porque o limiar é o número
            // que decidiu emiti-lo: sem eles, "por que este alerta saiu?" recomeça
            // do zero, e foi essa ausência que deixou o defeito da #486 invisível
            // pela tela — os dois campos existiam e um não fazia nada.
            p_metadata: {
              source: "ai-sentiment-worker",
              threshold,
              agent_id: agent?.id ?? null,
              agente_resolvido_por: motivoDoAgente,
            },
            p_organization_id: event.organization_id,
          } as never,
        );

        if (emitErr) {
          console.warn("[ai-sentiment-worker] ai.sentiment_alert emit failed", {
            message_id: messageId,
            error: emitErr.message,
          });
        } else {
          climaChamouUmaPessoa = true;
        }
      }

      return { skipped: false, sentiment_score: decisao.score };
    } finally {
      const observados = await pedidos;
      if (observados) {
        await avisarAEquipe(admin, observados, { chamouUmaPessoa: climaChamouUmaPessoa }, () =>
          aConversaAgora(
            admin,
            event.organization_id,
            observados.entrada.conversationId,
            (message.created_at as string | null | undefined) ?? null,
          ),
        );
      }
    }
  } catch (err) {
    // Global catch: NEVER throw — must not break the bot path.
    console.warn("[ai-sentiment-worker] sentiment_classify_failed", {
      event_id: event.id,
      error: err instanceof Error ? err.message : String(err),
    });
    return { skipped: true, reason: "classify_failed" };
  }
}

async function classificarComLlm(
  model: LanguageModel,
  body: string,
): Promise<{ score: number; promptTokens: number; completionTokens: number }> {
  const abortController = new AbortController();
  const timeout = setTimeout(() => abortController.abort(), CLASSIFY_TIMEOUT_MS);
  try {
    const generated = await generateObject({
      model,
      schema: sentimentSchema,
      system: SENTIMENT_SYSTEM_PROMPT,
      prompt: body,
      temperature: 0,
      // 80 era pequeno demais e nunca tinha sido exercitado (o worker morria
      // antes, na autenticação). `generateObject` com Anthropic usa modo
      // FERRAMENTA: o JSON vai dentro de um tool_use, que custa bem mais que
      // texto puro. Medido com mensagens reais desta instalação: 2 de 3
      // paravam em `stop_reason: max_tokens` com o JSON cortado no meio —
      // daí o "No object generated: response did not match schema", que
      // parecia erro de esquema e era truncamento. Pico observado: 146 sem
      // as descrições, 84 com elas. 256 dá folga sem virar cheque em branco.
      maxOutputTokens: 256,
      abortSignal: abortController.signal,
    });
    const usage = generated.usage as
      | { inputTokens?: number; outputTokens?: number; promptTokens?: number; completionTokens?: number }
      | undefined;
    return {
      score: generated.object.sentiment_score,
      promptTokens: usage?.inputTokens ?? usage?.promptTokens ?? 0,
      completionTokens: usage?.outputTokens ?? usage?.completionTokens ?? 0,
    };
  } finally {
    clearTimeout(timeout);
  }
}

/**
 * Quantas falhas seguidas, sem IA de linguagem, fazem de um tropeço uma queda.
 * O disjuntor abre na 3ª e deixa passar uma tentativa a cada 5 minutos, então a
 * 5ª chega depois de uns 10 minutos de clima sem medição — tempo de sobra para
 * não avisar por um soluço, e pouco para o dono não saber.
 */
const FALHAS_SEGUIDAS_PARA_AVISAR_SEM_RESERVA = 5;
