/**
 * GET /api/v1/pipelines/[id]/board
 *
 * Returns the full board snapshot for the Kanban: pipeline metadata + active
 * stages (ordered by position) + open leads (excluding archived). All RLS-
 * filtered to the caller's org via cookie session.
 *
 * Why this exists: previously useBoard hit supabase-js directly from the
 * browser. The auth cookie is httpOnly, which the browser Supabase client
 * cannot read — auth.uid() came back null and RLS dropped the pipeline row,
 * surfacing as PostgREST "Cannot coerce result to a single JSON object"
 * (PGRST116). Routing through the API ensures the server-side cookie reader
 * runs, same as every other authed query.
 */
import { randomUUID } from "node:crypto";
import { type NextRequest } from "next/server";

import { fail, ok } from "@/lib/api/wrappers";
import { loadAuthUser } from "@/lib/auth/server";
import { traduzir } from "@/lib/i18n/dicionario";
import {
  roteiaProximasAcoes,
  type EstadoDoContato,
  type PropostaAmbigua,
} from "@/lib/leads/next-action";
import type { LeadCandidate } from "@/lib/leads/active-lead";
import { anexarDadosDoContato, type LinhaDoContatoNoQuadro } from "@/lib/kanban/dados-do-contato";
import {
  anexarAnexosAosNegocios,
  type LinhaDoAnexoNoQuadro,
} from "@/lib/kanban/anexos-no-card";
import { camposDeArquivoDoNegocio } from "@/lib/leads/campos-do-funil";
import { diasDeRecorte, recortaEncerradosAntigos } from "@/lib/kanban/recorte-de-encerrados";
import { anexarTarefasAosNegocios, type LinhaDaTarefaNoQuadro } from "@/lib/kanban/tarefas-no-quadro";
import { buscaEmLotes } from "@/lib/supabase/em-lotes";
import { lerPaginado } from "@/lib/supabase/pagina";
import { createClient } from "@/lib/supabase/server";
import { idsDeContatosPessoais } from "@/app/api/v1/conversations/_handler";
import type { BoardData, Pipeline, Stage } from "@/lib/kanban/types";
import type { Lead } from "@/lib/types/leads";
import { ocultarProvaMetaNaResposta } from "@/lib/leads/prova-meta-lead-submitted";

export const dynamic = "force-dynamic";

interface RouteCtx {
  params: Promise<{ id: string }>;
}

/**
 * Anexa a identidade do agente dono (nome + versão publicada) aos leads que têm
 * `owner_kind='ai'`.
 *
 * **Sem filtro de `is_active`/`archived_at` de propósito.** Quem é o dono é
 * pergunta de EXIBIÇÃO e vale para qualquer agente: desativar um bot não pode
 * transformar os negócios dele em cards anônimos. A lista de agentes que PODEM
 * receber um lead (o picker, `/api/v1/ai/agents/assignable`) é outra pergunta e
 * lá os filtros estão certos.
 *
 * `organization_id` é filtrado explicitamente — vem do pipeline já validado pela
 * RLS do caller, nunca do body.
 */
async function withOwnerAgents(
  supabase: Awaited<ReturnType<typeof createClient>>,
  organizationId: string,
  leads: Lead[],
): Promise<{ leads: Lead[]; error: string | null }> {
  const agentIds = [
    ...new Set(
      leads
        .filter((l) => l.owner_kind === "ai" && l.owner_agent_id)
        .map((l) => l.owner_agent_id as string),
    ),
  ];
  if (agentIds.length === 0) return { leads, error: null };

  const { data: agents, error: agentsErr } = await supabase
    .from("ai_agents")
    .select("id, name, published_version_id")
    .eq("organization_id", organizationId)
    .in("id", agentIds);
  if (agentsErr) return { leads, error: agentsErr.message };

  const agentRows = (agents ?? []) as Array<{
    id: string;
    name: string;
    published_version_id: string | null;
  }>;

  const publishedIds = agentRows
    .map((a) => a.published_version_id)
    .filter((v): v is string => !!v);
  const versionById = new Map<string, number>();
  if (publishedIds.length > 0) {
    const { data: versions, error: versionsErr } = await supabase
      .from("ai_agent_versions")
      .select("id, version_number")
      .eq("organization_id", organizationId)
      .in("id", publishedIds);
    if (versionsErr) return { leads, error: versionsErr.message };
    for (const v of (versions ?? []) as Array<{ id: string; version_number: number }>) {
      versionById.set(v.id, v.version_number);
    }
  }

  const byId = new Map(agentRows.map((a) => [a.id, a]));
  return {
    leads: leads.map((lead) => {
      if (lead.owner_kind !== "ai" || !lead.owner_agent_id) return lead;
      const agent = byId.get(lead.owner_agent_id);
      if (!agent) return lead;
      return {
        ...lead,
        owner_agent: {
          id: agent.id,
          name: agent.name,
          version_number: agent.published_version_id
            ? (versionById.get(agent.published_version_id) ?? null)
            : null,
        },
      };
    }),
    error: null,
  };
}

/**
 * Anexa a próxima ação proposta pelo agente aos leads que a receberam.
 *
 * Os candidatos são buscados por CONTATO na org inteira, e não só neste
 * pipeline: `resolveActiveLeadForContact` precisa enxergar todos os negócios
 * abertos da pessoa para poder chamar de ambíguo o que é ambíguo. Recortando a
 * lista por pipeline, dois negócios ambíguos em boards diferentes apareceriam
 * como um único negócio em cada board, e os dois exibiriam a mesma proposta.
 */
/**
 * Abre um item de caixa por proposta sem dono — no máximo um por contato.
 *
 * Deduplicado por (kind, ref_id, status='open') porque o board é lido a cada
 * refresh: sem isto, um contato ambíguo produziria um item por render até a
 * caixa virar ruído e ninguém mais olhar.
 *
 * Falha aqui NÃO derruba o board: o aviso é importante, mas menos que a tela
 * abrir. O erro sobe para o Sentry pelo caminho normal de exceção não tratada
 * do handler — o que não pode é o usuário perder o board por causa do aviso.
 */
async function avisaAmbiguas(
  supabase: Awaited<ReturnType<typeof createClient>>,
  organizationId: string,
  ambiguas: PropostaAmbigua[],
): Promise<void> {
  if (ambiguas.length === 0) return;

  const { data: jaAbertos } = await buscaEmLotes(
    ambiguas.map((a) => a.contact_id),
    (lote) =>
      supabase
        .from("agent_inbox_items")
        .select("ref_id")
        .eq("organization_id", organizationId)
        .eq("kind", "next_action_ambiguous")
        .eq("status", "open")
        .in("ref_id", lote),
  );
  const abertos = new Set(
    ((jaAbertos ?? []) as Array<{ ref_id: string }>).map((r) => r.ref_id),
  );

  const novos = ambiguas
    .filter((a) => !abertos.has(a.contact_id))
    .map((a) => ({
      organization_id: organizationId,
      kind: "next_action_ambiguous",
      severity: "warn",
      title: `A IA propôs uma próxima ação, mas o contato tem ${a.candidateIds.length} negócios abertos`,
      body: `Proposta: "${a.texto}". Escolha a qual negócio ela pertence — o sistema não adivinha para não executar no negócio errado.`,
      ref_kind: "contact",
      ref_id: a.contact_id,
      status: "open",
    }));
  if (novos.length === 0) return;

  await supabase.from("agent_inbox_items").insert(novos);
}

/**
 * Anexa o score aos leads que o têm — LEFT JOIN, nunca INNER.
 *
 * Score ausente é estado legítimo (sinal insuficiente, cenário 17). Um INNER
 * apagaria do quadro justamente os leads sem sinal, que são os que mais
 * precisam de atenção humana — o oposto do que o produto existe para fazer.
 *
 * A faixa vem PERSISTIDA e é entregue como está: recalculá-la aqui (ou na UI)
 * ignoraria a histerese e devolveria o card piscando na fronteira, no único
 * lugar onde o CHECK de coerência não alcança.
 */
async function withScores(
  supabase: Awaited<ReturnType<typeof createClient>>,
  organizationId: string,
  leads: Lead[],
): Promise<{ leads: Lead[]; error: string | null }> {
  if (leads.length === 0) return { leads, error: null };

  const { data, error } = await buscaEmLotes(
    leads.map((l) => l.id),
    (lote) =>
      supabase
        .from("crm_lead_scores")
        .select(
          "lead_id, ai_probability, ai_probability_reason, ai_probability_band, ai_probability_evidence, ai_probability_at",
        )
        .eq("organization_id", organizationId)
        .in("lead_id", lote),
  );
  if (error) return { leads, error: error.message };

  const porLead = new Map<string, NonNullable<Lead["score"]>>();
  for (const row of (data ?? []) as Array<{
    lead_id: string;
    ai_probability: number | string | null;
    ai_probability_reason: string | null;
    ai_probability_band: string | null;
    ai_probability_evidence: { factors?: unknown } | null;
    ai_probability_at: string | null;
  }>) {
    // `numeric` chega como string no supabase-js; `null` continua null — e a
    // diferença entre null e 0 é justamente o que não pode se perder aqui.
    if (row.ai_probability === null || row.ai_probability_band === null) continue;
    const factors = Array.isArray(row.ai_probability_evidence?.factors)
      ? (row.ai_probability_evidence.factors as NonNullable<Lead["score"]>["factors"])
      : [];
    porLead.set(row.lead_id, {
      probability: Number(row.ai_probability),
      reason: row.ai_probability_reason ?? "",
      band: row.ai_probability_band as NonNullable<Lead["score"]>["band"],
      factors,
      at: row.ai_probability_at,
    });
  }

  return {
    leads: leads.map((lead) => {
      const score = porLead.get(lead.id);
      return score ? { ...lead, score } : lead;
    }),
    error: null,
  };
}

/**
 * Anexa a conversa mais recente do contato — o atalho do quadro para o inbox.
 *
 * LEFT, como o score: lead sem contato (criado à mão, vindo de webhook) e
 * contato sem conversa são estados normais, e sumir com esses cards do quadro
 * seria esconder justamente os que ninguém atendeu ainda.
 *
 * A MAIS RECENTE por contato, não todas: o card mostra uma linha, e escolher na
 * UI exigiria trazer o histórico inteiro de cada lead para descartar quase tudo.
 *
 * Ordena por `last_message_at` e fica com a primeira de cada contato — as
 * conversas já vêm ordenadas, então o primeiro visto é o mais recente.
 *
 * A MESMA consulta traz os marcadores de TODAS as conversas do contato
 * (`conversation_tags`, a terceira caixa — decisão do dono, doc 40, 19/09).
 * Aqui e não numa função própria porque ela já lê cada conversa do contato:
 * uma coluna a mais custa bytes; outra consulta com a mesma lista de ids na URL
 * custaria outra ida ao banco por quadro aberto.
 */
async function withConversas(
  supabase: Awaited<ReturnType<typeof createClient>>,
  organizationId: string,
  leads: Lead[],
): Promise<{ leads: Lead[]; error: string | null }> {
  const contactIds = [...new Set(leads.map((l) => l.contact_id).filter((c): c is string => !!c))];
  if (contactIds.length === 0) return { leads, error: null };

  // Em lotes, e a ordem continua valendo para o que importa: as conversas de um
  // contato caem todas no mesmo lote, e é DENTRO do contato que "a primeira vista
  // vence" lê a ordem.
  const { data, error } = await buscaEmLotes(contactIds, (lote) =>
    supabase
      .from("conversations")
      .select("id, contact_id, last_message_preview, last_message_at, unread_count_for_assignee, tags")
      .eq("organization_id", organizationId)
      .in("contact_id", lote)
      .order("last_message_at", { ascending: false, nullsFirst: false }),
  );
  if (error) return { leads, error: error.message };

  const porContato = new Map<string, NonNullable<Lead["conversa"]>>();
  const marcadoresPorContato = new Map<string, Set<string>>();
  for (const row of (data ?? []) as Array<{
    id: string;
    contact_id: string;
    last_message_preview: string | null;
    last_message_at: string | null;
    unread_count_for_assignee: number | null;
    tags: string[] | null;
  }>) {
    // Os marcadores somam TODAS as conversas; a linha do card é só a mais recente.
    for (const tag of row.tags ?? []) {
      const doContato = marcadoresPorContato.get(row.contact_id) ?? new Set<string>();
      doContato.add(tag);
      marcadoresPorContato.set(row.contact_id, doContato);
    }
    // Primeira vista vence: a consulta já veio ordenada por atividade.
    if (porContato.has(row.contact_id)) continue;
    porContato.set(row.contact_id, {
      id: row.id,
      preview: row.last_message_preview,
      last_message_at: row.last_message_at,
      unread: row.unread_count_for_assignee ?? 0,
    });
  }

  return {
    leads: leads.map((lead) => {
      if (!lead.contact_id) return lead;
      const conversa = porContato.get(lead.contact_id);
      const marcadores = marcadoresPorContato.get(lead.contact_id);
      return {
        ...lead,
        // `null`, nunca ausente: contato sem conversa é "não há conversa", e é
        // assim que o card e o dossiê sabem mostrar "Abrir conversa" (#1993).
        // Ausente (`undefined`) fica para o lead SEM contato, que sai acima.
        conversa: conversa ?? null,
        // Vazio não vira campo, como `contact_tags`: o payload não engorda.
        ...(marcadores && marcadores.size > 0 ? { conversation_tags: [...marcadores] } : {}),
      };
    }),
    error: null,
  };
}

/**
 * Anexa os marcadores do CONTATO — a outra caixa de marcador do produto.
 *
 * O filtro de marcador do quadro lia só `crm_leads.tags`, escrita em "Editar
 * lead". Quem marca a PESSOA (no Inbox ou na ficha) escreve em `contacts.tags`,
 * e esse marcador não chegava ao quadro: não filtrava e nem aparecia na lista
 * de opções. É o mesmo desencontro que o Inbox tinha no filtro dele.
 *
 * LEFT, como o score e a conversa: negócio sem contato é estado normal, e o
 * card dele não pode sumir do quadro por não ter marcador de pessoa.
 *
 * Marcador vazio não vira campo: `contact_tags` só é escrito quando há alguma
 * etiqueta, para o payload do quadro não engordar com array vazio em todo card.
 *
 * A MESMA leitura de `contacts` também alimenta telefone, e-mail e links do card
 * (`anexarDadosDoContato`): uma consulta por quadro, não duas.
 */
async function withMarcadoresDoContato(
  supabase: Awaited<ReturnType<typeof createClient>>,
  organizationId: string,
  leadsDoQuadro: Lead[],
): Promise<{ leads: Lead[]; error: string | null }> {
  const contactIds = [
    ...new Set(leadsDoQuadro.map((l) => l.contact_id).filter((c): c is string => !!c)),
  ];
  if (contactIds.length === 0) return { leads: leadsDoQuadro, error: null };

  const { data, error } = await buscaEmLotes(contactIds, (lote) =>
    supabase
      .from("contacts")
      .select("id, tags, phone_number, email, custom_fields, is_anonymized")
      .eq("organization_id", organizationId)
      .in("id", lote),
  );
  if (error) return { leads: leadsDoQuadro, error: error.message };

  const linhas = (data ?? []) as Array<{ id: string; tags: string[] | null } & LinhaDoContatoNoQuadro>;
  const leads = anexarDadosDoContato(leadsDoQuadro, linhas);

  const porContato = new Map<string, string[]>();
  for (const row of linhas) {
    const tags = row.tags ?? [];
    if (tags.length > 0) porContato.set(row.id, tags);
  }
  if (porContato.size === 0) return { leads, error: null };

  return {
    leads: leads.map((lead) => {
      const contact_tags = lead.contact_id ? porContato.get(lead.contact_id) : undefined;
      return contact_tags ? { ...lead, contact_tags } : lead;
    }),
    error: null,
  };
}

async function withNextActions(
  supabase: Awaited<ReturnType<typeof createClient>>,
  organizationId: string,
  leads: Lead[],
  defaultPipelineId: string | null,
): Promise<{ leads: Lead[]; error: string | null }> {
  const contactIds = [
    ...new Set(leads.map((l) => l.contact_id).filter((c): c is string => !!c)),
  ];
  if (contactIds.length === 0) return { leads, error: null };

  const [{ data: estados, error: estadosErr }, { data: candidatos, error: candErr }] =
    await Promise.all([
      buscaEmLotes(contactIds, (lote) =>
        supabase
          .from("lead_state")
          .select("contact_id, next_action, next_action_seq, updated_at")
          .eq("organization_id", organizationId)
          .in("contact_id", lote)
          .not("next_action", "is", null),
      ),
      buscaEmLotes(contactIds, (lote) =>
        supabase
          .from("crm_leads")
          .select(
            "id, organization_id, pipeline_id, status, last_activity_at, created_at, contact_id",
          )
          .eq("organization_id", organizationId)
          .eq("status", "open")
          .in("contact_id", lote),
      ),
    ]);
  if (estadosErr) return { leads, error: estadosErr.message };
  if (candErr) return { leads, error: candErr.message };
  if (!estados || estados.length === 0) return { leads, error: null };

  const { porLead, ambiguas } = roteiaProximasAcoes(
    estados as EstadoDoContato[],
    (candidatos ?? []) as Array<LeadCandidate & { contact_id: string | null }>,
    { defaultPipelineId },
  );

  // Recusar o palpite não pode virar silêncio: a proposta que não achou dono vai
  // para a caixa, onde um humano desambigua. Escrever a partir de um GET não é
  // bonito, e é deliberado — a ambiguidade só EXISTE quando se olha o conjunto
  // de negócios abertos AGORA, e é aqui que esse olhar acontece. Fazer no
  // momento da escrita da proposta perderia o caso em que o segundo negócio
  // nasce depois dela.
  await avisaAmbiguas(supabase, organizationId, ambiguas);

  if (porLead.size === 0) return { leads, error: null };

  return {
    leads: leads.map((lead) => {
      const acao = porLead.get(lead.id);
      return acao ? { ...lead, next_action: acao } : lead;
    }),
    error: null,
  };
}

/**
 * Anexa o resumo das tarefas EM ABERTO de cada negócio — o selo do card.
 *
 * Uma leitura de `crm_tasks` para o quadro inteiro, em lotes de ids como as
 * outras: o card não pode custar uma consulta por negócio. Só as abertas vêm do
 * banco, porque é só o que o selo conta; a régua de "aberta" e "atrasada" é a
 * mesma da lista do dossiê (`lib/kanban/tarefas-no-quadro.ts`).
 *
 * Erro de leitura derruba o quadro com 500, como as outras etapas desta rota:
 * um quadro que some com o selo em silêncio diria "nenhuma tarefa pendente"
 * justamente no dia em que não sabe.
 */
async function withTarefas(
  supabase: Awaited<ReturnType<typeof createClient>>,
  organizationId: string,
  leads: Lead[],
): Promise<{ leads: Lead[]; error: string | null }> {
  const leadIds = leads.map((l) => l.id);
  if (leadIds.length === 0) return { leads, error: null };

  const { data, error } = await buscaEmLotes(leadIds, (lote) =>
    supabase
      .from("crm_tasks")
      .select("lead_id, due_date, status")
      .eq("organization_id", organizationId)
      .in("lead_id", lote)
      .in("status", ["pending", "in_progress"]),
  );
  if (error) return { leads, error: error.message };

  return {
    leads: anexarTarefasAosNegocios(leads, (data ?? []) as LinhaDaTarefaNoQuadro[]),
    error: null,
  };
}

/**
 * Anexa a cada negócio do quadro o que os campos `file` do funil têm (#2829).
 *
 * Uma leitura de `crm_lead_attachments` para o quadro inteiro, em lotes de ids
 * como as outras etapas: o card não pode custar uma consulta por negócio.
 *
 * Funil SEM campo `file` sai daqui sem consultar nada — o `file` é a única forma
 * de haver anexo de campo personalizado, e quem nunca criou um não paga nem a
 * ida ao banco nem um byte na resposta.
 *
 * Erro de leitura derruba o quadro com 500, como as outras etapas: um quadro que
 * escondesse o anexo em silêncio diria "nenhum documento" justamente no dia em
 * que não sabe — e é a trava de etapa seguinte (fatia 2 da #2829) que vai
 * depender desta contagem para deixar o negócio andar.
 */
async function withAnexos(
  supabase: Awaited<ReturnType<typeof createClient>>,
  organizationId: string,
  leads: Lead[],
  campos: ReturnType<typeof camposDeArquivoDoNegocio>,
): Promise<{ leads: Lead[]; error: string | null }> {
  if (leads.length === 0 || campos.length === 0) return { leads, error: null };

  const leadIds = leads.map((l) => l.id);
  const chaves = campos.map((campo) => campo.key);

  const { data, error } = await buscaEmLotes(leadIds, (lote) =>
    supabase
      .from("crm_lead_attachments")
      .select("lead_id, field_key, id, file_name, mime_type, file_size, created_at")
      .eq("organization_id", organizationId)
      .in("lead_id", lote)
      .in("field_key", chaves)
      .order("created_at", { ascending: true }),
  );
  if (error) return { leads, error: error.message };

  return {
    leads: anexarAnexosAosNegocios(leads, (data ?? []) as LinhaDoAnexoNoQuadro[], campos) as Lead[],
    error: null,
  };
}

/**
 * As colunas do negócio que o quadro REALMENTE entrega (#4321).
 *
 * O `select("*")` antigo puxava a linha inteira de `crm_leads` — colunas que
 * nenhum card lê viajavam em cada página de cada leitura. Esta lista é a do
 * contrato `Lead` (`lib/types/leads.ts`): o que sai daqui é o que a tela
 * consome, e o que não está aqui não sai. Coluna nova no contrato do quadro
 * entra AQUI junto com o tipo — o teste de forma (`route-paginacao.test.ts`)
 * cobra as duas coisas na mesma régua.
 *
 * `organization_id` e `position_in_stage`/`id` não são decoração: são as três
 * primeiras colunas de `idx_crm_leads_org_pipeline_position_id`
 * (`supabase/baseline.sql`), o índice que só é usado quando o filtro por
 * organização vai junto — e são elas que dão a ordem estável da paginação.
 */
const COLUNAS_DO_QUADRO = [
  "id",
  "organization_id",
  "pipeline_id",
  "stage_id",
  "contact_id",
  "title",
  "description",
  "status",
  "lost_reason",
  "position_in_stage",
  "value_cents",
  "recurring_value_cents",
  "recurring_period",
  "recurring_months",
  "currency",
  "owner_user_id",
  "owner_kind",
  "owner_agent_id",
  "assigned_at",
  "last_activity_at",
  "stage_changed_at",
  "expected_close_date",
  "closed_at",
  "source",
  "source_metadata",
  "external_id",
  "custom_fields",
  "tags",
  "created_at",
  "updated_at",
  "created_by_user_id",
].join(", ");

export async function GET(req: NextRequest, ctx: RouteCtx): Promise<Response> {
  const requestId = randomUUID();
  const { id: pipelineId } = await ctx.params;

  const supabase = await createClient();
  const {
    data: { user },
    error: authErr,
  } = await supabase.auth.getUser();
  if (authErr || !user) {
    return fail("unauthenticated", "Auth required.", 401, { requestId });
  }
  const authUser = await loadAuthUser();
  const t = (texto: string) => traduzir(texto, authUser?.idioma ?? "pt-BR");

  // O funil vem PRIMEIRO, sozinho: é desta linha — a única que a RLS do caller
  // deixou passar — que sai a organização do quadro (#4321). Ela é a org da
  // SESSÃO, nunca um id do corpo da requisição: um funil de outra organização
  // nem chega a existir para quem pediu.
  const { data: pipeline, error: pipelineErr } = await supabase
    .from("crm_pipelines")
    .select("*")
    .eq("id", pipelineId)
    .maybeSingle();
  if (pipelineErr) return fail("internal_error", pipelineErr.message, 500, { requestId });
  if (!pipeline) return fail("resource_not_found", t("Pipeline não encontrado."), 404, { requestId });

  const organizationId = (pipeline as Pipeline).organization_id;

  const [{ data: stages, error: stagesErr }, leituraDosLeads] = await Promise.all([
    supabase
      .from("crm_stages")
      .select("*")
      .eq("pipeline_id", pipelineId)
      .eq("is_archived", false)
      .order("position"),
    // ─── A LEITURA PAGINADA (#4321) ────────────────────────────────────────
    // O PostgREST corta TODA resposta em `max_rows` (1000, `supabase/config.toml`)
    // sem erro: sem `.range()` o quadro de um funil grande chegava com um pedaço
    // arbitrário e a tela dizia que era o funil inteiro. `lerPaginado` lê por
    // páginas e prova o fim pelo `count` exato da primeira — quando não consegue
    // provar (mais páginas que o teto), devolve `truncado` e a resposta AVISA.
    //
    // O `organization_id` não é redundância com a RLS: é a PRIMEIRA coluna de
    // `idx_crm_leads_org_pipeline_position_id` (`supabase/baseline.sql`). Sem
    // ele o índice não serve e a consulta varre o funil inteiro filtrando a
    // organização linha a linha.
    lerPaginado<Lead>((inicio, fim, contar) =>
      supabase
        .from("crm_leads")
        // O genérico explícito (`<string, Lead>`) porque a lista de colunas é
        // montada em tempo de execução: sem ele o tipo da linha vira
        // `GenericStringError[]` e o `lerPaginado<Lead>` não fecha.
        .select<string, Lead>(COLUNAS_DO_QUADRO, contar ? { count: "exact" } : undefined)
        .eq("organization_id", organizationId)
        .eq("pipeline_id", pipelineId)
        .neq("status", "archived")
        .order("position_in_stage")
        .order("id")
        .range(inicio, fim),
    ),
  ]);

  if (stagesErr) return fail("internal_error", stagesErr.message, 500, { requestId });
  if (leituraDosLeads.erro) {
    return fail("internal_error", leituraDosLeads.erro, 500, { requestId });
  }
  const leads = leituraDosLeads.linhas;

  // Contato pessoal some do board mas a linha continua no banco (spec 21, etapa
  // 8, decisão 2 — critério 4): o card não é listado e volta ao desmarcar, com
  // o histórico intacto. Filtro em memória sobre a lista já lida: o board não
  // pagina, então nada se perde por filtrar depois de ler. NUNCA `delete`.
  const pessoais = await idsDeContatosPessoais(
    supabase,
    (pipeline as Pipeline).organization_id,
  );
  const leadsVisiveis =
    pessoais.length > 0
      ? ((leads ?? []) as Lead[]).filter(
          (l) => l.contact_id === null || !pessoais.includes(l.contact_id),
        )
      : ((leads ?? []) as Lead[]);

  // ─── O RECORTE DE ENCERRADOS ANTIGOS (#2995) ────────────────────────────
  // A opção é POR FUNIL (`crm_pipelines.settings.ocultar_encerrados_dias`,
  // lida do pipeline que a consulta acima já trouxe) e vem desligada: chave
  // ausente devolve `null` e o quadro segue trazendo tudo, como sempre trouxe.
  //
  // Mesmo lugar e mesma técnica do filtro de contato pessoal acima — em MEMÓRIA,
  // sobre a lista já lida —, e pelo mesmo motivo: o quadro não pagina (ordena
  // tudo por `position_in_stage` numa leitura só), então filtrar depois de ler
  // não perde nada e evita uma segunda ida ao banco só para saber a opção antes
  // de buscar. É pergunta de EXIBIÇÃO: a linha continua no banco com o
  // histórico intacto, e desligar a opção a traz de volta inteira. Nada aqui
  // apaga, arquiva ou mexe em `status`.
  //
  // O `?lead=` é o negócio que o link pediu (o dossiê abre por ele): fica mesmo
  // antigo. Ele só é comparado com a lista JÁ filtrada acima, então não traz de
  // volta nada que o filtro de contato pessoal tirou.
  const leadsDoQuadro = recortaEncerradosAntigos(
    leadsVisiveis,
    diasDeRecorte((pipeline as Pipeline).settings),
    new Date(),
    req.nextUrl.searchParams.get("lead"),
  );

  const leadsWithOwner = await withOwnerAgents(
    supabase,
    (pipeline as Pipeline).organization_id,
    leadsDoQuadro,
  );
  if (leadsWithOwner.error) {
    return fail("internal_error", leadsWithOwner.error, 500, { requestId });
  }

  const { data: pipelinePadrao } = await supabase
    .from("crm_pipelines")
    .select("id")
    .eq("organization_id", (pipeline as Pipeline).organization_id)
    .eq("is_default", true)
    .maybeSingle();

  const leadsComAcao = await withNextActions(
    supabase,
    (pipeline as Pipeline).organization_id,
    leadsWithOwner.leads,
    (pipelinePadrao as { id: string } | null)?.id ?? null,
  );
  if (leadsComAcao.error) {
    return fail("internal_error", leadsComAcao.error, 500, { requestId });
  }

  const leadsComScore = await withScores(
    supabase,
    (pipeline as Pipeline).organization_id,
    leadsComAcao.leads,
  );
  if (leadsComScore.error) {
    return fail("internal_error", leadsComScore.error, 500, { requestId });
  }

  // As tarefas entram NO MEIO da cadeia, e não no fim: a resposta continua
  // saindo de `withMarcadoresDoContato`, a última etapa que as cercas do quadro
  // vigiam (`kanban-atalho-conversa`, `funil-mostra-dados-do-cliente`,
  // `funil-filtro-de-tag-le-as-duas-caixas`). A ordem não muda o resultado: cada
  // etapa só acrescenta o próprio campo ao lead.
  const leadsComTarefas = await withTarefas(
    supabase,
    (pipeline as Pipeline).organization_id,
    leadsComScore.leads,
  );
  if (leadsComTarefas.error) {
    return fail("internal_error", leadsComTarefas.error, 500, { requestId });
  }

  // Os anexos dos campos `file` (#2829) entram ANTES da conversa e dos
  // marcadores, que seguem sendo a última etapa vigiada pelas cercas do quadro
  // (`kanban-atalho-conversa`, `funil-mostra-dados-do-cliente`). Cada etapa só
  // acrescenta o próprio campo ao lead, então a ordem não muda o resultado.
  const leadsComAnexos = await withAnexos(
    supabase,
    (pipeline as Pipeline).organization_id,
    leadsComTarefas.leads,
    camposDeArquivoDoNegocio((pipeline as Pipeline).settings),
  );
  if (leadsComAnexos.error) {
    return fail("internal_error", leadsComAnexos.error, 500, { requestId });
  }

  const leadsComConversa = await withConversas(
    supabase,
    (pipeline as Pipeline).organization_id,
    leadsComAnexos.leads,
  );
  if (leadsComConversa.error) {
    return fail("internal_error", leadsComConversa.error, 500, { requestId });
  }

  const leadsComMarcadores = await withMarcadoresDoContato(
    supabase,
    (pipeline as Pipeline).organization_id,
    leadsComConversa.leads,
  );
  if (leadsComMarcadores.error) {
    return fail("internal_error", leadsComMarcadores.error, 500, { requestId });
  }

  const board: BoardData = {
    pipeline: pipeline as Pipeline,
    stages: (stages ?? []) as Stage[],
    leads: leadsComMarcadores.leads.map(
      (lead) =>
        ocultarProvaMetaNaResposta(lead as unknown as Record<string, unknown>) as unknown as Lead,
    ),
    // O AVISO anda com o corpo (#4321): quando a leitura não conseguiu provar o
    // fim, quem desenha o quadro precisa saber que está vendo um recorte — o
    // resto do contrato fica igual. Ausente quando o fim foi provado, então o
    // quadro pequeno responde byte a byte o que respondia antes.
    ...(leituraDosLeads.truncado ? { truncado: true as const } : {}),
  };

  return ok(board, {
    requestId,
    headers: leituraDosLeads.truncado ? { "X-Quadro-Truncado": "1" } : undefined,
  });
}
