/**
 * LGPD export collector — aggregates all personal data the CRM holds about
 * one contact (Art. 18 II — direito de acesso).
 *
 * CLAUDE.md §LGPD: every query filters `organization_id` programmatically
 * (admin client bypasses RLS). PII is NEVER logged — only ids and counts.
 */

import { createAdminClient } from "@/lib/supabase/admin";
import { agenteAtende } from "@/lib/ai/agents/no-ar";
import {
  art15DoControlador,
  type Art15DoControlador,
} from "@/lib/legal/art15";
import {
  citacaoDaLei,
  PAIS_PADRAO,
  perfilDoPais,
  type AutoridadeDeSupervisao,
  type PerfilDoPais,
} from "@/lib/legal/perfil-do-pais";
import { logger } from "@/lib/logger";
import { textoLegivelDeMencao } from "@/lib/notifications/mentions";
import { camposLegiveis, perguntasDosGrafos, type CampoLegivel } from "@/lib/lgpd/campos-personalizados";
import { maskPhone } from "@/lib/lgpd/mask";
import { phoneLookupVariants } from "@/lib/channels/phone-variants";
import type { Json } from "@/lib/database.types";

// ---------------------------------------------------------------------------
// Public types
// ---------------------------------------------------------------------------

export interface ContactSnapshot {
  id: string;
  name: string | null;
  display_name: string | null;
  /**
   * O nome que a equipe salvou na agenda do celular (PR #2439). Só a equipe o
   * vê na tela, mas é dado pessoal do titular: a anonimização o apaga
   * (`trg_contato_anonimizado_esquece_a_agenda`), então o `data.json` o leva.
   * Quem RECEBE o `data.json` é o titular fora do Brasil: o worker só assina a
   * ligação dele quando o país não é o padrão (`lgpd-export-worker.ts`). No
   * Brasil o arquivo fica guardado sem ser enviado, e o PDF não lê este campo.
   */
  address_book_name: string | null;
  /**
   * Endereço da pessoa física (issue #3494). A anonimização o apaga
   * (`trg_contato_anonimizado_esquece_o_endereco`), então o titular o recebe.
   */
  zip_code: string | null;
  street: string | null;
  number: string | null;
  complement: string | null;
  district: string | null;
  city: string | null;
  state: string | null;
  email: string | null;
  phone_number: string | null;
  cpf_present: boolean;
  birthdate: string | null;
  is_blocked: boolean;
  is_anonymized: boolean;
  consent: Record<string, unknown> | null;
  tags: string[];
  source: string | null;
  source_metadata: Record<string, unknown> | null;
  created_at: string;
  last_activity_at: string | null;
  /** Primeiro atendimento marcado. Sobrevive à anonimização: é registro de operação. */
  first_service_at: string | null;
  /**
   * Os telefones EXTRAS da ficha (Spec 31, B1), com o `lid` do WhatsApp de cada
   * número. Ausente quando a ficha não tem extra: o `data.json` dela sai como saía
   * antes (a chave só aparece quando há o que entregar).
   */
  additional_phones?: ContactPhoneSnapshot[];
  /**
   * Campos personalizados — onde os roteiros de atendimento gravam o que o
   * cliente respondeu (CPF inclusive). A anonimização já os zera; sem esta
   * linha o titular pedia acesso e não recebia o que o roteiro coletou.
   */
  custom_fields: Record<string, unknown>;
  /** Para o PDF: rótulo da pergunta + valor, sem o CPF (ver `campos-personalizados.ts`). */
  campos_legiveis: CampoLegivel[];
  /** Um roteiro guardou o CPF nos campos (texto, não a coluna cifrada). */
  cpf_informado_na_conversa: boolean;
}

/** Um telefone extra da ficha, como o titular o recebe (Spec 31, B1). */
export interface ContactPhoneSnapshot {
  phone_number: string;
  tipo: string;
  whatsapp: string;
  rotulo: string | null;
  origem: string;
  /** O `lid` do WhatsApp DAQUELE número: é o que casa a mensagem de grupo dele. */
  wa_lid: string | null;
  created_at: string;
}

export interface ConsentRow {
  scope: string;
  granted: boolean;
  granted_at: string | null;
  source?: string | null;
}

export interface ConversationRow {
  id: string;
  status: string;
  channel: string;
  last_inbound_at: string | null;
  last_message_at: string | null;
  is_group: boolean;
  created_at: string;
}

export interface MessageRow {
  id: string;
  conversation_id: string;
  direction: string;
  type: string;
  status: string;
  body: string | null;
  has_media: boolean;
  /**
   * Transcrição do áudio / texto extraído da mídia (OCR de imagem) que a IA
   * leu (migration 0497). A anonimização o APAGA quando o titular pede
   * eliminação (#1989/#1990); o Art. 18 II exige o oposto — quem pede os
   * próprios dados recebe o texto que a organização leu da mídia dele. O
   * binário nunca vai no pacote (só `has_media`); sem esta coluna o export não
   * trazia nem o texto que a IA efetivamente processou.
   */
  media_derived_text: string | null;
  sent_at: string | null;
  created_at: string;
}

export interface LeadRow {
  id: string;
  pipeline_id: string;
  stage_id: string;
  title: string | null;
  status: string;
  value_cents: number | null;
  currency: string | null;
  created_at: string;
}

export interface OrderRow {
  id: string;
  external_id: string | null;
  external_provider: string | null;
  status: string;
  total_cents: number | null;
  currency: string | null;
  ordered_at: string | null;
}

export interface ActivityRow {
  id: string;
  lead_id: string | null;
  type: string;
  source_module: string | null;
  performed_at: string;
}

/**
 * A proposta de campo lida num documento do negócio (`lead_field_proposals`,
 * migration 0772).
 *
 * Entra porque a anonimização a REDIGE: `trecho`, `valor_proposto` e
 * `valor_anterior` são conteúdo do documento de quem pediu o acesso. Sem esta
 * coleta, o Art. 18 II devolveria um export sem exatamente o dado que a casa
 * apaga — e a régua de `lgpd-exporta-o-que-redige` reprova por isso.
 */
export interface LeadFieldProposalRow {
  id: string;
  lead_id: string;
  campo: string;
  valor_proposto: unknown;
  valor_anterior: unknown;
  trecho: string | null;
  attachment_id: string | null;
  status: string;
  proposed_at: string;
  expires_at: string;
  decided_at: string | null;
}

/**
 * O resumo que o agente guarda sobre o titular (`lead_checkpoints`).
 *
 * Entra porque a anonimização o REDIGE (migration 0391): o resumo corrido, os
 * compromissos e a próxima ação são texto que o modelo escreveu SOBRE a pessoa,
 * e o que se apaga a pedido do titular é o que se entrega a pedido dele.
 */
/**
 * Os acessos que o titular deu a servidores MCP externos (OAuth por contato,
 * migration 9095): os pedidos de autorização (apagados na anonimização) e as
 * credenciais. Entra o QUE ele autorizou, a quem e quando — nunca o token, o
 * `code_verifier` nem o `state`, que são segredo e não dado dele.
 */
export interface AcessoMcpRow {
  servidor_id: string;
  escopos: string[];
  criado_em: string | null;
  expira_em?: string | null;
  usado_em?: string | null;
  ultimo_uso_em?: string | null;
  revogado_em?: string | null;
}

export interface CheckpointRow {
  id: string;
  rolling_summary: string;
  commitments: unknown;
  objections: unknown;
  next_action: string | null;
  created_at: string;
}

/**
 * Vínculo do titular com um grupo de WhatsApp (migration 0482).
 *
 * A FK `channel_session_groups.contact_id` só aponta para o CONTATO PLACEHOLDER
 * do grupo (`contacts.kind = 'whatsapp_group'`), nunca para uma pessoa real — é
 * por isso que a redação (`fn_lgpd_cascade_redact_contact`) nulifica `subject`
 * comentando explicitamente que "nulificar não perde nada operacional: número,
 * conversa e liga/desliga ficam". Este bloco espelha a mesma chave
 * (`contact_id = p_contact_id`): quando o titular do pedido É o placeholder do
 * grupo, o Art. 18 II entrega o mesmo `subject` que a anonimização apagaria.
 */
export interface ChannelSessionGroupRow {
  id: string;
  group_chat_id: string;
  subject: string | null;
  enabled: boolean;
  enabled_at: string | null;
  created_at: string;
}

/**
 * Compromisso da agenda do titular.
 *
 * As colunas são as MESMAS que a migration 0184 redige ao anonimizar — e não é
 * coincidência: o que se apaga a pedido do titular é exatamente o que se
 * entrega a pedido dele. `starts_at`/`ends_at`/`status` a 0184 PRESERVA (é
 * registro de operação da clínica), e ainda assim entram aqui: o Art. 18 II é
 * sobre o que a organização sabe A RESPEITO DELE, e "houve consulta em tal dia"
 * é a informação mais legível que este export carrega.
 */
export interface AppointmentRow {
  id: string;
  title: string | null;
  description: string | null;
  notes: string | null;
  location_details: string | null;
  cancellation_reason: string | null;
  starts_at: string;
  ends_at: string;
  time_zone: string;
  status: string;
  google_base_projection?: Json | null;
  google_conflict?: Json | null;
  google_pending_write?: Json | null;
  meeting_url?: string | null;
  meeting_state?: string;
}

/**
 * O "quero encaixe" do titular na lista de espera (`calendar_waitlist`, módulo
 * opcional). Entra porque a seção de LGPD do módulo apaga `observacoes` na
 * anonimização: a redação e o export são as duas pontas da mesma regra.
 *
 * `janelas`, `antecedencia_minima_horas`, `urgencia` e `status` a redação
 * PRESERVA (parâmetro de operação, não identificação) e ainda assim entram: "eu
 * pedi encaixe para tal semana, com tal urgência, e fui atendido" é informação a
 * respeito dele. `owner_user_id` e `created_by_user_id` ficam de fora: são a
 * equipe, não ele.
 */
export interface WaitlistRow {
  id: string;
  event_type_id: string;
  status: string;
  urgencia: number;
  janelas: Json;
  antecedencia_minima_horas: number;
  valido_ate: string | null;
  consentimento_em: string;
  consentimento_origem: string;
  oferta_appointment_id: string | null;
  oferta_expira_em: string | null;
  ofertada_em: string | null;
  observacoes: string | null;
  created_by_kind: string;
  created_at: string;
  updated_at: string;
}

/**
 * A comanda do titular (migrations 0350-0359).
 *
 * Entra porque a anonimização APAGA: a 0359 pôs `sales` na cascata de redação
 * (`notes`, `cancel_reason`, `reverse_reason`), e neste repo redigir e exportar
 * andam juntos. Valor, forma de pagamento e datas a cascata PRESERVA — é
 * registro financeiro da organização —, e ainda assim entram aqui pela mesma
 * razão que `starts_at` da agenda entra: "gastei tanto, em tal dia, pago
 * assim" é informação a respeito dele, e é a mais legível deste bloco.
 *
 * Os ITENS não entram: `sale_items.description` é o nome do serviço, não dado
 * de pessoa, e a cascata não o toca — as duas pontas continuam espelhadas.
 */
export interface SaleRow {
  id: string;
  number: number;
  status: string;
  total_cents: number;
  currency: string;
  notes: string | null;
  cancel_reason: string | null;
  reverse_reason: string | null;
  finalized_at: string | null;
  created_at: string;
}

/**
 * Proposta comercial SOBRE a pessoa.
 *
 * A migration 0477 liga `destinatario_nome`, `briefing_json` e
 * `resumo_comercial` à cascata de anonimização, e este bloco é a outra
 * metade — o que se apaga a pedido do titular é o que se entrega a pedido
 * dele.
 */
export interface ProposalRow {
  id: string;
  numero: number | null;
  ano: number | null;
  titulo: string;
  status: string;
  total_cents: number;
  moeda: string;
  valid_until: string | null;
  sent_at: string | null;
  decided_at: string | null;
  destinatario_nome: string | null;
  resumo_comercial: string | null;
  /**
   * Houve um PDF gerado e enviado. O ARQUIVO não vai no pacote — mesma regra
   * de `has_media` das mensagens: o titular o recebeu no WhatsApp, e a
   * anonimização o expurga do Storage (0477). O caminho interno não sai.
   */
  tem_pdf: boolean;
  created_at: string;
}

/**
 * Tarefa combinada SOBRE a pessoa (migration 0210).
 *
 * ⚠️ ESTE BLOCO NASCEU COM A OUTRA METADE, e não depois dela. A migration liga o
 * trigger `trg_redigir_tarefas_ao_anonimizar`, que troca `title` e apaga
 * `description` quando o titular pede apagamento — e neste repo redigir e
 * exportar sempre andam juntos: o que se apaga a pedido do titular é o que se
 * entrega a pedido dele. Foi assim que `calendar_appointments` e
 * `webhook_lead_captures` chegaram aqui, as duas depois do fato, achadas por
 * `tests/unit/lgpd-exporta-o-que-redige.test.ts`.
 *
 * `due_date`, `status` e `priority` vão junto porque o titular tem direito a
 * saber não só que a empresa escreveu algo sobre ele, mas quando ela combinou
 * agir — que é a informação que dá sentido ao texto.
 */
export interface TaskRow {
  id: string;
  title: string;
  description: string | null;
  due_date: string | null;
  status: string;
  priority: string;
}

/**
 * Captação por webhook — de onde a pessoa veio.
 *
 * ⚠️ ESTA NÃO É DA ENTREGA DO CALENDÁRIO. Ela apareceu porque o gate novo
 * (`tests/unit/lgpd-exporta-o-que-redige.test.ts`) DERIVA a lista das duas
 * pontas em vez de escrevê-la: a mesma classe de defeito tinha duas instâncias,
 * e a segunda ninguém sabia que existia. Uma allowlist fixa teria fechado só a
 * que eu já conhecia.
 *
 * As colunas são exatamente as que `fn_redigir_captacoes_do_contato_anonimizado`
 * zera — inclusive `remote_ip` e `user_agent`, que a LGPD trata como dado
 * pessoal e que a organização guarda a respeito do titular.
 */
export interface CaptureRow {
  id: string;
  source_name: string | null;
  outcome: string;
  captured_name: string | null;
  captured_phone: string | null;
  captured_email: string | null;
  fields: unknown;
  utm: unknown;
  remote_ip: string | null;
  user_agent: string | null;
  received_at: string;
}

/**
 * Contrato de honorários (advocacia) — módulo opcional, ADR-0002 D8: todo
 * módulo com dados declara sua seção de export, mesmo sem estar na cascata de
 * redação (achado da revisão do PR #1578). O vínculo é `lead_id`, não
 * `contact_id` direto — o contrato pertence ao CASO, não à pessoa em geral —
 * por isso deriva dos ids de `leads` já coletados acima, e não de uma consulta
 * própria por contato.
 */
export interface HonorariosContratoRow {
  id: string;
  lead_id: string | null;
  modelo: string;
  valor_fixo_cents: number | null;
  percentual_exito: number | null;
  repasse_advogado_pct: number | null;
  created_at: string;
}

/** O calendário de parcelas do contrato acima — o titular tem direito de ver
 * o que foi combinado e o que já foi pago, do mesmo jeito que vê `sales`. */
export interface HonorariosParcelaRow {
  id: string;
  contrato_id: string;
  numero: number;
  vencimento: string;
  valor_cents: number;
  status: string;
  financial_entry_id: string | null;
}

export interface AuditRow {
  id: string;
  action: string;
  resource_type: string | null;
  resource_id: string | null;
  created_at: string;
}

/** Entrega do link: estado e referência ao compromisso, sem autorização/claim. */
export interface MeetingDeliveryRow {
  id: string;
  status: string;
  created_at: string;
  run_after: string;
  appointment_id: string | null;
}

/** Aviso sobre um compromisso comprovadamente ligado ao titular. */
export interface AppointmentNoticeRow {
  id: string;
  ref_id: string | null;
  title: string;
  body: string | null;
  status: string;
  created_at: string;
  resolved_at: string | null;
}

/**
 * Um caso aberto pela IA sobre o titular — o que ela entendeu quando travou.
 *
 * O vínculo é pela CONVERSA: `agent_cases` não tem FK para `contacts`. `kind`
 * (migration 0248) fica fora da projeção porque `lib/database.types.ts` ainda
 * não o conhece, e selecionar coluna que o tipo não tem é erro de compilação.
 */
export interface CaseRow {
  id: string;
  conversation_id: string;
  status: string;
  title: string;
  summary: string;
  blocker: string;
  source: string;
  opened_at: string;
  closed_at: string | null;
  created_at: string;
}

/** Uma linha do tempo do caso: quem tocou, quando, e o que escreveu. */
export interface CaseEventRow {
  id: string;
  case_id: string;
  kind: string;
  actor_kind: string;
  human_action: string | null;
  body: string | null;
  metadata: unknown;
  created_at: string;
}

/**
 * Uma mensagem da conversa INTERNA da equipe com a IA sobre um caso do titular
 * (migration 0281). O que se apaga a pedido dele é o que se entrega a pedido
 * dele: `body` está na cascata de redação, logo a tabela tem de ser visitada
 * aqui — é o que `tests/unit/lgpd-exporta-o-que-redige.test.ts` cobra.
 *
 * O vínculo é a FK DIRETA `contact_id`, e não a conversa: ela existe nesta
 * tabela exatamente para isso.
 *
 * Coletada, mas FORA do `data.json` que o titular recebe: é conversa interna da
 * equipe (doc 103, A — `lib/lgpd/copia-do-titular.ts`).
 */
export interface CaseChatMessageRow {
  id: string;
  case_id: string;
  turn_id: string;
  author_kind: string;
  body: string | null;
  error_code: string | null;
  created_at: string;
}

/**
 * Uma passagem do atendimento automático para uma pessoa (migration 0291).
 *
 * O que se apaga a pedido do titular é o que se entrega a pedido dele: as quatro
 * colunas de texto estão na cascata de redação, logo a tabela tem de ser
 * visitada aqui — é o que `tests/unit/lgpd-exporta-o-que-redige.test.ts` cobra,
 * derivando as duas pontas da fonte.
 *
 * O vínculo é a FK DIRETA `contact_id`. As colunas de OPERAÇÃO entram junto
 * (`motor`, `origem`, `motivo_codigo`, o par do aviso e o do reconhecimento):
 * o titular tem direito de saber não só o que escreveram sobre ele, mas que a
 * conversa dele foi passada a uma pessoa, por quê, e quando alguém assumiu.
 */
export interface PassagemDeAtendimentoRow {
  id: string;
  conversation_id: string;
  caso_id: string | null;
  motor: string;
  origem: string;
  motivo_codigo: string;
  title: string | null;
  body: string;
  notes: string | null;
  content: string | null;
  tentativas: unknown;
  cliente_avisado: boolean | null;
  aviso_motivo_codigo: string | null;
  criado_em: string;
  reconhecido_em: string | null;
}

/**
 * O registro de que a equipe foi (ou não foi) avisada no WhatsApp sobre um caso
 * do titular — migration 0292.
 *
 * ⚠️ `destino` entra MASCARADO. Ele é o telefone de um FUNCIONÁRIO, não do
 * titular: entregá-lo inteiro num relatório do Art. 18 II trocaria o dado
 * pessoal de uma pessoa pelo de outra. O que o titular tem direito de saber é
 * QUE houve um aviso sobre o atendimento dele, quando, e se chegou.
 *
 * O corpo do aviso não aparece porque ele NÃO É GUARDADO — a tabela tem só o
 * resumo criptográfico, e um hash não reidentifica ninguém.
 */
export interface AvisoDeCasoEntregaRow {
  id: string;
  case_id: string;
  destino_mascarado: string | null;
  status: string;
  erro_codigo: string | null;
  tentativas: number;
  enviado_em: string | null;
  created_at: string;
}

/** Uma demanda do titular — o pedido, seu dono e seu desfecho. */
export interface DemandaRow {
  id: string;
  agent_case_id: string | null;
  origem: string;
  assunto: string | null;
  estado: string;
  dono_kind: string;
  proximo_passo: string | null;
  desfecho: string | null;
  aberta_em: string;
  fechada_em: string | null;
}

/** Uma chamada de voz do titular — o registro, não a gravação (não gravamos). */
export interface VoiceCallRow {
  id: string;
  direction: string;
  peer_phone: string;
  status: string;
  end_reason: string | null;
  started_at: string;
  answered_at: string | null;
  ended_at: string | null;
  duration_ms: number | null;
}

/** Pesquisa e resultado da abordagem ligados ao titular (redação: migration 0370). */
export interface ProspectingCandidateRow {
  id: string;
  campaign_id: string;
  place_id: string;
  phone: string | null;
  data: Json;
  status: string;
  lead_id: string | null;
  conversation_id: string | null;
  attempted_at: string | null;
  error: string | null;
  created_at: string;
  updated_at: string;
}

/** O clique do próprio titular e o resultado do envio à plataforma de anúncios. */
export interface MetaCtwaClickRow {
  ctwa_clid: string;
  captured_at: string;
  status: string;
  attempted_at: string | null;
  created_at: string;
}

/**
 * Uma campanha que falou com este titular (migration 0374).
 *
 * O texto vai junto porque é o que foi DITO a ele; o telefone não, porque ele já
 * está no bloco do contato e repeti-lo só multiplica PII no arquivo entregue.
 */
export interface CampaignRecipientRow {
  id: string;
  campaign_id: string;
  status: string;
  eligibility_status: string;
  exclusion_reason: string | null;
  rendered_body: string | null;
  sent_at: string | null;
  delivered_at: string | null;
  read_at: string | null;
  replied_at: string | null;
  opted_out_at: string | null;
}

/**
 * Uma linha da lista de exclusão de campanhas que aponta para este titular
 * (migration 0375).
 *
 * O hash do telefone NÃO entra: ele não diz nada a quem lê e não é dado que o
 * titular reconheça. O que entra é o fato — "este número está fora das
 * campanhas desde tal dia, por tal motivo" —, que é exatamente a informação
 * dele que a organização guarda.
 */
export interface CampaignSuppressionRow {
  id: string;
  address_tail: string | null;
  reason: string | null;
  source: string;
  created_at: string;
}

/**
 * O que o relatório entrega além do corpo do documento — o art. 15.º, n.º 1
 * alínea a alínea (issue #2340). Os valores VARIÁVEIS moram aqui; o texto fixo
 * da lei (al. e) e a linha do n.º 3 vêm de `lib/legal/art15.ts` no render.
 */
export interface Art15NoDocumento {
  /** a) finalidades do tratamento — preenchido pelo responsável. */
  finalidades: string | null;
  /** c) destinatários ou categorias de destinatários — idem. */
  destinatarios: string | null;
  /** d) prazo de conservação — idem. */
  prazo_conservacao: string | null;
  /** f) a quem reclamar: vem do `autoridadeDeSupervisao` do perfil do país. */
  autoridade: AutoridadeDeSupervisao;
  /**
   * h) decisões automatizadas: o texto que descreve a lógica e a consequência
   * prevista, montado a partir dos agentes de IA NO AR (`agenteAtende`).
   */
  decisoes_automatizadas: string;
}

export interface ExportPayload {
  request_id: string;
  organization_id: string;
  /**
   * Razão social do CONTROLADOR (`organizations.legal_name`, `NOT NULL` em
   * `supabase/baseline.sql:1749`). É o que o rodapé do relatório imprime — e é
   * de propósito que NÃO é a marca: ver `lib/lgpd/pdf-renderer.tsx`.
   */
  organization_legal_name: string;
  /** Nome fantasia. Não vai para o rodapé; existe para o JSON do export. */
  organization_display_name: string;
  /** Encarregado da organização; `null` cai no encarregado da INSTALAÇÃO (0341). */
  dpo_email: string | null;
  /**
   * A lei que o documento de acesso cita, pronta (`LGPD Art. 18, II (Lei nº
   * 13.709/2018)`), ou `null` quando o país da organização ainda não tem
   * citação revisada (issue #1033). `null` NÃO cai para a lei brasileira: o
   * documento responde a um direito legal do titular, e afirmar a lei de outro
   * país é pior do que não citar artigo nenhum — o rodapé diz que não há
   * citação revisada em vez de inventar uma.
   */
  lei_citada: string | null;
  /**
   * Como o documento rotula a citação ("Direito exercido" em Portugal e,
   * desde a issue #2344, no Brasil também). AUSENTE no Brasil — o
   * renderizador lê o rótulo do PERFIL PADRÃO — para o `data.json` brasileiro
   * sair igual byte a byte (doc 88).
   */
  lei_rotulo?: string;
  /**
   * O papel do responsável pelos dados, no vocabulário do país: "Responsável
   * pelo tratamento" em Portugal (RGPD art. 4.º, n.º 7). AUSENTE no Brasil —
   * o renderizador usa o rótulo do PERFIL PADRÃO ("Controlador"), pela mesma
   * régua do `lei_rotulo`: nenhuma chave nova no `data.json` brasileiro.
   */
  papel_controlador?: string;
  /**
   * O papel de quem recebe os pedidos do titular: "Encarregado da proteção de
   * dados" em Portugal (RGPD art. 37.º). Ausente no Brasil, que segue com
   * "Encarregado (DPO)" pelo perfil padrão.
   */
  papel_encarregado?: string;
  /**
   * Fuso IANA da organização, para as datas do documento. Ausente no Brasil,
   * que segue no formato de sempre (`America/Sao_Paulo`, sem nome de fuso).
   */
  fuso?: string;
  /**
   * As alíneas a), c), d), e), f) e h) do art. 15.º, n.º 1, e a declaração da
   * cópia do n.º 3 (issue #2340). AUSENTE no Brasil e em país sem
   * autoridade revisada no perfil — mesma régua do `lei_rotulo`: a lista do
   * RGPD não é a da LGPD, e acrescentar a chave ao `data.json` brasileiro
   * mudaria os fixtures byte a byte do doc 88.
   */
  art15?: Art15NoDocumento;
  /**
   * TODAS as mensagens do titular — o que `messages_recent` (recorte de 100,
   * a amostra do PDF) não entrega. Portugal pela cópia do art. 15.º, n.º 3
   * (#2340); o Brasil pela declaração completa da LGPD (art. 19, II — doc 110,
   * 2A, que solta só aqui a trava "byte a byte" do doc 88).
   */
  messages_completas?: MessageRow[];
  /**
   * As seções deste `data.json` cuja consulta voltou no teto de linhas — pode
   * haver mais registros do que os entregues. Lista vazia = nenhuma bateu no
   * teto. Em todo país, junto de `messages_completas` (doc 110, 2A); fora do
   * Brasil é a ressalva que o PDF cita na linha do n.º 3 (o PDF brasileiro não
   * muda). Entra também a seção cuja PAGINAÇÃO foi interrompida por erro
   * (#2576): o arquivo sai com o que chegou, e a lista é o que avisa o titular
   * de que pode haver mais do que o entregue.
   */
  secoes_no_limite?: string[];
  /** O rótulo do documento do titular no país ("CPF", "Documento"). */
  documento_rotulo: string;
  generated_at: string;
  no_local_footprint: boolean;
  contact: ContactSnapshot | null;
  consents: ConsentRow[];
  conversations: ConversationRow[];
  messages_count_total: number;
  messages_recent: MessageRow[];
  leads: LeadRow[];
  /**
   * Módulo opcional de honorários (advocacia, ADR-0002). Vazio nas instalações
   * que não o instalaram, ou quando o titular não tem contrato — nunca ausente:
   * campo obrigatório é o que faz um caminho de export novo não compilar se
   * esquecer, a mesma razão de `case_chat_messages`.
   */
  honorarios_contratos: HonorariosContratoRow[];
  honorarios_parcelas: HonorariosParcelaRow[];
  /** Módulo opcional; o coletor atual inclui vazio se não instalado. Payloads antigos podem não conter a seção. */
  cadencias_inscricoes?: { id: string; cadence_id: string; lead_id: string; status: string; motivo_parada: string | null; rastreio_consentido: boolean; consentimento_em: string | null; emails_enviados: number; aberturas: number; created_at: string; concluida_em: string | null; parada_em: string | null }[];
  cadencias_eventos?: { id: string; enrollment_id: string; tipo: string; created_at: string }[];
  lead_field_proposals: LeadFieldProposalRow[];
  orders: OrderRow[];
  activities: ActivityRow[];
  checkpoints: CheckpointRow[];
  /** Opcionais: payload gerado antes da 9095 não os tem. */
  mcp_pedidos_de_acesso?: AcessoMcpRow[];
  mcp_acessos?: AcessoMcpRow[];
  appointments: AppointmentRow[];
  /**
   * A inscrição do titular na lista de espera (módulo opcional). Ausente quando
   * vazia — a cópia de quem nunca pediu encaixe não ganha uma chave a mais —,
   * e nunca ausente de propósito: quem tem inscrição precisa receber a linha
   * cujo `observacoes` a anonimização apaga.
   */
  calendar_waitlist?: WaitlistRow[];
  sales: SaleRow[];
  proposals: ProposalRow[];
  tasks: TaskRow[];
  webhook_captures: CaptureRow[];
  audit_log_extract: AuditRow[];
  meeting_deliveries: MeetingDeliveryRow[];
  appointment_notices: AppointmentNoticeRow[];
  /**
   * Chamadas de voz (migration 0232).
   *
   * Entra porque a anonimização APAGA: a 0235 pôs `voice_calls` na cascata de
   * redação, e o que se apaga a pedido do titular é o que se entrega a pedido
   * dele. Sem este bloco o relatório dizia "houve uma atividade de chamada" na
   * linha do tempo e não mostrava chamada nenhuma — export incoerente com o
   * próprio cascade.
   */
  voice_calls: VoiceCallRow[];
  prospecting_candidates: ProspectingCandidateRow[];
  /**
   * O registro por clique é apagado na anonimização (0806), então também entra
   * no acesso. Ausente quando vazio para preservar a cópia anterior de quem
   * não tem cliques; tokens de claim e detalhes internos não são coletados.
   */
  meta_ctwa_click_dispatches?: MetaCtwaClickRow[];
  /**
   * Casos, linha do tempo do caso e demandas (migration 0280).
   *
   * Entram pelo mesmo motivo de `voice_calls`: a 0280 pôs as três na cascata de
   * redação, e o que se apaga a pedido do titular é o que se entrega a pedido
   * dele. Sem os três blocos, o relatório mostrava a conversa e as mensagens e
   * não mencionava que o atendimento tinha parado, o que a IA entendeu do
   * problema dele, nem quem da equipe respondeu — que é a parte em que uma
   * pessoa identificável é DESCRITA por máquina.
   */
  cases: CaseRow[];
  case_events: CaseEventRow[];
  demandas: DemandaRow[];
  /**
   * O que a equipe PERGUNTOU à IA sobre os casos do titular, e o que ela
   * respondeu. Obrigatório, não opcional: campo obrigatório faz um caminho de
   * export novo NÃO COMPILAR se esquecer, que é a única sincronia que não
   * depende de memória humana.
   */
  case_chat_messages: CaseChatMessageRow[];
  /**
   * As passagens do atendimento dele para uma pessoa. Obrigatório, não
   * opcional, pela mesma razão do campo acima: campo obrigatório faz um caminho
   * de export novo NÃO COMPILAR se esquecer, que é a única sincronia que não
   * depende de memória humana.
   */
  passagens: PassagemDeAtendimentoRow[];
  avisos_de_caso: AvisoDeCasoEntregaRow[];
  /**
   * Campanhas que falaram com o titular (migration 0375).
   *
   * Entra pelo mesmo motivo de `voice_calls`: o trigger
   * `trg_redigir_campanhas_anonimizado` APAGA o texto e o telefone destas linhas
   * quando ele pede anonimização, e o que se apaga a pedido dele é o que se
   * entrega a pedido dele (Art. 18 II). Sem este bloco, alguém que recebeu uma
   * prospecção pediria acesso e não veria a mensagem que recebeu.
   */
  campaign_recipients: CampaignRecipientRow[];
  /**
   * Lista de exclusão de campanhas (migration 0375).
   *
   * Entra pelo mesmo motivo das demais: o trigger
   * `trg_redigir_exclusoes_anonimizado` APAGA o vínculo e os últimos dígitos
   * quando o titular pede anonimização, e o que se apaga a pedido dele é o que
   * se entrega a pedido dele (Art. 18 II).
   */
  campaign_suppressions: CampaignSuppressionRow[];
  /**
   * Grupos de WhatsApp vinculados ao titular (migration 0482) — ver o
   * docstring de `ChannelSessionGroupRow`. Obrigatório, não opcional, pela
   * mesma razão de `case_chat_messages`: campo obrigatório faz um caminho de
   * export novo NÃO COMPILAR se esquecer.
   */
  channel_session_groups: ChannelSessionGroupRow[];
  /**
   * Mensagens que o titular escreveu em GRUPOS de WhatsApp (migration 0482).
   * Moram na conversa do placeholder do grupo, não na dele, então
   * `messages_recent` não as vê. Casadas pelo autor em `metadata.group_sender`
   * — telefone (grafias com e sem o nono dígito) ou lid (`contacts.wa_lid`) —,
   * a mesma chave que a anonimização usa em `fn_redigir_conversas_ao_anonimizar`.
   * Só alcança quem JÁ É contato: o participante sem ficha não tem titular.
   */
  group_messages_authored: MessageRow[];
  /** Rascunhos escritos PARA o titular por outro sistema (0419), apagados na
   *  anonimização. Opcional como `reply_drafts`: o tipo é montado à mão nos testes de PDF. */
  conversation_drafts?: Array<{
    id: string;
    conversation_id: string;
    body: string;
    source: string;
    consumed_at: string | null;
    created_at: string;
  }>;
  /**
   * Notas internas das conversas do titular (#1863, F3) — o texto que a equipe
   * escreveu SOBRE ele e a mídia que anexou junto. Sem FK para `contacts` (só
   * para `conversations`), nenhuma outra leitura alcançaria a tabela; é o mesmo
   * motivo de `conversation_drafts`. A migration 0483 redige `body`, zera
   * `media_storage_path`/`media_mime`/`media_size_bytes` e enfileira o arquivo
   * com o bucket `internal-media` quando ele pede anonimização — o que se apaga
   * a pedido dele é o que se entrega a pedido dele (Art. 18 II). A mídia vem
   * como METADADO (caminho, MIME, bytes): o export é `data.json` + `report.pdf`,
   * e nenhum binário trafega por ele.
   *
   * No `data.json` que o titular recebe (`lib/lgpd/copia-do-titular.ts`):
   * fora no Brasil, por ser nota da equipe (doc 103, A); em Portugal vão só
   * `body` e `created_at` — sem o nome de quem escreveu nem o anexo (doc 110, 3A).
   */
  conversation_notes?: Array<{
    id: string;
    conversation_id: string;
    body: string;
    media_storage_path: string | null;
    media_mime: string | null;
    media_size_bytes: number | null;
    created_at: string;
    created_by_name: string | null;
  }>;
  /** Propostas de campo do contato (0123), também APAGADAS na anonimização. */
  contact_field_proposals?: Array<{
    id: string;
    campo: string;
    valor_proposto: string;
    valor_anterior: string | null;
    conversation_id: string | null;
    trecho: string | null;
    status: string;
    proposed_at: string;
    decided_at: string | null;
    motivo_recusa: string | null;
  }>;
  /**
   * Memória da IA sobre o titular (#1957): `lead_notes` guarda `headline` +
   * `body` — o nome e trechos do que a pessoa escreveu. A cascata redige os
   * dois quando ele pede anonimização; o que se apaga a pedido dele é o que se
   * entrega a pedido dele (Art. 18 II). Mesmo escopo da cascata: org + contato.
   */
  /**
   * Anexos dos negócios dele (#3229): o documento que ele mandou (voucher,
   * comprovante) é dado dele, e a cascata o apaga a pedido dele — então sai aqui
   * a pedido dele. Vai o registro (nome, tipo, tamanho, data), não o binário.
   */
  lead_attachments?: Array<{
    id: string;
    lead_id: string;
    field_key: string;
    file_name: string;
    mime_type: string;
    file_size: number;
    created_at: string;
  }>;
  lead_notes?: Array<{
    id: string;
    headline: string | null;
    body: string | null;
    created_at: string | null;
    updated_at: string | null;
  }>;
  /**
   * Registro de execução da IA (#1957): de `ai_agent_runs.tool_calls` (jsonb)
   * saem só o nome e os argumentos de cada ferramenta — nome do titular e
   * trechos do que escreveu. O `result` e o texto do passo ficam de fora
   * (podem trazer dado de OUTROS contatos; ver `toolCallsParaOTitular`).
   * Redigido na cascata; entregue no acesso. Org + contato.
   */
  ai_agent_runs?: Array<{
    id: string;
    tool_calls: unknown;
    created_at: string | null;
  }>;
  /**
   * Estado da lead (#1957): `lead_state.next_action` (texto) e `qualification`
   * (jsonb) descrevem o titular por máquina. Redigidos na cascata; entregues
   * no acesso. Org + contato.
   */
  lead_state?: Array<{
    id: string;
    next_action: string | null;
    qualification: unknown;
    updated_at: string | null;
  }>;
  /**
   * Empresas e pessoas (migrations 0448/0449, metade B2B do #1621): a PESSOA
   * para quem o contato aponta, os vínculos dela com empresas e as linhas de
   * planilha que falaram dela. A 0449 redige as três quando o titular pede
   * anonimização; o que se apaga a pedido dele é o que se entrega a pedido dele
   * (Art. 18 II). Opcional como `reply_drafts`: o tipo é montado à mão nos
   * testes de PDF, e quem vigia o esquecimento é
   * `tests/unit/lgpd-exporta-o-que-redige.test.ts`, que lê o catálogo.
   */
  b2b?: {
    pessoa: {
      id: string;
      full_name: string;
      email: string | null;
      notes: string | null;
      created_at: string;
    } | null;
    vinculos: Array<{
      company_id: string;
      job_title: string | null;
      department: string | null;
      is_decision_maker: boolean;
      notes: string | null;
    }>;
    linhas_importadas: Array<{
      id: string;
      batch_id: string;
      row_number: number;
      status: string;
      raw_data: unknown;
      normalized_data: unknown;
      error: string | null;
      created_at: string;
    }>;
  };
  reply_drafts?: Array<{
    id: string;
    status: string;
    original_body: string | null;
    edited_body: string | null;
    approved_body: string | null;
    proposals: unknown;
    feedback: unknown;
    created_at: string;
  }>;
}

// ---------------------------------------------------------------------------
// collectExportData
// ---------------------------------------------------------------------------

interface CollectArgs {
  organizationId: string;
  requestId: string;
  contactId: string | null;
  externalCustomerId: string | null;
  /**
   * O encarregado de dados da INSTALAÇÃO — o piso do da organização, já
   * RESOLVIDO por quem chama.
   *
   * Injetado, e não lido aqui, porque o coletor de LGPD tem de tocar o mínimo:
   * `tests/invariants/agenda-meet-export.test.ts` exige que a coleta sem
   * identificador visite APENAS `organizations`, e consultar a configuração da
   * instalação acrescentaria uma tabela a toda coleta — inclusive à que não vai
   * usar o valor. Quem chama já é assíncrono e já resolve outras coisas da
   * instalação; resolver mais esta ali não custa visita nenhuma aqui.
   */
  dpoDaInstalacao?: string | null;
  /**
   * O país que quem chama JÁ resolveu (o worker, que manda o e-mail com o mesmo
   * perfil). Quando vem, vale sobre o lido aqui: duas leituras do país divergem
   * no dia em que só uma falhar e cair no Brasil — e o titular receberia o PDF
   * com uma lei e o e-mail com outra. Ausente, o país é o lido com o controlador.
   */
  pais?: string | null;
}

const RECENT_MESSAGES_LIMIT = 100;
/**
 * Página da cópia COMPLETA do art. 15.º, n.º 3 (`messages_completas`). Só o
 * tamanho da requisição: o laço vai até a página vir vazia, então isto não é
 * teto — teto aqui seria a amostra de 100 com outro nome.
 */
const MENSAGENS_POR_PAGINA = 500;
const AUDIT_LIMIT = 200;

/** A identidade JURÍDICA da organização — quem responde pelos dados. */
interface Controlador {
  legal_name: string;
  display_name: string;
  dpo_email: string | null;
  /**
   * O país da organização (issue #1033). Lido JUNTO do controlador, na mesma
   * consulta, porque é dele que saem a lei citada e o rótulo do documento: dois
   * `select` na mesma linha divergem no dia em que um ganhar fallback e o outro
   * não — e aqui a divergência sairia impressa num documento entregue a um
   * titular, afirmando a lei de um país com o rótulo de outro.
   */
  country: string | null;
  /** `organizations.timezone` (NOT NULL no schema); só sai no documento fora do BR. */
  timezone: string | null;
  /**
   * As alíneas a), c) e d) do art. 15.º que o RESPONSÁVEL preencheu em
   * `organizations.settings.art15`. `null` em quem não preencheu — o
   * documento imprime "não informado pelo controlador" (issue #2340).
   */
  art15: Art15DoControlador;
}

/**
 * Lê o controlador. NUNCA lança e nunca inventa: se a leitura falhar, os campos
 * saem vazios e o rodapé mostra o traço de campo ausente. Abortar o export
 * seria trocar um rodapé feio por um SLA legal de D+7 estourado; preencher com
 * o nome do produto seria escrever a entidade errada num documento jurídico.
 */
async function lerControlador(
  admin: ReturnType<typeof createAdminClient>,
  organizationId: string,
  requestId: string,
  dpoDaInstalacao: string | null,
): Promise<Controlador> {
  const vazio: Controlador = {
    legal_name: "",
    display_name: "",
    dpo_email: dpoDaInstalacao,
    country: null,
    timezone: null,
    art15: { finalidades: null, destinatarios: null, prazo_conservacao: null },
  };
  const { data, error } = await admin
    .from("organizations")
    .select("legal_name, display_name, dpo_email, country, timezone, settings")
    .eq("id", organizationId)
    .maybeSingle();
  if (error || !data) {
    logger.warn("[lgpd-export-worker] organization load failed", {
      request_id: requestId,
      error: error?.message ?? "not_found",
    });
    return vazio;
  }
  return {
    legal_name: data.legal_name ?? "",
    display_name: data.display_name ?? "",
    dpo_email: data.dpo_email?.trim() || dpoDaInstalacao,
    country: (data as { country?: string | null }).country ?? null,
    timezone: (data as { timezone?: string | null }).timezone ?? null,
    art15: art15DoControlador((data as { settings?: unknown }).settings),
  };
}

/**
 * O que o titular recebe de `ai_agent_runs.tool_calls` (#1965): por passo, o
 * nome e os argumentos de cada ferramenta — o que o agente fez com o que a
 * pessoa escreveu. Saem o `result` de cada chamada e o `text` do passo (forma
 * em `lib/ai/runtime/serialize.ts`): o `result` de `crm_search_contacts` traz
 * nome, telefone e e-mail de até 50 OUTROS contatos, e o de
 * `crm_list_appointments` a agenda da organização — entregá-los seria dar ao
 * titular A o dado do titular B. O texto do modelo pode repetir esse resultado.
 * Passo já redigido pela cascata (`redacted: true`, sem `args`) sai como está.
 */
export function toolCallsParaOTitular(toolCalls: unknown): unknown[] {
  const passos = Array.isArray(toolCalls) ? (toolCalls as unknown[]) : [];
  return passos.map((p) => {
    const passo = (p ?? {}) as {
      step?: unknown;
      tool_name?: unknown;
      redacted?: unknown;
      tool_calls?: unknown;
    };
    const chamadas = Array.isArray(passo.tool_calls) ? (passo.tool_calls as unknown[]) : [];
    return {
      ...(passo.step !== undefined ? { step: passo.step } : {}),
      ...(typeof passo.tool_name === "string" ? { tool_name: passo.tool_name } : {}),
      ...(passo.redacted === true ? { redacted: true } : {}),
      tool_calls: chamadas.map((c) => {
        const chamada = (c ?? {}) as { tool_name?: unknown; args?: unknown };
        return {
          tool_name: typeof chamada.tool_name === "string" ? chamada.tool_name : "unknown",
          ...(chamada.args !== undefined ? { args: chamada.args } : {}),
        };
      }),
    };
  });
}

export async function collectExportData(args: CollectArgs): Promise<ExportPayload> {
  const admin = createAdminClient();
  const { organizationId, requestId, externalCustomerId } = args;
  // ANTES do primeiro `return`: o caminho "nenhum dado localizado" também gera
  // um relatório entregue ao titular, e ele precisa nomear o controlador igual.
  const lido = await lerControlador(admin, organizationId, requestId, args.dpoDaInstalacao ?? null);
  const controlador = args.pais === undefined ? lido : { ...lido, country: args.pais };
  let contactId = args.contactId;

  // Resolve contact_id when only external customer id is provided.
  if (!contactId && externalCustomerId) {
    const { data, error } = await admin
      .from("contacts")
      .select("id")
      .eq("organization_id", organizationId)
      .eq("source", "nuvemshop")
      .eq("source_metadata->>nuvemshop_customer_id", externalCustomerId)
      .maybeSingle();
    if (error) {
      logger.warn("[lgpd-export-worker] resolve-by-external failed", {
        request_id: requestId,
        error: error.message,
      });
    }
    if (data) contactId = data.id;
  }

  // No contact AND no external customer -> empty footprint.
  if (!contactId && !externalCustomerId) {
    return emptyPayload(requestId, organizationId, controlador);
  }

  // O país é lido UMA vez, logo depois do ponto que separa o caminho vazio do
  // caminho com dado: as alíneas do art. 15.º e a cópia do n.º 3
  // decidem aqui, e o renderizador decide pelo que veio no payload — duas
  // leituras diferentes dariam ao titular um PDF que promete o que o
  // `data.json` não entrega (mesma doutrina da leitura única do worker, doc 88).
  const perfil = perfilDoPais(controlador.country);
  // Alínea h) do art. 15.º — lida dos agentes de IA NO AR da organização,
  // DEPOIS do ponto que devolve o payload vazio: a coleta sem identificador
  // visita só `organizations` (tests/invariants/agenda-meet-export), e é neste
  // caminho que há pedido para entregar.
  const decisoes_automatizadas =
    perfil.codigo === PAIS_PADRAO
      ? ""
      : await descreveDecisoesAutomatizadas(admin, organizationId, requestId);

  // Seções cuja consulta voltou no TETO de linhas (`.limit()`): pode haver mais
  // do que o `data.json` entrega, e o titular tem de saber quais são. É o que
  // impede o relatório de chamar a cópia de "completa" — ela não é, e paginar
  // as dezenove seções seria trocar a ressalva por memória sem teto no worker.
  // Sai em todo país (`corpoDaCopiaCompleta`, doc 110, 2A).
  const secoes_no_limite: string[] = [];
  const conferirTeto = (secao: string, linhas: readonly unknown[] | null, teto: number) => {
    if ((linhas?.length ?? 0) >= teto && !secoes_no_limite.includes(secao)) secoes_no_limite.push(secao);
  };

  // Contact snapshot (PII intentionally retained — this report is the data
  // owner's right of access; only logs/metadata stay sanitized).
  let contact: ContactSnapshot | null = null;
  let contactLid: string | null = null;
  if (contactId) {
    const { data, error } = await admin
      .from("contacts")
      .select(
        "id, name, display_name, address_book_name, zip_code, street, number, complement, district, city, state, email, phone_number, wa_lid, cpf_encrypted, birthdate, is_blocked, is_anonymized, consent, tags, source, source_metadata, custom_fields, created_at, last_activity_at, first_service_at",
      )
      .eq("organization_id", organizationId)
      .eq("id", contactId)
      .maybeSingle();
    if (error) {
      logger.warn("[lgpd-export-worker] contact load failed", {
        request_id: requestId,
        error: error.message,
      });
    }
    if (data) {
      const customFields =
        data.custom_fields && typeof data.custom_fields === "object" && !Array.isArray(data.custom_fields)
          ? (data.custom_fields as Record<string, unknown>)
          : {};
      // Os rótulos vêm das perguntas dos roteiros que o contato percorreu. Duas
      // leituras planas (sem embed): o coletor também roda sobre clientes que
      // só entendem coluna simples (tests/invariants/agenda-meet-export).
      const grafos: unknown[] = [];
      const { data: inscricoes, error: inscricoesErr } = await admin
        .from("followup_enrollments")
        .select("version_id")
        .eq("organization_id", organizationId)
        .eq("contact_id", contactId)
        .order("started_at", { ascending: false })
        .limit(50);
      const versaoIds = [
        ...new Set((inscricoes ?? []).flatMap((r) => (r.version_id ? [r.version_id as string] : []))),
      ];
      if (versaoIds.length > 0 && !inscricoesErr) {
        const { data: versoes, error: versoesErr } = await admin
          .from("followup_flow_versions")
          .select("id, graph")
          .eq("organization_id", organizationId)
          .in("id", versaoIds);
        if (versoesErr) {
          logger.warn("[lgpd-export-worker] roteiros load failed", { request_id: requestId, error: versoesErr.message });
        }
        const porId = new Map((versoes ?? []).map((v) => [v.id as string, v.graph]));
        for (const id of versaoIds) grafos.push(porId.get(id)); // o mais recente primeiro
      }
      if (inscricoesErr) {
        logger.warn("[lgpd-export-worker] roteiros load failed", {
          request_id: requestId,
          error: inscricoesErr.message,
        });
      }
      const legiveis = camposLegiveis(customFields, perguntasDosGrafos(grafos));
      contactLid = data.wa_lid ?? null;
      contact = {
        id: data.id,
        name: data.name ?? null,
        display_name: data.display_name ?? null,
        address_book_name: data.address_book_name ?? null,
        zip_code: data.zip_code ?? null,
        street: data.street ?? null,
        number: data.number ?? null,
        complement: data.complement ?? null,
        district: data.district ?? null,
        city: data.city ?? null,
        state: data.state ?? null,
        email: data.email ?? null,
        phone_number: data.phone_number ?? null,
        cpf_present: Boolean(data.cpf_encrypted),
        birthdate: data.birthdate ?? null,
        is_blocked: Boolean(data.is_blocked),
        is_anonymized: Boolean(data.is_anonymized),
        consent: (data.consent as Record<string, unknown> | null) ?? null,
        tags: Array.isArray(data.tags) ? (data.tags as string[]) : [],
        source: data.source ?? null,
        source_metadata: (data.source_metadata as Record<string, unknown> | null) ?? null,
        created_at: data.created_at,
        last_activity_at: data.last_activity_at ?? null,
        first_service_at: data.first_service_at ?? null,
        custom_fields: customFields,
        campos_legiveis: legiveis.campos,
        cpf_informado_na_conversa: legiveis.cpfInformado,
      };
    }
  }

  // (9043) Os telefones extras da ficha, com o `lid` de cada número. Vêm ANTES da
  // busca de mensagens de grupo, que casa pelo número e pelo `lid` de cada um. A
  // consulta filtra organização e ficha: o cliente é o administrador (sem RLS).
  let telefonesExtras: ContactPhoneSnapshot[] = [];
  if (contactId) {
    const { data, error } = await admin
      .from("contact_phones")
      .select("phone_number, tipo, whatsapp, rotulo, origem, wa_lid, created_at")
      .eq("organization_id", organizationId)
      .eq("contact_id", contactId)
      .order("created_at", { ascending: true });
    if (error) {
      logger.warn("[lgpd-export-worker] contact phones load failed", {
        request_id: requestId,
        error: error.message,
      });
    }
    telefonesExtras = (data ?? []).map((t) => ({
      phone_number: t.phone_number,
      tipo: t.tipo,
      whatsapp: t.whatsapp,
      rotulo: t.rotulo ?? null,
      origem: t.origem,
      wa_lid: t.wa_lid ?? null,
      created_at: t.created_at,
    }));
  }
  if (contact && telefonesExtras.length > 0) contact.additional_phones = telefonesExtras;

  // No `consents` table in current schema; legal basis is in contacts.consent JSONB.
  const consents: ConsentRow[] = [];
  if (contact?.consent && typeof contact.consent === "object") {
    for (const [scope, value] of Object.entries(contact.consent)) {
      if (value && typeof value === "object") {
        const v = value as Record<string, unknown>;
        consents.push({
          scope,
          granted: Boolean(v.granted),
          granted_at: typeof v.granted_at === "string" ? v.granted_at : null,
          source: typeof v.source === "string" ? v.source : null,
        });
      } else {
        consents.push({
          scope,
          granted: Boolean(value),
          granted_at: null,
        });
      }
    }
  }

  // Conversations.
  let conversations: ConversationRow[] = [];
  if (contactId) {
    const { data, error } = await admin
      .from("conversations")
      .select("id, status, channel, last_inbound_at, last_message_at, is_group, created_at")
      .eq("organization_id", organizationId)
      .eq("contact_id", contactId)
      .order("last_message_at", { ascending: false, nullsFirst: false })
      .limit(500);
    if (error) {
      logger.warn("[lgpd-export-worker] conversations load failed", {
        request_id: requestId,
        error: error.message,
      });
    } else if (data) {
      conferirTeto("conversations", data, 500);
      conversations = data.map((c) => ({
        id: c.id,
        status: c.status,
        channel: c.channel,
        last_inbound_at: c.last_inbound_at,
        last_message_at: c.last_message_at,
        is_group: Boolean(c.is_group),
        created_at: c.created_at,
      }));
    }
  }

  // Messages — count total + sample recent.
  const paraMensagem = (m: {
    id: string;
    conversation_id: string;
    direction: string;
    type: string;
    status: string;
    body: string | null;
    media_url: string | null;
    media_derived_text: string | null;
    sent_at: string | null;
    created_at: string;
  }): MessageRow => ({
    id: m.id,
    conversation_id: m.conversation_id,
    direction: m.direction,
    type: m.type,
    status: m.status,
    body: m.body,
    has_media: Boolean(m.media_url),
    media_derived_text: m.media_derived_text ?? null,
    sent_at: m.sent_at,
    created_at: m.created_at,
  });
  let messages_count_total = 0;
  let messages_recent: MessageRow[] = [];
  /**
   * As mensagens da cópia — TODAS, em páginas de 500, e não as 100 de
   * `RECENT_MESSAGES_LIMIT`. Em todo país: Portugal pelo art. 15.º, n.º 3
   * (#2340), o Brasil pela declaração completa da LGPD (doc 110, 2A).
   */
  let messages_completas: MessageRow[] | null = null;
  if (contactId) {
    const { count, error: countErr } = await admin
      .from("messages")
      .select("id", { count: "exact", head: true })
      .eq("organization_id", organizationId)
      .eq("contact_id", contactId);
    if (countErr) {
      logger.warn("[lgpd-export-worker] messages count failed", {
        request_id: requestId,
        error: countErr.message,
      });
    } else {
      messages_count_total = count ?? 0;
    }

    const { data, error } = await admin
      .from("messages")
      .select("id, conversation_id, direction, type, status, body, media_url, media_derived_text, sent_at, created_at")
      .eq("organization_id", organizationId)
      .eq("contact_id", contactId)
      .order("created_at", { ascending: false })
      .limit(RECENT_MESSAGES_LIMIT);
    if (error) {
      logger.warn("[lgpd-export-worker] messages recent load failed", {
        request_id: requestId,
        error: error.message,
      });
    } else if (data) {
      messages_recent = data.map(paraMensagem);
    }

    // Mensagens da cópia, em todo país. Páginas de 500 até a última vir vazia:
    // limite fixo aqui seria entregar uma amostra com outro nome, que é
    // exatamente o defeito da issue #2340.
    const completas: MessageRow[] = [];
    for (let de = 0; ; de += MENSAGENS_POR_PAGINA) {
      const { data, error } = await admin
        .from("messages")
        .select(
          "id, conversation_id, direction, type, status, body, media_url, media_derived_text, sent_at, created_at",
        )
        .eq("organization_id", organizationId)
        .eq("contact_id", contactId)
        .order("created_at", { ascending: false })
        .range(de, de + MENSAGENS_POR_PAGINA - 1);
      if (error) {
        logger.warn("[lgpd-export-worker] messages completas load failed", {
          request_id: requestId,
          error: error.message,
        });
        // Página falhou NO MEIO da paginação (#2576): o arquivo sai com as
        // mensagens até aqui, e sem esta linha o titular receberia um arquivo
        // PARCIAL calado — sem dizer que pode haver mais. Registrar a seção
        // como incompleta é o que `secoes_no_limite` existe para; falhar o
        // pedido inteiro trocaria um aviso por uma tentativa de novo que leria
        // a mesma página que acabou de falhar.
        if (!secoes_no_limite.includes("messages_completas")) {
          secoes_no_limite.push("messages_completas");
        }
        break;
      }
      const linhas = data ?? [];
      completas.push(...linhas.map(paraMensagem));
      if (linhas.length < MENSAGENS_POR_PAGINA) break;
    }
    messages_completas = completas;
  }

  // Leads (direct contact_id FK on crm_leads).
  let leads: LeadRow[] = [];
  if (contactId) {
    const { data, error } = await admin
      .from("crm_leads")
      .select("id, pipeline_id, stage_id, title, status, value_cents, currency, created_at")
      .eq("organization_id", organizationId)
      .eq("contact_id", contactId)
      .order("created_at", { ascending: false })
      .limit(200);
    if (error) {
      logger.warn("[lgpd-export-worker] leads load failed", {
        request_id: requestId,
        error: error.message,
      });
    } else if (data) {
      conferirTeto("leads", data, 200);
      leads = data;
    }
  }

  // Anexos dos negócios dele (#3229) — pelos ids de `leads` já coletados.
  let lead_attachments: NonNullable<ExportPayload["lead_attachments"]> = [];
  if (leads.length > 0) {
    const { data, error } = await admin
      .from("crm_lead_attachments")
      .select("id, lead_id, field_key, file_name, mime_type, file_size, created_at")
      .eq("organization_id", organizationId)
      .in(
        "lead_id",
        leads.map((l) => l.id),
      )
      .order("created_at", { ascending: true })
      .limit(500);
    if (error) {
      logger.warn("[lgpd-export-worker] lead attachments load failed", {
        request_id: requestId,
        error: error.message,
      });
    } else if (data) {
      conferirTeto("lead_attachments", data, 500);
      lead_attachments = data;
    }
  }

  // Honorários — módulo opcional (ADR-0002/D8). Deriva dos ids de `leads` já
  // coletados: o contrato é `lead_id`, não `contact_id` direto.
  //
  // Módulo pode não estar instalado nesta instalação — a tabela então não
  // existe (42P01 no SQL, PGRST205 no PostgREST) — e o bloco sai vazio, sem falhar o export
  // inteiro por causa de um módulo que a organização nem ligou.
  let honorarios_contratos: HonorariosContratoRow[] = [];
  let honorarios_parcelas: HonorariosParcelaRow[] = [];
  const leadIds = leads.map((l) => l.id);

  // Propostas de campos lidos no documento — a cascata da 0772 redige `trecho`
  // e os valores, então o Art. 18 II tem de alcançá-las pela mesma porta dos
  // outros dados do negócio: o `lead_id` já coletado. Tabela de módulo: num
  // clone sem a migration ela não existe (42P01) e o bloco sai vazio.
  let lead_field_proposals: LeadFieldProposalRow[] = [];
  if (leadIds.length > 0) {
    const { data, error } = await admin
      .from("lead_field_proposals")
      .select(
        "id, lead_id, campo, valor_proposto, valor_anterior, trecho, attachment_id, status, proposed_at, expires_at, decided_at",
      )
      .eq("organization_id", organizationId)
      .in("lead_id", leadIds)
      .order("proposed_at", { ascending: false })
      .limit(500);
    if (error) {
      if (error.code !== "42P01") {
        throw new Error(`lead_field_proposals_load_failed: ${error.message}`);
      }
    } else if (data) {
      conferirTeto("lead_field_proposals", data, 500);
      lead_field_proposals = data;
    }
  }
  if (leadIds.length > 0) {
    const { data, error } = await admin
      .from("honorarios_contratos")
      .select(
        "id, lead_id, modelo, valor_fixo_cents, percentual_exito, repasse_advogado_pct, created_at",
      )
      .eq("organization_id", organizationId)
      .in("lead_id", leadIds);
    // Qualquer OUTRO erro lança: o worker marca a tentativa como falha e tenta de
    // novo, em vez de entregar ao titular um export sem o contrato como se fosse
    // completo (ADR-0002 D8 — seção de módulo ilegível nunca sai como completa).
    if (error) {
      if (error.code !== "42P01" && error.code !== "PGRST205") {
        throw new Error(`honorarios_contratos_load_failed: ${error.message}`);
      }
    } else if (data) {
      honorarios_contratos = data;
      const contratoIds = data.map((c) => c.id);
      if (contratoIds.length > 0) {
        const { data: parcelas, error: erroParcelas } = await admin
          .from("honorarios_parcelas")
          .select("id, contrato_id, numero, vencimento, valor_cents, status, financial_entry_id")
          .eq("organization_id", organizationId)
          .in("contrato_id", contratoIds)
          .order("numero", { ascending: true });
        if (erroParcelas) {
          throw new Error(`honorarios_parcelas_load_failed: ${erroParcelas.message}`);
        } else if (parcelas) {
          honorarios_parcelas = parcelas;
        }
      }
    }
  }

  // ADR-0002/D8: ausência do módulo é vazia; qualquer outra falha é explícita.
  let cadenciasInstaladas = false;
  let cadencias_inscricoes: NonNullable<ExportPayload["cadencias_inscricoes"]> = [];
  let cadencias_eventos: NonNullable<ExportPayload["cadencias_eventos"]> = [];
  if (contactId) {
    // O registro, não o cache de schema do PostgREST, diz se o módulo existe.
    // Suspenso também exporta: privacidade nunca depende do recurso ligado.
    const { data: moduloCadencias, error: erroModulo } = await admin.from("modulos_instalados").select("modulo").eq("modulo", "cadencias").maybeSingle();
    if (erroModulo) throw new Error("cadencias_export_module_read_failed");
    if (moduloCadencias) {
      cadenciasInstaladas = true;
      const { data: inscricoesCadencia, error: erroCadencias } = await admin.from("email_cadence_enrollments")
        .select("id, cadence_id, lead_id, status, motivo_parada, rastreio_consentido, consentimento_em, emails_enviados, aberturas, created_at, concluida_em, parada_em")
        .eq("organization_id", organizationId).eq("contact_id", contactId).order("created_at", { ascending: false }).limit(500);
      if (erroCadencias) throw new Error("cadencias_export_load_failed");
      if (inscricoesCadencia) {
        conferirTeto("cadencias_inscricoes", inscricoesCadencia, 500);
        cadencias_inscricoes = inscricoesCadencia;
        const ids = inscricoesCadencia.map((i) => i.id);
        if (ids.length > 0) {
          const { data: eventos, error: erroEventos } = await admin.from("email_cadence_events").select("id, enrollment_id, tipo, created_at")
            .eq("organization_id", organizationId).in("enrollment_id", ids).order("created_at", { ascending: false }).limit(500);
          if (erroEventos) throw new Error("cadencias_events_export_load_failed");
          if (eventos) { conferirTeto("cadencias_eventos", eventos, 500); cadencias_eventos = eventos; }
        }
      }
    }
  }

  // Orders (contact_id when available, otherwise external_customer_id).
  let orders: OrderRow[] = [];
  {
    let q = admin
      .from("orders")
      .select(
        "id, external_id, external_provider, status, total_cents, currency, ordered_at, contact_id, customer_external_id",
      )
      .eq("organization_id", organizationId)
      .order("ordered_at", { ascending: false, nullsFirst: false })
      .limit(500);
    if (contactId) {
      q = q.eq("contact_id", contactId);
    } else if (externalCustomerId) {
      q = q.eq("customer_external_id", externalCustomerId);
    }
    const { data, error } = await q;
    if (error) {
      logger.warn("[lgpd-export-worker] orders load failed", {
        request_id: requestId,
        error: error.message,
      });
    } else if (data) {
      conferirTeto("orders", data, 500);
      orders = data.map((o) => ({
        id: o.id,
        external_id: o.external_id,
        external_provider: o.external_provider,
        status: o.status,
        total_cents: o.total_cents,
        currency: o.currency,
        ordered_at: o.ordered_at,
      }));
    }
  }

  // Activities — direct contact_id on crm_lead_activities.
  let activities: ActivityRow[] = [];
  if (contactId) {
    const { data, error } = await admin
      .from("crm_lead_activities")
      .select("id, lead_id, type, source_module, performed_at")
      .eq("organization_id", organizationId)
      .eq("contact_id", contactId)
      .order("performed_at", { ascending: false })
      .limit(500);
    if (error) {
      logger.warn("[lgpd-export-worker] activities load failed", {
        request_id: requestId,
        error: error.message,
      });
    } else if (data) {
      conferirTeto("activities", data, 500);
      activities = data;
    }
  }

  // Resumos do agente — contact_id direto em lead_checkpoints.
  let checkpoints: CheckpointRow[] = [];
  if (contactId) {
    const { data, error } = await admin
      .from("lead_checkpoints")
      .select("id, rolling_summary, commitments, objections, next_action, created_at")
      .eq("organization_id", organizationId)
      .eq("contact_id", contactId)
      .order("created_at", { ascending: false })
      .limit(500);
    if (error) {
      logger.warn("[lgpd-export-worker] checkpoints load failed", {
        request_id: requestId,
        error: error.message,
      });
    } else if (data) {
      conferirTeto("checkpoints", data, 500);
      checkpoints = data;
    }
  }

  // Acessos a servidores MCP externos (OAuth por contato, 9095). A anonimização
  // APAGA os pedidos e revoga as credenciais: o que se apaga a pedido dele é o
  // que se entrega a pedido dele. Só metadado — nenhuma coluna cifrada.
  let mcpPedidos: AcessoMcpRow[] = [];
  let mcpAcessos: AcessoMcpRow[] = [];
  if (contactId) {
    const pedidos = await admin
      .from("mcp_oauth_pedidos")
      .select("servidor_id, escopos, created_at, expira_em, usado_em")
      .eq("organization_id", organizationId)
      .eq("contact_id", contactId)
      .order("created_at", { ascending: false })
      .limit(500);
    if (pedidos.error) {
      logger.warn("[lgpd-export-worker] mcp_oauth_pedidos load failed", { request_id: requestId, error: pedidos.error.message });
    } else if (pedidos.data) {
      conferirTeto("mcp_pedidos_de_acesso", pedidos.data, 500);
      mcpPedidos = (pedidos.data as Array<Record<string, unknown>>).map((l) => ({
        servidor_id: String(l.servidor_id),
        escopos: (l.escopos as string[]) ?? [],
        criado_em: (l.created_at as string) ?? null,
        expira_em: (l.expira_em as string) ?? null,
        usado_em: (l.usado_em as string) ?? null,
      }));
    }
    const acessos = await admin
      .from("contato_mcp_credenciais")
      .select("servidor_id, escopos, criado_em, ultimo_uso_em, revogado_em")
      .eq("organization_id", organizationId)
      .eq("contact_id", contactId)
      .limit(500);
    if (acessos.error) {
      logger.warn("[lgpd-export-worker] contato_mcp_credenciais load failed", { request_id: requestId, error: acessos.error.message });
    } else if (acessos.data) {
      conferirTeto("mcp_acessos", acessos.data, 500);
      mcpAcessos = (acessos.data as Array<Record<string, unknown>>).map((l) => ({
        servidor_id: String(l.servidor_id),
        escopos: (l.escopos as string[]) ?? [],
        criado_em: (l.criado_em as string) ?? null,
        ultimo_uso_em: (l.ultimo_uso_em as string) ?? null,
        revogado_em: (l.revogado_em as string) ?? null,
      }));
    }
  }

  // Agenda — contact_id direto em calendar_appointments.
  //
  // ⚠️ ESTA METADE FALTAVA, e a outra tinha gate. A migration 0184 declarou esta
  // tabela dado pessoal e ligou o trigger de REDAÇÃO; esta branch escreveu
  // `tests/invariants/agenda-lgpd-alcanca.test.ts` com quatro casos para provar
  // a redação — e ninguém acrescentou a agenda ao EXPORT. O titular exercia o
  // Art. 18 II e recebia um relatório que não mencionava nenhuma consulta que
  // ele marcou. Neste repo, redigir e exportar sempre andaram juntos.
  let appointments: AppointmentRow[] = [];
  if (contactId) {
    const { data, error } = await admin
      .from("calendar_appointments")
      .select(
        "id, title, description, notes, location_details, cancellation_reason, starts_at, ends_at, time_zone, status, google_base_projection, google_conflict, google_pending_write, meeting_url, meeting_state",
      )
      .eq("organization_id", organizationId)
      .eq("contact_id", contactId)
      .order("starts_at", { ascending: false })
      .limit(500);
    if (error) {
      logger.warn("[lgpd-export-worker] appointments load failed", {
        request_id: requestId,
        error: error.message,
      });
    } else if (data) {
      conferirTeto("appointments", data, 500);
      appointments = data;
    }
  }

  // Lista de espera — módulo opcional (ADR-0002 D8). Onde ele não foi instalado a
  // tabela não existe (42P01/PGRST205) e o bloco sai vazio. Qualquer outro erro
  // lança: o worker tenta de novo, e o titular nunca recebe como completo um
  // arquivo sem a inscrição cujo texto a anonimização apaga.
  let calendar_waitlist: WaitlistRow[] = [];
  if (contactId) {
    const { data, error } = await admin
      .from("calendar_waitlist")
      .select(
        "id, event_type_id, status, urgencia, janelas, antecedencia_minima_horas, valido_ate, consentimento_em, consentimento_origem, oferta_appointment_id, oferta_expira_em, ofertada_em, observacoes, created_by_kind, created_at, updated_at",
      )
      .eq("organization_id", organizationId)
      .eq("contact_id", contactId)
      .order("created_at", { ascending: false })
      .limit(500);
    if (error) {
      if (error.code !== "42P01" && error.code !== "PGRST205") {
        throw new Error(`calendar_waitlist_load_failed: ${error.message}`);
      }
    } else if (data) {
      conferirTeto("calendar_waitlist", data, 500);
      calendar_waitlist = data;
    }
  }

  // Comandas — contact_id direto em sales (migrations 0350-0359).
  //
  // A 0359 acrescentou esta tabela à cascata de redação; este bloco é a outra
  // metade, escrita no mesmo PR. Sem ele, o titular pediria acesso e receberia
  // um relatório que não menciona nenhuma compra que ele fez — o defeito que
  // `tests/unit/lgpd-exporta-o-que-redige.test.ts` existe para pegar, e que
  // pegou este bloco antes de ele ser escrito.
  let sales: SaleRow[] = [];
  if (contactId) {
    const { data, error } = await admin
      .from("sales")
      .select(
        "id, number, status, total_cents, currency, notes, cancel_reason, reverse_reason, finalized_at, created_at",
      )
      .eq("organization_id", organizationId)
      .eq("contact_id", contactId)
      .order("created_at", { ascending: false })
      .limit(500);
    // #1907: a comanda é tabela do módulo `financeiro`. Sem ele instalado, `sales`
    // não existe (42P01 no Postgres, PGRST205 pelo PostgREST) e o bloco sai vazio
    // sem registrar falha. Instalado, a leitura acontece mesmo com o módulo
    // suspenso: o titular tem direito ao que já está gravado.
    if (error && error.code !== "42P01" && error.code !== "PGRST205") {
      logger.warn("[lgpd-export-worker] sales load failed", {
        request_id: requestId,
        error: error.message,
      });
    } else if (data) {
      conferirTeto("sales", data, 500);
      sales = data;
    }
  }

  // Propostas comerciais — contact_id direto em crm_proposals. A 0477
  // acrescentou destinatario_nome/briefing_json/resumo_comercial à cascata de
  // redação; este bloco é a outra metade — sem ele, o titular pediria acesso
  // e receberia um relatório que não menciona nenhuma proposta que recebeu.
  let proposals: ProposalRow[] = [];
  if (contactId) {
    const { data, error } = await admin
      .from("crm_proposals")
      .select(
        "id, numero, ano, titulo, status, total_cents, moeda, valid_until, sent_at, decided_at, destinatario_nome, resumo_comercial, pdf_path, created_at",
      )
      .eq("organization_id", organizationId)
      .eq("contact_id", contactId)
      .order("created_at", { ascending: false })
      .limit(500);
    if (error) {
      logger.warn("[lgpd-export-worker] proposals load failed", {
        request_id: requestId,
        error: error.message,
      });
    } else if (data) {
      conferirTeto("proposals", data, 500);
      proposals = data.map(({ pdf_path, ...p }) => ({ ...p, tem_pdf: Boolean(pdf_path) }));
    }
  }

  // Empresas e pessoas (0448/0449) — ver o comentário do campo `b2b` no tipo.
  // A pessoa vem de `contacts.person_id`; as linhas de planilha casam pelo
  // contato OU pela pessoa, o MESMO escopo da redação da 0449. Erro lança:
  // um relatório sem este bloco diria ao titular que não guardamos o que
  // guardamos.
  let b2b: ExportPayload["b2b"];
  if (contactId) {
    const { data: vinculo, error: eVinculo } = await admin
      .from("contacts")
      .select("person_id")
      .eq("organization_id", organizationId)
      .eq("id", contactId)
      .maybeSingle();
    if (eVinculo) throw eVinculo;
    const personId = vinculo?.person_id ?? null;
    let pessoa: NonNullable<ExportPayload["b2b"]>["pessoa"] = null;
    let vinculos: NonNullable<ExportPayload["b2b"]>["vinculos"] = [];
    if (personId) {
      const { data: p, error: eP } = await admin
        .from("people")
        .select("id, full_name, email, notes, created_at")
        .eq("organization_id", organizationId)
        .eq("id", personId)
        .maybeSingle();
      if (eP) throw eP;
      pessoa = p;
      const { data: v, error: eV } = await admin
        .from("company_people")
        .select("company_id, job_title, department, is_decision_maker, notes")
        .eq("organization_id", organizationId)
        .eq("person_id", personId)
        .limit(500);
      if (eV) throw eV;
      conferirTeto("b2b.vinculos", v, 500);
      vinculos = v ?? [];
    }
    const { data: linhas, error: eL } = await admin
      .from("import_rows")
      .select("id, batch_id, row_number, status, raw_data, normalized_data, error, created_at")
      .eq("organization_id", organizationId)
      .or(personId ? `contact_id.eq.${contactId},person_id.eq.${personId}` : `contact_id.eq.${contactId}`)
      .order("created_at", { ascending: false })
      .limit(500);
    if (eL) throw eL;
    conferirTeto("b2b.linhas_importadas", linhas, 500);
    if (pessoa || vinculos.length > 0 || (linhas ?? []).length > 0) {
      b2b = { pessoa, vinculos, linhas_importadas: linhas ?? [] };
    }
  }

  // Tarefas — contact_id direto em crm_tasks (migration 0210).
  //
  // O texto que a equipe escreveu sobre o titular ("ligar para Fulano confirmar
  // o orçamento") é dado dele. Se a anonimização o apaga — e ela apaga —, o
  // pedido de acesso tem de entregá-lo.
  let tasks: TaskRow[] = [];
  if (contactId) {
    const { data, error } = await admin
      .from("crm_tasks")
      .select("id, title, description, due_date, status, priority")
      .eq("organization_id", organizationId)
      .eq("contact_id", contactId)
      .order("due_date", { ascending: false })
      .limit(500);
    if (error) {
      logger.warn("[lgpd-export-worker] tasks load failed", {
        request_id: requestId,
        error: error.message,
      });
    } else if (data) {
      conferirTeto("tasks", data, 500);
      tasks = data;
    }
  }

  // Chamadas de voz — `contact_id` direto em `voice_calls` (migration 0232).
  //
  // O que existe aqui é o REGISTRO da ligação, nunca o áudio: gravação está
  // deliberadamente fora do produto (spec 18 §1.2), então não há mídia a
  // enfileirar como acontece com foto e anexo.
  let voice_calls: VoiceCallRow[] = [];
  if (contactId) {
    const { data, error } = await admin
      .from("voice_calls")
      .select(
        "id, direction, peer_phone, status, end_reason, started_at, answered_at, ended_at, duration_ms",
      )
      .eq("organization_id", organizationId)
      .eq("contact_id", contactId)
      .order("started_at", { ascending: false })
      .limit(500);
    if (error) {
      logger.warn("[lgpd-export-worker] voice calls load failed", {
        request_id: requestId,
        error: error.message,
      });
    } else if (data) {
      conferirTeto("voice_calls", data, 500);
      voice_calls = data as VoiceCallRow[];
    }
  }

  // Campanhas — `contact_id` direto em `campaign_recipients` (migration 0375).
  let campaign_recipients: CampaignRecipientRow[] = [];
  if (contactId) {
    const { data, error } = await admin
      .from("campaign_recipients")
      .select(
        "id, campaign_id, status, eligibility_status, exclusion_reason, rendered_body, sent_at, delivered_at, read_at, replied_at, opted_out_at",
      )
      .eq("organization_id", organizationId)
      .eq("contact_id", contactId)
      .order("created_at", { ascending: false })
      .limit(500);
    if (error) {
      logger.warn("[lgpd-export-worker] campaign recipients load failed", {
        request_id: requestId,
        error: error.message,
      });
    } else if (data) {
      conferirTeto("campaign_recipients", data, 500);
      campaign_recipients = data as unknown as CampaignRecipientRow[];
    }
  }

  // Lista de exclusão de campanhas — `contact_id` direto (migration 0375).
  let campaign_suppressions: CampaignSuppressionRow[] = [];
  if (contactId) {
    const { data, error } = await admin
      .from("campaign_suppressions")
      .select("id, address_tail, reason, source, created_at")
      .eq("organization_id", organizationId)
      .eq("contact_id", contactId)
      .order("created_at", { ascending: false })
      .limit(100);
    if (error) {
      logger.warn("[lgpd-export-worker] campaign suppressions load failed", {
        request_id: requestId,
        error: error.message,
      });
    } else if (data) {
      conferirTeto("campaign_suppressions", data, 100);
      campaign_suppressions = data as unknown as CampaignSuppressionRow[];
    }
  }

  // Grupos de WhatsApp — `contact_id` direto em `channel_session_groups`
  // (migration 0482). Ver o docstring de `ChannelSessionGroupRow`: a FK só
  // aponta para o CONTATO PLACEHOLDER do grupo, então este bloco só devolve
  // linha quando o titular do pedido é esse placeholder — o mesmo escopo que a
  // redação usa (`fn_lgpd_cascade_redact_contact`, `contact_id = p_contact_id`).
  let channel_session_groups: ChannelSessionGroupRow[] = [];
  if (contactId) {
    const { data, error } = await admin
      .from("channel_session_groups")
      .select("id, group_chat_id, subject, enabled, enabled_at, created_at")
      .eq("organization_id", organizationId)
      .eq("contact_id", contactId)
      .order("created_at", { ascending: false })
      .limit(500);
    if (error) {
      logger.warn("[lgpd-export-worker] channel session groups load failed", {
        request_id: requestId,
        error: error.message,
      });
    } else if (data) {
      conferirTeto("channel_session_groups", data, 500);
      channel_session_groups = data;
    }
  }

  // Mensagens de grupo escritas pelo titular — ver `group_messages_authored`.
  // Duas consultas (telefone, lid) em vez de um `or` sobre caminho JSON: cada
  // uma é um filtro simples, e a união por id desfaz a mensagem que casa as duas.
  const porId = new Map<string, MessageRow>();
  // (9043) O principal E os extras, cada um com as variantes do nono dígito; e o
  // `lid` do principal junto com o de cada extra. Sem extra, é a busca de antes.
  const telefones = [
    ...new Set([
      ...(contact?.phone_number ? phoneLookupVariants(contact.phone_number) : []),
      ...telefonesExtras.flatMap((t) => phoneLookupVariants(t.phone_number)),
    ]),
  ];
  const lids = [
    ...new Set(
      [contactLid, ...telefonesExtras.map((t) => t.wa_lid)].filter((l): l is string => Boolean(l)),
    ),
  ];
  const buscas = [
    telefones.length > 0 ? { campo: "metadata->group_sender->>phone", valores: telefones } : null,
    lids.length > 0 ? { campo: "metadata->group_sender->>lid", valores: lids } : null,
  ];
  for (const busca of buscas) {
    if (!busca) continue;
    const { data, error } = await admin
      .from("messages")
      .select("id, conversation_id, direction, type, status, body, media_url, media_derived_text, sent_at, created_at")
      .eq("organization_id", organizationId)
      .in(busca.campo, busca.valores)
      .order("created_at", { ascending: false })
      .limit(RECENT_MESSAGES_LIMIT);
    if (error) {
      logger.warn("[lgpd-export-worker] group messages load failed", {
        request_id: requestId,
        error: error.message,
      });
      continue;
    }
    conferirTeto("group_messages_authored", data, RECENT_MESSAGES_LIMIT);
    for (const m of data ?? []) {
      porId.set(m.id, {
        id: m.id,
        conversation_id: m.conversation_id,
        direction: m.direction,
        type: m.type,
        status: m.status,
        body: m.body,
        has_media: Boolean(m.media_url),
        media_derived_text: m.media_derived_text ?? null,
        sent_at: m.sent_at,
        created_at: m.created_at,
      });
    }
  }
  const group_messages_authored = [...porId.values()].sort((a, b) => b.created_at.localeCompare(a.created_at));

  // Captação por webhook — a MESMA classe do bloco acima, achada pelo gate.
  let webhook_captures: CaptureRow[] = [];
  if (contactId) {
    const { data, error } = await admin
      .from("webhook_lead_captures")
      .select(
        "id, source_name, outcome, captured_name, captured_phone, captured_email, fields, utm, remote_ip, user_agent, received_at",
      )
      .eq("organization_id", organizationId)
      .eq("contact_id", contactId)
      .order("received_at", { ascending: false })
      .limit(500);
    if (error) {
      logger.warn("[lgpd-export-worker] webhook captures load failed", {
        request_id: requestId,
        error: error.message,
      });
    } else if (data) {
      conferirTeto("webhook_captures", data, 500);
      webhook_captures = data;
    }
  }

  // Propostas de campo do contato: a anonimização as APAGA, e o valor proposto é
  // dado do titular. Mesmo escopo da função que apaga, com os ids internos fora.
  //
  // POR PÁGINA, não por teto: esta fila a IA alimenta enquanto a conversa dura, e
  // um `limit` faria as mais antigas sumirem do relatório sem ninguém saber. A
  // chave é `id` (única) — ordenar por `proposed_at` deixaria empates decidirem a
  // página. Mesma forma do bloco dos rascunhos, logo acima.
  const contact_field_proposals: NonNullable<ExportPayload["contact_field_proposals"]> = [];
  if (contactId) {
    for (let offset = 0; ; offset += 500) {
      const { data, error } = await admin
        .from("contact_field_proposals")
        .select(
          "id, campo, valor_proposto, valor_anterior, conversation_id, trecho, status, proposed_at, decided_at, motivo_recusa",
        )
        .eq("organization_id", organizationId)
        .eq("contact_id", contactId)
        .order("id")
        .range(offset, offset + 499);
      // Uma falha não pode virar um relatório que diz que não guardamos dados.
      if (error) throw error;
      contact_field_proposals.push(...(data ?? []));
      if (!data || data.length < 500) break;
    }
  }

  // O clique liga o titular ao anúncio. A 0806 o apaga na anonimização; no
  // acesso ele precisa aparecer, inclusive além da primeira página. A chave
  // id ordena empates sem entregar o identificador interno no arquivo.
  const meta_ctwa_click_dispatches: MetaCtwaClickRow[] = [];
  if (contactId) {
    for (let offset = 0; ; offset += 500) {
      const { data, error } = await admin
        .from("meta_ctwa_click_dispatches")
        .select("ctwa_clid, captured_at, status, attempted_at, created_at")
        .eq("organization_id", organizationId)
        .eq("contact_id", contactId)
        .order("id")
        .range(offset, offset + 499);
      if (error) throw error;
      meta_ctwa_click_dispatches.push(...(data ?? []));
      if (!data || data.length < 500) break;
    }
  }

  // Audit log extract (best-effort: rows where metadata.contact_id matches).
  let audit_log_extract: AuditRow[] = [];
  if (contactId) {
    const { data, error } = await admin
      .from("api_audit_log")
      .select("id, action, resource_type, resource_id, created_at, metadata")
      .eq("organization_id", organizationId)
      .or(`resource_id.eq.${contactId},metadata->>contact_id.eq.${contactId}`)
      .order("created_at", { ascending: false })
      .limit(AUDIT_LIMIT);
    if (error) {
      logger.warn("[lgpd-export-worker] audit load failed", {
        request_id: requestId,
        error: error.message,
      });
    } else if (data) {
      conferirTeto("audit_log_extract", data, AUDIT_LIMIT);
      audit_log_extract = data.map((a) => ({
        id: a.id,
        action: a.action,
        resource_type: a.resource_type,
        resource_id: a.resource_id,
        created_at: a.created_at,
      }));
    }
  }

  // A 0226/0229 redige estes registros. Só o FK de contato e os compromissos
  // comprovados abaixo dão escopo: nunca o conteúdo livre de um aviso ou a
  // autorização privada do job. Paginar os IDs evita perder avisos de consultas
  // antigas além do recorte de appointments mostrado no relatório.
  const reply_drafts: NonNullable<ExportPayload["reply_drafts"]> = [];
  if (contactId) {
    for (let offset = 0; ; offset += 500) {
      const { data, error } = await admin
        .from("ai_reply_drafts")
        .select("id,status,original_body,edited_body,approved_body,proposals,feedback,created_at")
        .eq("organization_id", organizationId)
        .eq("contact_id", contactId)
        .order("id")
        .range(offset, offset + 499);
      if (error) throw error;
      reply_drafts.push(...(data ?? []));
      if (!data || data.length < 500) break;
    }
  }
  // Espelha exatamente o escopo da redação 0361: contato + organização.
  // Telefone coincidente sem vínculo não comprova identidade. Tokens de
  // supressão e a autorização de envio permanecem internos, fora da projeção.
  const prospecting_candidates: ProspectingCandidateRow[] = [];
  if (contactId) {
    for (let offset = 0; ; offset += 500) {
      const { data, error } = await admin
        .from("prospecting_candidates")
        .select(
          "id,campaign_id,place_id,phone,data,status,lead_id,conversation_id,attempted_at,error,created_at,updated_at",
        )
        .eq("organization_id", organizationId)
        .eq("contact_id", contactId)
        .order("id")
        .range(offset, offset + 499);
      // Uma falha não pode virar um relatório que diz que não guardamos dados.
      if (error) throw error;
      prospecting_candidates.push(...(data ?? []));
      if (!data || data.length < 500) break;
    }
  }
  // Memória da IA, registros de execução e estado da lead — o que a cascata
  // (#1957) redige a pedido de eliminação, e que o acesso entrega de volta.
  //
  // as três têm `contact_id` + `organization_id` na própria linha, então o
  // escopo sai do mesmo `eq` que a cascata usa — sem depender de derivação
  // por conversa ou lead. `lead_state.qualification` sai íntegro; de
  // `ai_agent_runs.tool_calls` saem os argumentos, não o resultado das
  // ferramentas, que pode trazer dado de outras pessoas (`toolCallsParaOTitular`).
  const lead_notes: NonNullable<ExportPayload["lead_notes"]> = [];
  const ai_agent_runs: NonNullable<ExportPayload["ai_agent_runs"]> = [];
  const lead_state: NonNullable<ExportPayload["lead_state"]> = [];
  if (contactId) {
    // A tabela entra por `.from("<nome>")` literal em quem chama, não por
    // parâmetro: `tests/unit/lgpd-exporta-o-que-redige.test.ts` só reconhece a
    // tabela exportada pelo literal, e um `.from(tabela)` a deixava invisível.
    const lePaginado = async (
      pagina: (
        de: number,
        ate: number,
      ) => PromiseLike<{ data: unknown[] | null; error: unknown }>,
    ): Promise<Record<string, unknown>[]> => {
      const linhas: Record<string, unknown>[] = [];
      for (let offset = 0; ; offset += 500) {
        const { data, error } = await pagina(offset, offset + 499);
        if (error) throw error;
        linhas.push(...((data ?? []) as unknown as Record<string, unknown>[]));
        if (!data || data.length < 500) break;
      }
      return linhas;
    };
    for (const nota of await lePaginado((de, ate) =>
      admin
        .from("lead_notes")
        .select("id, headline, body, created_at, updated_at")
        .eq("organization_id", organizationId)
        .eq("contact_id", contactId)
        .order("id")
        .range(de, ate),
    )) {
      lead_notes.push(nota as NonNullable<ExportPayload["lead_notes"]>[number]);
    }
    for (const run of await lePaginado((de, ate) =>
      admin
        .from("ai_agent_runs")
        .select("id, tool_calls, created_at")
        .eq("organization_id", organizationId)
        .eq("contact_id", contactId)
        .order("id")
        .range(de, ate),
    )) {
      ai_agent_runs.push({
        ...(run as NonNullable<ExportPayload["ai_agent_runs"]>[number]),
        tool_calls: toolCallsParaOTitular(run.tool_calls),
      });
    }
    for (const estado of await lePaginado((de, ate) =>
      admin
        .from("lead_state")
        .select("id, next_action, qualification, updated_at")
        .eq("organization_id", organizationId)
        .eq("contact_id", contactId)
        .order("id")
        .range(de, ate),
    )) {
      lead_state.push(estado as NonNullable<ExportPayload["lead_state"]>[number]);
    }
  }
  // Casos, linha do tempo do caso e demandas — o que a 0280 pôs na cascata.
  //
  // O escopo do CASO é a CONVERSA do titular: `agent_cases` não tem FK para
  // `contacts`. Os ids das conversas são paginados por conta própria em vez de
  // reaproveitar a projeção `conversations` acima — ela tem teto de 500 e existe
  // para o relatório. Usá-la como filtro faria o titular com mais de 500
  // conversas receber um export sem os casos das excedentes, em silêncio.
  const cases: CaseRow[] = [];
  const case_events: CaseEventRow[] = [];
  let demandas: DemandaRow[] = [];
  const case_chat_messages: CaseChatMessageRow[] = [];
  const passagens: PassagemDeAtendimentoRow[] = [];
  const avisos_de_caso: AvisoDeCasoEntregaRow[] = [];
  const conversation_drafts: NonNullable<ExportPayload["conversation_drafts"]> = [];
  const conversation_notes: NonNullable<ExportPayload["conversation_notes"]> = [];
  if (contactId) {
    const pageSize = 500;
    const refBatchSize = 100; // Mantém o filtro IN abaixo dos limites de URL dos proxies.
    const conversationIds: string[] = [];
    for (let offset = 0; ; offset += pageSize) {
      const { data, error } = await admin
        .from("conversations")
        .select("id")
        .eq("organization_id", organizationId)
        .eq("contact_id", contactId)
        .order("id")
        .range(offset, offset + pageSize - 1);
      if (error) {
        logger.warn("[lgpd-export-worker] case conversation refs load failed", {
          request_id: requestId,
          error: error.message,
        });
        break;
      }
      for (const conversa of data ?? []) conversationIds.push(conversa.id);
      if (!data || data.length < pageSize) break;
    }
    for (let batch = 0; batch < conversationIds.length; batch += refBatchSize) {
      for (let offset = 0; ; offset += pageSize) {
        const { data, error } = await admin
          .from("agent_cases")
          .select(
            "id, conversation_id, status, title, summary, blocker, source, opened_at, closed_at, created_at",
          )
          .eq("organization_id", organizationId)
          .in("conversation_id", conversationIds.slice(batch, batch + refBatchSize))
          .order("id")
          .range(offset, offset + pageSize - 1);
        if (error) {
          logger.warn("[lgpd-export-worker] cases load failed", {
            request_id: requestId,
            error: error.message,
          });
          break;
        }
        cases.push(...(data ?? []));
        if (!data || data.length < pageSize) break;
      }
    }
    // A linha do tempo pende do caso já coletado: um `case_id` que não esteja em
    // `cases` seria de outro titular, e é por isso que o escopo sai daqui e não
    // de uma segunda derivação pela conversa.
    const caseIds = cases.map((caso) => caso.id);
    for (let batch = 0; batch < caseIds.length; batch += refBatchSize) {
      for (let offset = 0; ; offset += pageSize) {
        const { data, error } = await admin
          .from("agent_case_events")
          .select("id, case_id, kind, actor_kind, human_action, body, metadata, created_at")
          .eq("organization_id", organizationId)
          .in("case_id", caseIds.slice(batch, batch + refBatchSize))
          .order("id")
          .range(offset, offset + pageSize - 1);
        if (error) {
          logger.warn("[lgpd-export-worker] case events load failed", {
            request_id: requestId,
            error: error.message,
          });
          break;
        }
        case_events.push(...(data ?? []));
        if (!data || data.length < pageSize) break;
      }
    }
    // Demanda tem FK direta para o contato (`contact_id` é `not null`).
    const { data, error } = await admin
      .from("demandas")
      .select(
        "id, agent_case_id, origem, assunto, estado, dono_kind, proximo_passo, desfecho, aberta_em, fechada_em",
      )
      .eq("organization_id", organizationId)
      .eq("contact_id", contactId)
      .order("aberta_em", { ascending: false })
      .limit(500);
    if (error) {
      logger.warn("[lgpd-export-worker] demandas load failed", {
        request_id: requestId,
        error: error.message,
      });
    } else if (data) {
      conferirTeto("demandas", data, 500);
      demandas = data;
    }
    // A conversa interna sobre o caso (migration 0281). FK direta para o
    // contato, então não passa pelos ids de conversa acima — e paginada, e não
    // com `limit`, porque uma deliberação longa num titular antigo não pode
    // sumir do relatório em silêncio.
    for (let offset = 0; ; offset += pageSize) {
      const { data: pagina, error: erro } = await admin
        .from("agent_case_chat_messages")
        .select("id, case_id, turn_id, author_kind, body, error_code, created_at")
        .eq("organization_id", organizationId)
        .eq("contact_id", contactId)
        .order("id")
        .range(offset, offset + pageSize - 1);
      if (erro) {
        logger.warn("[lgpd-export-worker] case chat messages load failed", {
          request_id: requestId,
          error: erro.message,
        });
        break;
      }
      case_chat_messages.push(...(pagina ?? []));
      if (!pagina || pagina.length < pageSize) break;
    }
    // As passagens para uma pessoa (migration 0291). FK direta para o contato,
    // como a de cima, e paginada pela mesma razão: um titular de dois anos pode
    // ter dezenas, e um `limit` faria as mais antigas sumirem do relatório sem
    // ninguém saber que sumiram.
    for (let offset = 0; ; offset += pageSize) {
      const { data: pagina, error: erro } = await admin
        .from("passagens_de_atendimento")
        // UM literal, sem concatenação: o supabase-js lê a lista de colunas do
        // TIPO da string para inferir a linha, e `"a" + "b"` vira `string` —
        // a linha volta como `GenericStringError` e o `push` não compila.
        .select("id, conversation_id, caso_id, motor, origem, motivo_codigo, title, body, notes, content, tentativas, cliente_avisado, aviso_motivo_codigo, criado_em, reconhecido_em")
        .eq("organization_id", organizationId)
        .eq("contact_id", contactId)
        .order("id")
        .range(offset, offset + pageSize - 1);
      if (erro) {
        logger.warn("[lgpd-export-worker] passagens load failed", {
          request_id: requestId,
          error: erro.message,
        });
        break;
      }
      passagens.push(...(pagina ?? []));
      if (!pagina || pagina.length < pageSize) break;
    }
    // Os rascunhos das conversas do titular — o MESMO escopo que a função de
    // anonimização usa, a partir dos ids já paginados acima: sem FK para
    // `contacts`, nenhuma outra leitura alcançaria a tabela.
    for (let batch = 0; batch < conversationIds.length; batch += refBatchSize) {
      for (let offset = 0; ; offset += pageSize) {
        const { data, error } = await admin
          .from("conversation_drafts")
          .select("id, conversation_id, body, source, consumed_at, created_at")
          .eq("organization_id", organizationId)
          .in("conversation_id", conversationIds.slice(batch, batch + refBatchSize))
          .order("id")
          .range(offset, offset + pageSize - 1);
        if (error) throw error;
        conversation_drafts.push(...(data ?? []));
        if (!data || data.length < pageSize) break;
      }
    }
    // As NOTAS INTERNAS das conversas do titular (migration 0483) — MESMO
    // escopo dos rascunhos, pelos mesmos ids já paginados: `conversation_notes`
    // não tem FK para `contacts`, e sem este bloco o Art. 18 II entregaria um
    // relatório que omita o que a equipe anotou sobre a pessoa. É a outra
    // metade do par que `tests/unit/lgpd-exporta-o-que-redige.test.ts` deriva
    // da fonte (a cascata 0483 passa a redigir esta tabela) e reprova quem
    // redige e não exporta. A mídia entra como metadado — caminho, MIME e
    // bytes — porque o export é `data.json` + `report.pdf`.
    for (let batch = 0; batch < conversationIds.length; batch += refBatchSize) {
      for (let offset = 0; ; offset += pageSize) {
        const { data, error } = await admin
          .from("conversation_notes")
          // Literal, sem concatenação: o supabase-js lê as colunas do TIPO da
          // string para inferir a linha, e string montada volta como
          // `GenericStringError` e não compila (mesma pegadinha logo acima).
          .select("id, conversation_id, body, media_storage_path, media_mime, media_size_bytes, created_at, created_by_name")
          .eq("organization_id", organizationId)
          .in("conversation_id", conversationIds.slice(batch, batch + refBatchSize))
          .order("id")
          .range(offset, offset + pageSize - 1);
        if (error) throw error;
        // Corpo legível: o token de menção (#2372) leva o UUID do atendente,
        // que não é dado do titular.
        conversation_notes.push(...(data ?? []).map((n) => ({ ...n, body: textoLegivelDeMencao(n.body) })));
        if (!data || data.length < pageSize) break;
      }
    }
    // O registro de entrega do aviso ao suporte (migration 0292). O escopo sai
    // dos CASOS já coletados, e não de uma segunda derivação pela conversa: um
    // `case_id` que não esteja em `cases` seria de outro titular.
    //
    // A cascata de LGPD zera `erro_detalhe` desta tabela, e é por isso que ela
    // entra aqui: `tests/unit/lgpd-exporta-o-que-redige.test.ts` deriva as duas
    // pontas da fonte e reprova quem redige e não exporta — o que se apaga a
    // pedido do titular é o que se entrega a pedido dele.
    for (let batch = 0; batch < caseIds.length; batch += refBatchSize) {
      for (let offset = 0; ; offset += pageSize) {
        const { data: pagina, error: erro } = await admin
          .from("entregas_de_aviso_de_caso")
          .select("id, case_id, destino, status, erro_codigo, tentativas, enviado_em, created_at")
          .eq("organization_id", organizationId)
          .in("case_id", caseIds.slice(batch, batch + refBatchSize))
          .order("id")
          .range(offset, offset + pageSize - 1);
        if (erro) {
          logger.warn("[lgpd-export-worker] avisos de caso load failed", {
            request_id: requestId,
            error: erro.message,
          });
          break;
        }
        for (const linha of pagina ?? []) {
          const { destino, ...resto } = linha;
          avisos_de_caso.push({ ...resto, destino_mascarado: maskPhone(destino) });
        }
        if (!pagina || pagina.length < pageSize) break;
      }
    }
  }

  const meeting_deliveries: MeetingDeliveryRow[] = [];
  const appointment_notices: AppointmentNoticeRow[] = [];
  if (contactId) {
    const appointmentIds = new Set<string>();
    const pageSize = 500;
    for (let offset = 0; ; offset += pageSize) {
      const { data, error } = await admin
        .from("calendar_appointments")
        .select("id")
        .eq("organization_id", organizationId)
        .eq("contact_id", contactId)
        .order("id")
        .range(offset, offset + pageSize - 1);
      if (error) {
        logger.warn("[lgpd-export-worker] meeting references load failed", {
          request_id: requestId,
        });
        break;
      }
      for (const appointment of data ?? []) appointmentIds.add(appointment.id);
      if (!data || data.length < pageSize) break;
    }
    for (let offset = 0; ; offset += pageSize) {
      const { data, error } = await admin
        .from("job_queue")
        .select("id,status,created_at,run_after,appointment_id:payload->>appointment_id")
        .eq("organization_id", organizationId)
        .eq("contact_id", contactId)
        .eq("kind", "transactional_delivery")
        .order("id")
        .range(offset, offset + pageSize - 1);
      if (error) {
        logger.warn("[lgpd-export-worker] meeting deliveries load failed", {
          request_id: requestId,
        });
        break;
      }
      for (const job of data ?? [])
        meeting_deliveries.push({
          id: job.id,
          status: job.status,
          created_at: job.created_at,
          run_after: job.run_after,
          appointment_id:
            typeof job.appointment_id === "string" && appointmentIds.has(job.appointment_id)
              ? job.appointment_id
              : null,
        });
      if (!data || data.length < pageSize) break;
    }
    const ids = [...appointmentIds];
    const refBatchSize = 100; // Mantém o filtro IN abaixo dos limites de URL dos proxies.
    for (let batch = 0; batch < ids.length; batch += refBatchSize) {
      for (let offset = 0; ; offset += pageSize) {
        const { data, error } = await admin
          .from("agent_inbox_items")
          .select("id,ref_id,title,body,status,created_at,resolved_at")
          .eq("organization_id", organizationId)
          .in("ref_kind", ["appointment", "agenda_reminder_sender"])
          .in("kind", ["other", "appointment_outcome_required", "appointment_recovery_review"])
          .in("ref_id", ids.slice(batch, batch + refBatchSize))
          .order("id")
          .range(offset, offset + pageSize - 1);
        if (error) {
          logger.warn("[lgpd-export-worker] appointment notices load failed", {
            request_id: requestId,
          });
          break;
        }
        for (const notice of data ?? [])
          appointment_notices.push({
            id: notice.id,
            ref_id: notice.ref_id,
            title: notice.title,
            body: notice.body,
            status: notice.status,
            created_at: notice.created_at,
            resolved_at: notice.resolved_at,
          });
        if (!data || data.length < pageSize) break;
      }
    }
  }

  return {
    request_id: requestId,
    organization_id: organizationId,
    organization_legal_name: controlador.legal_name,
    organization_display_name: controlador.display_name,
    dpo_email: controlador.dpo_email,
    lei_citada: citacaoDaLei(perfil),
    ...foraDoBrasil(perfil, controlador),
    ...blocoArt15(perfil, controlador, decisoes_automatizadas),
    ...corpoDaCopiaCompleta(messages_completas, secoes_no_limite),
    documento_rotulo: perfil.documento.rotulo,
    generated_at: new Date().toISOString(),
    no_local_footprint:
      !contact &&
      conversations.length === 0 &&
      orders.length === 0 &&
      prospecting_candidates.length === 0,
    contact,
    consents,
    conversations,
    messages_count_total,
    messages_recent,
    leads,
    lead_field_proposals,
    honorarios_contratos,
    honorarios_parcelas,
    // O módulo ausente não altera o contrato do arquivo legado. Instalado
    // (inclusive suspenso), entrega suas seções, mesmo sem linhas do titular.
    ...(cadenciasInstaladas ? { cadencias_inscricoes, cadencias_eventos } : {}),
    orders,
    activities,
    checkpoints,
    mcp_pedidos_de_acesso: mcpPedidos,
    mcp_acessos: mcpAcessos,
    appointments,
    ...(calendar_waitlist.length > 0 ? { calendar_waitlist } : {}),
    sales,
    proposals,
    tasks,
    webhook_captures,
    audit_log_extract,
    reply_drafts,
    meeting_deliveries,
    appointment_notices,
    voice_calls,
    prospecting_candidates,
    ...(meta_ctwa_click_dispatches.length > 0 ? { meta_ctwa_click_dispatches } : {}),
    cases,
    case_events,
    demandas,
    case_chat_messages,
    passagens,
    avisos_de_caso,
    campaign_recipients,
    campaign_suppressions,
    channel_session_groups,
    group_messages_authored,
    conversation_drafts,
    conversation_notes,
    contact_field_proposals,
    lead_attachments,
    lead_notes,
    ai_agent_runs,
    lead_state,
    b2b,
  };
}

/**
 * O que só existe no documento FORA do Brasil: o rótulo da citação, os papéis
 * e o fuso. Para o Brasil devolve `{}` — nenhuma chave nova no `data.json`
 * (doc 88). O rótulo brasileiro ("Direito exercido", issue #2344) e os papéis
 * brasileiros saem do PERFIL PADRÃO no renderizador, não daqui.
 */
function foraDoBrasil(
  perfil: PerfilDoPais,
  controlador: Controlador,
): Pick<ExportPayload, "lei_rotulo" | "papel_controlador" | "papel_encarregado" | "fuso"> {
  if (perfil.codigo === PAIS_PADRAO) return {};
  return {
    ...(perfil.lei?.rotuloNoDocumento ? { lei_rotulo: perfil.lei.rotuloNoDocumento } : {}),
    papel_controlador: perfil.papel.controlador,
    papel_encarregado: perfil.papel.encarregado,
    ...(controlador.timezone ? { fuso: controlador.timezone } : {}),
  };
}

/**
 * A alínea h) — decisões automatizadas, descritas SÓ com o que o sistema sabe:
 * quais agentes de IA estão no ar e em que modo.
 *
 * "No ar" é `agenteAtende` (`lib/ai/agents/no-ar.ts`), a régua da tela e dos
 * workers — NUNCA `is_active`: "Novo agente" grava `is_active: false` e
 * publicar não religa, então filtrar por `is_active` deixava de fora o caso
 * comum (agente criado pela tela, publicado, respondendo sozinho) e dizia ao
 * titular que não havia IA nenhuma. Ver `app/api/v1/ai/agents/assignable`.
 *
 * O modo é o fato que separa as duas frases: `automatic` responde ao titular
 * sem passar por uma pessoa; `assisted` propõe e uma pessoa decide. Se há
 * decisão com efeito jurídico (art. 22.º), o sistema não sabe — quem informa é
 * o controlador, e o texto diz isso em vez de afirmar que não há.
 *
 * Nunca lança: leitura falhou, a alínea diz que não foi possível determinar —
 * silenciar h) seria entregar um relatório que omite uma alínea que a lei exige.
 */
async function descreveDecisoesAutomatizadas(
  admin: ReturnType<typeof createAdminClient>,
  organizationId: string,
  requestId: string,
): Promise<string> {
  const { data, error } = await admin
    .from("ai_agents")
    .select("id, name, operation_mode, paused_at, published_version_id, archived_at, created_at")
    .eq("organization_id", organizationId)
    .order("created_at", { ascending: true })
    .limit(100);
  if (error) {
    logger.warn("[lgpd-export-worker] ai_agents load failed", {
      request_id: requestId,
      error: error.message,
    });
    return "Não foi possível determinar neste relatório: a leitura dos agentes de IA do controlador falhou.";
  }
  const noAr = (data ?? []).filter((a) => agenteAtende(a));
  if (noAr.length === 0) {
    return "Não há assistente de IA a responder às suas mensagens nesta organização.";
  }
  const nomes = (modo: string) =>
    noAr.filter((a) => a.operation_mode === modo).map((a) => a.name);
  const automaticos = nomes("automatic");
  const assistidos = nomes("assisted");
  const frases: string[] = [];
  if (automaticos.length > 0) {
    frases.push(
      `Responde(m) automaticamente às suas mensagens ${automaticos.length} assistente(s) de IA: ${automaticos.join(", ")}.`,
    );
  }
  if (assistidos.length > 0) {
    frases.push(
      `Sugere(m) respostas que uma pessoa revê e decide enviar ${assistidos.length} assistente(s) de IA: ${assistidos.join(", ")}.`,
    );
  }
  frases.push(
    "Lógica: modelo de linguagem sobre as suas mensagens, a base de conhecimento da organização e, conforme a " +
      "configuração de cada assistente, dados do CRM.",
    "Se deste tratamento resulta alguma decisão tomada exclusivamente por meios automatizados que produza efeitos " +
      "na sua esfera jurídica ou que o afete significativamente de forma similar (art. 22.º), quem o informa é o " +
      "controlador.",
  );
  return frases.join(" ");
}

/**
 * O bloco do art. 15.º para o `data.json` e o PDF (issue #2340).
 *
 * Sai FORA do Brasil, com lei revisada e com autoridade declarada no perfil —
 * três condições para uma: o Brasil segue a LGPD (art. 18, II), cuja lista não
 * é esta, e o `data.json` brasileiro é travado byte a byte pelo doc 88; país sem
 * citação revisada não ganha a de um outro (mesma régua de `citacaoDaLei`).
 */
function blocoArt15(
  perfil: PerfilDoPais,
  controlador: Controlador,
  decisoes: string,
): Pick<ExportPayload, "art15"> {
  const autoridade = perfil.autoridadeDeSupervisao;
  if (perfil.codigo === PAIS_PADRAO || !autoridade || !perfil.lei?.revisada || !decisoes) {
    return {};
  }
  return {
    art15: {
      finalidades: controlador.art15.finalidades,
      destinatarios: controlador.art15.destinatarios,
      prazo_conservacao: controlador.art15.prazo_conservacao,
      autoridade,
      decisoes_automatizadas: decisoes,
    },
  };
}

/**
 * A cópia: as mensagens TODAS, e não as 100 de `RECENT_MESSAGES_LIMIT`, e a
 * lista das seções que bateram no teto. `null` (não coletado) não vira chave —
 * o caminho sem contato fica sem `messages_completas`. Em todo país desde o
 * doc 110 (2A); antes o Brasil ficava sem as duas pela trava do doc 88.
 */
function corpoDaCopiaCompleta(
  mensagens: MessageRow[] | null,
  secoesNoLimite: string[],
): Pick<ExportPayload, "messages_completas" | "secoes_no_limite"> {
  return {
    ...(mensagens === null ? {} : { messages_completas: mensagens }),
    secoes_no_limite: secoesNoLimite,
  };
}

function emptyPayload(
  requestId: string,
  organizationId: string,
  controlador: Controlador,
): ExportPayload {
  return {
    request_id: requestId,
    organization_id: organizationId,
    organization_legal_name: controlador.legal_name,
    organization_display_name: controlador.display_name,
    dpo_email: controlador.dpo_email,
    lei_citada: citacaoDaLei(perfilDoPais(controlador.country)),
    ...foraDoBrasil(perfilDoPais(controlador.country), controlador),
    // Sem identificador não há como ler `ai_agents` (o invariant de coleta
    // vaza é só `organizations`); o que a alínea h) pode dizer com verdade é
    // que não existe dado localizado — e portanto não existe decisão sobre ele.
    ...blocoArt15(
      perfilDoPais(controlador.country),
      controlador,
      "Nenhum dado pessoal seu foi localizado nos sistemas internos, pelo que este relatório não tem tratamento automatizado de dados seus a descrever.",
    ),
    documento_rotulo: perfilDoPais(controlador.country).documento.rotulo,
    generated_at: new Date().toISOString(),
    no_local_footprint: true,
    contact: null,
    consents: [],
    conversations: [],
    messages_count_total: 0,
    messages_recent: [],
    leads: [],
    lead_field_proposals: [],
    honorarios_contratos: [],
    honorarios_parcelas: [],
    orders: [],
    activities: [],
    checkpoints: [],
    mcp_pedidos_de_acesso: [],
    mcp_acessos: [],
    appointments: [],
    sales: [],
    proposals: [],
    tasks: [],
    webhook_captures: [],
    audit_log_extract: [],
    meeting_deliveries: [],
    appointment_notices: [],
    voice_calls: [],
    prospecting_candidates: [],
    cases: [],
    case_events: [],
    demandas: [],
    case_chat_messages: [],
    passagens: [],
    avisos_de_caso: [],
    campaign_recipients: [],
    campaign_suppressions: [],
    channel_session_groups: [],
    group_messages_authored: [],
    lead_attachments: [],
    lead_notes: [],
    ai_agent_runs: [],
    lead_state: [],
  };
}
