import { requireSupportWrite } from "@/lib/impersonate/support";
/**
 * POST /api/v1/leads/bulk
 *
 * Bulk operations on leads (move/assign/tag/delete). Discriminated by `action`.
 * AT-06: max 50 ids per call.
 *
 * Status transitions are NOT performed here — bulk move only changes
 * stage_id/position; the trigger will close-as-won/lost if the target is a
 * close stage. RLS scopes everything to the caller's tenant.
 */
import { randomUUID } from "node:crypto";
import { type NextRequest } from "next/server";
import { audit, isServiceRoleConfigured } from "@/lib/audit";
import { ApiError } from "@/lib/api/types";
import { ok, fail } from "@/lib/api/wrappers";
import { requireRole } from "@/lib/auth/require-role";
import { resolveOwnerPatch } from "@/lib/leads/owner-patch";
import { emitLeadActivity, stageChangeReason } from "@/lib/leads/activity-emitter";
import { registraFalhaDeAtividade } from "@/lib/leads/activity-write-failure";
import { bulkLeadActionSchema, validateRequest } from "@/lib/schemas";
import {
  decideMotivoDaPerda,
  etapaDePerda,
  razaoDaPerdaNaTimeline,
  recusaDeMotivoDaPerdaPeloBanco,
} from "@/lib/leads/motivo-da-perda";
import {
  modoDeReabertura,
  recusaReabertura,
} from "@/lib/leads/reabertura";
import {
  camposComAnexo,
  recusaDeCamposObrigatorios,
  settingsDoFunil,
  validaCamposExigidos,
} from "@/lib/leads/campos-exigidos";
import { createClient } from "@/lib/supabase/server";
import { observeServiceOrigin } from "@/lib/atendimento/origem";
import { createAdminClient } from "@/lib/supabase/admin";
import { traduzir } from "@/lib/i18n/dicionario";

export const dynamic = "force-dynamic";

const MAX_BULK = 50;

/** Uma linha do retorno de `fn_mover_leads_em_lote` (migration 0209). */
interface LeadMovidoEmLote {
  lead_id: string;
  from_stage_id: string;
  pipeline_id: string;
}

export async function POST(req: NextRequest): Promise<Response> {
  const supportDenied = await requireSupportWrite();
  if (supportDenied) return supportDenied;

  const requestId = randomUUID();
  const supabase = await createClient();

  // spec 13 §4: escrita é agent+ (viewer é read-only).
  const authz = await requireRole("agent", { requestId, resource: "crm_leads" });
  if (!authz.ok) return authz.response;
  const t = (texto: string) => traduzir(texto, authz.user.idioma);
  const user = authz.user;

  let input;
  try {
    input = await validateRequest(bulkLeadActionSchema, req);
  } catch (err) {
    if (err instanceof ApiError) {
      return fail(err.code, err.message, err.status, {
        details: err.details as Record<string, unknown> | undefined,
        requestId,
      });
    }
    throw err;
  }

  if (input.lead_ids.length > MAX_BULK) {
    return fail("bulk_too_large", `${t("Máximo")} ${MAX_BULK} ${t("leads por bulk.")}`, 422, { requestId });
  }

  // G3-04: assign é reatribuição de dono em lote → piso ≥manager (spec 04 §6.5,
  // INB-03). Gate por-action: move/tag/delete continuam agent+ (piso acima);
  // só o assign exige manager. Reusa o helper (nada de ROLE_RANK na mão).
  if (input.action === "assign") {
    const mgr = await requireRole("manager", { requestId, resource: "crm_leads" });
    if (!mgr.ok) return mgr.response;

    // Novo dono tem que ser membro ativo agent+ da MESMA org (org de fonte
    // confiável = authz, nunca body). owner_user_id null = desatribuir (válido).
    // A RLS de user_organizations só mostra o próprio membership a um manager,
    // por isso o admin client filtrado pela org resolvida.
    const ownerId = input.params.owner_user_id;
    if (ownerId !== null) {
      // INB-09 nota 1: fail-closed. A validação de membership só roda com service
      // role (a RLS de user_organizations não mostra membership alheio a um
      // manager). Sem service role NÃO se pode validar o dono → recusar em vez
      // de atribuir um owner não-verificado. Desatribuir (owner null) segue livre.
      if (!isServiceRoleConfigured()) {
        return fail(
          "owner_validation_unavailable",
          t("Não foi possível validar o responsável agora. Tente novamente em instantes."),
          422,
          { requestId },
        );
      }
      const admin = createAdminClient();
      const { data: member, error: memberErr } = await admin
        .from("user_organizations")
        .select("role")
        .eq("organization_id", authz.org.orgId)
        .eq("user_id", ownerId)
        .is("revoked_at", null)
        .maybeSingle();
      if (memberErr) return fail("internal_error", memberErr.message, 500, { requestId });
      if (!member || member.role === "viewer") {
        return fail(
          "invalid_owner",
          t("Responsável não é um atendente ativo desta organização."),
          422,
          { requestId },
        );
      }
    }
  }

  // INB-09 nota 2: org de fonte confiável = org ativa do cookie (authz), NUNCA
  // inferida do 1º lead. A RLS já escopa por org do membro, mas um ator em 2+
  // orgs veria leads de ambas — o filtro explícito garante que o bulk só toca a
  // org ativa (mesmo padrão do gate de owner acima).
  const organizationId = authz.org.orgId;
  // `lost_reason` entra no select por causa da decisão de perda (issue #917): o
  // motivo que o negócio JÁ tem é metade da pergunta "esta escrita o deixa perdido
  // sem motivo?" — e perguntar card a card depois custaria N consultas.
  // `status` e `pipeline_id` (já vinha) respondem a OUTRA pergunta, a da
  // retomada (issue #1538): o lote não pode reabrir um negócio encerrado num
  // funil `novo_negocio`, e sem o `status` não há como saber quem ficaria.
  const { data: scoped } = await supabase
    .from("crm_leads")
    .select(
      "id, organization_id, tags, stage_id, pipeline_id, contact_id, lost_reason, custom_fields, won_reason, status",
    )
    .eq("organization_id", organizationId)
    .in("id", input.lead_ids);

  const visible = scoped ?? [];
  const first = visible[0];
  if (!first) {
    return fail(
      "not_found",
      t("Nenhum lead acessível na operação."),
      404,
      { requestId },
    );
  }
  const visibleIds = visible.map((r) => r.id);

  let updatedCount = 0;
  const nowIso = new Date().toISOString();

  switch (input.action) {
    case "move": {
      // ── O MOTIVO DA PERDA (issue #917) ──────────────────────────────────────
      //
      // O lote fecha N negócios de uma vez e a função do banco é uma transação só
      // ("move todos ou não move nenhum"): UM card que ficaria perdido sem motivo
      // derruba o lote inteiro com 23514, e o operador recebia 500 sem saber qual
      // card ofendeu. A decisão é a mesma das outras rotas de movimento — e a
      // resposta também: recusa de negócio, nomeando os cards, ANTES de o banco
      // tentar. Card que já tem motivo passa: trocar de "Perdido" para outra
      // etapa de perda não é uma perda nova.
      const { data: etapaDeDestino, error: etapaErr } = await supabase
        .from("crm_stages")
        .select("id, name, is_lost, is_won")
        .eq("id", input.params.stage_id)
        .maybeSingle();
      if (etapaErr) return fail("internal_error", etapaErr.message, 500, { requestId });
      if (!etapaDeDestino) {
        return fail("not_found", t("Stage não encontrado."), 404, { requestId });
      }

      const motivoDoLote = input.params.lost_reason ?? null;
      let recusaDoMotivo: { codigo: string; mensagem: string } | null = null;
      const leadsSemMotivo: string[] = [];
      for (const linha of visible) {
        const veredito = decideMotivoDaPerda({
          etapaDeDestino,
          motivo: motivoDoLote,
          motivoAtual: linha.lost_reason ?? null,
          idioma: user.idioma,
        });
        if (!veredito.ok) {
          recusaDoMotivo ??= { codigo: veredito.codigo, mensagem: veredito.mensagem };
          leadsSemMotivo.push(linha.id);
        }
      }
      if (recusaDoMotivo) {
        return fail(recusaDoMotivo.codigo, recusaDoMotivo.mensagem, 422, {
          requestId,
          details: { lead_ids: leadsSemMotivo },
        });
      }

      // ── O LOTE NÃO REABRE NEGÓCIO ENCERRADO (issue #1538) ───────────────────
      //
      // A função 0209 move o lote numa transação só, sem olhar `status`: para um
      // funil `novo_negocio` isso reabriria os cards encerrados do lote. A
      // recusa é ANTES da escrita, nomeando os cards, e devolve o MESMO 409
      // `reabertura_cria_novo` do arrasto — um lote com um só card ofensor já
      // derruba a operação inteira, e é o que o operador tem de saber (a porta
      // que resolve é a mesma, `/retomar`, card a card).
      //
      // O lote pode cruzar funis, então o settings é POR FUNIL e cacheado — o
      // MESMO cache serve a régua de campos logo abaixo.
      const settingsPorFunil = new Map<string, unknown>();
      for (const linha of visible) {
        const funilId = (linha as { pipeline_id?: string | null }).pipeline_id ?? "";
        if (!settingsPorFunil.has(funilId)) {
          settingsPorFunil.set(funilId, await settingsDoFunil(supabase, funilId || null));
        }
      }
      let recusaDeReabertura: { codigo: string; mensagem: string } | null = null;
      const reabertosNoLote: string[] = [];
      for (const linha of visible) {
        const veredito = recusaReabertura({
          modo: modoDeReabertura(settingsPorFunil.get(linha.pipeline_id ?? "")),
          statusAtual: linha.status,
          etapaDestino: etapaDeDestino,
          idioma: user.idioma,
        });
        if (veredito) {
          recusaDeReabertura ??= veredito;
          reabertosNoLote.push(linha.id);
        }
      }
      if (recusaDeReabertura) {
        return fail(recusaDeReabertura.codigo, recusaDeReabertura.mensagem, 409, {
          requestId,
          details: { lead_ids: reabertosNoLote, use: "/api/v1/leads/{id}/retomar" },
        });
      }

      // ── OS CAMPOS OBRIGATÓRIOS NO LOTE (issue #1536) ────────────────────────
      //
      // A mesma régua dos caminhos individuais, aqui por CARD: quem não passa
      // é LISTADO, nunca movido em silêncio — e como `fn_mover_leads_em_lote` é
      // uma transação só ("move todos ou não move nenhum"), um card que falharia
      // derrubaria o lote inteiro depois de a função já começar. A recusa vem
      // ANTES do RPC, nomeando os cards em `details.lead_ids` (o mesmo contrato
      // da recusa de motivo da perda logo acima) e o que falta em
      // `details.faltando`, por card.
      // O lote pode cruzar funis: a régua é a do funil de CADA card (cache
      // montado acima), nunca a do primeiro da lista.
      const leadIdsSemCampos: string[] = [];
      const faltandoPorCard: Record<string, { chave: string; rotulo: string }[]> = {};
      for (const linha of visible) {
        const funilId = (linha as { pipeline_id?: string | null }).pipeline_id ?? null;
        const settingsDaLinha = settingsPorFunil.get(funilId ?? "") ?? null;
        const veredito = validaCamposExigidos({
          lead: linha as unknown as Record<string, unknown>,
          settingsDoFunil: settingsDaLinha,
          // ponytail: uma ida ao banco por card, e só em funil com campo de arquivo exigível.
          camposComAnexo: await camposComAnexo(supabase, settingsDaLinha, organizationId, linha.id),
          destino: {
            stageId: etapaDeDestino.id,
            desfecho: etapaDeDestino.is_won
              ? "won"
              : etapaDeDestino.is_lost
                ? "lost"
                : null,
          },
          motivoDeGanho: (linha as { won_reason?: string | null }).won_reason ?? null,
        });
        if (veredito.faltando.length > 0) {
          leadIdsSemCampos.push(linha.id);
          faltandoPorCard[linha.id] = veredito.faltando;
        }
      }
      if (leadIdsSemCampos.length > 0) {
        const recusa = recusaDeCamposObrigatorios(
          Object.values(faltandoPorCard).flat(),
          user.idioma,
        );
        return fail(recusa.codigo, recusa.mensagem, 422, {
          requestId,
          details: { lead_ids: leadIdsSemCampos, faltando: faltandoPorCard },
        });
      }

      // Migration 0209: quem posiciona é o banco. Escrever aqui um
      // `position_in_stage` escalar para N linhas dava a TODOS os cards do lote
      // o mesmo número, e `midpoint(prev, next)` devolve NaN quando os vizinhos
      // empatam — o primeiro arrasto para entre dois cards do lote mandava NaN
      // como posição. A função dá uma posição distinta a cada um e faz do lote
      // uma transação só: move todos ou não move nenhum.
      //
      // O tipo é declarado aqui porque `createClient()` deste repo devolve um
      // cliente SEM o genérico `Database` — sem a anotação, `data` chega como
      // `any` e a checagem some justamente no lugar que passou a depender de
      // três campos vindos do banco.
      //
      // `p_lost_reason` (migration 0263) é o outro lado da decisão acima: o
      // motivo tem de entrar na MESMA escrita que muda a etapa, e quem escreve a
      // etapa do lote é esta função. String vazia é `null` — quem não trouxe
      // motivo não sobrescreve o que o card já tem.
      const { data, error } = await supabase.rpc("fn_mover_leads_em_lote", {
        p_organization_id: organizationId,
        p_lead_ids: visibleIds,
        p_stage_id: input.params.stage_id,
        p_lost_reason: (motivoDoLote ?? "").trim() || null,
      });
      if (error) {
        // Rede de segurança (#917): a recusa do banco por motivo da perda (o
        // motivo veio, mas não é do vocabulário deste funil) vira recusa de
        // negócio. Qualquer outro erro continua 500 com o texto do Postgres.
        const recusaDoBanco = recusaDeMotivoDaPerdaPeloBanco(error, user.idioma);
        if (recusaDoBanco) {
          return fail(recusaDoBanco.codigo, recusaDoBanco.mensagem, 422, { requestId });
        }
        return fail("internal_error", error.message, 500, { requestId });
      }
      const movidosNoBanco = (data ?? []) as LeadMovidoEmLote[];
      updatedCount = movidosNoBanco.length;

      // Per-lead lead.stage_changed so the automation engine (which only
      // consumes per-entity events) fires for bulk moves too — mirrors
      // moveLeadHandler's payload. Skip leads already at the target stage.
      //
      // A etapa de ORIGEM vem da função, não do `select` de antes: ela é lida
      // dentro do mesmo `update`, então não existe janela em que outra escrita
      // mova o card entre a leitura e a atualização e a timeline conte a
      // transição errada.
      const movidos = movidosNoBanco.filter((r) => r.from_stage_id !== input.params.stage_id);

      // Wave 3 (CORE 2): mover 30 cards de uma vez é 30 mudanças de estado —
      // cada uma entra no barramento, senão o lote inteiro fica invisível na
      // timeline e a operação em massa vira o buraco por onde a atividade some.
      //
      // Uma atividade POR LEAD, e não uma agregada: `crm_lead_activities` é a
      // timeline DE UM LEAD. Trinta cards movidos são uma linha em cada uma de
      // trinta timelines — não trinta linhas numa só. O ruído que uma agregada
      // evitaria não existe aqui; o que existiria sem elas é o oposto, um card
      // que mudou de etapa sem nada que conte por quê. `payload.bulk = true`
      // marca a origem, para quem lê distinguir lote de arrasto à mão.
      const nomesEstagio = new Map<string, string>();
      const { data: stageRows } = await supabase
        .from("crm_stages")
        .select("id, name")
        .eq("organization_id", organizationId)
        .in("id", [input.params.stage_id, ...movidos.map((r) => r.from_stage_id)]);
      for (const s of (stageRows ?? []) as Array<{ id: string; name: string }>) {
        nomesEstagio.set(s.id, s.name);
      }

      // ── A PERDA DO LOTE É A PERDA DO CARD, POR CARD (#2887) ─────────────────
      //
      // A `/lose` grava `demand_closed` "Perdido — <motivo>" e um `lead.lost`
      // por lead. O lote manda UM motivo para N cards, e o banco o grava só em
      // quem NÃO estava perdido (`fn_mover_leads_em_lote`: `status is distinct
      // from 'lost'`, migration 0674) — quem já estava perdido é movido com o
      // motivo dele intacto.
      //
      // Sem separar os dois grupos, a linha da timeline afirmaria uma troca que
      // o banco não fez ("Perdido — <motivo do lote>" sobre um card cujo motivo
      // continua o anterior) e o `lead.lost` contaria uma perda que não houve.
      // O discriminador é o MESMO do banco e da `/lose`: o `status` lido ANTES
      // da escrita — `visible`, a leitura que `decideMotivoDaPerda` já usa.
      const statusDoCard = new Map(visible.map((l) => [l.id, l.status]));
      const motivoDoCard = new Map(visible.map((l) => [l.id, l.lost_reason ?? null]));
      // Perda NOVA é a que a etapa de destino fecha AGORA: etapa de perda e card
      // que não estava perdido. Quem já estava perdido só troca de etapa.
      const ehPerdaNova = (row: LeadMovidoEmLote) =>
        etapaDePerda(etapaDeDestino) && statusDoCard.get(row.lead_id) !== "lost";
      // O motivo que a linha cita é o que FICOU na coluna: o do lote quando ele
      // veio, senão o que o card já tinha (card reaberto que se perde de novo
      // sem motivo novo — a coluna não muda).
      const motivoQueFicou = (row: LeadMovidoEmLote) =>
        (motivoDoLote ?? "").trim() || (motivoDoCard.get(row.lead_id) ?? "").trim();

      await Promise.all(
        movidos.map(async (row) => {
          const perdaNova = ehPerdaNova(row);
          const motivo = perdaNova ? motivoQueFicou(row) : "";
          const r = await emitLeadActivity(supabase, {
            organizationId,
            leadId: row.lead_id,
            // ⚠️ O TIPO SEGUE `stage_changed`, e isso é decisão declarada, não
            // esquecimento: `crm_lead_activities.type = 'stage_changed'` é a
            // fonte de `lib/metrics/taxa-da-etapa.ts` e da rota `win-rates`
            // (#1753) — "aterrissar numa etapa de ganho ou de PERDA" é o que
            // conta o negócio como encerrado, e o lote é um dos caminhos
            // listados lá. Trocar por `demand_closed` (o tipo da `/lose`)
            // tiraria do funil a passagem de todo negócio perdido em lote. O
            // que a #2887 cobra da timeline é o MOTIVO — e ele vai no `reason`,
            // por card.
            type: "stage_changed",
            sourceModule: "crm",
            sourceId: row.lead_id,
            actor: { type: "user", id: user.id },
            reason:
              perdaNova && motivo.length > 0
                ? razaoDaPerdaNaTimeline(motivo)
                : stageChangeReason(
                    nomesEstagio.get(row.from_stage_id) ?? null,
                    nomesEstagio.get(input.params.stage_id) ?? null,
                  ),
            payload: {
              // O desfecho só viaja quando ESTE card fechou agora — é o mesmo
              // `desfecho` que a `/lose` põe no payload da linha dela, e é o
              // que separa, para quem lê depois, "movido" de "perdido".
              ...(perdaNova ? { desfecho: "lost" } : {}),
              from_stage_id: row.from_stage_id,
              to_stage_id: input.params.stage_id,
              pipeline_id: row.pipeline_id,
              bulk: true,
            },
          });
          if (!r.ok) {
            // O bulk é o pior caso dos três: N leads movidos, e uma falha de
            // atividade some junto com as outras N-1 que deram certo. Sem o
            // aviso, o buraco na timeline não tem nem tamanho conhecido.
            await registraFalhaDeAtividade(supabase, {
              organizationId,
              leadId: row.lead_id,
              tipo: "stage_changed",
              origem: "leads/bulk",
              erro: r.error,
              requestId,
            });
          }
          if (perdaNova) {
            // A auditoria da PERDA, por lead (#2887), como a `/lose` faz
            // (`encerraDemanda`): o negócio perdido entra no audit log com o
            // motivo COM QUE foi perdido. O `lead.bulk_action` agregado — no
            // fim da rota — continua, porque ele é o registro da OPERAÇÃO, não
            // o da perda de cada negócio.
            await audit({
              action: "lead.lost",
              actorUserId: user.id,
              organizationId,
              resourceType: "crm_lead",
              resourceId: row.lead_id,
              requestId,
              metadata: {
                actor_type: "user",
                from_stage_id: row.from_stage_id,
                to_stage_id: input.params.stage_id,
                lost_reason: motivo,
              },
            });
          }
        }),
      );
      await Promise.all(
        movidos.map((row) =>
          supabase
            .rpc("emit_event", {
              p_event_type: "lead.stage_changed",
              p_entity_kind: "crm_lead",
              p_entity_id: row.lead_id,
              p_payload: {
                pipeline_id: row.pipeline_id,
                from_stage_id: row.from_stage_id,
                to_stage_id: input.params.stage_id,
              },
              p_metadata: { request_id: requestId, actor_user_id: user.id },
              p_organization_id: organizationId,
            })
            .then(({ error: emitError }) => {
              if (emitError) console.error("[lead.bulk_moved] emit_event failed", emitError.message);
            }),
        ),
      );
      break;
    }
    case "assign": {
      // 0070: o trio de posse vem do helper compartilhado. Escrever só
      // owner_user_id aqui quebrava de dois jeitos: lote com algum lead de dono
      // AGENTE estourava 23514 e derrubava a operação inteira; e lead sem dono
      // ganhava dono sem owner_kind (drift silencioso).
      const owner = resolveOwnerPatch({ owner_user_id: input.params.owner_user_id });
      if (!owner.ok || !owner.patch) {
        return fail(
          "validation_failed",
          t("Um lead tem um dono: informe owner_user_id OU owner_agent_id."),
          422,
          { requestId },
        );
      }
      const patch: Record<string, unknown> = {
        ...owner.patch,
        updated_at: nowIso,
      };
      if (owner.patch.owner_kind !== null) {
        patch.assigned_at = nowIso;
      }
      const { data, error } = await supabase
        .from("crm_leads")
        .update(patch)
        .in("id", visibleIds)
        .select("id");
      if (error) return fail("internal_error", error.message, 500, { requestId });
      updatedCount = data?.length ?? 0;
      break;
    }
    case "tag": {
      const add = input.params.add ?? [];
      const remove = new Set(input.params.remove ?? []);
      // Compute next tags per row from already-fetched `scoped`.
      for (const row of visible) {
        const current = (row.tags ?? []) as string[];
        const next = Array.from(new Set([...current.filter((t) => !remove.has(t)), ...add]));
        const tagServiceOrigin = add.some((tag) => !current.includes(tag))
          ? await observeServiceOrigin(createAdminClient(), organizationId, row.contact_id)
          : null;
        const { error } = await supabase
          .from("crm_leads")
          .update({ tags: next, updated_at: nowIso })
          .eq("id", row.id);
        if (error) return fail("internal_error", error.message, 500, { requestId });
        updatedCount += 1;

        // Per-lead lead.tag_added (only-when-added), same contract as
        // updateLeadHandler, so the automation engine fires for bulk tags too.
        const addedTags = add.filter((t) => !current.includes(t));
        if (addedTags.length) {
          await createAdminClient()
            .rpc("emit_event", {
              p_event_type: "lead.tag_added",
              p_entity_kind: "crm_lead",
              p_entity_id: row.id,
              p_payload: { added_tags: addedTags, tags: next, service_origin: tagServiceOrigin },
              p_metadata: { request_id: requestId, actor_user_id: user.id },
              p_organization_id: organizationId,
            })
            .then(({ error: emitError }) => {
              if (emitError) console.error("[lead.bulk_tagged] emit_event failed", emitError.message);
            });
        }
      }
      break;
    }
    case "delete": {
      // crm_leads has no `is_archived` column → real DELETE.
      const { data, error } = await supabase
        .from("crm_leads")
        .delete()
        .in("id", visibleIds)
        .select("id");
      if (error) return fail("internal_error", error.message, 500, { requestId });
      updatedCount = data?.length ?? 0;
      break;
    }
  }

  // Aggregate event + aggregate audit (one record per bulk call).
  const eventType =
    input.action === "move"
      ? "lead.bulk_moved"
      : input.action === "assign"
        ? "lead.bulk_assigned"
        : input.action === "tag"
          ? "lead.bulk_tagged"
          : "lead.bulk_deleted";

  await supabase
    .rpc("emit_event", {
      p_event_type: eventType,
      p_entity_kind: "crm_lead",
      p_entity_id: null,
      p_payload: {
        action: input.action,
        lead_ids: visibleIds,
        params: "params" in input ? input.params : {},
      },
      p_metadata: { request_id: requestId, actor_user_id: user.id },
      p_organization_id: organizationId,
    })
    .then(({ error }) => {
      if (error) console.error("[lead.bulk] emit_event failed", error.message);
    });

  // Assign audita com action agregada dedicada (spec 04 §6.5); as demais ações
  // mantêm o code genérico. Uma única entrada por chamada, com a contagem.
  await audit({
    action: input.action === "assign" ? "leads.bulk_assigned" : "lead.bulk_action",
    actorUserId: user.id,
    organizationId,
    resourceType: "crm_lead",
    resourceId: null,
    requestId,
    metadata: {
      action: input.action,
      lead_ids: visibleIds,
      count: updatedCount,
      updated_count: updatedCount,
      ...(input.action === "assign" ? { owner_user_id: input.params.owner_user_id } : {}),
      params: "params" in input ? input.params : {},
    },
  });

  return ok({ updated_count: updatedCount, lead_ids: visibleIds }, { requestId });
}
