/**
 * src/autonomy/heartbeat.ts
 *
 * Ambient heartbeat — Muassis pings the owner unsolicited when something
 * interesting happens. Pattern from OpenClaw's HEARTBEAT.md / "agent has
 * recurring poll → can ping without being asked." This is what makes the
 * agent feel alive instead of just reactive.
 *
 * Triggers (in priority order, highest fires first):
 *   1. Critical escalation pending >30 minutes (someone needs a decision)
 *   2. Goal deadline within 24 hours and progress <50%
 *   3. Overdue invoice scan found NEW overdue invoices since last heartbeat
 *   4. Hot lead went 5+ days without follow-up
 *   5. >8 hours since the owner last interacted with the agent
 *   6. Subagent finished a long task while owner was away
 *
 * Throttle: max 1 heartbeat per 60 minutes, max 5 per calendar day. Owner
 * can disable via identity_fact `preference.heartbeat_enabled` = "false".
 *
 * Delivery: WhatsApp DM to owner phone if connected, else web chat banner
 * via journal kind=heartbeat.
 */
import { createChildLogger } from '../utils/logger.js';
import { supabase } from '../utils/supabase.js';
import { record as journalRecord, list as listJournal } from './journal.js';
import { listActiveGoals } from './agenda.js';
import { listPending as listPendingEscalations } from './escalation.js';
import { getFact } from './identity.js';
const log = createChildLogger('autonomy:heartbeat');
const ONE_HOUR_MS = 60 * 60 * 1000;
const MAX_PER_HOUR = 1;
const MAX_PER_DAY = 5;
/**
 * Check whether we're allowed to fire a heartbeat right now (throttle).
 * Returns the reason if blocked, or null if green-lit.
 */
async function canFire(tenantId) {
    // Check the disable flag
    const disabledFact = await getFact(tenantId, 'preference.heartbeat_enabled');
    if (disabledFact && disabledFact.fact_value.toLowerCase() === 'false') {
        return 'heartbeat disabled by owner preference';
    }
    // (Round-3 audit fix) Business-hours guard. Don't ping the owner at 3am
    // about an "8h silence" they had because they were asleep. Only fire
    // during waking hours per the tenant's timezone, with override for
    // critical (level=critical) events handled at the trigger layer.
    // Owner can override via fact `preference.heartbeat_hours` = "7-23"
    // (24h format). Default is 7am-11pm.
    try {
        const hoursFact = await getFact(tenantId, 'preference.heartbeat_hours');
        const hoursRange = hoursFact?.fact_value || '7-23';
        const m = hoursRange.match(/^(\d{1,2})\s*-\s*(\d{1,2})$/);
        if (m) {
            const start = parseInt(m[1], 10);
            const end = parseInt(m[2], 10);
            // Resolve tenant timezone (default Asia/Riyadh)
            let tz = 'Asia/Riyadh';
            const tzFact = await getFact(tenantId, 'owner.timezone');
            if (tzFact?.fact_value)
                tz = tzFact.fact_value;
            const localHour = parseInt(new Intl.DateTimeFormat('en-US', { hour: 'numeric', hour12: false, timeZone: tz })
                .format(new Date())
                .replace(/[^\d]/g, ''), 10);
            if (Number.isFinite(localHour) && (localHour < start || localHour >= end)) {
                return `outside business hours (${localHour}:00 ${tz}, allowed ${start}-${end})`;
            }
        }
    }
    catch (err) {
        log.debug({ err: err.message }, 'heartbeat hours check failed; proceeding');
    }
    // Throttle: count heartbeat journal entries in last hour and last day
    const now = Date.now();
    const lastHour = await listJournal({
        tenantId,
        kind: 'heartbeat',
        since: new Date(now - ONE_HOUR_MS).toISOString(),
        limit: 10,
    });
    if (lastHour.length >= MAX_PER_HOUR)
        return `throttle: ${lastHour.length}/h`;
    const startOfDay = new Date();
    startOfDay.setUTCHours(0, 0, 0, 0);
    const today = await listJournal({
        tenantId,
        kind: 'heartbeat',
        since: startOfDay.toISOString(),
        limit: 20,
    });
    if (today.length >= MAX_PER_DAY)
        return `throttle: ${today.length}/day`;
    return null;
}
/**
 * Scan triggers in priority order. Returns the first one that fires, or null.
 */
async function detectTrigger(tenantId) {
    const now = Date.now();
    // Trigger 1: stale critical escalation (>30 min)
    try {
        const escalations = await listPendingEscalations(tenantId);
        const staleCritical = escalations.find(e => (e.risk_level === 'critical' || e.risk_level === 'high') &&
            now - new Date(e.created_at).getTime() > 30 * 60 * 1000);
        if (staleCritical) {
            return {
                kind: 'escalation_stale',
                priority: 1,
                message: `🟠 تذكير: قرار ينتظر منذ أكثر من 30 دقيقة\n` +
                    `"${staleCritical.title}"\n` +
                    `توصيتي: ${staleCritical.recommendation.slice(0, 140)}`,
                metadata: { escalation_id: staleCritical.id, risk_level: staleCritical.risk_level },
            };
        }
    }
    catch { /* ignore */ }
    // Trigger 2: goal with deadline <24h and progress <50%
    try {
        const goals = await listActiveGoals(tenantId);
        for (const g of goals) {
            if (!g.deadline)
                continue;
            const deadlineMs = new Date(g.deadline).getTime();
            const hoursLeft = (deadlineMs - now) / (60 * 60 * 1000);
            if (hoursLeft > 0 && hoursLeft <= 24) {
                const progressPct = g.target_value
                    ? ((g.current_value ?? 0) / g.target_value) * 100
                    : 0;
                if (progressPct < 50) {
                    return {
                        kind: 'goal_deadline',
                        priority: 2,
                        message: `⏰ هدف قريب من الموعد النهائي\n` +
                            `"${g.title}" — ${Math.round(hoursLeft)} ساعة فقط، التقدم ${Math.round(progressPct)}%\n` +
                            `هل نراجع الخطة؟`,
                        metadata: { goal_id: g.id, hours_left: hoursLeft, progress_pct: progressPct },
                    };
                }
            }
        }
    }
    catch { /* ignore */ }
    // Trigger 3: subagent finished while owner was away (>30 min ago, last
    // owner interaction >2h ago)
    try {
        const recentSubagentDone = await listJournal({
            tenantId,
            kind: 'subagent_result',
            since: new Date(now - 2 * 60 * 60 * 1000).toISOString(),
            limit: 5,
        });
        const ownerSilence = await listJournal({
            tenantId,
            since: new Date(now - 2 * 60 * 60 * 1000).toISOString(),
            limit: 5,
        });
        const ownerActiveRecently = ownerSilence.some(e => e.actor === 'owner' || (e.kind === 'tool' && e.actor === 'muassis'));
        if (recentSubagentDone.length > 0 && !ownerActiveRecently) {
            const r = recentSubagentDone[0];
            return {
                kind: 'subagent_done',
                priority: 3,
                message: `✅ المهمة الخلفية انتهت\n` +
                    `"${(r.action ?? '').slice(0, 100)}"\n` +
                    `النتيجة جاهزة في لوحة العمليات. تبغى ملخص؟`,
                metadata: { journal_id: r.id },
            };
        }
    }
    catch { /* ignore */ }
    // Trigger 4: long silence (>8h) — only ONCE per silence period
    try {
        const last8h = await listJournal({
            tenantId,
            since: new Date(now - 8 * 60 * 60 * 1000).toISOString(),
            limit: 1,
        });
        const lastSilenceHeartbeat = await listJournal({
            tenantId,
            kind: 'heartbeat',
            since: new Date(now - 8 * 60 * 60 * 1000).toISOString(),
            limit: 1,
        });
        if (last8h.length === 0 && lastSilenceHeartbeat.length === 0) {
            // 8+ hours of total silence and no heartbeat fired in that window
            return {
                kind: 'silence',
                priority: 4,
                message: `👋 ما سمعت منك من زمان. كل شي تمام؟\n` +
                    `لو عندك مهمة جديدة، اكتبلي. ولو مشغول، تجاهلني.`,
            };
        }
    }
    catch { /* ignore */ }
    return null;
}
/**
 * Run one heartbeat cycle. This is what the cron job calls every hour.
 */
export async function runHeartbeatCycle(tenantId) {
    const blocked = await canFire(tenantId);
    if (blocked) {
        log.debug({ tenantId, reason: blocked }, 'heartbeat throttled');
        return { fired: false, reason: blocked };
    }
    const trigger = await detectTrigger(tenantId);
    if (!trigger) {
        return { fired: false, reason: 'no triggers active' };
    }
    // Journal the heartbeat (drives the throttle on next cycle + dashboard surface)
    await journalRecord({
        tenantId,
        kind: 'heartbeat',
        action: trigger.kind,
        target: null,
        outcome: 'success',
        reason: trigger.message.slice(0, 300),
        metadata: trigger.metadata,
    });
    // Deliver via WhatsApp DM if owner phone is allowed and a session is connected
    try {
        const ownerPhoneFact = await getFact(tenantId, 'owner.whatsapp');
        if (ownerPhoneFact && ownerPhoneFact.fact_value) {
            // Look up tenant's WA session — fall back to the delivery layer if no live socket
            const { data: tenant } = await supabase
                .from('tenants')
                .select('whatsapp_phone')
                .eq('id', tenantId)
                .maybeSingle();
            const recipient = tenant?.whatsapp_phone ?? ownerPhoneFact.fact_value;
            const { deliverMessage } = await import('../delivery/delivery.js');
            await deliverMessage({
                tenantId,
                to: recipient,
                message: trigger.message,
                channel: 'whatsapp',
            }).catch(() => undefined);
        }
    }
    catch (err) {
        log.debug({ err: err.message }, 'heartbeat WA delivery skipped');
    }
    log.info({ tenantId, kind: trigger.kind, priority: trigger.priority }, 'heartbeat fired');
    return { fired: true, trigger };
}
//# sourceMappingURL=heartbeat.js.map