import type { SupabaseClient } from "@supabase/supabase-js";
import { z } from "zod";
import type { InboxKind } from "@/lib/agent-engine/db/repository";
import { ROLE_RANK, type Role } from "@/lib/auth/types";
import { logger } from "@/lib/logger";

/** DTO projetado no servidor. O browser nunca deriva URLs de referências livres. */
export type DestinoDoAviso =
  | { estado: "disponivel"; rotulo: string; href: string; orientacao?: string }
  | { estado: "sem_permissao" | "indisponivel" | "sem_destino"; orientacao: string };

interface ReferenciaDoAviso { kind: string; ref_kind: string | null; ref_id: string | null }
interface Alvo { tabela: string; papel: Role; rotulo: string; href: (id: string, pipelineId?: string) => string; ativo?: boolean }

/** Apenas entidades que têm produtor e superfície atual; agenda nasce com a Task 6. */
export const REFERENCIAS_DE_AVISO = {
  ai_agent: { tabela: "ai_agents", papel: "admin", rotulo: "Revisar agente", href: (id: string) => `/app/ai/agents/${id}`, ativo: true },
  agenda_reminder_sender: { tabela: "calendar_appointments", papel: "manager", rotulo: "Configurar canal do lembrete", href: () => "/app/settings/tenant/agenda" },
  appointment: {tabela:"calendar_appointments",papel:"agent",rotulo:"Abrir compromisso",href:(id:string)=>`/app/agenda?compromisso=${id}`},
  conversation: { tabela: "conversations", papel: "agent", rotulo: "Abrir conversa", href: (id: string) => `/app/inbox/${id}` },
  contact: { tabela: "contacts", papel: "agent", rotulo: "Ver contato", href: (id: string) => `/app/contacts/${id}` },
  lead: { tabela: "crm_leads", papel: "agent", rotulo: "Abrir negócio", href: (id: string, pipelineId?: string) => `/app/pipelines/${pipelineId}?lead=${id}` },
  followup_enrollment: { tabela: "followup_enrollments", papel: "viewer", rotulo: "Abrir acompanhamento", href: (id: string) => `/app/ai/followups/enrollments/${id}` },
  channel_session: { tabela: "channel_sessions", papel: "admin", rotulo: "Revisar conexão", href: () => "/app/connections", ativo: true },
  ai_knowledge_source: { tabela: "ai_knowledge_sources", papel: "manager", rotulo: "Abrir base de conhecimento", href: () => "/app/ai/knowledge/sources" },
  agent_case: { tabela: "agent_cases", papel: "agent", rotulo: "Abrir atendimento", href: (id: string) => `/app/ai/cases?caso=${id}` },
  proposal: { tabela: "crm_proposals", papel: "agent", rotulo: "Abrir proposta", href: (id: string) => `/app/proposals/${id}` },
  // O PONTEIRO do fluxo, não a inscrição: o aviso de `followup_sem_agente` é
  // sobre um fluxo que não tem inscrição nenhuma — é exatamente essa a queixa.
  // `manager` é a mesma régua da aba Fluxos (`canWrite` em FlowsList).
  followup_flow: { tabela: "followup_flow_pointers", papel: "manager", rotulo: "Abrir o fluxo", href: (id: string) => `/app/ai/followups/${id}` },
  // A credencial do provedor que ficou sem saldo (`lib/agent-engine/queue/espera-de-saldo.ts`).
  // O remédio é recarregar na conta do provedor, fora do CRM; a tela de
  // credenciais é onde se confere QUAL chave é, e onde se troca por outra.
  ai_provider_credential: { tabela: "ai_provider_credentials", papel: "admin", rotulo: "Revisar credencial", href: () => "/app/ai/credentials" },
  // (migration 0655, issue #2529) A tarefa que vence ou que já venceu: o aviso
  // de prazo leva ao TRABALHO que pede gente, e a lista de tarefas é onde ele
  // está (o mesmo destino do push de tarefa criada, em
  // `lib/tarefas/criar-tarefa.ts`). Sem referência o botão cairia na Central,
  // meio caminho — quem recebe o aviso já está nela.
  task: { tabela: "crm_tasks", papel: "agent", rotulo: "Abrir tarefa", href: () => "/app/tasks" },
} satisfies Record<string, Alvo>;

export type InboxRefKind = keyof typeof REFERENCIAS_DE_AVISO | "organization" | "ai_budget" | "plano" | "job_queue" | "cron_jobs";
type ContextoGeral = { papel: Role; href: string; rotulo: string };
interface Politica { refs: readonly InboxRefKind[]; orientacao: string; geral?: ContextoGeral }
const EVOLUCAO: ContextoGeral = { papel: "manager", href: "/app/ai/evolution", rotulo: "Abrir evolução do assistente" };
const CONEXOES: ContextoGeral = { papel: "admin", href: "/app/connections", rotulo: "Revisar conexões" };

/** Completude em compile time; pares desconhecidos em clones falham fechados. */
export const POLITICAS_DE_AVISO = {
  // O caso parado.  traz só  porque o aviso SEMPRE nasce com
  // o id do caso — nunca é genérico.
  case_stale: { refs: ["agent_case"], orientacao: "Abra o atendimento e diga o que fazer: concluir, pedir informação ao cliente ou passar para uma pessoa." },
  // Aponta para o FLUXO, e não para a tela de agentes onde mora o conserto, por
  // uma razão só: a organização pode ter vários agentes, e nenhum deles é "o"
  // agente deste fluxo — é justamente isso que falta. O botão leva a quem o
  // aviso é sobre; o passo que conserta está escrito no corpo, com as três
  // telas na ordem.
  followup_sem_agente: { refs: ["followup_flow"], orientacao: "Abra o agente que atende esse número, ligue este fluxo em «follow-ups que arma» e publique a versão." },
  appointment_outcome_required:{refs:["appointment"],orientacao:"Abra o compromisso e confirme a presença."},
  appointment_recovery_review:{refs:["appointment"],orientacao:"Confira o motivo e escolha o próximo passo no compromisso."},
  routing_unassigned: { refs: ["conversation"], orientacao: "Confira os responsáveis em Configurações → Atendimento." },
  routing_ia_wait_expired: { refs: ["conversation"], orientacao: "Abra a conversa para ver até onde a IA foi. Se isso se repetir, confira o agente de IA deste número." },
  qr_rescan: { refs: ["channel_session"], orientacao: "Peça a quem administra para revisar a conexão do WhatsApp." },
  job_dead: { refs: ["conversation", "job_queue", "cron_jobs"], orientacao: "Confira o motivo deste aviso com quem administra antes de tentar a operação novamente." },
  event_dead: { refs: [], orientacao: "Peça a quem administra para conferir o processamento descrito neste aviso." },
  // `plano` é o teto de IA do plano de cobrança (spec cobrança §5): a IA parou
  // porque o uso incluído acabou, e quem resolve é o admin, na tela do plano.
  budget_exceeded: { refs: ["ai_budget", "plano"], orientacao: "Peça ao gestor para revisar o limite e o uso de IA." },
  budget_warning: { refs: ["ai_budget"], orientacao: "Peça ao gestor para revisar o limite e o uso de IA." },
  // `conversation` primeiro porque é o que o produtor grava hoje (o corpo do
  // aviso ficou CURTO e o contexto foi para dentro da conversa, onde a RLS o
  // protege). `contact` continua na lista por causa dos itens de clone antigo,
  // gravados antes da troca — tirá-lo faria aqueles avisos perderem o destino.
  handoff: { refs: ["conversation", "contact"], orientacao: "Abra a conversa: o cartão no fim do fio diz por que a IA passou, o que ela já tentou e se o cliente foi avisado." },
  promotion_review: { refs: [], orientacao: "Na evolução do assistente, confira as propostas disponíveis. Este aviso não identifica uma proposta específica.", geral: EVOLUCAO },
  judge_unaligned: { refs: [], orientacao: "Na evolução do assistente, confira a avaliação de qualidade. Este aviso não identifica uma avaliação específica.", geral: EVOLUCAO },
  followup_dead: { refs: ["followup_enrollment"], orientacao: "Peça ao gestor para revisar o acompanhamento que parou." },
  snooze_expired: { refs: ["conversation"], orientacao: "Confira se cabe retomar o atendimento descrito neste aviso." },
  next_action_ambiguous: { refs: ["contact"], orientacao: "Confira os negócios do contato e escolha a qual deles pertence a próxima ação." },
  risk_backlog_seeded: { refs: ["organization"], orientacao: "Revise os negócios parados no Radar e defina o próximo passo." },
  reactivation_expired: { refs: ["organization"], orientacao: "Revise no Radar se ainda cabe retomar os negócios indicados." },
  capabilities_missing: { refs: ["conversation"], orientacao: "Peça ao gestor para revisar as ferramentas habilitadas para o assistente deste atendimento." },
  message_send_stuck: { refs: ["conversation"], orientacao: "Confira a resposta que não chegou antes de decidir se precisa enviar novamente." },
  midia_nao_lida: { refs: [], orientacao: "Peça ao gestor para revisar o provedor e as credenciais de leitura de fotos e áudios.", geral: { papel: "manager", href: "/app/ai/providers", rotulo: "Revisar provedores de IA" } },
  channel_template_review: { refs: [], orientacao: "Confira os modelos na conexão WhatsApp via Parceiro. Este aviso não identifica um modelo específico.", geral: { papel: "admin", href: "/app/connections?aba=parceiro&sub=templates", rotulo: "Revisar modelos do canal" } },
  // (migration 9327, issue #3635) O mesmo evento no canal OFICIAL DIRETO. O
  // `ref_kind='channel_session'` do produtor é o que responde "QUAL conexão" —
  // a empresa pode ter dois números com modelos homônimos —, e o destino geral
  // é a sub-aba de modelos da aba oficial, não a do parceiro: o modelo reprovado
  // se conserta lá, e mandar para a outra aba é um aviso que leva ao lugar errado.
  channel_template_review_direct: { refs: ["channel_session"], orientacao: "Abra Conexões › Modelos do canal oficial: o modelo reprovado precisa de correção no texto antes de voltar para revisão.", geral: { papel: "admin", href: "/app/connections?aba=oficial&sub=templates", rotulo: "Revisar modelos do canal oficial" } },
  channel_number_alert: { refs: ["channel_session"], orientacao: "Peça a quem administra para revisar a situação do número nas conexões.", geral: CONEXOES },
  // Sem `geral`, ao contrário do vizinho acima: o `canal-mudo-watcher` SEMPRE
  // nasce apontando para a conexão que ficou muda (`ref_kind: channel_session`),
  // nunca genérico — quem emite sem referência é uma ponte de canal que este
  // aviso não tem. Um contexto geral aqui seria caminho que nunca executa.
  // A orientação evita "revisar a conexão": nada caiu, e o conserto é um clique
  // de autorização em Conexões — dizer "revisar" mandaria procurar um defeito.
  canal_mudo_sem_numero: { refs: ["channel_session"], orientacao: "Peça a quem administra para autorizar os números de teste em Conexões ou abrir o canal ao público." },
  // Sem `geral`, pelo mesmo motivo do vizinho acima: o item nasce SEMPRE
  // apontando para a conexão que foi pausada (`ref_kind: channel_session`).
  // Nada quebrou, então a orientação não manda "revisar" — manda retomar a
  // pausa, que é o único clique que devolve o canal ao ar. E o aviso fecha
  // sozinho quando isso acontecer.
  canal_pausado: { refs: ["channel_session"], orientacao: "Se este canal precisar voltar a operar, retome a pausa em Conexões — este aviso se resolve sozinho quando a pausa for desfeita." },
  promise_unfulfilled: { refs: ["conversation"], orientacao: "Confira o compromisso descrito e defina quem fica responsável." },
  contact_proposal_expired: { refs: ["organization"], orientacao: "A sugestão venceu. Se a informação ainda for relevante, confirme com o cliente antes de editar sua ficha." },
  conhecimento_nao_indexado: { refs: ["ai_knowledge_source"], orientacao: "Peça ao gestor para conferir o material e o motivo da falha na base de conhecimento." },
  // Aponta para o CONTATO, e não para a chamada: a ficha do contato é onde mora
  // o botão de ligar (`components/voice/DialButton.tsx`), então "abrir o
  // contexto" e "fazer o que o aviso pede" viram o mesmo clique. Uma tela de
  // detalhe da ligação mostraria o registro de algo que já acabou e deixaria a
  // ação — retornar — a mais dois passos de distância.
  //
  // Chamada de número que não casou com contato nenhum entra sem referência e
  // cai em "sem destino" com a orientação abaixo: o telefone está no corpo do
  // aviso, escrito pelo worker.
  voice_call_missed: { refs: ["contact"], orientacao: "Retorne a ligação quando puder — quem ligou não foi atendido." },
  proposal_expired_notice: {
    refs: ["proposal"],
    orientacao: "A validade passou sem decisão do cliente. Confirme se ainda vale a pena manter a oferta ou revise o preço.",
  },
  proposal_acceptance_rate_drop: {
    refs: ["organization"],
    orientacao: "A proporção de propostas aceitas caiu de forma sustentada — revise preço, prazo ou o texto padrão.",
  },
  proposal_promised_not_created: {
    refs: ["lead"],
    orientacao: "Uma promessa de proposta venceu sem que a proposta tenha sido criada. Abra o negócio e monte o rascunho.",
  },
  // Leva AO CASO (`REFERENCIAS_DE_AVISO.agent_case` já aponta para
  // `/app/ai/cases?caso=<id>`), e não a uma tela genérica de conexões: o que
  // está pendente é o ATENDIMENTO, e quem abre o aviso precisa cair nele. A
  // conferência da conexão é o segundo passo, e vai na orientação.
  aviso_de_caso_nao_entregue: { refs: ["agent_case"], orientacao: "O aviso deste atendimento não saiu no WhatsApp. Abra o atendimento — ele continua esperando — e confira a conexão de avisos em Configurações." },
  // O Jev percebeu o pedido NA conversa (`lib/ai/decisao/pedidos.ts`): o botão
  // leva a ela, onde está o que o cliente escreveu — a Central não o repete.
  jev_pedido_de_humano: { refs: ["conversation"], orientacao: "Abra a conversa e decida se alguém da equipe assume o atendimento." },
  jev_parar_de_receber: { refs: ["conversation"], orientacao: "Abra a conversa e confira se o cliente quer mesmo parar de receber mensagens." },
  // Mesmo par de `message_send_stuck`, para a proposta — o cron devolve a
  // rascunho sozinho, sem reenviar; quem lê decide se envia de novo.
  proposta_travada: { refs: ["proposal"], orientacao: "Confira a proposta antes de decidir se precisa enviar novamente." },
  proposta_pronta_para_revisao: {
    refs: ["proposal"],
    orientacao: "A IA rascunhou esta proposta — confirme o modelo sugerido (ou escolha outro) e confira se todos os itens têm preço antes de enviar.",
  },
  // A revisão depois da reativação (`fn_reativar_organizacao`). Nasce SEM
  // referência: o aviso é sobre N conversas, não sobre uma, e com
  // `ref_kind='organization'` o resolvedor não chegaria ao Inbox. O Inbox não
  // tem parâmetro de aba na URL (`app/app/inbox/page.tsx` só lê `id` e
  // `rascunho`), por isso o botão leva ao Inbox e a orientação nomeia as abas.
  // As DUAS: numa empresa com IA, a conversa sem dono e sem silêncio é
  // classificada como `automatico` (comando-da-conversa.ts) e só cai na Fila
  // quando a empresa não tem atendimento automático.
  // 0583: o aviso também nasce SEM conversa (agendamento de disparo único
  // desligado, passo de follow-up descartado). A fila deles é a de
  // IA › Follow-ups (QueueTab lê cron_jobs); a orientação nomeia os dois lugares.
  org_reativada: {
    refs: [],
    orientacao: "A IA não respondeu nem vai responder sozinha às conversas que chegaram durante a suspensão: abra o Inbox e procure-as nas abas Fila e Automático. Os agendamentos e os passos de follow-up que este aviso cita também não voltam sozinhos: confira em IA › Follow-ups.",
    geral: { papel: "agent", href: "/app/inbox", rotulo: "Abrir o Inbox" },
  },
  // A cobrança do revendedor (spec cobrança §2.5). Os avisos da régua nascem
  // SEM referência e vão pelo `geral`; o de 80% do teto de IA nasce com
  // `ref_kind='plano'` e usa o ramo de `plano` do resolvedor. Os dois levam ao
  // mesmo lugar — Plano e cobrança, onde está o "Pagar agora" — e só quem
  // administra a empresa chega lá (a entrada do menu é `minRole: "admin"`).
  cobranca: {
    refs: ["plano"],
    orientacao:
      "Abra Plano e cobrança: lá estão o link para pagar, a troca do cartão e a troca de plano. Só quem administra a empresa vê essa tela.",
    geral: { papel: "admin", href: "/app/settings/billing", rotulo: "Abrir plano e cobrança" },
  },
  // A troca do e-mail de login pelo admin da plataforma. Sem referência: o
  // aviso não leva o id da pessoa (a Central é lida por toda a empresa), e
  // quem precisa conferir é quem administra a equipe.
  email_de_login_trocado: {
    refs: [],
    orientacao: "Confirme com a pessoa que ela reconhece a troca. Se não reconhece, fale com quem administra o servidor.",
    geral: { papel: "admin", href: "/app/team", rotulo: "Abrir a equipe" },
  },
  // Os dois avisos de prazo de tarefa (#2529) levam à tarefa, e a orientação
  // diz as DUAS saídas possíveis — concluir ou reagendar — porque as duas são
  // conclusões legítimas do aviso: ele não pede que a pessoa se desculpe pelo
  // atraso, pede que o prazo volte a ser verdade.
  task_due: {
    refs: ["task"],
    orientacao: "Abra a tarefa e conclua antes do prazo — ou reagende agora, se já dá para ver que não vai caber.",
  },
  task_overdue: {
    refs: ["task"],
    orientacao: "A tarefa venceu sem conclusão. Abra e conclua, ou reagende o prazo se ele não for mais possível.",
  },
  // Spec 32. O resumo nasce SEMPRE apontando para a conexão (`ref_kind: channel_session`),
  // como o `canal_pausado` e o `canal_mudo_sem_numero` — por isso sem `geral`. Papel
  // admin pela entrada `channel_session` de REFERENCIAS_DE_AVISO: é a conexão e o
  // histórico dela que o administrador confere, não a conversa de um cliente.
  recuperacao_da_queda: { refs: ["channel_session"], orientacao: "Confira em Conexões se o número voltou e se o histórico do período fora do ar entrou como esperado." },
  // Spec 32. Nasce SEMPRE com `ref_kind: conversation` (a conversa recuperada), como o
  // `message_send_stuck` e o `promise_unfulfilled`. Papel agent pela entrada
  // `conversation`: quem pode ver a conversa pode responder; não há "dono" a quem
  // mandar, porque a conversa recuperada pode não ter responsável.
  cliente_sem_resposta_na_queda: { refs: ["conversation"], orientacao: "Abra a conversa e responda o cliente — ele escreveu enquanto o número estava fora do ar." },
  // (migration 9027, issue #3371) O aviso nasce SEM referência: o que o conserta
  // não é uma linha do banco, é o login da conta — e o login mora na tela de
  // Credenciais. O botão diz o que fazer ("Reconectar"), não "revisar", porque
  // nada há para inspecionar: a conta precisa ser conectada de novo.
  assinatura_desconectada: { refs: [], orientacao: "A conta que paga a assinatura recusou o login. Reconecte em IA › Credenciais; este aviso se resolve sozinho quando o login voltar.", geral: { papel: "admin", href: "/app/ai/credentials", rotulo: "Reconectar a assinatura" } },
  // `conversation`: a apresentação do catálogo interrompida
  // (`lib/catalogo/retomada-de-apresentacao.ts`) nasce `other` apontando para a
  // conversa. Nenhum kind de conversa existente diz o que ela diz, e o
  // `message_send_stuck` cala o aviso de automação aberto na mesma janela. A porta
  // passa pela MESMA conferência dos outros kinds: RLS de `conversations` no
  // client da request, papel `agent` da entrada `conversation`.
  other: { refs: ["agenda_reminder_sender", "lead", "conversation", "channel_session", "appointment", "ai_agent", "ai_provider_credential", "agent_case"], orientacao: "Confira a situação descrita neste aviso com a pessoa responsável." },
} satisfies Record<InboxKind, Politica>;

/**
 * Quando o rótulo do BOTÃO depende do aviso, não do alvo.
 *
 * `REFERENCIAS_DE_AVISO.contact` diz "Ver contato" — certo para um aviso que
 * pede conferência, errado para um que pede AÇÃO. Numa chamada perdida o botão
 * tem de dizer o que a pessoa vai fazer ao clicar; "ver contato" transforma um
 * pedido em um convite a olhar.
 */
const ROTULO_POR_KIND: Record<string, string> = {
  message_send_stuck: "Abrir uma conversa afetada",
  proposta_travada: "Abrir proposta",
  voice_call_missed: "Ligar de volta",
  // "Abrir o fluxo" convida a olhar; o aviso pede CONFERIR qual fluxo está
  // parado antes de ir ligá-lo no agente.
  followup_sem_agente: "Ver o fluxo parado",
  // O aviso é sobre o que o cliente escreveu ali: a ação é ler e decidir.
  jev_pedido_de_humano: "Abrir a conversa",
  jev_parar_de_receber: "Abrir a conversa",
};

const SEM_DESTINO: DestinoDoAviso = { estado: "sem_destino", orientacao: "Este aviso não tem um contexto que possa ser aberto nesta versão." };
const INDISPONIVEL: DestinoDoAviso = { estado: "indisponivel", orientacao: "Este contexto não está disponível para você. Ele pode ter sido removido ou seu acesso pode ter mudado." };
const uuid = z.uuid();
function politica(item: ReferenciaDoAviso): Politica | undefined {
  return Object.hasOwn(POLITICAS_DE_AVISO, item.kind) ? POLITICAS_DE_AVISO[item.kind as InboxKind] : undefined;
}
function alvo(ref: string | null): Alvo | undefined {
  return ref && Object.hasOwn(REFERENCIAS_DE_AVISO, ref) ? REFERENCIAS_DE_AVISO[ref as keyof typeof REFERENCIAS_DE_AVISO] : undefined;
}
function permite(papel: Role, minimo: Role) { return ROLE_RANK[papel] >= ROLE_RANK[minimo]; }
function semPermissao(minimo: Role): DestinoDoAviso {
  return { estado: "sem_permissao", orientacao: minimo === "admin" ? "Peça a quem administra para revisar este contexto." : "Peça ao gestor para revisar este contexto." };
}

/**
 * Recebe EXCLUSIVAMENTE o client autenticado da request. A RLS é dona da regra
 * own/own_and_unassigned, inclusive em suporte; existência via admin não basta.
 * Uma consulta por tipo, não por aviso. Erro, ausência e referência externa não
 * revelam existência nem liberam URL. Não há escrita ou resolução automática.
 */
export async function resolverDestinosDosAvisos<T extends ReferenciaDoAviso>(
  leitor: SupabaseClient, organizationId: string, papel: Role, itens: T[],
): Promise<Array<T & { destination: DestinoDoAviso }>> {
  const grupos = new Map<string, Set<string>>();
  for (const item of itens) {
    const p = politica(item), a = alvo(item.ref_kind);
    if (!p || !a || !p.refs.includes(item.ref_kind as InboxRefKind) || !permite(papel, a.papel) || !uuid.safeParse(item.ref_id).success) continue;
    const ids = grupos.get(item.ref_kind!) ?? new Set<string>();
    ids.add(item.ref_id!);
    grupos.set(item.ref_kind!, ids);
  }
  const visiveis = new Map<string, Set<string>>();
  const funilPorLead = new Map<string, string>();
  await Promise.all([...grupos].map(async ([ref, ids]) => {
    const a = alvo(ref)!;
    try {
      let query = leitor.from(a.tabela).select(ref === "lead" ? "id, pipeline_id" : "id").eq("organization_id", organizationId).in("id", [...ids]);
      if (a.ativo) query = query.is("archived_at", null);
      const { data, error } = await query;
      if (error) throw new Error("consulta_indisponivel");
      if (ref === "lead") {
        const leads = (data ?? []) as unknown as Array<{ id: string; pipeline_id: string }>;
        const idsDeFunis = [...new Set(leads.map(l => l.pipeline_id).filter(id => uuid.safeParse(id).success))];
        if (idsDeFunis.length > 0) {
          // A FK simples não prova organização: legado inconsistente também
          // precisa falhar fechado. Um segundo lote valida a superfície real.
          const { data: funis, error: erroFunis } = await leitor.from("crm_pipelines").select("id").eq("organization_id", organizationId).in("id", idsDeFunis);
          if (erroFunis) throw new Error("consulta_indisponivel");
          const permitidos = new Set((funis ?? []).map((f: { id: string }) => f.id));
          for (const lead of leads) if (permitidos.has(lead.pipeline_id)) funilPorLead.set(lead.id, lead.pipeline_id);
        }
        visiveis.set(ref, new Set(funilPorLead.keys()));
      } else visiveis.set(ref, new Set(((data ?? []) as unknown as Array<{ id: string }>).map(linha => linha.id)));
    } catch {
      // Somente catálogo fechado e contagem. Nunca erro bruto, título, UUID ou payload.
      logger.warn("[inbox] contexto indisponível na projeção", { referencia: ref, quantidade: ids.size });
    }
  }));
  return itens.map((item) => {
    const p = politica(item);
    let destination: DestinoDoAviso = SEM_DESTINO;
    if (p) {
      if (item.ref_kind === null && item.ref_id === null) {
        destination = p.geral
          ? permite(papel, p.geral.papel) ? { estado: "disponivel", href: p.geral.href, rotulo: p.geral.rotulo, orientacao: p.orientacao } : semPermissao(p.geral.papel)
          : { estado: "sem_destino", orientacao: p.orientacao };
      } else if (!p.refs.includes(item.ref_kind as InboxRefKind) || !uuid.safeParse(item.ref_id).success) {
        destination = INDISPONIVEL;
      } else {
        const a = alvo(item.ref_kind);
        if (a) {
          destination = !permite(papel, a.papel) ? semPermissao(a.papel)
            : visiveis.get(item.ref_kind!)?.has(item.ref_id!)
              ? { estado: "disponivel", rotulo: ROTULO_POR_KIND[item.kind] ?? a.rotulo, href: a.href(item.ref_id!, funilPorLead.get(item.ref_id!)) }
              : INDISPONIVEL;
        } else if (item.ref_kind === "ai_budget" || item.ref_kind === "organization") {
          destination = item.ref_id !== organizationId ? INDISPONIVEL
            : item.kind === "contact_proposal_expired" ? { estado: "sem_destino", orientacao: p.orientacao }
            : !permite(papel, item.ref_kind === "ai_budget" ? "manager" : "agent") ? semPermissao("manager")
            : { estado: "disponivel", href: item.ref_kind === "ai_budget" ? "/app/ai/usage" : "/app/radar", rotulo: item.ref_kind === "ai_budget" ? "Abrir uso de IA" : "Abrir Radar" };
        } else if (item.ref_kind === "plano") {
          // Plano é assunto do ADMIN (a entrada de Configurações é `minRole: "admin"`).
          destination = item.ref_id !== organizationId ? INDISPONIVEL
            : !permite(papel, "admin") ? semPermissao("admin")
            : { estado: "disponivel", href: "/app/settings/billing", rotulo: "Abrir plano e cobrança" };
        } else destination = { estado: "sem_destino", orientacao: p.orientacao };
      }
    }
    return { ...item, destination };
  });
}

/**
 * O papel mínimo para enxergar a Central de avisos: a lista, o contador do sino,
 * o resolver de um aviso e o "Marcar todos resolvidos" exigem este papel, e o
 * push dos avisos (`lib/notifications/push-dos-avisos.ts`) usa o mesmo.
 */
export const PAPEL_MINIMO_DA_CENTRAL: Role = "agent";

/**
 * Quem ENXERGA um aviso da Central (migration 0689, doc de decisão 162): sem
 * `para_user_id` é da empresa inteira; com ele, só daquela pessoa. É o filtro
 * `.or()` do PostgREST que a lista, o contador do sino, o "Marcar todos
 * resolvidos" e o resolver de um aviso aplicam — os quatro com o mesmo texto,
 * para que o que a pessoa vê seja exatamente o que ela resolve. `userId` vem do
 * JWT validado (`requireRole` → `getUser`), nunca do corpo.
 */
export function avisoVisivelPara(userId: string): string {
  return `para_user_id.is.null,para_user_id.eq.${userId}`;
}
