/**
 * src/cron/scheduler.ts
 * Cron Scheduler Engine — Persistent job execution with node-cron
 */
import * as cron from 'node-cron';
import { v4 as uuidv4 } from 'uuid';
import { createChildLogger } from '../utils/logger.js';
import { supabase } from '../utils/supabase.js';
import { processMessage } from '../agent/brain.js';
import { getTenantById } from '../tenants/manager.js';
import { deliverMessage } from '../delivery/delivery.js';
const log = createChildLogger('cron:scheduler');
// In-memory job map: jobId -> CronTask
const activeJobs = new Map();
/**
 * Load all enabled jobs from Supabase and start them.
 * Also kicks off a 60s polling loop that picks up jobs created at runtime
 * (e.g. by `schedule_followup` tool calls) without a gateway restart.
 * Polling avoids the circular import that direct registration would cause
 * (executor → scheduler → brain → executor).
 */
let pollInterval = null;
export async function startCronScheduler() {
    log.info('🕒 Loading cron jobs from database...');
    await pollForNewJobs(/*verbose=*/ true);
    // Live registration: poll every 60s for newly-inserted enabled jobs.
    if (pollInterval)
        clearInterval(pollInterval);
    pollInterval = setInterval(() => {
        pollForNewJobs(false).catch(err => {
            log.warn({ error: err instanceof Error ? err.message : String(err) }, 'Cron poll failed');
        });
    }, 60_000);
}
// (Audit M1) Track each registered job's schedule so we can detect updates
// from the DB and re-register with the new schedule.
const activeJobSchedules = new Map();
async function pollForNewJobs(verbose) {
    const { data: jobs, error } = await supabase
        .from('cron_jobs')
        .select('*');
    if (error) {
        if (verbose)
            log.error({ error: error.message }, 'Failed to load cron jobs');
        return;
    }
    const dbIds = new Set();
    let loaded = 0;
    let updated = 0;
    let removed = 0;
    for (const job of (jobs || [])) {
        dbIds.add(job.id);
        // Disabled in DB → unregister if currently active
        if (!job.enabled) {
            if (activeJobs.has(job.id)) {
                await unregisterJob(job.id);
                activeJobSchedules.delete(job.id);
                removed++;
            }
            continue;
        }
        // Schedule changed → re-register
        const prevSchedule = activeJobSchedules.get(job.id);
        if (activeJobs.has(job.id)) {
            if (prevSchedule && prevSchedule !== job.schedule) {
                await unregisterJob(job.id);
                try {
                    await registerJob(job);
                    activeJobSchedules.set(job.id, job.schedule);
                    updated++;
                }
                catch (err) {
                    log.error({ jobId: job.id, error: err }, 'Failed to re-register updated job');
                }
            }
            continue;
        }
        // New job
        try {
            await registerJob(job);
            activeJobSchedules.set(job.id, job.schedule);
            loaded++;
        }
        catch (err) {
            log.error({ jobId: job.id, error: err }, 'Failed to register job');
        }
    }
    // (M1) DELETE-from-DB → unregister. Without this, deleted rows linger as
    // zombie tasks in node-cron until gateway restart.
    for (const activeId of [...activeJobs.keys()]) {
        if (!dbIds.has(activeId)) {
            await unregisterJob(activeId);
            activeJobSchedules.delete(activeId);
            removed++;
        }
    }
    if (verbose) {
        log.info(`🕒 Loaded ${loaded} cron jobs (polling every 60s for changes)`);
    }
    else if (loaded > 0 || updated > 0 || removed > 0) {
        log.info({ loaded, updated, removed }, '🕒 cron poll: changes applied');
    }
}
/**
 * Register a job in the scheduler
 */
export async function registerJob(job) {
    if (activeJobs.has(job.id)) {
        log.warn({ jobId: job.id }, 'Job already registered');
        return;
    }
    // Validate cron expression
    if (!cron.validate(job.schedule)) {
        throw new Error(`Invalid cron schedule: ${job.schedule}`);
    }
    const task = cron.schedule(job.schedule, async () => {
        await executeJob(job);
    }, {
        timezone: job.timezone || 'Asia/Riyadh',
    });
    activeJobs.set(job.id, task);
    log.info({ jobId: job.id, schedule: job.schedule, name: job.name }, 'Job registered');
}
/**
 * Unregister a job
 */
export async function unregisterJob(jobId) {
    const task = activeJobs.get(jobId);
    if (task) {
        task.stop();
        activeJobs.delete(jobId);
        log.info({ jobId }, 'Job unregistered');
    }
}
/**
 * Execute a job immediately
 */
export async function runJobNow(jobId) {
    const { data: job } = await supabase
        .from('cron_jobs')
        .select('*')
        .eq('id', jobId)
        .single();
    if (!job) {
        throw new Error(`Job not found: ${jobId}`);
    }
    return executeJob(job);
}
/**
 * Internal job execution logic
 */
// (Audit M2) Per-process lock to prevent concurrent runs of the same job.
// Same-process double-fire is the common case (overlapping schedules,
// poll race). Cross-process (gateway restart mid-execution) is mitigated
// by checking cron_executions for an in-progress entry created in the
// last `LOCK_TTL_MS` window.
const inFlightJobs = new Set();
// 10-minute upper bound on a single job execution. Jobs that take longer
// are likely runaway — operators should kill the gateway. Currently
// referenced in design notes only; node-cron's tier timeout enforces this.
const _JOB_LOCK_TTL_MS = 10 * 60 * 1000;
void _JOB_LOCK_TTL_MS;
async function executeJob(job) {
    const startTime = Date.now();
    // In-process lock
    if (inFlightJobs.has(job.id)) {
        log.warn({ jobId: job.id, name: job.name }, 'job already running in this process — skipping double-fire');
        return {
            jobId: job.id,
            tenantId: job.tenant_id,
            executedAt: new Date().toISOString(),
            success: false,
            error: 'concurrent execution skipped',
            durationMs: 0,
        };
    }
    inFlightJobs.add(job.id);
    // Cross-process / restart-mid-execution guard via cron_executions
    try {
        const { data: recent } = await supabase
            .from('cron_executions')
            .select('id, executed_at')
            .eq('job_id', job.id)
            .order('executed_at', { ascending: false })
            .limit(1)
            .maybeSingle();
        if (recent?.executed_at) {
            const ageMs = Date.now() - new Date(recent.executed_at).getTime();
            if (ageMs < 60_000) {
                // Last execution was logged less than 60s ago — a duplicate fire
                // from a bouncing scheduler. Skip.
                inFlightJobs.delete(job.id);
                log.warn({ jobId: job.id, ageMs }, 'duplicate fire detected (last execution <60s ago) — skipping');
                return {
                    jobId: job.id,
                    tenantId: job.tenant_id,
                    executedAt: new Date().toISOString(),
                    success: false,
                    error: 'duplicate fire suppressed',
                    durationMs: 0,
                };
            }
        }
    }
    catch { /* the lock check is best-effort; missing row is not fatal */ }
    const tenant = await getTenantById(job.tenant_id);
    if (!tenant) {
        inFlightJobs.delete(job.id);
        log.error({ jobId: job.id, tenantId: job.tenant_id }, 'Tenant not found');
        return {
            jobId: job.id,
            tenantId: job.tenant_id,
            executedAt: new Date().toISOString(),
            success: false,
            error: 'Tenant not found',
            durationMs: Date.now() - startTime,
        };
    }
    try {
        let output = '';
        // (Round-7 fix) Validate payload.kind explicitly. If a row has malformed
        // payload (missing kind, or kind is some unrecognized value), the prior
        // code silently fell through both branches and reported success on a
        // no-op. Now we throw — operators see the broken job in cron_executions
        // instead of it logging "Job executed successfully" while doing nothing.
        const kind = job.payload?.kind;
        if (kind !== 'systemEvent' && kind !== 'agentTurn') {
            throw new Error(`invalid payload.kind: ${kind ?? 'undefined'} (expected systemEvent or agentTurn)`);
        }
        if (kind === 'systemEvent') {
            output = await handleSystemEvent(job, tenant);
        }
        else {
            output = (await handleAgentTurn(job, tenant)).text;
        }
        // Update job stats. Column names are snake_case in the DB schema —
        // the prior camelCase keys (lastRunAt / runCount) were silently dropped
        // by the supabase shim, leaving last_run_at NULL forever. (Audit C1.)
        const prevCount = job.run_count
            ?? job.runCount
            ?? 0;
        // Advance next_run_at from the schedule so the dashboard shows the REAL
        // upcoming fire time. Previously this column was only ever written at
        // seed time and never updated, so it froze at a past date and every job
        // looked like "لم يُنفّذ بعد" (never ran) even while node-cron fired it on
        // time. node-cron remains the actual trigger; this is display truth.
        let nextRunAt;
        try {
            const cpMod = await import('cron-parser');
            const parseExpr = cpMod.parseExpression || cpMod.default?.parseExpression || cpMod.default;
            nextRunAt = parseExpr(job.schedule, { tz: job.timezone || 'Asia/Riyadh' }).next().toISOString();
        }
        catch { /* keep the prior next_run_at if the schedule can't be parsed */ }
        await supabase
            .from('cron_jobs')
            .update({
            last_run_at: new Date().toISOString(),
            run_count: prevCount + 1,
            updated_at: new Date().toISOString(),
            ...(nextRunAt ? { next_run_at: nextRunAt } : {}),
        })
            .eq('id', job.id);
        // (Round-7 fix) Guard against negative duration when system clock jumps
        // backwards mid-execution (NTP skew). A negative duration_ms breaks
        // dashboard charts and skews cost analytics.
        const duration = Math.max(0, Date.now() - startTime);
        await logExecution(job.id, tenant.id, true, output, null, duration);
        // Surface cron output in the owner's chat thread so scheduled work is
        // visible (morning-brief, research-cycle, goal-pursuit, etc.). Pure
        // internal-maintenance ticks (heartbeat, housekeeping, telemetry, …) are
        // suppressed from chat — they'd be hourly noise. They still write to
        // cron_executions for the audit trail. Best-effort: a push failure does
        // not flip the cron's own success status.
        if (output && output.trim().length > 0 && shouldSurfaceInChat(job, output)) {
            try {
                await pushCronOutputToChat(tenant.id, job.name, output, job.id, job.delivery?.channel === 'whatsapp' ? 'whatsapp' : null);
            }
            catch (err) {
                log.warn({ jobId: job.id, error: err.message }, 'cron output → chat push failed (cron itself succeeded)');
            }
        }
        log.info({ jobId: job.id, tenantId: tenant.id, duration }, 'Job executed successfully');
        inFlightJobs.delete(job.id);
        return {
            jobId: job.id,
            tenantId: tenant.id,
            executedAt: new Date().toISOString(),
            success: true,
            output,
            durationMs: duration,
        };
    }
    catch (err) {
        const error = err instanceof Error ? err.message : String(err);
        const duration = Math.max(0, Date.now() - startTime);
        await logExecution(job.id, tenant.id, false, null, error, duration);
        log.error({ jobId: job.id, tenantId: tenant.id, error }, 'Job execution failed');
        inFlightJobs.delete(job.id);
        return {
            jobId: job.id,
            tenantId: tenant.id,
            executedAt: new Date().toISOString(),
            success: false,
            error,
            durationMs: duration,
        };
    }
}
/**
 * Handle system event payload. Special sentinel texts (prefix `__autonomy:`)
 * dispatch to autonomy modules instead of the regular announce path.
 */
async function handleSystemEvent(job, tenant) {
    const text = job.payload.text || 'System event triggered';
    if (text === '__autonomy:research-cycle__') {
        const { runResearchCycle } = await import('../autonomy/research.js');
        const result = await runResearchCycle(tenant.id);
        return result.topic
            ? `Research: "${result.topic}" — ${result.hits} sources ingested`
            : 'Research: no candidate topic';
    }
    if (text === '__autonomy:skill-discovery__') {
        const { runSkillDiscovery } = await import('../autonomy/skill-discovery.js');
        const result = await runSkillDiscovery(tenant.id);
        return result.topCandidate
            ? `Skill discovery: ${result.candidates} candidates, top: ${result.topCandidate.full_name} (score ${result.topCandidate.relevance_score.toFixed(1)})`
            : `Skill discovery: ${result.queries.length} queries ran, no relevant candidates`;
    }
    if (text === '__autonomy:meeting__') {
        const { runAutonomousMeeting } = await import('../autonomy/autonomous-meeting.js');
        const r = await runAutonomousMeeting(tenant.id);
        return r.convened
            ? `Autonomous meeting held: "${String(r.question).slice(0, 60)}" — outcome reported to owner`
            : 'Autonomous meeting: reviewed state, nothing warranted a council this cycle';
    }
    if (text === '__autonomy:heartbeat__') {
        const { runHeartbeatCycle } = await import('../autonomy/heartbeat.js');
        const result = await runHeartbeatCycle(tenant.id);
        return result.fired
            ? `Heartbeat fired: ${result.trigger?.kind}`
            : `Heartbeat skipped: ${result.reason}`;
    }
    if (text === '__autonomy:telemetry__') {
        const { runTelemetryCycle } = await import('../telemetry/uploader.js');
        const r = await runTelemetryCycle(tenant.id);
        return r.uploaded
            ? `Telemetry: uploaded daily aggregate (${r.aggregate?.message_count} messages)`
            : `Telemetry skipped: ${r.reason}`;
    }
    if (text === '__autonomy:reflection__') {
        const { runReflectionCycle } = await import('../autonomy/reflection.js');
        const r = await runReflectionCycle(tenant.id);
        return r.lessonsLearned > 0
            ? `Reflection: ${r.lessonsLearned} lesson(s) learned across ${r.skillsTouched.length} skill(s) (from ${r.failuresFound} failures)`
            : `Reflection: nothing actionable today (${r.failuresFound} failures, 0 lessons)`;
    }
    if (text === '__autonomy:housekeeping__') {
        const { runHousekeeping } = await import('../autonomy/housekeeping.js');
        const r = await runHousekeeping(tenant.id);
        return `Housekeeping: purged ${r.totalDeleted} stale rows (approvals: ${r.approvals}, escalations: ${r.escalations}, deliveries: ${r.deliveries}, cron_executions: ${r.cronExecutions}, subagents: ${r.subagents}, episodes: ${r.episodes})`;
    }
    if (text === '__autonomy:audit-verify__') {
        const { runAuditVerify } = await import('../autonomy/audit-verify.js');
        const r = await runAuditVerify(tenant.id);
        return r.valid
            ? `Audit-verify: chain valid, ${r.checked} entries verified`
            : `⚠️ Audit-verify: chain INVALID at entry ${r.firstBadEntryId} — possible tampering`;
    }
    if (text === '__autonomy:proactive-actions__') {
        const { runProactiveActions } = await import('../autonomy/proactive-actions.js');
        const r = await runProactiveActions(tenant.id);
        return `Proactive: drafted ${r.drafted}, auto-executed ${r.autoExecuted}, escalated ${r.escalated}, skipped ${r.skipped}`;
    }
    // (v3.0.5) Aggressive goal pursuit — picks the most-stale active goal
    // and converts it into an agent kickoff that the cron pipeline will
    // route through the brain like any other agentTurn. The actual brain
    // call happens in the agentTurn branch (re-injects the kickoff text)
    // so the user sees real progress in chat, not a system-event log line.
    if (text === '__autonomy:goal-pursuit__') {
        const { pursueNextGoal } = await import('../autonomy/goal-pursuit.js');
        const r = await pursueNextGoal(tenant.id);
        if (!r.picked || !r.kickoffMessage)
            return `Goal pursuit: ${r.reason}`;
        // Re-route as an agentTurn so the brain actually executes the goal.
        // We synthesize a shadow CronJob with the agentTurn payload and
        // delegate to handleAgentTurn — same code path the message-based
        // crons use, so all the existing safety + logging applies.
        const shadow = {
            ...job,
            payload: {
                kind: 'agentTurn',
                message: r.kickoffMessage,
            },
        };
        try {
            const turn = await handleAgentTurn(shadow, tenant);
            const { recordPursuitOutcome, recordPursuitSteps } = await import('../autonomy/goal-pursuit.js');
            // Log the real steps against the goal so its activity timeline shows
            // what actually happened (not just a success stamp).
            await recordPursuitSteps(tenant.id, r.picked.goalId, turn.toolsExecuted ?? []);
            // HONEST outcome: a goal only "progressed" if the agent actually
            // executed at least one tool successfully. Text-only turns (the agent
            // describing what it would do, or claiming done with no action) are
            // recorded as no-progress — NOT fake success. After 3 such ticks the
            // goal stops being auto-pursued and an escalation is raised, so the
            // owner finds out instead of the system pretending forever.
            if (turn.toolsSucceeded > 0) {
                await recordPursuitOutcome(tenant.id, r.picked.goalId, 'success');
                return `Goal pursued: "${r.picked.title}" — ${turn.toolsSucceeded} إجراء فعلي ✅\n\n${turn.text}`;
            }
            await recordPursuitOutcome(tenant.id, r.picked.goalId, 'failure', 'no tools executed — agent produced text without taking action');
            return `⚠️ الهدف "${r.picked.title}": لم أُحرز تقدماً ملموساً هذه الجولة (لم تُنفَّذ أي أداة فعلية). إن تكرر هذا، سأرفع لك إشعاراً وأتوقف عن المحاولة التلقائية.\n\n${turn.text}`;
        }
        catch (err) {
            const { recordPursuitOutcome } = await import('../autonomy/goal-pursuit.js');
            await recordPursuitOutcome(tenant.id, r.picked.goalId, 'failure', err.message);
            throw err;
        }
    }
    // (v3.1) Generative initiative — observe→hypothesise→act. Unlike the fixed
    // proactive scanners, this asks the planning-tier brain what beneficial
    // actions the business needs RIGHT NOW, routes each through the policy
    // control plane, and batches the auto-approved ones into a single agent
    // kickoff so the brain actually EXECUTES them (act-then-tell). Ask/hard-gated
    // proposals become escalation cards. Stays silent on thin accounts.
    if (text === '__autonomy:initiative__') {
        const { runInitiativeCycle } = await import('../autonomy/initiative.js');
        const r = await runInitiativeCycle(tenant.id);
        if (r.reason && !r.kickoffMessage) {
            return `Initiative: ${r.reason} (proposed ${r.proposed}, escalated ${r.escalated})`;
        }
        if (!r.kickoffMessage) {
            return `Initiative: proposed ${r.proposed}, auto ${r.autoQueued}, escalated ${r.escalated}, skipped ${r.skipped}`;
        }
        // Re-route the auto batch as an agentTurn so the brain executes it and the
        // output surfaces in the owner's chat — same safe path goal-pursuit uses.
        const shadow = {
            ...job,
            payload: { kind: 'agentTurn', message: r.kickoffMessage },
        };
        try {
            const turn = await handleAgentTurn(shadow, tenant);
            return `Initiative: نفّذت ${r.autoQueued} إجراء تلقائي (${turn.toolsSucceeded} أداة فعلية ✅)، ورفعت ${r.escalated} للموافقة.\n\n${turn.text}`;
        }
        catch (err) {
            log.warn({ tenantId: tenant.id, err: err.message }, 'initiative kickoff turn failed');
            return `Initiative: auto-batch failed (${err.message}); escalated ${r.escalated}`;
        }
    }
    // (v3.1) Lead discovery — derives the ICP, searches the public web, extracts
    // + lawful-basis-classifies candidate businesses, dedupes, stores as leads.
    // It does NOT contact anyone: the initiative engine surfaces every fresh lead
    // as a cold-outreach approval (first-touch = ask, PDPL-safe).
    if (text === '__autonomy:lead-discovery__') {
        const { runLeadDiscovery } = await import('../autonomy/lead-discovery.js');
        const r = await runLeadDiscovery(tenant.id);
        if (r.reason)
            return `Lead discovery: ${r.reason}`;
        const basis = Object.entries(r.byBasis).map(([k, v]) => `${k}=${v}`).join(', ');
        return `Lead discovery: أضفت ${r.inserted} عميل محتمل جديد (${r.duplicates} مكرر) من ${r.queriesRun} بحث${basis ? ` — ${basis}` : ''}. ستظهر للموافقة على التواصل.`;
    }
    // (v3.1) KG relink — turn real tool USAGE from the journal into graph edges
    // (goal→tool, tool↔tool co-occurrence) so the knowledge graph shows how
    // things actually connect instead of a field of disconnected dots.
    if (text === '__autonomy:kg-relink__') {
        const { relinkFromJournal } = await import('../knowledge/linker.js');
        const r = await relinkFromJournal(tenant.id);
        return `KG relink: +${r.goalToolEdges} goal→tool, +${r.toolToolEdges} tool↔tool edges (resolved ${r.toolsResolved} tools)`;
    }
    if (text === '__autonomy:scorecard__') {
        const { runDailyScorecard } = await import('../autonomy/scorecard.js');
        const r = await runDailyScorecard(tenant.id);
        return `Scorecard: ${r.totalActions} actions, ~${r.estimatedMinutesSaved} min saved, delivered=${r.delivered}`;
    }
    if (text === '__autonomy:delivery-health__') {
        const { runDeliveryHealthCheck } = await import('../autonomy/delivery-health.js');
        const r = await runDeliveryHealthCheck(tenant.id);
        return r.ok
            ? `Delivery-health: ✓ ${r.failuresLast7d} failures in last 7d (acceptable)`
            : `⚠️ Delivery-health: ${r.detail}, alertSent=${r.alertSent}`;
    }
    // For now, just deliver as a message to the tenant's primary channel
    if (job.delivery?.mode === 'announce' && job.delivery.channel === 'whatsapp') {
        await deliverMessage({
            tenantId: tenant.id,
            to: tenant.whatsapp_phone,
            message: `[Reminder] ${text}`,
            channel: 'whatsapp',
        });
    }
    return `System event: ${text}`;
}
async function handleAgentTurn(job, tenant) {
    const message = job.payload.message || 'Agent turn triggered';
    // Create a synthetic customer ID for system tasks
    const customerId = `system-${job.id}`;
    const result = await processMessage(tenant.id, customerId, message, {
        recentMessages: [],
        additionalInstructions: 'This is an automated scheduled task.',
        // C5: every cron-triggered agentTurn starts at depth 1. If THIS turn
        // schedules another agentTurn, it inherits depth+1 (handled by the
        // schedule_recurring/schedule_followup tools — see recursionDepth
        // propagation in their executors).
        recursionDepth: (job.payload.recursionDepth ?? 0) + 1,
    }, 
    // Automatic cost routing: background scheduled work runs on the mid-tier
    // model (Sonnet), NOT the owner's Opus brain pin — keeps the always-on
    // loops affordable. The owner reads these outputs, but they don't need
    // frontier-model judgment.
    { budgetAware: true, enableTools: true, maxToolLoops: 3, purpose: 'background_work' });
    const toolsSucceeded = (result.toolsExecuted ?? []).filter((t) => t.success).length;
    // Deliver the response to an external channel only when the job explicitly
    // targets one (e.g. WhatsApp). Webchat/dashboard surfacing happens via
    // pushCronOutputToChat in executeJob — not here — so we don't double-post.
    if (job.delivery?.mode === 'announce' && job.delivery.channel === 'whatsapp') {
        await deliverMessage({
            tenantId: tenant.id,
            to: tenant.whatsapp_phone,
            message: result.response,
            channel: 'whatsapp',
        });
    }
    // Return the FULL response (so it surfaces verbatim in the owner's chat)
    // plus the real tool-success count + the executed-tools list so callers can
    // judge actual progress and log goal-linked steps.
    return { text: result.response, toolsSucceeded, toolsExecuted: result.toolsExecuted ?? [] };
}
/**
 * Log execution to database. Writes to BOTH cron_executions (legacy) and
 * cron_runs (the new per-job run history surfaced in the dashboard so owners
 * can answer "did my Sunday brief actually run?"). Pattern from OpenClaw's
 * cron/runs/<jobId>.jsonl files; we use SQLite rows instead of JSONL.
 */
async function logExecution(jobId, tenantId, success, output, error, durationMs) {
    const now = new Date().toISOString();
    await supabase.from('cron_executions').insert({
        id: uuidv4(),
        job_id: jobId,
        tenant_id: tenantId,
        executed_at: now,
        success,
        output,
        error,
        duration_ms: durationMs,
    });
    // Also record in the new cron_runs table for the dashboard. Best-effort —
    // a missing index or migration shouldn't break execution logging.
    try {
        await supabase.from('cron_runs').insert({
            id: uuidv4(),
            tenant_id: tenantId,
            job_id: jobId,
            fired_at: new Date(Date.now() - durationMs).toISOString(),
            finished_at: now,
            duration_ms: durationMs,
            success: success ? 1 : 0,
            output_excerpt: output ? output.slice(0, 500) : null,
            error: error ? error.slice(0, 500) : null,
        });
    }
    catch { /* best-effort */ }
}
/**
 * Get job run history
 */
export async function getJobRuns(jobId, limit = 10) {
    const { data, error } = await supabase
        .from('cron_executions')
        .select('*')
        .eq('job_id', jobId)
        .order('executed_at', { ascending: false })
        .limit(limit);
    if (error) {
        throw new Error(`Database error: ${error.message}`);
    }
    return data;
}
// System-event sentinels that are pure internal maintenance. Their output is
// a status line ("Heartbeat skipped", "purged 0 rows") that the owner doesn't
// need in chat. They still log to cron_executions. An output containing ⚠️
// means a real alert fired (e.g. audit chain invalid) — those still surface.
const CHAT_SILENT_SENTINELS = new Set([
    '__autonomy:heartbeat__',
    '__autonomy:housekeeping__',
    '__autonomy:telemetry__',
    '__autonomy:audit-verify__',
    '__autonomy:delivery-health__',
]);
/**
 * Decide whether a cron's output should appear in the owner's chat thread.
 * agentTurn jobs (real agent work) always surface. systemEvent maintenance
 * ticks are suppressed unless they carry a ⚠️ alert.
 */
function shouldSurfaceInChat(job, output) {
    const kind = job.payload?.kind;
    if (kind === 'agentTurn')
        return true;
    const sentinel = job.payload?.text ?? '';
    if (CHAT_SILENT_SENTINELS.has(sentinel) && !output.includes('⚠️'))
        return false;
    return true;
}
/**
 * Push cron output into the owner's chat history as an outbound message
 * from a dedicated "system-cron" customer per tenant. This is the bridge
 * that takes invisible cron execution and makes it visible — the dashboard
 * picks up the new message via /api/chat/conversations and surfaces it in
 * the recent-activity panel and the conversations tab.
 *
 * One "system-cron" customer per tenant (idempotently created on first
 * call), so every cron job's output threads into the same conversation —
 * "morning-brief", "competitor-news-scan", "research-cycle" all show up
 * chronologically as outbound messages tagged with the job name.
 */
async function pushCronOutputToChat(tenantId, jobName, output, jobId, legacyChannel) {
    // Write into the OWNER'S primary chat thread — the SAME customer that
    // /api/chat/recent reads from. resolveOwnerPrimaryCustomerId is the shared
    // source of truth; using it here closes the writer/reader mismatch that
    // previously funnelled all cron output into the system-cron pseudo-customer
    // (which the reader deliberately excludes → output never surfaced).
    const { resolveOwnerPrimaryCustomerId } = await import('../web-chat/owner-thread.js');
    let customerId = await resolveOwnerPrimaryCustomerId(tenantId);
    if (!customerId) {
        // No real customer exists yet — fall back to a system-cron thread so the
        // message at least lands somewhere retrievable. Once the owner sends a
        // single chat message, that becomes the primary thread and future cron
        // output threads there instead.
        const cronPhone = 'system-cron';
        const lookup = await supabase
            .from('customers')
            .select('id')
            .eq('tenant_id', tenantId)
            .eq('phone', cronPhone)
            .maybeSingle();
        customerId = lookup.data?.id;
        if (!customerId) {
            customerId = uuidv4();
            const now = new Date().toISOString();
            await supabase.from('customers').insert({
                id: customerId,
                tenant_id: tenantId,
                phone: cronPhone,
                name: 'تنبيهات المؤسس · Cron',
                language: 'ar',
                first_contact: now,
                last_contact: now,
                total_messages: 0,
                status: 'active',
                metadata: { channel: 'system', kind: 'cron' },
            });
        }
    }
    // session_id prefix `cron:<jobId>` is what the dashboard's
    // /api/chat/recent poll looks for to render the ⏰ source badge.
    await supabase.from('messages').insert({
        id: uuidv4(),
        tenant_id: tenantId,
        customer_id: customerId,
        session_id: `cron:${jobId ?? jobName.replace(/\s+/g, '_')}`,
        direction: 'outbound',
        type: 'text',
        content: `🕒 **${jobName}**\n\n${output}`,
        delivery_status: 'sent',
        created_at: new Date().toISOString(),
    });
    // Bump last_contact so the conversation surfaces at the top of recent
    // activity.
    await supabase
        .from('customers')
        .update({ last_contact: new Date().toISOString() })
        .eq('id', customerId);
    // PUSH the scheduled result to the owner's originating channel
    // (WhatsApp/Telegram) so it reaches them even when the dashboard is closed.
    // skipChannel avoids a double message when the legacy announce path in
    // handleAgentTurn/handleSystemEvent already delivered to that channel.
    try {
        const { deliverToCustomerChannel } = await import('../delivery/delivery.js');
        await deliverToCustomerChannel(tenantId, customerId, `🕒 ${jobName}\n\n${output}`, { skipChannel: legacyChannel });
    }
    catch (err) {
        log.warn({ jobId, error: err.message }, 'cron result: external-channel push failed (spooled to retry)');
    }
}
/**
 * Shutdown all jobs and the live-poll loop.
 */
export function shutdownCronScheduler() {
    if (pollInterval) {
        clearInterval(pollInterval);
        pollInterval = null;
    }
    for (const [jobId, task] of activeJobs) {
        task.stop();
        log.info({ jobId }, 'Job stopped');
    }
    activeJobs.clear();
    log.info('🕒 Cron scheduler shut down');
}
//# sourceMappingURL=scheduler.js.map