/**
 * Steward Scheduler Service
 *
 * This service executes stewards on schedule (cron) or in response to events.
 * It manages the lifecycle of scheduled steward executions and tracks execution history.
 *
 * Key features:
 * - Cron-based scheduling using node-cron
 * - Event-driven triggers with condition evaluation
 * - Execution history tracking
 * - Graceful shutdown support
 *
 * TB-O23: Steward Scheduler Service
 *
 * @module
 */
import { EventEmitter } from 'node:events';
import { createTimestamp } from '@stoneforge/core';
import { isCronTrigger, isEventTrigger, } from '../types/index.js';
import { getAgentMetadata } from './agent-registry.js';
// ============================================================================
// Cron Schedule Utilities
// ============================================================================
/**
 * Validates a cron expression.
 * This is a basic validation - we check the format but not all edge cases.
 */
export function isValidCronExpression(schedule) {
    // Standard cron format: * * * * * (minute hour day month weekday)
    // Extended format with seconds: * * * * * * (second minute hour day month weekday)
    const parts = schedule.trim().split(/\s+/);
    if (parts.length < 5 || parts.length > 6) {
        return false;
    }
    // Basic validation: each part should be a valid cron field
    const cronFieldPattern = /^(\*|\d+(-\d+)?(,\d+(-\d+)?)*)(\/\d+)?$/;
    return parts.every(part => cronFieldPattern.test(part) || part === '*');
}
/**
 * Calculates the next run time for a cron expression.
 * This is a simplified implementation - for production use node-cron handles this.
 */
export function getNextCronRunTime(_schedule) {
    // This is a placeholder - node-cron provides this via the scheduled task
    // For now we return undefined; actual implementation uses node-cron internals
    return undefined;
}
// ============================================================================
// Condition Evaluation
// ============================================================================
/**
 * Validates that a condition string contains only safe expression tokens.
 * Allows: property access, comparisons, string/number literals, booleans,
 * logical operators, parentheses.
 * Rejects: function calls, assignments, semicolons, template literals,
 * dangerous globals.
 */
function isSafeCondition(condition) {
    // Strip string literals so their contents don't trigger false positives
    const withoutStrings = condition
        .replace(/'[^']*'/g, '""')
        .replace(/"[^"]*"/g, '""');
    // Reject dangerous patterns
    const dangerousPatterns = [
        /[;{}]/, // statements, blocks
        /\b(import|require|eval|Function|constructor|__proto__|prototype)\b/,
        /\b(process|global|globalThis|window|document)\b/,
        /\b(setTimeout|setInterval|fetch|XMLHttpRequest)\b/,
        /=>|\.\.\.|\+\+|--/, // arrow functions, spread, increment/decrement
        /\[.*\]/, // bracket notation (property access escape vector)
        /`/, // template literals
        /\$\{/, // template expressions
        /\bthis\b/, // this reference
        /\bnew\b/, // constructor calls
        /\bdelete\b/, // delete operator
        /\bvoid\b/, // void operator
        /\btypeof\b/, // typeof operator
        /\bin\b/, // in operator
        /\binstanceof\b/, // instanceof operator
        /[^=!<>]=[^=]/, // assignment (but not ==, !=, <=, >=)
    ];
    for (const pattern of dangerousPatterns) {
        if (pattern.test(withoutStrings)) {
            return false;
        }
    }
    // Only allow safe characters after string removal
    const safeTokenPattern = /^[\w\s.$?!&|><=()'",-]+$/;
    if (!safeTokenPattern.test(withoutStrings)) {
        return false;
    }
    // Reject function calls: identifier followed by (
    if (/\w\s*\(/.test(withoutStrings)) {
        return false;
    }
    return true;
}
/**
 * Evaluates a simple condition expression against event data.
 * Supports basic JavaScript-like expressions with property access.
 *
 * Examples:
 * - "task.status === 'closed'"
 * - "task.assignedAgent?.role === 'worker'"
 * - "branch.tests === 'passing'"
 */
export function evaluateCondition(condition, context) {
    try {
        // Validate condition contains only safe expression tokens
        if (!isSafeCondition(condition)) {
            return false;
        }
        const contextKeys = Object.keys(context);
        const contextValues = Object.values(context);
        const fn = new Function(...contextKeys, `"use strict"; try { return Boolean(${condition}); } catch { return false; }`);
        return fn(...contextValues);
    }
    catch {
        return false;
    }
}
// ============================================================================
// Steward Scheduler Implementation
// ============================================================================
/**
 * Default configuration values
 */
const DEFAULT_CONFIG = {
    maxHistoryPerSteward: 100,
    defaultTimeoutMs: 5 * 60 * 1000, // 5 minutes
    startImmediately: false,
};
/**
 * Implementation of the Steward Scheduler Service.
 */
export class StewardSchedulerImpl {
    agentRegistry;
    config;
    executor;
    emitter;
    running = false;
    cronJobs = new Map(); // key: stewardId-triggerIndex
    eventSubscriptions = new Map(); // key: eventName
    executionHistory = [];
    runningExecutions = new Set();
    executionCounter = 0;
    constructor(agentRegistry, executor, config = {}) {
        this.agentRegistry = agentRegistry;
        this.executor = executor;
        this.config = { ...DEFAULT_CONFIG, ...config };
        this.emitter = new EventEmitter();
    }
    // ----------------------------------------
    // Lifecycle
    // ----------------------------------------
    async start() {
        if (this.running) {
            return;
        }
        this.running = true;
        // Start all registered cron jobs
        for (const job of this.cronJobs.values()) {
            this.startCronJob(job);
        }
        // Activate all event subscriptions
        for (const subs of this.eventSubscriptions.values()) {
            for (const sub of subs) {
                sub.isActive = true;
            }
        }
        // Register all stewards if configured to do so
        if (this.config.startImmediately) {
            await this.registerAllStewards();
        }
    }
    async stop() {
        if (!this.running) {
            return;
        }
        this.running = false;
        // Stop all cron jobs
        for (const job of this.cronJobs.values()) {
            this.stopCronJob(job);
        }
        // Deactivate event subscriptions
        for (const subs of this.eventSubscriptions.values()) {
            for (const sub of subs) {
                sub.isActive = false;
            }
        }
    }
    isRunning() {
        return this.running;
    }
    // ----------------------------------------
    // Steward Management
    // ----------------------------------------
    async registerSteward(stewardId) {
        const agent = await this.agentRegistry.getAgent(stewardId);
        if (!agent) {
            return false;
        }
        const metadata = getAgentMetadata(agent);
        if (!metadata || metadata.agentRole !== 'steward') {
            return false;
        }
        const stewardMeta = metadata;
        const triggers = stewardMeta.triggers ?? [];
        // Remove existing registrations for this steward
        await this.unregisterSteward(stewardId);
        // Register each trigger
        for (let i = 0; i < triggers.length; i++) {
            const trigger = triggers[i];
            if (isCronTrigger(trigger)) {
                const jobKey = `${stewardId}-${i}`;
                const jobState = {
                    stewardId,
                    stewardName: agent.name,
                    trigger,
                    isRunning: false,
                };
                this.cronJobs.set(jobKey, jobState);
                // Start job if scheduler is running
                if (this.running) {
                    this.startCronJob(jobState);
                }
            }
            else if (isEventTrigger(trigger)) {
                const eventName = trigger.event;
                if (!this.eventSubscriptions.has(eventName)) {
                    this.eventSubscriptions.set(eventName, []);
                }
                this.eventSubscriptions.get(eventName).push({
                    stewardId,
                    stewardName: agent.name,
                    trigger,
                    isActive: this.running,
                });
            }
        }
        this.emitter.emit('steward:registered', stewardId);
        return true;
    }
    async unregisterSteward(stewardId) {
        let unregistered = false;
        // Remove cron jobs
        for (const [key, job] of this.cronJobs.entries()) {
            if (job.stewardId === stewardId) {
                this.stopCronJob(job);
                this.cronJobs.delete(key);
                unregistered = true;
            }
        }
        // Remove event subscriptions
        for (const [eventName, subs] of this.eventSubscriptions.entries()) {
            const remaining = subs.filter(s => s.stewardId !== stewardId);
            if (remaining.length !== subs.length) {
                unregistered = true;
            }
            if (remaining.length === 0) {
                this.eventSubscriptions.delete(eventName);
            }
            else {
                this.eventSubscriptions.set(eventName, remaining);
            }
        }
        if (unregistered) {
            this.emitter.emit('steward:unregistered', stewardId);
        }
        return unregistered;
    }
    async refreshSteward(stewardId) {
        await this.unregisterSteward(stewardId);
        await this.registerSteward(stewardId);
    }
    async registerAllStewards() {
        const stewards = await this.agentRegistry.getStewards();
        let registered = 0;
        for (const steward of stewards) {
            const success = await this.registerSteward(steward.id);
            if (success) {
                registered++;
            }
        }
        return registered;
    }
    // ----------------------------------------
    // Manual Execution
    // ----------------------------------------
    async executeSteward(stewardId, context) {
        const agent = await this.agentRegistry.getAgent(stewardId);
        if (!agent) {
            return {
                success: false,
                error: `Steward not found: ${stewardId}`,
                durationMs: 0,
            };
        }
        const metadata = getAgentMetadata(agent);
        if (!metadata || metadata.agentRole !== 'steward') {
            return {
                success: false,
                error: `Entity is not a steward: ${stewardId}`,
                durationMs: 0,
            };
        }
        // Create a manual trigger
        const manualTrigger = {
            type: 'event',
            event: 'manual',
        };
        return this.runExecution(agent, manualTrigger, true, context);
    }
    // ----------------------------------------
    // Event Publishing
    // ----------------------------------------
    async publishEvent(eventName, eventData) {
        if (!this.running) {
            return 0;
        }
        const subscriptions = this.eventSubscriptions.get(eventName) ?? [];
        let triggered = 0;
        for (const sub of subscriptions) {
            if (!sub.isActive) {
                continue;
            }
            // Check condition if present
            if (sub.trigger.condition) {
                const matches = evaluateCondition(sub.trigger.condition, eventData);
                if (!matches) {
                    continue;
                }
            }
            // Execute the steward
            const agent = await this.agentRegistry.getAgent(sub.stewardId);
            if (agent) {
                // Run execution asynchronously
                this.runExecution(agent, sub.trigger, false, eventData).catch(() => {
                    // Errors are handled in runExecution
                });
                triggered++;
            }
        }
        return triggered;
    }
    // ----------------------------------------
    // Status & Queries
    // ----------------------------------------
    getScheduledJobs(stewardId) {
        const jobs = [];
        for (const job of this.cronJobs.values()) {
            if (stewardId && job.stewardId !== stewardId) {
                continue;
            }
            jobs.push({
                stewardId: job.stewardId,
                stewardName: job.stewardName,
                trigger: job.trigger,
                isRunning: job.isRunning,
                nextRunAt: job.nextRunAt,
                lastRunAt: job.lastRunAt,
            });
        }
        return jobs;
    }
    getEventSubscriptions(stewardId) {
        const subscriptions = [];
        for (const subs of this.eventSubscriptions.values()) {
            for (const sub of subs) {
                if (stewardId && sub.stewardId !== stewardId) {
                    continue;
                }
                subscriptions.push({
                    stewardId: sub.stewardId,
                    stewardName: sub.stewardName,
                    trigger: sub.trigger,
                    isActive: sub.isActive,
                });
            }
        }
        return subscriptions;
    }
    getExecutionHistory(filter) {
        let entries = [...this.executionHistory];
        if (filter) {
            if (filter.stewardId) {
                entries = entries.filter(e => e.stewardId === filter.stewardId);
            }
            if (filter.triggerType) {
                entries = entries.filter(e => e.trigger.type === filter.triggerType);
            }
            if (filter.success !== undefined) {
                entries = entries.filter(e => e.result?.success === filter.success);
            }
            if (filter.startedAfter) {
                entries = entries.filter(e => e.startedAt >= filter.startedAfter);
            }
            if (filter.startedBefore) {
                entries = entries.filter(e => e.startedAt <= filter.startedBefore);
            }
        }
        // Sort by startedAt descending
        entries.sort((a, b) => (b.startedAt > a.startedAt ? 1 : -1));
        if (filter?.limit) {
            entries = entries.slice(0, filter.limit);
        }
        return entries;
    }
    getLastExecution(stewardId) {
        const entries = this.getExecutionHistory({ stewardId, limit: 1 });
        return entries[0];
    }
    getStats() {
        const registeredStewards = new Set();
        for (const job of this.cronJobs.values()) {
            registeredStewards.add(job.stewardId);
        }
        for (const subs of this.eventSubscriptions.values()) {
            for (const sub of subs) {
                registeredStewards.add(sub.stewardId);
            }
        }
        const successfulExecutions = this.executionHistory.filter(e => e.result?.success === true).length;
        const failedExecutions = this.executionHistory.filter(e => e.result?.success === false).length;
        return {
            registeredStewards: registeredStewards.size,
            activeCronJobs: this.cronJobs.size,
            activeEventSubscriptions: [...this.eventSubscriptions.values()].flat().length,
            totalExecutions: this.executionHistory.length,
            successfulExecutions,
            failedExecutions,
            runningExecutions: this.runningExecutions.size,
        };
    }
    on(event, listener) {
        this.emitter.on(event, listener);
    }
    off(event, listener) {
        this.emitter.off(event, listener);
    }
    // ----------------------------------------
    // Private Helpers
    // ----------------------------------------
    startCronJob(job) {
        if (job.intervalId) {
            return; // Already running
        }
        this.scheduleNextRun(job);
    }
    scheduleNextRun(job) {
        const nextTime = this.getNextCronTime(job.trigger.schedule);
        if (!nextTime)
            return;
        job.nextRunAt = nextTime;
        const delayMs = Math.max(0, nextTime.getTime() - Date.now());
        job.intervalId = setTimeout(async () => {
            if (!this.running)
                return;
            if (job.isRunning) {
                // Skip this run but schedule the next one
                job.intervalId = undefined;
                this.scheduleNextRun(job);
                return;
            }
            const agent = await this.agentRegistry.getAgent(job.stewardId);
            if (agent && this.running) {
                await this.runExecution(agent, job.trigger, false);
                job.lastRunAt = createTimestamp();
            }
            if (this.running) {
                job.intervalId = undefined;
                this.scheduleNextRun(job);
            }
        }, delayMs);
    }
    stopCronJob(job) {
        if (job.intervalId) {
            clearTimeout(job.intervalId);
            job.intervalId = undefined;
        }
    }
    /**
     * Calculates the next fire time for a cron expression after the given date.
     * Supports 5-field cron (minute hour dom month dow) with optional 6th seconds field (ignored).
     * Supports: *, specific values, step (* /N), range (N-M), and lists (N,M).
     * Returns null for invalid expressions.
     */
    getNextCronTime(schedule, after) {
        const parts = schedule.trim().split(/\s+/);
        if (parts.length < 5 || parts.length > 6) {
            return null;
        }
        // If 6 fields, ignore the first (seconds) field
        const fields = parts.length === 6 ? parts.slice(1) : parts;
        const [minuteField, hourField, domField, monthField, dowField] = fields;
        // Parse each field into a set of allowed values
        const minutes = this.parseCronField(minuteField, 0, 59);
        const hours = this.parseCronField(hourField, 0, 23);
        const doms = this.parseCronField(domField, 1, 31);
        const months = this.parseCronField(monthField, 1, 12);
        const dows = this.parseCronField(dowField, 0, 6); // 0=Sunday
        if (!minutes || !hours || !doms || !months || !dows) {
            return null;
        }
        // Start from the next minute after 'after'
        const start = after ? new Date(after) : new Date();
        start.setSeconds(0, 0);
        start.setMinutes(start.getMinutes() + 1);
        // Iterate forward up to 366 days to find the next matching time
        const maxIterations = 366 * 24 * 60; // worst case: check every minute for a year
        const candidate = new Date(start);
        for (let i = 0; i < maxIterations; i++) {
            const month = candidate.getMonth() + 1; // 1-12
            const dom = candidate.getDate();
            const dow = candidate.getDay(); // 0=Sunday
            const hour = candidate.getHours();
            const minute = candidate.getMinutes();
            if (months.has(month) &&
                doms.has(dom) &&
                dows.has(dow) &&
                hours.has(hour) &&
                minutes.has(minute)) {
                return candidate;
            }
            // Advance by 1 minute
            candidate.setMinutes(candidate.getMinutes() + 1);
        }
        return null; // No match found within 366 days
    }
    /**
     * Parses a single cron field into a set of valid values.
     * Supports: *, N, N-M, N,M, * /N, N-M/S
     */
    parseCronField(field, min, max) {
        const result = new Set();
        // Handle comma-separated list
        const segments = field.split(',');
        for (const segment of segments) {
            // Handle step: */N or N-M/S
            const stepMatch = segment.match(/^(.+)\/(\d+)$/);
            if (stepMatch) {
                const step = parseInt(stepMatch[2], 10);
                if (step <= 0)
                    return null;
                let rangeStart = min;
                let rangeEnd = max;
                if (stepMatch[1] !== '*') {
                    const rangeMatch = stepMatch[1].match(/^(\d+)-(\d+)$/);
                    if (rangeMatch) {
                        rangeStart = parseInt(rangeMatch[1], 10);
                        rangeEnd = parseInt(rangeMatch[2], 10);
                    }
                    else {
                        rangeStart = parseInt(stepMatch[1], 10);
                        rangeEnd = max;
                    }
                }
                for (let v = rangeStart; v <= rangeEnd; v += step) {
                    if (v >= min && v <= max)
                        result.add(v);
                }
                continue;
            }
            // Handle wildcard
            if (segment === '*') {
                for (let v = min; v <= max; v++)
                    result.add(v);
                continue;
            }
            // Handle range: N-M
            const rangeMatch = segment.match(/^(\d+)-(\d+)$/);
            if (rangeMatch) {
                const start = parseInt(rangeMatch[1], 10);
                const end = parseInt(rangeMatch[2], 10);
                for (let v = start; v <= end; v++) {
                    if (v >= min && v <= max)
                        result.add(v);
                }
                continue;
            }
            // Handle specific value
            const value = parseInt(segment, 10);
            if (isNaN(value) || value < min || value > max)
                return null;
            result.add(value);
        }
        return result.size > 0 ? result : null;
    }
    async runExecution(agent, trigger, manual, eventContext) {
        const executionId = `exec-${++this.executionCounter}-${Date.now()}`;
        const stewardId = agent.id;
        const startedAt = createTimestamp();
        const entry = {
            executionId,
            stewardId,
            stewardName: agent.name,
            trigger,
            manual,
            startedAt,
            eventContext,
        };
        this.addHistoryEntry(entry);
        this.runningExecutions.add(executionId);
        this.emitter.emit('execution:started', entry);
        // Update job state if this is a cron job
        for (const job of this.cronJobs.values()) {
            if (job.stewardId === stewardId && isCronTrigger(trigger)) {
                job.isRunning = true;
            }
        }
        const startTime = Date.now();
        try {
            // Execute the steward
            const result = await this.executor(agent, { trigger, eventContext });
            // Complete the entry
            const completedEntry = {
                ...entry,
                completedAt: createTimestamp(),
                result,
            };
            this.updateHistoryEntry(executionId, completedEntry);
            this.runningExecutions.delete(executionId);
            if (result.success) {
                this.emitter.emit('execution:completed', completedEntry);
            }
            else {
                this.emitter.emit('execution:failed', completedEntry);
            }
            // Update job state
            for (const job of this.cronJobs.values()) {
                if (job.stewardId === stewardId) {
                    job.isRunning = false;
                    job.lastRunAt = completedEntry.completedAt;
                }
            }
            // Update steward metadata with last execution time
            await this.agentRegistry.updateAgentMetadata(stewardId, {
                lastExecutedAt: completedEntry.completedAt,
            });
            return result;
        }
        catch (error) {
            const errorMessage = error instanceof Error ? error.message : String(error);
            const result = {
                success: false,
                error: errorMessage,
                durationMs: Date.now() - startTime,
            };
            const failedEntry = {
                ...entry,
                completedAt: createTimestamp(),
                result,
            };
            this.updateHistoryEntry(executionId, failedEntry);
            this.runningExecutions.delete(executionId);
            this.emitter.emit('execution:failed', failedEntry);
            // Update job state
            for (const job of this.cronJobs.values()) {
                if (job.stewardId === stewardId) {
                    job.isRunning = false;
                }
            }
            return result;
        }
    }
    addHistoryEntry(entry) {
        this.executionHistory.push(entry);
        this.pruneHistory();
    }
    updateHistoryEntry(executionId, entry) {
        const index = this.executionHistory.findIndex(e => e.executionId === executionId);
        if (index !== -1) {
            this.executionHistory[index] = entry;
        }
    }
    pruneHistory() {
        // Group by steward and keep only maxHistoryPerSteward entries per steward
        const bysteward = new Map();
        for (const entry of this.executionHistory) {
            if (!bysteward.has(entry.stewardId)) {
                bysteward.set(entry.stewardId, []);
            }
            bysteward.get(entry.stewardId).push(entry);
        }
        const prunedHistory = [];
        for (const entries of bysteward.values()) {
            // Sort by startedAt descending and keep only the first maxHistoryPerSteward
            entries.sort((a, b) => (b.startedAt > a.startedAt ? 1 : -1));
            prunedHistory.push(...entries.slice(0, this.config.maxHistoryPerSteward));
        }
        this.executionHistory = prunedHistory;
    }
}
// ============================================================================
// Factory Function
// ============================================================================
/**
 * Creates a StewardScheduler instance
 *
 * @param agentRegistry - The agent registry for looking up stewards
 * @param executor - Function to execute steward logic
 * @param config - Optional configuration
 */
export function createStewardScheduler(agentRegistry, executor, config) {
    return new StewardSchedulerImpl(agentRegistry, executor, config);
}
// ============================================================================
// Default Executor (placeholder)
// ============================================================================
/**
 * Creates a default steward executor that delegates to the appropriate
 * service based on the steward's focus.
 *
 * This is a placeholder - the actual executor would integrate with:
 * - MergeStewardService for 'merge' focus
 * - HealthStewardService for 'health' focus (TB-O24)
 * - etc.
 */
export function createDefaultStewardExecutor() {
    return async (steward, _context) => {
        const metadata = getAgentMetadata(steward);
        const focus = metadata?.stewardFocus;
        // Placeholder - returns success with info about what would be done
        return {
            success: true,
            output: `Steward ${steward.name} (${focus}) executed successfully`,
            durationMs: 0,
            itemsProcessed: 0,
        };
    };
}
//# sourceMappingURL=steward-scheduler.js.map