'use strict';

/**
 * WhatsApp-Web bridge for the ERP WhatsApp module.
 *
 * One process serves ONE channel (one WhatsApp number). It:
 *   - links to WhatsApp by QR (pushed to the ERP so staff scan it in the UI),
 *   - sends a heartbeat + connection state to the ERP,
 *   - pulls queued outbound messages and sends them through the live socket,
 *   - forwards inbound messages back to the ERP.
 *
 * It authenticates to the ERP with the channel's bridge token
 * (wa_channels.api_token). Session credentials persist on disk so the link
 * survives restarts; if the link drops, the ERP flags the channel and prompts
 * staff to re-scan — meanwhile queued messages wait safely and flush on
 * reconnect (the "never lose a notification" guarantee).
 *
 * Config via environment variables:
 *   ERP_BASE_URL    e.g. https://erp.example.com/api/whatsapp/agent
 *   CHANNEL_TOKEN   the wa_channels.api_token for this channel
 *   AUTH_DIR        folder to persist session creds (default ./auth)
 *   POLL_MS         outbox poll interval (default 4000)
 *   HEARTBEAT_MS    heartbeat interval (default 30000)
 */

require('dotenv').config();

const {
  default: makeWASocket,
  useMultiFileAuthState,
  DisconnectReason,
  fetchLatestBaileysVersion,
  downloadMediaMessage,
} = require('@whiskeysockets/baileys');
const axios = require('axios');
const pino = require('pino');
const qrcodeTerminal = require('qrcode-terminal');

const ERP_BASE_URL = (process.env.ERP_BASE_URL || '').replace(/\/+$/, '');
const CHANNEL_TOKEN = process.env.CHANNEL_TOKEN || '';
const AUTH_DIR = process.env.AUTH_DIR || './auth';
const POLL_MS = parseInt(process.env.POLL_MS || '4000', 10);
const HEARTBEAT_MS = parseInt(process.env.HEARTBEAT_MS || '30000', 10);
const VERSION = '1.0.0';

if (!ERP_BASE_URL || !CHANNEL_TOKEN) {
  console.error('ERP_BASE_URL and CHANNEL_TOKEN are required.');
  process.exit(1);
}

const api = axios.create({
  baseURL: ERP_BASE_URL,
  timeout: 30000,
  headers: { Authorization: `Bearer ${CHANNEL_TOKEN}` },
});

const log = pino({ level: process.env.LOG_LEVEL || 'info' });

let sock = null;
let connected = false;
let sending = { gap_min: 4, gap_max: 12, typing_per_char_ms: 55, typing_min_ms: 900, typing_max_ms: 9000 };

const randInt = (a, b) => Math.floor(Math.random() * (b - a + 1)) + a;
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const digitsOf = (s) => String(s || '').replace(/[^0-9]/g, '');
const jidFor = (waId) => `${digitsOf(waId)}@s.whatsapp.net`;

// Remembers the exact JID each contact messaged us from, keyed by its digits.
// WhatsApp's privacy "LID" addressing means a 1:1 chat can be `<id>@lid` rather
// than `<phone>@s.whatsapp.net`; we must reply to that same JID for it to route.
const jidByDigits = {};
const resolveSendJid = (to) => jidByDigits[digitsOf(to)] || jidFor(to);

// Pull the real phone number out of a message key. WhatsApp usually carries the
// phone-number counterpart of a LID in one of these "alt"/"Pn" fields.
function phoneFromKey(key) {
  const cands = [key.remoteJidAlt, key.senderPn, key.participantPn, key.participantAlt, key.participant];
  for (const c of cands) {
    if (c && String(c).endsWith('@s.whatsapp.net')) return digitsOf(c);
  }
  return null;
}

// Fallback: ask Baileys' LID↔PN mapping store (method names vary by version).
async function pnFromLid(lid) {
  try {
    const lm = sock && sock.signalRepository && sock.signalRepository.lidMapping;
    if (lm) {
      for (const fn of ['getPNForLID', 'getPnForLid', 'getPNForLid']) {
        if (typeof lm[fn] === 'function') {
          const r = await lm[fn](lid);
          if (r) return digitsOf(r);
        }
      }
    }
  } catch (e) {}
  return null;
}

async function post(path, body) {
  try {
    const { data } = await api.post(path, body);
    return data;
  } catch (e) {
    log.warn(`POST ${path} failed: ${e.message}`);
    return null;
  }
}

async function reportState(state, extra = {}) {
  await post('/state', { state, ...extra });
}

async function startSocket() {
  const { state, saveCreds } = await useMultiFileAuthState(AUTH_DIR);
  const { version } = await fetchLatestBaileysVersion();

  sock = makeWASocket({
    version,
    auth: state,
    logger: pino({ level: 'silent' }),
    printQRInTerminal: false,
    markOnlineOnConnect: false,
  });

  sock.ev.on('creds.update', saveCreds);

  sock.ev.on('connection.update', async (update) => {
    const { connection, lastDisconnect, qr } = update;

    if (qr) {
      // Surface the QR both in the ERP UI and the terminal.
      qrcodeTerminal.generate(qr, { small: true });
      await post('/qr', { qr });
      await reportState('scanning');
    }

    if (connection === 'open') {
      connected = true;
      const me = sock.user && sock.user.id ? sock.user.id.split(':')[0] : null;
      log.info(`Connected as ${me}`);
      try { await sock.sendPresenceUpdate('available'); } catch (e) {}
      await reportState('connected', { connected_as: me });
    }

    if (connection === 'close') {
      connected = false;
      const code = lastDisconnect && lastDisconnect.error
        ? (lastDisconnect.error.output ? lastDisconnect.error.output.statusCode : null)
        : null;
      const loggedOut = code === DisconnectReason.loggedOut;

      log.warn(`Connection closed (code=${code}, loggedOut=${loggedOut})`);
      await reportState('disconnected', { reason: loggedOut ? 'Logged out — re-scan required.' : `Closed (code ${code}).` });

      if (loggedOut) {
        // Credentials are dead; the operator must re-scan. Restart fresh.
        log.error('Logged out. Delete the auth dir and re-scan from the ERP.');
        process.exit(1);
      } else {
        await sleep(3000);
        startSocket().catch((e) => log.error(e.message));
      }
    }
  });

  // Inbound messages → forward to ERP.
  sock.ev.on('messages.upsert', async (m) => {
    if (m.type !== 'notify') return;
    for (const msg of m.messages) {
      if (!msg.message || msg.key.fromMe) continue;
      const remoteJid = msg.key.remoteJid || '';
      if (!remoteJid || remoteJid.endsWith('@g.us')) continue; // skip groups/empty
      const lidDigits = digitsOf(remoteJid);
      if (!lidDigits) continue;

      // Resolve the real phone behind a LID so the ERP can match the contact.
      let phone = phoneFromKey(msg.key);
      if (!phone && remoteJid.endsWith('@lid')) {
        phone = await pnFromLid(remoteJid);
      }
      const from = phone || lidDigits;       // prefer the real phone for identity

      // Remember how to reach this person — always reply to the original JID.
      jidByDigits[from] = remoteJid;
      jidByDigits[lidDigits] = remoteJid;

      if (remoteJid.endsWith('@lid')) {
        log.info(`LID inbound ${remoteJid} → phone=${phone || 'UNRESOLVED'} alt=${JSON.stringify({
          remoteJidAlt: msg.key.remoteJidAlt, senderPn: msg.key.senderPn,
          participantPn: msg.key.participantPn, participantAlt: msg.key.participantAlt,
        })}`);
      }

      let type = 'text';
      let body =
        msg.message.conversation ||
        (msg.message.extendedTextMessage && msg.message.extendedTextMessage.text) ||
        '';
      let media_base64 = null;
      let media_mime = null;

      // Image messages: download so the ERP can read the photo (vision OCR).
      const img = msg.message.imageMessage;
      if (img) {
        type = 'image';
        body = img.caption || '';
        media_mime = img.mimetype || 'image/jpeg';
        try {
          const buf = await downloadMediaMessage(msg, 'buffer', {}, { reuploadRequest: sock.updateMediaMessage });
          if (buf && buf.length && buf.length <= 5 * 1024 * 1024) {
            media_base64 = buf.toString('base64');
          } else if (buf && buf.length > 5 * 1024 * 1024) {
            log.warn(`image too large (${buf.length} bytes) — not forwarding`);
          }
        } catch (e) {
          log.warn(`image download failed: ${e.message}`);
        }
      }

      await post('/inbound', {
        from,
        address_jid: remoteJid,
        name: msg.pushName || null,
        type,
        body,
        media_base64,
        media_mime,
        provider_message_id: msg.key.id || null,
        payload: { key: msg.key },
      });

      // Realism: send a read receipt (blue ticks) + brief online presence.
      try {
        await sock.readMessages([msg.key]);
        await sock.sendPresenceUpdate('available');
      } catch (e) {}
    }
  });
}

async function register() {
  const data = await post('/register', { bridge_version: VERSION });
  if (data && data.sending) {
    sending = Object.assign(sending, data.sending);
  }
}

async function heartbeatLoop() {
  for (;;) {
    await post('/heartbeat', { bridge_version: VERSION });
    await sleep(HEARTBEAT_MS);
  }
}

async function sendOne(item) {
  // Prefer the durable routing JID the ERP stored (e.g. a LID), then the
  // in-memory map, then a plain phone JID.
  const jid = item.address_jid || resolveSendJid(item.to);
  const isLid = jid.endsWith('@lid');
  const payload = item.payload || {};

  // Verify the number is on WhatsApp before sending — but ONLY for phone JIDs.
  // onWhatsApp expects phone numbers; a LID contact already messaged us, so it
  // clearly has WhatsApp — skip the check and reply directly to their JID.
  if (!isLid) {
    try {
      const results = await sock.onWhatsApp(jid);
      const info = Array.isArray(results) ? results[0] : null;
      if (!info || !info.exists) {
        await post('/ack', { id: item.id, success: false, wa_available: false, error: 'Recipient is not a WhatsApp user.' });
        return false;
      }
    } catch (e) {
      // If the check itself fails, fall through and attempt the send anyway.
      log.warn(`onWhatsApp check failed for ${item.to}: ${e.message}`);
    }
  }

  // Realism: "typing…" for a human-like duration proportional to the message
  // length (chars × per-char ms), clamped, with a little randomness.
  try {
    var len = (item.body || '').length;
    var base = len * (sending.typing_per_char_ms || 55);
    var jitter = randInt(-200, 400);
    var dur = Math.max(sending.typing_min_ms || 900, Math.min(sending.typing_max_ms || 9000, base + jitter));
    await sock.sendPresenceUpdate('composing', jid);
    await sleep(dur);
  } catch (e) {}

  try {
    if (item.type === 'image' && (payload.link || payload.url)) {
      await sock.sendMessage(jid, { image: { url: payload.link || payload.url }, caption: payload.caption || item.body || '' });
    } else if ((item.type === 'document') && (payload.link || payload.url)) {
      await sock.sendMessage(jid, { document: { url: payload.link || payload.url }, fileName: payload.filename || 'file', caption: payload.caption || '' });
    } else {
      await sock.sendMessage(jid, { text: item.body || '' });
    }
    try { await sock.sendPresenceUpdate('paused', jid); } catch (e) {}
    await post('/ack', { id: item.id, success: true, provider_message_id: null, wa_available: true });
    return true;
  } catch (e) {
    log.warn(`send #${item.id} failed: ${e.message}`);
    await post('/ack', { id: item.id, success: false, error: e.message });
    return false;
  }
}

async function outboxLoop() {
  for (;;) {
    if (connected) {
      const data = await api.get('/outbox', { params: { limit: 5 } })
        .then((r) => r.data)
        .catch((e) => { log.warn(`GET /outbox failed: ${e.message}`); return null; });
      const messages = (data && data.messages) || [];
      for (const item of messages) {
        await sendOne(item);
        // Anti-ban: randomised gap (gap_min..gap_max seconds) between sends.
        await sleep(randInt(sending.gap_min || 4, sending.gap_max || 12) * 1000);
      }
    }
    await sleep(POLL_MS);
  }
}

(async () => {
  log.info(`WhatsApp bridge v${VERSION} starting → ${ERP_BASE_URL}`);
  await register();
  await startSocket();
  heartbeatLoop().catch((e) => log.error(e.message));
  outboxLoop().catch((e) => log.error(e.message));
})();

process.on('SIGINT', async () => {
  await reportState('disconnected', { reason: 'Bridge stopped.' });
  process.exit(0);
});
