/**
 * Adapter da WhatsApp Cloud API — o transporte do canal oficial.
 *
 * Burro de propósito, como o irmão não-oficial: traduz formato e nada mais. Se
 * aparecer aqui um `if` sobre janela de 24h, cap diário ou horário, o desenho vazou —
 * essas regras vivem na cadeia `before_send` (doutrina `restricao-de-canal.md`).
 *
 * ─── Três diferenças que mordem quem copia o adapter do outro canal ──────────
 *
 * 1. **Não existe "sessão".** O outro canal endereça por `sessionRef` (um nome de
 *    sessão); aqui o `sessionRef` é o `phone_number_id`, e ele entra na URL, não no
 *    corpo. Mandar no corpo devolve 400 sem explicar.
 *
 * 2. **Destinatário é E.164 em DÍGITOS, sem `+` e sem sufixo.** Nada de `@c.us`. Um
 *    `+` sobrevivente vira `(#131009) Parameter value is not valid`.
 *
 * 3. **Áudio vira nota de voz só com `voice: true`.** Medido na doc oficial: sem a
 *    flag, um `.ogg/opus` chega como anexo de música, com ícone de nota musical em vez
 *    da bolha de voz. E a Meta **não converte** — quem manda mp3 com `voice:true` erra;
 *    o outro canal converte por nós, este não.
 */
import { createAdminClient } from "@/lib/supabase/admin";
import { metaContactsPayload } from "@/lib/channels/meta/contact-card";
import { graphBaseUrl } from "@/lib/channels/meta/graph-base";
import { metaCloudTemplateOps } from "@/lib/channels/meta/templates";
import { resolveMetaCreds } from "../meta/credentials";
import type {
  ChannelAdapter,
  ChannelHealth,
  ChannelTenantScope,
  OutboundEnvelope,
  RecipientInput,
} from "../types";

/** Só dígitos. `+55 (31) 99896-6398` → `5531998966398`. */
export function toE164Digits(raw: string): string {
  return raw.replace(/\D/g, "");
}

/**
 * Credencial do ambiente — o caminho de instalação de número único.
 *
 * O env continua sendo LIDO (`resolveMetaCreds`, que o `send` chama como
 * fallback depois da sessão), mas ele já não é quem decide se o canal está
 * configurado: essa pergunta não tem resposta síncrona honesta — ver
 * `isConfigured`.
 */
import { metaCredsFromEnv } from "../meta/credentials";
export { metaCredsFromEnv as getMetaCreds };

/**
 * `kind: "contact"` → objeto `contacts` da Cloud API.
 *
 * Exportada (e a de mídia também) porque o canal Datafy fala o MESMO dialeto:
 * duas cópias garantiriam que a primeira correção de mídia faltasse num lado.
 */
export function contactPayload(env: OutboundEnvelope): Record<string, unknown> | null {
  if (env.kind !== "contact" || !env.contact) return null;
  return {
    type: "contacts",
    contacts: metaContactsPayload(env.contact.fullName, env.contact.phoneNumber),
  };
}

/** `kind` do envelope → objeto de mídia da Cloud API. */
export function mediaPayload(env: OutboundEnvelope): Record<string, unknown> | null {
  if (!env.media) return null;
  const link = env.media.url;
  const caption = env.media.caption ?? undefined;

  switch (env.kind) {
    case "image":
      return { type: "image", image: { link, ...(caption ? { caption } : {}) } };
    case "video":
      return { type: "video", video: { link, ...(caption ? { caption } : {}) } };
    case "audio":
      // `voice: true` é o que faz virar BOLHA DE VOZ. Sem ele, anexo de música.
      // Exige ogg/opus — a Meta não converte, diferente do outro canal.
      return { type: "audio", audio: { link, voice: true } };
    default:
      return {
        type: "document",
        document: {
          link,
          ...(env.media.filename ? { filename: env.media.filename } : {}),
          ...(caption ? { caption } : {}),
        },
      };
  }
}

export const metaCloudAdapter: ChannelAdapter = {
  provider: "meta_cloud",

  resolveRecipient(input: RecipientInput): string | null {
    // Grupos: a API de grupos da Cloud é recente e não faz parte deste seam ainda.
    // Devolver null é honesto — o chamador grava `missing_phone_number` em vez de
    // montar um endereço que a Meta recusaria.
    if (input.isGroup) return null;
    if (!input.phoneNumber) return null;
    const digits = toE164Digits(input.phoneNumber);
    return digits.length > 0 ? digits : null;
  },

  /**
   * SEMPRE `true`, e isso não é preguiça: para este canal a pergunta não tem
   * resposta síncrona honesta.
   *
   * A credencial vive na SESSÃO (a tela de "Conectar canal oficial" grava
   * `meta_token_encrypted` desde a 0118), e `isConfigured` é síncrono — não
   * consulta o banco. Olhar só o env respondia "não configurado" para toda
   * instalação que conectou pela tela: o handler gravava `queued` com
   * `queued_reason: meta_not_configured` sem NUNCA chamar `send`, e a mensagem
   * ficava parada no inbox, sem erro, com o canal conectado e funcionando
   * (issue #674). O canal intermediado pagou o mesmo defeito antes e resolveu
   * assim — ver `adapters/zernio.ts`, que adotou este contrato primeiro.
   *
   * O custo de responder `true` é que `send` precisa ser quem desiste — e ele
   * LANÇA `meta_not_configured` em vez de devolver `{externalId: null}`, para o
   * handler gravar `queued` com o motivo em vez de um `sent` sem id, que diria
   * "enviado" para algo que nunca saiu. O fallback de ambiente para instalações
   * legadas segue vivo DENTRO do `send` (`resolveMetaCreds`): sessão primeiro,
   * env depois.
   */
  isConfigured(): boolean {
    // Quem decide é `send()`, que pode consultar o banco. Ver o comentário.
    return true;
  },

  /**
   * Pergunta à plataforma se o número ainda responde.
   *
   * Sem este método o cron de saúde PULAVA a sessão (`if (!adapter.checkHealth)
   * continue`), sem log e sem contador: token vencido, número suspenso ou
   * permissão removida viravam silêncio absoluto com a tela dizendo
   * "conectado". E este canal não tem sequer o empurrão que o intermediado tem
   * — `lib/channels/meta/webhook.ts` só trata `messages` e status de template,
   * não `account_update` nem `phone_number_quality_update`.
   *
   * Reusa a MESMA chamada da validação de credencial: `GET /{phone_number_id}`.
   * O `sessionRef` deste canal É o `phone_number_id` (ver `resolveSessionRef`),
   * então ele já é a chave da consulta.
   *
   * `error` no corpo com HTTP 200 é comportamento real da Graph API, por isso a
   * checagem olha os dois. Erro de rede devolve `reachable: false` sem status:
   * uma oscilação virando "canal caído" ensinaria o operador a ignorar o aviso.
   */
  async checkHealth(
    input: ChannelTenantScope & { sessionRef: string },
  ): Promise<ChannelHealth> {
    const creds = await resolveMetaCreds(createAdminClient(), {
      organizationId: input.organizationId,
      phoneNumberId: input.sessionRef,
    });
    if (!creds) return { reachable: false, status: null, detail: "sem_credencial_para_a_sessao" };

    try {
      const res = await fetch(
        `${graphBaseUrl()}/${input.sessionRef}?fields=display_phone_number,quality_rating`,
        {
          headers: { Authorization: `Bearer ${creds.token}` },
          // Teto de espera: um endpoint que pendura a conexão penduraria o cron
          // junto, e a varredura pararia para TODAS as sessões.
          signal: AbortSignal.timeout(15_000),
        },
      );
      const body = (await res.json().catch(() => ({}))) as {
        error?: { message?: string; code?: number };
      };

      if (res.status === 401 || res.status === 403) {
        return { reachable: true, status: "FAILED", detail: null };
      }
      if (!res.ok || body.error) {
        // A Graph devolve 400 com `error.code` para token vencido — que é falha
        // de credencial, não indisponibilidade. Tratar como "não sei" deixaria
        // justamente a falha calada sem aviso.
        return { reachable: true, status: "FAILED", detail: (body.error?.message ?? "").slice(0, 200) || null };
      }
      return { reachable: true, status: "WORKING", detail: null };
    } catch (err) {
      const detail = err instanceof Error ? err.message : "erro_desconhecido";
      return { reachable: false, status: null, detail: detail.slice(0, 200) };
    }
  },

  codes: {
    notConfigured: "meta_not_configured",
    sendFailed: "meta_error",
    unknownError: "meta_unknown",
  },

  async fetchInboundMedia(input): Promise<{ buffer: Buffer; mime: string }> {
    const creds = await resolveMetaCreds(createAdminClient(), {
      organizationId: input.organizationId,
      phoneNumberId: input.sessionRef,
    });
    if (!creds) {
      throw new Error("meta_not_configured: sem credencial para baixar a mídia.");
    }

    const prefix = "meta-media:";
    if (!input.url.startsWith(prefix)) {
      throw new Error("meta_media_invalid_ref: ponte não reconhecido.");
    }
    const mediaId = input.url.slice(prefix.length);
    if (!/^[A-Za-z0-9._~-]+$/.test(mediaId)) {
      throw new Error("meta_media_invalid_ref: media_id inválido.");
    }

    const headers = { Authorization: `Bearer ${creds.token}` };
    const lookup = await fetch(
      `${graphBaseUrl()}/${encodeURIComponent(mediaId)}`,
      { headers, signal: AbortSignal.timeout(15_000) },
    );
    const metadata = (await lookup.json().catch(() => ({}))) as {
      url?: string;
      mime_type?: string;
      error?: { code?: number; message?: string };
    };
    if (!lookup.ok || metadata.error || !metadata.url) {
      const detalhe = metadata.error?.message ?? lookup.statusText ?? "sem URL";
      throw new Error(
        `meta_media_lookup_failed: ${metadata.error?.code ?? lookup.status} ${detalhe}`.trim(),
      );
    }

    // ⚠️ ALLOWLIST DE HOST, e ela é fail-closed de propósito: a `url` vem da
    // resposta da Graph API, e seguir cegamente uma URL que chegou de fora é
    // SSRF — mesmo vindo de um endereço autenticado.
    //
    // O sufixo, e não o host exato. O `lookaside.fbsbx.com` é o que a
    // documentação da Meta cita, e era o que estava aqui; a leitura mais ampla
    // (inclusive implementações de referência) descreve a mídia saindo também de
    // hosts `*.fbcdn.net`. Não consegui MEDIR isso — não há conta Meta nesta
    // casa —, e essa incerteza decide a direção do erro: um host legítimo
    // recusado faz a mídia NUNCA chegar, com uma mensagem que parece problema de
    // segurança e manda quem opera investigar o lugar errado. Um sufixo da Meta
    // a mais não abre superfície nova.
    //
    // Se algum dia a lista precisar crescer de novo, cresça por SUFIXO de
    // domínio da Meta — nunca para host arbitrário, e nunca sem `https:`.
    const HOSTS_DE_MIDIA_DA_META = [".fbsbx.com", ".fbcdn.net"] as const;
    const mediaUrl = new URL(metadata.url);
    const hostPermitido = HOSTS_DE_MIDIA_DA_META.some(
      (sufixo) => mediaUrl.hostname === sufixo.slice(1) || mediaUrl.hostname.endsWith(sufixo),
    );
    if (mediaUrl.protocol !== "https:" || !hostPermitido) {
      throw new Error(
        `meta_media_lookup_failed: host de mídia inesperado (${mediaUrl.protocol}//${mediaUrl.hostname}).`,
      );
    }

    const download = await fetch(mediaUrl.toString(), {
      headers,
      signal: AbortSignal.timeout(30_000),
    });
    if (!download.ok) {
      throw new Error(`meta_media_download_failed: ${download.status} ${download.statusText}`.trim());
    }

    const buffer = Buffer.from(await download.arrayBuffer());
    const mime =
      download.headers.get("content-type")?.split(";")[0]?.trim() ||
      metadata.mime_type ||
      input.hintMime ||
      "application/octet-stream";
    return { buffer, mime };
  },

  /**
   * "digitando…" no aparelho do cliente, antes da 1ª bolha do turno da IA.
   *
   * Aqui o indicador não é da conversa, é da MENSAGEM que se está respondendo:
   * a Graph pede o `message_id` recebido e, no mesmo pedido, marca essa
   * mensagem como lida. Não existe "digitando" sem o "lido" — é um corpo só,
   * `status: "read"` com `typing_indicator`. O indicador some quando a resposta
   * sai ou em 25 s, o que vier antes.
   *
   * Sem mensagem do cliente para responder, ou sem credencial para a sessão, é
   * NOOP, não erro — o mesmo critério do outro canal com o transporte ausente:
   * o produto não para por causa de um indicador decorativo. Recusa da Graph
   * LANÇA, como manda o contrato: quem decide engolir é quem chama.
   */
  async signalTyping(
    input: ChannelTenantScope & { sessionRef: string; recipient: string; inboundExternalId: string | null },
  ): Promise<void> {
    if (!input.inboundExternalId) return;
    const creds = await resolveMetaCreds(createAdminClient(), {
      organizationId: input.organizationId,
      phoneNumberId: input.sessionRef,
    });
    if (!creds) return;

    const res = await fetch(`${graphBaseUrl()}/${creds.phoneNumberId}/messages`, {
      method: "POST",
      headers: {
        Authorization: `Bearer ${creds.token}`,
        "Content-Type": "application/json",
      },
      body: JSON.stringify({
        messaging_product: "whatsapp",
        status: "read",
        message_id: input.inboundExternalId,
        typing_indicator: { type: "text" },
      }),
      // Teto curto de propósito: no início do turno ninguém espera esta chamada
      // (`acenderDigitando`), mas antes da 1ª bolha `esperarComoHumano` a aguarda
      // — uma Graph pendurada seguraria a mensagem, que é o produto, por causa
      // do indicador, que é decoração.
      signal: AbortSignal.timeout(5_000),
    });
    const body = (await res.json().catch(() => ({}))) as {
      error?: { code?: number; message?: string };
    };
    if (!res.ok || body.error) {
      throw new Error(`meta_${body.error?.code ?? res.status}: ${body.error?.message ?? `http_${res.status}`}`);
    }
  },

  async sendReaction(input: ChannelTenantScope & { sessionRef: string; recipient: string | null; externalId: string; emoji: string }): Promise<{ externalId: string | null }> {
    if (!input.recipient) throw new Error("recipient_unavailable");
    const creds = await resolveMetaCreds(createAdminClient(), { organizationId: input.organizationId, phoneNumberId: input.sessionRef });
    if (!creds) throw new Error("meta_not_configured");
    const res = await fetch(`${graphBaseUrl()}/${creds.phoneNumberId}/messages`, {
      method: "POST",
      headers: { Authorization: `Bearer ${creds.token}`, "Content-Type": "application/json" },
      body: JSON.stringify({ messaging_product: "whatsapp", recipient_type: "individual", to: input.recipient, type: "reaction", reaction: { message_id: input.externalId, emoji: input.emoji } }),
    });
    const body = (await res.json().catch(() => ({}))) as { messages?: { id?: string }[]; error?: { code?: number; message?: string } };
    if (!res.ok || body.error) throw new Error(`meta_${body.error?.code ?? res.status}: ${body.error?.message ?? `http_${res.status}`}`);
    return { externalId: body.messages?.[0]?.id ?? null };
  },

  async send(envelope: OutboundEnvelope): Promise<{ externalId: string | null }> {
    // Sessão primeiro, env como fallback. O `sessionRef` do canal oficial É o
    // `phone_number_id` (ver `resolveSessionRef`), então ele é a chave da busca.
    const creds = await resolveMetaCreds(createAdminClient(), {
      organizationId: envelope.organizationId,
      phoneNumberId: envelope.sessionRef,
    });
    // LANÇA, não devolve null: com `isConfigured` sempre true, quem desiste é
    // este ponto — e `{externalId: null}` faria o handler gravar `sent` sem id,
    // dizendo "enviado" para algo que nunca saiu. O handler traduz o prefixo
    // `meta_not_configured` para `queued` com o motivo: credencial ausente é
    // canal ainda não conectado, não falha desta mensagem.
    if (!creds) {
      throw new Error(
        "meta_not_configured: nenhuma credencial para esta sessão (nem na sessão, nem no ambiente).",
      );
    }

    const corpo =
      contactPayload(envelope) ??
      mediaPayload(envelope) ??
      { type: "text", text: { body: envelope.body ?? "" } };

    await envelope.beforeSend?.();
    const res = await fetch(
      `${graphBaseUrl()}/${creds.phoneNumberId}/messages`,
      {
        method: "POST",
        headers: {
          Authorization: `Bearer ${creds.token}`,
          "Content-Type": "application/json",
        },
        body: JSON.stringify({
          messaging_product: "whatsapp",
          recipient_type: "individual",
          to: envelope.to,
          ...corpo,
        }),
      },
    );

    const body = (await res.json().catch(() => ({}))) as {
      messages?: { id?: string }[];
      error?: { code?: number; message?: string; error_data?: { details?: string } };
    };

    if (!res.ok || body.error) {
      // `details` é o campo que diz QUAL parâmetro divergiu; sem ele o operador lê
      // "Parameter format does not match" e não tem pista nenhuma.
      const detalhe = body.error?.error_data?.details ?? body.error?.message ?? `http_${res.status}`;
      throw new Error(`meta_${body.error?.code ?? res.status}: ${detalhe}`);
    }

    return { externalId: body.messages?.[0]?.id ?? null };
  },

  /**
   * Gestão das definições aprovadas PELA TELA (#3635).
   *
   * Mora no adapter, e não na rota, pelo mesmo motivo de `sendTemplate`: a rota
   * de modelos do canal oficial não sabe com quem fala — pede o que o canal
   * oferece. O binding (`lib/channels/meta/templates.ts`) resolve a credencial
   * da SESSÃO e a WABA dela; aqui é só a ligação com o seam.
   */
  templates: metaCloudTemplateOps,
};
