import os from "node:os";
import {
  makeWASocket,
  useMultiFileAuthState,
  fetchLatestBaileysVersion,
  makeCacheableSignalKeyStore,
  downloadMediaMessage,
  DisconnectReason,
  type WAMessage,
  type proto,
  isJidGroup,
  jidNormalizedUser,
} from "@whiskeysockets/baileys";
import P from "pino";
import path from "node:path";
import fs from "node:fs";
import { execFileSync } from "node:child_process";
import { emitEvent } from "./events.ts";
import { buildReadKeys } from "./whatsappReadKeys.ts";
export { buildReadKeys } from "./whatsappReadKeys.ts";

import {
  initializeDatabase,
  storeMessage,
  storeChat,
  storeContact,
  getContactName,
  getOldestMessage,
  getBackfillMessages,
  storeRawMessage,
  getRawMessage,
  getRawMessageContent,
  getRecentInboundMessages,
  type Message as DbMessage,
} from "./database.ts";

const ACCOUNT_NAME = process.env.WHATSAPP_ACCOUNT_NAME || "default";
const AUTH_DIR = process.env.WHATSAPP_AUTH_DIR ||
  path.join(process.env.HOME || os.homedir(), ".whatsapp-baileys", ACCOUNT_NAME, "auth_info");

export type WhatsAppSocket = ReturnType<typeof makeWASocket>;

// The connection reconnects often (each reconnect = a NEW socket). main.ts captured the socket once
// at startup, so after any reconnect it was sending via a dead socket → messages silently failed.
// Track the LIVE socket here and always send through it.
let currentSock: WhatsAppSocket | null = null;
export function getCurrentSocket(): WhatsAppSocket | null { return currentSock; }
let connectionStable = false;   // true only while the socket is open — gate network calls (avatars) on this
let reconnecting = false;       // guard against stacking overlapping reconnections

// Media is downloaded to a per-account folder and the local path is emitted so the app can play/render it.
const MEDIA_DIR = path.join(
  process.env.HOME || os.homedir(),
  "Library", "Application Support", "DewLink", "media", ACCOUNT_NAME,
);
const MEDIA_EXT: Record<string, string> = { audio: "ogg", image: "jpg", video: "mp4", gif: "mp4", document: "bin", sticker: "webp" };
// WhatsApp voice notes are OGG/Opus, which AVAudioPlayer can't decode — transcode to AAC/.m4a when ffmpeg exists.
const FFMPEG = ["/opt/homebrew/bin/ffmpeg", "/usr/local/bin/ffmpeg", "/usr/bin/ffmpeg"].find((p) => fs.existsSync(p));

const AVATAR_DIR = path.join(MEDIA_DIR, "avatars");
const fetchedAvatars = new Set<string>();   // jids we've already tried this session
const pendingAvatars = new Set<string>();   // jids requested before the connection was stable — retried on open
const MEDIA_DOWNLOAD_ATTEMPTS = 3;           // first try + two retries for expired/reuploadable media
const MEDIA_DOWNLOAD_RETRY_MS = 350;
const MEDIA_BACKFILL_TIMEOUT_MS = 12_000;
const pendingMediaBackfills = new Map<string, NodeJS.Timeout>();

export async function markChatRead(chatJid: string, logger: P.Logger, messageIds?: string[]): Promise<void> {
  const active = currentSock as any;
  if (!active || !active.user) {
    logger.warn({ chatJid }, "Cannot mark chat read: WhatsApp socket not connected.");
    return;
  }
  try {
    const rows = messageIds?.length ? [] : getRecentInboundMessages(chatJid, 10);
    const keys = buildReadKeys(chatJid, rows, messageIds?.length ? messageIds : undefined);
    if (!keys.length) return;
    await active.readMessages(keys);
  } catch (error: any) {
    logger.warn({ err: error?.message, chatJid }, "mark chat read failed");
  }
}

/// Fetch a contact/group profile picture once per jid, save it locally, return the path (or null).
export async function fetchAvatar(jid: string, logger: P.Logger): Promise<string | null> {
  if (!jid || fetchedAvatars.has(jid)) return null;
  const active = currentSock as any;
  if (!active || !connectionStable) { pendingAvatars.add(jid); return null; }   // queue until stable
  try {
    // WhatsApp now stores contacts as @lid (privacy). profilePictureUrl needs the phone jid, so resolve it.
    let lookupJid = jid;
    let pn: string | null = null;
    if (jid.endsWith("@lid")) {
      pn = await active.signalRepository?.lidMapping?.getPNForLID?.(jid).catch((e: any) => { logger.info(`[avatar] getPNForLID err ${e?.message}`); return null; });
      if (pn) {
        lookupJid = jidNormalizedUser(pn);   // strip the :0 device suffix — the photo API wants the bare jid
        const phone = lookupJid.replace(/@.*/, "");   // digits — for address-book (Apple/Google) matching
        if (phone) emitEvent({ type: "phone", jid, phone });
      } else { pendingAvatars.add(jid); return null; }   // map not filled yet → retry on a later sync
    }
    fetchedAvatars.add(jid);
    // "preview" (thumbnail) is far more widely accessible than the full "image"; try both, both jids.
    let url = await active.profilePictureUrl(lookupJid, "preview").catch((e: any) => { logger.info(`[avatar] ppUrl err ${e?.message}`); return null; });
    if (!url) url = await active.profilePictureUrl(lookupJid, "image").catch(() => null);
    if (!url && lookupJid !== jid) url = await active.profilePictureUrl(jid, "preview").catch(() => null);
    logger.info(`[avatar] jid=${jid} pn=${pn ?? "-"} url=${url ? "YES" : "no"}`);
    if (!url) return null;   // genuinely no accessible photo (privacy) — leave a clean fallback tile
    fs.mkdirSync(AVATAR_DIR, { recursive: true });
    const dest = path.join(AVATAR_DIR, `${jid.replace(/[^A-Za-z0-9_-]/g, "")}.jpg`);   // keyed by original jid
    const res = await fetch(url);
    if (!res.ok) return null;
    const buf = Buffer.from(await res.arrayBuffer());
    fs.writeFileSync(dest, buf);
    return dest;
  } catch (e: any) {
    logger.warn({ err: e?.message }, "avatar fetch failed");
    return null;
  }
}

/// Retry the queued avatars (e.g. @lid contacts whose phone mapping wasn't known yet). Called on
/// connect and periodically — the LID↔PN map fills as messages decrypt, so later passes resolve more.
export function drainPendingAvatars(logger: P.Logger): void {
  if (!connectionStable || pendingAvatars.size === 0) return;
  const queued = [...pendingAvatars]; pendingAvatars.clear();
  logger.info(`[avatar] draining ${queued.length}`);
  for (const j of queued) fetchAvatar(j, logger).then((p) => { if (p) emitEvent({ type: "avatar", jid: j, path: p }); });
}

function sleep(ms: number): Promise<void> {
  return new Promise((resolve) => setTimeout(resolve, ms));
}

export function unwrapMessage(message: proto.IMessage | null | undefined): proto.IMessage | undefined {
  let current = message ?? undefined;
  for (let i = 0; i < 8 && current; i++) {
    const protocolEdited = current.protocolMessage?.editedMessage as
      | (proto.IMessage & { message?: proto.IMessage | null })
      | null
      | undefined;
    const next =
      current.ephemeralMessage?.message ??
      current.viewOnceMessage?.message ??
      current.viewOnceMessageV2?.message ??
      current.viewOnceMessageV2Extension?.message ??
      current.documentWithCaptionMessage?.message ??
      current.editedMessage?.message ??
      protocolEdited?.message ??
      protocolEdited;

    if (!next || next === current) break;
    current = next;
  }
  return current;
}

export function mediaType(msg: WAMessage): string | null {
  const m = unwrapMessage(msg.message);
  if (!m) return null;
  if (m.audioMessage) return "audio";
  if (m.imageMessage) return "image";
  if (m.videoMessage) return m.videoMessage.gifPlayback ? "gif" : "video";
  if (m.documentMessage) return "document";
  if (m.stickerMessage) return "sticker";
  return null;
}

function clean(parts: Array<string | null | undefined>): string {
  return parts.map((p) => (p ?? "").trim()).filter(Boolean).join(" · ");
}

function coordinates(lat?: number | null, lon?: number | null): string {
  if (typeof lat !== "number" || typeof lon !== "number") return "";
  return `${lat.toFixed(6)}, ${lon.toFixed(6)}`;
}

function locationText(
  prefix: "Location" | "Live location",
  loc: proto.Message.ILocationMessage | proto.Message.ILiveLocationMessage,
): string {
  const title = "name" in loc ? loc.name : undefined;
  const address = "address" in loc ? loc.address : undefined;
  const comment = "comment" in loc ? loc.comment : undefined;
  const caption = "caption" in loc ? loc.caption : undefined;
  const url = "url" in loc ? loc.url : undefined;
  const label = clean([title, address, comment, caption, url, coordinates(loc.degreesLatitude, loc.degreesLongitude)]);
  return label ? `[${prefix}] ${label}` : `[${prefix}]`;
}

function vcardField(vcard: string | null | undefined, field: string): string {
  if (!vcard) return "";
  const line = vcard.split(/\r?\n/).find((l) => l.toUpperCase().startsWith(`${field.toUpperCase()}:`));
  return line?.split(":").slice(1).join(":").trim() ?? "";
}

function contactName(c: proto.Message.IContactMessage | null | undefined): string {
  return clean([c?.displayName, vcardField(c?.vcard, "FN"), vcardField(c?.vcard, "TEL")]);
}

function contactsText(arr: proto.Message.IContactsArrayMessage): string {
  const names = (arr.contacts ?? []).map(contactName).filter(Boolean);
  const display = arr.displayName || names.slice(0, 3).join(", ");
  const suffix = names.length > 3 ? ` +${names.length - 3} more` : "";
  return display ? `[Contacts] ${display}${suffix}` : `[Contacts] ${names.length || ""}`.trim();
}

function pollText(poll: proto.Message.IPollCreationMessage | null | undefined): string {
  const options = (poll?.options ?? []).map((o) => o.optionName).filter(Boolean);
  const suffix = options.length ? ` · ${options.slice(0, 4).join(", ")}${options.length > 4 ? "..." : ""}` : "";
  return poll?.name ? `[Poll] ${poll.name}${suffix}` : "[Poll]";
}

function unsupportedType(inner: proto.IMessage | undefined): string | null {
  if (!inner) return null;
  const ignored = new Set(["messageContextInfo"]);
  for (const [key, value] of Object.entries(inner as Record<string, unknown>)) {
    if (ignored.has(key)) continue;
    if (value != null) return key;
  }
  return Object.entries(inner as Record<string, unknown>).some(([, value]) => value != null)
    ? "messageContextInfo"
    : null;
}

function mediaTypeFromContent(content: string): string | null {
  const marker = content.match(/^\[(Image|Video|GIF|Audio|Sticker|Document)\]/)?.[1]?.toLowerCase();
  return marker ?? null;
}

export function reactionForMessage(msg: WAMessage): { targetId: string; emoji: string } | null {
  const reaction = unwrapMessage(msg.message)?.reactionMessage;
  const targetId = reaction?.key?.id;
  if (!targetId) return null;
  return { targetId, emoji: reaction.text ?? "" };
}

async function downloadMediaWithRetry(msg: WAMessage, sock: WhatsAppSocket, logger: P.Logger, type: string): Promise<Buffer> {
  let lastError: unknown;
  for (let attempt = 1; attempt <= MEDIA_DOWNLOAD_ATTEMPTS; attempt++) {
    try {
      const buf = await downloadMediaMessage(msg, "buffer", {}, {
        logger,
        reuploadRequest: (m) => sock.updateMediaMessage(m),
      });
      return buf as Buffer;
    } catch (e: any) {
      lastError = e;
      logger.warn(
        { err: e, msgId: msg.key?.id, mediaType: type, attempt, attempts: MEDIA_DOWNLOAD_ATTEMPTS },
        `media download attempt failed (${type})`,
      );
      if (attempt < MEDIA_DOWNLOAD_ATTEMPTS) {
        await sleep(MEDIA_DOWNLOAD_RETRY_MS * attempt);
      }
    }
  }
  throw lastError;
}

/// Download a message's media to MEDIA_DIR and return the local path (idempotent; null on failure).
/// Audio is transcoded Opus→AAC (.m4a) when ffmpeg is present so it plays in AVAudioPlayer.
async function saveMedia(msg: WAMessage, sock: WhatsAppSocket, logger: P.Logger, type: string): Promise<string | null> {
  try {
    fs.mkdirSync(MEDIA_DIR, { recursive: true });
    const id = (msg.key?.id || String(Date.now())).replace(/[^A-Za-z0-9_-]/g, "");

    if (type === "audio" && FFMPEG) {
      const m4a = path.join(MEDIA_DIR, `${id}.m4a`);
      if (fs.existsSync(m4a)) return m4a;
      const ogg = path.join(MEDIA_DIR, `${id}.ogg`);
      const buf = await downloadMediaWithRetry(msg, sock, logger, type);
      fs.writeFileSync(ogg, buf as Buffer);
      try {
        execFileSync(FFMPEG, ["-y", "-i", ogg, "-c:a", "aac", "-b:a", "96k", m4a], { stdio: "ignore" });
        fs.unlinkSync(ogg);
        return m4a;
      } catch {
        return ogg; // transcode failed — hand back the raw file
      }
    }

    let ext = MEDIA_EXT[type] || "bin";
    if (type === "document") {
      const fn = unwrapMessage(msg.message)?.documentMessage?.fileName || "";
      const e = fn.includes(".") ? fn.split(".").pop()!.toLowerCase().slice(0, 8) : "";
      if (e) ext = e;
    }
    const dest = path.join(MEDIA_DIR, `${id}.${ext}`);
    if (fs.existsSync(dest)) return dest; // already downloaded — don't refetch
    const buf = await downloadMediaWithRetry(msg, sock, logger, type);
    fs.writeFileSync(dest, buf as Buffer);
    return dest;
  } catch (e: any) {
    logger.warn({ err: e?.message }, `media download failed (${type})`);
    return null;
  }
}

function mediaBackfillKey(chatJid: string, messageId: string): string {
  return `${chatJid}\n${messageId}`;
}

function clearPendingMediaBackfill(chatJid: string, messageId: string): void {
  const key = mediaBackfillKey(chatJid, messageId);
  const timer = pendingMediaBackfills.get(key);
  if (timer) clearTimeout(timer);
  pendingMediaBackfills.delete(key);
}

function hasPendingMediaBackfill(msg: WAMessage): boolean {
  const chatJid = msg.key?.remoteJid;
  const messageId = msg.key?.id;
  return !!chatJid && !!messageId && pendingMediaBackfills.has(mediaBackfillKey(chatJid, messageId));
}

function emitMediaFailure(providerMsgId: string, mediaType: string | null | undefined, error: string): void {
  emitEvent({ type: "media", providerMsgId, state: "failed", mediaType: mediaType ?? undefined, error });
}

function resolveStoredMediaMessage(chatJid: string, messageId: string): WAMessage | null {
  const stored = getRawMessage(chatJid, messageId);
  if (!stored?.message) return null;
  return stored;
}

export async function retryMediaDownload(
  chatJid: string,
  messageId: string,
  fromMe: boolean,
  logger: P.Logger,
  participant?: string | null,
): Promise<void> {
  const active = currentSock;
  if (!active || !active.user) {
    emitMediaFailure(messageId, null, "WhatsApp engine is not connected.");
    return;
  }

  const stored = resolveStoredMediaMessage(chatJid, messageId);
  if (stored) {
    const type = mediaType(stored);
    if (!type) {
      emitMediaFailure(messageId, null, "This message no longer has downloadable media.");
      return;
    }
    const mediaPath = await saveMedia(stored, active, logger, type);
    if (mediaPath) {
      emitEvent({ type: "media", providerMsgId: messageId, state: "success", mediaType: type, mediaPath });
    } else {
      emitMediaFailure(messageId, type, "Couldn't fetch this media from WhatsApp. It may have expired or the phone could not re-upload it.");
    }
    return;
  }

  const requestPlaceholderResend = (active as any).requestPlaceholderResend;
  if (typeof requestPlaceholderResend !== "function") {
    emitMediaFailure(messageId, null, "This Baileys build cannot request old message content from the phone.");
    return;
  }

  const key = mediaBackfillKey(chatJid, messageId);
  clearPendingMediaBackfill(chatJid, messageId);
  const timer = setTimeout(() => {
    pendingMediaBackfills.delete(key);
    emitMediaFailure(messageId, null, "Couldn't fetch this media from WhatsApp. The message may be too old or unavailable on the phone.");
  }, MEDIA_BACKFILL_TIMEOUT_MS);
  pendingMediaBackfills.set(key, timer);

  try {
    await requestPlaceholderResend.call(active, { remoteJid: chatJid, id: messageId, fromMe, participant: participant || undefined });
    emitEvent({ type: "media", providerMsgId: messageId, state: "requested" });
  } catch (e: any) {
    clearPendingMediaBackfill(chatJid, messageId);
    logger.warn({ err: e?.message, chatJid, messageId }, "media placeholder resend failed");
    emitMediaFailure(messageId, null, "Couldn't ask WhatsApp to resend this media.");
  }
}

function finishPendingMediaBackfill(msg: WAMessage, type: string | null, mediaPath: string | null): void {
  const chatJid = msg.key?.remoteJid;
  const messageId = msg.key?.id;
  if (!chatJid || !messageId) return;
  const key = mediaBackfillKey(chatJid, messageId);
  if (!pendingMediaBackfills.has(key)) return;
  clearPendingMediaBackfill(chatJid, messageId);
  if (mediaPath && type) {
    emitEvent({ type: "media", providerMsgId: messageId, state: "success", mediaType: type, mediaPath });
  } else {
    emitMediaFailure(messageId, type, "WhatsApp returned the message, but the media file still couldn't be downloaded.");
  }
}

export function parseMessageForDb(msg: WAMessage): DbMessage | null {
  if (!msg.message || !msg.key || !msg.key.remoteJid) {
    return null;
  }
  // WhatsApp Status updates (the "Broadcast"/Status feed) are not real conversations — keep them out.
  if (msg.key.remoteJid === "status@broadcast") {
    return null;
  }

  let content: string | null = null;
  const inner = unwrapMessage(msg.message);

  if (inner?.conversation) {
    content = inner.conversation;
  } else if (inner?.extendedTextMessage?.text) {
    content = inner.extendedTextMessage.text;
  } else if (inner?.imageMessage) {
    content = inner.imageMessage.caption ? `[Image] ${inner.imageMessage.caption}` : `[Image]`;
  } else if (inner?.videoMessage) {
    const label = inner.videoMessage.gifPlayback ? "GIF" : "Video";
    content = inner.videoMessage.caption ? `[${label}] ${inner.videoMessage.caption}` : `[${label}]`;
  } else if (inner?.documentMessage) {
    const label = inner.documentMessage.caption || inner.documentMessage.fileName || "";
    content = label ? `[Document] ${label}` : `[Document]`;
  } else if (inner?.audioMessage) {
    content = `[Audio]`;
  } else if (inner?.stickerMessage) {
    content = `[Sticker]`;
  } else if (inner?.locationMessage) {
    content = locationText("Location", inner.locationMessage);
  } else if (inner?.liveLocationMessage) {
    content = locationText("Live location", inner.liveLocationMessage);
  } else if (inner?.contactMessage) {
    const label = contactName(inner.contactMessage);
    content = label ? `[Contact] ${label}` : `[Contact]`;
  } else if (inner?.contactsArrayMessage) {
    content = contactsText(inner.contactsArrayMessage);
  } else if (inner?.pollCreationMessage) {
    content = pollText(inner.pollCreationMessage);
  } else if (inner?.pollCreationMessageV2) {
    content = pollText(inner.pollCreationMessageV2);
  } else if (inner?.pollCreationMessageV3) {
    content = pollText(inner.pollCreationMessageV3);
  } else if (inner?.pollCreationMessageV5) {
    content = pollText(inner.pollCreationMessageV5);
  } else if (inner?.reactionMessage) {
    return null;
  }

  if (!content) {
    const type = unsupportedType(inner);
    if (!type) return null;
    content = `[Unsupported: ${type}]`;
  }

  // Use WhatsApp's original message timestamp (seconds since epoch)
  let timestampSeconds: number;

  if (msg.messageTimestamp != null) {
    // Handles number, bigint, and Long-like objects
    timestampSeconds = Number(msg.messageTimestamp);
  } else {
    // Fallback only if WA didn't give us a timestamp at all
    timestampSeconds = Date.now() / 1000;
  }

  const timestamp = new Date(timestampSeconds * 1000);

  let senderJid: string | null | undefined = msg.key.participant;
  if (!msg.key.fromMe && !senderJid && !isJidGroup(msg.key.remoteJid)) {
    senderJid = msg.key.remoteJid;
  }
  if (msg.key.fromMe && !isJidGroup(msg.key.remoteJid)) {
    senderJid = null;
  }

  return {
    id: msg.key.id!,
    chat_jid: msg.key.remoteJid,
    sender: senderJid ? jidNormalizedUser(senderJid) : null,
    content: content,
    timestamp: timestamp,
    is_from_me: msg.key.fromMe ?? false,
  };
}

export async function startWhatsAppConnection(
  logger: P.Logger
): Promise<WhatsAppSocket> {
  initializeDatabase();

  const { state, saveCreds } = await useMultiFileAuthState(AUTH_DIR);
  const { version, isLatest } = await fetchLatestBaileysVersion();
  logger.info(`Using WA v${version.join(".")}, isLatest: ${isLatest}`);

  const sock = makeWASocket({
    version,
    logger,
    auth: {
      creds: state.creds,
      keys: makeCacheableSignalKeyStore(state.keys, logger),
    },
    // (currentSock is updated right after creation, below)
    generateHighQualityLinkPreview: true,
    // Pull the FULL on-device message history on pairing (default is a tiny recent slice — the cause
    // of "I have many messages but only a few load"). WhatsApp streams it in chunks via repeated
    // messaging-history.set events (each with progress/isLatest), which our handler accumulates.
    // NOTE: a full sync only fires on a FRESH registration, so an already-paired account must Re-pair once.
    syncFullHistory: true,
    getMessage: getRawMessageContent,
    // Don't flip the phone to "offline" on connect — keeps phone notifications working and makes the
    // history-sync push more reliable.
    markOnlineOnConnect: false,
    // Groups are now included (was: shouldIgnoreJid isJidGroup). Group msgs carry a participant sender.
  });
  currentSock = sock;   // mark this as the live socket so sends always target the current connection

  sock.ev.process(async (events) => {
    if (events["connection.update"]) {
      const update = events["connection.update"];
      const { connection, lastDisconnect, qr } = update;

      if (connection === "connecting") {
        emitEvent({ type: "status", state: "connecting" });
      }

      if (qr) {
        logger.info({ qrCodeData: qr }, "QR Code Received.");
        // DewLink renders the QR natively (CIQRCodeGenerator) — just hand it the string.
        emitEvent({ type: "qr", qr });
      }

      if (connection === "close") {
        const statusCode = (lastDisconnect?.error as any)?.output?.statusCode;
        logger.warn(
          `Connection closed. Reason: ${
            DisconnectReason[statusCode as number] || "Unknown"
          }`,
          lastDisconnect?.error
        );
        connectionStable = false;
        if (statusCode !== DisconnectReason.loggedOut) {
          emitEvent({ type: "status", state: "close" });
          if (!reconnecting) {                       // don't stack overlapping reconnects
            reconnecting = true;
            logger.info("Reconnecting...");
            setTimeout(() => { startWhatsAppConnection(logger).finally(() => { reconnecting = false; }); }, 2000);
          }
        } else {
          logger.error("Connection closed: Logged Out.");
          emitEvent({ type: "status", state: "logged_out" });
          process.exit(1);
        }
      } else if (connection === "open") {
        logger.info(`Connection opened. WA user: ${sock.user?.name}`);
        connectionStable = true; reconnecting = false;
        emitEvent({ type: "status", state: "open", user: sock.user?.name ?? null });
        // Replay any history already on disk into the app inbox (fixes "engine has 900 chats but the
        // app shows 4" — the one-time history sync landed in whatsapp.db before the app DB captured it).
        backfillOnce(logger);
        // Stable now — fetch any avatars requested before we connected (give the LID map a moment to fill).
        setTimeout(() => drainPendingAvatars(logger), 4000);
      }
    }

    if (events["creds.update"]) {
      await saveCreds();
      logger.info("Credentials saved.");
    }

    if (events["messaging-history.set"]) {
      const { chats, contacts, messages, isLatest, progress, syncType } =
        events["messaging-history.set"];
      if (contacts.length > 0) {
        logger.info(`Storing ${contacts.length} contacts from history sync.`);
        contacts.forEach((c) =>
          storeContact({
            jid: c.id,
            name: c.name ?? null,
            notify: c.notify ?? null,
            phoneNumber: (c as any).phoneNumber ?? null,
          })
        );
        logger.info(`Stored ${contacts.length} contacts from history sync.`);
      }

      logger.info(`Storing ${chats.length} chats from history sync.`);
      chats.forEach((chat) =>
        storeChat({
          jid: chat.id,
          name: chat.name,
          last_message_time: chat.conversationTimestamp
            ? new Date(Number(chat.conversationTimestamp) * 1000)
            : undefined,
        })
      );

      let storedCount = 0;
      for (const msg of messages) {
        storeRawMessage(msg);
        const reaction = reactionForMessage(msg);
        if (reaction) {
          emitEvent({ type: "reaction", targetProviderMsgId: reaction.targetId, emoji: reaction.emoji });
          continue;
        }
        const parsed = parseMessageForDb(msg);
        if (parsed) {
          storeMessage(parsed);
          // Backfill the local inbox so existing chats appear immediately on pairing.
          // Download AUDIO in history (voice notes are small) so existing chats' audio is playable;
          // heavier media (image/video/doc) is fetched lazily on the live path to keep backfill fast.
          const mt = mediaType(msg);
          // Download audio + images during history sync; heavier media stays lazy and retryable.
          const mp = (mt === "audio" || mt === "image") ? await saveMedia(msg, sock, logger, mt!) : null;
          finishPendingMediaBackfill(msg, mt, mp);
          emitEvent({
            type: "message",
            convExternalId: parsed.chat_jid,
            convTitle: parsed.chat_name ?? getContactName(parsed.chat_jid) ?? (parsed.is_from_me ? null : (msg.pushName || null)),
            direction: parsed.is_from_me ? "OUT" : "IN",
            body: parsed.content,
            sender: parsed.sender ?? null,
            ts: parsed.timestamp instanceof Date ? parsed.timestamp.getTime() : Date.now(),
            providerMsgId: parsed.id,
            historical: true,
            mediaType: mt ?? undefined,
            mediaPath: mp ?? undefined,
          });
          fetchAvatar(parsed.chat_jid, logger).then((p) => {
            if (p) emitEvent({ type: "avatar", jid: parsed.chat_jid, path: p });
          }).catch((e: any) => logger.warn(`[avatar] fetch failed ${parsed.chat_jid}: ${e?.message ?? e}`));
          storedCount++;
        }
      }
      logger.info(`Stored ${storedCount} messages from history sync.`);
    }

    if (events["messages.upsert"]) {
      const { messages, type } = events["messages.upsert"];
      logger.info(
        { type, count: messages.length },
        "Received messages.upsert event"
      );

      if (type === "notify" || type === "append") {
        for (const msg of messages) {
          storeRawMessage(msg);
          const reaction = reactionForMessage(msg);
          if (reaction) {
            emitEvent({ type: "reaction", targetProviderMsgId: reaction.targetId, emoji: reaction.emoji });
            continue;
          }
          const parsed = parseMessageForDb(msg);
          if (parsed) {
            logger.info(
              {
                msgId: parsed.id,
                chatId: parsed.chat_jid,
                fromMe: parsed.is_from_me,
                sender: parsed.sender,
              },
              `Storing message: ${parsed.content.substring(0, 50)}...`
            );
            storeMessage(parsed);
            // Persist the sender's self-set name (pushName) so it resolves everywhere next time —
            // the inbox list, the backfill, and future sessions — instead of falling back to a phone
            // number or "WhatsApp user". For groups the pushName belongs to the participant, not the group.
            // CRITICAL: only for INCOMING messages. On an outgoing (fromMe) message, pushName is the
            // ACCOUNT OWNER's own name, and `target` is the OTHER person — storing it clobbers their name
            // (this is what renamed every 1:1 partner "Roki"). Never write our own name onto a contact.
            if (msg.pushName && !parsed.is_from_me) {
              const target = isJidGroup(parsed.chat_jid) ? msg.key.participant : parsed.chat_jid;
              if (target) storeContact({ jid: target, notify: msg.pushName });
            }
            const pendingMediaRetry = hasPendingMediaBackfill(msg);
            const liveMediaType = mediaType(msg);
            const liveMediaPath = liveMediaType ? await saveMedia(msg, sock, logger, liveMediaType) : null;
            finishPendingMediaBackfill(msg, liveMediaType, liveMediaPath);
            emitEvent({
              type: "message",
              convExternalId: parsed.chat_jid,
              convTitle: parsed.chat_name ?? getContactName(parsed.chat_jid) ?? (parsed.is_from_me ? null : (msg.pushName || null)),
              direction: parsed.is_from_me ? "OUT" : "IN",
              body: parsed.content,
              sender: parsed.sender ?? null,
              ts: parsed.timestamp instanceof Date ? parsed.timestamp.getTime() : Date.now(),
              providerMsgId: parsed.id,
              historical: pendingMediaRetry ? true : false,
              mediaType: liveMediaType ?? undefined,
              mediaPath: liveMediaPath ?? undefined,
            });
            // Pull the contact/group avatar once (fire-and-forget).
            fetchAvatar(parsed.chat_jid, logger).then((p) => {
              if (p) emitEvent({ type: "avatar", jid: parsed.chat_jid, path: p });
            }).catch((e: any) => logger.warn(`[avatar] fetch failed ${parsed.chat_jid}: ${e?.message ?? e}`));
          } else {
            logger.warn(
              { msgId: msg.key?.id, chatId: msg.key?.remoteJid },
              "Skipped storing message (parsing failed or unsupported type)"
            );
          }
        }
      }
    }

    // Delivery/read receipts for our outgoing messages (status: 2=sent 3=delivered 4/5=read/played).
    if (events["messages.update"]) {
      for (const u of events["messages.update"]) {
        const id = u.key?.id;
        const status = (u.update as any)?.status;
        if (id && typeof status === "number") {
          emitEvent({ type: "msgStatus", providerMsgId: id, status });
        }
      }
    }

    if (events["chats.update"]) {
      logger.info(
        { count: events["chats.update"].length },
        "Received chats.update event"
      );
      for (const chatUpdate of events["chats.update"]) {
        storeChat({
          jid: chatUpdate.id!,
          name: chatUpdate.name,
          last_message_time: chatUpdate.conversationTimestamp
            ? new Date(Number(chatUpdate.conversationTimestamp) * 1000)
            : undefined,
        });
      }
    }

    // Contact name sync — Baileys delivers address-book/pushName info here (was previously ignored,
    // which is why unsaved contacts showed as raw phone numbers / "WhatsApp user"). Persist names so
    // they resolve in the inbox list + backfill, and push a live name update for the open session.
    const contactEvents = [
      ...(events["contacts.upsert"] ?? []),
      ...(events["contacts.update"] ?? []),
    ];
    if (contactEvents.length) {
      for (const c of contactEvents as any[]) {
        if (!c?.id) continue;
        const name = c.name ?? c.verifiedName ?? null;
        const notify = c.notify ?? null;
        if (!name && !notify) continue;
        storeContact({ jid: c.id, name, notify });
        const resolved = getContactName(c.id);
        if (resolved) emitEvent({ type: "contactName", jid: c.id, name: resolved });
      }
      logger.info(`Synced ${contactEvents.length} contact name update(s)`);
    }
  });

  return sock;
}

export async function sendWhatsAppMessage(
  logger: P.Logger,
  sock: WhatsAppSocket | null,
  recipientJid: string,
  text: string
): Promise<proto.WebMessageInfo | void> {
  // Always prefer the LIVE socket — the one passed in may be a stale pre-reconnect socket.
  const active = currentSock ?? sock;
  if (!active || !active.user) {
    logger.error(
      "Cannot send message: WhatsApp socket not connected or initialized."
    );
    return;
  }
  sock = active;
  if (!recipientJid) {
    logger.error("Cannot send message: Recipient JID is missing.");
    return;
  }
  if (!text) {
    logger.error("Cannot send message: Message text is empty.");
    return;
  }

  try {
    logger.info(
      `Sending message to ${recipientJid}: ${text.substring(0, 50)}...`
    );
    const normalizedJid = jidNormalizedUser(recipientJid);
    const result = await sock.sendMessage(normalizedJid, { text: text });
    logger.info({ msgId: result?.key.id }, "Message sent successfully");
    return result;
  } catch (error) {
    logger.error({ err: error, recipientJid }, "Failed to send message");
    return;
  }
}

/// Send a voice note (PTT). Transcodes the recorded file to OGG/Opus (what WhatsApp expects for PTT).
export async function sendWhatsAppAudio(
  logger: P.Logger,
  _sock: WhatsAppSocket | null,
  recipientJid: string,
  filePath: string,
): Promise<proto.WebMessageInfo | void> {
  const active = currentSock ?? _sock;
  if (!active || !active.user) { logger.error("Cannot send audio: not connected."); return; }
  if (!recipientJid || !filePath || !fs.existsSync(filePath)) { logger.error("Cannot send audio: bad jid/file."); return; }
  try {
    let sendPath = filePath;
    if (FFMPEG) {
      const ogg = filePath.replace(/\.[^.]+$/, ".ptt.ogg");
      try {
        execFileSync(FFMPEG, ["-y", "-i", filePath, "-c:a", "libopus", "-b:a", "32k", "-ar", "48000", "-ac", "1", ogg], { stdio: "ignore" });
        sendPath = ogg;
      } catch (e: any) { logger.warn({ err: e?.message }, "opus transcode failed; sending raw"); }
    }
    const buf = fs.readFileSync(sendPath);
    const jid = jidNormalizedUser(recipientJid);
    const result = await active.sendMessage(jid, { audio: buf, ptt: true, mimetype: "audio/ogg; codecs=opus" });
    logger.info({ msgId: result?.key.id }, "Voice note sent");
    return result;
  } catch (error) {
    logger.error({ err: error, recipientJid }, "Failed to send voice note");
    return;
  }
}

let hasBackfilled = false;

/**
 * Replay every message already stored in the engine's whatsapp.db into the app's unified inbox.
 * The WhatsApp history sync only fires ONCE at pairing and lands in whatsapp.db — but if the app's
 * unified DB was created/reset later, those chats never reached the inbox (symptom: "only a few chats
 * show though the engine has 900"). This emits them as historical message events; the app upserts
 * idempotently (provider_msg_id unique), so re-running is safe. Runs once per process by default.
 */
/** Probe MEDIA_DIR for an already-downloaded media file for this message id (backfill can't re-derive
 *  it from the text-only whatsapp.db, but if the file is on disk we can re-attach it). */
function findDownloadedMedia(id: string, content: string): { type: string; path: string } | null {
  const cleanId = id.replace(/[^A-Za-z0-9_-]/g, "");
  const pref = mediaTypeFromContent(content);
  const probes: Array<[string, string[]]> = [
    ["image", ["jpg"]], ["video", ["mp4"]], ["gif", ["mp4"]], ["sticker", ["webp"]],
    ["audio", ["m4a", "ogg"]], ["document", ["pdf", "doc", "docx", "ppt", "pptx", "xls", "xlsx", "png", "jpg", "zip", "bin"]],
  ];
  for (const [type, exts] of probes) {
    if (pref && pref !== type) continue;
    for (const ext of exts) {
      const p = path.join(MEDIA_DIR, `${cleanId}.${ext}`);
      if (fs.existsSync(p)) return { type, path: p };
    }
  }
  return null;
}

export function backfillUnifiedInbox(logger: P.Logger, limit = 20000): number {
  const rows = getBackfillMessages(limit);
  let emitted = 0, withMedia = 0;
  for (const r of rows) {
    const ts = r.timestamp instanceof Date ? r.timestamp.getTime() : Date.now();
    const media = findDownloadedMedia(r.id, r.content ?? "");
    const replayMediaType = media?.type ?? mediaTypeFromContent(r.content ?? "");
    if (media) withMedia++;
    emitEvent({
      type: "message",
      convExternalId: r.chat_jid,
      convTitle: r.chat_name ?? getContactName(r.chat_jid) ?? null,
      direction: r.is_from_me ? "OUT" : "IN",
      body: r.content ?? "",
      sender: r.sender ?? null,
      ts,
      providerMsgId: r.id,
      historical: true,   // backfill — never inflates unread
      mediaType: replayMediaType ?? undefined,
      mediaPath: media?.path,
    });
    emitted++;
  }
  logger.info(`backfillUnifiedInbox: replayed ${emitted} message(s) (${withMedia} with on-disk media) into the inbox`);
  return emitted;
}

/** One-shot backfill per process (called on first connection open). Force=true re-runs on demand. */
export function backfillOnce(logger: P.Logger, force = false): number {
  if (hasBackfilled && !force) return 0;
  hasBackfilled = true;
  return backfillUnifiedInbox(logger);
}

/**
 * React to a message with an emoji (user-initiated, like a normal send). Empty emoji removes the reaction.
 * Uses the live socket; the key identifies the target message (remoteJid + id + fromMe).
 */
export async function reactToMessage(
  chatJid: string, messageId: string, fromMe: boolean, emoji: string, logger: P.Logger,
): Promise<void> {
  const active = currentSock;
  if (!active || !active.user) { logger.warn("react: not connected"); return; }
  try {
    const jid = jidNormalizedUser(chatJid).includes("@") ? chatJid : jidNormalizedUser(chatJid);
    await active.sendMessage(jid, { react: { text: emoji, key: { remoteJid: chatJid, id: messageId, fromMe } } });
    logger.info(`reacted ${emoji || "(cleared)"} to ${messageId}`);
  } catch (e: any) {
    logger.error({ err: e?.message, messageId }, "react failed");
  }
}

/**
 * On-demand history: ask WhatsApp for older messages in a chat WITHOUT re-pairing. Anchors on the
 * oldest message we've already stored and requests `count` messages before it. WhatsApp replies via
 * a messaging-history.set event (syncType ON_DEMAND), which the main event handler already ingests.
 * This is the "Load older messages" path for accounts that were paired before syncFullHistory was on.
 */
export async function loadMoreHistory(
  chatJid: string,
  count: number,
  logger: P.Logger,
): Promise<void> {
  const active = currentSock;
  if (!active || !active.user) { logger.warn("loadMoreHistory: not connected"); return; }
  const oldest = getOldestMessage(chatJid);
  if (!oldest) { logger.warn(`loadMoreHistory: no stored messages for ${chatJid}`); return; }
  const key = { remoteJid: chatJid, id: oldest.id, fromMe: oldest.is_from_me };
  const tsSeconds = Math.floor((oldest.timestamp?.getTime() ?? Date.now()) / 1000);
  try {
    // Baileys: fetchMessageHistory(count, oldestMsgKey, oldestMsgTimestamp) → older messages stream
    // back through messaging-history.set (ON_DEMAND). Available on recent Baileys (7.x).
    const fn = (active as any).fetchMessageHistory;
    if (typeof fn !== "function") { logger.error("loadMoreHistory: fetchMessageHistory unavailable in this Baileys build"); return; }
    await fn.call(active, count, key, tsSeconds);
    logger.info(`loadMoreHistory: requested ${count} older message(s) for ${chatJid}`);
  } catch (e: any) {
    logger.error({ err: e?.message, chatJid }, "loadMoreHistory failed");
  }
}
