#!/usr/bin/env node
import fs from "node:fs";
import http from "node:http";
import https from "node:https";
import path from "node:path";
import { setTimeout as sleep } from "node:timers/promises";
import makeWASocket, {
  DisconnectReason,
  downloadMediaMessage,
  fetchLatestBaileysVersion,
  useMultiFileAuthState,
} from "@whiskeysockets/baileys";
import pino from "pino";
import QRCode from "qrcode";

process.umask(0o077);

const FALLBACK_WA_VERSION = [2, 3000, 1035194821];
const WEBHOOK_TIMEOUT_MS = 60 * 1000;
const OUTBOX_MAX_ATTEMPTS = 5;
const ARTIFACT_RETENTION_MS = 7 * 24 * 60 * 60 * 1000;
const SEND_TIMEOUT_MS = Number.parseInt(process.env.NIWA_WA_SEND_TIMEOUT_MS || process.env.WHATSAPP_SEND_TIMEOUT_MS || "60000", 10);

const args = parseArgs(process.argv.slice(2));
const stateDir = path.resolve(args["state-dir"] || "");
const siteUrl = String(args["site-url"] || "").replace(/\/+$/, "");
const webhookUrl = String(args["webhook-url"] || (siteUrl ? `${siteUrl}/wp-json/niwa-ai/v1/whatsapp/webhook` : ""));
const initialSecret = String(args.secret || process.env.NIWA_WA_SECRET || "");

if (!stateDir || !webhookUrl) {
  console.error("Missing --state-dir and webhook URL.");
  process.exit(2);
}

const authDir = path.join(stateDir, "auth");
const outboxDir = path.join(stateDir, "outbox");
const sentDir = path.join(stateDir, "sent");
const mediaDir = path.join(stateDir, "media");
const statusPath = path.join(stateDir, "status.json");
const pidPath = path.join(stateDir, "gateway.pid");
const controlPath = path.join(stateDir, "control.json");
const gatewaySecretPath = path.join(stateDir, ".gateway-secret.json");
const linkedControlPath = path.join(stateDir, "linked-control-chat.json");
const chatJidsPath = path.join(stateDir, "chat-jids.json");
const identityAliasesPath = path.join(stateDir, "identity-aliases.json");
const accessPolicyPath = path.join(stateDir, "access-policy.json");
const logPath = path.join(stateDir, "gateway.log");

for (const dir of [stateDir, authDir, outboxDir, sentDir, mediaDir]) {
  fs.mkdirSync(dir, { recursive: true });
}
if (!readGatewaySecret()) {
  console.error("Missing gateway secret.");
  process.exit(2);
}
fs.writeFileSync(pidPath, String(process.pid));

const logger = pino({ level: "silent" });
const gatewaySentIds = new Map();
const inboundProcessedIds = new Map();
const linkedControlState = readJson(linkedControlPath, {});
let linkedControlChat = String(linkedControlState?.chat || "");
let linkedControlJid = normalizeChatJid(linkedControlState?.jid || "");
let chatJids = readJson(chatJidsPath, {});
let identityAliases = readJson(identityAliasesPath, {});
let sock = null;
let sockGeneration = 0;
let connected = false;
let stopping = false;
let outboxLoopStarted = false;
let controlLoopStarted = false;
let lastArtifactPruneAt = Date.now();

writeStatus({ status: "starting", pid: process.pid });
appendLog("gateway starting");
recoverStrandedOutboxClaims();
pruneOldArtifacts();

function sendMessageWithTimeout(jid, payload, timeoutMs = SEND_TIMEOUT_MS) {
  let timer;
  const timeout = Number.isFinite(timeoutMs) && timeoutMs > 0 ? timeoutMs : 60000;
  const timeoutPromise = new Promise((_, reject) => {
    timer = setTimeout(() => reject(new Error(`sendMessage timed out after ${timeout / 1000}s`)), timeout);
  });
  return Promise.race([sock.sendMessage(jid, payload), timeoutPromise]).finally(() => clearTimeout(timer));
}

process.on("SIGTERM", () => {
  stopping = true;
  appendLog("signal SIGTERM");
  writeStatus({ status: "stopping", pid: process.pid });
  tryClose();
  process.exit(0);
});

process.on("SIGINT", () => {
  stopping = true;
  appendLog("signal SIGINT");
  writeStatus({ status: "stopping", pid: process.pid });
  tryClose();
  process.exit(0);
});

process.on("uncaughtException", (err) => {
  fatalExit("uncaughtException", err);
});

process.on("unhandledRejection", (reason) => {
  fatalExit("unhandledRejection", reason);
});

await start();

async function start() {
  const generation = ++sockGeneration;
  const { state, saveCreds } = await useMultiFileAuthState(authDir);
  const version = await resolveWhatsAppVersion();
  sock = makeWASocket({
    auth: state,
    version,
    printQRInTerminal: false,
    logger,
    browser: ["Niwa AI", "Chrome", "1.0.0"],
  });

  sock.ev.on("creds.update", saveCreds);
  sock.ev.on("connection.update", (update) => {
    if (generation !== sockGeneration) {
      return;
    }
    void handleConnectionUpdate(update, generation).catch((err) => fatalExit("connection.update", err));
  });

  sock.ev.on("messages.upsert", ({ messages }) => {
    if (generation !== sockGeneration) {
      return;
    }
    void handleMessagesUpsert(messages).catch((err) => fatalExit("messages.upsert", err));
  });

  sock.ev.on("chats.phoneNumberShare", ({ lid, jid }) => {
    try {
      rememberPhoneNumberJid(jid, lid, "phone-share");
    } catch (err) {
      fatalExit("chats.phoneNumberShare", err);
    }
  });

  sock.ev.on("contacts.upsert", (contacts) => {
    try {
      rememberContactJids(contacts, "contacts-upsert");
    } catch (err) {
      fatalExit("contacts.upsert", err);
    }
  });

  sock.ev.on("contacts.update", (contacts) => {
    try {
      rememberContactJids(contacts, "contacts-update");
    } catch (err) {
      fatalExit("contacts.update", err);
    }
  });

  ensureLoopsStarted();
}

async function handleConnectionUpdate(update, generation) {
  if (generation !== sockGeneration) {
    return;
  }
  if (update.qr) {
    const qrDataUrl = await QRCode.toDataURL(update.qr, {
      errorCorrectionLevel: "M",
      margin: 2,
      scale: 6,
    });
    writeStatus({ status: "qr", qrDataUrl, pid: process.pid, lastError: "" });
    appendLog("qr received");
  }

  if (update.connection === "open") {
    connected = true;
    try {
      await sock?.sendPresenceUpdate?.("available");
    } catch (err) {
      appendLog(`global presence failed ${String(err?.message || err)}`);
    }
    writeStatus({
      status: "connected",
      pid: process.pid,
      qrDataUrl: "",
      lastError: "",
      selfJid: sock?.user?.id || "",
      selfName: sock?.user?.name || "",
    });
    appendLog("connected");
  }

  if (update.connection === "close") {
    if (generation !== sockGeneration) {
      return;
    }
    sockGeneration += 1;
    connected = false;
    const statusCode = update.lastDisconnect?.error?.output?.statusCode;
    if (stopping || statusCode === DisconnectReason.loggedOut) {
      writeStatus({
        status: "logged_out",
        pid: process.pid,
        lastError: statusCode === DisconnectReason.loggedOut ? "logged_out" : "",
      });
      appendLog("connection closed");
      return;
    }

    writeStatus({
      status: "reconnecting",
      pid: process.pid,
      lastError: statusCode ? `disconnect_${statusCode}` : "connection_closed",
    });
    appendLog(`reconnecting after ${statusCode || "close"}`);
    tryClose();
    await sleep(1500);
    await start();
  }
}

async function handleMessagesUpsert(messages) {
  for (const message of messages || []) {
    await handleInbound(message);
  }
}

async function resolveWhatsAppVersion() {
  try {
    const latest = await fetchLatestBaileysVersion({ timeout: 8000 });
    if (Array.isArray(latest?.version) && latest.version.length >= 3) {
      appendLog(`using whatsapp web version ${latest.version.join(".")}`);
      return latest.version;
    }
  } catch (err) {
    appendLog(`version lookup failed ${String(err?.message || err)}`);
  }
  appendLog(`using fallback whatsapp web version ${FALLBACK_WA_VERSION.join(".")}`);
  return FALLBACK_WA_VERSION;
}

async function handleInbound(message) {
  try {
    const remoteJid = message?.key?.remoteJid || "";
    const messageId = String(message?.key?.id || "");
    const fromMe = Boolean(message?.key?.fromMe);
    pruneGatewaySentIds();
    pruneInboundProcessedIds();
    if (fromMe && messageId && gatewaySentIds.has(messageId)) {
      appendLog(`inbound ignored gateway-sent id=${shortId(messageId)}`);
      return;
    }
    if (isIgnoredConversationJid(remoteJid)) {
      appendLog(`inbound ignored chat id=${shortId(messageId)} jid=${maskValue(remoteJid)}`);
      return;
    }

    const remoteChatJid = normalizeChatJid(remoteJid);
    const groupJid = normalizeGroupJid(remoteJid);
    const isGroup = Boolean(groupJid);

    const payload = unwrapMessageContent(message);
    const type = firstMessageType(payload);
    const text = extractText(payload);
    const structuredCommand = extractStructuredCommand(payload);
    const contextInfo = extractContextInfo(payload);
    const mentionedIds = Array.from(new Set((contextInfo?.mentionedJid || []).map(normalizeChatJid).filter(Boolean)));
    const quotedMessageId = contextInfo?.stanzaId ? String(contextInfo.stanzaId) : "";
    const quotedParticipant = normalizeChatJid(contextInfo?.participant || "");
    const quotedRemoteJid = normalizeChatJid(contextInfo?.remoteJid || "");
    const hasQuotedMessage = Boolean(contextInfo?.quotedMessage);
    const botIds = Array.from(new Set([
      normalizeChatJid(sock?.user?.id || ""),
      normalizeChatJid(sock?.user?.lid || ""),
    ].filter(Boolean)));

    let accepted = null;
    if (isGroup) {
      if (fromMe) {
        appendLog(`inbound ignored group-self id=${shortId(messageId)} group=${maskValue(groupJid)}`);
        return;
      }
      const senderJid = normalizeChatJid(message?.key?.senderPn || message?.key?.participantPn || message?.key?.participant || "");
      const senderNumber = canonicalIdentifier(normalizeJidNumber(senderJid));
      if (!senderNumber || !senderJid) {
        appendLog(`inbound denied group missing sender id=${shortId(messageId)} group=${maskValue(groupJid)}`);
        return;
      }
      if (!isGroupAllowed(groupJid)) {
        appendLog(`inbound denied group policy id=${shortId(messageId)} group=${maskValue(groupJid)}`);
        return;
      }
      if (!messageAddressesBot(text, structuredCommand, mentionedIds, quotedParticipant, botIds)) {
        appendLog(`inbound ignored group-unaddressed id=${shortId(messageId)} group=${maskValue(groupJid)}`);
        return;
      }
      accepted = {
        chat_id: `whatsapp-group:${groupJid}`,
        chatId: groupJid,
        senderId: senderJid,
        senderName: message.pushName || senderNumber,
        chatName: groupJid.replace(/@g\.us$/, ""),
        isGroup: true,
        remote_jid: groupJid,
        phone_jid: senderJid,
        chat_jid: groupJid,
        sender_jid: senderJid,
        from: senderNumber,
        from_me: false,
      };
    } else {
      const phoneJid = !fromMe ? normalizeChatJid(message?.key?.senderPn || message?.key?.participantPn || "") : "";
      const phoneNumber = normalizeJidNumber(phoneJid);
      const remoteAlias = normalizeJidNumber(remoteChatJid || remoteJid);
      const remoteNumber = canonicalIdentifier(phoneNumber || remoteAlias, remoteAlias);
      if (phoneNumber && remoteAlias) {
        rememberIdentityAlias(phoneNumber, remoteAlias, "inbound");
      }
      const replyJid = rememberChatJid(remoteNumber, remoteChatJid || remoteJid);
      const selfNumber = canonicalIdentifier(sock?.user?.id || "");
      if (fromMe && remoteNumber !== selfNumber) {
        if (!isLinkedControlChat(remoteNumber, remoteJid)) {
          appendLog(`inbound ignored linked-outgoing id=${shortId(messageId)} remote=${maskValue(remoteNumber)}`);
          return;
        }
      }
      if (!fromMe && !isInboundAllowed(remoteNumber)) {
        appendLog(`inbound denied by access policy id=${shortId(messageId)} remote=${maskValue(remoteNumber)}`);
        return;
      }
      accepted = {
        chat_id: `whatsapp:${remoteNumber}`,
        chatId: remoteChatJid,
        senderId: phoneJid || remoteChatJid,
        senderName: message.pushName || remoteNumber,
        chatName: message.pushName || remoteNumber,
        isGroup: false,
        remote_jid: replyJid,
        phone_jid: phoneJid,
        chat_jid: remoteChatJid,
        sender_jid: phoneJid,
        from: remoteNumber,
        from_me: Boolean(message.key?.fromMe),
      };
    }

    if (messageId && inboundProcessedIds.has(messageId)) {
      appendLog(`inbound ignored duplicate id=${shortId(messageId)}`);
      return;
    }
    const media = await maybeDownloadMedia(message, type, payload);
    if ("" === text.trim() && !media && !structuredCommand) {
      appendLog(`inbound ignored empty id=${shortId(messageId)} fromMe=${fromMe ? 1 : 0} type=${type}`);
      return;
    }
    if (messageId) {
      inboundProcessedIds.set(messageId, Date.now());
    }

    const whatsappTimestamp = normalizeTimestampSeconds(message.messageTimestamp);
    const nowSeconds = Math.floor(Date.now() / 1000);
    const receiveLagSeconds = whatsappTimestamp > 0 ? Math.max(0, nowSeconds - whatsappTimestamp) : 0;
    appendLog(`inbound accepted id=${shortId(messageId)} fromMe=${fromMe ? 1 : 0} group=${accepted.isGroup ? 1 : 0} chat=${maskValue(accepted.from)} type=${type} textLen=${text.length} waTs=${whatsappTimestamp || 0} lag=${receiveLagSeconds}`);
    const usedSecret = readGatewaySecret();
    const body = {
      secret: usedSecret,
      messages: [
        {
          id: messageId,
          chat_id: accepted.chat_id,
          messageId,
          chatId: accepted.chatId,
          senderId: accepted.senderId,
          senderName: accepted.senderName,
          chatName: accepted.chatName,
          isGroup: accepted.isGroup,
          body: text,
          hasMedia: Boolean(media),
          mediaType: media?.type || media?.kind || "",
          mediaUrls: media?.local_path ? [media.local_path] : [],
          mentionedIds,
          quotedMessageId,
          quotedParticipant,
          quotedRemoteJid,
          hasQuotedMessage,
          botIds,
          remote_jid: accepted.remote_jid,
          phone_jid: accepted.phone_jid,
          chat_jid: accepted.chat_jid,
          sender_jid: accepted.sender_jid,
          from: accepted.from,
          from_me: accepted.from_me,
          push_name: message.pushName || "",
          timestamp: whatsappTimestamp || nowSeconds,
          type,
          text,
          callback_data: structuredCommand,
          media,
        },
      ],
    };

    let res = await postJson(webhookUrl, body);
    if (res.statusCode === 403) {
      const refreshedSecret = readGatewaySecret();
      if (refreshedSecret && refreshedSecret !== usedSecret) {
        body.secret = refreshedSecret;
        appendLog(`inbound post forbidden; retrying with refreshed secret id=${shortId(messageId)}`);
        res = await postJson(webhookUrl, body);
      }
    }
    if (res.statusCode < 200 || res.statusCode >= 300) {
      if (messageId) {
        inboundProcessedIds.delete(messageId);
      }
      appendLog(`inbound post failed ${res.statusCode}`);
      return;
    }

    try {
      if (!fromMe && message?.key) {
        await sock?.readMessages?.([message.key]);
      }
    } catch (err) {
      appendLog(`read receipt failed ${String(err?.message || err)}`);
    }
  } catch (err) {
    appendLog(`inbound error ${String(err?.message || err)}`);
  }
}

async function maybeDownloadMedia(message, type, payload) {
  if (!["image", "video", "audio", "document", "sticker", "ptt"].includes(type)) {
    return null;
  }

  if (Date.now() - lastArtifactPruneAt >= 60 * 60 * 1000) {
    pruneOldArtifacts();
    lastArtifactPruneAt = Date.now();
  }

  const node = payload[`${type}Message`] || (type === "ptt" ? payload.audioMessage : {}) || {};
  const kind = type === "ptt" ? "audio" : type;
  try {
    const buffer = await downloadMediaMessage(message, "buffer", {}, { logger });
    if (!buffer || !buffer.length) {
      return null;
    }

    const ext = extensionFor(type, node.mimetype || "");
    const safeId = String(message.key?.id || Date.now()).replace(/[^A-Za-z0-9._-]/g, "_");
    const fileName = node.fileName || `${safeId}.${ext}`;
    const targetPath = path.join(mediaDir, `${safeId}-${fileName}`.replace(/[^A-Za-z0-9._-]/g, "_"));
    fs.writeFileSync(targetPath, buffer);

    return {
      kind,
      type,
      local_path: targetPath,
      mime_hint: node.mimetype || "",
      file_name: fileName,
      caption: node.caption || "",
      size: buffer.length,
    };
  } catch (err) {
    appendLog(`media download failed ${String(err?.message || err)}`);
    return {
      kind,
      type,
      mime_hint: node.mimetype || "",
      file_name: node.fileName || "",
      caption: node.caption || "",
      error: "download_failed",
    };
  }
}

async function outboxLoop() {
  while (!stopping) {
    try {
      if (connected && sock) {
        for (const file of fs.readdirSync(outboxDir).filter((name) => name.endsWith(".json"))) {
          const fullPath = path.join(outboxDir, file);
          const claimPath = `${fullPath}.sending`;
          try {
            fs.renameSync(fullPath, claimPath);
          } catch (err) {
            if (err?.code !== "ENOENT") {
              appendLog(`outbox claim failed ${String(err?.message || err)}`);
            }
            continue;
          }

          try {
            await sendOutboxFile(claimPath);
          } catch (err) {
            appendLog(`outbox send failed ${String(err?.message || err)}`);
            try {
              if (fs.existsSync(claimPath)) {
                requeueOrParkOutboxClaim(claimPath, fullPath);
              }
            } catch (restoreErr) {
              appendLog(`outbox restore failed ${String(restoreErr?.message || restoreErr)}`);
            }
          }
        }
      }
    } catch (err) {
      appendLog(`outbox loop error ${String(err?.message || err)}`);
    }
    await sleep(1000);
  }
}

async function sendOutboxFile(fullPath) {
  const raw = fs.readFileSync(fullPath, "utf8");
  const item = JSON.parse(raw);
  const itemType = String(item.type || "message");
  const groupTo = normalizeGroupJid(item.jid || item.to || "");
  const to = groupTo || normalizeJidNumber(item.to || "");
  if (!to) {
    fs.renameSync(fullPath, path.join(sentDir, `${path.basename(fullPath)}.invalid`));
    return;
  }

  const jid = resolveOutboundJid(to, item.jid || "");
  if (itemType === "presence") {
    const presence = normalizePresence(item.presence || "composing");
    if (!presence) {
      fs.renameSync(fullPath, path.join(sentDir, `${path.basename(fullPath)}.invalid`));
      return;
    }
    try {
      await sock.presenceSubscribe(jid);
    } catch (err) {
      appendLog(`presence subscribe failed jid=${maskValue(jid)} ${String(err?.message || err)}`);
    }
    await sock.sendPresenceUpdate(presence, jid);
    appendLog(`presence sent jid=${maskValue(jid)} presence=${presence}`);
    const sentFileName = path.basename(fullPath).replace(/\.sending$/, "");
    fs.writeFileSync(
      path.join(sentDir, `${sentFileName}.presence`),
      JSON.stringify({ id: item.id || "", jid, presence, sentAt: new Date().toISOString() }),
    );
    fs.unlinkSync(fullPath);
    return;
  }

  if (itemType === "media") {
    await sendOutboxMedia(fullPath, item, jid);
    return;
  }

  const text = String(item.text || "");
  if (!text) {
    fs.renameSync(fullPath, path.join(sentDir, `${path.basename(fullPath)}.invalid`));
    return;
  }

  const result = await sendMessageWithTimeout(jid, { text });
  const sentId = String(result?.key?.id || "");
  if (sentId) {
    gatewaySentIds.set(sentId, Date.now());
  }
  const sentFileName = path.basename(fullPath).replace(/\.sending$/, "");
  fs.writeFileSync(
    path.join(sentDir, `${sentFileName}.sent`),
    JSON.stringify({ id: item.id || "", jid, messageId: sentId, sentAt: new Date().toISOString() }),
  );
  fs.unlinkSync(fullPath);
}

async function sendOutboxMedia(fullPath, item, jid) {
  const filePath = String(item.file_path || item.path || "");
  const mimeType = String(item.mime_type || item.mimetype || "");
  const kind = normalizeOutboundMediaKind(item.media_kind || item.kind || "", filePath, mimeType);
  if (!filePath || !kind || !fs.existsSync(filePath) || !fs.statSync(filePath).isFile()) {
    fs.renameSync(fullPath, path.join(sentDir, `${path.basename(fullPath)}.invalid`));
    return;
  }

  const buffer = fs.readFileSync(filePath);
  if (!buffer.length) {
    fs.renameSync(fullPath, path.join(sentDir, `${path.basename(fullPath)}.invalid`));
    return;
  }

  const payload = buildOutboundMediaPayload(kind, buffer, item, mimeType);
  const result = await sendMessageWithTimeout(jid, payload);
  const sentId = String(result?.key?.id || "");
  if (sentId) {
    gatewaySentIds.set(sentId, Date.now());
  }
  const sentFileName = path.basename(fullPath).replace(/\.sending$/, "");
  fs.writeFileSync(
    path.join(sentDir, `${sentFileName}.media`),
    JSON.stringify({ id: item.id || "", jid, messageId: sentId, mediaKind: kind, sentAt: new Date().toISOString() }),
  );
  fs.unlinkSync(fullPath);
}

function normalizeOutboundMediaKind(kind, filePath = "", mimeType = "") {
  const direct = String(kind || "").trim().toLowerCase();
  if (["image", "video", "audio", "document", "sticker"].includes(direct)) {
    return direct;
  }

  const mime = String(mimeType || "").toLowerCase();
  if (mime.startsWith("image/")) return "image";
  if (mime.startsWith("video/")) return "video";
  if (mime.startsWith("audio/")) return "audio";

  const ext = path.extname(String(filePath || "")).toLowerCase().replace(/^\./, "");
  if (["jpg", "jpeg", "png", "webp", "gif"].includes(ext)) return "image";
  if (["mp4", "mov"].includes(ext)) return "video";
  if (["mp3", "ogg", "opus"].includes(ext)) return "audio";
  return ext ? "document" : "";
}

function buildOutboundMediaPayload(kind, buffer, item, mimeType = "") {
  const caption = String(item.caption || "");
  const fileName = String(item.file_name || path.basename(String(item.file_path || item.path || "file")));
  const mime = String(mimeType || "");
  if (kind === "image") {
    return withDefined({ image: buffer, caption, mimetype: mime || undefined });
  }
  if (kind === "video") {
    return withDefined({ video: buffer, caption, mimetype: mime || undefined });
  }
  if (kind === "audio") {
    return withDefined({ audio: buffer, mimetype: mime || "audio/ogg" });
  }
  if (kind === "sticker") {
    return { sticker: buffer };
  }
  return withDefined({ document: buffer, fileName, caption, mimetype: mime || "application/octet-stream" });
}

function withDefined(payload) {
  return Object.fromEntries(Object.entries(payload).filter(([, value]) => value !== undefined && value !== ""));
}

function requeueOrParkOutboxClaim(claimPath, fullPath) {
  let item = null;
  try {
    item = JSON.parse(fs.readFileSync(claimPath, "utf8"));
  } catch {
    item = null;
  }

  if (!item || typeof item !== "object" || Array.isArray(item)) {
    fs.renameSync(claimPath, path.join(sentDir, `${path.basename(fullPath)}.failed`));
    appendLog(`outbox parked unreadable file=${path.basename(fullPath)}`);
    return;
  }

  const attempts = (Number.isFinite(Number(item.attempts)) ? Number(item.attempts) : 0) + 1;
  item.attempts = attempts;
  fs.writeFileSync(claimPath, JSON.stringify(item));
  if (attempts >= OUTBOX_MAX_ATTEMPTS) {
    fs.renameSync(claimPath, path.join(sentDir, `${path.basename(fullPath)}.failed`));
    appendLog(`outbox parked after ${attempts} attempts file=${path.basename(fullPath)}`);
    return;
  }
  fs.renameSync(claimPath, fullPath);
}

function recoverStrandedOutboxClaims() {
  let names = [];
  try {
    names = fs.readdirSync(outboxDir).filter((name) => name.endsWith(".json.sending"));
  } catch (err) {
    appendLog(`outbox recover scan failed ${String(err?.message || err)}`);
    return;
  }
  for (const name of names) {
    const claimPath = path.join(outboxDir, name);
    const restoredPath = path.join(outboxDir, name.replace(/\.sending$/, ""));
    try {
      if (fs.existsSync(restoredPath)) {
        fs.unlinkSync(claimPath);
      } else {
        fs.renameSync(claimPath, restoredPath);
      }
      appendLog(`outbox recovered stranded claim file=${name}`);
    } catch (err) {
      appendLog(`outbox recover failed file=${name} ${String(err?.message || err)}`);
    }
  }
}

function pruneOldArtifacts() {
  const cutoff = Date.now() - ARTIFACT_RETENTION_MS;
  for (const dir of [sentDir, mediaDir]) {
    let names = [];
    try {
      names = fs.readdirSync(dir);
    } catch {
      continue;
    }
    for (const name of names) {
      const fullPath = path.join(dir, name);
      try {
        const stat = fs.statSync(fullPath);
        if (stat.isFile() && stat.mtimeMs < cutoff) {
          fs.unlinkSync(fullPath);
        }
      } catch {
        // ignore individual prune failures
      }
    }
  }
}

function pruneGatewaySentIds() {
  const cutoff = Date.now() - 10 * 60 * 1000;
  for (const [id, ts] of gatewaySentIds.entries()) {
    if (ts < cutoff) {
      gatewaySentIds.delete(id);
    }
  }
}

function pruneInboundProcessedIds() {
  const cutoff = Date.now() - 60 * 60 * 1000;
  for (const [id, ts] of inboundProcessedIds.entries()) {
    if (ts < cutoff) {
      inboundProcessedIds.delete(id);
    }
  }
}

function ensureLoopsStarted() {
  if (!outboxLoopStarted) {
    outboxLoopStarted = true;
    void outboxLoop();
  }
  if (!controlLoopStarted) {
    controlLoopStarted = true;
    void controlLoop();
  }
}

function isLinkedControlChat(remoteNumber, remoteJid = "") {
  const normalized = normalizeJidNumber(remoteNumber);
  const jid = normalizeChatJid(remoteJid);
  if (!normalized || !linkedControlChat || normalized !== linkedControlChat) {
    return false;
  }
  return !linkedControlJid || !jid || linkedControlJid === jid;
}

async function controlLoop() {
  while (!stopping) {
    try {
      if (fs.existsSync(controlPath)) {
        const control = JSON.parse(fs.readFileSync(controlPath, "utf8"));
        fs.unlinkSync(controlPath);
        if (control.action === "logout") {
          stopping = true;
          await sock?.logout?.();
          tryClose();
          writeStatus({ status: "logged_out", pid: process.pid });
          process.exit(0);
        }
      }
    } catch (err) {
      appendLog(`control error ${String(err?.message || err)}`);
    }
    await sleep(1000);
  }
}

function firstMessageType(payload) {
  if (payload?.conversation) {
    return "text";
  }
  if (payload?.audioMessage?.ptt) {
    return "ptt";
  }
  const key = Object.keys(payload || {}).find((name) => name.endsWith("Message"));
  if (!key) {
    return "unknown";
  }
  return key.replace(/Message$/, "").replace(/^extendedText$/, "text").replace(/^conversation$/, "text");
}

function unwrapMessageContent(message) {
  const content = message?.message || {};
  if (content.ephemeralMessage?.message) return content.ephemeralMessage.message;
  if (content.viewOnceMessage?.message) return content.viewOnceMessage.message;
  if (content.viewOnceMessageV2?.message) return content.viewOnceMessageV2.message;
  if (content.documentWithCaptionMessage?.message) return content.documentWithCaptionMessage.message;
  if (content.templateMessage?.hydratedTemplate) return content.templateMessage.hydratedTemplate;
  if (content.buttonsMessage) return content.buttonsMessage;
  if (content.listMessage) return content.listMessage;
  return content;
}

function extractContextInfo(payload) {
  if (!payload || typeof payload !== "object") {
    return {};
  }
  for (const value of Object.values(payload)) {
    if (value && typeof value === "object" && value.contextInfo) {
      return value.contextInfo;
    }
  }
  return {};
}

function extractText(payload) {
  if (payload.conversation) {
    return String(payload.conversation);
  }
  if (payload.extendedTextMessage?.text) {
    return String(payload.extendedTextMessage.text);
  }
  for (const key of ["imageMessage", "videoMessage", "documentMessage"]) {
    if (payload[key]?.caption) {
      return String(payload[key].caption);
    }
  }
  return "";
}

function normalizeJidNumber(value) {
  const raw = String(value || "").trim();
  if (raw.startsWith("whatsapp-group:") || raw.endsWith("@g.us")) {
    return "";
  }
  return raw
    .replace(/@s\.whatsapp\.net$/, "")
    .replace(/@lid$/, "")
    .replace(/:\d+$/, "")
    .replace(/^whatsapp:/, "")
    .replace(/[^0-9+]/g, "")
    .replace(/^\+/, "");
}

function normalizeChatJid(value) {
  const text = String(value || "").trim();
  const direct = /^([0-9]+)(?::\d+)?@(s\.whatsapp\.net|lid)$/.exec(text);
  if (direct) {
    return `${direct[1]}@${direct[2]}`;
  }

  return "";
}

function normalizeGroupJid(value) {
  const raw = String(value || "").trim();
  const prefixed = raw.startsWith("whatsapp-group:");
  const text = prefixed ? raw.slice("whatsapp-group:".length) : raw;
  const direct = /^([0-9]+)@g\.us$/.exec(text);
  if (direct) {
    return `${direct[1]}@g.us`;
  }
  if (prefixed && /^[0-9]{6,}$/.test(text)) {
    return `${text}@g.us`;
  }
  return "";
}

function rememberPhoneNumberJid(phoneJid, chatJid, source = "jid-map") {
  const normalizedPhoneJid = normalizeChatJid(phoneJid);
  const normalizedChatJid = normalizeChatJid(chatJid);
  const phoneNumber = normalizeJidNumber(normalizedPhoneJid);
  const chatNumber = normalizeJidNumber(normalizedChatJid);
  if (!phoneNumber || !normalizedChatJid) {
    return "";
  }

  if (chatNumber) {
    rememberIdentityAlias(phoneNumber, chatNumber, source);
  }
  const remembered = rememberChatJid(phoneNumber, normalizedChatJid);
  if (remembered) {
    appendLog(`jid mapped source=${source} phone=${maskValue(phoneNumber)} jid=${maskValue(remembered)}`);
  }
  return remembered;
}

function rememberContactJids(contacts, source = "contacts") {
  for (const contact of contacts || []) {
    const phoneJid = normalizeChatJid(contact?.id || contact?.jid || "");
    const lidJid = normalizeChatJid(contact?.lid || contact?.lidJid || "");
    if (phoneJid && lidJid) {
      rememberPhoneNumberJid(phoneJid, lidJid, source);
    }
  }
}

function rememberChatJid(number, jid) {
  const normalizedNumber = canonicalIdentifier(number);
  const normalizedJid = normalizeChatJid(jid);
  if (!normalizedNumber || !normalizedJid) {
    return normalizedJid;
  }

  if (!chatJids || typeof chatJids !== "object" || Array.isArray(chatJids)) {
    chatJids = {};
  }
  if (chatJids[normalizedNumber] !== normalizedJid) {
    chatJids[normalizedNumber] = normalizedJid;
    writeJsonFileAtomic(chatJidsPath, JSON.stringify(chatJids, null, 2));
  }

  return normalizedJid;
}

function resolveOutboundJid(number, requestedJid = "") {
  const requestedGroup = normalizeGroupJid(requestedJid);
  if (requestedGroup) {
    return requestedGroup;
  }
  const numberGroup = normalizeGroupJid(number);
  if (numberGroup) {
    return numberGroup;
  }

  const direct = normalizeChatJid(requestedJid);
  if (direct) {
    return direct;
  }

  const normalizedNumber = normalizeJidNumber(number);
  const canonicalNumber = canonicalIdentifier(normalizedNumber);
  const known = normalizeChatJid(chatJids?.[canonicalNumber] || chatJids?.[normalizedNumber] || "");
  if (known) {
    return known;
  }

  return `${canonicalNumber || normalizedNumber}@s.whatsapp.net`;
}

function isGroupAllowed(groupJid) {
  const normalizedGroup = normalizeGroupJid(groupJid);
  if (!normalizedGroup) {
    return false;
  }

  const policy = readJson(accessPolicyPath, {});
  const lists = [
    ...(Array.isArray(policy?.admin_groups) ? policy.admin_groups : []),
    ...(Array.isArray(policy?.stock_manager_groups) ? policy.stock_manager_groups : []),
    ...(Array.isArray(policy?.lead_groups) ? policy.lead_groups : []),
  ].map((value) => normalizeGroupJid(value)).filter(Boolean);

  return lists.includes(normalizedGroup);
}

function isInboundAllowed(number) {
  const canonicalNumber = canonicalIdentifier(number);
  if (!canonicalNumber) {
    return false;
  }

  const policy = readJson(accessPolicyPath, {});
  const lists = [
    ...(Array.isArray(policy?.admins) ? policy.admins : []),
    ...(Array.isArray(policy?.stock_managers) ? policy.stock_managers : []),
    ...(Array.isArray(policy?.leads) ? policy.leads : []),
  ].map((value) => canonicalIdentifier(value)).filter(Boolean);

  if (lists.includes("*")) {
    return true;
  }
  if (lists.includes(canonicalNumber)) {
    return true;
  }
  return false;
}

function messageAddressesBot(text, structuredCommand, mentionedIds = [], quotedParticipant = "", botIds = []) {
  if (structuredCommand) {
    return true;
  }
  if (String(text || "").trim().startsWith("/")) {
    return true;
  }

  const bots = new Set((botIds || []).map(normalizeChatJid).filter(Boolean));
  if (!bots.size) {
    return false;
  }
  if (bots.has(normalizeChatJid(quotedParticipant))) {
    return true;
  }
  for (const mentioned of mentionedIds || []) {
    if (bots.has(normalizeChatJid(mentioned))) {
      return true;
    }
  }
  return false;
}

function extractStructuredCommand(payload) {
  const candidates = [
    payload?.buttonsResponseMessage?.selectedButtonId,
    payload?.templateButtonReplyMessage?.selectedId,
    payload?.listResponseMessage?.singleSelectReply?.selectedRowId,
    payload?.interactiveResponseMessage?.nativeFlowResponseMessage?.name,
  ];

  const paramsJson = payload?.interactiveResponseMessage?.nativeFlowResponseMessage?.paramsJson;
  if (typeof paramsJson === "string" && paramsJson.trim()) {
    try {
      const params = JSON.parse(paramsJson);
      candidates.push(params?.id, params?.button_id, params?.selectedId);
    } catch (err) {
      appendLog(`interactive command params parse failed ${String(err?.message || err)}`);
    }
  }

  for (const raw of candidates) {
    const normalized = normalizeStructuredCommandId(raw);
    if (normalized) {
      return normalized;
    }
  }
  return "";
}

function isIgnoredConversationJid(value) {
  const jid = String(value || "").trim();
  const normalized = normalizeChatJid(jid);
  const comparable = normalized || jid;
  return !comparable
    || comparable === "status@broadcast"
    || comparable.endsWith("@broadcast")
    || comparable.endsWith("@newsletter");
}

function normalizeStructuredCommandId(raw) {
  const value = String(raw || "").trim();
  if (!value) {
    return "";
  }
  const command = value.startsWith("niwa:") ? value.slice(5) : value;
  const safe = command.toLowerCase().replace(/[^a-z_]/g, "");
  return safe ? `niwa:${safe}` : "";
}

function canonicalIdentifier(...values) {
  const firstRaw = values.map((value) => String(value || "").trim()).find(Boolean) || "";
  if (firstRaw === "*") {
    return "*";
  }
  const first = values.map((value) => normalizeJidNumber(value)).find(Boolean) || "";
  if (!first) {
    return "";
  }
  if (!identityAliases || typeof identityAliases !== "object" || Array.isArray(identityAliases)) {
    identityAliases = {};
  }

  let current = first;
  const seen = new Set();
  for (let i = 0; i < 8; i += 1) {
    if (!current || seen.has(current) || !identityAliases[current]) {
      break;
    }
    seen.add(current);
    const next = normalizeJidNumber(identityAliases[current]);
    if (!next || next === current) {
      break;
    }
    current = next;
  }
  return current || first;
}

function rememberIdentityAlias(canonical, alias, source = "identity") {
  const canonicalNumber = normalizeJidNumber(canonical);
  const aliasNumber = normalizeJidNumber(alias);
  if (!canonicalNumber || !aliasNumber || canonicalNumber === aliasNumber) {
    return false;
  }
  if (!identityAliases || typeof identityAliases !== "object" || Array.isArray(identityAliases)) {
    identityAliases = {};
  }

  let changed = false;
  if (identityAliases[aliasNumber] !== canonicalNumber) {
    identityAliases[aliasNumber] = canonicalNumber;
    changed = true;
  }
  if (!identityAliases[canonicalNumber]) {
    identityAliases[canonicalNumber] = canonicalNumber;
    changed = true;
  }
  if (changed) {
    writeJsonFileAtomic(identityAliasesPath, JSON.stringify(identityAliases, null, 2));
    appendLog(`identity alias mapped source=${source} canonical=${maskValue(canonicalNumber)} alias=${maskValue(aliasNumber)}`);
  }
  return changed;
}

function normalizePresence(value) {
  const presence = String(value || "").trim();
  return ["composing", "paused", "available", "unavailable", "recording"].includes(presence)
    ? presence
    : "";
}

function normalizeTimestampSeconds(value) {
  if (!value) {
    return 0;
  }
  if (typeof value === "number") {
    return Number.isFinite(value) ? Math.floor(value) : 0;
  }
  if (typeof value === "bigint") {
    return Number(value);
  }
  if (typeof value?.toNumber === "function") {
    const numeric = value.toNumber();
    return Number.isFinite(numeric) ? Math.floor(numeric) : 0;
  }
  const parsed = Number(value);
  return Number.isFinite(parsed) ? Math.floor(parsed) : 0;
}

function shortId(value) {
  return String(value || "").slice(0, 12);
}

function maskValue(value) {
  const text = String(value || "");
  if (text.length <= 4) {
    return text;
  }
  return `...${text.slice(-4)}`;
}

function extensionFor(type, mime) {
  const normalized = String(mime || "").toLowerCase();
  if (normalized.includes("png")) return "png";
  if (normalized.includes("webp")) return "webp";
  if (normalized.includes("jpeg") || normalized.includes("jpg")) return "jpg";
  if (normalized.includes("mp4")) return "mp4";
  if (normalized.includes("ogg")) return "ogg";
  if (normalized.includes("mpeg")) return "mp3";
  if (type === "audio" || type === "ptt") return "ogg";
  if (type === "document") return "bin";
  return type;
}

function postJson(targetUrl, payload, redirectCount = 0) {
  return new Promise((resolve, reject) => {
    const url = new URL(targetUrl);
    const body = JSON.stringify(payload);
    const transport = url.protocol === "http:" ? http : https;
    const req = transport.request(
      url,
      {
        method: "POST",
        headers: {
          "content-type": "application/json",
          "content-length": Buffer.byteLength(body),
        },
        timeout: WEBHOOK_TIMEOUT_MS,
      },
      (res) => {
        const location = res.headers?.location || "";
        if ([301, 302, 303, 307, 308].includes(res.statusCode || 0) && location && redirectCount < 5) {
          res.resume();
          res.on("end", () => {
            const nextUrl = new URL(location, url).toString();
            appendLog(`webhook redirect ${res.statusCode} -> ${new URL(nextUrl).origin}`);
            postJson(nextUrl, payload, redirectCount + 1).then(resolve, reject);
          });
          return;
        }
        res.resume();
        res.on("end", () => resolve({ statusCode: res.statusCode || 0 }));
      },
    );

    req.on("timeout", () => {
      req.destroy(new Error("request_timeout"));
    });
    req.on("error", reject);
    req.write(body);
    req.end();
  });
}

function writeStatus(next) {
  const prev = readJson(statusPath, {});
  const status = {
    ...prev,
    ...next,
    pid: process.pid,
    updatedAt: new Date().toISOString(),
  };
  writeJsonFileAtomic(statusPath, JSON.stringify(status, null, 2));
}

function writeJsonFileAtomic(file, contents) {
  const tmp = `${file}.${process.pid}.tmp`;
  fs.writeFileSync(tmp, contents);
  fs.renameSync(tmp, file);
}

function appendLog(line) {
  fs.appendFileSync(logPath, `[${new Date().toISOString()}] ${line}\n`);
}

function fatalExit(source, err) {
  const detail = formatError(err);
  try {
    appendLog(`fatal ${source}: ${detail}`);
    writeStatus({
      status: "fatal",
      pid: process.pid,
      lastError: `${source}: ${String(err?.message || err || "unknown_error")}`,
    });
  } catch {
    // ignore
  }
  tryClose();
  process.exit(1);
}

function formatError(err) {
  if (err?.stack) {
    return String(err.stack).replace(/\s+/g, " ").slice(0, 2000);
  }
  return String(err?.message || err || "unknown_error").slice(0, 2000);
}

function tryClose() {
  try {
    sock?.ws?.close?.();
  } catch {
    // ignore
  }
}

function readJson(file, fallback) {
  try {
    return JSON.parse(fs.readFileSync(file, "utf8"));
  } catch {
    return fallback;
  }
}

function readGatewaySecret() {
  const fromFile = readJson(gatewaySecretPath, {});
  const fileSecret = String(fromFile?.secret || "").trim();
  return fileSecret || initialSecret;
}

function parseArgs(values) {
  const out = {};
  for (const value of values) {
    const match = /^--([^=]+)=(.*)$/.exec(value);
    if (match) {
      out[match[1]] = match[2];
    }
  }
  return out;
}
