/**
 * IngestorManager — owns and manages the lifecycle of all ingestor instances.
 *
 * Keyed by `callerAlias:connectionAlias:instanceId`, so each caller gets its
 * own ingestor instance (with its own secrets, buffer, and connection state).
 * Multiple sessions from the same caller share the same ingestor/buffer.
 *
 * Supports multi-instance listeners: when a caller defines `listenerInstances`
 * for a connection, one ingestor is spawned per instanceId (e.g., watching
 * multiple Trello boards or multiple Reddit subreddits simultaneously).
 *
 * The manager is created once when the remote server starts, and provides
 * event retrieval and status methods used by the `poll_events` and
 * `ingestor_status` tool handlers.
 */
import { resolveCallerRoutes, resolveRoutes, resolveSecrets, } from '../../shared/config.js';
import { createLogger } from '../../shared/logger.js';
import { TriggerRuleEngine } from '../triggers/rule-engine.js';
const log = createLogger('ingestor');
import { createIngestor } from './registry.js';
import { WebhookIngestor } from './webhook/base-webhook-ingestor.js';
// Import providers so they self-register their factories.
// Each provider calls registerIngestorFactory() at module load time.
import './discord/discord-gateway.js';
import './slack/socket-mode.js';
import './webhook/github-webhook-ingestor.js';
import './webhook/stripe-webhook-ingestor.js';
import './webhook/trello-webhook-ingestor.js';
import './poll/poll-ingestor.js';
// ── Key helpers ────────────────────────────────────────────────────────────
/** Sentinel instance ID used for single-instance (default) connections. */
const DEFAULT_INSTANCE_ID = '_default';
/** Build a composite ingestor map key. */
function makeKey(caller, connection, instance = DEFAULT_INSTANCE_ID) {
    return `${caller}:${connection}:${instance}`;
}
/** Parse a composite ingestor map key. */
function parseKey(key) {
    const firstColon = key.indexOf(':');
    const secondColon = key.indexOf(':', firstColon + 1);
    return {
        caller: key.slice(0, firstColon),
        connection: key.slice(firstColon + 1, secondColon),
        instance: key.slice(secondColon + 1),
    };
}
// ── Manager ────────────────────────────────────────────────────────────────
export class IngestorManager {
    config;
    /** Active ingestor instances, keyed by `callerAlias:connectionAlias:instanceId`. */
    ingestors = new Map();
    /** Trigger rule engines per caller. Created during startAll() for callers with triggerRules. */
    triggerEngines = new Map();
    /** Global event listeners (e.g. SSE streams). Called for every event from every ingestor. */
    eventListeners = new Set();
    /**
     * Optional config loader for hot-reload support. When provided, `startOne()`
     * uses it to get fresh config from disk instead of the constructor snapshot.
     */
    configLoader;
    constructor(config, configLoader) {
        this.config = config;
        this.configLoader = configLoader;
    }
    /** Return fresh config if a loader is available, otherwise the constructor snapshot. */
    getConfig() {
        return this.configLoader ? this.configLoader() : this.config;
    }
    /**
     * Start ingestors for all callers whose connections have an `ingestor` config.
     * Called once when the remote server starts listening.
     *
     * For connections with `listenerInstances`, spawns one ingestor per instanceId.
     * For connections without, spawns a single default instance (backward compatible).
     */
    async startAll() {
        for (const [callerAlias, callerConfig] of Object.entries(this.config.callers)) {
            // Resolve routes for this caller (raw + resolved)
            const rawRoutes = resolveCallerRoutes(this.config, callerAlias);
            const callerEnvResolved = resolveSecrets(callerConfig.env ?? {});
            const resolvedRoutes = resolveRoutes(rawRoutes, callerEnvResolved, callerAlias);
            for (let i = 0; i < rawRoutes.length; i++) {
                const rawRoute = rawRoutes[i];
                const resolvedRoute = resolvedRoutes[i];
                const connectionAlias = callerConfig.connections[i];
                // Skip connections without an ingestor config
                if (!rawRoute.ingestor)
                    continue;
                // Check if this connection has multi-instance definitions
                const instances = callerConfig.listenerInstances?.[connectionAlias];
                if (instances && Object.keys(instances).length > 0) {
                    // Multi-instance: spawn one ingestor per instanceId
                    for (const [instanceId, instanceOverrides] of Object.entries(instances)) {
                        if (instanceOverrides.disabled) {
                            log.info(`Skipping disabled instance ${callerAlias}:${connectionAlias}:${instanceId}`);
                            continue;
                        }
                        const key = makeKey(callerAlias, connectionAlias, instanceId);
                        if (this.ingestors.has(key))
                            continue;
                        await this.startIngestor(key, connectionAlias, rawRoute, resolvedRoute, instanceOverrides, instanceId);
                    }
                }
                else {
                    // Single-instance (backward compatible)
                    const overrides = callerConfig.ingestorOverrides?.[connectionAlias];
                    if (overrides?.disabled) {
                        log.info(`Skipping disabled ingestor for ${callerAlias}:${connectionAlias}`);
                        continue;
                    }
                    const key = makeKey(callerAlias, connectionAlias);
                    if (this.ingestors.has(key))
                        continue;
                    await this.startIngestor(key, connectionAlias, rawRoute, resolvedRoute, overrides, undefined);
                }
            }
        }
        const count = this.ingestors.size;
        if (count > 0) {
            log.info(`${count} ingestor(s) started`);
        }
        // Wire up trigger rule engines for callers that have triggerRules
        this.initTriggerEngines();
    }
    /**
     * Internal: create, register, and start a single ingestor instance.
     */
    async startIngestor(key, connectionAlias, rawRoute, resolvedRoute, overrides, instanceId) {
        // Merge caller overrides into a copy of the template config
        const effectiveConfig = IngestorManager.mergeIngestorConfig(rawRoute.ingestor, overrides);
        // Apply instance params to config and secrets
        const instanceSecrets = { ...resolvedRoute.secrets };
        if (overrides?.params && rawRoute.listenerConfig) {
            IngestorManager.applyInstanceParams(effectiveConfig, instanceSecrets, overrides.params, rawRoute.listenerConfig.fields);
        }
        // For poll ingestors, attach the resolved route headers so the factory
        // can pass them through for authenticated HTTP requests.
        if (effectiveConfig.type === 'poll') {
            // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-explicit-any -- private property consumed by poll factory
            effectiveConfig._resolvedRouteHeaders = resolvedRoute.headers;
        }
        const ingestor = createIngestor(connectionAlias, effectiveConfig, instanceSecrets, overrides?.bufferSize, instanceId);
        if (ingestor) {
            const { caller } = parseKey(key);
            ingestor.callerAlias = caller;
            this.ingestors.set(key, ingestor);
            // Forward events to global listeners (SSE streams, etc.)
            // eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-explicit-any -- BaseIngestor extends EventEmitter; .on() is inherited
            ingestor.on('event', (event) => {
                this.notifyEventListeners(event);
            });
            log.info(`Starting ${effectiveConfig.type} ingestor for ${key}`);
            try {
                await ingestor.start();
            }
            catch (err) {
                log.error(`Failed to start ${key}:`, err);
            }
        }
    }
    /**
     * Initialize trigger rule engines for callers with triggerRules config.
     * Subscribes to 'event' emissions from matching ingestors and dispatches
     * to Claude Code remote triggers.
     */
    initTriggerEngines() {
        const config = this.getConfig();
        for (const [callerAlias, callerConfig] of Object.entries(config.callers)) {
            if (!callerConfig.triggerRules || callerConfig.triggerRules.length === 0)
                continue;
            // Resolve caller-level secrets for API key access
            const callerEnvResolved = resolveSecrets(callerConfig.env ?? {});
            const engine = new TriggerRuleEngine(callerConfig.triggerRules, callerEnvResolved);
            if (engine.activeRuleCount === 0)
                continue;
            this.triggerEngines.set(callerAlias, engine);
            // Subscribe the engine to all ingestors belonging to this caller
            for (const [key, ingestor] of this.ingestors) {
                const { caller } = parseKey(key);
                if (caller !== callerAlias)
                    continue;
                // eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-explicit-any -- BaseIngestor extends EventEmitter; .on() is inherited
                ingestor.on('event', (event) => {
                    engine.handleEvent(event);
                });
            }
            log.info(`Trigger rule engine for ${callerAlias}: ${engine.activeRuleCount} active rule(s)`);
        }
    }
    /**
     * Stop all running ingestors. Called during graceful shutdown.
     */
    async stopAll() {
        const stops = Array.from(this.ingestors.entries()).map(async ([key, ingestor]) => {
            log.info(`Stopping ${key}`);
            try {
                await ingestor.stop(true);
            }
            catch (err) {
                log.error(`Error stopping ${key}:`, err);
            }
        });
        await Promise.all(stops);
        this.ingestors.clear();
    }
    /**
     * Get events for a specific caller and connection, optionally filtered by instance.
     * When instanceId is omitted, aggregates events from all instances of that connection.
     */
    getEvents(callerAlias, connectionAlias, afterId = -1, instanceId) {
        if (instanceId) {
            // Specific instance
            const key = makeKey(callerAlias, connectionAlias, instanceId);
            const ingestor = this.ingestors.get(key);
            if (!ingestor)
                return [];
            return ingestor.getEvents(afterId);
        }
        // All instances of this connection (including _default)
        const prefix = `${callerAlias}:${connectionAlias}:`;
        const events = [];
        for (const [key, ingestor] of this.ingestors) {
            if (key.startsWith(prefix)) {
                events.push(...ingestor.getEvents(afterId));
            }
        }
        events.sort((a, b) => a.receivedAt.localeCompare(b.receivedAt));
        return events;
    }
    /**
     * Get events across all ingestors for a caller, sorted chronologically.
     * @param callerAlias  The caller whose events to retrieve.
     * @param afterId  Return events with id > afterId. Pass -1 for all.
     */
    getAllEvents(callerAlias, afterId = -1) {
        const events = [];
        const prefix = `${callerAlias}:`;
        for (const [key, ingestor] of this.ingestors) {
            if (key.startsWith(prefix)) {
                events.push(...ingestor.getEvents(afterId));
            }
        }
        // Sort by receivedAt (ISO strings sort lexicographically)
        events.sort((a, b) => a.receivedAt.localeCompare(b.receivedAt));
        return events;
    }
    /**
     * Get status of all ingestors for a caller.
     */
    getStatuses(callerAlias) {
        const statuses = [];
        const prefix = `${callerAlias}:`;
        for (const [key, ingestor] of this.ingestors) {
            if (key.startsWith(prefix)) {
                statuses.push(ingestor.getStatus());
            }
        }
        return statuses;
    }
    /**
     * Get status of every ingestor across all callers, augmented with the
     * owning caller alias. Used by the read-only /admin API to render a global
     * dashboard view without iterating per-caller.
     */
    getAllStatuses() {
        const out = [];
        for (const [key, ingestor] of this.ingestors) {
            const { caller } = parseKey(key);
            out.push({ ...ingestor.getStatus(), callerAlias: caller });
        }
        return out;
    }
    /**
     * Subscribe to all events from all ingestors (current and future).
     * Used by the SSE /events/stream endpoint to fan out events to CLI watchers.
     */
    onEvent(listener) {
        this.eventListeners.add(listener);
    }
    /** Unsubscribe a global event listener. */
    offEvent(listener) {
        this.eventListeners.delete(listener);
    }
    /** Forward an ingestor event to all global listeners. */
    notifyEventListeners(event) {
        for (const listener of this.eventListeners) {
            try {
                listener(event);
            }
            catch {
                // Don't let a broken listener crash ingestor event processing
            }
        }
    }
    /**
     * Find all webhook ingestor instances that match a given webhook path.
     * Returns all matching instances across all callers (for fan-out dispatch).
     *
     * @param path - The webhook path segment (e.g., 'github' from /webhooks/github).
     */
    getWebhookIngestors(path) {
        const matches = [];
        for (const ingestor of this.ingestors.values()) {
            if (ingestor instanceof WebhookIngestor && ingestor.webhookPath === path) {
                matches.push(ingestor);
            }
        }
        return matches;
    }
    // ── Runtime lifecycle control ──────────────────────────────────────────
    /**
     * Start a single ingestor for a specific caller+connection pair.
     * When instanceId is provided, starts that specific instance.
     * When omitted, starts the default instance (or all instances if listenerInstances is defined).
     */
    async startOne(callerAlias, connectionAlias, instanceId) {
        // Get fresh config (from disk in production, or constructor snapshot in tests)
        // so we pick up changes made by tool handlers (e.g. set_connection_enabled,
        // set_listener_params, set_secrets) without requiring a server restart.
        const config = this.getConfig();
        const callerConfig = config.callers[callerAlias];
        if (!callerConfig) {
            return {
                success: false,
                connection: connectionAlias,
                error: `Unknown caller: ${callerAlias}`,
            };
        }
        const connectionIndex = callerConfig.connections.indexOf(connectionAlias);
        if (connectionIndex === -1) {
            return {
                success: false,
                connection: connectionAlias,
                error: `Caller does not have connection: ${connectionAlias}`,
            };
        }
        const rawRoutes = resolveCallerRoutes(config, callerAlias);
        const callerEnvResolved = resolveSecrets(callerConfig.env ?? {});
        const resolvedRoutes = resolveRoutes(rawRoutes, callerEnvResolved, callerAlias);
        const rawRoute = rawRoutes[connectionIndex];
        const resolvedRoute = resolvedRoutes[connectionIndex];
        if (!rawRoute.ingestor) {
            return {
                success: false,
                connection: connectionAlias,
                error: 'This connection does not have an ingestor.',
            };
        }
        // If a specific instanceId is given, start just that one
        if (instanceId) {
            return this.startOneInstance(callerAlias, connectionAlias, instanceId, rawRoute, resolvedRoute, config);
        }
        // If listenerInstances is defined, start all instances
        const instances = callerConfig.listenerInstances?.[connectionAlias];
        if (instances && Object.keys(instances).length > 0) {
            const results = [];
            for (const [instId, instOverrides] of Object.entries(instances)) {
                if (instOverrides.disabled)
                    continue;
                results.push(await this.startOneInstance(callerAlias, connectionAlias, instId, rawRoute, resolvedRoute, config));
            }
            return results;
        }
        // Single default instance
        return this.startOneInstance(callerAlias, connectionAlias, undefined, rawRoute, resolvedRoute, config);
    }
    /** Internal: start a single specific instance. */
    async startOneInstance(callerAlias, connectionAlias, instanceId, rawRoute, resolvedRoute, config) {
        const key = makeKey(callerAlias, connectionAlias, instanceId ?? DEFAULT_INSTANCE_ID);
        // If already running, return current status
        const existing = this.ingestors.get(key);
        if (existing) {
            const status = existing.getStatus();
            if (status.state === 'connected' ||
                status.state === 'starting' ||
                status.state === 'reconnecting') {
                return { success: true, connection: connectionAlias, instanceId, state: status.state };
            }
            // Remove stopped instance to recreate
            this.ingestors.delete(key);
        }
        const callerCfg = config.callers[callerAlias];
        const overrides = instanceId
            ? callerCfg.listenerInstances?.[connectionAlias]?.[instanceId]
            : callerCfg.ingestorOverrides?.[connectionAlias];
        const effectiveConfig = IngestorManager.mergeIngestorConfig(rawRoute.ingestor, overrides);
        // Apply instance params
        const instanceSecrets = { ...resolvedRoute.secrets };
        if (overrides?.params && rawRoute.listenerConfig) {
            IngestorManager.applyInstanceParams(effectiveConfig, instanceSecrets, overrides.params, rawRoute.listenerConfig.fields);
        }
        if (effectiveConfig.type === 'poll') {
            // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-explicit-any
            effectiveConfig._resolvedRouteHeaders = resolvedRoute.headers;
        }
        const ingestor = createIngestor(connectionAlias, effectiveConfig, instanceSecrets, overrides?.bufferSize, instanceId);
        if (!ingestor) {
            return {
                success: false,
                connection: connectionAlias,
                instanceId,
                error: 'Failed to create ingestor.',
            };
        }
        ingestor.callerAlias = callerAlias;
        this.ingestors.set(key, ingestor);
        // Forward events to global listeners (SSE streams, etc.)
        // eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-explicit-any -- BaseIngestor extends EventEmitter; .on() is inherited
        ingestor.on('event', (event) => {
            this.notifyEventListeners(event);
        });
        log.info(`Starting ${effectiveConfig.type} ingestor for ${key}`);
        try {
            await ingestor.start();
            return {
                success: true,
                connection: connectionAlias,
                instanceId,
                state: ingestor.getStatus().state,
            };
        }
        catch (err) {
            log.error(`Failed to start ${key}:`, err);
            return {
                success: false,
                connection: connectionAlias,
                instanceId,
                error: err instanceof Error ? err.message : String(err),
            };
        }
    }
    /**
     * Stop a single ingestor for a specific caller+connection pair.
     * When instanceId is provided, stops that specific instance.
     * When omitted, stops all instances of that connection.
     */
    async stopOne(callerAlias, connectionAlias, instanceId, options) {
        if (instanceId) {
            return this.stopOneInstance(callerAlias, connectionAlias, instanceId, options);
        }
        // Stop all instances for this connection
        const prefix = `${callerAlias}:${connectionAlias}:`;
        const keysToStop = Array.from(this.ingestors.keys()).filter((k) => k.startsWith(prefix));
        if (keysToStop.length === 0) {
            return {
                success: false,
                connection: connectionAlias,
                error: 'No ingestor running for this connection.',
            };
        }
        if (keysToStop.length === 1) {
            const parsed = parseKey(keysToStop[0]);
            const instId = parsed.instance === DEFAULT_INSTANCE_ID ? undefined : parsed.instance;
            return this.stopOneInstance(callerAlias, connectionAlias, instId, options);
        }
        const results = [];
        for (const key of keysToStop) {
            const parsed = parseKey(key);
            const instId = parsed.instance === DEFAULT_INSTANCE_ID ? undefined : parsed.instance;
            results.push(await this.stopOneInstance(callerAlias, connectionAlias, instId, options));
        }
        return results;
    }
    /** Internal: stop a single specific instance. */
    async stopOneInstance(callerAlias, connectionAlias, instanceId, options) {
        const key = makeKey(callerAlias, connectionAlias, instanceId ?? DEFAULT_INSTANCE_ID);
        const ingestor = this.ingestors.get(key);
        if (!ingestor) {
            return {
                success: false,
                connection: connectionAlias,
                instanceId,
                error: 'No ingestor running for this connection.',
            };
        }
        log.info(`Stopping ${key}`);
        try {
            await ingestor.stop(options?.permanent);
            this.ingestors.delete(key);
            return { success: true, connection: connectionAlias, instanceId, state: 'stopped' };
        }
        catch (err) {
            log.error(`Error stopping ${key}:`, err);
            return {
                success: false,
                connection: connectionAlias,
                instanceId,
                error: err instanceof Error ? err.message : String(err),
            };
        }
    }
    /**
     * Restart a single ingestor (stop + start). Useful after configuration changes.
     */
    async restartOne(callerAlias, connectionAlias, instanceId) {
        // Stop all matching instances
        const prefix = instanceId
            ? makeKey(callerAlias, connectionAlias, instanceId)
            : `${callerAlias}:${connectionAlias}:`;
        for (const key of Array.from(this.ingestors.keys())) {
            if (instanceId ? key === prefix : key.startsWith(prefix)) {
                const parsed = parseKey(key);
                const instId = parsed.instance === DEFAULT_INSTANCE_ID ? undefined : parsed.instance;
                await this.stopOneInstance(callerAlias, connectionAlias, instId);
            }
        }
        // Start fresh
        return this.startOne(callerAlias, connectionAlias, instanceId);
    }
    /**
     * Check if an ingestor exists (running or otherwise) for a caller+connection pair.
     * When instanceId is provided, checks that specific instance.
     * When omitted, checks if any instance exists for that connection.
     */
    has(callerAlias, connectionAlias, instanceId) {
        if (instanceId) {
            return this.ingestors.has(makeKey(callerAlias, connectionAlias, instanceId));
        }
        const prefix = `${callerAlias}:${connectionAlias}:`;
        for (const key of this.ingestors.keys()) {
            if (key.startsWith(prefix))
                return true;
        }
        return false;
    }
    /**
     * Merge caller-level ingestor overrides into a copy of the template config.
     * Override fields replace template values; omitted fields inherit the template defaults.
     */
    static mergeIngestorConfig(templateConfig, overrides) {
        if (!overrides)
            return templateConfig;
        // Deep-copy to avoid mutating the shared template
        const merged = {
            type: templateConfig.type,
            ...(templateConfig.websocket && {
                websocket: { ...templateConfig.websocket },
            }),
            ...(templateConfig.webhook && {
                webhook: { ...templateConfig.webhook },
            }),
            ...(templateConfig.poll && {
                poll: { ...templateConfig.poll },
            }),
        };
        // Apply WebSocket-specific overrides
        if (merged.websocket) {
            const ws = merged.websocket;
            if (overrides.intents !== undefined)
                ws.intents = overrides.intents;
            if (overrides.eventFilter !== undefined)
                ws.eventFilter = overrides.eventFilter;
            if (overrides.guildIds !== undefined)
                ws.guildIds = overrides.guildIds;
            if (overrides.channelIds !== undefined)
                ws.channelIds = overrides.channelIds;
            if (overrides.userIds !== undefined)
                ws.userIds = overrides.userIds;
        }
        // Apply poll-specific overrides
        if (merged.poll) {
            if (overrides.intervalMs !== undefined)
                merged.poll.intervalMs = overrides.intervalMs;
        }
        return merged;
    }
    /**
     * Apply instance-specific params from IngestorOverrides to the effective config and secrets.
     *
     * For each param, checks the connection's listenerConfig fields:
     *   - If the field has `overrideKey`, injects `params[key]` as `secrets[overrideKey]`
     *     (used by poll ingestors for URL template resolution, e.g., REDDIT_SUBREDDIT).
     *   - If the field has `instanceKey` and the config is a webhook, attaches
     *     the value to the webhook config for payload discrimination (e.g., _boardId for Trello).
     *   - Otherwise, attaches the value as a generic `_instanceParams` bag on the config.
     */
    static applyInstanceParams(config, secrets, params, fields) {
        const fieldsByKey = new Map(fields.map((f) => [f.key, f]));
        for (const [paramKey, paramValue] of Object.entries(params)) {
            const field = fieldsByKey.get(paramKey);
            if (!field)
                continue;
            // overrideKey: inject as a secret for ${VAR} placeholder resolution
            if (field.overrideKey && typeof paramValue === 'string') {
                secrets[field.overrideKey] = paramValue;
            }
            // instanceKey on webhook config: attach for payload discrimination
            // Also inject into secrets for lifecycle ${VAR} template resolution
            if (field.instanceKey && config.webhook) {
                // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-explicit-any
                config.webhook[`_${paramKey}`] = paramValue;
                if (typeof paramValue === 'string') {
                    secrets[paramKey] = paramValue;
                }
                else if (Array.isArray(paramValue) &&
                    paramValue.length > 0 &&
                    typeof paramValue[0] === 'string') {
                    // For text[] instanceKey fields, inject the first element into secrets
                    // for lifecycle URL ${VAR} resolution (e.g., repoFilter → "owner/repo")
                    secrets[paramKey] = paramValue[0];
                }
            }
        }
    }
}
// Export key helpers for testing
export { makeKey, parseKey, DEFAULT_INSTANCE_ID };
//# sourceMappingURL=manager.js.map