/**
 * Workflow execution engine.
 *
 * Loads a workflow's structured steps, resolves their dependency order,
 * executes each step, passes outputs between steps via named template
 * variables, and records the entire run as WorkflowRun + StepResult nodes.
 *
 * Two step types are supported:
 *   type: "tool"  — spawns a plugin MCP server child process
 *   type: "llm"   — spawns Claude Code subprocess (handles its own OAuth)
 *
 *   LOAD WORKFLOW + STEPS
 *     │
 *     v
 *   PRE-FLIGHT LLM GATE (Task 74)
 *     │   Only runs if the workflow has ≥1 llm step.
 *     ├─ probeLlmHealth("pong") ok → llmAvailable = true
 *     └─ probe fails → llmAvailable = false, probeError captured
 *     │
 *     v
 *   CREATE WorkflowRun (status: running)
 *     │
 *     v
 *   RESOLVE ORDER (topological sort)
 *     │
 *     v
 *   FOR EACH STEP:
 *     ├─ type: "tool"
 *     │    resolve {{templates}} in params → spawn MCP server → call tool
 *     ├─ type: "llm" + llm available
 *     │    resolve {{templates}} in prompt → spawn Claude Code → capture text
 *     │    └─ spawn fails + fallbackMode "raw" → render fallback (degraded)
 *     ├─ type: "llm" + llm NOT available
 *     │    ├─ fallbackMode "raw"  → render fallback (degraded), continue
 *     │    └─ fallbackMode "fail" → onFailure policy, retry short-circuited
 *     ├─ parse output (JSON or _raw fallback, strips markdown fences)
 *     ├─ store output under step's outputKey
 *     ├─ create StepResult node (status: completed | degraded | skipped | failed)
 *     └─ on failure: abort | skip | retry (per step config)
 *     │
 *     v
 *   UPDATE WorkflowRun status:
 *     any failed → "failed"
 *     any degraded or skipped → "partial"
 *     else → "completed"
 */
import { randomUUID } from "node:crypto";
import { existsSync } from "node:fs";
import { resolve } from "node:path";
import { getSession } from "../lib/neo4j.js";
import { resolveOrder, resolveTemplates, resolveStringTemplate, parseStepOutput, renderFallbackTemplate, } from "../lib/step-resolver.js";
import { executeLlmCall, probeLlmHealth, readAdminModel } from "../lib/llm-call.js";
// ─────────────────────────────────────────────────────────────
// Constants
// ─────────────────────────────────────────────────────────────
const DEFAULT_TOOL_TIMEOUT_S = 30;
const DEFAULT_LLM_TIMEOUT_S = 60;
const MAX_OUTPUT_SIZE = 10_240; // 10KB cap on stored step output
// ─────────────────────────────────────────────────────────────
// MCP tool dispatch (same pattern as check-due-events.ts)
// ─────────────────────────────────────────────────────────────
async function dispatchStep(plugin, tool, args, stepAccountId, timeoutMs) {
    const platformRoot = process.env.PLATFORM_ROOT;
    const serverPath = resolve(platformRoot, "plugins", plugin, "mcp", "dist", "index.js");
    // Path traversal check at execution time (defense in depth — also checked at creation)
    const pluginsRoot = resolve(platformRoot, "plugins");
    if (!serverPath.startsWith(pluginsRoot + "/")) {
        throw new Error(`Invalid plugin name: "${plugin}" — path traversal blocked`);
    }
    if (!existsSync(serverPath)) {
        throw new Error(`MCP server not found: ${serverPath}`);
    }
    // Dynamic import — MCP SDK Client only needed at dispatch time
    const { Client } = await import("@modelcontextprotocol/sdk/client/index.js");
    const { StdioClientTransport } = await import("@modelcontextprotocol/sdk/client/stdio.js");
    // Inherit parent env + set ACCOUNT_ID for the child process
    const env = {};
    for (const [k, v] of Object.entries(process.env)) {
        if (v !== undefined)
            env[k] = v;
    }
    env.PLATFORM_ROOT = platformRoot;
    env.ACCOUNT_ID = stepAccountId;
    const transport = new StdioClientTransport({
        command: process.execPath,
        args: [serverPath],
        env,
        stderr: "pipe",
    });
    // Capture child stderr for diagnostics
    let childStderr = "";
    const stderrStream = transport.stderr;
    if (stderrStream) {
        stderrStream.on("data", (chunk) => {
            if (childStderr.length < 4096) {
                childStderr += chunk.toString("utf-8").slice(0, 4096 - childStderr.length);
            }
        });
    }
    const client = new Client({ name: "workflow-executor", version: "1.0.0" });
    let timedOut = false;
    const timeout = setTimeout(() => {
        timedOut = true;
        client.close().catch(() => { });
    }, timeoutMs);
    try {
        await client.connect(transport);
        const result = await client.callTool({ name: tool, arguments: args });
        const contentArr = Array.isArray(result.content) ? result.content : [];
        const text = contentArr
            .filter((c) => c.type === "text")
            .map((c) => c.text)
            .join("\n");
        return { text, isError: result.isError === true };
    }
    catch (err) {
        const msg = err instanceof Error ? err.message : String(err);
        const stderrSuffix = childStderr ? `\nChild stderr: ${childStderr.trim()}` : "";
        if (timedOut) {
            throw new Error(`Tool "${tool}" on plugin "${plugin}" timed out after ${timeoutMs}ms${stderrSuffix}`);
        }
        throw new Error(`Tool "${tool}" on plugin "${plugin}" failed: ${msg}${stderrSuffix}`);
    }
    finally {
        clearTimeout(timeout);
        // Always attempt cleanup — including when timed out.
        // The timeout callback's client.close() is fire-and-forget;
        // this ensures we also try from the finally block.
        try {
            await client.close();
        }
        catch { /* best-effort cleanup */ }
    }
}
// ─────────────────────────────────────────────────────────────
// Main execution function
// ─────────────────────────────────────────────────────────────
export async function workflowExecute(params) {
    const { workflowId, accountId, context: inputContext, trigger = "conversational" } = params;
    const platformRoot = process.env.PLATFORM_ROOT;
    if (!platformRoot) {
        throw new Error("PLATFORM_ROOT environment variable is required for execution");
    }
    // ── Load workflow + steps ────────────────────────────────
    const session = getSession();
    let steps;
    let workflowName;
    try {
        const result = await session.run(`MATCH (w:Workflow {workflowId: $workflowId, accountId: $accountId})
       OPTIONAL MATCH (w)-[:HAS_STEP]->(s:WorkflowStep)
       RETURN w, collect(s) AS steps`, { workflowId, accountId });
        if (result.records.length === 0) {
            throw new Error(`Workflow not found: ${workflowId}`);
        }
        const record = result.records[0];
        const w = record.get("w").properties;
        workflowName = w.name;
        const status = w.status;
        if (status !== "active") {
            throw new Error(`Workflow "${workflowName}" cannot be executed — status is "${status}" (must be "active")`);
        }
        const stepsRaw = record.get("steps");
        if (stepsRaw.length === 0) {
            throw new Error(`Workflow "${workflowName}" has no steps`);
        }
        steps = stepsRaw.map((s) => {
            const stepType = s.properties.type;
            const base = {
                stepId: s.properties.stepId,
                dependsOn: s.properties.dependsOn
                    ? JSON.parse(s.properties.dependsOn)
                    : undefined,
                onFailure: s.properties.onFailure ?? "abort",
                retryLimit: s.properties.retryLimit ?? undefined,
                outputKey: s.properties.outputKey ?? undefined,
                timeout: s.properties.timeout ?? undefined,
                position: s.properties.position ?? 0,
                label: s.properties.label,
            };
            if (stepType === "llm") {
                return {
                    ...base,
                    type: "llm",
                    prompt: s.properties.prompt,
                    model: s.properties.model ?? undefined,
                    maxTokens: s.properties.maxTokens ?? undefined,
                    fallbackMode: s.properties.fallbackMode ?? undefined,
                    fallbackTemplate: s.properties.fallbackTemplate ?? undefined,
                };
            }
            return {
                ...base,
                type: "tool",
                plugin: s.properties.plugin,
                tool: s.properties.tool,
                params: s.properties.params
                    ? JSON.parse(s.properties.params)
                    : undefined,
            };
        });
    }
    finally {
        await session.close();
    }
    // ── Resolve execution order ──────────────────────────────
    const ordered = resolveOrder(steps);
    // ── Pre-flight LLM health gate (Task 74) ─────────────────
    // If the workflow contains at least one LLM step, probe once before
    // running any tool steps. A failed probe does NOT abort the workflow —
    // it flips the llmAvailable flag so LLM steps with fallbackMode="raw"
    // emit a degraded output instead of burning tool steps first.
    //
    // KNOWN LIMITATION: only the first LLM step's resolved model is probed.
    // Heterogeneous-model workflows where different LLM steps use different
    // explicit models still get correct per-step behaviour via the
    // spawn-failure convergence arm in the step loop — the only regression
    // is that tool-step work isn't saved upfront for non-first-model steps.
    let llmAvailable = true;
    let probeError;
    const llmStep = ordered.find((s) => s.type === "llm");
    if (llmStep) {
        let probeModel = llmStep.model;
        if (!probeModel) {
            probeModel = readAdminModel(platformRoot, accountId) ?? undefined;
        }
        if (!probeModel) {
            // No resolvable model is itself a form of LLM unavailability —
            // treat as probe failure so fallbackMode="raw" engages and
            // fallbackMode="fail" short-circuits. Otherwise the "no model"
            // throw would escape the inner spawn-catch and bypass the
            // documented degradation guarantee.
            llmAvailable = false;
            probeError =
                "No model configured (step-level override and account adminModel both unset)";
            console.error(`[llm-probe] FAILED: ${probeError}`);
        }
        else {
            const probe = await probeLlmHealth({ model: probeModel });
            llmAvailable = probe.ok;
            if (!probe.ok) {
                probeError = probe.error;
            }
        }
    }
    // ── Create WorkflowRun node ──────────────────────────────
    const runId = randomUUID();
    const startedAt = new Date().toISOString();
    const runSession = getSession();
    try {
        await runSession.run(`CREATE (r:WorkflowRun $props)
       WITH r
       MATCH (w:Workflow {workflowId: $workflowId})
       CREATE (r)-[:RUN_OF]->(w)`, {
            workflowId,
            props: {
                runId,
                workflowId,
                accountId,
                trigger,
                status: "running",
                startedAt,
                context: inputContext ? JSON.stringify(inputContext) : null,
            },
        });
    }
    finally {
        await runSession.close();
    }
    // ── Execute steps ────────────────────────────────────────
    // Context accumulates outputs from prior steps keyed by outputKey
    const outputContext = {};
    // Track which keys in outputContext came from seeded inputContext vs.
    // from step outputs. The fallback renderer excludes seeded input keys
    // to avoid leaking potentially-sensitive workflow invocation context
    // (credentials, tokens, trigger metadata) into a delivered degraded
    // output. Only step outputs are dumped into the fallback template's
    // {{_priorOutputs}} substitution.
    const seededInputKeys = new Set();
    // Seed context with any input variables
    if (inputContext) {
        for (const [k, v] of Object.entries(inputContext)) {
            if (typeof v === "object" && v !== null && !Array.isArray(v)) {
                outputContext[k] = v;
            }
            else {
                outputContext[k] = { _raw: String(v) };
            }
            seededInputKeys.add(k);
        }
    }
    let stepsCompleted = 0;
    let stepsSkipped = 0;
    let stepsFailed = 0;
    let stepsDegraded = 0;
    let lastError;
    let aborted = false;
    for (const step of ordered) {
        if (aborted)
            break;
        const stepStartedAt = new Date().toISOString();
        let attempt = 0;
        // retryLimit means "how many retries after the initial attempt"
        // retryLimit=2 → 1 initial + 2 retries = 3 total attempts
        let maxAttempts = step.onFailure === "retry" ? 1 + (step.retryLimit ?? 1) : 1;
        let stepSuccess = false;
        let stepOutput = "";
        let stepError = "";
        let stepDegraded = false;
        const stepLabel = step.type === "tool"
            ? `${step.label} → ${step.plugin}/${step.tool}`
            : `${step.label} → llm${step.model ? ` (${step.model})` : ""}`;
        console.error(`[workflow-execute] step ${step.position + 1}/${ordered.length}: ${stepLabel}`);
        // ── Pre-flight degradation shortcut (Task 74) ──────────
        // If the pre-flight probe failed and this is an LLM step with
        // fallbackMode="raw", skip the spawn entirely and render a
        // degraded output. For fallbackMode="fail" we short-circuit
        // retries since we already know the call will fail.
        if (step.type === "llm" && !llmAvailable) {
            if (step.fallbackMode === "raw") {
                stepOutput = renderFallbackTemplate({
                    template: step.fallbackTemplate,
                    outputContext,
                    error: probeError ?? "LLM unavailable",
                    excludeFromPriorOutputs: seededInputKeys,
                });
                stepDegraded = true;
                stepSuccess = true;
                // attempt = 1 reflects the implicit attempt made before short-
                // circuiting — keeps StepResult.attempt semantically meaningful
                // instead of the misleading 0 that "never tried" would imply.
                attempt = 1;
                if (step.outputKey) {
                    outputContext[step.outputKey] = { _raw: stepOutput };
                }
                console.error(`[workflow-execute] step ${step.position + 1} DEGRADED: ${probeError} (fallbackMode=raw, pre-flight)`);
            }
            else {
                // fallbackMode "fail" (default) — short-circuit retries
                stepError = `LLM unavailable: ${probeError ?? "unknown"}`;
                maxAttempts = 0;
                attempt = 1;
                if (step.retryLimit !== undefined && step.onFailure === "retry") {
                    console.error(`[workflow-execute] step ${step.position + 1} retry short-circuited — probe already confirmed LLM unavailable`);
                }
            }
        }
        while (attempt < maxAttempts && !stepSuccess) {
            attempt++;
            try {
                if (step.type === "tool") {
                    // ── Tool step: resolve params, spawn MCP child process ──
                    const resolvedParams = step.params
                        ? resolveTemplates(step.params, outputContext)
                        : {};
                    const stepTimeoutMs = (step.timeout ?? DEFAULT_TOOL_TIMEOUT_S) * 1000;
                    const result = await dispatchStep(step.plugin, step.tool, resolvedParams, accountId, stepTimeoutMs);
                    if (result.isError) {
                        throw new Error(`Tool returned error: ${result.text}`);
                    }
                    stepOutput = result.text;
                }
                else {
                    // ── LLM step: resolve prompt, spawn Claude Code ──
                    const resolvedPrompt = resolveStringTemplate(step.prompt, outputContext);
                    if (typeof resolvedPrompt !== "string") {
                        throw new Error("LLM prompt resolved to a non-string value — prompt templates must resolve to text");
                    }
                    // Resolve model: step-level override → account adminModel → throw
                    let model = step.model;
                    if (!model) {
                        model = readAdminModel(platformRoot, accountId) ?? undefined;
                    }
                    if (!model) {
                        throw new Error("No model specified on LLM step and account adminModel is not configured. " +
                            "Either set a model on the step or configure adminModel in account settings.");
                    }
                    const stepTimeoutMs = (step.timeout ?? DEFAULT_LLM_TIMEOUT_S) * 1000;
                    try {
                        stepOutput = await executeLlmCall({
                            prompt: resolvedPrompt,
                            model,
                            maxTokens: step.maxTokens,
                            timeoutMs: stepTimeoutMs,
                        });
                    }
                    catch (spawnErr) {
                        // Spawn-time failure convergence — probe passed but the
                        // real call failed (rate limit, network partition,
                        // mid-run token expiry). fallbackMode="raw" degrades.
                        if (step.fallbackMode === "raw") {
                            const errSummary = spawnErr instanceof Error ? spawnErr.message : String(spawnErr);
                            const oneLine = errSummary.replace(/\s*\n\s*/g, " — ");
                            stepOutput = renderFallbackTemplate({
                                template: step.fallbackTemplate,
                                outputContext,
                                error: oneLine,
                                excludeFromPriorOutputs: seededInputKeys,
                            });
                            stepDegraded = true;
                            console.error(`[workflow-execute] step ${step.position + 1} DEGRADED: ${oneLine} (fallbackMode=raw, spawn)`);
                        }
                        else {
                            throw spawnErr;
                        }
                    }
                }
                stepSuccess = true;
                // Store output under outputKey for subsequent steps.
                // Degraded LLM steps bypass parseStepOutput and are stored
                // verbatim as {_raw: ...} so downstream {{step._raw}} refs
                // consume the fallback text directly.
                if (step.outputKey) {
                    if (stepDegraded) {
                        outputContext[step.outputKey] = { _raw: stepOutput };
                    }
                    else {
                        outputContext[step.outputKey] = parseStepOutput(stepOutput);
                    }
                }
                console.error(`[workflow-execute] step ${step.position + 1} ${stepDegraded ? "degraded" : "completed"} in attempt ${attempt}`);
            }
            catch (err) {
                stepError = err instanceof Error ? err.message : String(err);
                console.error(`[workflow-execute] step ${step.position + 1} FAILED (attempt ${attempt}/${maxAttempts}): ${stepError}`);
            }
        }
        // Record StepResult
        const stepCompletedAt = new Date().toISOString();
        const stepStatus = stepDegraded
            ? "degraded"
            : stepSuccess
                ? "completed"
                : step.onFailure === "skip"
                    ? "skipped"
                    : "failed";
        // Truncate output for storage
        const storedOutput = stepOutput.length > MAX_OUTPUT_SIZE
            ? stepOutput.slice(0, MAX_OUTPUT_SIZE) + "\n[truncated]"
            : stepOutput;
        const resultSession = getSession();
        try {
            await resultSession.run(`MATCH (r:WorkflowRun {runId: $runId})
         CREATE (r)-[:HAS_RESULT]->(sr:StepResult $props)`, {
                runId,
                props: {
                    stepId: step.stepId,
                    runId,
                    status: stepStatus,
                    output: storedOutput || null,
                    error: stepError || null,
                    startedAt: stepStartedAt,
                    completedAt: stepCompletedAt,
                    attempt,
                },
            });
        }
        finally {
            await resultSession.close();
        }
        // Update counters and handle failure policy
        if (stepDegraded) {
            stepsDegraded++;
        }
        else if (stepSuccess) {
            stepsCompleted++;
        }
        else if (step.onFailure === "skip") {
            stepsSkipped++;
            console.error(`[workflow-execute] step ${step.position + 1} skipped per onFailure policy`);
        }
        else {
            stepsFailed++;
            lastError = stepError;
            aborted = true;
            console.error(`[workflow-execute] step ${step.position + 1} failed — aborting workflow`);
        }
    }
    // ── Determine final run status ───────────────────────────
    let runStatus;
    if (stepsFailed > 0) {
        runStatus = "failed";
    }
    else if (stepsDegraded > 0 || stepsSkipped > 0) {
        runStatus = "partial";
    }
    else {
        runStatus = "completed";
    }
    // ── Update WorkflowRun ──────────────────────────────────
    const completedAt = new Date().toISOString();
    const finalSession = getSession();
    try {
        await finalSession.run(`MATCH (r:WorkflowRun {runId: $runId})
       SET r.status = $status,
           r.completedAt = $completedAt,
           r.error = $error`, {
            runId,
            status: runStatus,
            completedAt,
            error: lastError ?? null,
        });
    }
    finally {
        await finalSession.close();
    }
    const duration = new Date(completedAt).getTime() - new Date(startedAt).getTime();
    console.error(`[workflow-execute] run complete: ${runStatus}, ${duration}ms, ` +
        `${stepsCompleted} completed, ${stepsDegraded} degraded, ${stepsSkipped} skipped, ${stepsFailed} failed`);
    return {
        runId,
        status: runStatus,
        stepsCompleted,
        stepsDegraded,
        stepsSkipped,
        stepsFailed,
        stepsTotal: ordered.length,
        error: lastError,
    };
}
//# sourceMappingURL=workflow-execute.js.map