/**
 * WhatsApp channel provider using Baileys (WhiskeySockets).
 * Handles connection, QR code flow, message send/receive, and auth persistence.
 */

import makeWASocket, {
  makeCacheableSignalKeyStore,
  fetchLatestBaileysVersion,
  downloadMediaMessage,
  DisconnectReason,
  Browsers,
  type WASocket,
  type BaileysEventMap,
  type WAMessageKey,
} from '@whiskeysockets/baileys';
import fs from 'fs';
import path from 'path';
import QRCode from 'qrcode';
import pino from 'pino';
import { DATA_DIR } from '../../shared/paths.js';
import { log } from '../../shared/logger.js';
import { useAtomicMultiFileAuthState, hasValidCredsFile, flushAuthWrites } from './whatsapp-auth.js';
import type { ChannelProvider, ChannelStatus, ChannelType } from './types.js';

const AUTH_DIR = path.join(DATA_DIR, 'channels', 'whatsapp', 'auth');

/** Image attachment extracted from a WhatsApp message */
export interface WhatsAppImageAttachment {
  mediaType: string;
  data: string; // base64
}

/** Callback when a new message arrives.
 * - sender: who sent it (phone JID, translated from LID where possible)
 * - chatJid: the conversation identifier (group JID for groups, peer JID for 1:1) — reply to this
 * - isGroup: true when the chat is a WhatsApp group (@g.us)
 * - inboundKey: original Baileys message key — used to react/quote/ack the user's message
 */
export type OnWhatsAppMessage = (
  sender: string,
  senderName: string | undefined,
  text: string,
  fromMe: boolean,
  isSelfChat: boolean,
  chatJid: string,
  isGroup: boolean,
  images?: WhatsAppImageAttachment[],
  inboundKey?: WAMessageKey,
) => void;

/** Callback to transcribe audio via whisper */
export type TranscribeFn = (audioBase64: string) => Promise<string | null>;

export class WhatsAppChannel implements ChannelProvider {
  readonly type: ChannelType = 'whatsapp';

  private sock: WASocket | null = null;
  private connected = false;
  private qrData: string | null = null;
  private qrSvg: string | null = null;
  private onMessage: OnWhatsAppMessage;
  private onStatusChange: (status: ChannelStatus) => void;
  private transcribe: TranscribeFn | null = null;
  private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
  private intentionalDisconnect = false;
  /** Monotonic token — bumping it invalidates any in-flight connectInternal(), so a
   *  pending reconnect timer and a manual connect can't race into two live sockets
   *  (two sockets on one auth dir = 440 conflict loops + divergent creds writes). */
  private connectGen = 0;
  /** Consecutive failed reconnects — drives backoff; reset on successful open */
  private reconnectAttempts = 0;
  /** Auto-regenerated QR windows since the last explicit connect — caps the
   *  unattended pairing loop instead of cycling QR codes in the background forever */
  private pairingRetries = 0;
  /** Last pending creds write — drained on disconnect so process exit can't clip it */
  private lastCredsWrite: Promise<unknown> = Promise.resolve();

  /** IDs of messages we sent — used to prevent echo loops */
  private sentMessageIds = new Set<string>();
  private readonly MAX_SENT_IDS = 100;
  /** Active typing indicator intervals per chat JID */
  private typingIntervals = new Map<string, ReturnType<typeof setInterval>>();

  /** Maps LID JIDs to phone JIDs (WhatsApp uses LIDs internally for self-chat) */
  private lidToPhoneMap = new Map<string, string>();
  /** Our own phone JID (number@s.whatsapp.net) */
  private ownPhoneJid: string | null = null;

  constructor(
    onMessage: OnWhatsAppMessage,
    onStatusChange: (status: ChannelStatus) => void,
    transcribe?: TranscribeFn,
  ) {
    this.onMessage = onMessage;
    this.onStatusChange = onStatusChange;
    this.transcribe = transcribe || null;
  }

  async connect(): Promise<void> {
    this.intentionalDisconnect = false;
    this.reconnectAttempts = 0; // explicit connect starts a fresh backoff ladder
    this.pairingRetries = 0;
    await this.connectInternal();
  }

  /** Arm the reconnect timer. connectInternal can reject (disk errors during auth
   *  load) — without the catch + re-arm, a timer-driven failure would strand the
   *  channel offline with no retry until someone manually reconnects. */
  private scheduleReconnect(delay: number): void {
    if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
    this.reconnectTimer = setTimeout(() => {
      this.connectInternal().catch((err: any) => {
        if (this.intentionalDisconnect) return;
        const next = Math.min(5000 * 2 ** this.reconnectAttempts++, 60_000);
        log.warn(`[whatsapp] Reconnect attempt failed: ${err.message} — retrying in ${Math.round(next / 1000)}s`);
        this.scheduleReconnect(next);
      });
    }, delay);
  }

  async disconnect(): Promise<void> {
    this.intentionalDisconnect = true;
    this.connectGen++; // invalidate any connectInternal() still in flight
    if (this.reconnectTimer) {
      clearTimeout(this.reconnectTimer);
      this.reconnectTimer = null;
    }
    if (this.sock) {
      this.sock.end(undefined);
      this.sock = null;
    }
    // Clear all typing intervals
    for (const interval of this.typingIntervals.values()) clearInterval(interval);
    this.typingIntervals.clear();
    // Let in-flight auth writes (creds + signal keys) land before callers proceed to
    // process.exit — writes are atomic now, but a lost update rolls session keys back.
    await Promise.race([
      Promise.all([this.lastCredsWrite, flushAuthWrites(AUTH_DIR)]),
      new Promise((r) => setTimeout(r, 2000)),
    ]);
    this.connected = false;
    this.qrData = null;
    this.qrSvg = null;
    this.emitStatus();
  }

  async sendMessage(to: string, text: string): Promise<void> {
    if (!this.sock || !this.connected) {
      log.warn('[whatsapp] Cannot send — not connected');
      return;
    }
    // Normalize: ensure JID format (number@s.whatsapp.net)
    const jid = to.includes('@') ? to : `${to.replace(/[^0-9]/g, '')}@s.whatsapp.net`;

    // Clear typing indicator before sending
    this.stopTyping(jid);

    const result = await this.sock.sendMessage(jid, { text });

    // Track sent message ID to prevent echo loops
    if (result?.key?.id) {
      this.trackSentId(result.key.id);
    }

    log.info(`[whatsapp] Sent message to ${jid} (id=${result?.key?.id || 'unknown'})`);
  }

  /** Send an image via WhatsApp */
  async sendImage(to: string, image: Buffer, caption?: string, mimetype?: string): Promise<void> {
    if (!this.sock || !this.connected) {
      log.warn('[whatsapp] Cannot send image — not connected');
      return;
    }
    const jid = to.includes('@') ? to : `${to.replace(/[^0-9]/g, '')}@s.whatsapp.net`;
    this.stopTyping(jid);

    const msg: any = { image, mimetype: mimetype || 'image/png' };
    if (caption) msg.caption = caption;

    const result = await this.sock.sendMessage(jid, msg);
    if (result?.key?.id) this.trackSentId(result.key.id);
    log.info(`[whatsapp] Sent image to ${jid} (id=${result?.key?.id || 'unknown'})`);
  }

  /** Send an audio file via WhatsApp. Set voiceNote=true for push-to-talk bubble. */
  async sendAudio(to: string, audio: Buffer, opts?: { mimetype?: string; voiceNote?: boolean }): Promise<void> {
    if (!this.sock || !this.connected) {
      log.warn('[whatsapp] Cannot send audio — not connected');
      return;
    }
    const jid = to.includes('@') ? to : `${to.replace(/[^0-9]/g, '')}@s.whatsapp.net`;
    this.stopTyping(jid);

    const msg: any = {
      audio,
      mimetype: opts?.mimetype || 'audio/mpeg',
      ptt: !!opts?.voiceNote,
    };

    const result = await this.sock.sendMessage(jid, msg);
    if (result?.key?.id) this.trackSentId(result.key.id);
    log.info(`[whatsapp] Sent audio to ${jid} (ptt=${msg.ptt}, mime=${msg.mimetype}, id=${result?.key?.id || 'unknown'})`);
  }

  /** Send a video via WhatsApp */
  async sendVideo(to: string, video: Buffer, caption?: string, mimetype?: string): Promise<void> {
    if (!this.sock || !this.connected) {
      log.warn('[whatsapp] Cannot send video — not connected');
      return;
    }
    const jid = to.includes('@') ? to : `${to.replace(/[^0-9]/g, '')}@s.whatsapp.net`;
    this.stopTyping(jid);

    const msg: any = { video, mimetype: mimetype || 'video/mp4' };
    if (caption) msg.caption = caption;

    const result = await this.sock.sendMessage(jid, msg);
    if (result?.key?.id) this.trackSentId(result.key.id);
    log.info(`[whatsapp] Sent video to ${jid} (id=${result?.key?.id || 'unknown'})`);
  }

  /** Send an emoji reaction onto an existing message.
   *  Pass an empty string to remove a previously-sent reaction (Baileys convention). */
  async sendReaction(chatJid: string, key: WAMessageKey, emoji: string): Promise<void> {
    if (!this.sock || !this.connected) {
      log.warn('[whatsapp] Cannot react — not connected');
      return;
    }
    if (!key || !key.id) {
      log.warn('[whatsapp] Cannot react — missing message key');
      return;
    }
    try {
      await this.sock.sendMessage(chatJid, { react: { text: emoji, key } });
      log.info(`[whatsapp] Reacted "${emoji}" to ${key.id} in ${chatJid}`);
    } catch (err: any) {
      log.warn(`[whatsapp] Reaction failed: ${err.message}`);
    }
  }

  /** Send a document (PDF, zip, etc.) via WhatsApp */
  async sendDocument(to: string, document: Buffer, fileName: string, mimetype?: string, caption?: string): Promise<void> {
    if (!this.sock || !this.connected) {
      log.warn('[whatsapp] Cannot send document — not connected');
      return;
    }
    const jid = to.includes('@') ? to : `${to.replace(/[^0-9]/g, '')}@s.whatsapp.net`;
    this.stopTyping(jid);

    const msg: any = {
      document,
      mimetype: mimetype || 'application/octet-stream',
      fileName,
    };
    if (caption) msg.caption = caption;

    const result = await this.sock.sendMessage(jid, msg);
    if (result?.key?.id) this.trackSentId(result.key.id);
    log.info(`[whatsapp] Sent document to ${jid} (name=${fileName}, id=${result?.key?.id || 'unknown'})`);
  }

  /** Show "typing..." indicator in a chat. Re-sends every 20s to keep it visible. */
  startTyping(jid: string): void {
    if (!this.sock || !this.connected) return;
    // Clear any existing interval for this chat
    this.stopTyping(jid);

    const send = () => {
      this.sock?.sendPresenceUpdate('composing', jid).catch(() => {});
    };

    send(); // immediate
    this.typingIntervals.set(jid, setInterval(send, 20_000)); // refresh every 20s
  }

  /** Clear "typing..." indicator in a chat */
  stopTyping(jid: string): void {
    const interval = this.typingIntervals.get(jid);
    if (interval) {
      clearInterval(interval);
      this.typingIntervals.delete(jid);
    }
    this.sock?.sendPresenceUpdate('paused', jid).catch(() => {});
  }

  /** Track a sent message ID, evicting oldest when at capacity */
  private trackSentId(id: string) {
    this.sentMessageIds.add(id);
    if (this.sentMessageIds.size > this.MAX_SENT_IDS) {
      // Delete the first (oldest) entry
      const first = this.sentMessageIds.values().next().value;
      if (first) this.sentMessageIds.delete(first);
    }
  }

  getStatus(): ChannelStatus {
    return {
      channel: 'whatsapp',
      connected: this.connected,
      info: {
        hasQr: !!this.qrData,
        hasCredentials: this.hasCredentials(),
        phoneNumber: this.sock?.user?.id?.split(':')[0] || null,
      },
    };
  }

  getQrCode(): string | null {
    return this.qrSvg;
  }

  /** Request a pairing code for phone-number-based linking (alternative to QR scan) */
  async requestPairingCode(phoneNumber: string): Promise<string> {
    if (!this.sock) throw new Error('WhatsApp socket not initialized — call connect() first');
    if (this.connected) throw new Error('Already connected — no pairing needed');

    // Digits only, no + or dashes
    const cleaned = phoneNumber.replace(/[^0-9]/g, '');
    if (cleaned.length < 8 || cleaned.length > 15) {
      throw new Error('Invalid phone number — use digits with country code (e.g. 5511999998888)');
    }

    log.info(`[whatsapp] Requesting pairing code for ${cleaned}...`);
    const code = await this.sock.requestPairingCode(cleaned);
    log.info(`[whatsapp] Pairing code generated: ${code}`);
    return code;
  }

  hasCredentials(): boolean {
    // Validates content, not just existence — a 0-byte creds.json (interrupted legacy
    // write) must not count as "linked", or boot lands in a silent QR loop.
    return hasValidCredsFile(AUTH_DIR);
  }

  /** Delete stored credentials (for re-auth / logout) */
  async deleteCredentials(): Promise<void> {
    try {
      if (fs.existsSync(AUTH_DIR)) {
        fs.rmSync(AUTH_DIR, { recursive: true, force: true });
      }
    } catch (err: any) {
      log.warn(`[whatsapp] Failed to delete credentials: ${err.message}`);
    }
  }

  // ── Internal ──

  /** Translate a JID from LID format to phone format if possible.
   *  Falls back to the supplied `alt` (Baileys 7+ `key.participantAlt`/`remoteJidAlt`) which
   *  carries the phone form when the primary identifier is a LID. */
  private translateJid(jid: string, alt?: string | null): string {
    // If it's already a phone JID, return as-is
    if (jid.endsWith('@s.whatsapp.net')) return jid;

    // Check learned LID map first (covers our own LID + any pairs we've seen)
    const mapped = this.lidToPhoneMap.get(jid);
    if (mapped) return mapped;

    // Baileys 7 ships the alternate identifier on the message key. If the primary
    // is a LID, the alt is the phone-number form — adopt it and learn the mapping
    // for future messages on this conversation.
    if (alt && alt.endsWith('@s.whatsapp.net')) {
      this.lidToPhoneMap.set(jid, alt);
      // Also map the bare LID number → phone, so different LID encodings collapse
      const lidNum = jid.split(':')[0].split('@')[0];
      if (lidNum) this.lidToPhoneMap.set(`${lidNum}@lid`, alt);
      return alt;
    }

    // Unknown LID — don't guess. Return as-is so isSelfChat stays false.
    return jid;
  }

  /** Build the LID-to-phone mapping from sock.user */
  private buildLidMap() {
    if (!this.sock?.user) return;

    const user = this.sock.user;
    // user.id is "phone:device@s.whatsapp.net" — extract phone
    const phone = user.id.split(':')[0];
    this.ownPhoneJid = `${phone}@s.whatsapp.net`;

    // user.lid (if available) is the LID JID — map it and variations
    const lid = (user as any).lid;
    if (lid) {
      // Map the exact LID
      this.lidToPhoneMap.set(lid, this.ownPhoneJid);
      // Also map the numeric part without @lid suffix (remoteJid may use @lid format)
      const lidNum = lid.split(':')[0].split('@')[0];
      this.lidToPhoneMap.set(`${lidNum}@lid`, this.ownPhoneJid);
      log.info(`[whatsapp] LID map: ${lid} (and ${lidNum}@lid) → ${this.ownPhoneJid}`);
    }

    log.info(`[whatsapp] Own phone JID: ${this.ownPhoneJid}`);
  }

  private async connectInternal(): Promise<void> {
    const gen = ++this.connectGen;
    // A pending reconnect must not fire on top of this attempt — clear it so the
    // timer and a manual connect can't produce two sockets on the same auth dir.
    if (this.reconnectTimer) {
      clearTimeout(this.reconnectTimer);
      this.reconnectTimer = null;
    }

    // Clean up any existing socket before creating a new one
    if (this.sock) {
      try { this.sock.ev.removeAllListeners('creds.update'); } catch {}
      try { this.sock.ev.removeAllListeners('connection.update'); } catch {}
      try { this.sock.ev.removeAllListeners('messages.upsert'); } catch {}
      try { this.sock.end(undefined); } catch {}
      this.sock = null;
    }

    // Ensure auth directory exists
    fs.mkdirSync(AUTH_DIR, { recursive: true });

    // Atomic-write auth state — survives the kills/crashes that used to truncate
    // creds.json, and recovers from creds.json.bak when the primary is corrupt.
    const { state, saveCreds, freshIdentity } = await useAtomicMultiFileAuthState(AUTH_DIR);
    if (gen !== this.connectGen || this.intentionalDisconnect) return; // superseded while loading auth
    if (freshIdentity) log.info('[whatsapp] No valid credentials — starting pairing flow');

    // Suppress Baileys' noisy logging
    const logger = pino({ level: 'silent' }) as any;

    let version: [number, number, number] | undefined;
    try {
      // fetchLatestBaileysVersion = newest WA Web version the LIBRARY supports.
      // (fetchLatestWaWebVersion scrapes web.whatsapp.com and can return a protocol
      // revision newer than Baileys handles — a disconnect source.)
      const result = await fetchLatestBaileysVersion();
      version = result.version;
    } catch {
      log.warn('[whatsapp] Could not fetch latest WA version — using default');
    }
    if (gen !== this.connectGen || this.intentionalDisconnect) return; // superseded during version fetch

    const sock = makeWASocket({
      auth: {
        creds: state.creds,
        keys: makeCacheableSignalKeyStore(state.keys, logger),
      },
      version,
      browser: Browsers.macOS('Chrome'),
      logger,
      markOnlineOnConnect: false, // keep message notifications on the phone
      generateHighQualityLinkPreview: false,
      // Baileys 7.x retry handshake: without this, inbound messages that need an
      // E2EE session re-establishment are silently dropped (msg.message === null).
      getMessage: async () => ({ conversation: '' }),
    });

    this.sock = sock;

    // Persist credential updates (fires constantly while connected — key rotations,
    // app-state sync). Track the promise so disconnect() can drain it before exit.
    sock.ev.on('creds.update', () => {
      this.lastCredsWrite = saveCreds().catch((err: any) => {
        log.warn(`[whatsapp] Failed to persist credentials: ${err.message}`);
      });
    });

    // Connection state changes
    sock.ev.on('connection.update', async (update) => {
      if (this.sock !== sock) return; // stale event from a superseded socket
      const { connection, lastDisconnect, qr } = update;

      // QR code received — render to SVG
      if (qr) {
        this.qrData = qr;
        try {
          this.qrSvg = await QRCode.toString(qr, { type: 'svg' });
        } catch {
          this.qrSvg = null;
        }
        log.info('[whatsapp] QR code generated — waiting for scan');
        this.emitStatus();
      }

      if (connection === 'open') {
        this.connected = true;
        this.reconnectAttempts = 0;
        this.pairingRetries = 0;
        this.qrData = null;
        this.qrSvg = null;
        this.buildLidMap();
        // Set presence to unavailable so the phone doesn't show "online" constantly
        sock.sendPresenceUpdate('unavailable').catch(() => {});
        log.ok(`[whatsapp] Connected as ${sock.user?.id}`);
        this.emitStatus();
      }

      if (connection === 'close') {
        this.connected = false;
        this.qrData = null;
        this.qrSvg = null;

        const statusCode = (lastDisconnect?.error as any)?.output?.statusCode;
        const reason = DisconnectReason[statusCode] || `code ${statusCode}`;
        log.warn(`[whatsapp] Disconnected: ${reason}`);

        if (this.intentionalDisconnect) return;

        // Logged out (401) — credentials are invalid, user must re-scan
        if (statusCode === DisconnectReason.loggedOut) {
          log.warn('[whatsapp] Logged out — credentials cleared. Re-scan QR to reconnect.');
          await this.deleteCredentials();
          this.emitStatus();
          return;
        }

        // Connection replaced (440) — another instance took over intentionally, don't fight it
        if (statusCode === DisconnectReason.connectionReplaced) {
          log.info('[whatsapp] Connection replaced by a new instance — not reconnecting');
          this.emitStatus();
          return;
        }

        // restartRequired (515) is the server's reconnect-now handoff. In the QR flow it
        // arrives right after pair-success, while creds.registered is STILL false (that
        // flag is only set in the pairing-code flow, messages-recv link_code_pairing_ref)
        // — so it MUST be handled before any unpaired/phantom logic, or a successful
        // scan gets thrown away. Backoff guards a pathological repeated-515 loop.
        if (statusCode === DisconnectReason.restartRequired) {
          const delay = Math.min(1000 * 2 ** this.reconnectAttempts++, 60_000);
          log.info(`[whatsapp] Restart required (normal after pairing) — reconnecting in ${delay}ms...`);
          this.emitStatus();
          this.scheduleReconnect(delay);
          return;
        }

        // Pure QR wait expired without anyone claiming an identity — regenerate a fresh
        // QR a couple of times (the link page is likely still open), then stop instead
        // of cycling QR codes in the background forever.
        if (!state.creds.registered && !state.creds.me) {
          if (this.pairingRetries < 2) {
            this.pairingRetries++;
            log.info(`[whatsapp] QR expired — generating a fresh one (retry ${this.pairingRetries}/2)`);
            this.emitStatus();
            this.scheduleReconnect(2000);
          } else {
            log.info('[whatsapp] Pairing window closed without a scan — connect again to retry');
            this.emitStatus();
          }
          return;
        }

        // Anything else reconnects with backoff (5s → 60s). This includes the
        // me-set-but-unregistered states: a real post-pairing identity completes its
        // login on reconnect, and a stale pairing-code placeholder gets a 401 from the
        // server — which lands in the loggedOut branch above and wipes it cleanly.
        const delay = Math.min(5000 * 2 ** this.reconnectAttempts++, 60_000);
        log.info(`[whatsapp] Reconnecting in ${Math.round(delay / 1000)}s...`);
        this.emitStatus();
        this.scheduleReconnect(delay);
      }
    });

    // Incoming messages
    sock.ev.on('messages.upsert', async (m: BaileysEventMap['messages.upsert']) => {
      if (m.type !== 'notify') return;

      for (const msg of m.messages) {
        // Skip status broadcasts and protocol-only messages.
        // Groups are passed through — the manager filters them based on channel config (allowGroups).
        if (msg.key.remoteJid === 'status@broadcast') continue;
        if (msg.key.remoteJid?.endsWith('@newsletter')) continue; // channels/newsletters
        if (!msg.message) continue;

        // Echo prevention: skip messages we sent ourselves
        if (msg.key.id && this.sentMessageIds.has(msg.key.id)) {
          this.sentMessageIds.delete(msg.key.id);
          continue;
        }

        // Extract text — or transcribe audio if it's a voice note
        let rawText = this.extractText(msg.message);
        const images: WhatsAppImageAttachment[] = [];

        // Download image if present
        if (this.isImageMessage(msg.message)) {
          try {
            const buffer = await downloadMediaMessage(msg, 'buffer', {}) as Buffer;
            const mimeType = this.getImageMimeType(msg.message) || 'image/jpeg';
            const base64 = buffer.toString('base64');
            images.push({ mediaType: mimeType, data: base64 });
            log.info(`[whatsapp] Downloaded image (${Math.round(buffer.length / 1024)}KB, ${mimeType})`);
          } catch (err: any) {
            log.warn(`[whatsapp] Image download failed: ${err.message}`);
          }
        }

        if (!rawText && this.isAudioMessage(msg.message)) {
          // Voice note / audio — download and transcribe
          if (!this.transcribe) {
            log.info('[whatsapp] Audio message received but no transcribe function configured — skipping');
            await this.sendMessage(msg.key.remoteJid!, 'Whisper not enabled, click on the 3 dots settings on the Chat of your bloby and provide an OpenAI API key.');
            continue;
          }
          try {
            const buffer = await downloadMediaMessage(msg, 'buffer', {}) as Buffer;
            const base64 = buffer.toString('base64');
            log.info(`[whatsapp] Transcribing audio (${Math.round(buffer.length / 1024)}KB)...`);
            const transcript = await this.transcribe(base64);
            if (!transcript) {
              log.warn('[whatsapp] Transcription returned empty — skipping');
              await this.sendMessage(msg.key.remoteJid!, 'Whisper not enabled, click on the 3 dots settings on the Chat of your bloby and provide an OpenAI API key.');
              continue;
            }
            rawText = transcript;
            log.info(`[whatsapp] Transcribed: "${rawText.slice(0, 80)}"`);
          } catch (err: any) {
            log.warn(`[whatsapp] Audio transcription failed: ${err.message}`);
            continue;
          }
        }

        // Skip if no text AND no images; otherwise default text for image-only
        // messages. Collapsing both branches also narrows `rawText` to `string`.
        if (!rawText) {
          if (images.length === 0) continue;
          rawText = '(image)';
        }

        // Escape special characters to prevent prompt injection via message content
        const text = this.escapeMessageText(rawText);

        const fromMe = msg.key.fromMe || false;
        const rawSender = msg.key.remoteJid || '';
        const participant = msg.key.participant || '';
        const isGroup = rawSender.endsWith('@g.us');

        // Baileys 7 exposes the alternate identifier on the key — when the primary is a LID,
        // the alt is the phone-number form (and vice versa). Use these to translate cleanly.
        const remoteJidAlt = (msg.key as any).remoteJidAlt as string | undefined;
        const participantAlt = (msg.key as any).participantAlt as string | undefined;

        // chatJid: where to reply (group JID for groups, peer JID otherwise).
        // For peer chats, prefer the phone-form JID so reactions/replies hit the canonical chat.
        const chatJid = isGroup
          ? rawSender
          : this.translateJid(rawSender, remoteJidAlt);

        // The actual sender JID:
        //   - groups: always `participant` (remoteJid is the group)
        //   - 1:1:   `participant` if Baileys provided one (newer protocol), else remoteJid
        const actualSender = isGroup
          ? participant || rawSender
          : (participant || rawSender);
        const senderAlt = isGroup ? participantAlt : (participantAlt || remoteJidAlt);

        // Translate LID → phone via the learned map + the message's `*Alt` fallback.
        const sender = this.translateJid(actualSender, senderAlt);
        const pushName = msg.pushName || undefined;

        // Self-chat: only meaningful for 1:1. True when the (translated) chat JID is our own number
        // AND no group participant. Both `participant` and `participantAlt` must be absent — Baileys
        // sets `participant` on newer 1:1 messages too, so we additionally accept when the participant
        // (or its alt) resolves to our own phone.
        const participantResolved = participant ? this.translateJid(participant, senderAlt) : '';
        const ownsChat = this.ownPhoneJid !== null && chatJid === this.ownPhoneJid;
        const ownsParticipant = !participant || participantResolved === this.ownPhoneJid;
        const isSelfChat = !isGroup && ownsChat && ownsParticipant;

        log.info(`[whatsapp] Message from ${sender} (chat=${chatJid}, group=${isGroup}, fromMe=${fromMe}, selfChat=${isSelfChat}, images=${images.length}): ${text.slice(0, 80)}`);

        this.onMessage(
          sender,
          pushName,
          text,
          fromMe,
          isSelfChat,
          chatJid,
          isGroup,
          images.length > 0 ? images : undefined,
          msg.key,
        );
      }
    });
  }

  /** Extract text content from a Baileys message object */
  private extractText(message: any): string | null {
    if (!message) return null;

    // Direct text
    if (message.conversation) return message.conversation;
    if (message.extendedTextMessage?.text) return message.extendedTextMessage.text;

    // Image/video/document captions
    if (message.imageMessage?.caption) return message.imageMessage.caption;
    if (message.videoMessage?.caption) return message.videoMessage.caption;
    if (message.documentMessage?.caption) return message.documentMessage.caption;

    // View-once wrappers
    if (message.viewOnceMessage?.message) return this.extractText(message.viewOnceMessage.message);
    if (message.viewOnceMessageV2?.message) return this.extractText(message.viewOnceMessageV2.message);

    // Ephemeral wrapper
    if (message.ephemeralMessage?.message) return this.extractText(message.ephemeralMessage.message);

    // Edited message
    if (message.editedMessage?.message) return this.extractText(message.editedMessage.message);
    if (message.protocolMessage?.editedMessage?.message) {
      return this.extractText(message.protocolMessage.editedMessage.message);
    }

    return null;
  }

  /** Check if a message contains an image */
  private isImageMessage(message: any): boolean {
    if (!message) return false;
    if (message.imageMessage) return true;
    if (message.viewOnceMessage?.message?.imageMessage) return true;
    if (message.viewOnceMessageV2?.message?.imageMessage) return true;
    if (message.ephemeralMessage?.message?.imageMessage) return true;
    return false;
  }

  /** Extract the MIME type from an image message */
  private getImageMimeType(message: any): string | null {
    if (!message) return null;
    if (message.imageMessage?.mimetype) return message.imageMessage.mimetype;
    if (message.viewOnceMessage?.message?.imageMessage?.mimetype) return message.viewOnceMessage.message.imageMessage.mimetype;
    if (message.viewOnceMessageV2?.message?.imageMessage?.mimetype) return message.viewOnceMessageV2.message.imageMessage.mimetype;
    if (message.ephemeralMessage?.message?.imageMessage?.mimetype) return message.ephemeralMessage.message.imageMessage.mimetype;
    return null;
  }

  /** Check if a message contains audio (voice note or audio file) */
  private isAudioMessage(message: any): boolean {
    if (!message) return false;
    if (message.audioMessage) return true;
    // Check inside wrappers
    if (message.viewOnceMessage?.message?.audioMessage) return true;
    if (message.viewOnceMessageV2?.message?.audioMessage) return true;
    if (message.ephemeralMessage?.message?.audioMessage) return true;
    return false;
  }

  /** Escape message text to prevent prompt injection via special characters */
  private escapeMessageText(text: string): string {
    return text
      .replace(/\[WhatsApp\s*\|/gi, '(WhatsApp|')  // prevent faking channel context tags
      .replace(/\[Telegram\s*\|/gi, '(Telegram|')
      .replace(/\[\s*(admin|customer)\s*\]/gi, '($1)');  // prevent faking role tags
  }

  private emitStatus() {
    this.onStatusChange(this.getStatus());
  }
}
