/**
 * Webhook da Meta (Cloud API) — verificação e parse. **Puro e síncrono**: nada
 * aqui toca banco nem rede, para que cada regra seja testável sem ambiente.
 *
 * Duas diferenças em relação ao webhook do WAHA (`lib/waha/ingest.ts`), e as duas
 * já morderam quem tentou reaproveitar o outro:
 *
 *   1. **HMAC SHA-256, não SHA-512**, e o header vem prefixado: `X-Hub-Signature-256:
 *      sha256=<hex>`. Comparar sem tirar o prefixo reprova sempre.
 *   2. **A chave é o App Secret**, não um segredo por sessão. Um App Secret vale para
 *      todas as WABAs do app — por isso a rota ainda usa token no path, para amarrar
 *      o payload a UMA organização antes de confiar nele.
 *
 * `hub.challenge` (GET) faz parte do protocolo: a Meta só passa a entregar eventos
 * depois que o endpoint devolve o desafio **em texto puro** — não JSON, não
 * `{data:...}`. Envelopar quebra a verificação com uma mensagem inútil no dashboard.
 */
import { createHmac, timingSafeEqual } from "node:crypto";

import { parseMetaInboundContact } from "@/lib/channels/meta/contact-card";
import type { SharedContact } from "@/lib/messaging/contact-card";
import type { MetaWebhookEnvelope } from "./envelope";

/** Assinatura da Meta: `sha256=<hex>` no header `X-Hub-Signature-256`. */
/**
 * A instalação consegue RECEBER pelo canal oficial? — as duas metades.
 *
 * São dois segredos com papéis diferentes, e conferir só um produz o pior tipo
 * de tela: a que diz "pronto" sobre algo que não funciona.
 *
 *   META_WEBHOOK_VERIFY_TOKEN → responde o handshake GET, em que a Meta valida
 *                               o endereço. Sem ele o webhook nem é aceito.
 *   META_APP_SECRET           → valida a ASSINATURA de cada mensagem que chega
 *                               (`verifyMetaSignature`, logo abaixo). Sem ele,
 *                               o handshake passa e TODO POST assinado morre em
 *                               401 `invalid_signature` — o número envia e
 *                               nunca recebe, sem erro em lugar nenhum.
 *
 * Nenhum dos dois é escrito pelo `install.sh` (`git grep 'META_' -- '*.sh'`
 * devolve vazio): numa instalação recém-feita os dois estão ausentes, que é
 * exatamente quando o aviso precisa aparecer.
 *
 * `.trim() !== ""` e não `Boolean()`: o contrato do `.env` deste projeto é que
 * vazio é ausente — o template gera `CHAVE=` e é assim que `preenchida()` em
 * `lib/instalacao/ambiente.ts:61` já decide.
 */
// O mesmo tipo que `lib/instalacao/ambiente.ts` usa para ler `.env`:
// `NodeJS.ProcessEnv` exige `NODE_ENV` e obrigaria todo teste a montá-lo.
export function metaPodeReceber(
  source: Record<string, string | undefined> = process.env,
): boolean {
  const cheia = (nome: string): boolean => (source[nome] ?? "").trim() !== "";
  return cheia("META_WEBHOOK_VERIFY_TOKEN") && cheia("META_APP_SECRET");
}

export function verifyMetaSignature(
  rawBody: string,
  signatureHeader: string | null,
  appSecret: string,
): boolean {
  if (!signatureHeader || !appSecret) return false;
  const [algo, received] = signatureHeader.split("=");
  if (algo !== "sha256" || !received) return false;

  const expected = createHmac("sha256", appSecret).update(rawBody, "utf8").digest("hex");
  // timingSafeEqual estoura se os tamanhos diferem — comparar antes evita
  // transformar assinatura malformada em exceção 500.
  if (received.length !== expected.length) return false;
  return timingSafeEqual(Buffer.from(received, "hex"), Buffer.from(expected, "hex"));
}

/** Resposta do handshake de verificação (GET). `null` = recusar com 403. */
export function verificationChallenge(
  params: URLSearchParams,
  expectedVerifyToken: string,
): string | null {
  if (params.get("hub.mode") !== "subscribe") return null;
  if (!expectedVerifyToken) return null;
  if (params.get("hub.verify_token") !== expectedVerifyToken) return null;
  return params.get("hub.challenge");
}

/** Um template mudou de estado na Meta — o evento que a Fase 3a persegue. */
export interface TemplateStatusEvent {
  kind: "template_status";
  wabaId: string;
  templateName: string;
  templateLanguage: string;
  event: string;
  reason: string | null;
}

/**
 * Mensagem ENVIADA PELO CONTATO. A metade que faltava do canal: sem ela o oficial
 * é um megafone — o cliente responde e nada chega, nenhum lead se move, o agente não
 * acorda, e a janela de 24h (que deriva de `last_inbound_at`) nunca abre.
 */
export interface InboundMessageEvent {
  kind: "inbound_message";
  wabaId: string;
  /** Qual número NOSSO recebeu — é o que amarra a mensagem à sessão certa. */
  phoneNumberId: string;
  /** `wamid` — a chave de idempotência. A Meta re-entrega o que não recebe 2xx. */
  externalId: string;
  /**
   * `wa_id` do contato. **Pode vir sem o nono dígito** em celular brasileiro
   * (medido: 553198966398 para quem recebemos como 5531998966398) — quem resolve o
   * contato TEM de usar `phoneLookupVariants`, senão duplica a pessoa.
   */
  from: string;
  profileName: string | null;
  sentAt: Date;
  /** `text` | `audio` | `image` | `video` | `document` | `sticker` | `contact` | … */
  type: string;
  text: string | null;
  /** Preenchido quando `type === "contact"` (cartão compartilhado). */
  sharedContact?: SharedContact | null;
  media: {
    id: string;
    /** A Meta manda URL pronta, com `ext=` de expiração — baixe na hora, não guarde. */
    url: string | null;
    mime: string | null;
    /** Nota de voz de verdade (não anexo de áudio). */
    voice: boolean;
    /**
     * Nome ORIGINAL do arquivo — a Cloud API só manda `filename` em
     * `document` (imagem/vídeo/áudio vêm sem). Vira
     * `metadata.media_filename` na ingestão, que é o que o cartão do Inbox
     * lê (#2613). Opcional: o parser SEMPRE preenche (null quando não há),
     * mas quem monta um evento à mão não é obrigado a inventar a chave.
     */
    filename?: string | null;
  } | null;
  /**
   * `messages[].referral` cru — vem na mensagem que o app do cliente manda ao
   * clicar num anúncio "Clique para o WhatsApp". Repassado sem interpretar: a
   * leitura é de `extrairAtribuicaoMeta`. Opcional porque só a ingestão o lê.
   */
  referral?: unknown;
}

/** Status de entrega de uma mensagem que ENVIAMOS (sent/delivered/read/failed). */
export interface MessageStatusEvent {
  kind: "message_status";
  wabaId: string;
  externalId: string;
  status: string;
  recipient: string | null;
  errorCode: number | null;
  errorTitle: string | null;
}

/**
 * Mensagem que a EMPRESA enviou pelo app WhatsApp Business, num número em
 * coexistência (o mesmo número no app e na Cloud API). A Meta entrega no campo
 * `smb_message_echoes` — só o que saiu PELO APP; o que sai pela API não volta
 * como eco. Sem este evento a conversa no CRM fica sem as respostas dadas pelo
 * celular, e o agente responde por cima de um humano que já respondeu.
 *
 * `revoke` e `edit` também chegam por este campo e ficam de fora de propósito:
 * não são mensagem nova, e sim alteração de uma que talvez nem tenhamos gravado.
 */
export interface OutboundEchoEvent {
  kind: "outbound_echo";
  wabaId: string;
  /** Qual número NOSSO enviou — amarra o eco à sessão, como no recebimento. */
  phoneNumberId: string;
  /** `wamid` — mesma chave de idempotência das mensagens recebidas. */
  externalId: string;
  /** `wa_id` do CLIENTE (o destinatário). Mesma ressalva do nono dígito. */
  to: string;
  sentAt: Date;
  /** `text` | `image` | `video` | `document` | `contact` | … */
  type: string;
  text: string | null;
  /** Preenchido quando `type === "contact"` (cartão compartilhado pelo app). */
  sharedContact?: SharedContact | null;
  media: {
    id: string;
    url: string | null;
    mime: string | null;
    voice: boolean;
    /** Nome original do arquivo, só em `document` — idem a recebida (#2613). */
    filename?: string | null;
  } | null;
}

/**
 * Contato que a empresa criou ou renomeou no ENDEREÇO do app WhatsApp Business,
 * num número em coexistência. A Meta entrega no campo `smb_app_state_sync`
 * (webhooks/reference/smb_app_state_sync).
 *
 * Serve para o CRM conhecer o mesmo nome que a equipe usa no celular antes da
 * primeira mensagem. `action` é `add` (criou OU editou, segundo a Meta) ou
 * `remove` (apagou do endereço) — e `remove` vem SEM `full_name`/`first_name`:
 * a referência diz que o nome sai junto com o contato.
 *
 * **Não é mensagem**: não há `id`, não há conversa e não há efeito em caixa de
 * entrada — por isso o evento não carrega timestamp (nada aqui o leria).
 */
export interface AppContactSyncEvent {
  kind: "app_contact_sync";
  wabaId: string;
  /** Qual número NOSSO tem o endereço — amarra o evento à sessão certa. */
  phoneNumberId: string;
  /** `contact.phone_number` do CLIENTE, sem `+` (a mesma grafia de `from`/`to`). */
  phone: string;
  /** `full_name`, ou `first_name` na falta dele. `null` quando a Meta não manda nome. */
  name: string | null;
  /** `add` = criou/editou no app; `remove` = apagou do endereço. */
  action: string;
}

/**
 * O usuário REAGIU a uma mensagem, e depois talvez desfaça (#2797).
 *
 * A Cloud API entrega a reação DENTRO de `messages[]`, com `type: "reaction"` e
 * o par `reaction.{message_id,emoji}` (webhooks/reference/messages/reaction) —
 * o mesmo lugar de onde vem uma mensagem de texto, o que fazia o normalizador
 * tratá-la como mensagem: linha em `messages` sem emoji e sem alvo, que ainda
 * carimbava a conversa como nova.
 *
 * `emoji` é `null` quando o usuário REMOVEU a reação: a referência diz que a
 * Meta omite a propriedade e reentrega o mesmo `id`. É o caso em que a
 * ingestão apaga a reação anterior do mesmo autor sobre o mesmo alvo.
 *
 * `externalId` é o id da PRÓPRIA reação (não o do alvo) — é a chave de
 * idempotência, e a Meta reentrega o mesmo evento até receber 2xx.
 */
export interface ReactionEvent {
  kind: "reaction";
  wabaId: string;
  /** Qual número NOSSO recebeu o gesto — amarra à sessão, como no recebimento. */
  phoneNumberId: string;
  /** `wamid` da reação — a chave de idempotência. */
  externalId: string;
  /** `wa_id` de quem reagiu. A Cloud API só entrega reação do contato. */
  from: string;
  /** Emoji; `null` = o usuário removeu a reação que tinha dado. */
  emoji: string | null;
  /** `reaction.message_id` — a mensagem que foi reagida. */
  targetExternalId: string;
  sentAt: Date;
}

export type MetaWebhookEvent =
  | TemplateStatusEvent
  | MessageStatusEvent
  | InboundMessageEvent
  | OutboundEchoEvent
  | AppContactSyncEvent
  | ReactionEvent;

/**
 * O formato do fio mora em `./envelope.ts`, onde é um schema Zod — e o tipo
 * NASCE dele (`z.infer`). Aqui era um `interface` escrita à mão, que o
 * `JSON.parse ... as` da rota prometia sem nunca conferir.
 */
export type { MetaWebhookEnvelope } from "./envelope";

/**
 * A Meta manda `rejected_reason: "NONE"` em template APROVADO (medido contra a WABA
 * real). Guardar o literal faz a tela anunciar um motivo de recusa que não existe.
 *
 * Mora aqui, no módulo puro, porque tem DOIS escritores da mesma coluna — o sync e
 * este webhook. Na prova ao vivo o webhook gravou `"NONE"` enquanto o sync gravava
 * `null`: a mesma linha ficava com convenções diferentes dependendo de quem a
 * tocou por último. Regra duplicada é regra que diverge.
 */
export function normalizeRejectedReason(v: unknown): string | null {
  const s = typeof v === "string" && v.length > 0 ? v : null;
  return s === null || s.toUpperCase() === "NONE" ? null : s;
}

function str(v: unknown): string | null {
  return typeof v === "string" && v.length > 0 ? v : null;
}

/** Objeto simples (nem array, nem null) — a guarda para dado que vem do fio. */
function objetoSimples(v: unknown): Record<string, unknown> | null {
  return typeof v === "object" && v !== null && !Array.isArray(v) ? (v as Record<string, unknown>) : null;
}

/** Rótulo do botão tocado: `button.text` (modelo) ou `interactive.button_reply|list_reply.title`. */
function textoDoClique(raw: Record<string, unknown>, tipo: string): string | null {
  if (tipo === "button") {
    const b = raw.button as Record<string, unknown> | undefined;
    return str(b?.text) ?? str(b?.payload);
  }
  const i = raw.interactive as Record<string, unknown> | undefined;
  const r = (i?.button_reply ?? i?.list_reply) as Record<string, unknown> | undefined;
  return str(r?.title) ?? str(r?.id);
}

/**
 * Extrai os eventos que nos interessam. **Evento desconhecido é IGNORADO, não erro** —
 * a Meta re-entrega tudo que não recebe 2xx, então devolver falha para um evento que
 * não nos interessa vira auto-DDoS: ela re-tenta o mesmo payload em backoff por horas.
 */
export function parseMetaWebhook(envelope: MetaWebhookEnvelope): MetaWebhookEvent[] {
  const out: MetaWebhookEvent[] = [];
  if (envelope?.object !== "whatsapp_business_account") return out;

  for (const entry of envelope.entry ?? []) {
    const wabaId = str(entry.id) ?? "";
    for (const change of entry.changes ?? []) {
      const v = change.value ?? {};

      if (change.field === "message_template_status_update") {
        const name = str(v.message_template_name);
        const language = str(v.message_template_language);
        if (!name || !language) continue; // payload capenga não vira linha meia-boca
        out.push({
          kind: "template_status",
          wabaId,
          templateName: name,
          templateLanguage: language,
          event: str(v.event) ?? "UNKNOWN",
          reason: normalizeRejectedReason(v.reason),
        });
        continue;
      }

      // Mensagens RECEBIDAS. Vem no mesmo `field: "messages"` das entregas — o que
      // separa é `messages[]` (do contato) vs `statuses[]` (das nossas). Tratar os
      // dois no mesmo `if` faria um mascarar o outro quando ambos vêm juntos.
      if (change.field === "messages" && Array.isArray(v.messages)) {
        const meta = (v.metadata ?? {}) as Record<string, unknown>;
        const contatos = Array.isArray(v.contacts) ? (v.contacts as Record<string, unknown>[]) : [];
        for (const raw of v.messages as Record<string, unknown>[]) {
          const id = str(raw.id);
          const from = str(raw.from);
          if (!id || !from) continue; // payload capenga não vira linha meia-boca

          const perfil = contatos.find((c) => str(c.wa_id) === from);
          const tipo = str(raw.type) ?? "unknown";

          // ── REAÇÃO (#2797): não é mensagem, e não pode virar linha cega ──
          //
          // Ela chega no MESMO `messages[]` de um texto, o que fazia o bloco de
          // baixo tratá-la como mensagem qualquer: linha `type='reaction'` sem
          // emoji e sem alvo, e o carimbo da conversa movendo última mensagem,
          // janela de 24h e prévia por um gesto que não é fala. O evento próprio
          // é o que separa os dois caminhos lá na frente (a ingestão de reação
          // não chama `fn_mark_conversation_message`).
          //
          // Sem `reaction.message_id` não há o que ligar: descartar AQUI, antes
          // de qualquer escrita, é o que impede a linha órfã.
          if (tipo === "reaction") {
            const miolo = objetoSimples(raw.reaction);
            const alvo = str(miolo?.message_id);
            if (!alvo) continue;
            out.push({
              kind: "reaction",
              wabaId,
              phoneNumberId: str(meta.phone_number_id) ?? "",
              externalId: id,
              from,
              // A Meta OMITE o `emoji` quando o usuário remove a reação — é
              // como o WhatsApp desfaz o gesto, e o modelo tem de caber nos dois.
              emoji: str(miolo?.emoji),
              targetExternalId: alvo,
              sentAt: new Date(Number(str(raw.timestamp) ?? "0") * 1000),
            });
            continue;
          }

          // Toque num botão de modelo chega como `button`, e numa resposta interativa como
          // `interactive`: nenhum dos dois cabe no CHECK de `messages.type`, e gravá-los crus
          // fazia o insert falhar — a resposta do contato sumia do CRM. Entram como texto,
          // com o rótulo que a pessoa tocou.
          const cliqueDeBotao = tipo === "button" || tipo === "interactive";
          const corpoMidia =
            tipo !== "contacts" && !cliqueDeBotao ? (raw[tipo] as Record<string, unknown> | undefined) : undefined;
          const sharedContact = tipo === "contacts" ? parseMetaInboundContact(raw) : null;
          const tipoCrm = tipo === "contacts" ? "contact" : cliqueDeBotao ? "text" : tipo;

          out.push({
            kind: "inbound_message",
            wabaId,
            phoneNumberId: str(meta.phone_number_id) ?? "",
            externalId: id,
            from,
            profileName: str((perfil?.profile as Record<string, unknown> | undefined)?.name),
            // A Meta manda epoch em SEGUNDOS, string. Passar direto ao Date daria 1970.
            sentAt: new Date(Number(str(raw.timestamp) ?? "0") * 1000),
            type: tipoCrm,
            text: cliqueDeBotao
              ? textoDoClique(raw, tipo)
              : tipoCrm === "text"
                ? str((raw.text as Record<string, unknown>)?.body)
                : sharedContact?.name ?? null,
            ...(sharedContact ? { sharedContact } : {}),
            media:
              corpoMidia && str(corpoMidia.id)
                ? {
                    id: str(corpoMidia.id)!,
                    url: str(corpoMidia.url),
                    mime: str(corpoMidia.mime_type),
                    voice: corpoMidia.voice === true,
                    // Nome original (Cloud API: `document.filename`). `str`
                    // devolve null em string vazia — a chave não nasce vazia.
                    filename: str(corpoMidia.filename),
                  }
                : null,
            referral: raw.referral ?? null,
          });
        }
        continue;
      }

      if (change.field === "messages" && Array.isArray(v.statuses)) {
        for (const raw of v.statuses as Record<string, unknown>[]) {
          const id = str(raw.id);
          if (!id) continue;
          const errors = Array.isArray(raw.errors)
            ? (raw.errors as Record<string, unknown>[])
            : [];
          const first = errors[0] ?? {};
          out.push({
            kind: "message_status",
            wabaId,
            externalId: id,
            status: str(raw.status) ?? "unknown",
            recipient: str(raw.recipient_id),
            errorCode: typeof first.code === "number" ? first.code : null,
            errorTitle: str(first.title),
          });
        }
      }
      // Coexistência: o que a empresa enviou pelo app WhatsApp Business.
      if (change.field === "smb_message_echoes" && Array.isArray(v.message_echoes)) {
        const meta = (v.metadata ?? {}) as Record<string, unknown>;
        for (const raw of v.message_echoes as Record<string, unknown>[]) {
          const id = str(raw.id);
          const to = str(raw.to);
          const tipo = str(raw.type) ?? "unknown";
          if (!id || !to) continue; // payload capenga não vira linha meia-boca
          if (tipo === "revoke" || tipo === "edit") continue; // ver `OutboundEchoEvent`

          // Cartão chega como `contacts`, que o CHECK de `messages.type` recusa —
          // mesmo mapeamento da recebida, senão o insert falha e a IA não pausa.
          const corpoMidia = tipo !== "contacts" ? (raw[tipo] as Record<string, unknown> | undefined) : undefined;
          const sharedContact = tipo === "contacts" ? parseMetaInboundContact(raw) : null;
          const tipoCrm = tipo === "contacts" ? "contact" : tipo;
          out.push({
            kind: "outbound_echo",
            wabaId,
            phoneNumberId: str(meta.phone_number_id) ?? "",
            externalId: id,
            to,
            sentAt: new Date(Number(str(raw.timestamp) ?? "0") * 1000),
            type: tipoCrm,
            // Texto do balão: o corpo, o nome do cartão, ou a legenda da mídia.
            text:
              tipoCrm === "text"
                ? str((raw.text as Record<string, unknown>)?.body)
                : sharedContact?.name ?? str(corpoMidia?.caption),
            ...(sharedContact ? { sharedContact } : {}),
            media:
              corpoMidia && str(corpoMidia.id)
                ? {
                    id: str(corpoMidia.id)!,
                    url: str(corpoMidia.url),
                    mime: str(corpoMidia.mime_type),
                    voice: corpoMidia.voice === true,
                    // Idem a recebida: o eco do app também traz `document.filename`.
                    filename: str(corpoMidia.filename),
                  }
                : null,
          });
        }
        continue;
      }
      // Coexistência: contato que a empresa criou/editou no ENDEREÇO do app
      // WhatsApp Business (`smb_app_state_sync`). O payload é o da referência
      // webhooks/reference/smb_app_state_sync: `state_sync[]` com `type`,
      // `contact` e `action`.
      if (change.field === "smb_app_state_sync" && Array.isArray(v.state_sync)) {
        const meta = (v.metadata ?? {}) as Record<string, unknown>;
        for (const raw of v.state_sync as Record<string, unknown>[]) {
          // Hoje só `contact` tem forma que sabemos ler; outro `type` fica de fora.
          if (str(raw.type) !== "contact") continue;
          const contato = (raw.contact ?? {}) as Record<string, unknown>;
          const phone = str(contato.phone_number);
          if (!phone) continue; // payload capenga não vira linha meia-boca
          out.push({
            kind: "app_contact_sync",
            wabaId,
            phoneNumberId: str(meta.phone_number_id) ?? "",
            phone,
            // `remove` vem sem nome de propósito — ver `AppContactSyncEvent`.
            name: str(contato.full_name) ?? str(contato.first_name),
            action: str(raw.action) ?? "add",
          });
        }
        continue;
      }
      // Qualquer outro `field` cai fora de propósito — ver o comentário acima.
    }
  }
  return out;
}
