import { logActivityStart, logActivityComplete, logActivityFail, } from '../services/activity-service.js';
import { createPipelineTasksFromYaml, markAllTasksDone, markAllTasksFailed, markTaskExecutingByPosition, } from '../services/pipeline-task-service.js';
import path from 'node:path';
import fs from 'node:fs';
import YAML from 'yaml';
const RETRY_BASE_MS = 30_000;
const HANDOFF_MAX_CHARS = 2000;
const DEFAULT_CHAIN_MAX_TURNS = 25;
const RETRY_MAX_MS = 300_000;
const SUMMARY_TIMEOUT_MS = 30_000;
const SUMMARY_INPUT_CHARS = 1000;
function getRetryDelay(attempt) {
    return Math.min(RETRY_BASE_MS * Math.pow(2, attempt - 1), RETRY_MAX_MS);
}
export class JobRunner {
    db;
    bridge;
    broadcast;
    broadcastToAll;
    squadsDir;
    runningSquads = new Set();
    squadQueues = new Map();
    summaryCounter = 0;
    constructor(config) {
        this.db = config.db;
        this.bridge = config.bridge;
        this.broadcast = config.broadcast;
        this.broadcastToAll = config.broadcastToAll;
        this.squadsDir = config.squadsDir;
        this.cleanupStaleRunningStates();
    }
    /**
     * On server startup, any squad state.json left in status "running" from a previous
     * server process is stale — the executor is dead. Mark them as "interrupted" so the
     * UI doesn't show phantom running pipelines.
     * Checks both <squad>/state.json and <squad>/_state/state.json patterns.
     */
    cleanupStaleRunningStates() {
        try {
            const entries = fs.readdirSync(this.squadsDir, { withFileTypes: true });
            for (const entry of entries) {
                if (!entry.isDirectory())
                    continue;
                const statePatterns = [
                    path.join(this.squadsDir, entry.name, 'state.json'),
                    path.join(this.squadsDir, entry.name, '_state', 'state.json'),
                ];
                for (const statePath of statePatterns) {
                    if (!fs.existsSync(statePath))
                        continue;
                    try {
                        const raw = fs.readFileSync(statePath, 'utf-8');
                        const state = JSON.parse(raw);
                        if (state.status !== 'running')
                            continue;
                        state.status = 'interrupted';
                        state.interruptedAt = new Date().toISOString();
                        state.interruptReason = 'Server restarted — execution orphaned';
                        fs.writeFileSync(statePath, JSON.stringify(state, null, 2));
                        console.log(`[startup] Marked stale "running" state as "interrupted": ${entry.name}`);
                    }
                    catch {
                        // ignore parse errors on individual files
                    }
                }
            }
        }
        catch {
            // squadsDir doesn't exist yet — ignore
        }
    }
    async run(opts) {
        if (this.runningSquads.has(opts.squadName)) {
            const queue = this.squadQueues.get(opts.squadName) ?? [];
            queue.push(opts);
            this.squadQueues.set(opts.squadName, queue);
            console.log(`[queue] "${opts.squadName}" is busy — queued (position ${queue.length})`);
            this.broadcast(opts.squadName, {
                type: 'schedule:queued',
                squadName: opts.squadName,
                queuePosition: queue.length,
            });
            return 'queued';
        }
        this.runningSquads.add(opts.squadName);
        const activity = logActivityStart(this.db, {
            squadCode: opts.squadName,
            userMessage: opts.prompt,
            triggeredBy: 'schedule',
        });
        this.db.prepare('UPDATE squad_activities SET attempt = ? WHERE id = ?')
            .run(opts.attempt, activity.id);
        this.broadcast(opts.squadName, {
            type: 'schedule:started',
            squadName: opts.squadName,
            jobId: activity.id,
            attempt: opts.attempt,
        });
        // Create pipeline tasks from squad.yaml — mark first step as executing
        const pipelineTasks = createPipelineTasksFromYaml(this.db, this.squadsDir, opts.squadName, activity.id, 'schedule');
        if (pipelineTasks.length > 0) {
            markTaskExecutingByPosition(this.db, { activityId: activity.id, position: 0 });
            this.broadcastToAll({ type: 'kanban:task_updated', activityId: activity.id, squadName: opts.squadName });
        }
        return new Promise((resolve) => {
            const key = `schedule:${opts.squadName}`;
            this.bridge.send({
                prompt: opts.prompt,
                cwd: path.join(this.squadsDir, opts.squadName),
                squadName: key,
                maxTurns: opts.maxTurns,
                onChunk: (text) => {
                    this.broadcast(opts.squadName, {
                        type: 'schedule:chunk',
                        squadName: opts.squadName,
                        jobId: activity.id,
                        delta: text,
                    });
                },
                onDone: async (fullText) => {
                    this.runningSquads.delete(opts.squadName);
                    this.processNextInQueue(opts.squadName);
                    logActivityComplete(this.db, activity.id, fullText.slice(0, 2000));
                    // Mark pipeline tasks done with output summary
                    if (pipelineTasks.length > 0) {
                        markAllTasksDone(this.db, { activityId: activity.id, result: fullText.slice(0, 2000) });
                        this.broadcastToAll({ type: 'kanban:task_updated', activityId: activity.id, status: 'done' });
                    }
                    this.broadcast(opts.squadName, {
                        type: 'schedule:done',
                        squadName: opts.squadName,
                        jobId: activity.id,
                        summary: fullText.slice(0, 2000),
                        attempt: opts.attempt,
                    });
                    this.db.prepare("UPDATE scheduled_jobs SET last_run_at = datetime('now'), last_result = 'success', updated_at = datetime('now') WHERE squad_name = ?").run(opts.squadName);
                    // Auto-trigger chained squads
                    try {
                        const chainTargets = this.readChainConfig(opts.squadName);
                        if (chainTargets.length > 0) {
                            // Generate intelligent L1 summary once for all chain targets
                            const l1Summary = await this.generateSummary(opts.squadName, fullText);
                            for (const ct of chainTargets) {
                                const targetDir = path.join(this.squadsDir, ct.squadName);
                                if (!fs.existsSync(path.join(targetDir, 'squad.yaml'))) {
                                    console.error(`[chain] Target squad "${ct.squadName}" not found, skipping`);
                                    continue;
                                }
                                // Save truncated summary to handoff file for reference
                                const outputDir = path.join(this.squadsDir, opts.squadName, '_output');
                                fs.mkdirSync(outputDir, { recursive: true });
                                const handoffFile = path.join(outputDir, `handoff-to-${ct.squadName}.md`);
                                const timestamp = new Date().toISOString();
                                const truncatedOutput = fullText.length > HANDOFF_MAX_CHARS
                                    ? fullText.slice(-HANDOFF_MAX_CHARS)
                                    : fullText;
                                fs.writeFileSync(handoffFile, `# Handoff: ${opts.squadName} → ${ct.squadName}\n\n**Data:** ${timestamp}\n\n---\n\n${truncatedOutput}`, 'utf-8');
                                this.broadcast(opts.squadName, {
                                    type: 'chain:triggered',
                                    sourceSquad: opts.squadName,
                                    targetSquad: ct.squadName,
                                    handoffFile,
                                });
                                // L0 (description) + L1 (intelligent summary or fallback) + L2 (file reference)
                                const desc = ct.description
                                    ? ct.description
                                    : `Continuação automática da squad "${opts.squadName}"`;
                                const summary = l1Summary ?? (fullText.length > 500 ? fullText.slice(-500) : fullText);
                                const chainPrompt = `${desc}\n\nResumo da squad anterior:\n${summary}\n\nContexto completo disponível em: ${handoffFile}`;
                                this.run({
                                    squadName: ct.squadName,
                                    prompt: chainPrompt,
                                    timeoutMinutes: opts.timeoutMinutes,
                                    retryOnFailure: 0,
                                    attempt: 1,
                                    maxTurns: ct.maxTurns ?? DEFAULT_CHAIN_MAX_TURNS,
                                });
                            }
                        }
                    }
                    catch (chainErr) {
                        console.error(`[chain] Error processing chain for "${opts.squadName}":`, chainErr);
                    }
                    this.scheduleRetryIfNeeded(opts.squadName, opts);
                    resolve('completed');
                },
                onError: (error) => {
                    this.runningSquads.delete(opts.squadName);
                    this.processNextInQueue(opts.squadName);
                    logActivityFail(this.db, activity.id, error.message);
                    // Mark pipeline tasks failed
                    if (pipelineTasks.length > 0) {
                        markAllTasksFailed(this.db, { activityId: activity.id, error: error.message });
                        this.broadcastToAll({ type: 'kanban:task_updated', activityId: activity.id, status: 'failed' });
                    }
                    const willRetry = opts.attempt < opts.retryOnFailure + 1;
                    this.broadcast(opts.squadName, {
                        type: 'schedule:error',
                        squadName: opts.squadName,
                        jobId: activity.id,
                        error: error.message,
                        attempt: opts.attempt,
                        willRetry,
                    });
                    this.db.prepare("UPDATE scheduled_jobs SET last_run_at = datetime('now'), last_result = 'failure', updated_at = datetime('now') WHERE squad_name = ?").run(opts.squadName);
                    if (willRetry) {
                        const delay = getRetryDelay(opts.attempt);
                        setTimeout(() => {
                            this.run({ ...opts, attempt: opts.attempt + 1 });
                        }, delay);
                    }
                    resolve('failed');
                },
            });
        });
    }
    /**
     * Generate an intelligent L1 summary via a micro-call to Claude.
     * Returns null on failure (timeout, error) — caller should fallback to truncation.
     */
    generateSummary(squadName, output) {
        return new Promise((resolve) => {
            const input = output.length > SUMMARY_INPUT_CHARS
                ? output.slice(-SUMMARY_INPUT_CHARS)
                : output;
            const summaryKey = `summary:${squadName}:${++this.summaryCounter}`;
            console.log(`[chain] Generating L1 summary for "${squadName}" (${input.length} chars)`);
            const timer = setTimeout(() => {
                this.bridge.kill(summaryKey);
                console.log(`[chain] L1 summary timed out for "${squadName}", using fallback`);
                resolve(null);
            }, SUMMARY_TIMEOUT_MS);
            this.bridge.send({
                prompt: `Resuma em 3 bullets curtos e objetivos o resultado abaixo. Responda APENAS os bullets, nada mais:\n\n${input}`,
                cwd: path.join(this.squadsDir, squadName),
                squadName: summaryKey,
                maxTurns: 1,
                onChunk: () => { },
                onDone: (text) => {
                    clearTimeout(timer);
                    console.log(`[chain] L1 summary ready for "${squadName}" (${text.length} chars)`);
                    resolve(text.trim() || null);
                },
                onError: () => {
                    clearTimeout(timer);
                    console.log(`[chain] L1 summary failed for "${squadName}", using fallback`);
                    resolve(null);
                },
            });
        });
    }
    /**
     * Read chain targets from squad.yaml.
     * Supports both formats:
     *   chain: "squad-name"
     *   chain: ["squad-a", "squad-b"]
     *   chain: [{ target_squad: "squad-a", trigger: "on_complete", description: "..." }]
     */
    readChainConfig(squadName) {
        try {
            const yamlPath = path.join(this.squadsDir, squadName, 'squad.yaml');
            if (!fs.existsSync(yamlPath))
                return [];
            const raw = fs.readFileSync(yamlPath, 'utf-8');
            const parsed = YAML.parse(raw);
            const chain = parsed?.squad?.chain;
            if (!chain)
                return [];
            if (typeof chain === 'string')
                return [{ squadName: chain }];
            if (Array.isArray(chain)) {
                return chain
                    .map((c) => {
                    if (typeof c === 'string')
                        return { squadName: c };
                    if (c && typeof c === 'object' && typeof c.target_squad === 'string') {
                        return {
                            squadName: c.target_squad,
                            description: typeof c.description === 'string' ? c.description : undefined,
                            maxTurns: typeof c.max_turns === 'number' ? c.max_turns : undefined,
                        };
                    }
                    return null;
                })
                    .filter((c) => c !== null);
            }
            return [];
        }
        catch {
            return [];
        }
    }
    processNextInQueue(squadName) {
        const queue = this.squadQueues.get(squadName);
        if (!queue || queue.length === 0)
            return;
        const next = queue.shift();
        if (queue.length === 0)
            this.squadQueues.delete(squadName);
        const delayMs = this.getSpacingDelayMs(squadName);
        const logSuffix = delayMs > 0 ? `spacing window: waiting ${Math.ceil(delayMs / 1000)}s` : 'running immediately';
        console.log(`[queue] "${squadName}" dequeuing (${logSuffix}, ${queue.length} remaining)`);
        this.broadcast(squadName, {
            type: 'schedule:dequeued',
            squadName,
            queueRemaining: queue.length,
            retryInMs: delayMs,
        });
        setTimeout(() => this.run(next), delayMs);
    }
    /**
     * Read last-published.json and return how many ms remain before the spacing window opens.
     * Returns 0 if the window is already open or the file doesn't exist.
     */
    getSpacingDelayMs(squadName) {
        const SPACING_MS = 10 * 60_000;
        try {
            const lastPublishedPath = path.join(this.squadsDir, squadName, '_state', 'last-published.json');
            if (!fs.existsSync(lastPublishedPath))
                return 0;
            const data = JSON.parse(fs.readFileSync(lastPublishedPath, 'utf-8'));
            if (typeof data.published_at !== 'string')
                return 0;
            const elapsedMs = Date.now() - new Date(data.published_at).getTime();
            return Math.max(0, SPACING_MS - elapsedMs);
        }
        catch {
            return 0;
        }
    }
    /**
     * After a run completes, check if the agent wrote _state/retry-at.json requesting a
     * scheduled one-shot retry (e.g. because the spacing window hadn't opened yet).
     * Consumes the file immediately to prevent duplicate scheduling.
     */
    scheduleRetryIfNeeded(squadName, opts) {
        const retryPath = path.join(this.squadsDir, squadName, '_state', 'retry-at.json');
        try {
            if (!fs.existsSync(retryPath))
                return;
            const data = JSON.parse(fs.readFileSync(retryPath, 'utf-8'));
            fs.unlinkSync(retryPath); // consume — prevent duplicate scheduling
            if (typeof data.retry_at !== 'string')
                return;
            const delayMs = Math.max(0, new Date(data.retry_at).getTime() - Date.now());
            const reason = typeof data.reason === 'string' ? data.reason : 'spacing';
            const retryPrompt = typeof data.prompt === 'string'
                ? data.prompt
                : 'Verificar fila pendente em _queue/pending.yaml e publicar próximo conteúdo agendado.';
            console.log(`[retry] "${squadName}" scheduled retry in ${Math.ceil(delayMs / 1000)}s — reason: ${reason}`);
            this.broadcast(squadName, {
                type: 'schedule:retry_scheduled',
                squadName,
                retryInMs: delayMs,
                reason,
            });
            setTimeout(() => {
                this.run({
                    squadName,
                    prompt: retryPrompt,
                    timeoutMinutes: opts.timeoutMinutes,
                    retryOnFailure: 0,
                    attempt: 1,
                    maxTurns: opts.maxTurns,
                });
            }, delayMs);
        }
        catch {
            // ignore — retry scheduling is best-effort
        }
    }
    isRunning(squadName) {
        return this.runningSquads.has(squadName);
    }
    stopAll() {
        for (const squadName of this.runningSquads) {
            this.bridge.kill(`schedule:${squadName}`);
        }
        this.runningSquads.clear();
    }
}
//# sourceMappingURL=job-runner.js.map