/**
 * Core handlers para /api/v1/contacts (lista + get + create + patch + delete).
 *
 * Reusados pelo Route Handler REST e por MCP tools (S-13.03/04).
 * - Recebem actor polimórfico (`user` | `ai_agent`).
 * - Lançam `ApiError` em caso de erro estruturado; sucesso retorna data.
 * - Audit + emit_event são responsabilidade do handler (DRY entre REST e MCP).
 */
import type { SupabaseClient } from "@supabase/supabase-js";

import { createAdminClient } from "@/lib/supabase/admin";
import { revogarAcessosDoContato } from "@/lib/mcp/servidor-externo/do-agente/revogacao";
import { logger } from "@/lib/logger";
import { observeServiceOrigin } from "@/lib/atendimento/origem";
import { ApiError } from "@/lib/api/types";
import type { Actor, HandlerCtx } from "@/lib/api/handlers/types";
import { audit } from "@/lib/audit";
import { traduzir } from "@/lib/i18n/dicionario";
import type { Idioma } from "@/lib/i18n/idiomas";
import { roleAtLeast } from "@/lib/auth/types";
import { canonicalPhoneBR, phoneLookupVariants } from "@/lib/channels/phone-variants";
import { encontrarContatoPorTelefone } from "@/lib/channels/contato-por-telefone";
import {
  INDICE_CPF,
  INDICE_EMAIL,
  encontrarContatoPorCpfHash,
  encontrarContatoPorEmail,
  nomeDoIndiceUnico,
} from "@/lib/contacts/conflito-unico";
import { camposCpfParaGravar, hashCpf } from "@/lib/contacts/cpf";
import type { Contact } from "@/lib/types/contacts";
import { abrirConversaDoContatoNovo, type PortaDaCriacao } from "@/lib/contacts/conversa-ao-criar";
import { emitLeadActivity } from "@/lib/leads/activity-emitter";
import { listaLegivel } from "@/lib/leads/activity-vocabulary";
import { registraFalhaDeAtividade } from "@/lib/leads/activity-write-failure";
import { atribuirConversaDoCadastroAoCriador } from "@/lib/contacts/conversa-do-criador";
import type {
  ContactCreate,
  ContactPatch,
  ContactListQuery,
  ContactListQueryParams,
} from "@/lib/schemas";
import { contactListQuerySchema } from "@/lib/schemas";
import { arrayDeUmValorParaOr } from "@/lib/inbox/marcador-da-conversa";
import {
  buscaValeConsulta,
  normalizarTermoDeBusca,
  termoSeguroParaOr,
  termoSemAcento,
} from "@/lib/inbox/termo-de-busca";
import {
  camposQueMudaram,
  defsDoPadraoDaOrg,
  erroDePadrao,
  recusasDePadrao,
} from "@/lib/leads/padrao-do-campo";
import { testaComPrazo } from "@/lib/leads/padrao-do-campo-no-servidor";

type SB = SupabaseClient;

const SELECT_COLS =
  "id, organization_id, name, display_name, address_book_name, zip_code, street, number, complement, district, city, state, email, email_normalized, phone_number, cpf_encrypted, birthdate, is_blocked, blocked_reason, is_personal, contact_type, ai_opt_out, is_anonymized, anonymized_at, is_merged_into, merged_at, consent, tags, source, source_metadata, custom_fields, created_at, updated_at, last_activity_at, first_service_at";

/**
 * A linha como o banco devolve. `cpf_encrypted` é interno a este módulo e só diz
 * se há CPF: `contacts_cpf_consistency` obriga as duas colunas de CPF a serem
 * nulas juntas, e a cifra só se abre em `decrypt_cpf`. Comparar por CPF é do
 * servidor (`idsDeContatosPorCpf`, `erroDeConflitoUnico`).
 */
type LinhaDeContato = Omit<Contact, "cpf_available"> & { cpf_encrypted: unknown };

/**
 * A ficha que sai deste módulo: o sinal de que há CPF, nunca a coluna. Toda
 * saída de `SELECT_COLS` passa aqui — os handlers servem à REST e ao MCP.
 */
function fichaDaResposta({ cpf_encrypted, ...ficha }: LinhaDeContato): Contact {
  return { ...ficha, cpf_available: !!cpf_encrypted };
}

/**
 * As fichas vivas desta organização com o CPF de `digits`. Sem `limit`: o índice
 * único de CPF nasce com `if not exists`, e a ficha repetida num clone sem ele
 * também tem de aparecer na busca.
 */
async function idsDeContatosPorCpf(ctx: HandlerCtx, digits: string): Promise<string[]> {
  const { data, error } = await createAdminClient()
    .from("contacts")
    .select("id")
    .eq("organization_id", ctx.organization_id)
    .eq("cpf_hash", hashCpf(digits))
    .is("is_merged_into", null);
  if (error) {
    throw new ApiError(500, "internal_error", undefined, ctx.requestId, error.message);
  }
  return ((data ?? []) as { id: string }[]).map((r) => r.id);
}

interface CursorPayload {
  sort: string | null;
  id: string;
}

function encodeCursor(p: CursorPayload): string {
  return Buffer.from(JSON.stringify(p), "utf8").toString("base64url");
}
function decodeCursor(raw: string): CursorPayload | null {
  try {
    const json = Buffer.from(raw, "base64url").toString("utf8");
    const parsed = JSON.parse(json) as CursorPayload & {
      last_activity_at?: string | null;
      created_at?: string | null;
    };
    if (typeof parsed.id !== "string") return null;
    // Cursores legados (só created_at) ou do formato anterior (last_activity_at).
    const sort = parsed.sort ?? parsed.last_activity_at ?? parsed.created_at ?? null;
    if (sort !== null && typeof sort !== "string") return null;
    return { sort, id: parsed.id };
  } catch {
    return null;
  }
}

function actorAuditPayload(actor: Actor): {
  actorUserId: string | null;
  metadataActor: Record<string, unknown>;
} {
  if (actor.type === "user") {
    return { actorUserId: actor.id, metadataActor: { actor_type: "user" } };
  }
  return {
    actorUserId: null,
    metadataActor: {
      actor_type: actor.type,
      actor_id: actor.id,
      ...(actor.type === "ai_agent" && actor.api_token_id
        ? { actor_api_token_id: actor.api_token_id }
        : {}),
    },
  };
}

// ---------------------------------------------------------------------------
// list
// ---------------------------------------------------------------------------

export interface ListContactsResult {
  contacts: Contact[];
  cursor: string | null;
  has_more: boolean;
}

export async function listContactsHandler(
  supabase: SB,
  ctx: HandlerCtx,
  raw: ContactListQueryParams,
  /**
   * Só este contato — o escopo do turno do agente (`crm_search_contacts`).
   * Fora do `raw` de propósito: não é parâmetro da rota HTTP, e vai no WHERE,
   * antes do limite.
   */
  soContato?: string,
): Promise<ListContactsResult> {
  const q: ContactListQuery = contactListQuerySchema.parse(raw);

  // ─── O PISO DA BUSCA (#1835) ───────────────────────────────────────────────
  //
  // `?search=a` montava `name.ilike.%a%` e devolvia a LISTA INTEIRA — e lista
  // inteira sob busca não é resposta, é ruído que PARECE resposta. É a MESMA
  // medição que criou `PISO_DA_BUSCA` no inbox, aqui pela porta que hoje não a
  // tinha (a de conversas recusa no schema Zod; a de contatos deixava passar).
  //
  // A régua é consultada DENTRO do handler, e não no schema, de propósito: a
  // tela de contatos manda o termo como foi digitado (`useContactList` não tem
  // guarda de piso), então um `422` acenderia `showApiError` a cada letra
  // digitada; e o MCP (`crm_search_contacts`) chama este handler direto e
  // receberia um `ZodError` no meio da ferramenta. O efeito pedido é o mesmo
  // dos dois lados: abaixo do piso a busca NÃO VAI AO BANCO, e quem digita vê
  // a lista vazia até completar os dois caracteres.
  //
  // O piso mede o MESMO termo que vai ao filtro: sem os parênteses (ver o
  // bloco do `.or()` abaixo). Medir o cru deixava `"()"` consultar `%%` e
  // `"(a"` consultar `%a%` — a lista inteira de volta pela porta do parêntese.
  const termoDeTexto = q.search ? q.search.replace(/[()]/g, " ") : undefined;
  if (termoDeTexto !== undefined && !buscaValeConsulta(termoDeTexto)) {
    return { contacts: [], cursor: null, has_more: false };
  }

  const sortCol = q.order_by;
  const asc = q.order_dir === "asc";

  let query = supabase
    .from("contacts")
    .select(SELECT_COLS)
    .eq("organization_id", ctx.organization_id)
    // O placeholder de GRUPO (`kind='whatsapp_group'`) não é uma pessoa da
    // base: é o registro técnico que a conversa do grupo pendura para caber no
    // mesmo esquema de `contacts`. Listar junto misturaria grupo com cliente
    // numa lista que existe para achar CLIENTE — mesmo raciocínio da lápide de
    // fusão logo abaixo.
    .eq("kind", "person")
    // A LÁPIDE DE FUSÃO NÃO É UM CONTATO VIVO.
    //
    // `is_merged_into` marca o cadastro que foi absorvido por outro. Ele não é
    // apagado de propósito (é o que libera telefone e e-mail para o vencedor
    // herdar, e é o registro da fusão), mas ele deixou de ser uma pessoa da
    // base — e o resto do produto já o trata assim: `contacts/duplicates`, o
    // webhook de captação (`webhooks/in/[token]`) e as duas leituras de
    // `lib/channels/contato-por-telefone` filtram `is_merged_into is null`.
    // Esta listagem era a ÚNICA que não filtrava.
    //
    // Sem esta linha, a fusão parece não ter acontecido: medido pela tela em
    // 2026-09-04, logo depois de juntar dois cadastros a lista seguia mostrando
    // OS DOIS, com o mesmo telefone e ambos com status "ativo" — e quem opera
    // ou tenta juntar de novo (o diálogo de duplicados já não os oferece) ou
    // conclui que o recurso não funciona. Antes de a fusão existir na tela, a
    // coluna só era escrita por uma data migration de mão única, e por isso
    // ninguém tinha esbarrado nisto.
    .is("is_merged_into", null)
    .order(sortCol, { ascending: asc, nullsFirst: false })
    .order("id", { ascending: asc })
    .limit(q.limit + 1);

  if (q.search && termoDeTexto !== undefined) {
    // ─── A MESMA RÉGUA DA BUSCA DO INBOX (#3918, fatia 2) ──────────────────
    //
    // Aqui havia `name.imatch.*…*`, `display_name.imatch.*…*` e
    // `address_book_name.imatch.*…*`. O `ILIKE` (e o regex que o imitava) dobra
    // a CAIXA, não o ACENTO — `lower('Á') = 'á'`, que não é `'a'` —, então
    // "Joao" não achava "João" e quem digita rápido, sem acento, concluía que o
    // cliente não existia. Desde o #3924 o banco tem a coluna GERADA
    // `contacts.busca_sem_acento` — `lower(public.f_unaccent(display_name,
    // name, address_book_name))`, com índice `pg_trgm` (migration 9098) — e o
    // inbox já busca nela. A partir daqui as TRÊS portas da busca de contato
    // (a tela de Contatos, o seletor e a ferramenta do agente) usam a MESMA
    // coluna, e `lib/contacts/busca-regex.ts` (a régua própria da #1835) foi
    // aposentada: duas réguas de acento divergem no primeiro ajuste.
    //
    // Composição, nesta ordem — cada passo tem um dono:
    //   normalizarTermoDeBusca → como a PESSOA digitou (espaço duplo, vírgula
    //                            e ponto e vírgula viram UM curinga)
    //   termoSeguroParaOr      → a GRAMÁTICA do `or=` do PostgREST (`,` `(` `)`
    //                            viram curinga; `%`/`_` digitados, literais)
    //   termoSemAcento         → o lado do TERMO da coluna: NFD sem marca,
    //                            minúsculo, com a mesma tabela de letras que o
    //                            `unaccent` do banco cobre (ß→ss, ø→o…)
    //
    // `like` e não `ilike`: a coluna já é minúscula, e o índice GIN de trigrama
    // serve os dois operadores. O `%`/`_` escapado pelo `termoSeguroParaOr` é
    // literal no LIKE do Postgres — o `%` digitado não vira "casa tudo".
    //
    // Os PARÊNTESES saem ANTES da régua, como antes: são delimitador do DSL do
    // `.or()` (um `(` sem fechar derrubaria o filtro inteiro com HTTP 400) e a
    // régua não os conhece — tirá-los depois deixaria espaço solto, que não
    // casa nada.
    const s = termoSemAcento(termoSeguroParaOr(normalizarTermoDeBusca(termoDeTexto)));

    // A coluna do NOME muda com `soContato`: `busca_sem_acento` carrega também
    // o nome da agenda do celular (#2439), e ele é SÓ DA EQUIPE. Na busca do
    // agente durante a conversa (`crm_search_contacts`), casar nele faria a
    // ferramenta responder ao modelo se o rótulo interno contém o termo — o
    // rótulo chegaria ao cliente aos pedaços. `busca_sem_acento_sem_agenda`
    // (migration 9325) é `display_name` + `name` e nada mais, e é a coluna que
    // aquela busca usa; ela não tem índice porque o `id` do turno já a reduz a
    // UMA linha.
    const colunaDoNome = soContato ? "busca_sem_acento_sem_agenda" : "busca_sem_acento";

    const digits = q.search.replace(/\D/g, "");
    const orParts = [
      `${colunaDoNome}.like.*${s}*`,
      // Email e telefone NÃO têm acento: seguem no `ilike` com o termo
      // saneado, e não na coluna gerada — ela guarda só os NOMES. Continuam no
      // OR porque a pessoa busca o cadastro pelo que tem à mão: o e-mail que o
      // cliente mandou ou o número do identificador de chamadas.
      `email.ilike.*${s}*`,
      `phone_number.ilike.*${s}*`,
    ];
    if (digits.length >= 8) {
      // 10/11 dígitos sem DDI: no Brasil é DDD+local. Sem o 55, `3284793302`
      // não gera a variante com o 9 e o cadastro `+5532984793302` some da busca.
      const base =
        !digits.startsWith("55") && (digits.length === 10 || digits.length === 11)
          ? `55${digits}`
          : digits;
      // Os critérios de TELEFONE do principal, reaproveitados para o extra abaixo.
      const criteriosDeTelefone = [`phone_number.ilike.*${s}*`];
      for (const v of phoneLookupVariants(base)) {
        const d = v.replace(/\D/g, "");
        if (d && d !== digits) {
          orParts.push(`phone_number.ilike.%${d}%`);
          criteriosDeTelefone.push(`phone_number.ilike.%${d}%`);
        }
      }
      // Ficha cujo TELEFONE EXTRA casa o termo também entra na busca: sem isto, o
      // número que não é o principal não acharia a pessoa que o tem. A consulta
      // aos extras é do mesmo jeito filtrada por organização; a ficha continua
      // passando pelo filtro de lápide e de pessoal da listagem.
      //
      // Só quando o termo TEM CARA DE TELEFONE (dígitos e a pontuação de número):
      // texto com letras e dígitos ("a,organization_id.eq.000…") não é busca de
      // número, e uma consulta a mais por termo hostil é o que a cerca da busca
      // (`busca-de-contatos-normaliza-termo`) conta.
      if (/^[\d\s()+.-]+$/.test(q.search.trim())) {
        const { data: extras } = await supabase
          .from("contact_phones")
          .select("contact_id")
          .eq("organization_id", ctx.organization_id)
          .or(criteriosDeTelefone.join(","));
        const idsDeExtras = [
          ...new Set(((extras ?? []) as { contact_id: string }[]).map((r) => r.contact_id)),
        ];
        if (idsDeExtras.length > 0) orParts.push(`id.in.(${idsDeExtras.join(",")})`);
      }
    }
    if (digits.length === 11) {
      const idsPorCpf = await idsDeContatosPorCpf(ctx, digits);
      if (idsPorCpf.length > 0) orParts.push(`id.in.(${idsPorCpf.join(",")})`);
    }
    query = query.or(orParts.join(","));
  }
  // ⚠️ E/OU (#1274). E e OU viraram DOIS textos, e o que os separa e o
  // operador — a mesma régua do Inbox (`lib/inbox/marcador-da-conversa.ts`), com
  // a diferença de que aqui existe UMA caixa só (`contacts.tags`).
  //
  // - E: `tags=cs.{a,b}` — `contains` com a LISTA, que o builder já sabe escrever.
  //   Uma etiqueta só continua `tags=cs.{a}`, byte a byte o que era antes.
  // - OU: um `or=` com um `ov` por etiqueta. ⚠️ NÃO é `overlaps` repetido: o
  //   builder escreve `tags=ov.…` no MESMO parametro cada vez, e parâmetro
  //   repetido no PostgREST é E — que é o modo oposto com o nome de OU.
  if (q.tag && q.tag.length > 1 && q.modo === "ou") {
    query = query.or(
      q.tag.map((marcador) => `tags.ov.${arrayDeUmValorParaOr(marcador)}`).join(","),
    );
  } else if (q.tag) {
    query = query.contains("tags", q.tag);
  }
  if (q.source) query = query.eq("source", q.source);
  // `yes` responde "quem pediu para não ser incomodado"; `no`, quem não pediu
  // opt-out. Só o opt-out: exclusão de campanha e anonimizados não entram aqui.
  if (q.blocked) query = query.eq("is_blocked", q.blocked === "yes");
  if (soContato) query = query.eq("id", soContato);
  // Pessoal fora da lista por padrão; `?pessoais=true` lista SÓ pessoais
  // (spec 21, etapa 13 — a tela do filtro e o desmarcar). Sem esta linha a
  // lista de Contatos furava pelo outro lado o esconderijo que o inbox
  // construiu — e o MCP search herdaria o furo junto.
  if (q.pessoais) query = query.eq("is_personal", true);
  else query = query.eq("is_personal", false);

  if (q.cursor) {
    const c = decodeCursor(q.cursor);
    if (!c) {
      throw new ApiError(
        400,
        "invalid_cursor",
        undefined,
        ctx.requestId,
        traduzir("Cursor inválido.", ctx.idioma ?? "pt-BR"),
      );
    }
    const op = asc ? "gt" : "lt";
    if (c.sort !== null) {
      // O NULL vem depois dos valores nos dois sentidos. O valor pode ser
      // vazio ou conter pontuação da gramática do PostgREST.
      const valor = `"${c.sort.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
      query = query.or(
        `${sortCol}.${op}.${valor},and(${sortCol}.eq.${valor},id.${op}.${c.id}),${sortCol}.is.null`,
      );
    } else {
      // Página na região de sort NULL (nulls last): pagina só por id.
      query = query.is(sortCol, null);
      query = asc ? query.gt("id", c.id) : query.lt("id", c.id);
    }
  }

  const { data, error } = await query;
  if (error) {
    throw new ApiError(500, "internal_error", undefined, ctx.requestId, error.message);
  }

  const rows = ((data ?? []) as LinhaDeContato[]).map(fichaDaResposta);
  const hasMore = rows.length > q.limit;
  const page = hasMore ? rows.slice(0, q.limit) : rows;
  const last = page[page.length - 1];
  const nextCursor =
    hasMore && last
      ? encodeCursor({
          sort: (last[sortCol] as string | null) ?? null,
          id: last.id,
        })
      : null;

  const { contacts, error: convErr } = await withConversas(supabase, ctx.organization_id, page);
  if (convErr) {
    throw new ApiError(500, "internal_error", undefined, ctx.requestId, convErr);
  }

  return { contacts, cursor: nextCursor, has_more: hasMore };
}

/**
 * Anexa a conversa mais recente de cada contato — o atalho da lista para o inbox.
 * Mesma regra do quadro Kanban (`pipelines/[id]/board/route.ts:withConversas`).
 */
async function withConversas(
  supabase: SB,
  organizationId: string,
  contacts: Contact[],
): Promise<{ contacts: Contact[]; error: string | null }> {
  const contactIds = contacts.map((c) => c.id);
  if (contactIds.length === 0) return { contacts, error: null };

  const { data, error } = await supabase
    .from("conversations")
    .select("id, contact_id, last_message_preview, last_message_at, unread_count_for_assignee")
    .eq("organization_id", organizationId)
    .in("contact_id", contactIds)
    .order("last_message_at", { ascending: false, nullsFirst: false });
  if (error) return { contacts, error: error.message };

  const porContato = new Map<string, NonNullable<Contact["conversa"]>>();
  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;
  }>) {
    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 {
    contacts: contacts.map((contact) => {
      const conversa = porContato.get(contact.id);
      return conversa ? { ...contact, conversa } : contact;
    }),
    error: null,
  };
}

// ---------------------------------------------------------------------------
// get
// ---------------------------------------------------------------------------

export interface GetContactInput {
  contactId: string;
  decryptPurpose?: string | null;
}

export interface GetContactResult extends Contact {
  cpf_decrypted: string | null;
  cpf_decrypt_denied?: boolean;
}

export async function getContactHandler(
  supabase: SB,
  ctx: HandlerCtx,
  input: GetContactInput,
): Promise<GetContactResult> {
  const { data, error } = await supabase
    .from("contacts")
    .select(SELECT_COLS)
    .eq("id", input.contactId)
    .eq("organization_id", ctx.organization_id)
    .maybeSingle();

  if (error) {
    throw new ApiError(500, "internal_error", undefined, ctx.requestId, error.message);
  }
  if (!data) {
    throw new ApiError(
      404,
      "not_found",
      undefined,
      ctx.requestId,
      traduzir("Contato não encontrado.", ctx.idioma ?? "pt-BR"),
    );
  }
  const contact = fichaDaResposta(data as LinhaDeContato);

  let cpfDecrypted: string | null = null;
  let cpfDecryptDenied = false;

  if (input.decryptPurpose && contact.cpf_available && ctx.actor.type === "user") {
    const { data: membership } = await supabase
      .from("user_organizations")
      .select("role")
      .eq("user_id", ctx.actor.id)
      .eq("organization_id", contact.organization_id)
      .is("revoked_at", null)
      .maybeSingle();

    const role = membership?.role as string | undefined;
    if (!roleAtLeast(role, "manager")) {
      cpfDecryptDenied = true;
    } else {
      const { data: dec, error: decErr } = await supabase.rpc("decrypt_cpf", {
        p_contact_id: input.contactId,
      });
      if (decErr) {
        console.warn("[contacts.get] decrypt_cpf RPC unavailable", decErr.message);
      } else if (typeof dec === "string") {
        cpfDecrypted = dec;
      }
      const a = actorAuditPayload(ctx.actor);
      await audit({
        action: "contact.updated",
        actorUserId: a.actorUserId,
        organizationId: contact.organization_id,
        resourceType: "contact",
        resourceId: contact.id,
        requestId: ctx.requestId,
        metadata: {
          ...a.metadataActor,
          decrypt_purpose: input.decryptPurpose,
          success: !!cpfDecrypted,
        },
      });
    }
  }

  const { contacts: enriched, error: convErr } = await withConversas(
    supabase,
    ctx.organization_id,
    [contact],
  );
  if (convErr) {
    throw new ApiError(500, "internal_error", undefined, ctx.requestId, convErr);
  }
  const contactWithConversa = enriched[0] ?? contact;

  return {
    ...contactWithConversa,
    cpf_decrypted: cpfDecrypted,
    cpf_decrypt_denied: cpfDecryptDenied || undefined,
  };
}

// ---------------------------------------------------------------------------
// conflito de dado único (#3084)
// ---------------------------------------------------------------------------

/**
 * Traduz um 23505 do INSERT/UPDATE de `contacts` no 409 `contact_exists` certo —
 * ou devolve `null` para deixá-lo seguir no 500 de sempre.
 *
 * ─── O telefone continua decidindo pela RELEITURA, não pelo texto ──────────
 *
 * O 23505 sozinho nomeia UM índice; o estado (contato vivo com o mesmo telefone
 * na mesma organização) já implica a trava de telefone de qualquer jeito. Por
 * isso o caminho do telefone é o de antes — a releitura por `organization_id`
 * decide, sem olhar o nome do índice. Mudar isso seria trocar um desenho que já
 * estava certo.
 *
 * ─── CPF e e-mail precisam do NOME do índice ───────────────────────────────
 *
 * Aqui não há como inferir pelo estado: o erro diz qual índice bateu (o driver
 * escreve o nome na `message`). Só o índice conhecido vira 409, e só depois de a
 * releitura — sempre repetindo `organization_id` e `is_merged_into IS NULL` —
 * achar o contato vivo que já tem o dado. Sem o contato, ou com o índice
 * trocado, o 23505 continua no 500: não se inventa 409 sobre um conflito que
 * não se sabe qual é.
 *
 * O `contact_id` devolvido vem SEMPRE da releitura, nunca do corpo.
 */
async function erroDeConflitoUnico(
  supabase: SB,
  ctx: HandlerCtx,
  campos: { phone_number?: unknown; email?: unknown; cpf_hash?: unknown },
  erro: { code?: string; message?: string; details?: string | null; constraint?: string | null },
  excluirId?: string,
): Promise<ApiError | null> {
  if (erro.code !== "23505") return null;
  const idioma = ctx.idioma ?? "pt-BR";

  // 1) CPF e e-mail — só quando o erro NOMEIA o índice (#3084). Vem ANTES do
  //    telefone: o PATCH da tela reenvia o telefone do PRÓPRIO contato, e a
  //    releitura por telefone o acharia — 409 de telefone apontando para si mesmo.
  const indice = nomeDoIndiceUnico(erro);
  if (indice === INDICE_CPF) {
    const hash = typeof campos.cpf_hash === "string" ? campos.cpf_hash : null;
    if (hash) {
      const existente = await encontrarContatoPorCpfHash(createAdminClient(), ctx.organization_id, hash);
      if (existente) {
        return new ApiError(
          409,
          "contact_exists",
          { contact_id: existente.id },
          ctx.requestId,
          traduzir("Já existe um contato com este CPF.", idioma),
        );
      }
    }
  }
  if (indice === INDICE_EMAIL) {
    const email = typeof campos.email === "string" ? campos.email : null;
    if (email) {
      const existente = await encontrarContatoPorEmail(supabase, ctx.organization_id, email);
      if (existente) {
        return new ApiError(
          409,
          "contact_exists",
          { contact_id: existente.id },
          ctx.requestId,
          traduzir("Já existe um contato com este e-mail.", idioma),
        );
      }
    }
  }

  // 2) Telefone — o desenho de sempre (fatia S1 da #852): a releitura decide,
  //    salvo quando o erro já nomeou CPF ou e-mail. No PATCH, `excluirId` é o
  //    contato editado: achar a si mesmo pelo telefone não explica conflito
  //    nenhum, e o 23505 segue no 500.
  if (indice === INDICE_CPF || indice === INDICE_EMAIL) return null;
  const telefone = typeof campos.phone_number === "string" ? campos.phone_number : null;
  if (telefone) {
    const existente = await encontrarContatoPorTelefone(supabase, ctx.organization_id, telefone);
    if (existente && existente.id !== excluirId) {
      return new ApiError(
        409,
        "contact_exists",
        { contact_id: existente.id },
        ctx.requestId,
        traduzir("Já existe um contato com este telefone.", idioma),
      );
    }
  }

  return null;
}

// ---------------------------------------------------------------------------
// create
// ---------------------------------------------------------------------------

export interface CreateContactResult {
  contact: Contact;
  action: "created";
}

export interface CreateContactOptions {
  /**
   * Quem está criando, para a regra de "criar contato abre conversa"
   * (`lib/contacts/conversa-ao-criar.ts`). Padrão `manual`: a rota e o cartão
   * do negócio. A prospecção declara `prospeccao`, porque abre a conversa no
   * canal da campanha por conta própria.
   */
  porta?: Extract<PortaDaCriacao, "manual" | "prospeccao">;
}

export async function createContactHandler(
  supabase: SB,
  ctx: HandlerCtx,
  input: ContactCreate,
  linkedLeadId?: string,
  opcoes: CreateContactOptions = {},
): Promise<CreateContactResult> {
  // ── O PADRÃO (#2730) — a MESMA régua do formulário, aqui no SERVIDOR ──────
  //
  // O form é a primeira porta, não a garantia: dá para POSTar direto na API.
  // As definições vêm de TODOS os funis da organização, porque o CONTATO não
  // tem funil próprio — a tela lê o padrão (`useDefaultPipeline`) e o servidor,
  // que não sabe qual é, pergunta a todos.
  //
  // Falha de leitura devolve lista vazia e a escrita segue: fail-open da casa
  // (`settingsDoFunil` faz igual). Cair a rota por um `settings` torto seria
  // trocar "grava torto" por "não grava nada" — e a régua só tem poder sobre
  // o que ela consegue LER.
  if (input.custom_fields && Object.keys(input.custom_fields).length > 0) {
    // Escopo `contato` (#2852): a ficha do contato só recebe os campos do
    // CONTATO, e são só esses que ela pode gravar. Sem o filtro, o padrão de um
    // campo do NEGÓCIO — que o diálogo nem conhece — recusava a criação.
    const defs = await defsDoPadraoDaOrg(supabase, ctx.organization_id, "contato");
    const recusas = recusasDePadrao(defs, input.custom_fields, testaComPrazo);
    if (recusas.length > 0) throw erroDePadrao(recusas, ctx.requestId, ctx.idioma);
  }

  const a = actorAuditPayload(ctx.actor);
  const insertRow: Record<string, unknown> = {
    organization_id: ctx.organization_id,
    created_by_user_id: ctx.actor.type === "user" ? ctx.actor.id : null,
    name: input.name ?? null,
    display_name: input.display_name ?? null,
    zip_code: input.zip_code ?? null,
    street: input.street ?? null,
    number: input.number ?? null,
    complement: input.complement ?? null,
    district: input.district ?? null,
    city: input.city ?? null,
    state: input.state ?? null,
    email: input.email ?? null,
    phone_number: input.phone_number ? canonicalPhoneBR(input.phone_number) : null,
    birthdate: input.birthdate ?? null,
    // Só quando INFORMADO: sem a chave, quem grava o default é o banco
    // ('cliente', migration 0763) — e é isso que o invariante de coluna prova
    // ("contato novo nasce cliente sem ninguém informar").
    ...(input.contact_type !== undefined ? { contact_type: input.contact_type } : {}),
    tags: input.tags ?? [],
    source: input.source,
    source_metadata: input.source_metadata ?? {},
    custom_fields: input.custom_fields ?? {},
    consent: input.consent ?? {},
  };

  if (input.cpf) {
    // #2522: os DOIS campos ou NENHUM — o CHECK `contacts_cpf_consistency`
    // recusa a linha inteira se só o hash for gravado. Sem cifra disponível
    // (RPC ausente ou chave não semeada) o contato nasce SEM CPF.
    Object.assign(insertRow, await camposCpfParaGravar(supabase, input.cpf));
  }

  const { data: created, error: insErr } = linkedLeadId
    ? await supabase.rpc("fn_create_contact_for_lead", {
        p_organization_id: ctx.organization_id,
        p_lead_id: linkedLeadId,
        p_contact: insertRow,
      })
    : await supabase.from("contacts").insert(insertRow).select(SELECT_COLS).single();

  if (insErr) {
    if (insErr.code === "PT404") throw new ApiError(404, "not_found", undefined, ctx.requestId);
    if (insErr.code === "PT409")
      throw new ApiError(
        409,
        "conflict",
        undefined,
        ctx.requestId,
        traduzir("Este negócio já tem contato vinculado. Atualize a ficha.", ctx.idioma ?? "pt-BR"),
      );
    if (insErr.code === "42501") throw new ApiError(403, "forbidden", undefined, ctx.requestId);
    // 409 quando o dado já pertence a um contato vivo desta organização (#3084).
    //
    // O índice parcial `uniq_contacts_org_phone` (organization_id, phone_number)
    // barra o telefone repetido com 23505; o CPF (`uniq_contacts_org_cpf`) e o
    // e-mail (`uniq_contacts_org_email`) têm a mesma trava. O telefone decide
    // pela RELEITURA (não pelo texto do erro); CPF e e-mail usam o nome do
    // índice que o driver nomeia. A releitura repete o filtro por
    // `organization_id` — o id devolvido no `details` nunca vem do corpo da
    // requisição — e qualquer outro conflito continua no 500 de sempre.
    const conflito = await erroDeConflitoUnico(supabase, ctx, insertRow, insErr);
    if (conflito) throw conflito;
    throw new ApiError(500, "internal_error", undefined, ctx.requestId, insErr.message);
  }

  // A função do negócio já devolve a ficha, com `cpf_available` e sem coluna de CPF.
  const contact = linkedLeadId ? (created as Contact) : fichaDaResposta(created as LinhaDeContato);
  // ── A CONVERSA DO CONTATO NOVO (#3718) ────────────────────────────────────
  //
  // A regra — esta porta abre, a importação por CSV não — mora num lugar só:
  // `lib/contacts/conversa-ao-criar.ts`. Aqui só se diz QUAL porta está
  // criando. A chamada é best-effort e não lança: o contato já está gravado, e
  // uma sessão torta não pode derrubar o cadastro. O que ela NÃO faz mais é
  // falhar calada — o erro sai em `logger.warn` lá dentro, com org, contato e
  // porta.
  // Criar pelo negócio é a MESMA ficha manual, uma por vez: a regra de abrir
  // conversa é a da porta `manual` (`lib/contacts/conversa-ao-criar.ts`), e o
  // `contact.created` sai igual ao das outras portas que criam contato.
  //
  // Cliente de SERVIÇO, não o recebido: `fn_service_begin` só é concedido a
  // `service_role`, e com o cliente do cookie a RPC voltava 42501 — o contato
  // gravava e a conversa nunca abria. A organização já veio de fonte confiável
  // (sessão ou token) em `ctx`, e a escolha da sessão e a RPC filtram por ela.
  const conversa = await abrirConversaDoContatoNovo(createAdminClient(), {
    porta: opcoes.porta ?? "manual",
    organizationId: ctx.organization_id,
    contactId: contact.id,
    phoneNumber: contact.phone_number,
    requestId: ctx.requestId,
  });

  // ── QUEM CRIOU O CONTATO ATENDE O CONTATO (#3479) ─────────────────────────
  //
  // No "Só os seus" a conversa sem dono é invisível ao Atendente (0035), e a
  // que o cadastro acabou de abrir nascia de ninguém. Aqui ela nasce dele —
  // pelo MESMO claim do botão Assumir, com CAS de "só se estiver livre". A
  // decisão e o efeito moram em `lib/contacts/conversa-do-criador.ts`; esta
  // chamada é best-effort e não lança (o contato já está gravado).
  if (conversa.aberta) {
    await atribuirConversaDoCadastroAoCriador(supabase, {
      organizationId: ctx.organization_id,
      conversationId: conversa.conversationId,
      actor: ctx.actor,
      requestId: ctx.requestId,
    });
  }

  await supabase
    .rpc("emit_event", {
      p_event_type: "contact.created",
      p_entity_kind: "contact",
      p_entity_id: contact.id,
      p_payload: {
        source: contact.source,
        has_email: !!contact.email,
        has_phone: !!contact.phone_number,
        has_cpf: contact.cpf_available,
      },
      p_metadata: { request_id: ctx.requestId, ...a.metadataActor },
      p_organization_id: contact.organization_id,
    })
    .then(({ error }) => {
      if (error) console.error("[contacts.create] emit_event failed", error.message);
    });

  await audit({
    action: "contact.created",
    actorUserId: a.actorUserId,
    organizationId: contact.organization_id,
    resourceType: "contact",
    resourceId: contact.id,
    requestId: ctx.requestId,
    metadata: { ...a.metadataActor, source: contact.source },
  });

  if (linkedLeadId) {
    // O vínculo entra na timeline do negócio como o PATCH de `contact_id` já
    // faz (`leads/_handler.updateLeadHandler`): nome do campo, nunca o valor.
    const atividade = await emitLeadActivity(supabase, {
      organizationId: ctx.organization_id,
      leadId: linkedLeadId,
      contactId: contact.id,
      type: "lead_edited",
      sourceModule: "crm",
      sourceId: linkedLeadId,
      actor: ctx.actor,
      reason: `Alterou ${listaLegivel(["contact_id"])}`,
      payload: { fields: ["contact_id"] },
    });
    if (!atividade.ok) {
      await registraFalhaDeAtividade(supabase, {
        organizationId: ctx.organization_id,
        leadId: linkedLeadId,
        tipo: "lead_edited",
        origem: "contacts/_handler.createContactHandler",
        erro: atividade.error,
        requestId: ctx.requestId,
      });
    }
    await audit({
      action: "lead.updated",
      actorUserId: a.actorUserId,
      organizationId: ctx.organization_id,
      resourceType: "lead",
      resourceId: linkedLeadId,
      requestId: ctx.requestId,
      metadata: { ...a.metadataActor, from: { contact_id: null }, to: { contact_id: contact.id } },
    });
  }
  return { contact, action: "created" };
}

// ---------------------------------------------------------------------------
// patch
// ---------------------------------------------------------------------------

export async function patchContactHandler(
  supabase: SB,
  ctx: HandlerCtx,
  contactId: string,
  // `null` em `phone_number` é o pedido de apagar o principal (Spec 31, B2);
  // o schema da rota é o único que o deixa passar (`contactPatchSchemaDoPais`).
  input: Omit<ContactPatch, "phone_number"> & { phone_number?: string | null },
): Promise<Contact> {
  const { data: existing, error: selErr } = await supabase
    .from("contacts")
    // ⚠️ Os campos sensíveis entram aqui para existir o **`from`** da regra L-06
    // ("audit com who/what/which/when/**from/to**", exceção: "Nenhuma"). Antes
    // este select pedia só `id, organization_id, is_anonymized, tags`, e o audit
    // gravava apenas os NOMES dos campos alterados — quem quisesse saber qual
    // e-mail foi substituído não tinha onde olhar. `consent` vem junto porque o
    // patch dele passou a ser MERGE (ver abaixo), e merge precisa do estado
    // anterior.
    .select(
      "id, organization_id, is_anonymized, tags, email, phone_number, name, display_name, consent, custom_fields",
    )
    .eq("organization_id", ctx.organization_id)
    .eq("id", contactId)
    .maybeSingle();

  if (selErr) {
    throw new ApiError(500, "internal_error", undefined, ctx.requestId, selErr.message);
  }
  if (!existing) {
    throw new ApiError(
      404,
      "not_found",
      undefined,
      ctx.requestId,
      traduzir("Contato não encontrado.", ctx.idioma ?? "pt-BR"),
    );
  }
  if (existing.is_anonymized) {
    throw new ApiError(
      403,
      "lgpd_anonymization_irreversible",
      undefined,
      ctx.requestId,
      traduzir("Contato anonimizado — edição bloqueada (LGPD).", ctx.idioma ?? "pt-BR"),
    );
  }

  // ── O PADRÃO (#2730) — DEPOIS do select, e só sobre o que MUDOU ──────────
  //
  // A MESMA régua da criação, com a MESMA leitura (todos os funis, escopo
  // `contato`: o contato não tem funil próprio), mas agora JULGANDO O DIFF.
  //
  // Antes isto rodava no começo e julgava o `custom_fields` INTEIRO, na
  // suposição de que "PATCH manda só o que mudou" — suposição falsa para este
  // mapa: o `EditContactDialog` grava o objeto completo (o PATCH SUBSTITUI),
  // e a edição pela lista de contatos manda o mapa que nem renderizou. Efeito
  // medido no #2814: a pessoa troca só o nome e recebe 422 por um campo
  // escondido (`mostrar_quando`) — ou por um campo de outro funil — que ela
  // nem tocou. O estado anterior está aqui do lado (`existing.custom_fields`),
  // então o que se julga é o valor que vai ser DIFERENTE do gravado.
  if (input.custom_fields && Object.keys(input.custom_fields).length > 0) {
    const alterados = camposQueMudaram(
      input.custom_fields,
      (existing as { custom_fields?: unknown }).custom_fields,
    );
    if (Object.keys(alterados).length > 0) {
      const defs = await defsDoPadraoDaOrg(supabase, ctx.organization_id, "contato");
      const recusas = recusasDePadrao(defs, alterados, testaComPrazo);
      if (recusas.length > 0) throw erroDePadrao(recusas, ctx.requestId, ctx.idioma);
    }
  }

  const patch: Record<string, unknown> = {};
  if (input.name !== undefined) patch.name = input.name;
  if (input.display_name !== undefined) patch.display_name = input.display_name;
  if (input.zip_code !== undefined) patch.zip_code = input.zip_code;
  if (input.street !== undefined) patch.street = input.street;
  if (input.number !== undefined) patch.number = input.number;
  if (input.complement !== undefined) patch.complement = input.complement;
  if (input.district !== undefined) patch.district = input.district;
  if (input.city !== undefined) patch.city = input.city;
  if (input.state !== undefined) patch.state = input.state;
  // `email_normalized` NÃO entra no patch — é `GENERATED ALWAYS AS
  // (lower(trim(email))) STORED` (baseline.sql:1349), e o Postgres RECUSA
  // qualquer atribuição a coluna gerada (SQLSTATE 428C9), abortando o UPDATE
  // inteiro. Efeito medido: salvar o email de um contato pela tela devolvia 500,
  // e junto morriam todos os outros campos do mesmo PATCH.
  //
  // O banco deriva a coluna sozinho — era só não escrever nela.
  if (input.email !== undefined) patch.email = input.email;
  if (input.phone_number !== undefined) {
    patch.phone_number = input.phone_number
      ? canonicalPhoneBR(input.phone_number)
      : input.phone_number;
  }
  // Tipo e WhatsApp do principal (Spec 31, B2). Ausentes não entram no patch: o
  // gatilho do banco recalcula só o que a pessoa não fixou (ver a migration 9043).
  if (input.phone_type !== undefined) patch.phone_type = input.phone_type;
  if (input.phone_whatsapp !== undefined) patch.phone_whatsapp = input.phone_whatsapp;
  if (input.birthdate !== undefined) patch.birthdate = input.birthdate;
  // O TIPO DO CONTATO (issue 3005): o PATCH troca cliente ↔ fornecedor. Ausente
  // NÃO entra no patch — quem não fala de tipo não reescreve a coluna, e é por
  // isso que o schema não tem default aqui (o default 'cliente' é do BANCO).
  if (input.contact_type !== undefined) patch.contact_type = input.contact_type;
  if (input.tags !== undefined) patch.tags = input.tags;
  if (input.source !== undefined) patch.source = input.source;
  if (input.source_metadata !== undefined) patch.source_metadata = input.source_metadata;
  // SUBSTITUIÇÃO, não merge — ao contrário de `consent`. O editor da tela manda
  // o objeto inteiro que ele renderizou a partir do schema do funil; um merge
  // aqui tornaria IMPOSSÍVEL apagar um campo pela tela, porque a chave removida
  // voltaria do estado anterior a cada gravação.
  if (input.custom_fields !== undefined) patch.custom_fields = input.custom_fields;
  if (input.consent !== undefined) {
    // MERGE por finalidade, nunca substituição.
    //
    // `contacts.consent` é um mapa de finalidades — a regra L-05 nomeia
    // `marketing`, `transactional` e `profiling`. Atribuir o objeto inteiro
    // (o que esta linha fazia) significa que gravar UMA finalidade APAGA as
    // outras duas: registrar o consentimento transacional de alguém apagaria em
    // silêncio o consentimento de marketing que essa pessoa tinha dado.
    //
    // Perda de consentimento não é bug barulhento — é a base legal de um envio
    // futuro sumindo sem ninguém ver.
    const anterior = ((existing as { consent?: Record<string, unknown> }).consent ?? {}) as Record<
      string,
      unknown
    >;
    patch.consent = { ...anterior, ...input.consent };
  }
  if (input.cpf !== undefined) {
    // Mesma regra do create (#2522): os DOIS campos ou NENHUM. Quando a cifra
    // falha aqui, o par ANTIGO permanece intacto (hash e texto continuam do
    // mesmo CPF) — apagar só um dos dois seria a corrupção silenciosa que o
    // CHECK existe para impedir.
    Object.assign(patch, await camposCpfParaGravar(supabase, input.cpf));
  }

  if (Object.keys(patch).length === 0) {
    throw new ApiError(
      400,
      "invalid_request",
      undefined,
      ctx.requestId,
      traduzir("Nenhum campo para atualizar.", ctx.idioma ?? "pt-BR"),
    );
  }

  // Apagar o principal com telefone extra deixaria os extras sem número de
  // resposta ("Tornar principal" é o caminho). Recusa ANTES do UPDATE: nada é
  // gravado (Spec 31, B2, item 6). Leitura pela sessão, com o filtro de organização.
  if (input.phone_number === null && (existing as { phone_number?: string | null }).phone_number) {
    const { data: extras, error: errExtras } = await supabase
      .from("contact_phones")
      .select("id")
      .eq("organization_id", ctx.organization_id)
      .eq("contact_id", contactId)
      .limit(1);
    if (errExtras) {
      throw new ApiError(500, "internal_error", undefined, ctx.requestId, errExtras.message);
    }
    if (extras && extras.length > 0) {
      throw new ApiError(
        409,
        "primary_phone_has_extras",
        undefined,
        ctx.requestId,
        traduzir("Torne outro número principal antes de remover este.", ctx.idioma ?? "pt-BR"),
      );
    }
  }

  const tagServiceOrigin = input.tags !== undefined
    ? await observeServiceOrigin(createAdminClient(), ctx.organization_id, contactId)
    : null;
  patch.updated_at = new Date().toISOString();

  const { data: updated, error: updErr } = await supabase
    .from("contacts")
    .update(patch)
    .eq("organization_id", ctx.organization_id)
    .eq("id", contactId)
    .select(SELECT_COLS)
    .maybeSingle();

  if (updErr) {
    // #3084: o PATCH grava pela MESMA tabela e cai nas MESMAS travas únicas —
    // `uniq_contacts_org_email` é o caso medido na issue (trocar o e-mail por um
    // que já é de outro contato). O 409 sai com o `contact_id` de quem já tem o
    // dado; qualquer outro conflito segue no 500 de sempre.
    const conflito = await erroDeConflitoUnico(supabase, ctx, patch, updErr, contactId);
    if (conflito) throw conflito;
    throw new ApiError(500, "internal_error", undefined, ctx.requestId, updErr.message);
  }
  if (!updated) {
    throw new ApiError(
      404,
      "not_found",
      undefined,
      ctx.requestId,
      traduzir("Contato não encontrado após update.", ctx.idioma ?? "pt-BR"),
    );
  }

  const contact = fichaDaResposta(updated as LinhaDeContato);
  const a = actorAuditPayload(ctx.actor);
  const fields = Object.keys(patch).filter((k) => k !== "updated_at");

  /**
   * O par ANTES/DEPOIS dos campos que a L-06 nomeia.
   *
   * Grafia `old_`/`new_` seguindo o precedente de `team.role_changed`
   * (app/api/v1/team/[user_id]/_shared.ts:93-94), que tem teste-guarda. O repo
   * tem mais de uma grafia para este conceito; escolher a que já é vigiada evita
   * criar a quinta.
   *
   * Só os campos SENSÍVEIS e só quando mudaram: o audit é lido por humano e
   * despejar o objeto inteiro afogaria o que importa. `consent` entra como
   * lista de finalidades tocadas, não como jsonb cru — o valor é um mapa e o
   * que se audita é qual finalidade mudou.
   */
  const antes = existing as Record<string, unknown>;
  const sensiveis: Record<string, unknown> = {};
  for (const campo of ["email", "phone_number", "name", "display_name"]) {
    if (patch[campo] !== undefined && patch[campo] !== antes[campo]) {
      sensiveis[`old_${campo}`] = antes[campo] ?? null;
      sensiveis[`new_${campo}`] = patch[campo] ?? null;
    }
  }
  if (input.consent !== undefined) {
    sensiveis.consent_scopes = Object.keys(input.consent);
  }

  await supabase
    .rpc("emit_event", {
      p_event_type: "contact.updated",
      p_entity_kind: "contact",
      p_entity_id: contact.id,
      p_payload: { fields },
      p_metadata: { request_id: ctx.requestId, ...a.metadataActor },
      p_organization_id: contact.organization_id,
    })
    .then(({ error }) => {
      if (error) console.error("[contacts.patch] emit_event failed", error.message);
    });

  if (input.tags !== undefined) {
    const prevTags: string[] = (existing as { tags?: string[] }).tags ?? [];
    const addedTags = input.tags.filter((t) => !prevTags.includes(t));
    if (addedTags.length) {
      await createAdminClient()
        .rpc("emit_event", {
          p_event_type: "contact.tag_added",
          p_entity_kind: "contact",
          p_entity_id: contact.id,
          p_payload: { added_tags: addedTags, tags: input.tags, service_origin: tagServiceOrigin },
          p_metadata: { request_id: ctx.requestId, ...a.metadataActor },
          p_organization_id: contact.organization_id,
        })
        .then(({ error }) => {
          if (error) console.error("[contacts.patch] emit_event failed", error.message);
        });
    }
  }

  /**
   * #2812 (segunda metade) — O CONTATO GANHOU TELEFONE.
   *
   * O gatilho `contact.phone_added` é a transição que faltava no vocabulário de
   * automação: a ficha que estava SEM número passa a ter um. Emitido AQUI, e só
   * aqui, por dois motivos que são a metade do trabalho:
   *
   *  - DEPOIS do UPDATE acima — a regra que este evento dispara lê a ficha para
   *    decidir, e a guarda `checarGuardasDeContato` (lib/automation/guarda-do-contato.ts)
   *    mata com `no_phone` (sem nova tentativa) quem chega sem número. Emitir
   *    antes da gravação entregaria à regra um contato ainda vazio, e o defeito
   *    é MUDO: a regra some sem erro, sem log e sem run.
   *  - SÓ na TRANSIÇÃO (antes vazio/nulo, depois preenchido) — quem já tinha
   *    número não "ganhou" nenhum, e tirar o número não é ganhá-lo. Um evento a
   *    cada edição de ficha encheria a fila de automação com o que não aconteceu.
   *
   * A grafia do par é a MESMA de `contact.tag_added`, logo acima — e o `contact.phone_added`
   * está irmão dele no mapa de gatilhos (lib/schemas/webhooks.ts).
   */
  const telefoneAntes = ((antes as { phone_number?: string | null }).phone_number ?? "").trim();
  const telefoneDepois = ((contact as { phone_number?: string | null }).phone_number ?? "").trim();
  if (!telefoneAntes && telefoneDepois) {
    await createAdminClient()
      .rpc("emit_event", {
        p_event_type: "contact.phone_added",
        p_entity_kind: "contact",
        p_entity_id: contact.id,
        p_payload: { phone_number: telefoneDepois },
        p_metadata: { request_id: ctx.requestId, ...a.metadataActor },
        p_organization_id: contact.organization_id,
      })
      .then(({ error }) => {
        if (error) console.error("[contacts.patch] emit_event failed", error.message);
      });
  }

  await audit({
    action: "contact.updated",
    actorUserId: a.actorUserId,
    organizationId: contact.organization_id,
    resourceType: "contact",
    resourceId: contact.id,
    requestId: ctx.requestId,
    metadata: { ...a.metadataActor, fields, ...sensiveis },
  });

  return contact;
}

// ---------------------------------------------------------------------------
// delete
// ---------------------------------------------------------------------------

function throwOnDbError(
  err: { code?: string; message: string } | null,
  requestId: string,
  idioma: Idioma = "pt-BR",
): void {
  if (!err) return;
  // conversations/messages apontam para contacts com ON DELETE RESTRICT.
  if (err.code === "23503") {
    throw new ApiError(
      409,
      "state_conflict",
      undefined,
      requestId,
      traduzir("Não foi possível excluir: o contato ainda tem registros vinculados.", idioma),
    );
  }
  throw new ApiError(500, "internal_error", undefined, requestId, err.message);
}

/**
 * Os vínculos `on delete restrict` que apontam para `contacts` e que este
 * handler NÃO apaga de propósito.
 *
 * Medido em `supabase/baseline.sql` (não inferido): além de
 * `conversations.contact_id` (linha 3531) e `messages.contact_id` (3781) — o
 * histórico, que sai logo abaixo porque é o pedido da exclusão —, a única FK
 * RESTRICT que sobra é `calendar_appointments.contact_id` (linha 15226, da
 * migração 0177). Ela é da agenda, e a decisão da 0177 foi explícita: o
 * compromisso aconteceu, tem dono e tem texto livre, então ele não cai por
 * cascata junto com a ficha.
 *
 * Conferir esta lista ANTES do primeiro DELETE é o arranjo que a issue #752
 * pede: sem ele, apagar `messages`/`conversations` e só então esbarrar no
 * RESTRICT é destruir o histórico do lead para devolver 409 — o pior dos dois
 * desfechos, porque a ficha continua lá e as mensagens não.
 *
 * Tabela nova com RESTRICT para `contacts` entra aqui como uma linha, e o
 * teste que acompanha este handler (`tests/unit/contato-delete.test.ts`)
 * exercita a contagem com filtro de organização.
 */
const VINCULOS_RESTRICT_NAO_APAGADOS: ReadonlyArray<{ tabela: string; rotulo: string }> = [
  { tabela: "calendar_appointments", rotulo: "compromisso(s) na agenda" },
];

/**
 * A ficha precisa existir DENTRO da organização de quem chama — o mesmo 404 da
 * exclusão. É a porta também da pré-checagem de vínculos (#1925): sem o filtro
 * por organização, um id alheio responderia "sem vínculos" e a tela diria que é
 * seguro excluir algo que quem chamou nem enxerga.
 */
async function contatoExistente(supabase: SB, ctx: HandlerCtx, contactId: string): Promise<void> {
  const { data: existing, error: selErr } = await supabase
    .from("contacts")
    .select("id, organization_id")
    .eq("id", contactId)
    .eq("organization_id", ctx.organization_id)
    .maybeSingle();

  if (selErr) {
    throw new ApiError(500, "internal_error", undefined, ctx.requestId, selErr.message);
  }
  if (!existing) {
    throw new ApiError(
      404,
      "not_found",
      undefined,
      ctx.requestId,
      traduzir("Contato não encontrado.", ctx.idioma ?? "pt-BR"),
    );
  }
}

/**
 * Pré-checagem dos vínculos que barram o DELETE da ficha (issue #752), que a
 * pré-checagem da tela e a exclusão compartilham (#1925).
 *
 * Só CONTA: quem recusa continua sendo o banco, com o 23503 do RESTRICT. A
 * contagem existe para saber disso antes de apagar o histórico, e é por isso
 * que ela vem antes da chamada que apaga — depois não há mais como desfazer.
 */
async function contarVinculosRestrict(
  supabase: SB,
  ctx: HandlerCtx,
  contactId: string,
): Promise<{ vinculos: string[]; por_tabela: Record<string, number> }> {
  const vinculos: string[] = [];
  // A contagem crua por tabela é o que a tela traduz e pluraliza; `vinculos`
  // (texto em pt-BR) segue igual ao da auditoria.
  const por_tabela: Record<string, number> = {};
  for (const vinculo of VINCULOS_RESTRICT_NAO_APAGADOS) {
    const { count, error } = await supabase
      .from(vinculo.tabela)
      .select("id", { count: "exact", head: true })
      .eq("contact_id", contactId)
      .eq("organization_id", ctx.organization_id);
    if (error) {
      // Falha da CONTAGEM não autoriza seguir apagando: seguir aqui é cair no
      // defeito que esta pré-checagem existe para impedir (histórico
      // destruído antes de saber se a ficha sai). Um 500 agora é reversível;
      // mensagem apagada não volta.
      throw new ApiError(500, "internal_error", undefined, ctx.requestId, error.message);
    }
    if ((count ?? 0) > 0) {
      vinculos.push(`${count} ${vinculo.rotulo}`);
      por_tabela[vinculo.tabela] = count ?? 0;
    }
  }
  return { vinculos, por_tabela };
}

/**
 * O que a exclusão VAI encontrar, apurado sem apagar nada: a pré-checagem que o
 * diálogo "Excluir contato?" consulta antes do clique (issue #1925).
 *
 * Reusa as duas funções acima de propósito: uma lista própria de vínculos para a
 * tela viraria uma segunda verdade — dia em que uma FK RESTRICT nova entrar em
 * `VINCULOS_RESTRICT_NAO_APAGADOS`, o diálogo avisaria de um bloqueio que não
 * existe e ficaria quieto sobre outro.
 */
export async function vinculosDoContatoHandler(
  supabase: SB,
  ctx: HandlerCtx,
  contactId: string,
): Promise<{ vinculos: string[]; por_tabela: Record<string, number> }> {
  await contatoExistente(supabase, ctx, contactId);
  return contarVinculosRestrict(supabase, ctx, contactId);
}

export async function deleteContactHandler(
  supabase: SB,
  ctx: HandlerCtx,
  contactId: string,
): Promise<{ id: string }> {
  await contatoExistente(supabase, ctx, contactId);

  const a = actorAuditPayload(ctx.actor);

  const { vinculos, por_tabela } = await contarVinculosRestrict(supabase, ctx, contactId);

  if (vinculos.length > 0) {
    // O 409 é o MESMO do caminho de FK (mesma causa, mesmo tratamento no
    // cliente), mas aqui ele chega com `messages`/`conversations` intactos.
    // O detalhe que o cliente não vê está na auditoria: `vinculos` diz o que
    // barrou, `apagados` vazio diz que nada foi tocado.
    await audit({
      action: "contact.delete_blocked",
      actorUserId: a.actorUserId,
      organizationId: ctx.organization_id,
      resourceType: "contact",
      resourceId: contactId,
      requestId: ctx.requestId,
      metadata: { ...a.metadataActor, motivo: "vinculo_restrict", vinculos, apagados: [] },
    });
    throw new ApiError(
      409,
      "state_conflict",
      { vinculos, por_tabela },
      ctx.requestId,
      traduzir(
        "Não foi possível excluir: o contato ainda tem registros vinculados.",
        ctx.idioma ?? "pt-BR",
      ),
    );
  }

  // UMA função, UMA transação (issue #1862).
  //
  // Antes eram três DELETE separados: `messages`, depois `conversations`, depois
  // `contacts`. Quando a última chamada era recusada (o gatilho de follow-up
  // devolvia 42501 em quem tem `auth.uid()`), as duas primeiras já estavam
  // gravadas — a ficha ficava na base SEM histórico, e a auditoria do erro saía
  // com `apagados: ["messages","conversations"]` como rastro do estrago.
  //
  // Agora a rota chama `fn_apagar_contato_com_historico` (migration 0488), que
  // apaga as três na ordem certa do RESTRICT dentro de uma transação só: ou sai
  // tudo, ou não sai nada. É `security invoker` — a RLS de quem chama continua
  // valendo, e o filtro de `organization_id` é do banco (argumento + where), não
  // do chamador. Nenhum service role entra aqui.
  //
  // A exceção, antes da transação: os acessos OAuth do contato a servidores
  // MCP externos (9095). A cascata apaga o token, e sem ele não há revogação no
  // terceiro (RFC 7009) — então revoga primeiro. A tabela é server-side only
  // (service role), filtrada pela organização do ctx. Melhor esforço: falha
  // do terceiro não barra a exclusão.
  await revogarAcessosDoContato(createAdminClient(), ctx.organization_id, contactId).catch(
    (err: unknown) => {
      logger.warn("revogacao dos acessos MCP do contato falhou antes da exclusao", {
        organization_id: ctx.organization_id,
        error: err instanceof Error ? err.message : String(err),
      });
    },
  );
  let apagou = false;
  try {
    const { data, error: rpcErr } = await supabase.rpc("fn_apagar_contato_com_historico", {
      p_contact_id: contactId,
      p_organization_id: ctx.organization_id,
    });
    throwOnDbError(rpcErr, ctx.requestId, ctx.idioma);
    apagou = data === true;
  } catch (err) {
    // `audit()` é best-effort por doutrina (engole a própria falha e reporta),
    // então registrar aqui não pode trocar o desfecho do erro real.
    //
    // `apagados` fica VAZIO de propósito: numa transação só não existe "chegou
    // até a metade". Ou a ficha saiu com o histórico (aqui seria outro desfecho),
    // ou a transação desfez tudo — e é isso que a linha de auditoria diz.
    await audit({
      action: "contact.delete_blocked",
      actorUserId: a.actorUserId,
      organizationId: ctx.organization_id,
      resourceType: "contact",
      resourceId: contactId,
      requestId: ctx.requestId,
      metadata: { ...a.metadataActor, motivo: "falha_ao_apagar", vinculos: [], apagados: [] },
    });
    throw err;
  }

  if (!apagou) {
    // A ficha existia na pré-checagem e sumiu antes da chamada (outra sessão a
    // apagou, ou a RLS não a deixou ver): o mesmo 404 de sempre.
    throw new ApiError(
      404,
      "not_found",
      undefined,
      ctx.requestId,
      traduzir("Contato não encontrado.", ctx.idioma ?? "pt-BR"),
    );
  }

  await supabase
    .rpc("emit_event", {
      p_event_type: "contact.deleted",
      p_entity_kind: "contact",
      p_entity_id: contactId,
      p_payload: {},
      p_metadata: { request_id: ctx.requestId, ...a.metadataActor },
      p_organization_id: ctx.organization_id,
    })
    .then(({ error }) => {
      if (error) console.error("[contacts.delete] emit_event failed", error.message);
    });

  await audit({
    action: "contact.deleted",
    actorUserId: a.actorUserId,
    organizationId: ctx.organization_id,
    resourceType: "contact",
    resourceId: contactId,
    requestId: ctx.requestId,
    metadata: a.metadataActor,
  });

  return { id: contactId };
}
