"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
    return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.MessageHandler = void 0;
/**
 * 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
 */
const path_1 = __importDefault(require("path"));
const promises_1 = __importDefault(require("fs/promises"));
const engine_1 = require("../engine");
const client_1 = require("@prisma/client");
const prisma_1 = require("../../../infra/database/prisma");
const logger_1 = require("../../../config/logger");
const whatsapp_1 = require("../../../shared/utils/whatsapp");
const StorageProvider_1 = require("../../../core/StorageProvider");
const hook_bus_1 = require("../../../core/hook-bus");
const chat_cache_1 = require("../../chats/chat.cache");
/** Diretório raiz onde os arquivos de mídia são salvos (organizados por instanceId) */
const MEDIA_DIR = path_1.default.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) {
    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;
}
class MessageHandler {
    constructor(
    /** Socket Baileys ativo — usado para download de mídia e reupload */
    sock, 
    /** UUID da instância WhatsApp (1 instância = 1 número conectado) */
    instanceId, 
    /** UUID do tenant (empresa/cliente) — isolamento multi-tenant */
    tenantId, 
    /** Socket.IO server — emite eventos em tempo real para o frontend */
    io, 
    /** Serviço de chatbot (opcional) — processa mensagens recebidas com IA */
    chatbotService) {
        this.sock = sock;
        this.instanceId = instanceId;
        this.tenantId = tenantId;
        this.io = io;
        this.chatbotService = chatbotService;
        /**
         * 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).
         */
        this.peerRecipientHints = new Map();
    }
    /**
     * 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, peerRecipientPn) {
        this.peerRecipientHints.set(msgId, peerRecipientPn);
        setTimeout(() => this.peerRecipientHints.delete(msgId), 60000).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 }) {
        if (type === 'notify') {
            for (const msg of messages) {
                try {
                    await this.processMessage(msg);
                }
                catch (err) {
                    logger_1.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) {
        if (messages.length === 0)
            return;
        // Define o limite de 2 dias atrás em segundos (timestamp do WhatsApp usa segundos)
        const doisDiasAtrasEmSegundos = Math.floor((Date.now() - 2 * 24 * 60 * 60 * 1000) / 1000);
        const mensagensRecentes = [];
        const mensagensAntigas = [];
        // Separa as mensagens por idade
        for (const msg of messages) {
            const ts = Number(msg.messageTimestamp ?? 0);
            if (ts >= doisDiasAtrasEmSegundos) {
                mensagensRecentes.push(msg);
            }
            else {
                mensagensAntigas.push(msg);
            }
        }
        // 1. PROCESSAMENTO DE ALTA PRIORIDADE (Imediato)
        if (mensagensRecentes.length > 0) {
            logger_1.logger.info({
                instanceId: this.instanceId,
                count: mensagensRecentes.length
            }, '[History] Sincronizando conversas recentes (últimos 2 dias) com prioridade máxima!');
            await this.processHistoryBatch(mensagensRecentes);
        }
        // 2. PROCESSAMENTO DE BAIXA PRIORIDADE (Gradual em segundo plano)
        if (mensagensAntigas.length > 0) {
            logger_1.logger.info({
                instanceId: this.instanceId,
                count: mensagensAntigas.length
            }, '[History] Agendando histórico antigo para sincronização gradual em background...');
            this.processOlderHistoryGradually(mensagensAntigas);
        }
    }
    /**
     * Executa o processamento gradual do histórico antigo em lotes de 100 msgs a cada 3 segundos
     */
    processOlderHistoryGradually(messages) {
        const tamanhoDoLote = 100;
        const intervaloMs = 3000; // 3 segundos de folga para a CPU/Postgres respirar
        let offset = 0;
        const processarProximoLote = async () => {
            // Se processou tudo, encerra o ciclo
            if (offset >= messages.length) {
                logger_1.logger.info({ instanceId: this.instanceId }, '[History] Sincronização completa de histórico antigo concluída com sucesso!');
                return;
            }
            // Corta o próximo pedaço do histórico
            const lote = messages.slice(offset, offset + tamanhoDoLote);
            offset += tamanhoDoLote;
            try {
                await this.processHistoryBatch(lote);
            }
            catch (err) {
                logger_1.logger.error({ err, instanceId: this.instanceId }, '[History] Erro ao processar lote gradual de histórico antigo');
            }
            // Agenda o próximo lote de forma não bloqueante
            setTimeout(processarProximoLote, intervaloMs).unref();
        };
        // Inicia o sincronismo em background após 5 segundos, garantindo que
        // a conexão inicial e o frontend já estejam 100% carregados e estáveis
        setTimeout(processarProximoLote, 5000).unref();
    }
    /**
     * Lógica original de agrupamento e escrita no banco (Refatorada em método auxiliar)
     */
    async processHistoryBatch(messages) {
        if (messages.length === 0)
            return;
        // JID próprio da sessão (ex: "5511999@s.whatsapp.net") — usado para filtrar self-chat
        const ownJid = (0, whatsapp_1.normalizeJid)(this.sock.user?.id ?? '');
        // Agrupa por JID para minimizar queries (1 chat upsert + 1 createMany por JID)
        const byJid = new Map();
        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 = (0, whatsapp_1.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.remoteJidAlt;
                if (remoteJidAlt && remoteJidAlt.endsWith('@s.whatsapp.net')) {
                    jidRaw = (0, whatsapp_1.normalizeJid)(remoteJidAlt);
                }
                else {
                    // 2. senderPn / participantPn
                    const senderPn = msg.key.senderPn || msg.key.participantPn;
                    if (senderPn) {
                        jidRaw = (0, whatsapp_1.normalizeJid)(senderPn);
                        if (!jidRaw.endsWith('@s.whatsapp.net'))
                            continue;
                    }
                    else {
                        // 3. DB lookup — mapeamento LID → phone salvo pelo ContactHandler
                        const contact = await prisma_1.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 = (0, whatsapp_1.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_1.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_1.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 = (0, engine_1.getContentType)(message);
                    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 === client_1.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', // históricas são consideradas já lidas
                            timestamp: ts,
                        }];
                });
                if (rows.length > 0) {
                    const { count } = await prisma_1.prisma.message.createMany({ data: rows, skipDuplicates: true });
                    totalSaved += count;
                }
            }
            catch (err) {
                logger_1.logger.error({ err, jid }, '[History] Erro ao salvar mensagens do chat');
            }
        }
        if (totalSaved > 0) {
            logger_1.logger.info({ totalSaved, chats: byJid.size, instanceId: this.instanceId }, `[History] Lote de ${totalSaved} 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)
     */
    async processMessage(msg) {
        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 = null;
        if (key.remoteJid.endsWith('@lid')) {
            const phoneJid = await this.resolveLidToPhoneJid(key.remoteJid, key);
            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_1.logger.warn({ remoteJid: key.remoteJid, instanceId: this.instanceId, fromMe: key.fromMe }, '[MessageHandler] @lid sem resolução de telefone — descartando');
                return;
            }
            originalLidJid = key.remoteJid;
            key.remoteJid = phoneJid;
        }
        const contentType = (0, engine_1.getContentType)(message);
        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 === client_1.MessageType.UNSUPPORTED)
            return;
        const fromMe = key.fromMe ?? false;
        const remoteJidRaw = key.remoteJid;
        /** JID normalizado (sem sufixo de dispositivo :1, :2 etc) */
        const remoteJid = (0, whatsapp_1.normalizeJid)(remoteJidRaw);
        const messageId = key.id;
        // Ignora mensagens de/para o próprio número da sessão (self-chat / notas pessoais)
        const ownJid = (0, whatsapp_1.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.participant ?? null;
        const safePushName = (!fromMe && !isGroupMessage && pushName) ? pushName : null;
        const contact = await prisma_1.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_1.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 * 1000),
            },
            update: {
                contactId: contact.id, // Garante o vínculo (corrige chats órfãos antigos)
                lastMessageAt: new Date(messageTimestamp * 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 = 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 = null;
        if (replyToWaId) {
            const replyMsg = await prisma_1.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_1.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 * 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_1.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
        hook_bus_1.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_1.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
     */
    async resolveLidToPhoneJid(lidJid, key) {
        // 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 = (0, whatsapp_1.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 = (0, whatsapp_1.normalizeJid)(hint);
                if (phoneJid && phoneJid.endsWith('@s.whatsapp.net')) {
                    return phoneJid;
                }
            }
        }
        // 3. Busca no DB por mapeamento LID salvo anteriormente
        const contact = await prisma_1.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.
     */
    async emitChatUpsert(chatId, lastMessage) {
        try {
            const chat = await prisma_1.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,
            });
            // Invalida cache da listagem — força refetch na próxima request da inbox
            (0, chat_cache_1.invalidateChatListCache)(this.tenantId, this.instanceId).catch(() => { });
        }
        catch (err) {
            logger_1.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}
     */
    async downloadAndPersistMedia(msg, savedMsgId, chatId, contentType) {
        try {
            const buffer = await (0, engine_1.downloadMediaMessage)(msg, 'buffer', {}, { logger: logger_1.logger, reuploadRequest: this.sock.updateMediaMessage });
            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;
            if (contentType === 'documentMessage' && originalFileName) {
                ext = path_1.default.extname(originalFileName).replace('.', '') || 'bin';
            }
            else {
                ext = this.extensionForType(contentType);
            }
            const fileName = `${savedMsgId}.${ext}`;
            const filePath = path_1.default.join(MEDIA_DIR, this.instanceId, fileName);
            await promises_1.default.mkdir(path_1.default.dirname(filePath), { recursive: true });
            await promises_1.default.writeFile(filePath, buffer);
            // Tenta upload para Wasabi; se falhar usa path local como fallback
            let mediaUrl = `/media/${this.instanceId}/${fileName}`;
            let mediaPath = filePath;
            let uploadedToRemote = false;
            if (StorageProvider_1.storageProvider.isRegistered()) {
                try {
                    const result = await StorageProvider_1.storageProvider.uploadFile({
                        category: 'whatsapp',
                        usuarioSis: this.tenantId,
                        sessionJID: this.instanceId,
                        file: {
                            originalname: originalFileName ?? fileName,
                            buffer: buffer,
                            mimetype: originalMimetype,
                        },
                    });
                    mediaUrl = result.path;
                    if (result.provider !== 'local_fallback') {
                        mediaPath = null;
                        uploadedToRemote = true;
                    }
                    logger_1.logger.debug({ savedMsgId, path: result.path, provider: result.provider }, 'Mídia enviada ao storage');
                }
                catch (uploadErr) {
                    logger_1.logger.warn({ uploadErr, savedMsgId }, 'Upload Wasabi falhou, usando path local');
                }
            }
            await prisma_1.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) {
                promises_1.default.unlink(filePath).catch((err) => {
                    logger_1.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,
            });
            // Persiste sticker na biblioteca (deduplicado por sha256)
            if (contentType === 'stickerMessage') {
                this.persistSticker(msg, mediaUrl).catch(() => { });
            }
            logger_1.logger.debug({ savedMsgId, filePath }, 'Mídia salva');
        }
        catch (err) {
            logger_1.logger.error({ err, savedMsgId }, 'Falha ao baixar mídia');
        }
    }
    async persistSticker(msg, wasabiPath) {
        const stickerMsg = msg.message?.stickerMessage;
        if (!stickerMsg)
            return;
        const sha256Bytes = stickerMsg.fileSha256;
        if (!sha256Bytes || sha256Bytes.length === 0)
            return;
        const sha256 = Buffer.from(sha256Bytes).toString('hex');
        const isAnimated = stickerMsg.isAnimated ?? false;
        await prisma_1.prisma.sticker.upsert({
            where: { tenantId_sha256: { tenantId: this.tenantId, sha256 } },
            create: {
                tenantId: this.tenantId,
                instanceId: this.instanceId,
                sha256,
                wasabiPath,
                isAnimated,
                useCount: 1,
                lastSeenAt: new Date(),
            },
            update: {
                useCount: { increment: 1 },
                lastSeenAt: new Date(),
                instanceId: this.instanceId,
            },
        });
    }
    // ═══════════════════════════════════════════════════════════════════════════
    //  UTILITÁRIOS
    // ═══════════════════════════════════════════════════════════════════════════
    /** Verifica se o contentType é de mídia (precisa de download) */
    isMediaMessage(contentType) {
        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.
     */
    mapContentType(contentType) {
        const map = {
            conversation: client_1.MessageType.TEXT,
            extendedTextMessage: client_1.MessageType.TEXT,
            imageMessage: client_1.MessageType.IMAGE,
            videoMessage: client_1.MessageType.VIDEO,
            audioMessage: client_1.MessageType.AUDIO,
            documentMessage: client_1.MessageType.DOCUMENT,
            stickerMessage: client_1.MessageType.STICKER,
            locationMessage: client_1.MessageType.LOCATION,
            contactMessage: client_1.MessageType.CONTACT,
            reactionMessage: client_1.MessageType.REACTION,
            pollCreationMessage: client_1.MessageType.POLL,
            pollCreationMessageV2: client_1.MessageType.POLL, // poll multi-select grupos
            pollCreationMessageV3: client_1.MessageType.POLL, // poll single-select
            // Resposta ao clique de botão / seleção de item de lista
            interactiveResponseMessage: client_1.MessageType.INTERACTIVE,
            // Mensagem interativa recebida de outro remetente (botões/lista/carrossel)
            interactiveMessage: client_1.MessageType.BUTTONS,
        };
        return map[contentType] ?? client_1.MessageType.UNSUPPORTED;
    }
    /** Retorna a extensão de arquivo adequada para o tipo de mídia */
    extensionForType(contentType) {
        const map = {
            imageMessage: 'jpg',
            videoMessage: 'mp4',
            audioMessage: 'ogg',
            documentMessage: 'bin',
            stickerMessage: 'webp',
        };
        return map[contentType] ?? 'bin';
    }
    /** Retorna o mimetype adequado para o tipo de mídia */
    mimetypeForType(contentType) {
        const map = {
            imageMessage: 'image/jpeg',
            videoMessage: 'video/mp4',
            audioMessage: 'audio/ogg',
            documentMessage: 'application/octet-stream',
            stickerMessage: 'image/webp',
        };
        return map[contentType] ?? 'application/octet-stream';
    }
}
exports.MessageHandler = MessageHandler;
//# sourceMappingURL=MessageHandler.js.map