/**
 * src/channels/whatsapp/baileys-adapter.ts
 *
 * Baileys-backed WhatsApp adapter for Phase 1.
 *
 * Why Baileys (vs Meta Cloud API):
 *   - Customer scans a QR from the WhatsApp Business mobile app on a fresh
 *     dedicated SIM and Muassis is connected. No Meta Business setup, no
 *     paperwork, no BSP fees.
 *   - The user has been running OpenClaw on this same pattern for 4 months on
 *     their own device with zero ban events, so the risk is empirically
 *     acceptable for low-volume B2B conversational AI on a dedicated number.
 *
 * Session credentials are persisted to the platform data dir:
 *   {MUASSIS_DATA_DIR}/whatsapp/<tenantId>/   (creds.json + key files)
 *
 * The dashboard polls/streams the QR code via /api/tenants/:id/whatsapp/qr.
 * Once paired, incoming messages route through the existing agent loop —
 * processMessage() doesn't care which channel a message came in on.
 *
 * Future: when the locked decision flips back to Meta Cloud API or BSP, swap
 * this adapter behind the WhatsAppAdapter interface and the agent path stays
 * unchanged. See al-moasis.md "WhatsApp" decision lock.
 */
// (v3.0.3) Migrated from abandoned `@whiskeysockets/baileys@6.7.23` →
// active `baileys@7.x` fork. The old package was speaking outdated WA
// pairing protocol → "couldn't link device" on every attempt. The new
// package keeps up with WA protocol changes.
import { default as makeWASocket, DisconnectReason, useMultiFileAuthState, fetchLatestBaileysVersion, Browsers, } from 'baileys';
import qrcodeTerminal from 'qrcode-terminal';
import { mkdir } from 'node:fs/promises';
import { existsSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
import { createChildLogger } from '../../utils/logger.js';
import { supabase } from '../../utils/supabase.js';
import { processMessage } from '../../agent/brain.js';
import { learnFromConversation } from '../../knowledge/unified.js';
import { getDataDir } from '../../utils/data-dir.js';
const log = createChildLogger('whatsapp:baileys');
// Session map keyed by `${tenantId}:${accountId}` so a single tenant can
// run multiple WhatsApp numbers concurrently (sales WA + support WA).
const sessions = new Map();
function sessionKey(tenantId, accountId) {
    return `${tenantId}:${accountId}`;
}
const RECENT_MESSAGES_CAP = 500;
/**
 * Resolve a `@lid`-format JID to a real phone number. WhatsApp 2025+ moved
 * many users to the new LID (Local Identifier) format; without this lookup,
 * inbound messages from those users land with an unparseable JID and the
 * adapter silently drops them.
 *
 * Baileys auto-creates `lid-mapping-<lid>_reverse.json` files inside the auth
 * dir as it learns the mapping; the file's content is the phone number string.
 * We try the targeted reverse file first (O(1)), then fall back to scanning.
 *
 * Pattern lifted from OpenClaw's baileys-bridge — the equivalent function
 * was the difference between "messages flow reliably" and "messages randomly
 * disappear with no error."
 */
function resolveLidToPhone(authDir, lid) {
    const lidNum = String(lid).replace(/@lid$/, '').replace(/:.*$/, '');
    if (!lidNum)
        return null;
    try {
        const reversePath = join(authDir, `lid-mapping-${lidNum}_reverse.json`);
        if (existsSync(reversePath)) {
            const phone = JSON.parse(readFileSync(reversePath, 'utf-8'));
            return String(phone);
        }
    }
    catch { /* fall through */ }
    return null;
}
/** Path to where Baileys stores per-tenant per-account auth files. */
function authDirFor(tenantId, accountId = 'default') {
    return join(getDataDir(), 'whatsapp', tenantId, accountId);
}
/** Render a QR string to ASCII art the dashboard can display in a <pre> block. */
function qrToAscii(qr) {
    return new Promise((resolve) => {
        qrcodeTerminal.generate(qr, { small: true }, (out) => resolve(out));
    });
}
/**
 * Render a QR string to a base64-encoded PNG data URL. Used by the dashboard
 * to show a scannable image instead of ASCII art (which phone cameras struggle
 * with at small terminal-cell sizes). Emerald-on-white to match brand.
 */
async function qrToPngDataUrl(qr) {
    const QR = await import('qrcode');
    return QR.toDataURL(qr, {
        errorCorrectionLevel: 'M',
        width: 320,
        margin: 2,
        color: { dark: '#0A0F14', light: '#FFFFFF' },
    });
}
/**
 * Idempotent: returns the existing session if connected/connecting,
 * otherwise spins up a new one. Always returns immediately — connection
 * happens async; poll status via getSessionStatus.
 */
// Per-tenant pair-attempt rate limiter. WhatsApp throttles aggressively
// when a single number does many pair attempts in an hour — they soft-block
// link_device for 24h+. This in-process limiter prevents Muassis itself
// from being the source of those rapid retries. Each entry is a sliding
// window of timestamps; we keep at most the last 20 minutes per tenant.
//
// IMPORTANT: only count attempts that ACTUALLY open a new Baileys WS. Calls
// that short-circuit to a cached session (or that just peek at status) must
// NEVER be counted, otherwise dashboard status-polls burn the budget and
// lock the user out of pairing for 10–20 minutes (which was the v3.0.0
// "WhatsApp won't connect" bug — GET /status was eating attempts).
const PAIR_ATTEMPTS = new Map();
const PAIR_WINDOW_MS = 20 * 60 * 1000;
const PAIR_MAX_PER_WINDOW = 5;
function recordPairAttempt(tenantId) {
    const now = Date.now();
    const arr = (PAIR_ATTEMPTS.get(tenantId) ?? []).filter((t) => now - t < PAIR_WINDOW_MS);
    if (arr.length >= PAIR_MAX_PER_WINDOW) {
        const oldest = arr[0];
        const cooldown = Math.ceil((PAIR_WINDOW_MS - (now - oldest)) / 1000);
        return { allowed: false, cooldownSec: cooldown };
    }
    arr.push(now);
    PAIR_ATTEMPTS.set(tenantId, arr);
    return { allowed: true, cooldownSec: 0 };
}
/** Wipe the rate-limit bucket for a tenant. Called on explicit user-initiated
 * reset so the user isn't stuck behind a budget they didn't realize they
 * were spending. */
function clearPairAttempts(tenantId) {
    PAIR_ATTEMPTS.delete(tenantId);
}
export async function startWhatsAppSession(tenantId, accountId = 'default') {
    const key = sessionKey(tenantId, accountId);
    const existing = sessions.get(key);
    if (existing && existing.status !== 'logged_out' && existing.status !== 'disconnected') {
        return existing;
    }
    // Rate-limit pair attempts. Without this, a misbehaving dashboard poll
    // loop or impatient user can hammer WA into a 24h cooldown.
    const limit = recordPairAttempt(tenantId);
    if (!limit.allowed) {
        log.warn({ tenantId, cooldownSec: limit.cooldownSec }, 'WA pair-attempt rate limit hit — refusing new session start');
        const stale = {
            tenantId,
            accountId,
            socket: null,
            status: 'disconnected',
            lastQr: null,
            lastQrAt: null,
            authDir: authDirFor(tenantId, accountId),
            reconnectTimer: null,
            presenceTimer: null,
            recentMessages: [],
            jid: null,
            connectedSince: null,
            reconnectAttempts: 0,
            lastReconnectAt: null,
            lastSuccessfulConnectAt: existing?.lastSuccessfulConnectAt ?? null,
            reconnectGivenUp: true,
            messagesToday: 0,
            messagesTotal: 0,
            lastMessagePreview: null,
            lastMessageAt: null,
            lastError: `Pair rate-limited — wait ${Math.ceil(limit.cooldownSec / 60)} min before retrying. WhatsApp throttles after rapid attempts.`,
        };
        sessions.set(key, stale);
        return stale;
    }
    const authDir = authDirFor(tenantId, accountId);
    // If we're restarting a session that PREVIOUSLY GAVE UP and was NEVER
    // successfully paired, the auth dir likely contains half-formed creds
    // from the failed pair — Baileys will try to "resume" with them and fail
    // the same way. Wipe the auth dir before recreating so the user gets a
    // truly fresh QR. (Sessions that paired at least once keep their creds.)
    if (existing &&
        existing.reconnectGivenUp &&
        existing.lastSuccessfulConnectAt === null) {
        try {
            const { rm } = await import('node:fs/promises');
            await rm(authDir, { recursive: true, force: true });
            log.info({ tenantId, authDir }, 'baileys: wiped stale auth dir before retry (never paired)');
        }
        catch (err) {
            log.warn({ tenantId, error: err.message }, 'baileys: auth dir wipe failed (continuing)');
        }
    }
    await mkdir(authDir, { recursive: true });
    const session = {
        tenantId,
        accountId,
        socket: null,
        status: 'connecting',
        lastQr: null,
        lastQrAt: null,
        authDir,
        reconnectTimer: existing?.reconnectTimer ?? null,
        presenceTimer: existing?.presenceTimer ?? null,
        recentMessages: existing?.recentMessages ?? [],
        jid: existing?.jid ?? null,
        connectedSince: existing?.connectedSince ?? null,
        reconnectAttempts: existing?.reconnectAttempts ?? 0,
        lastReconnectAt: existing?.lastReconnectAt ?? null,
        // (v2.5.5) If the caller is explicitly re-opening the session (e.g.
        // dashboard QR refresh), reset the circuit-breaker so reconnect can
        // resume. The fields are preserved across restartWhatsAppSession to
        // keep the "have they ever scanned" history available to status APIs.
        lastSuccessfulConnectAt: existing?.lastSuccessfulConnectAt ?? null,
        reconnectGivenUp: false,
        messagesToday: existing?.messagesToday ?? 0,
        messagesTotal: existing?.messagesTotal ?? 0,
        lastMessagePreview: existing?.lastMessagePreview ?? null,
        lastMessageAt: existing?.lastMessageAt ?? null,
        lastError: null,
    };
    sessions.set(key, session);
    // Kick off connection in the background; do not block the caller.
    void connect(session).catch((err) => {
        log.error({ tenantId, error: err.message }, 'baileys connect failed');
        session.status = 'disconnected';
    });
    return session;
}
async function connect(session) {
    const { state, saveCreds } = await useMultiFileAuthState(session.authDir);
    const { version } = await fetchLatestBaileysVersion().catch(() => ({ version: undefined }));
    // Optional terminal QR — useful for ops debugging when the dashboard isn't
    // open. Default off (we serve QR via dashboard). Enable via env var:
    //   MUASSIS_BAILEYS_TERMINAL_QR=1
    // OpenClaw has this on by default; we keep it opt-in to avoid leaking the
    // QR to terminal-attached spectators (shoulder-surf in coffee shops).
    const terminalQR = process.env.MUASSIS_BAILEYS_TERMINAL_QR === '1';
    const sock = makeWASocket({
        auth: state,
        version,
        printQRInTerminal: terminalQR,
        // syncFullHistory replays messages received while offline — critical for
        // a B2B SMB agent that must not silently drop a customer message after a
        // gateway restart. Borrowed from OpenClaw's reliability patterns.
        syncFullHistory: true,
        // markOnlineOnConnect:false keeps presence private (no "online" indicator)
        // — preferred for a back-office agent that processes asynchronously.
        markOnlineOnConnect: false,
        // (v3.1) Standard platform identity via Baileys' Browsers helper →
        // ['Mac OS','Chrome','<ver>']. The prior custom ['Al Moasis','Chrome',…]
        // put a non-OS string in the PLATFORM slot, which WhatsApp's companion
        // registration rejects AFTER accepting the pairing — symptom: creds.json
        // gets written, then immediate "Connection Failure"/401 → logged_out, and
        // the phone shows "couldn't link device". A real OS in slot 1 is what WA
        // validates. (Device label shows as "Chrome (Mac OS)"; branding is
        // cosmetic — reinstate a custom name only after a link is proven.)
        browser: Browsers.macOS('Chrome'),
        // Socket-level keep-alive. Baileys' default ping cadence is conservative
        // and WA occasionally drops sockets that go quiet during a slow
        // pair-then-history-sync. 25s keeps the link warm without spamming.
        keepAliveIntervalMs: 25_000,
        // Pairing-time QR validity. Default is 60s; we serve 4 QR refreshes in
        // a row before giving up so the user has a real chance to grab their
        // phone from another room.
        // Bumped 60s → 120s (v3.0.2) — owner-reported "QR rotates mid-scan
        // and connection fails". Baileys serves ~4 QR refreshes within this
        // window. 120s gives the human enough time to: open WA on phone,
        // navigate to Linked Devices, point camera, hold still for the
        // (slow on Saudi cell networks) handshake. Trade-off: longer
        // dangling pairing socket if abandoned, but the in-process circuit
        // breaker still bails after the configured retry cap.
        qrTimeout: 120_000,
        // Spawn-time connection timeout: bumped from the default 20s. Phone
        // pairing on Saudi networks frequently takes 25–35s end-to-end
        // (WA-server hop + your phone's link confirmation), so the previous
        // shorter cap was producing premature "connection close" events that
        // looked like pairing errors to the user.
        connectTimeoutMs: 60_000,
        // getMessage satisfies server-side retry decryption — without it, certain
        // message types fail silently after a brief disconnect. Returning
        // undefined when we don't have the message lets Baileys fall back to
        // its built-in retry handling instead of replaying empty content (which
        // could keep WA in a retry loop).
        getMessage: async (key) => {
            const stored = session.recentMessages.find(m => m.msgId === key.id);
            return stored ? { conversation: stored.text } : undefined;
        },
    });
    session.socket = sock;
    log.info({ tenantId: session.tenantId, authDir: session.authDir }, 'baileys: socket created');
    sock.ev.on('creds.update', saveCreds);
    sock.ev.on('connection.update', async (update) => {
        const { connection, lastDisconnect, qr } = update;
        if (qr) {
            session.lastQr = qr;
            session.lastQrAt = Date.now();
            session.status = 'pending_qr';
            log.info({ tenantId: session.tenantId }, 'baileys: QR ready for scan');
        }
        if (connection === 'open') {
            session.status = 'connected';
            session.lastQr = null;
            session.jid = sock.user?.id ?? null;
            session.connectedSince = Date.now();
            session.lastSuccessfulConnectAt = Date.now(); // (v2.5.5) remember we got here at least once
            session.reconnectGivenUp = false;
            session.reconnectAttempts = 0; // success — reset backoff
            session.lastError = null;
            log.info({ tenantId: session.tenantId, jid: sock.user?.id, attempts: session.reconnectAttempts }, 'baileys: WhatsApp connected ✅');
            // Announce presence so WhatsApp shows the linked device as online
            // (instead of "last online 15:52"). Without this, customers texting
            // the number see a stale offline indicator which makes the agent
            // look unresponsive — even though it's actually receiving messages.
            // Also send a 'composing' presence ping every 50s as a heartbeat
            // so WA keeps the linked-device status fresh.
            sock.sendPresenceUpdate('available').catch((err) => {
                log.warn({ tenantId: session.tenantId, error: err.message }, 'sendPresenceUpdate(available) failed');
            });
            if (session.presenceTimer)
                clearInterval(session.presenceTimer);
            session.presenceTimer = setInterval(() => {
                if (!session.socket || session.status !== 'connected')
                    return;
                session.socket.sendPresenceUpdate('available').catch(() => { });
            }, 50_000);
        }
        if (connection === 'close') {
            const code = lastDisconnect?.error?.output?.statusCode;
            const reason = lastDisconnect?.error?.message ?? `code ${code ?? '?'}`;
            const loggedOut = code === DisconnectReason.loggedOut;
            // Some disconnect reasons require a fresh socket but NOT a fresh QR
            // (e.g. restartRequired, connectionReplaced). Others (loggedOut, banned)
            // mean the device is gone and the user must scan again.
            const restartRequired = code === DisconnectReason.restartRequired || code === DisconnectReason.connectionReplaced;
            // "Stream Errored (conflict)" — WhatsApp kicked us because another
            // device is using the same session (typically: user re-paired and a
            // new device # took over). Auto-retrying with the same auth files
            // just gets us kicked again. Treat it like loggedOut: wipe the auth
            // dir, mark session needs-fresh-pair, and STOP reconnecting until
            // the dashboard explicitly asks for a new QR.
            const conflict = /conflict/i.test(reason) && !restartRequired;
            session.status = loggedOut ? 'logged_out' : 'disconnected';
            session.socket = null;
            session.connectedSince = null;
            session.lastError = reason;
            log.warn({ tenantId: session.tenantId, code, reason, loggedOut, restartRequired, conflict }, 'baileys: connection closed');
            // Clear the presence heartbeat — no socket to ping anymore.
            if (session.presenceTimer) {
                clearInterval(session.presenceTimer);
                session.presenceTimer = null;
            }
            // On conflict, wipe auth + halt reconnect loop. The user must trigger
            // a fresh QR from the dashboard. Without this, every re-pair sends
            // the gateway into a 6-attempt retry storm against an auth dir that
            // WA will reject every single time.
            if (conflict) {
                log.warn({ tenantId: session.tenantId }, 'baileys: conflict detected — wiping auth dir + halting reconnect (user must re-pair via QR)');
                try {
                    const { rm } = await import('node:fs/promises');
                    await rm(session.authDir, { recursive: true, force: true });
                }
                catch (err) {
                    log.warn({ tenantId: session.tenantId, error: err.message }, 'auth dir wipe failed on conflict');
                }
                session.reconnectGivenUp = true;
                session.status = 'logged_out';
                return;
            }
            if (!loggedOut) {
                // (v2.5.5) Circuit breaker. Without this, an un-scanned QR loop
                // generates QR refs forever (every 20s) and produces 8+ auto-
                // reconnects with exponential backoff that never terminate. Cap:
                //   - if user has NEVER successfully connected: give up after 3
                //     attempts (likely the QR was never scanned; require manual
                //     restart from dashboard)
                //   - if user HAS connected at least once: cap at 8 attempts (5m
                //     backoff × 8 = ~17min total) before requiring manual restart
                const everConnected = session.lastSuccessfulConnectAt !== null;
                // First-connect cap was 3 — too aggressive for Saudi cell networks
                // where the initial pair-then-handshake legitimately fails 1–2x due
                // to WA-server hiccups before sticking. Bumped to 6 so the user
                // doesn't have to manually click "إعادة الإنشاء" mid-pair.
                const maxAttempts = everConnected ? 8 : 6;
                if (session.reconnectAttempts >= maxAttempts) {
                    session.reconnectGivenUp = true;
                    session.status = 'disconnected';
                    log.warn({ tenantId: session.tenantId, attempts: session.reconnectAttempts, everConnected, maxAttempts }, 'baileys: reconnect circuit-breaker tripped — giving up. Call startWhatsAppSession() to retry.');
                    return;
                }
                // Exponential backoff: 3s, 6s, 12s, 24s, 48s capped at 5m. Without
                // backoff a stuck WA-server-side issue spams reconnects in lockstep
                // and burns the rate-limit budget for every Muassis device on the
                // same tenant id.
                if (session.reconnectTimer)
                    clearTimeout(session.reconnectTimer);
                const attempt = session.reconnectAttempts + 1;
                const backoffMs = Math.min(3000 * Math.pow(2, attempt - 1), 300_000);
                // Tighten if it's a restart-required: try faster (Baileys docs say
                // these recover quickly).
                const delay = restartRequired ? Math.min(backoffMs, 5_000) : backoffMs;
                session.reconnectAttempts = attempt;
                session.lastReconnectAt = Date.now();
                log.info({ tenantId: session.tenantId, attempt, delayMs: delay, reason, maxAttempts, everConnected }, 'baileys: scheduling auto-reconnect');
                session.reconnectTimer = setTimeout(() => {
                    if (session.reconnectGivenUp) {
                        log.debug({ tenantId: session.tenantId }, 'baileys: skipping auto-reconnect — circuit-breaker tripped');
                        return;
                    }
                    log.info({ tenantId: session.tenantId, attempt }, 'baileys: auto-reconnect firing');
                    session.status = 'connecting';
                    void connect(session).catch((err) => {
                        log.error({ tenantId: session.tenantId, error: err.message }, 'baileys: auto-reconnect failed');
                        session.status = 'disconnected';
                        session.lastError = err.message;
                    });
                }, delay);
            }
        }
    });
    sock.ev.on('messages.upsert', async ({ messages, type }) => {
        // Accept both 'notify' (real-time delivery) and 'append' (history sync /
        // missed messages on reconnect). Without 'append', any message received
        // while the gateway was down is invisible after restart.
        if (type !== 'notify' && type !== 'append')
            return;
        for (const m of messages) {
            try {
                await handleIncomingMessage(session, m);
            }
            catch (err) {
                log.error({ tenantId: session.tenantId, error: err.message }, 'inbound message failed');
            }
        }
    });
    // Subscribe to additional events for delivery receipts + history sync —
    // logged for observability; future work can wire them to UI badges.
    sock.ev.on('messages.update', () => { });
    sock.ev.on('messaging-history.set', () => { });
}
/**
 * Convert a Baileys message to text, route through the agent, send the reply
 * back via the same socket. Self-messages and status updates are ignored.
 */
async function handleIncomingMessage(session, m) {
    if (m.key.fromMe)
        return;
    if (!m.message)
        return;
    // Skip status broadcasts and ephemeral admin messages
    const remoteJid = m.key.remoteJid ?? '';
    if (remoteJid === 'status@broadcast')
        return;
    // Group messages: ambient-mode reactions vs full replies
    // Pattern adopted from OpenClaw (`messages.ackReactionScope: "group-mentions"`):
    // in groups, default to a single emoji reaction so Muassis is felt-but-not-loud.
    // Full reply only when the message explicitly mentions the bot's name or
    // contains the configured wake word. Cuts noise dramatically in busy groups.
    const isGroup = remoteJid.endsWith('@g.us');
    if (isGroup) {
        await handleGroupMessage(session, m, remoteJid);
        return;
    }
    const text = m.message.conversation ||
        m.message.extendedTextMessage?.text ||
        m.message.buttonsResponseMessage?.selectedDisplayText ||
        m.message.imageMessage?.caption ||
        m.message.videoMessage?.caption ||
        '';
    if (!text.trim())
        return;
    // Dedupe by msgId — Baileys can replay the same message during 'append' sync.
    const msgId = m.key.id ?? '';
    if (msgId && session.recentMessages.some(r => r.msgId === msgId))
        return;
    // Resolve sender JID. WhatsApp 2025+ delivers many users via @lid (Linked
    // ID — a privacy-preserving identifier). We try to map @lid → phone via
    // on-disk lid-mapping files that Baileys writes after the first inbound
    // message round-trip. Critically, if the mapping isn't there yet (fresh
    // pair, first message from this sender), we MUST NOT drop the message —
    // we use the LID itself as the customer key. Dropping was the cause of
    // "agent says connected but doesn't reply": the very first message from
    // any new sender always arrives via an unmapped @lid.
    let phone = null;
    if (remoteJid.endsWith('@s.whatsapp.net')) {
        phone = '+' + remoteJid.replace(/@s\.whatsapp\.net$/, '');
    }
    else if (remoteJid.endsWith('@lid')) {
        const resolved = resolveLidToPhone(session.authDir, remoteJid);
        if (resolved) {
            phone = '+' + resolved.replace(/^\+/, '');
            log.info({ tenantId: session.tenantId, jid: remoteJid, phone }, 'resolved LID to phone');
        }
        else {
            // No mapping yet — fall back to using the LID as the customer key.
            // The customer record gets phone='lid:261572769...' which is a stable
            // identifier across messages from this sender. We can still reply to
            // remoteJid via sock.sendMessage. Once Baileys writes the mapping
            // (usually after the first round-trip), subsequent messages from the
            // same sender will resolve to the real phone and we'll merge.
            phone = 'lid:' + remoteJid.replace(/@lid$/, '');
            log.info({ tenantId: session.tenantId, jid: remoteJid, lidKey: phone }, 'no LID mapping yet — using LID as customer key (will resolve on later message)');
        }
    }
    else {
        return;
    }
    const profileName = m.pushName ?? null;
    // Cache for getMessage retry path (cap to prevent unbounded growth).
    // Order by messageTimestamp when WhatsApp provided one — matters when
    // we're processing 'append' batches after a reconnect.
    const msgTs = m.messageTimestamp ? Number(m.messageTimestamp) * 1000 : Date.now();
    if (msgId) {
        session.recentMessages.push({ msgId, jid: remoteJid, text });
        // Keep approximately ordered by timestamp — matters for getMessage retry
        // resolving to the correct prior content.
        if (session.recentMessages.length > RECENT_MESSAGES_CAP) {
            session.recentMessages.splice(0, session.recentMessages.length - RECENT_MESSAGES_CAP);
        }
    }
    void msgTs; // reserved for future ordering refinements
    // Update telemetry counters surfaced by the status endpoint.
    session.messagesTotal++;
    // messagesToday resets at midnight Asia/Riyadh — checking on each inbound
    // is cheaper than scheduling a separate timer.
    const nowDay = new Date().toLocaleDateString('en-CA', { timeZone: 'Asia/Riyadh' });
    const lastDay = session.lastMessageAt
        ? new Date(session.lastMessageAt).toLocaleDateString('en-CA', { timeZone: 'Asia/Riyadh' })
        : null;
    if (nowDay !== lastDay)
        session.messagesToday = 1;
    else
        session.messagesToday++;
    session.lastMessageAt = Date.now();
    session.lastMessagePreview = text.slice(0, 80);
    // Allowlist gate. Owner-authorized contacts pass; unknown senders depend
    // on the inbound_access policy. Three behaviors:
    //   • policy=auto:  allow + auto-add the contact (first-touch open mode)
    //   • policy=ask:   block, queue escalation so owner can authorize manually
    //   • policy=never: silently drop (strict shops)
    {
        const { decideInbound } = await import('../../autonomy/allowlist.js');
        const access = await decideInbound(session.tenantId, 'whatsapp', phone, profileName);
        if (!access.allowed) {
            if (access.policyMode === 'ask') {
                const { escalate } = await import('../../autonomy/escalation.js');
                // Embed a structured payload at the end of `context` so the
                // approval endpoint can find the original message + sender JID
                // and resume the conversation on approve (or send a polite
                // decline on deny). Marker pattern: __WA_APPROVAL_PAYLOAD__:<json>
                const payload = {
                    channel: 'whatsapp',
                    tenantId: session.tenantId,
                    remoteJid,
                    phone,
                    profileName: profileName ?? null,
                    text,
                    receivedAt: new Date().toISOString(),
                };
                await escalate({
                    tenantId: session.tenantId,
                    title: `طلب تواصل جديد: ${profileName ?? phone}`,
                    context: `الرسالة: "${text.slice(0, 300)}"\n\n__WA_APPROVAL_PAYLOAD__:${JSON.stringify(payload)}`,
                    recommendation: `وافق على ${profileName ?? phone} للسماح بالردود المستقبلية. ارفض إذا ما تعرفه.`,
                    counterargument: `لو الرقم خطأ أو سبام، الرفض يحمي صندوقك.`,
                    alternatives: [
                        'موافقة + إضافة كعميل (سماح دائم)',
                        'موافقة لمرة واحدة',
                        'رفض وحظر',
                    ],
                    riskLevel: 'medium',
                    expiresInMinutes: 60 * 24,
                });
                // Polite ack to the sender so they don't think they're being
                // ignored while approval is pending. Without this, customers
                // assume the number is dead and move on.
                if (session.socket) {
                    try {
                        await session.socket.sendMessage(remoteJid, {
                            text: `أهلاً ${profileName ?? ''} 👋\n\nوصلت رسالتك. صاحب الحساب راح يراجع طلبك ويرد عليك بعد لحظات.\n\nشكراً لانتظارك.`,
                        });
                    }
                    catch (err) {
                        log.warn({ tenantId: session.tenantId, error: err.message }, 'failed to ack pending sender');
                    }
                }
            }
            log.info({ tenantId: session.tenantId, phone, mode: access.policyMode, reason: access.reason }, 'inbound blocked by allowlist');
            return;
        }
    }
    // Look up tenant — single tenant per device, but be defensive
    const customer = await ensureWhatsAppCustomer(session.tenantId, phone, profileName);
    // Pull the last ~10 messages BEFORE logging the inbound (so the current
    // message isn't duplicated into its own history). Same shape as the
    // Telegram + web-chat paths — without this, EVERY WhatsApp turn was
    // stateless: the brain greeted fresh and forgot what was just discussed
    // (the "agent can't keep track of the conversation" bug). This is the
    // short-term turn memory; Graphiti/KG is the separate long-term layer.
    const recentResult = await supabase
        .from('messages')
        .select('direction, content')
        .eq('tenant_id', session.tenantId)
        .eq('customer_id', customer.id)
        .order('created_at', { ascending: false })
        .limit(10);
    const recentMessages = (recentResult.data ?? [])
        .reverse()
        .filter((r) => r.content && r.content.trim().length > 0)
        .map((r) => ({
        role: (r.direction === 'inbound' ? 'user' : 'assistant'),
        content: r.content,
    }));
    await logInbound(session.tenantId, customer.id, text);
    // Show "يكتب..." (typing) indicator while the brain works. WhatsApp
    // shows this as the live typing animation in the customer's chat so
    // they don't think they're being ignored during the 2–8 seconds the
    // model takes. The indicator stays on for ~25s max (WA auto-clears
    // after that), so for longer tool chains we refresh every 15s.
    let typingTimer = null;
    const startTyping = () => {
        if (!session.socket)
            return;
        session.socket.sendPresenceUpdate('composing', remoteJid).catch(() => { });
    };
    const stopTyping = () => {
        if (typingTimer) {
            clearInterval(typingTimer);
            typingTimer = null;
        }
        if (!session.socket)
            return;
        session.socket.sendPresenceUpdate('paused', remoteJid).catch(() => { });
    };
    startTyping();
    typingTimer = setInterval(startTyping, 15_000);
    // (v2.5.5) Collect artifacts the brain's tools produce so we can attach
    // them to the WhatsApp reply as media — no more "saved to your desktop,
    // go look" hand-waves. The dedupe key prevents the same file being
    // attached twice if multiple tools reference it.
    const artifacts = [];
    const seenArtifacts = new Set();
    const brainResp = await processMessage(session.tenantId, customer.id, text, {
        mediaType: 'text',
        channel: 'whatsapp',
        customerName: profileName ?? phone,
        recentMessages,
    }, {
        // (v2.5.5) Auto-attach artifacts produced by tools into the WhatsApp
        // reply as media. Without this, the agent would say "saved to your
        // desktop" and the user has to dig for the file.
        budgetAware: true,
        enableTools: true,
        maxToolLoops: 12,
        onArtifact: (a) => {
            if (!seenArtifacts.has(a.path)) {
                seenArtifacts.add(a.path);
                artifacts.push(a);
            }
        },
    });
    // Parse delivery directives from the brain reply so the agent can
    // choose to react silently, ack with emoji, stay silent, or delay.
    const { parseDirectives } = await import('../../agent/directives.js');
    const directive = parseDirectives(brainResp.response);
    const finalText = directive.bodyText;
    if (directive.delaySec > 0) {
        await new Promise(r => setTimeout(r, directive.delaySec * 1000));
    }
    // Stop the typing indicator as we're about to send the actual message
    // (or commit to silence if the brain chose to suppress).
    stopTyping();
    if (session.socket) {
        if (directive.mode === 'silent') {
            // No outbound; only journal. Brain still records the (suppressed) reply.
            log.info({ tenantId: session.tenantId, jid: remoteJid }, 'directive=silent — suppressing reply');
        }
        else if ((directive.mode === 'react' || directive.mode === 'ack') && directive.reactionEmoji) {
            try {
                await session.socket.sendMessage(remoteJid, {
                    react: { text: directive.reactionEmoji, key: m.key },
                });
            }
            catch (err) {
                log.debug({ err: err.message }, 'react directive failed');
            }
            if (directive.mode === 'ack' && finalText) {
                await session.socket.sendMessage(remoteJid, { text: finalText });
            }
        }
        else {
            // Default reply mode
            await session.socket.sendMessage(remoteJid, { text: finalText || brainResp.response });
        }
        // (v2.5.5) Effortless artifact display: attach every file the brain
        // produced this turn as a WhatsApp media message. Documents go as
        // document, images as image, audio as audio, video as video — Baileys
        // handles the routing once we pick the right field. Silent failures
        // are logged but don't block the text reply.
        for (const a of artifacts) {
            try {
                const { readFile } = await import('node:fs/promises');
                const buf = await readFile(a.path);
                const filename = a.path.split('/').pop() || 'attachment';
                const mt = a.mimeType || 'application/octet-stream';
                let waContent;
                if (mt.startsWith('image/')) {
                    waContent = { image: buf, mimetype: mt, fileName: filename };
                }
                else if (mt.startsWith('video/')) {
                    waContent = { video: buf, mimetype: mt, fileName: filename };
                }
                else if (mt.startsWith('audio/')) {
                    waContent = { audio: buf, mimetype: mt, ptt: false };
                }
                else {
                    waContent = { document: buf, mimetype: mt, fileName: filename };
                }
                await session.socket.sendMessage(remoteJid, waContent);
                log.info({ tenantId: session.tenantId, file: a.path, mime: mt, size: buf.length }, 'whatsapp: artifact attached');
            }
            catch (err) {
                log.warn({ err: err.message, file: a.path }, 'whatsapp: artifact attach failed');
            }
        }
        await logOutbound({
            tenantId: session.tenantId,
            customerId: customer.id,
            content: finalText || brainResp.response,
            modelUsed: brainResp.model_used,
            tokensInput: brainResp.tokens.input,
            tokensOutput: brainResp.tokens.output,
            costUsd: brainResp.cost,
            responseTimeMs: brainResp.latencyMs,
        });
    }
    // Background-learn into the knowledge graph
    learnFromConversation(session.tenantId, customer.id, profileName ?? phone, `User: ${text}\nMuassis: ${brainResp.response}`);
}
/**
 * Handle a group message. Default behavior is "ambient reaction" — a single
 * emoji react if the message references the agent or contains a wake word,
 * otherwise nothing. Full conversational reply only when explicitly mentioned
 * (@-mention or wake word). This is OpenClaw's `ackReactionScope: "group-mentions"`
 * pattern — Muassis should be a quiet presence in busy WhatsApp groups, not
 * a chatty interrupter.
 */
async function handleGroupMessage(session, m, remoteJid) {
    if (!m.message)
        return;
    const text = m.message.conversation ||
        m.message.extendedTextMessage?.text ||
        m.message.imageMessage?.caption ||
        m.message.videoMessage?.caption ||
        '';
    if (!text.trim())
        return;
    // Wake words that promote a group message to a full reply. Configurable
    // per-tenant via identity_facts later; defaults cover Arabic + English.
    const lowered = text.toLowerCase();
    const mentioned = m.message.extendedTextMessage?.contextInfo?.mentionedJid?.length;
    // Brand-aware wake words. Arabic identity stays "المؤسس"; Latin script
    // now uses "Al Moasis" / "moasis" (rebrand v3.0). Legacy "muassis" kept
    // so existing installs / muscle memory still trigger.
    const wakeWords = [
        'المؤسس', '@المؤسس',
        'موسس', 'موسيس',
        'ai moasis', '@almoasis', 'almoasis',
        'moasis', '@moasis',
        'muassis', '@muassis',
        'مساعد', '@مساعد',
        'بوت', 'bot',
    ];
    const explicitlyAddressed = !!mentioned || wakeWords.some(w => lowered.includes(w.toLowerCase()));
    if (!explicitlyAddressed) {
        // Pure ambient mode: pick a contextual emoji and react. Cheap signal that
        // "Muassis sees this" without flooding the group with text.
        const reaction = pickAmbientReaction(text);
        if (reaction && session.socket && m.key) {
            try {
                await session.socket.sendMessage(remoteJid, {
                    react: { text: reaction, key: m.key },
                });
            }
            catch (err) {
                log.debug({ err: err.message }, 'group reaction failed');
            }
        }
        return;
    }
    // Explicitly addressed → fall through to the regular handler. We re-enter
    // by resetting the message JID to a synthetic per-sender JID so the brain
    // gets the right customer context.
    const senderJid = m.key.participant ?? m.key.remoteJid;
    if (!senderJid)
        return;
    const fakeKey = {
        ...m,
        key: { ...m.key, remoteJid: senderJid, participant: undefined },
    };
    // The full handler does its own dedup/allowlist/text-extraction, so this is safe.
    await handleIncomingMessage(session, fakeKey);
}
/**
 * Pick a contextual emoji for ambient group reactions. The intent is "I saw
 * this" — not "I have an opinion on this." Default to 👀 if nothing matches.
 */
function pickAmbientReaction(text) {
    const lc = text.toLowerCase();
    // Saudi/Arabic confirmations
    if (/[👍✅✔️]/.test(text) || /\b(ok|okay|تمام|نعم|آه|اوكي|تم)\b/i.test(text))
        return '✅';
    // Questions or thinking moments
    if (/\?|؟|متى|أين|كيف|ليش|why|when|how/i.test(text))
        return '🤔';
    // Excitement
    if (/[🎉🔥💪]/.test(text) || /\b(مبروك|تهاني|congratulations|congrats)\b/i.test(text))
        return '🎉';
    // Money/numbers/business decisions
    if (/\b(\d{3,}|ريال|sar|riyal|payment|invoice|فاتورة|دفع)\b/i.test(text))
        return '💰';
    // Greetings — silent, no reaction needed in busy groups
    if (/^(hi|hello|مرحبا|السلام|أهلا|هلا)\b/i.test(text.trim()))
        return null;
    // Default: "I'm watching" eyes
    return '👀';
}
/** Find or create a customer row for an inbound WhatsApp number. */
async function ensureWhatsAppCustomer(tenantId, phone, name) {
    const existing = await supabase
        .from('customers')
        .select('id, name, phone')
        .eq('tenant_id', tenantId)
        .eq('phone', phone)
        .maybeSingle();
    if (existing.data) {
        return existing.data;
    }
    const { randomUUID } = await import('node:crypto');
    const id = randomUUID();
    const now = new Date().toISOString();
    await supabase.from('customers').insert({
        id,
        tenant_id: tenantId,
        phone,
        name,
        language: 'ar',
        first_contact: now,
        last_contact: now,
        total_messages: 0,
        status: 'active',
        metadata: { channel: 'whatsapp' },
    });
    return { id, name, phone };
}
async function logInbound(tenantId, customerId, content) {
    const { randomUUID } = await import('node:crypto');
    await supabase.from('messages').insert({
        id: randomUUID(),
        tenant_id: tenantId,
        customer_id: customerId,
        direction: 'inbound',
        type: 'text',
        content,
        delivery_status: 'delivered',
        created_at: new Date().toISOString(),
    });
    await supabase
        .from('customers')
        .update({ last_contact: new Date().toISOString() })
        .eq('id', customerId);
}
async function logOutbound(p) {
    const { randomUUID } = await import('node:crypto');
    await supabase.from('messages').insert({
        id: randomUUID(),
        tenant_id: p.tenantId,
        customer_id: p.customerId,
        direction: 'outbound',
        type: 'text',
        content: p.content,
        model_used: p.modelUsed ?? null,
        tokens_input: p.tokensInput ?? 0,
        tokens_output: p.tokensOutput ?? 0,
        cost_usd: p.costUsd ?? 0,
        response_time_ms: p.responseTimeMs ?? null,
        delivery_status: 'sent',
        created_at: new Date().toISOString(),
    });
}
/**
 * READ-ONLY status peek. Does NOT call startWhatsAppSession, so dashboard
 * polling (GET /api/tenants/:id/whatsapp/status) cannot burn pair-attempt
 * budget. If no in-memory session exists, returns a synthetic
 * `disconnected` status with `needs_poll=false` so the dashboard knows to
 * stop polling until the user explicitly clicks "Generate QR" /
 * "Request code", which goes through getOrCreateSession.
 */
export async function peekSession(tenantId, accountId = 'default') {
    const key = sessionKey(tenantId, accountId);
    const session = sessions.get(key);
    if (!session) {
        return {
            status: 'disconnected',
            qr_ascii: null,
            qr_png: null,
            jid: null,
            last_qr_at: null,
            account_id: accountId,
            connected_since: null,
            reconnect_attempts: 0,
            last_reconnect_at: null,
            messages_today: 0,
            messages_total: 0,
            last_message_preview: null,
            last_message_at: null,
            last_error: null,
            needs_poll: false,
        };
    }
    const needsPoll = session.status === 'connecting' || session.status === 'pending_qr';
    return {
        status: session.status,
        qr_ascii: session.lastQr ? await qrToAscii(session.lastQr) : null,
        qr_png: session.lastQr ? await qrToPngDataUrl(session.lastQr) : null,
        jid: session.socket?.user?.id ?? session.jid,
        last_qr_at: session.lastQrAt,
        account_id: session.accountId,
        connected_since: session.connectedSince,
        reconnect_attempts: session.reconnectAttempts,
        last_reconnect_at: session.lastReconnectAt,
        messages_today: session.messagesToday,
        messages_total: session.messagesTotal,
        last_message_preview: session.lastMessagePreview,
        last_message_at: session.lastMessageAt,
        last_error: session.lastError,
        needs_poll: needsPoll,
    };
}
export async function getOrCreateSession(tenantId, accountId = 'default') {
    const session = await startWhatsAppSession(tenantId, accountId);
    // Wait up to ~10s for a QR or connected state to materialize. Baileys first
    // connect takes 6-12s on a cold device; 5s was too aggressive and returned
    // an empty status that the dashboard rendered as "feature under setup".
    for (let i = 0; i < 50 && !session.lastQr && session.status === 'connecting'; i++) {
        await new Promise((r) => setTimeout(r, 200));
    }
    const needsPoll = session.status === 'connecting' || session.status === 'pending_qr';
    return {
        status: session.status,
        qr_ascii: session.lastQr ? await qrToAscii(session.lastQr) : null,
        qr_png: session.lastQr ? await qrToPngDataUrl(session.lastQr) : null,
        jid: session.socket?.user?.id ?? session.jid,
        last_qr_at: session.lastQrAt,
        account_id: session.accountId,
        connected_since: session.connectedSince,
        reconnect_attempts: session.reconnectAttempts,
        last_reconnect_at: session.lastReconnectAt,
        messages_today: session.messagesToday,
        messages_total: session.messagesTotal,
        last_message_preview: session.lastMessagePreview,
        last_message_at: session.lastMessageAt,
        last_error: session.lastError,
        needs_poll: needsPoll,
    };
}
export async function disconnectSession(tenantId, accountId = 'default') {
    const key = sessionKey(tenantId, accountId);
    const session = sessions.get(key);
    // Logout the socket (invalidates the device on WhatsApp's servers).
    if (session?.socket) {
        try {
            await session.socket.logout();
        }
        catch (err) {
            log.warn({ tenantId, accountId, error: err.message }, 'logout failed (continuing)');
        }
    }
    // Cancel any pending reconnect timer so we don't try to revive the dead
    // session on a backoff tick.
    if (session?.reconnectTimer) {
        clearTimeout(session.reconnectTimer);
        session.reconnectTimer = null;
    }
    if (session?.presenceTimer) {
        clearInterval(session.presenceTimer);
        session.presenceTimer = null;
    }
    // Wipe the on-disk auth dir. Without this, the next "Generate QR" click
    // would call startWhatsAppSession, Baileys would find the now-invalid
    // creds in authDir, attempt to resume the killed session, and fail
    // silently — which is exactly what the user reported ("clicked disconnect,
    // now the QR button does nothing"). Wiping forces a clean pair flow.
    const authDir = authDirFor(tenantId, accountId);
    try {
        const { rm } = await import('node:fs/promises');
        await rm(authDir, { recursive: true, force: true });
        log.info({ tenantId, accountId, authDir }, 'baileys: wiped auth dir on disconnect');
    }
    catch (err) {
        log.warn({ tenantId, accountId, error: err.message }, 'auth dir wipe failed on disconnect (continuing)');
    }
    // Drop the in-memory state entirely so the next startWhatsAppSession()
    // gets a fresh socket. Keeping the entry around with status='logged_out'
    // was working around old code paths but means status checks return stale
    // data after disconnect.
    sessions.delete(key);
    // Clear the pair-attempt rate-limit bucket so the user can re-pair
    // immediately without waiting out the sliding window.
    clearPairAttempts(tenantId);
}
/**
 * Hard-reset a WhatsApp session. Wipes Baileys auth files on disk, clears
 * in-memory session state, then returns. The next call to startWhatsAppSession()
 * will trigger a fresh QR pair.
 *
 * Used by:
 *   POST /api/tenants/:id/whatsapp/reset (dashboard "Re-pair" button)
 *   `moasis whatsapp reset` CLI command
 *
 * Why this exists: Baileys creds.json can land in a half-paired state when a
 * pairing is interrupted at the final handshake (very common: user closes the
 * QR page mid-scan, or the device is removed from WhatsApp's linked-devices
 * list later). The presence of stale `account` data without `registered:true`
 * makes the adapter refuse to generate a fresh QR. Wipe-and-retry is the
 * only reliable recovery.
 */
export async function resetSession(tenantId, accountId = 'default') {
    const key = sessionKey(tenantId, accountId);
    const session = sessions.get(key);
    // Best-effort logout — fine if it fails (creds may already be half-broken).
    if (session?.socket) {
        try {
            await session.socket.logout();
        }
        catch (err) {
            log.debug({ err: err.message }, 'reset: logout failed (continuing)');
        }
    }
    if (session?.reconnectTimer)
        clearTimeout(session.reconnectTimer);
    sessions.delete(key);
    // Clear the pair-attempt rate-limit bucket. The user just explicitly
    // asked for a fresh pair — they shouldn't be silently locked out of the
    // next attempt because earlier polls / connects ate the budget.
    clearPairAttempts(tenantId);
    // Wipe the auth directory. Baileys keeps creds.json + ~800 pre-key files
    // per session; recursive rm is safe because we own this directory.
    const dir = authDirFor(tenantId, accountId);
    try {
        const { rm } = await import('node:fs/promises');
        await rm(dir, { recursive: true, force: true });
        log.info({ tenantId, accountId, dir }, 'whatsapp: auth dir wiped — ready for fresh pair');
    }
    catch (err) {
        log.warn({ tenantId, error: err.message }, 'reset: rm authDir failed');
    }
}
/** List active WA sessions for a tenant — supports the multi-account UI. */
export function listSessionsForTenant(tenantId) {
    const out = [];
    for (const [key, s] of sessions) {
        if (key.startsWith(tenantId + ':')) {
            out.push({ accountId: s.accountId, status: s.status, jid: s.socket?.user?.id ?? null });
        }
    }
    return out;
}
/**
 * (v2.5.2) Is there a logged-in Baileys session for this tenant? Used by
 * delivery.ts to route around Cloud-API attempts when only Baileys is set up.
 */
export function isWhatsAppConnected(tenantId, accountId = 'default') {
    const s = sessions.get(sessionKey(tenantId, accountId));
    return !!s?.socket && s.status === 'connected';
}
/**
 * (v2.5.2) Send a text message via the tenant's Baileys session.
 * Throws if no session is connected. Returns the WA message id.
 */
/**
 * Request an 8-character pairing code for the given phone number. This is
 * the modern WhatsApp pairing flow that bypasses the QR scan entirely —
 * the user enters their phone in WA → "Link with phone number" → types
 * the 8-char code we return. Way more reliable than QR for fresh setups:
 *   • No camera / lighting / focus issues
 *   • No 20-second QR rotation race
 *   • Works on any device that can type
 *
 * Caller must have a Baileys session that's reached the QR/pending state
 * (not yet connected). The session keeps polling for the pair completion
 * the same way as the QR flow.
 */
export async function requestPairingCode(tenantId, phoneNumber, accountId = 'default') {
    // Ensure a session is up — same as the QR flow's preflight
    const session = await startWhatsAppSession(tenantId, accountId);
    // Wait up to 8s for the socket to be ready to accept pairing requests.
    // Baileys' requestPairingCode requires a connected (pre-auth) socket.
    for (let i = 0; i < 40 && !session.socket; i++) {
        await new Promise((r) => setTimeout(r, 200));
    }
    if (!session.socket) {
        throw new Error('socket not ready — call again in a moment');
    }
    const digits = phoneNumber.replace(/[^\d]/g, '');
    if (!/^\d{10,15}$/.test(digits)) {
        throw new Error(`invalid phone number: ${phoneNumber} (expected E.164 digits, 10–15)`);
    }
    const code = await session.socket.requestPairingCode(digits);
    log.info({ tenantId, phone: digits, codeMasked: code.slice(0, 4) + '****' }, 'pairing code issued');
    return code;
}
/**
 * Send a WA message to a raw JID (not a phone). Used by the approval-flow
 * endpoints — when an unknown sender hits the agent, we stash their
 * remoteJid (which may be @lid for unmapped 2025+ senders) and need to
 * reply to that exact JID, not via phone-based routing.
 */
export async function sendBaileysMessageToJid(tenantId, remoteJid, text, accountId = 'default') {
    const s = sessions.get(sessionKey(tenantId, accountId));
    if (!s?.socket || s.status !== 'connected') {
        throw new Error(`Baileys not connected for tenant ${tenantId}`);
    }
    if (!remoteJid || !/@(s\.whatsapp\.net|lid|g\.us)$/.test(remoteJid)) {
        throw new Error(`invalid JID: ${remoteJid}`);
    }
    const result = await s.socket.sendMessage(remoteJid, { text });
    const id = result?.key?.id ?? `baileys-${Date.now()}`;
    s.messagesToday = (s.messagesToday ?? 0) + 1;
    s.messagesTotal = (s.messagesTotal ?? 0) + 1;
    s.lastMessageAt = Date.now();
    s.lastMessagePreview = text.slice(0, 80);
    return id;
}
export async function sendBaileysMessage(tenantId, to, text, accountId = 'default') {
    const s = sessions.get(sessionKey(tenantId, accountId));
    if (!s?.socket || s.status !== 'connected') {
        throw new Error(`Baileys not connected for tenant ${tenantId}`);
    }
    // Convert phone E.164 → Baileys JID
    const digits = to.replace(/[^\d]/g, '');
    if (!digits)
        throw new Error(`invalid recipient: ${to}`);
    const jid = `${digits}@s.whatsapp.net`;
    const result = await s.socket.sendMessage(jid, { text });
    const id = result?.key?.id ?? `baileys-${Date.now()}`;
    s.messagesToday = (s.messagesToday ?? 0) + 1;
    s.messagesTotal = (s.messagesTotal ?? 0) + 1;
    s.lastMessageAt = Date.now();
    s.lastMessagePreview = text.slice(0, 80);
    return id;
}
// Async file delivery (subagents / scheduled tasks). Mirrors the live-turn
// artifact attach (baileys-adapter ~676) so background-created files reach the
// WhatsApp chat too. `to` may be an E.164 phone or a full JID.
export async function sendBaileysFile(tenantId, to, filePath, mimeType, accountId = 'default') {
    const s = sessions.get(sessionKey(tenantId, accountId));
    if (!s?.socket || s.status !== 'connected') {
        throw new Error(`Baileys not connected for tenant ${tenantId}`);
    }
    const { readFile } = await import('node:fs/promises');
    const buf = await readFile(filePath);
    const fileName = filePath.split('/').pop() || 'attachment';
    const mt = mimeType || 'application/octet-stream';
    let content;
    if (mt.startsWith('image/'))
        content = { image: buf, mimetype: mt, fileName };
    else if (mt.startsWith('video/'))
        content = { video: buf, mimetype: mt, fileName };
    else if (mt.startsWith('audio/'))
        content = { audio: buf, mimetype: mt, ptt: false };
    else
        content = { document: buf, mimetype: mt, fileName };
    const jid = to.includes('@') ? to : `${to.replace(/[^\d]/g, '')}@s.whatsapp.net`;
    const result = await s.socket.sendMessage(jid, content);
    s.messagesToday = (s.messagesToday ?? 0) + 1;
    s.messagesTotal = (s.messagesTotal ?? 0) + 1;
    s.lastMessageAt = Date.now();
    return result?.key?.id ?? `baileys-${Date.now()}`;
}
//# sourceMappingURL=baileys-adapter.js.map