/**
 * VL Workflow Executor – bridges VL-Workflow-Engine with VL-Code services
 *
 * Uses the isolated VL-Workflow-Engine vendor copy under .codex/vendor as the
 * core execution engine, with VL-Code-specific adapters for:
 *   - LLM calls (via createProvider)
 *   - Direct VLCode tool registry calls via Tool_* steps
 *   - File I/O (with section-merge for VL files)
 *   - DocCenter document resolution
 *   - MetaDiff custom step handler
 *   - Checkpoint/self-healing
 *   - Pause/resume & abort
 */
import { createRequire } from 'module';
import { createProvider } from '../core/llm-provider.js';
import fs from 'fs/promises';
import path from 'path';
import { computeMetaDiff } from './meta-diff.js';
import { smartMerge } from './section-diff.js';
import { docIdFor, docIdForPath, DOCCENTER_API_URL } from '../data/doc-paths.js';
import { DEFAULT_THEME_CONTENT } from '../data/default-theme.js';
import { WORKFLOW_ENGINE_VENDOR_ENTRY } from './workflow-engine-vendor.js';

const require = createRequire(import.meta.url);
const {
  Engine, RunEventType, ExpressionEvaluator, applyOutputMapping,
} = require(WORKFLOW_ENGINE_VENDOR_ENTRY);

function normalizeCookieHeader(cookie) {
  const value = String(cookie || '').trim();
  if (!value) return '';
  return value.startsWith('ih5bearer=') ? value : `ih5bearer=${value}`;
}

export class WorkflowExecutor {
  constructor(config) {
    this.config = config;
    this.client = createProvider(config);
    this.filesWritten = [];

    // Optional: SymbolIndex for MetaDiff cascade impact
    this.symbolIndex = null;

    // Self-healing: checkpoint support
    this.checkpoints = {};
    this.checkpointEnabled = false;
    this._checkpointFile = null;
    this._workflowName = 'workflow';

    // Engine checkpoint (v0.3+): full serializable state for resume/rerun
    this._latestCheckpoint = null;
    this._runID = null;

    // Section-merge: only active for incremental workflows
    this.sectionMergeEnabled = false;

    // Engine references for pause/resume/abort
    this._engine = null;
    this._ctx = null;
    this._aborted = false;

    // Node timing tracker
    this._nodeStartTimes = {};
  }

  /** Get the active execution context (works during pause when this._ctx isn't set yet) */
  _getCtx() { return this._ctx || this._engine?.activeCtx || null; }

  /** Abort the running workflow */
  abort() {
    this._aborted = true;
    const ctx = this._getCtx();
    if (ctx) ctx.abort();
  }

  /** Get latest engine checkpoint (for resume/rerun) */
  getCheckpoint() { return this._latestCheckpoint; }

  /** Get current run ID */
  getRunID() { return this._runID; }

  /**
   * Re-run workflow from a specific step using a checkpoint.
   * @param {Object} workflow - VL Workflow Spec JSON
   * @param {Object} checkpoint - WorkflowCheckpoint from getCheckpoint()
   * @param {Object} overrides - Variable overrides { '$varName': value }
   * @param {Object} adapters - Adapter map (llm, file, doc, etc.)
   * @param {Object} callbacks - Event callbacks
   */
  async executeFrom(workflow, checkpoint, overrides = {}, callbacks = {}) {
    const cb = callbacks;
    this.filesWritten = Array.isArray(checkpoint.artifacts)
      ? [...checkpoint.artifacts]
      : checkpoint.artifacts && typeof checkpoint.artifacts === 'object'
        ? Object.values(checkpoint.artifacts)
        : [];
    this._workflowName = workflow.name || 'workflow';
    this._aborted = false;
    this._warnCallback = (msg) => cb.onText?.(msg);

    const docs = { ...(workflow.registry?.docs || {}) };
    await this._resolveDocCenterDocs(docs, cb);

    const adapters = {
      llm: this._buildLLMAdapter(),
      file: this._buildFileAdapter(),
      doc: this._buildDocAdapter(docs),
      service: { call: async () => ({ success: true, errors: [], warnings: [], stats: {} }) },
      api: { call: async () => ({}) },
      component: this._buildComponentAdapter(cb),
    };

    const customHandlers = {
      Tool: (engine, ctx, step) => this._executeTool(engine, ctx, step, cb),
      Subflow: (engine, ctx, step) => this._executeSubflow(engine, ctx, step, cb),
      MetaDiff: (engine, ctx, step) => this._executeMetaDiff(engine, ctx, step, cb),
      ComponentFetch: (engine, ctx, step) => this._executeComponentFetch(engine, ctx, step, cb),
      ClearFiles: (engine, ctx, step) => this._executeClearFiles(engine, ctx, step, cb),
      SeedTheme: (engine, ctx, step) => this._executeSeedTheme(engine, ctx, step, cb),
    };

    this._engine = new Engine(workflow, {
      customHandlers,
      errorStrategy: 'collectAll',
      onEvent: (event) => this._dispatchEvent(event, cb),
      onCheckpoint: (cp) => {
        this._latestCheckpoint = cp;
        cb.onCheckpoint?.(cp);
      },
    });

    try {
      this._ctx = await this._engine.executeFrom(checkpoint, adapters, overrides);
      cb.onDone?.({ filesWritten: this.filesWritten, stageCount: workflow.steps?.length || 0, resumedFrom: checkpoint.currentStepID });
    } catch (err) {
      cb.onError?.(err.message);
    }
  }

  /**
   * Execute a workflow JSON
   * @param {Object} workflow - VL Workflow Spec 3.13+ JSON
   * @param {Object} params - Input parameters (e.g. { userRequest, targetLang })
   * @param {Object} callbacks - { onNodeStart, onNodeDone, onNodeError, onFileWritten, onToken, onDone, onError, ... }
   */
  async execute(workflow, params = {}, callbacks = {}) {
    const cb = callbacks;
    this.filesWritten = [];
    this._workflowName = workflow.name || 'workflow';
    this._aborted = false;

    // Wire warning callback so LLM adapter can send warnings to the user
    this._warnCallback = (msg) => cb.onText?.(msg);

    // Enable section-merge for incremental-update workflows
    this.sectionMergeEnabled = (workflow.name === 'IncrementalUpdate' || !!workflow.sectionMerge);

    // Resolve DocCenter docs (fetch actual content by path)
    const docs = { ...(workflow.registry?.docs || {}) };
    await this._resolveDocCenterDocs(docs, cb);

    // Checkpoint: load saved state if resuming
    let checkpointVars = {};
    if (this.checkpointEnabled) {
      this._checkpointFile = path.join(
        this.config.workDir, '.vl-code', 'workflow-checkpoints',
        `${this._workflowName.replace(/\W+/g, '_')}.json`
      );
      try {
        const saved = JSON.parse(await fs.readFile(this._checkpointFile, 'utf-8'));
        if (saved.vars) checkpointVars = saved.vars;
        if (saved.filesWritten) this.filesWritten = saved.filesWritten;
        if (saved.checkpoints) this.checkpoints = saved.checkpoints;
      } catch { /* no checkpoint file */ }
    }

    // Build adapters
    const adapters = {
      llm: this._buildLLMAdapter(),
      file: this._buildFileAdapter(),
      doc: this._buildDocAdapter(docs),
      service: { call: async () => ({ success: true, errors: [], warnings: [], stats: {} }) },
      api: { call: async () => ({}) },
      component: this._buildComponentAdapter(cb),
    };

    // Build custom step handlers
    const customHandlers = {
      Tool: (engine, ctx, step) => this._executeTool(engine, ctx, step, cb),
      Subflow: (engine, ctx, step) => this._executeSubflow(engine, ctx, step, cb),
      MetaDiff: (engine, ctx, step) => this._executeMetaDiff(engine, ctx, step, cb),
      ComponentFetch: (engine, ctx, step) => this._executeComponentFetch(engine, ctx, step, cb),
      ClearFiles: (engine, ctx, step) => this._executeClearFiles(engine, ctx, step, cb),
      SeedTheme: (engine, ctx, step) => this._executeSeedTheme(engine, ctx, step, cb),
    };

    // Create engine with event listener + checkpoint persistence
    // Use collectAll strategy so one failed branch doesn't abort siblings
    this._engine = new Engine(workflow, {
      customHandlers,
      errorStrategy: 'collectAll',
      onEvent: (event) => this._dispatchEvent(event, cb),
      onCheckpoint: (cp) => {
        this._latestCheckpoint = cp;
        this._runID = cp.workflowID;
        cb.onCheckpoint?.(cp);
      },
    });

    // Merge checkpoint vars with incoming params
    const initialVars = { ...params, ...checkpointVars };

    try {
      this._ctx = await this._engine.execute(initialVars, adapters);

      // Clear checkpoint on successful completion
      if (this.checkpointEnabled && this._checkpointFile) {
        try { await fs.unlink(this._checkpointFile); } catch {}
      }

      cb.onDone?.({ filesWritten: this.filesWritten, stageCount: workflow.steps?.length || 0 });
    } catch (err) {
      cb.onError?.(err.message);
    }
  }

  /**
   * Resume a paused workflow at a specific Pause node.
   */
  resume(nodeId, payload) {
    // Use _getCtx() which falls back to engine.activeCtx during pause
    const ctx = this._getCtx();
    if (!this._engine || !ctx?.pauseState) return false;
    try {
      this._engine.resume(ctx, {
        token: ctx.pauseState.token,
        payload,
        requestId: `resume_${Date.now()}`,
      });
      return true;
    } catch {
      return false;
    }
  }

  /** Cancel a paused workflow at a specific Pause node */
  cancelPause(nodeId) {
    const ctx = this._getCtx();
    if (!ctx?.pauseState) return false;
    ctx.abort();
    return true;
  }

  // ─── Event Dispatching ──────────────────────────────────────

  /** Map VL-Workflow-Engine events to VL-Code callbacks */
  _dispatchEvent(event, cb) {
    const payload = event.payload || {};
    const stepId = event.stepID;

    switch (event.type) {
      case RunEventType.StepStart: {
        this._nodeStartTimes[stepId] = Date.now();
        // Extract node input: documents, assignments, config
        const nodeInput = {};
        if (payload.documents) nodeInput.documents = Object.keys(payload.documents);
        if (payload.input) nodeInput.input = this._summarizeObj(payload.input);
        if (payload.config) nodeInput.config = this._summarizeObj(payload.config);
        if (payload.meta?.tokens) nodeInput.tokens = payload.meta.tokens;
        if (payload.meta?.messages) nodeInput.messages = payload.meta.messages;
        // Include relevant variable keys feeding this node
        if (payload.assignments && this._getCtx()) {
          const assignKeys = {};
          for (const [k, v] of Object.entries(payload.assignments)) {
            const resolved = typeof v === 'string' && v.startsWith('$') ? this._summarizeVar(v, this._getCtx()?.variables) : v;
            assignKeys[k] = resolved;
          }
          if (Object.keys(assignKeys).length) nodeInput.assignments = assignKeys;
        }
        // Forward resolvedInputs from Engine v0.3+ (full evaluated inputs)
        const resolvedInputs = payload.resolvedInputs || null;
        cb.onNodeStart?.({
          nodeId: stepId,
          title: payload.meta?.title || stepId,
          type: payload.type || stepId?.split('_')[0] || 'unknown',
          input: Object.keys(nodeInput).length ? nodeInput : undefined,
          resolvedInputs,
        });
        break;
      }

      case RunEventType.StepDone: {
        const duration = this._nodeStartTimes[stepId] ? Date.now() - this._nodeStartTimes[stepId] : 0;
        delete this._nodeStartTimes[stepId];
        // Extract output summary
        const nodeOutput = {};
        if (payload.output) nodeOutput.output = this._summarizeObj(payload.output);
        if (payload.result) nodeOutput.result = this._summarizeObj(payload.result);
        // Forward outputs from Engine v0.3+ (full output mapping results)
        const outputs = payload.outputs || null;
        const selected = payload.selected || null;
        cb.onNodeDone?.({
          nodeId: stepId,
          title: payload.meta?.title || stepId,
          duration_ms: duration,
          output: Object.keys(nodeOutput).length ? nodeOutput : undefined,
          outputs,
          selected,
        });
        // Variable inspector
        if (this._getCtx()) {
          cb.onVarsChanged?.(stepId, sanitizeVarsForInspector(this._getCtx().variables));
        }
        // Normalize ModuleManifest if just set
        if (this._getCtx()?.variables?.['$moduleManifest']) {
          this._normalizeModuleManifest(this._getCtx().variables['$moduleManifest']);
        }
        // Checkpoint
        if (this.checkpointEnabled && stepId) {
          this.checkpoints[stepId] = {
            vars: { ...this._getCtx()?.variables },
            filesWritten: [...this.filesWritten],
            timestamp: Date.now(),
          };
          this._saveCheckpoint();
        }
        break;
      }

      case RunEventType.StepError: {
        const errDuration = this._nodeStartTimes[stepId] ? Date.now() - this._nodeStartTimes[stepId] : 0;
        delete this._nodeStartTimes[stepId];
        cb.onNodeError?.({
          nodeId: stepId,
          title: payload.meta?.title || stepId,
          type: payload.type || stepId?.split('_')[0] || 'unknown',
          error: payload.error || 'Unknown error',
          detail: payload.detail || payload.stack || '',
          duration_ms: errDuration,
        });
        break;
      }

      case RunEventType.StepSkipped:
        cb.onNodeSkipped?.({
          nodeId: stepId,
          reason: payload.condition || 'condition false',
        });
        break;

      case RunEventType.LLMToken:
        cb.onToken?.(payload.chunk || payload.delta || '');
        break;

      // Extended LLM events (v3.15.1+) — expose full AI communication
      case RunEventType.LLMThinking:
        cb.onLLMThinking?.({ stepId, delta: payload.delta || '' });
        break;

      case RunEventType.LLMToolUse:
        cb.onLLMToolUse?.({
          stepId,
          tool_use_id: payload.tool_use_id || '',
          name: payload.name || 'unknown',
          input: payload.input || {},
        });
        break;

      case RunEventType.LLMToolResult:
        cb.onLLMToolResult?.({
          stepId,
          tool_use_id: payload.tool_use_id || '',
          content: payload.content || '',
          is_error: payload.is_error || false,
        });
        break;

      case 'tool_start':
        cb.onToolStart?.({
          stepId,
          name: payload.name || '',
          input: payload.input || {},
        });
        break;

      case 'tool_done':
        cb.onToolDone?.({
          stepId,
          name: payload.name || '',
          output: payload.output ?? null,
        });
        break;

      case 'tool_error':
        cb.onToolError?.({
          stepId,
          name: payload.name || '',
          error: payload.error || 'Unknown tool error',
          allowError: payload.allowError === true,
          output: payload.output ?? null,
          toolResult: payload.toolResult ?? null,
        });
        break;

      case 'tool_message':
        cb.onToolMessage?.({
          stepId,
          name: payload.name || '',
          level: payload.level || 'info',
          message: payload.message || '',
          data: payload.data ?? null,
        });
        break;

      case RunEventType.LLMDone:
        cb.onLLMDone?.({
          stepId,
          model: payload.model || '',
          usage: payload.usage || {},
          finish_reason: payload.finish_reason || 'stop',
          latency_ms: payload.latency_ms || 0,
        });
        break;

      case RunEventType.LLMError:
        cb.onLLMError?.({
          stepId,
          error: payload.error || 'Unknown LLM error',
          type: payload.type || 'unknown',
          code: payload.code || '',
          retryable: payload.retryable || false,
          latency_ms: payload.latency_ms || 0,
        });
        break;

      case RunEventType.FileStart:
        cb.onFileStart?.(payload.path || '');
        break;

      case RunEventType.FileDone:
        if (payload.path) {
          if (!this.filesWritten.includes(payload.path)) {
            this.filesWritten.push(payload.path);
          }
          cb.onFileWritten?.(payload.path);
          // Broadcast file_changed for real-time file tree update
          cb.onFileChanged?.(payload.path, payload.size_bytes || 0);
        }
        break;

      case RunEventType.StepPrint:
        cb.onText?.(String(payload.value || ''));
        break;

      case RunEventType.PauseStart:
        cb.onPause?.({
          nodeId: payload.nodeId || stepId,
          title: stepId,
          reason: payload.reason || 'Awaiting approval',
          resumeResultTarget: payload.resumeResultTarget,
        });
        break;

      case RunEventType.PauseResumed:
        cb.onResumed?.({ nodeId: payload.nodeId || stepId });
        break;

      case RunEventType.PauseTimeout:
        cb.onPauseTimeout?.({
          nodeId: payload.nodeId || stepId,
          timeoutAction: payload.timeoutAction || '',
        });
        break;

      case RunEventType.PauseRejected:
        cb.onPauseRejected?.({
          nodeId: payload.nodeId || stepId,
          reason: payload.reason || '',
        });
        break;

      case RunEventType.VarChanged:
        cb.onVarChanged?.({
          stepId,
          name: payload.name || payload.variable || '',
          oldValue: payload.oldValue ?? payload.old ?? null,
          newValue: payload.newValue ?? payload.new ?? null,
        });
        break;

      // WorkflowStart, WorkflowDone, WorkflowFailed handled by execute() directly
    }
  }

  // ─── Adapters ───────────────────────────────────────────────

  async _buildReadFilesContext(readFiles) {
    const targets = [];
    const visit = (value) => {
      if (Array.isArray(value)) {
        for (const item of value) visit(item);
        return;
      }
      if (value == null) return;
      const text = String(value).trim();
      if (!text) return;
      targets.push(text.replace(/^\/+/, ''));
    };
    visit(readFiles);

    const uniqueTargets = [...new Set(targets)].slice(0, 16);
    if (uniqueTargets.length === 0) return null;

    const blocks = [];
    for (const relPath of uniqueTargets) {
      const fullPath = path.resolve(this.config.workDir, relPath);
      try {
        const content = await fs.readFile(fullPath, 'utf-8');
        const trimmed = content.length > 40000
          ? `${content.slice(0, 40000)}\n...[truncated]`
          : content;
        blocks.push(`<file-context path="${relPath}">\n${trimmed}\n</file-context>`);
      } catch (err) {
        this._warnCallback?.(`⚠ Failed to load workflow readFiles target: ${relPath} (${err.message})\n`);
      }
    }

    if (blocks.length === 0) return null;
    return `Current project files requested by the workflow. Treat them as authoritative source context:\n\n${blocks.join('\n\n')}`;
  }

  /** Build LLM adapter wrapping VL-Code's LLM provider */
  _buildLLMAdapter() {
    const client = this.client;
    const model = this.config.model;
    const executor = this;

    return {
      async call(params, onToken, callbacks) {
        let systemPrompt = params.system || '';
        const isJson = params.output_config?.format?.type === 'json_object' ||
                       params.output_config?.type === 'json_object';
        const rejectEmptyJson = isCriticalJsonGenerationStep(params);
        if (isJson) systemPrompt += '\n\nCRITICAL FORMATTING RULE: Your ENTIRE response must be a single valid JSON object. Start with { and end with }. NO commentary, NO explanation, NO markdown, NO code fences, NO text before or after the JSON. If the spec already exists, regenerate it fresh — do NOT say "already exists" or "no changes needed".';

        // Separate system messages
        const messages = (params.messages || []).filter(m => {
          if (m.role === 'system') {
            systemPrompt += '\n\n' + (typeof m.content === 'string' ? m.content : JSON.stringify(m.content));
            return false;
          }
          return true;
        });
        const readFilesContext = await executor._buildReadFilesContext(params.readFiles);
        delete params.readFiles;

        // Auto-stringify non-string message content (objects from expression evaluation)
        const normalizedMessages = messages.map(m => {
          if (m.content != null && typeof m.content !== 'string') {
            return { ...m, content: JSON.stringify(m.content) };
          }
          if (m.content === undefined || m.content === null) {
            return { ...m, content: '' };
          }
          return m;
        });
        if (readFilesContext) {
          normalizedMessages.unshift({ role: 'user', content: readFilesContext });
        }

        const apiParams = {
          model: params.model || model,
          max_tokens: params.max_tokens || 32768,
          system: systemPrompt,
          messages: normalizedMessages,
        };

        let content, usage = {}, finish_reason = 'stop', responseModel = model;

        if (params.stream && onToken) {
          const stream = client.messages.stream(apiParams);
          let fullText = '';
          let currentBlockType = null;

          for await (const event of stream) {
            // Track content block types for thinking vs response
            if (event.type === 'content_block_start') {
              currentBlockType = event.content_block?.type || 'text';
              if (currentBlockType === 'tool_use') {
                // Emit tool_use start with name and id
                callbacks?.onToolUse?.({
                  id: event.content_block.id,
                  name: event.content_block.name,
                  input: {},
                });
              }
            }
            if (event.type === 'content_block_delta') {
              if (event.delta?.type === 'thinking_delta' && event.delta?.thinking) {
                // Extended thinking stream
                callbacks?.onThinking?.(event.delta.thinking);
              } else if (event.delta?.type === 'text_delta' && event.delta?.text) {
                fullText += event.delta.text;
                onToken(event.delta.text);
              } else if (event.delta?.type === 'input_json_delta' && event.delta?.partial_json) {
                // Tool input streaming — accumulate but don't emit per-chunk
              } else if (event.delta?.text) {
                // Fallback for simple text delta
                fullText += event.delta.text;
                onToken(event.delta.text);
              }
            }
            if (event.type === 'content_block_stop') {
              currentBlockType = null;
            }
            // Message-level events for usage
            if (event.type === 'message_delta') {
              if (event.usage) usage = { ...usage, ...event.usage };
              if (event.delta?.stop_reason) finish_reason = event.delta.stop_reason;
            }
            if (event.type === 'message_start' && event.message) {
              if (event.message.usage) usage = event.message.usage;
              responseModel = event.message.model || model;
            }
          }
          content = fullText;
        } else {
          const response = await client.messages.create(apiParams);
          // Find the text block — with extended thinking models, content[0] may be a thinking block
          const textBlock = response.content?.find(b => b.type === 'text');
          content = textBlock?.text || '';
          if (!textBlock) {
            console.warn('[WorkflowLLM] No text block found in response. Block types:', response.content?.map(b => b.type));
          }
          usage = response.usage || {};
          finish_reason = response.stop_reason || 'stop';
          responseModel = response.model || model;
        }

        // Strip markdown code fences and extract JSON from LLM output
        if (isJson && typeof content === 'string') {
          if (!content || content.trim().length === 0) {
            console.warn('[WorkflowLLM] json_object requested but LLM returned empty content. Messages count:', messages.length, 'System length:', (systemPrompt || '').length);
            executor._warnCallback?.(`⚠ LLM returned empty content for JSON step. Check DocCenter login status — agent prompts may not have loaded.`);
            if (rejectEmptyJson) {
              throw new Error('Critical workflow JSON step returned empty content.');
            }
          }
          const extracted = extractJsonFromText(content);
          if (extracted !== null) {
            content = extracted;
            // Warn if extracted JSON is empty or near-empty — cascading steps will produce stubs
            const parsed = JSON.parse(extracted);
            if (typeof parsed === 'object' && parsed !== null && Object.keys(parsed).length === 0) {
              console.warn('[WorkflowLLM] json_object extracted but result is empty {}. System prompt length:', (systemPrompt || '').length);
              executor._warnCallback?.(`⚠ LLM returned empty JSON {}. This will cascade to downstream steps producing stubs. Possible cause: DocCenter agent prompts not loaded (check login).`);
              if (rejectEmptyJson) {
                throw new Error(`Critical workflow JSON step returned empty object for docs ${String(params.docs || [])}.`);
              }
            }
          } else {
            // No valid JSON found — LLM returned commentary instead of JSON
            console.warn('[WorkflowLLM] json_object requested but no JSON found in output. Content length:', content.length, 'First 500 chars:', content.substring(0, 500));
            console.warn('[WorkflowLLM] Last 200 chars:', content.substring(content.length - 200));
            executor._warnCallback?.(`⚠ LLM did not return valid JSON. Content preview: ${content.substring(0, 200)}...`);
            await saveInvalidJsonDebugArtifact(executor.config.workDir, params, content);
            if (rejectEmptyJson) {
              throw new Error('Critical workflow JSON step did not return valid JSON.');
            }
            // Return empty object to prevent garbage in files
            content = '{}';
          }
        }

        return { content, model: responseModel, usage, finish_reason };
      }
    };
  }

  /** Build file adapter with section-merge support */
  _buildFileAdapter() {
    const config = this.config;
    const executor = this;

    const normalizeVLSource = (rawContent, targetPath) => {
      let content = rawContent.replace(/^\uFEFF/, '');
      const projectVersion = config.vlVersion || '3.5';
      const versionPattern = /\/\/\s*VL_VERSION:\d+\.\d+/;

      if (/\.(sc|cp|vs|vx|vdb|vth)$/.test(targetPath)) {
        const versionMatch = content.match(versionPattern);
        content = content.trimStart();

        if (versionMatch) {
          content = content.replace(versionPattern, '').trimStart();
          content = `${versionMatch[0]}\n${content}`;
        } else {
          content = `// VL_VERSION:${projectVersion}\n${content}`;
        }

        content = content.replace(versionPattern, `// VL_VERSION:${projectVersion}`);
      }

      return content;
    };

    return {
      async write(targetPath, data, mode) {
        // Guard: skip writes when data is null/undefined (e.g. LLM field mismatch)
        if (data == null) {
          console.warn(`[FileAdapter] Skipping write to ${targetPath}: data is ${data}`);
          return;
        }
        // Strip leading '/' so path.resolve treats it as relative to workDir
        if (targetPath.startsWith('/')) targetPath = targetPath.slice(1);
        const fullPath = path.resolve(config.workDir, targetPath);
        await fs.mkdir(path.dirname(fullPath), { recursive: true });

        let content = typeof data === 'string' ? data
          : Buffer.isBuffer(data) ? data.toString('utf-8')
          : (typeof data === 'object' ? JSON.stringify(data, null, 2) : String(data));

        // Strip code fences from LLM output for VL code files
        // LLMs often wrap code in ```vl ... ``` with commentary before/after
        if (/\.(sc|cp|vs|vx|vdb|vth)$/.test(targetPath) && content.includes('```')) {
          const fenceMatch = content.match(/```(?:vl|VL)?\s*\n([\s\S]*?)```/);
          if (fenceMatch) {
            content = fenceMatch[1].trim();
          }
        }

        // Normalize generated VL files so the version header is always on line 1.
        content = normalizeVLSource(content, targetPath);

        // Section-merge for VL code files in incremental workflows
        if (executor.sectionMergeEnabled && /\.(sc|cp|vs|vx)$/.test(targetPath)) {
          try {
            const existing = await fs.readFile(fullPath, 'utf-8');
            content = smartMerge(existing, content);
          } catch { /* file doesn't exist yet */ }
        }

        switch (mode) {
          case 'append':
            await fs.appendFile(fullPath, content, 'utf-8');
            break;
          case 'prepend': {
            let existing = '';
            try { existing = await fs.readFile(fullPath, 'utf-8'); } catch {}
            await fs.writeFile(fullPath, content + existing, 'utf-8');
            break;
          }
          case 'failIfExists':
            try {
              await fs.access(fullPath);
              throw new Error(`File already exists: ${targetPath}`);
            } catch (e) {
              if (e.code !== 'ENOENT') throw e;
            }
            await fs.writeFile(fullPath, content, 'utf-8');
            break;
          default: // overwrite
            await fs.writeFile(fullPath, content, 'utf-8');
        }
      },

      async read(targetPath) {
        if (targetPath.startsWith('/')) targetPath = targetPath.slice(1);
        const fullPath = path.resolve(config.workDir, targetPath);
        return fs.readFile(fullPath);
      },
    };
  }

  _resolveToolRegistry() {
    return this.config.toolRegistry || this.config._toolRegistry || null;
  }

  _resolveToolStepName(step, ev) {
    const raw = step.tool ?? step.toolName ?? step.name ?? null;
    const resolved = raw != null ? ev.evaluateValue(raw) : null;
    if (typeof resolved === 'string' && resolved.trim()) return resolved.trim();

    const parts = String(step.id || '').split('_').slice(1);
    if (parts.length > 1 && /^\d+$/.test(parts[0])) parts.shift();
    return parts.join('_') || String(step.id || '').replace(/^Tool_/, '') || '';
  }

  async _executeTool(engine, ctx, step, cb) {
    const registry = this._resolveToolRegistry();
    if (!registry?.execute) {
      throw new Error('Tool registry unavailable for Tool_* step execution');
    }

    const ev = new ExpressionEvaluator(ctx);
    const toolName = this._resolveToolStepName(step, ev);
    if (!toolName) throw new Error(`Tool step ${step.id} is missing a tool name`);

    const toolInput = step.input !== undefined
      ? ev.evaluateDeep(step.input)
      : ev.evaluateDeep(step.in || {});
    if (step.timeout != null && toolInput && typeof toolInput === 'object' && toolInput.timeout == null) {
      toolInput.timeout = ev.evaluateValue(step.timeout);
    }
    const allowError = step.allowError === true || step.continueOnError === true;

    ctx.emitEvent({
      type: 'tool_start',
      stepID: step.id,
      payload: {
        name: toolName,
        input: this._truncateEventValue(toolInput),
        inputSummary: this._summarizeObj(toolInput, 240),
      },
    });

    const response = await registry.execute(toolName, toolInput, {
      onEvent: (event = {}) => {
        if (!event || typeof event !== 'object') return;
        const message = event.message || (typeof event.value === 'string' ? event.value : '');
        ctx.emitEvent({
          type: 'tool_message',
          stepID: step.id,
          payload: {
            name: toolName,
            level: event.level || (event.type === 'progress' ? 'info' : event.type || 'info'),
            message,
            data: this._truncateEventValue(event.data ?? (event.message ? null : event.value ?? null)),
          },
        });
      },
    });
    const rawToolOutput = response && Object.prototype.hasOwnProperty.call(response, 'result')
      ? response.result
      : response;
    const toolError = response?.error
      || (rawToolOutput && typeof rawToolOutput === 'object' && typeof rawToolOutput.error === 'string'
        ? rawToolOutput.error
        : null);

    if (toolError) {
      ctx.emitEvent({
        type: 'tool_error',
        stepID: step.id,
        payload: {
          name: toolName,
          error: toolError,
          allowError,
          output: this._truncateEventValue(rawToolOutput),
          toolResult: this._truncateEventValue(rawToolOutput),
        },
      });
      if (!allowError) {
        throw new Error(`[Tool:${toolName}] ${toolError}`);
      }
      ctx.setVariable('_result', { ok: false, error: toolError, tool: toolName });
      ctx.setVariable('_toolResult', rawToolOutput);
      await applyOutputMapping(engine, ctx, step);
      ctx.setVariable('_result', undefined);
      ctx.setVariable('_toolResult', undefined);
      return;
    }

    const toolOutput = rawToolOutput && typeof rawToolOutput === 'object' && Object.prototype.hasOwnProperty.call(rawToolOutput, 'result')
      ? rawToolOutput.result
      : rawToolOutput;
    ctx.emitEvent({
      type: 'tool_done',
      stepID: step.id,
      payload: {
        name: toolName,
        output: this._truncateEventValue(toolOutput),
        outputSummary: this._summarizeObj(toolOutput, 240),
        toolResult: this._truncateEventValue(rawToolOutput),
      },
    });
    ctx.setVariable('_result', toolOutput);
    ctx.setVariable('_toolResult', rawToolOutput);
    await applyOutputMapping(engine, ctx, step);
    ctx.setVariable('_result', undefined);
    ctx.setVariable('_toolResult', undefined);
    cb.onText?.(`[Tool] ${toolName} executed\n`);
  }

  async _executeSubflow(engine, ctx, step, cb) {
    const subflowStep = {
      ...step,
      tool: 'WorkflowRun',
      input: {
        mode: step.mode || step.executionMode || 'sync',
        workflow_path: step.workflow_path || step.workflowPath || step.path || step.workflow,
        params: step.params || step.input || step.in || {},
        work_dir: step.work_dir || step.workDir || step.base_dir || step.baseDir || step.subspace,
        emit_events: step.emit_events ?? step.emitEvents ?? true,
      },
    };
    if (!subflowStep.input.workflow_path) {
      throw new Error(`Subflow step ${step.id} is missing workflow_path`);
    }
    return this._executeTool(engine, ctx, subflowStep, cb);
  }

  /** Build doc adapter for DocCenter-resolved documents */
  _buildDocAdapter(resolvedDocs) {
    return {
      async get(docPath) {
        return resolvedDocs[String(docPath)] || null;
      }
    };
  }

  _buildComponentAdapter(cb = {}) {
    return {
      call: async (componentID, params = {}) => {
        if (componentID === 'ClearExisting') {
          return this._runClearExisting(params, cb);
        }
        cb.onText?.(`[Component] Unsupported component "${componentID}" — treated as no-op\n`);
        return {};
      }
    };
  }

  // ─── Custom Step Handlers ───────────────────────────────────

  /** Execute MetaDiff node */
  async _executeMetaDiff(engine, ctx, step, cb) {
    const ev = new ExpressionEvaluator(ctx);
    const input = ev.evaluateDeep(step.in || {});

    let oldMeta = input.oldMeta === 'current' || input.oldMeta === '=current'
      ? await this._loadCurrentMeta()
      : input.oldMeta;
    const newMeta = input.newMeta;

    if (!oldMeta || !newMeta) {
      cb.onText?.(`\n[MetaDiff] Warning: missing oldMeta or newMeta, returning empty diff\n`);
      const empty = { added: {}, removed: {}, modified: {}, affectedFiles: [], cascadeImpact: [], summary: 'No data' };
      ctx.setVariable('_result', empty);
      applyOutputMapping(engine, ctx, step);
      ctx.setVariable('_result', undefined);
      return;
    }

    const diff = computeMetaDiff(
      typeof oldMeta === 'string' ? JSON.parse(oldMeta) : oldMeta,
      typeof newMeta === 'string' ? JSON.parse(newMeta) : newMeta,
      this.symbolIndex
    );

    cb.onText?.(`\n[MetaDiff] ${diff.summary}\n`);
    ctx.setVariable('_result', diff);
    applyOutputMapping(engine, ctx, step);
    ctx.setVariable('_result', undefined);
  }

  async _loadCurrentMeta() {
    try {
      const metaPath = path.join(this.config.workDir, '.vl-code', 'ProjectMeta.json');
      return JSON.parse(await fs.readFile(metaPath, 'utf-8'));
    } catch { return null; }
  }

  /**
   * Execute ComponentFetch node — search DocCenter component library for matching components.
   * For each component in the source list, search DocCenter for a match.
   * Found components are written directly to ExtComponents/; unfound ones pass through to LLM generation.
   *
   * Step config:
   *   in.source — array of component specs from ProjectMeta (e.g. =$projectMeta.components)
   *   in.keyword — optional search keyword override
   *   out.$fetchedComponents — list of components found in library
   *   out.$unfetchedComponents — list of components not found (need LLM generation)
   */
  async _executeComponentFetch(engine, ctx, step, cb) {
    const ev = new ExpressionEvaluator(ctx);
    const input = ev.evaluateDeep(step.in || {});
    const components = input.source || [];

    const fetched = [];
    const unfetched = [];
    const DC_BASE = DOCCENTER_API_URL;
    const cookie = this.config.cookie || '';

    const dcPost = async (endpoint, body) => {
      const headers = { 'Content-Type': 'application/json' };
      if (cookie) headers.Cookie = normalizeCookieHeader(cookie);
      const res = await fetch(`${DC_BASE}/${endpoint}`, {
        method: 'POST', headers, body: JSON.stringify(body),
      });
      try { return JSON.parse(await res.text()); } catch { return {}; }
    };

    cb.onText?.(`\n[ComponentFetch] Searching library for ${components.length} component(s)...\n`);

    // Load Factory component library (alias: componentFactory)
    let factoryComponents = [];
    try {
      const configuredDocIds = {
        ...(this.config.coreDocIds || {}),
        ...(this.config.docIdOverrides || {}),
      };
      const factoryRes = await dcPost('SERVICE_DocCenter_GetDocById', { docId: docIdFor('componentFactory', configuredDocIds) });
      const factoryContent = factoryRes?.data?.currentContent || factoryRes?.currentContent || '';
      if (factoryContent) {
        const parsed = JSON.parse(factoryContent);
        factoryComponents = parsed?.components || [];
        cb.onText?.(`  Factory library: ${factoryComponents.length} component(s) available\n`);
      }
    } catch (err) {
      cb.onText?.(`  Factory library unavailable: ${err.message}\n`);
    }

    for (const comp of components) {
      const name = comp.name || comp;
      try {
        // 1. Search Factory library first
        const factoryMatch = factoryComponents.find(c =>
          c.name?.toLowerCase() === name.toLowerCase()
        );

        if (factoryMatch && factoryMatch.vlSource) {
          let content = factoryMatch.vlSource;
          content = content.replace(/<Component-\w+/g, `<Component-${name}`);
          content = content.replace(/<\/Component-\w+>/g, `</Component-${name}>`);

          const filePath = `ExtComponents/${name}.cp`;
          const fullPath = path.resolve(this.config.workDir, filePath);
          await fs.mkdir(path.dirname(fullPath), { recursive: true });
          await fs.writeFile(fullPath, content, 'utf-8');
          this.filesWritten.push(filePath);

          fetched.push({ name, source: 'factory', filePath });
          cb.onFileWritten?.(filePath);
          cb.onText?.(`  ✓ ${name} — fetched from Factory library\n`);
          continue;
        }

        // 2. Fallback: search individual CP: docs
        const searchRes = await dcPost('SERVICE_DocCenter_GetDocList', {
          keyword: `CP: ${name}`, tagId: 0, page: 1, pageSize: 5,
        });
        const docs = searchRes?.data || [];
        const match = docs.find(d =>
          d.name?.replace(/^CP:\s*/, '').toLowerCase() === name.toLowerCase()
        );

        if (match && match.currentContent?.includes('<Component-')) {
          let content = match.currentContent;
          content = content.replace(/<Component-\w+/g, `<Component-${name}`);
          content = content.replace(/<\/Component-\w+>/g, `</Component-${name}>`);

          const filePath = `ExtComponents/${name}.cp`;
          const fullPath = path.resolve(this.config.workDir, filePath);
          await fs.mkdir(path.dirname(fullPath), { recursive: true });
          await fs.writeFile(fullPath, content, 'utf-8');
          this.filesWritten.push(filePath);

          fetched.push({ name, docId: match._id, filePath });
          cb.onFileWritten?.(filePath);
          cb.onText?.(`  ✓ ${name} — fetched from DocCenter\n`);
        } else {
          unfetched.push(comp);
          cb.onText?.(`  ✗ ${name} — not in library, will generate via LLM\n`);
        }
      } catch (err) {
        unfetched.push(comp);
        cb.onText?.(`  ✗ ${name} — search error: ${err.message}\n`);
      }
    }

    cb.onText?.(`[ComponentFetch] Done: ${fetched.length} fetched, ${unfetched.length} to generate\n`);

    const result = { fetched, unfetched, fetchedCount: fetched.length, unfetchedCount: unfetched.length };
    ctx.setVariable('_result', result);
    applyOutputMapping(engine, ctx, step);
    ctx.setVariable('_result', undefined);
  }

  /** Execute ClearFiles node — remove existing VL project files before regeneration */
  async _executeClearFiles(engine, ctx, step, cb) {
    const result = await this._runClearExisting(step?.in || {}, cb);
    ctx.setVariable('_result', result);
    applyOutputMapping(engine, ctx, step);
    ctx.setVariable('_result', undefined);
  }

  async _executeSeedTheme(engine, ctx, step, cb) {
    const result = await this._writeDefaultTheme(step?.in || {}, cb);
    ctx.setVariable('_result', result);
    applyOutputMapping(engine, ctx, step);
    ctx.setVariable('_result', undefined);
  }

  async _runClearExisting(_params = {}, cb = {}) {
    const workDir = this.config.workDir;
    const VL_EXTS = new Set(['.vx', '.sc', '.cp', '.vs', '.vdb', '.vth']);
    const VL_DIRS = ['Apps', 'Sections', 'ExtComponents', 'Services', 'Database', 'Theme'];

    cb.onText?.('\n[ClearFiles] Removing existing VL project files...\n');
    let deleted = 0;

    // Clear spec files in Process/Specs/
    const specsDir = path.resolve(workDir, 'Process', 'Specs');
    try {
      const specEntries = await fs.readdir(specsDir);
      for (const entry of specEntries) {
        if (entry.endsWith('.json')) {
          await fs.unlink(path.resolve(specsDir, entry));
          deleted++;
          cb.onText?.(`  Cleared spec: Process/Specs/${entry}\n`);
        }
      }
    } catch (err) {
      if (err.code !== 'ENOENT') cb.onText?.(`  ⚠ Process/Specs: ${err.message}\n`);
    }

    // Clear VL code files
    for (const dir of VL_DIRS) {
      const dirPath = path.resolve(workDir, dir);
      try {
        const entries = await fs.readdir(dirPath);
        for (const entry of entries) {
          const ext = path.extname(entry).toLowerCase();
          if (VL_EXTS.has(ext)) {
            await fs.unlink(path.resolve(dirPath, entry));
            deleted++;
            cb.onText?.(`  ✗ ${dir}/${entry}\n`);
          }
        }
        // Remove dir if now empty
        const remaining = await fs.readdir(dirPath);
        if (remaining.length === 0) await fs.rmdir(dirPath);
      } catch (err) {
        if (err.code !== 'ENOENT') cb.onText?.(`  ⚠ ${dir}: ${err.message}\n`);
      }
    }

    cb.onText?.(`[ClearFiles] Removed ${deleted} file(s)\n`);

    return { deleted, success: true };
  }

  async _writeDefaultTheme(params = {}, cb = {}) {
    const relPath = String(params.path || 'Theme/Theme.vth');
    const content = typeof params.content === 'string' && params.content.trim()
      ? params.content
      : DEFAULT_THEME_CONTENT;
    const fullPath = path.resolve(this.config.workDir, relPath);

    await fs.mkdir(path.dirname(fullPath), { recursive: true });
    await fs.writeFile(fullPath, content, 'utf-8');
    this.filesWritten.push(relPath);
    cb.onFileWritten?.(relPath);
    cb.onText?.(`[SeedTheme] Wrote default theme: ${relPath}\n`);
    return content;
  }

  // ─── Variable Normalization ─────────────────────────────────

  /**
   * Normalize ModuleManifest field names to match expected Loop source references.
   * LLMs may generate alternative names — this maps them to canonical names.
   */
  _normalizeModuleManifest(manifest) {
    if (!manifest || typeof manifest !== 'object') return;
    const aliasMap = {
      databaseFile:  ['database', 'db', 'databaseSchema', 'dbFile'],
      themeFile:     ['theme', 'themeConfig', 'themeTokens'],
      serviceFiles:  ['services', 'serviceDomains', 'serviceDomainFiles', 'vsFiles'],
      componentFiles:['components', 'componentList', 'cpFiles'],
      sectionFiles:  ['sections', 'sectionList', 'scFiles'],
      appFiles:      ['apps', 'applications', 'appList', 'vxFiles'],
    };
    for (const [canonical, aliases] of Object.entries(aliasMap)) {
      if (manifest[canonical] !== undefined) continue;
      for (const alias of aliases) {
        if (manifest[alias] !== undefined) {
          manifest[canonical] = manifest[alias];
          break;
        }
      }
    }
    for (const field of ['serviceFiles', 'componentFiles', 'sectionFiles', 'appFiles']) {
      if (Array.isArray(manifest[field])) {
        manifest[field] = manifest[field].map(item =>
          typeof item === 'string' ? { path: item, name: item.split('/').pop()?.replace(/\.\w+$/, '') } : item
        );
      }
    }
    for (const field of ['databaseFile', 'themeFile']) {
      if (typeof manifest[field] === 'string') {
        manifest[field] = { path: manifest[field] };
      }
    }
  }

  // ─── Event Helpers ─────────────────────────────────────────

  /** Summarize an object for display (truncate long values) */
  _summarizeObj(obj, maxLen = 120) {
    if (!obj || typeof obj !== 'object') return obj;
    const out = {};
    for (const [k, v] of Object.entries(obj)) {
      if (typeof v === 'string') {
        out[k] = v.length > maxLen ? v.slice(0, maxLen) + `... (${v.length} chars)` : v;
      } else if (Array.isArray(v)) {
        out[k] = `[${v.length} items]`;
      } else if (v && typeof v === 'object') {
        out[k] = `{${Object.keys(v).join(', ')}}`;
      } else {
        out[k] = v;
      }
    }
    return out;
  }

  _truncateEventValue(value, maxLen = 24000) {
    if (value == null) return value;
    if (typeof value === 'string') {
      return value.length > maxLen
        ? `${value.slice(0, maxLen)}\n...[truncated ${value.length - maxLen} chars]`
        : value;
    }
    try {
      const json = JSON.stringify(value);
      if (json.length <= maxLen) return value;
      return {
        _truncated: true,
        type: Array.isArray(value) ? 'array' : typeof value,
        length: json.length,
        preview: `${json.slice(0, Math.min(4000, maxLen))}...`,
      };
    } catch {
      return this._summarizeObj(value, 240);
    }
  }

  /** Summarize a variable reference for display */
  _summarizeVar(ref, vars) {
    const name = ref.startsWith('$') ? ref.slice(1) : ref;
    const val = vars?.[ref] ?? vars?.[name];
    if (val === undefined) return ref;
    if (typeof val === 'string') return val.length > 80 ? `${val.slice(0, 80)}... (${val.length})` : val;
    if (Array.isArray(val)) return `[${val.length} items]`;
    if (val && typeof val === 'object') return `{${Object.keys(val).slice(0, 5).join(', ')}${Object.keys(val).length > 5 ? '...' : ''}}`;
    return String(val);
  }

  // ─── DocCenter Resolution ───────────────────────────────────

  async _resolveDocCenterDocs(docs, cb) {
    const cookie = this.config.cookie;
    if (!cookie) {
      const docPaths = Object.keys(docs);
      if (docPaths.length > 0) {
        cb.onText?.(`⚠ [DocCenter] No cookie available — ${docPaths.length} document(s) will NOT be loaded. Agent prompts will be missing, which may cause empty/stub generation output. Please log in to DocCenter.\n`);
      }
      return;
    }

    const docPaths = Object.keys(docs);
    if (docPaths.length === 0) return;
    const configuredDocIds = {
      ...(this.config.coreDocIds || {}),
      ...(this.config.docIdOverrides || {}),
    };

    const BASE_URL = DOCCENTER_API_URL;
    const headers = {
      'Content-Type': 'application/json',
      Cookie: normalizeCookieHeader(cookie),
    };

    const pathToId = {};
    const explicitPathToId = {};
    for (const docPath of docPaths) {
      const configuredDocId = docIdForPath(docPath, configuredDocIds);
      if (configuredDocId) explicitPathToId[docPath] = configuredDocId;
    }
    if (Object.keys(explicitPathToId).length > 0) {
      cb.onText?.(`[DocCenter] Using configured core doc IDs for paths: ${Object.keys(explicitPathToId).join(', ')}\n`);
    }

    const unresolvedPaths = docPaths.filter((docPath) => !explicitPathToId[docPath]);
    if (unresolvedPaths.length > 0) {
      try {
        const listRes = await fetch(`${BASE_URL}/SERVICE_DocCenter_GetDocList`, {
          method: 'POST', headers, body: JSON.stringify({ keyword: '', tagId: 0, page: 1, pageSize: 100 }),
        });
        const listData = await listRes.json();
        const docList = Array.isArray(listData?.data) ? listData.data : listData?.data?.list || [];
        for (const d of docList) {
          if (d.path) pathToId[String(d.path)] = d._id;
        }
        cb.onText?.(`[DocCenter] Found ${Object.keys(pathToId).length} docs in catalog\n`);
      } catch (e) {
        cb.onText?.(`[DocCenter] Failed to list docs: ${e.message}\n`);
        return;
      }
    }

    const fetchDoc = async (docPath) => {
      const docId = explicitPathToId[docPath] || pathToId[docPath];
      if (!docId) return null;
      try {
        const res = await fetch(`${BASE_URL}/SERVICE_DocCenter_GetDocById`, {
          method: 'POST', headers, body: JSON.stringify({ docId }),
        });
        const data = await res.json();
        return data?.data?.currentContent || null;
      } catch { return null; }
    };

    const BATCH = 5;
    for (let i = 0; i < docPaths.length; i += BATCH) {
      const batch = docPaths.slice(i, i + BATCH);
      const results = await Promise.all(batch.map(p => fetchDoc(p)));
      for (let j = 0; j < batch.length; j++) {
        if (results[j]) {
          docs[batch[j]] = results[j];
          cb.onText?.(`[DocCenter] Loaded doc path ${batch[j]} (${results[j].length} chars)\n`);
        }
      }
    }

    // Path 1 is the canonical VL syntax reference. Always pin it to the
    // local bundled syntax doc so workflow generation stays aligned with the
    // repo/runtime VL version even if DocCenter drifts ahead.
    if (Object.prototype.hasOwnProperty.call(docs, '1') && !explicitPathToId['1']) {
      try {
        const localSyntax = await fs.readFile(new URL('../data/vl-syntax.md', import.meta.url), 'utf-8');
        docs['1'] = localSyntax;
        cb.onText?.(`[DocCenter] Overrode doc path 1 with bundled local VL syntax (${localSyntax.length} chars)\n`);
      } catch (err) {
        cb.onText?.(`[DocCenter] Failed to load bundled VL syntax override for path 1: ${err.message}\n`);
      }
    }
  }

  // ─── Checkpoint ─────────────────────────────────────────────

  async _saveCheckpoint() {
    if (!this._checkpointFile) return;
    try {
      await fs.mkdir(path.dirname(this._checkpointFile), { recursive: true });
      await fs.writeFile(this._checkpointFile, JSON.stringify({
        workflowName: this._workflowName,
        vars: this._getCtx()?.variables || {},
        filesWritten: this.filesWritten,
        checkpoints: this.checkpoints,
        savedAt: new Date().toISOString(),
      }, null, 2), 'utf-8');
    } catch { /* non-critical */ }
  }
}

/**
 * Extract valid JSON from text that may contain LLM commentary.
 * Returns the JSON string if found, or null if no valid JSON exists.
 */
export function extractJsonFromText(raw) {
  let content = raw.trim()
    .replace(/^\s*```(?:json|JSON)?\s*\n?/, '')
    .replace(/\n?\s*```\s*$/, '')
    .trim();

  // Try direct parse first
  try { JSON.parse(content); return content; } catch {}

  // Try repairing common LLM JSON issues (unescaped quotes in strings)
  const repaired = repairJsonQuotes(content);
  if (repaired !== content) {
    try { JSON.parse(repaired); return repaired; } catch {}
  }

  // Try repairing extra/misordered closing braces/brackets from truncated tails.
  const structuralRepair = repairJsonStructure(repaired);
  if (structuralRepair !== repaired) {
    const normalizedStructuralRepair = stripTrailingJsonCommas(structuralRepair);
    try { JSON.parse(normalizedStructuralRepair); return normalizedStructuralRepair; } catch {}
  }

  // Prefer { over [ (specs are objects, [ might be markdown table syntax)
  const idxObj = content.indexOf('{');
  const idxArr = content.indexOf('[');
  let jsonStart = -1, openChar = '{';
  if (idxObj >= 0) {
    jsonStart = idxObj; openChar = '{';
  } else if (idxArr >= 0) {
    jsonStart = idxArr; openChar = '[';
  }
  if (jsonStart < 0) return null;

  const closeChar = openChar === '{' ? '}' : ']';
  let depth = 0, inStr = false, esc = false, end = -1;
  for (let i = jsonStart; i < content.length; i++) {
    const c = content[i];
    if (esc) { esc = false; continue; }
    if (c === '\\' && inStr) { esc = true; continue; }
    if (c === '"' && !esc) { inStr = !inStr; continue; }
    if (inStr) continue;
    if (c === openChar) depth++;
    else if (c === closeChar) { depth--; if (depth === 0) { end = i; break; } }
  }
  if (end <= jsonStart) {
    const truncatedCandidate = content.substring(jsonStart);
    const repairedTruncatedCandidate = stripTrailingJsonCommas(
      repairJsonStructure(
        repairJsonQuotes(truncatedCandidate)
      )
    );
    try { JSON.parse(repairedTruncatedCandidate); return repairedTruncatedCandidate; } catch { return null; }
  }

  const candidate = content.substring(jsonStart, end + 1);
  try { JSON.parse(candidate); return candidate; } catch {}

  // Try repair on the candidate too
  const repairedCandidate = repairJsonQuotes(candidate);
  try { JSON.parse(repairedCandidate); return repairedCandidate; } catch {}

  const repairedStructuredCandidate = stripTrailingJsonCommas(repairJsonStructure(repairedCandidate));
  try { JSON.parse(repairedStructuredCandidate); return repairedStructuredCandidate; } catch { return null; }
}

function isCriticalJsonGenerationStep(params) {
  const docs = Array.isArray(params?.docs) ? params.docs.map(String) : [];
  return docs.some((docId) => ['31', '33', '34', '111'].includes(docId));
}

async function saveInvalidJsonDebugArtifact(workDir, params, content) {
  if (!workDir || !content) return;
  try {
    const debugDir = path.join(workDir, '.vl-code', 'llm-debug');
    await fs.mkdir(debugDir, { recursive: true });
    const docs = Array.isArray(params?.docs) ? params.docs.map(String).join('-') : 'unknown';
    const debugPath = path.join(debugDir, `invalid-json-${Date.now()}-docs-${docs}.txt`);
    await fs.writeFile(debugPath, content, 'utf-8');
  } catch {
    // Debug artifact write is best-effort only.
  }
}

/**
 * Repair unescaped double quotes inside JSON string values.
 * LLMs sometimes produce: "changes": "如"共找到"..." where the inner quotes
 * should be escaped as \" but aren't. This function walks through the text,
 * identifies quotes that are inside strings but not structural, and escapes them.
 */
function repairJsonQuotes(text) {
  let result = '';
  let i = 0;
  let inString = false;

  while (i < text.length) {
    const ch = text[i];

    if (ch === '\\' && inString && i + 1 < text.length) {
      // Escape sequence — copy both chars
      result += ch + text[i + 1];
      i += 2;
      continue;
    }

    if (ch === '"') {
      if (!inString) {
        inString = true;
        result += ch;
      } else {
        // Could be closing quote or unescaped embedded quote.
        // Look ahead: if next non-whitespace is structural (,:}]), it's a closing quote.
        let j = i + 1;
        while (j < text.length && /[\s]/.test(text[j])) j++;
        const next = text[j];
        if (next === ',' || next === ':' || next === '}' || next === ']' || next === undefined) {
          // Structural context → closing quote
          inString = false;
          result += ch;
        } else {
          // Non-structural context → embedded quote, escape it
          result += '\\"';
        }
      }
    } else {
      result += ch;
    }
    i++;
  }

  return result;
}

function repairJsonStructure(text) {
  let result = '';
  let inString = false;
  let escaped = false;
  const closers = [];

  for (const ch of text) {
    if (escaped) {
      result += ch;
      escaped = false;
      continue;
    }

    if (ch === '\\' && inString) {
      result += ch;
      escaped = true;
      continue;
    }

    if (ch === '"') {
      result += ch;
      inString = !inString;
      continue;
    }

    if (inString) {
      result += ch;
      continue;
    }

    if (ch === '{') {
      closers.push('}');
      result += ch;
      continue;
    }

    if (ch === '[') {
      closers.push(']');
      result += ch;
      continue;
    }

    if (ch === '}' || ch === ']') {
      if (closers.length && closers[closers.length - 1] === ch) {
        closers.pop();
        result += ch;
      }
      continue;
    }

    result += ch;
  }

  // Truncated LLM JSON often ends mid-string or right after a trailing escape.
  // Close that string before appending any synthetic object/array closers.
  if (escaped) {
    result += '\\';
  }
  if (inString) {
    result += '"';
  }

  while (closers.length) {
    result += closers.pop();
  }

  return result;
}

function stripTrailingJsonCommas(text) {
  return String(text || '').replace(/,\s*([}\]])/g, '$1');
}

/** Truncate large variable values for the variable inspector UI */
function sanitizeVarsForInspector(vars) {
  const out = {};
  for (const [k, v] of Object.entries(vars || {})) {
    if (typeof v === 'string' && v.length > 200) {
      out[k] = v.substring(0, 200) + '…';
    } else if (typeof v === 'object' && v !== null) {
      const s = JSON.stringify(v);
      out[k] = s.length > 200 ? s.substring(0, 200) + '…' : v;
    } else {
      out[k] = v;
    }
  }
  return out;
}
