/**
 * MessageHandler — Processa mensagens recebidas do Baileys (tempo real e histórico).
 *
 * Responsabilidades:
 *   1. Receber eventos `messages.upsert` do Baileys (tipo 'notify' ou 'append')
 *   2. Resolver JIDs LID → telefone (@lid → @s.whatsapp.net)
 *   3. Criar/atualizar Contact, Chat e Message no banco
 *   4. Emitir eventos Socket.IO para o frontend (message:new, chat:upsert)
 *   5. Download assíncrono de mídia (imagens, vídeos, docs)
 *   6. Disparar chatbot para mensagens recebidas com texto
 *
 * CONTEXTO LID (Linked ID):
 *   A partir de 2024, o WhatsApp passou a usar LID como identificador primário
 *   nas mensagens peer-to-peer. O remoteJid chega no formato "123456@lid" em vez
 *   de "5511999999999@s.whatsapp.net". O telefone real está disponível em:
 *     - key.senderPn: campo do protocolo WA contendo o JID com telefone
 *     - key.participantPn: idem, usado em contextos de grupo
 *     - DB lookup: tabela contacts.lidJid (mapeamento persistido anteriormente)
 *
 * @see WhatsAppConnectionManager — registra os event listeners do Baileys
 * @see ContactHandler — gerencia contatos e avatares
 */
import path from 'path'
import fs from 'fs/promises'
import {
  type WASocket,
  type WAMessage,
  downloadMediaMessage,
  getContentType,
} from '../engine'
import { MessageType } from '@prisma/client'
import { prisma } from '../../../infra/database/prisma'
import { logger } from '../../../config/logger'
import { normalizeJid } from '../../../shared/utils/whatsapp'
import { storageProvider } from '../../../core/StorageProvider'
import type { Server as SocketIOServer } from 'socket.io'
import type { ChatbotService } from '../../../modules/chatbot/chatbot.service'
import { hookBus } from '../../../core/hook-bus'

/** Diretório raiz onde os arquivos de mídia são salvos (organizados por instanceId) */
const MEDIA_DIR = path.resolve('./media')

/**
 * Tipos de mensagem do protocolo WhatsApp que NÃO representam conteúdo real para o usuário.
 * Mensagens com estes contentTypes são descartadas silenciosamente — sem criar Chat nem Message.
 */
const PROTOCOL_CONTENT_TYPES = new Set([
  'senderKeyDistributionMessage',  // distribuição de chaves de grupo (protocolo)
  'protocolMessage',               // revogações, timers de desaparecimento (protocolo)
  'ephemeralMessage',              // wrapper de mensagem efêmera (o conteúdo real está dentro)
  'deviceSentMessage',             // eco de mensagem enviada de outro dispositivo (protocolo)
  'messageContextInfo',            // só metadados, sem conteúdo
  'appStateSyncKeyShare',          // sync de estado do app (protocolo)
  'appStateFatalExceptionNotification', // notificação interna (protocolo)
  'keepInChatMessage',             // manter no chat (protocolo)
])

/**
 * Wrappers compostos do WhatsApp que aninham a mensagem real em `.message`.
 * Sem desembrulhar, getContentType retorna o nome do wrapper (ex:
 * `documentWithCaptionMessage` é o que o WhatsApp usa quando o usuário envia
 * um PDF com legenda) e mapContentType cai em UNSUPPORTED — a msg é
 * descartada silenciosamente. Itera enquanto houver wrapper aninhado.
 */
const COMPOSITE_WRAPPER_TYPES = new Set([
  'documentWithCaptionMessage',
  'viewOnceMessage',
  'viewOnceMessageV2',
  'viewOnceMessageV2Extension',
  'editedMessage',
  'botInvokeMessage',
])

// eslint-disable-next-line @typescript-eslint/no-explicit-any
function unwrapInnerMessage(msg: any): any {
  let cur = msg
  let depth = 0
  while (cur && depth < 5) {
    const keys = Object.keys(cur).filter(k => k !== 'messageContextInfo')
    const wrapperKey = keys.find(k => COMPOSITE_WRAPPER_TYPES.has(k))
    if (!wrapperKey) return cur
    const inner = cur[wrapperKey]?.message
    if (!inner || typeof inner !== 'object') return cur
    cur = inner
    depth++
  }
  return cur
}

export class MessageHandler {
  /**
   * Cache de dicas de `peer_recipient_pn` extraídas das stanzas brutas.
   *
   * Mapa: msgId → phone JID do destinatário (ex: "556799138694@s.whatsapp.net").
   *
   * Contexto: quando o usuário envia uma mensagem pelo celular (fromMe=true)
   * para um contato que usa LID, o WhatsApp sincroniza a mensagem pra nossa
   * sessão com remoteJid=@lid. Nesse caso, `key.senderPn` descreve o DONO da
   * sessão (não o destinatário) — inútil pra resolução. A ÚNICA pista do
   * telefone real é o atributo `peer_recipient_pn` na stanza bruta, que o
   * Baileys NÃO expõe em `decode-wa-message.js`.
   *
   * Por isso, o WhatsAppConnectionManager registra um listener extra em
   * `sock.ws.on('CB:message')` que extrai esse atributo e popula este mapa
   * ANTES do Baileys emitir `messages.upsert`. Depois, `resolveLidToPhoneJid`
   * consome a dica e remove a entrada (mapa efêmero, não cresce).
   */
  private peerRecipientHints = new Map<string, string>()

  constructor(
    /** Socket Baileys ativo — usado para download de mídia e reupload */
    private sock: WASocket,
    /** UUID da instância WhatsApp (1 instância = 1 número conectado) */
    private instanceId: string,
    /** UUID do tenant (empresa/cliente) — isolamento multi-tenant */
    private tenantId: string,
    /** Socket.IO server — emite eventos em tempo real para o frontend */
    private io: SocketIOServer,
    /** Serviço de chatbot (opcional) — processa mensagens recebidas com IA */
    private chatbotService?: ChatbotService
  ) {}

  /**
   * Registra uma dica de destinatário extraída da stanza bruta.
   *
   * Chamado pelo listener `sock.ws.on('CB:message')` no
   * WhatsAppConnectionManager. Entradas expiram em 60s como garantia
   * contra vazamento caso o `messages.upsert` correspondente nunca chegue.
   */
  setPeerRecipientHint(msgId: string, peerRecipientPn: string): void {
    this.peerRecipientHints.set(msgId, peerRecipientPn)
    setTimeout(() => this.peerRecipientHints.delete(msgId), 60_000).unref()
  }

  /**
   * Ponto de entrada principal — chamado pelo event listener `messages.upsert`.
   *
   * @param type - 'notify' = mensagem em tempo real; 'append' = histórico sendo sincronizado
   *
   * Diferença entre os modos:
   *   - notify: processa completo (salva, emite socket, dispara chatbot, baixa mídia)
   *   - append: apenas salva no banco em lote (bulk insert), sem emitir eventos
   */
  async handle({ messages, type }: { messages: WAMessage[]; type: string }): Promise<void> {
    if (type === 'notify') {
      for (const msg of messages) {
        try {
          await this.processMessage(msg)
        } catch (err) {
          logger.error({ err, msgId: msg.key.id }, 'Erro ao processar mensagem')
        }
      }
    } else if (type === 'append') {
      await this.handleHistory(messages)
    }
  }

  // ═══════════════════════════════════════════════════════════════════════════
  //  HISTÓRICO — Salva mensagens em lote durante sync inicial (sem socket/chatbot)
  // ═══════════════════════════════════════════════════════════════════════════

  /**
   * Processa mensagens históricas (history sync / append).
   *
   * Estratégia: agrupa mensagens por JID e faz 1 upsert de Contact + Chat + createMany
   * por grupo, minimizando round-trips ao banco.
   *
   * Mensagens @lid são resolvidas para o JID real via senderPn ou DB lookup.
   * Se não for possível resolver, a mensagem é descartada silenciosamente
   * (será capturada quando o mapeamento LID for descoberto via phoneNumberShare).
   */
  async handleHistory(messages: WAMessage[]): Promise<void> {
    if (messages.length === 0) return

    // JID próprio da sessão (ex: "5511999@s.whatsapp.net") — usado para filtrar self-chat
    const ownJid = normalizeJid(this.sock.user?.id ?? '')

    // Agrupa por JID para minimizar queries (1 chat upsert + 1 createMany por JID)
    const byJid = new Map<string, WAMessage[]>()
    for (const msg of messages) {
      let jidRaw = msg.key.remoteJid
      if (!jidRaw || jidRaw.includes('@broadcast') || jidRaw.endsWith('@newsletter') || jidRaw.startsWith('0@') || !msg.message) continue

      // Ignora mensagens de/para o próprio número da sessão (self-chat / notas pessoais)
      const normalizedRaw = normalizeJid(jidRaw)
      if (ownJid && normalizedRaw === ownJid) continue

      // ── Resolução LID → phone JID para mensagens históricas ──────────
      // O history sync do WhatsApp pode enviar mensagens com remoteJid @lid.
      // Precisamos converter para @s.whatsapp.net antes de persistir,
      // caso contrário o chat ficaria duplicado (um com LID, outro com phone).
      //
      // Fontes de resolução (ordem de prioridade):
      //   1. key.remoteJidAlt — InfiniteAPI v7+ inclui o JID alternativo (phone ↔ LID)
      //   2. key.senderPn / participantPn — campo do protocolo WA
      //   3. DB lookup — contacts.lidJid salvo por sessões anteriores
      if (jidRaw.endsWith('@lid')) {
        // 1. remoteJidAlt: InfiniteAPI preenche o JID alternativo em mensagens históricas.
        //    Quando remoteJid=@lid, remoteJidAlt contém @s.whatsapp.net (e vice-versa).
        const remoteJidAlt = (msg.key as any).remoteJidAlt as string | undefined
        if (remoteJidAlt && remoteJidAlt.endsWith('@s.whatsapp.net')) {
          jidRaw = normalizeJid(remoteJidAlt)
        } else {
          // 2. senderPn / participantPn
          const senderPn = (msg.key as any).senderPn || (msg.key as any).participantPn
          if (senderPn) {
            jidRaw = normalizeJid(senderPn)
            if (!jidRaw.endsWith('@s.whatsapp.net')) continue
          } else {
            // 3. DB lookup — mapeamento LID → phone salvo pelo ContactHandler
            const contact = await prisma.contact.findFirst({
              where: { tenantId: this.tenantId, instanceId: this.instanceId, lidJid: jidRaw },
              select: { jid: true },
            })
            if (contact?.jid && !contact.jid.endsWith('@lid')) {
              jidRaw = contact.jid
            } else {
              // Sem resolução possível — descarta (será capturada via phoneNumberShare futuro)
              continue
            }
          }
        }
      }

      const jid = normalizeJid(jidRaw)
      const list = byJid.get(jid) ?? []
      list.push(msg)
      byJid.set(jid, list)
    }

    let totalSaved = 0

    for (const [jid, msgs] of byJid) {
      try {
        // Calcula o timestamp mais recente para atualizar lastMessageAt do chat
        const latestTs = msgs.reduce((max, m) =>
          Math.max(max, Number(m.messageTimestamp ?? 0)), 0)
        const lastAt = latestTs ? new Date(latestTs * 1000) : null

        // 1. Garantir que o Contato existe (cria sem nome — será preenchido pela reconciliação)
        const contact = await prisma.contact.upsert({
          where: { tenantId_instanceId_jid: { tenantId: this.tenantId, instanceId: this.instanceId, jid } },
          create: {
            tenantId: this.tenantId,
            instanceId: this.instanceId,
            jid,
            phone: jid.endsWith('@g.us') ? null : jid.split('@')[0],
          },
          update: {},
        })

        // 2. Garantir que o Chat existe e está vinculado ao Contact
        const chat = await prisma.chat.upsert({
          where: { tenantId_instanceId_jid: { tenantId: this.tenantId, instanceId: this.instanceId, jid } },
          create: {
            tenantId: this.tenantId,
            instanceId: this.instanceId,
            jid,
            contactId: contact.id,
            lastMessageAt: lastAt,
            unreadCount: 0
          },
          update: {
            contactId: contact.id,
            ...(lastAt && { lastMessageAt: lastAt })
          },
        })

        // 3. Prepara registros para bulk insert (createMany com skipDuplicates)
        const rows = msgs.flatMap((msg) => {
          const { key, messageTimestamp } = msg
          let message = msg.message
          if (!message || !key.id) return []
          message = unwrapInnerMessage(message)
          const contentType = getContentType(message as any)
          if (!contentType) return []
          // Descarta tipos de protocolo/sistema que não representam conteúdo real
          if (PROTOCOL_CONTENT_TYPES.has(contentType)) return []
          const msgType = this.mapContentType(contentType)
          if (msgType === MessageType.UNSUPPORTED) return []
          const ts = messageTimestamp ? new Date(Number(messageTimestamp) * 1000) : new Date()

          // Extrai o corpo textual — tenta vários campos em ordem de prioridade
          const body =
            message.conversation ??
            message.extendedTextMessage?.text ??
            message.imageMessage?.caption ??
            message.videoMessage?.caption ??
            message.documentMessage?.caption ??
            null

          const isGroup = jid.endsWith('@g.us')
          return [{
            tenantId:   this.tenantId,
            instanceId: this.instanceId,
            chatId:     chat.id,
            remoteJid:  jid,
            messageId:  key.id,
            fromMe:     key.fromMe ?? false,
            type:       msgType,
            body,
            // pushName: nome público do remetente (só confiável em msgs recebidas)
            pushName:   (key.fromMe ? null : msg.pushName) ?? null,
            // senderJid: em grupos, identifica quem enviou (JID do participante)
            senderJid:  isGroup ? (key.participant ?? null) : null,
            status:     'READ' as const,   // históricas são consideradas já lidas
            timestamp:  ts,
          }]
        })

        if (rows.length > 0) {
          const { count } = await prisma.message.createMany({ data: rows, skipDuplicates: true })
          totalSaved += count
        }
      } catch (err) {
        logger.error({ err, jid }, '[History] Erro ao salvar mensagens do chat')
      }
    }

    if (totalSaved > 0) {
      logger.info({ totalSaved, chats: byJid.size, instanceId: this.instanceId },
        '[History] Mensagens históricas salvas')
    }
  }

  // ═══════════════════════════════════════════════════════════════════════════
  //  TEMPO REAL — Processa uma única mensagem (notify)
  // ═══════════════════════════════════════════════════════════════════════════

  /**
   * Processa uma mensagem recebida em tempo real.
   *
   * Fluxo completo:
   *   1. Filtra broadcasts, newsletters
   *   2. Resolve LID → telefone (se @lid)
   *   3. Cria/atualiza Contact com pushName (se aplicável)
   *   4. Cria/atualiza Chat vinculado ao Contact
   *   5. Persiste a Message no banco
   *   6. Emite socket events (message:new, chat:upsert)
   *   7. Agenda download de mídia (assíncrono)
   *   8. Dispara chatbot (assíncrono)
   */
  private async processMessage(msg: WAMessage): Promise<void> {
    const { key, messageTimestamp, pushName } = msg
    let message = msg.message

    if (!message || !key.remoteJid) return

    // Desembrulha wrappers compostos (documentWithCaptionMessage, viewOnceMessage, etc).
    // Sem isso, getContentType retornaria o nome do wrapper e cairia em UNSUPPORTED.
    message = unwrapInnerMessage(message)

    // Ignora mensagens de status broadcast, newsletters e conta PSA do WhatsApp
    if (key.remoteJid === 'status@broadcast') return
    if (key.remoteJid.endsWith('@newsletter')) return
    if (key.remoteJid.startsWith('0@')) return

    // ── Resolução LID → telefone ──────────────────────────────────────────
    // WhatsApp moderno usa LID (Linked ID) como remoteJid em vez de @s.whatsapp.net.
    // O telefone real está em key.senderPn (quando disponível) ou no mapeamento DB.
    //
    // SEM esta resolução, mensagens de contatos que usam LID seriam descartadas
    // e o chat apareceria com a foto/número errado no frontend (bug crítico).
    //
    // Prioridade de resolução:
    //   1. key.senderPn — campo do protocolo WA (mais confiável, disponível ~95% dos casos)
    //   2. key.participantPn — idem, usado em contextos de grupo
    //   3. DB lookup — contacts.lidJid (mapeamento salvo por processamento anterior ou phoneNumberShare)
    /**
     * Guarda o JID @lid original quando a resolução é bem-sucedida, para
     * persistir em `contacts.lidJid` mais abaixo (após o upsert do Contact).
     * Tentar salvar ANTES do upsert via `updateMany` afeta 0 rows para
     * contatos novos — bug silencioso que fazia o mapeamento ser perdido.
     */
    let originalLidJid: string | null = null
    if (key.remoteJid.endsWith('@lid')) {
      const phoneJid = await this.resolveLidToPhoneJid(key.remoteJid, key as any)
      if (!phoneJid) {
        // Sem resolução: não há como saber o número real do contato.
        // A mensagem será perdida, mas o mapeamento será capturado no futuro
        // via chats.phoneNumberShare e as próximas mensagens serão processadas.
        logger.warn({ remoteJid: key.remoteJid, instanceId: this.instanceId, fromMe: key.fromMe },
          '[MessageHandler] @lid sem resolução de telefone — descartando')
        return
      }
      originalLidJid = key.remoteJid
      // Reescreve o remoteJid para que TODO o processamento abaixo use o JID real.
      // Isso garante que Contact, Chat e Message sejam criados com @s.whatsapp.net.
      ;(key as any).remoteJid = phoneJid
    }

    const contentType = getContentType(message as any)
    if (!contentType) return

    // Descarta tipos de protocolo/sistema (sem conteúdo real para o usuário)
    if (PROTOCOL_CONTENT_TYPES.has(contentType)) return

    const msgType = this.mapContentType(contentType)
    // Descarta tipos completamente desconhecidos — evita poluir o DB com UNSUPPORTED
    if (msgType === MessageType.UNSUPPORTED) return

    const fromMe = key.fromMe ?? false
    const remoteJidRaw = key.remoteJid
    /** JID normalizado (sem sufixo de dispositivo :1, :2 etc) */
    const remoteJid = normalizeJid(remoteJidRaw)
    const messageId = key.id!

    // Ignora mensagens de/para o próprio número da sessão (self-chat / notas pessoais)
    const ownJid = normalizeJid(this.sock.user?.id ?? '')
    if (ownJid && remoteJid === ownJid) return

    // ── 1. Contact: criar ou atualizar ────────────────────────────────────
    // REGRA IMPORTANTE sobre pushName:
    //   - pushName é o "nome público" que o remetente escolheu no WhatsApp
    //   - Para mensagens RECEBIDAS (fromMe=false) de contatos individuais: é confiável
    //   - Para mensagens ENVIADAS (fromMe=true): pushName é o nome da NOSSA sessão
    //     → NUNCA sobrescrever o nome do contato destinatário com isso ("efeito espelho")
    //   - Para GRUPOS: pushName é do remetente individual, não do grupo
    //     → O nome do grupo vem de groups.upsert → Chat.name (subject)
    const isGroupMessage = remoteJid.endsWith('@g.us')
    const participantJid = key.participant ?? (msg as any).participant ?? null

    const safePushName = (!fromMe && !isGroupMessage && pushName) ? pushName : null

    const contact = await prisma.contact.upsert({
      where: { tenantId_instanceId_jid: { tenantId: this.tenantId, instanceId: this.instanceId, jid: remoteJid } },
      create: {
        tenantId: this.tenantId,
        instanceId: this.instanceId,
        jid: remoteJid,
        name:   safePushName,
        notify: safePushName,
        phone:  isGroupMessage ? null : remoteJid.split('@')[0],
        // Se resolvemos a partir de @lid, persiste o mapeamento já na criação.
        // Sem isso, o próximo @lid do mesmo contato falharia o DB lookup.
        ...(originalLidJid && { lidJid: originalLidJid }),
      },
      update: {
        // Atualiza apenas pushname (notify) — preserva name se já veio de contacts.upsert
        ...(safePushName && { notify: safePushName }),
        // Garante que o lidJid fique salvo mesmo em contatos pré-existentes
        // que ainda não tinham o mapeamento (ex: criados via history sync).
        ...(originalLidJid && { lidJid: originalLidJid }),
      },
    })

    // ── 2. Chat: criar ou atualizar, vinculando ao Contact ────────────────
    const chat = await prisma.chat.upsert({
      where: { tenantId_instanceId_jid: { tenantId: this.tenantId, instanceId: this.instanceId, jid: remoteJid } },
      create: {
        tenantId: this.tenantId,
        instanceId: this.instanceId,
        jid: remoteJid,
        contactId: contact.id,
        unreadCount: fromMe ? 0 : 1,
        lastMessageAt: new Date((messageTimestamp as number) * 1000),
      },
      update: {
        contactId: contact.id, // Garante o vínculo (corrige chats órfãos antigos)
        lastMessageAt: new Date((messageTimestamp as number) * 1000),
        // fromMe: zera unread (estamos respondendo); !fromMe: incrementa
        unreadCount: fromMe ? { set: 0 } : { increment: 1 },
      },
    })

    // ── 3. Extrai corpo textual da mensagem ───────────────────────────────
    // nativeFlowResponseMessage: resposta ao clique de botão/lista
    let nativeFlowBody: string | null = null
    if (message.interactiveResponseMessage?.nativeFlowResponseMessage) {
      try {
        const params = JSON.parse(
          message.interactiveResponseMessage.nativeFlowResponseMessage.paramsJson ?? '{}'
        )
        nativeFlowBody = params.display_text ?? params.title ?? params.id ?? null
      } catch { /* ignora JSON inválido */ }
    }

    const body =
      message.conversation ??
      message.extendedTextMessage?.text ??
      message.imageMessage?.caption ??
      message.videoMessage?.caption ??
      message.documentMessage?.caption ??
      nativeFlowBody ??
      null

    // ── 4. Contexto de reply (se for resposta a outra mensagem) ───────────
    const replyToWaId = message.extendedTextMessage?.contextInfo?.stanzaId ?? null
    let replyToId: string | null = null

    if (replyToWaId) {
      const replyMsg = await prisma.message.findUnique({
        where: { instanceId_messageId: { instanceId: this.instanceId, messageId: replyToWaId } },
      })
      replyToId = replyMsg?.id ?? null
    }

    // ── 5. Persiste a mensagem (mídia será baixada depois) ────────────────
    const saved = await prisma.message.upsert({
      where: { instanceId_messageId: { instanceId: this.instanceId, messageId } },
      create: {
        tenantId: this.tenantId,
        instanceId: this.instanceId,
        chatId: chat.id,
        remoteJid,
        messageId,
        fromMe,
        type: msgType,
        body,
        pushName: fromMe ? null : (pushName ?? null),
        senderJid: isGroupMessage ? participantJid : null,
        replyToId,
        status: fromMe ? 'SENT' : 'PENDING',
        timestamp: new Date((messageTimestamp as number) * 1000),
      },
      update: {}, // Se já existe (duplicata), não sobrescreve
    })

    // ── 6. Emite eventos Socket.IO ────────────────────────────────────────

    // 6a. Para quem está com o chat aberto (sala chat:{id})
    // Inclui replyTo quando é uma resposta — o frontend precisa para exibir
    // a caixa de citação (quoted block) em tempo real, sem precisar recarregar.
    const emitMsg = replyToId
      ? await prisma.message.findUnique({
          where: { id: saved.id },
          include: {
            replyTo: {
              select: { id: true, messageId: true, body: true, fromMe: true, type: true, mediaUrl: true },
            },
          },
        }) ?? saved
      : saved

    this.io.to(`chat:${chat.id}`).emit('message:new', {
      ...emitMsg,
      pushName,
      senderJid: isGroupMessage ? participantJid : null,
    })
    // ext: bridge — satélites externos recebem via WS /api/ext/v1/stream
    hookBus.emit('ext:message.new', {
      instanceId: this.instanceId,
      tenantId: this.tenantId,
      chatId: chat.id,
      message: { ...emitMsg, pushName, senderJid: isGroupMessage ? participantJid : null },
      timestamp: Date.now(),
    }).catch(() => {})

    // 6b. Para TODOS os clientes do tenant (atualiza a lista de chats)
    // Necessário para: novos chats, chats não abertos, badge de unread
    this.emitChatUpsert(chat.id, { body, fromMe, status: saved.status, type: msgType, timestamp: saved.timestamp })

    // ── 7. Download de mídia assíncrono (não bloqueia o event loop) ────────
    if (this.isMediaMessage(contentType)) {
      // Passa msg com message desembrulhada (necessário p/ documentWithCaptionMessage etc).
      const msgUnwrapped = { ...msg, message }
      setImmediate(() => this.downloadAndPersistMedia(msgUnwrapped, saved.id, chat.id, contentType))
    }

    // ── 8. Dispara chatbot para mensagens recebidas com texto ─────────────
    if (!fromMe && body && this.chatbotService) {
      setImmediate(() =>
        this.chatbotService!.handleIncoming({
          tenantId: this.tenantId,
          instanceId: this.instanceId,
          chatId: chat.id,
          jid: remoteJid,
          text: body,
          sock: this.sock,
        }).catch((err) => logger.error({ err }, '[Chatbot] Erro assíncrono'))
      )
    }
  }

  // ═══════════════════════════════════════════════════════════════════════════
  //  RESOLUÇÃO LID → TELEFONE
  // ═══════════════════════════════════════════════════════════════════════════

  /**
   * Converte um JID LID (ex: "123456789@lid") para o JID com telefone
   * (ex: "5511999999999@s.whatsapp.net").
   *
   * O WhatsApp moderno usa LID (Linked ID) como identificador interno.
   * Para exibir o contato corretamente, precisamos mapear para o telefone real.
   *
   * Fontes de resolução (em ordem de prioridade):
   *   1. key.senderPn / key.participantPn — campo do protocolo WA.
   *      Útil para mensagens RECEBIDAS e contextos de grupo.
   *   2. peerRecipientHints[msgId] — dica extraída da stanza bruta
   *      (atributo `peer_recipient_pn`, não exposto pelo Baileys).
   *      Única fonte confiável para mensagens fromMe=true enviadas pelo
   *      celular pra um contato @lid que ainda não temos mapeado.
   *   3. DB lookup — contacts.lidJid (mapeamento persistido anteriormente).
   *      Populado por: processamento anterior, chats.phoneNumberShare,
   *      ou contacts.upsert do history sync.
   *
   * A persistência do mapeamento LID→phone foi movida para processMessage
   * (após o upsert do Contact), para evitar o bug anterior onde o
   * `updateMany` disparado aqui afetava 0 rows em contatos inexistentes.
   *
   * @param lidJid - O JID no formato @lid (ex: "123456789@lid")
   * @param key - WAMessageKey (id, fromMe, senderPn, participantPn)
   * @returns O JID @s.whatsapp.net correspondente, ou null se não resolver
   */
  private async resolveLidToPhoneJid(
    lidJid: string,
    key: { id?: string | null; fromMe?: boolean | null; senderPn?: string; participantPn?: string }
  ): Promise<string | null> {
    // 1. senderPn vem direto do protocolo WhatsApp (mais confiável)
    //    OBS: em mensagens fromMe=true sincronizadas do celular, senderPn
    //    descreve o DONO da sessão, não o destinatário — inútil aqui.
    const senderPn = key.senderPn || key.participantPn
    if (senderPn && !key.fromMe) {
      const phoneJid = normalizeJid(senderPn)
      if (phoneJid && phoneJid.endsWith('@s.whatsapp.net')) {
        return phoneJid
      }
    }

    // 2. Dica do peer_recipient_pn (stanza bruta) — fonte primária para
    //    mensagens fromMe=true que chegam via sync de outro dispositivo.
    if (key.fromMe && key.id) {
      const hint = this.peerRecipientHints.get(key.id)
      this.peerRecipientHints.delete(key.id) // uso único
      if (hint) {
        const phoneJid = normalizeJid(hint)
        if (phoneJid && phoneJid.endsWith('@s.whatsapp.net')) {
          return phoneJid
        }
      }
    }

    // 3. Busca no DB por mapeamento LID salvo anteriormente
    const contact = await prisma.contact.findFirst({
      where: { tenantId: this.tenantId, instanceId: this.instanceId, lidJid },
      select: { jid: true },
    })
    if (contact?.jid) return contact.jid

    return null
  }

  // ═══════════════════════════════════════════════════════════════════════════
  //  EMISSÃO DE EVENTOS SOCKET.IO
  // ═══════════════════════════════════════════════════════════════════════════

  /**
   * Emite evento `chat:upsert` para todos os clientes do tenant.
   *
   * Usado para atualizar a lista de chats no frontend em tempo real:
   *   - Novos chats aparecem instantaneamente
   *   - Chats existentes atualizam lastMessage, unreadCount, etc.
   *   - Chats @lid são filtrados (não devem aparecer no frontend)
   *
   * O frontend usa este evento para reordenar a lista por lastMessageAt
   * e exibir o preview da última mensagem.
   */
  private async emitChatUpsert(
    chatId: string,
    lastMessage: { body: string | null; fromMe: boolean; status: string; type: string; timestamp: Date }
  ): Promise<void> {
    try {
      const chat = await prisma.chat.findUnique({
        where: { id: chatId },
        include: {
          contact: {
            select: {
              name:          true,
              verifiedName:  true,
              notify:        true,
              phone:         true,
              avatarUrl:     true,
              scoreReputacao: true,
              flagRestricao:  true,
            },
          },
        },
      })
      if (!chat) return

      // Chats @lid são artefatos internos — nunca expor ao frontend
      if (chat.jid.endsWith('@lid')) return

      const c = chat.contact
      const isGroup = chat.jid.endsWith('@g.us')

      // Regra de resolução de nome:
      //   Grupos: usa Chat.name (subject do grupo, vem de groups.upsert)
      //   Individuais: name (agenda) > verifiedName (business) > notify (pushname)
      //   NUNCA usar pushName do remetente como nome de grupo
      const displayName = isGroup
        ? (chat.name ?? null)
        : (c?.name ?? c?.verifiedName ?? c?.notify ?? null)

      this.io.to(`tenant:${this.tenantId}`).emit('chat:upsert', {
        id:           chat.id,
        tenantId:     chat.tenantId,
        instanceId:   chat.instanceId,
        jid:          chat.jid,
        name:         chat.name ?? null,
        unreadCount:  chat.unreadCount,
        lastMessageAt: chat.lastMessageAt,
        isPinned:     chat.isPinned,
        isArchived:   chat.isArchived,
        botPaused:    chat.botPaused,
        contact: c
          ? {
              name:          displayName,
              phone:         c.phone,
              avatarUrl:     c.avatarUrl,
              scoreReputacao: c.scoreReputacao,
              flagRestricao:  c.flagRestricao,
            }
          : isGroup
          ? { name: displayName, phone: null, avatarUrl: null, scoreReputacao: 0, flagRestricao: false }
          : null,
        lastMessage,
      })
    } catch (err) {
      logger.error({ err, chatId }, '[MessageHandler] Falha ao emitir chat:upsert')
    }
  }

  // ═══════════════════════════════════════════════════════════════════════════
  //  DOWNLOAD DE MÍDIA
  // ═══════════════════════════════════════════════════════════════════════════

  /**
   * Baixa o conteúdo de mídia de uma mensagem e salva no disco.
   *
   * Executado via setImmediate() para não bloquear o event loop.
   * Após salvar, atualiza o registro da mensagem com mediaPath/mediaUrl
   * e emite `message:media_ready` para o frontend renderizar o preview.
   *
   * Organização: /media/{instanceId}/{messageId}.{ext}
   */
  private async downloadAndPersistMedia(
    msg: WAMessage,
    savedMsgId: string,
    chatId: string,
    contentType: string
  ): Promise<void> {
    try {
      const buffer = await downloadMediaMessage(
        msg as any,
        'buffer',
        {},
        { logger: logger as any, reuploadRequest: this.sock.updateMediaMessage as any }
      )

      if (!buffer) return

      // Para documentos: preserva nome e extensão originais do arquivo
      const docMsg = msg.message?.documentMessage
      const originalFileName = docMsg?.fileName?.trim() || null
      const originalMimetype = docMsg?.mimetype || this.mimetypeForType(contentType)

      let ext: string
      if (contentType === 'documentMessage' && originalFileName) {
        ext = path.extname(originalFileName).replace('.', '') || 'bin'
      } else {
        ext = this.extensionForType(contentType)
      }

      const fileName = `${savedMsgId}.${ext}`
      const filePath = path.join(MEDIA_DIR, this.instanceId, fileName)

      await fs.mkdir(path.dirname(filePath), { recursive: true })
      await fs.writeFile(filePath, buffer as Buffer)

      // Tenta upload para Wasabi; se falhar usa path local como fallback
      let mediaUrl = `/media/${this.instanceId}/${fileName}`
      let mediaPath: string | null = filePath
      let uploadedToRemote = false

      if (storageProvider.isRegistered()) {
        try {
          const result = await storageProvider.uploadFile({
            category: 'whatsapp',
            usuarioSis: this.tenantId,
            sessionJID: this.instanceId,
            file: {
              originalname: originalFileName ?? fileName,
              buffer: buffer as Buffer,
              mimetype: originalMimetype,
            },
          })
          mediaUrl = result.path
          if (result.provider !== 'local_fallback') {
            mediaPath = null
            uploadedToRemote = true
          }
          logger.debug({ savedMsgId, path: result.path, provider: result.provider }, 'Mídia enviada ao storage')
        } catch (uploadErr) {
          logger.warn({ uploadErr, savedMsgId }, 'Upload Wasabi falhou, usando path local')
        }
      }

      await prisma.message.update({
        where: { id: savedMsgId },
        data: {
          mediaPath,
          mediaUrl,
          // Preserva o nome original do documento para exibição no frontend
          ...(contentType === 'documentMessage' && originalFileName
            ? { fileName: originalFileName }
            : {}),
        },
      })

      if (uploadedToRemote) {
        fs.unlink(filePath).catch((err) => {
          logger.error({ err, filePath }, 'Erro ao deletar arquivo local após upload remoto')
        })
      }

      // Notifica frontend que a mídia está pronta para exibição
      this.io.to(`chat:${chatId}`).emit('message:media_ready', {
        messageId: savedMsgId,
        mediaUrl,
      })

      logger.debug({ savedMsgId, filePath }, 'Mídia salva')
    } catch (err) {
      logger.error({ err, savedMsgId }, 'Falha ao baixar mídia')
    }
  }

  // ═══════════════════════════════════════════════════════════════════════════
  //  UTILITÁRIOS
  // ═══════════════════════════════════════════════════════════════════════════

  /** Verifica se o contentType é de mídia (precisa de download) */
  private isMediaMessage(contentType: string): boolean {
    return ['imageMessage', 'videoMessage', 'audioMessage', 'documentMessage', 'stickerMessage'].includes(contentType)
  }

  /**
   * Mapeia o contentType do Baileys para o enum MessageType do Prisma.
   * Tipos não reconhecidos são mapeados para UNSUPPORTED.
   */
  private mapContentType(contentType: string): MessageType {
    const map: Record<string, MessageType> = {
      conversation:               MessageType.TEXT,
      extendedTextMessage:        MessageType.TEXT,
      imageMessage:               MessageType.IMAGE,
      videoMessage:               MessageType.VIDEO,
      audioMessage:               MessageType.AUDIO,
      documentMessage:            MessageType.DOCUMENT,
      stickerMessage:             MessageType.STICKER,
      locationMessage:            MessageType.LOCATION,
      contactMessage:             MessageType.CONTACT,
      reactionMessage:            MessageType.REACTION,
      pollCreationMessage:        MessageType.POLL,
      pollCreationMessageV2:      MessageType.POLL,   // poll multi-select grupos
      pollCreationMessageV3:      MessageType.POLL,   // poll single-select
      // Resposta ao clique de botão / seleção de item de lista
      interactiveResponseMessage: MessageType.INTERACTIVE,
      // Mensagem interativa recebida de outro remetente (botões/lista/carrossel)
      interactiveMessage:         MessageType.BUTTONS,
    }
    return map[contentType] ?? MessageType.UNSUPPORTED
  }

  /** Retorna a extensão de arquivo adequada para o tipo de mídia */
  private extensionForType(contentType: string): string {
    const map: Record<string, string> = {
      imageMessage: 'jpg',
      videoMessage: 'mp4',
      audioMessage: 'ogg',
      documentMessage: 'bin',
      stickerMessage: 'webp',
    }
    return map[contentType] ?? 'bin'
  }

  /** Retorna o mimetype adequado para o tipo de mídia */
  private mimetypeForType(contentType: string): string {
    const map: Record<string, string> = {
      imageMessage:    'image/jpeg',
      videoMessage:    'video/mp4',
      audioMessage:    'audio/ogg',
      documentMessage: 'application/octet-stream',
      stickerMessage:  'image/webp',
    }
    return map[contentType] ?? 'application/octet-stream'
  }
}
