/**
 * AutoTest Pipeline Tool — deeply integrated automated testing for VL apps
 *
 * Actions:
 *   generate  — extract metadata + generate test cases via Workflow Engine / Anthropic SDK
 *   run       — execute test cases with Playwright (parallel browsers supported)
 *   fix       — analyze failures (test-level fixes)
 *   fixVL     — generate VL source code fixes
 *   report    — generate markdown summary report
 *   status    — return current pipeline state
 */
import path from 'path';
import fs from 'fs';
import { createHash } from 'crypto';
import { createRequire } from 'module';
import { TestRunner } from './autotest/test-runner.js';
import { TestGenerator } from './autotest/test-generator.js';
import { WorkflowBuilder } from './autotest/workflow-builder.js';
import { WorkflowRunner } from './autotest/workflow-runner.js';
import { AutoFixer } from './autotest/auto-fixer.js';
import { TestEvaluator } from './autotest/test-evaluator.js';
import { ReportGenerator } from './autotest/report-generator.js';
import { formatElementsForPrompt, parseCaseJson } from './autotest/case-parser.js';
import { createVLCompileTool } from './vl-compile.js';
import { APIProvider, createProvider } from '../core/llm-provider.js';

const require = createRequire(import.meta.url);

function log(tag, msg) { console.log(`[AutoTest] [${tag}] ${msg}`); }

// ── Pipeline State (persists across tool calls within same process) ──
const _state = {
  metadata: null,
  testPlan: null,
  testCases: [],
  runResults: [],
  evaluations: [],
  fixResults: [],
  navElements: null,
  componentTree: null,
  pageVisibility: null,
  activeAction: null,
  activeToken: null,
  cacheWorkDir: null,
  cacheStore: null,
  navCacheKey: null,
};

const AUTOTEST_CACHE_VERSION = 1;
const CACHE_BUCKET_LIMITS = {
  navDiscovery: 12,
  pageVisibility: 24,
  promptFragments: 24,
  parsedPlans: 24,
  generatedCases: 64,
};

const CLOUD_WORKFLOW_SPEC_VERSIONS = new Set(['3.6', '3.7', '3.8', '3.9', '3.10', '3.12', '3.13']);

export function supportsCloudWorkflowSpecVersion(version) {
  return CLOUD_WORKFLOW_SPEC_VERSIONS.has(String(version || ''));
}

export function createAutoTestSdkProvider(config) {
  const apiKey = config.apiKey || process.env.ANTHROPIC_API_KEY;
  if (apiKey) return new APIProvider({ ...config, apiKey });
  return createProvider(config);
}

function hydrateAutoTestStateFromDisk(workDir) {
  if (_state.activeAction) return;

  const vlDir = path.join(workDir, '.vl-code');
  const casesPath = path.join(vlDir, 'autotest-cases.json');
  const resultsPath = path.join(vlDir, 'autotest-results.json');

  if (_state.testCases.length === 0 || !_state.testPlan) {
    try {
      const savedCases = JSON.parse(fs.readFileSync(casesPath, 'utf-8'));
      if (_state.testCases.length === 0 && Array.isArray(savedCases.testCases)) {
        _state.testCases = savedCases.testCases;
      }
      if (!_state.testPlan && savedCases.testPlan) {
        _state.testPlan = savedCases.testPlan;
      }
    } catch {}
  }

  if (_state.runResults.length === 0 || _state.evaluations.length === 0) {
    try {
      const savedResults = JSON.parse(fs.readFileSync(resultsPath, 'utf-8'));
      if (_state.runResults.length === 0 && Array.isArray(savedResults.runResults)) {
        _state.runResults = savedResults.runResults;
      }
      if (_state.evaluations.length === 0 && Array.isArray(savedResults.evaluations)) {
        _state.evaluations = savedResults.evaluations;
      }
    } catch {}
  }
}

function getAutoTestCachePath(workDir) {
  return path.join(workDir, '.vl-code', 'autotest-cache.json');
}

function createEmptyAutoTestCache() {
  return {
    version: AUTOTEST_CACHE_VERSION,
    updatedAt: new Date().toISOString(),
    navDiscovery: {},
    pageVisibility: {},
    promptFragments: {},
    parsedPlans: {},
    generatedCases: {},
  };
}

function pruneCacheBucket(bucket = {}, limit = 24) {
  const keys = Object.keys(bucket);
  if (keys.length <= limit) return bucket;
  keys
    .sort((a, b) => Number(bucket[b]?.lastUsedAt || bucket[b]?.updatedAt || 0) - Number(bucket[a]?.lastUsedAt || bucket[a]?.updatedAt || 0))
    .slice(limit)
    .forEach((key) => {
      delete bucket[key];
    });
  return bucket;
}

function ensureAutoTestCache(workDir) {
  if (_state.cacheWorkDir === workDir && _state.cacheStore) return _state.cacheStore;

  let cache = createEmptyAutoTestCache();
  try {
    const raw = JSON.parse(fs.readFileSync(getAutoTestCachePath(workDir), 'utf-8'));
    if (raw && raw.version === AUTOTEST_CACHE_VERSION) {
      cache = {
        ...cache,
        ...raw,
      };
    }
  } catch {}

  _state.cacheWorkDir = workDir;
  _state.cacheStore = cache;
  return cache;
}

function persistAutoTestCache(workDir) {
  const cache = ensureAutoTestCache(workDir);
  cache.updatedAt = new Date().toISOString();
  try {
    fs.mkdirSync(path.join(workDir, '.vl-code'), { recursive: true });
    fs.writeFileSync(getAutoTestCachePath(workDir), JSON.stringify(cache, null, 2));
  } catch {}
}

function hashObject(value) {
  return createHash('sha1').update(JSON.stringify(value || null)).digest('hex');
}

export function buildPreviewCacheKey(workDir, baseUrl) {
  let gid = '';
  let ts = '';
  try {
    const compile = JSON.parse(fs.readFileSync(path.join(workDir, '.vl-code', 'last-compile.json'), 'utf-8'));
    gid = compile?.gid || compile?.groupId || '';
    ts = compile?.timestamp || '';
  } catch {}
  return hashObject({ baseUrl, gid, ts });
}

export function buildWorkspaceFingerprint(projectContext, fileCache = null) {
  const fileInfos = [];
  for (const file of projectContext?.getAllFiles?.() || []) {
    const relPath = String(file.path || '');
    const isStableVLCodeFile = (
      relPath === '.vl-code/ProjectMeta.json' ||
      relPath === '.vl-code/project.json' ||
      relPath === '.vl-code/PROJECT.md' ||
      relPath === '.vl-code/VL.md' ||
      relPath.startsWith('.vl-code/rules/')
    );
    if (!file.isVL && !isStableVLCodeFile) continue;
    try {
      const stat = fs.statSync(file.fullPath);
      fileInfos.push({
        path: relPath,
        size: stat.size,
        mtimeMs: Math.round(stat.mtimeMs),
        hash: fileCache?.getHash?.(relPath) || null,
      });
    } catch {
      fileInfos.push({ path: relPath, missing: true });
    }
  }
  fileInfos.sort((a, b) => a.path.localeCompare(b.path));
  return hashObject(fileInfos);
}

function getCacheEntry(workDir, bucketName, key) {
  const cache = ensureAutoTestCache(workDir);
  const bucket = cache[bucketName] || {};
  const entry = bucket[key];
  if (!entry) return null;
  entry.lastUsedAt = Date.now();
  return structuredClone(entry.value);
}

function setCacheEntry(workDir, bucketName, key, value) {
  const cache = ensureAutoTestCache(workDir);
  if (!cache[bucketName]) cache[bucketName] = {};
  cache[bucketName][key] = {
    updatedAt: new Date().toISOString(),
    lastUsedAt: Date.now(),
    value,
  };
  pruneCacheBucket(cache[bucketName], CACHE_BUCKET_LIMITS[bucketName] || 24);
  persistAutoTestCache(workDir);
}

function buildNavSignature(navElements) {
  return hashObject((navElements || []).map((el) => ({
    instanceId: el.instanceId,
    text: el.text,
    position: el.position,
    isNavButton: el.isNavButton,
  })));
}

export function buildPromptCacheKey({ appId, workspaceFingerprint, appMeta, navElements }) {
  return hashObject({
    appId,
    workspaceFingerprint,
    appMeta,
    navSignature: buildNavSignature(navElements),
  });
}

export function buildParseCacheKey({ appId, workspaceFingerprint, appMeta, navElements }) {
  return hashObject({
    appId,
    workspaceFingerprint,
    appMeta,
    navSignature: buildNavSignature(navElements),
  });
}

export function buildGeneratedCasesCacheKey({ appId, workspaceFingerprint, appMeta, baseUrl, navElements, target, workDir }) {
  return hashObject({
    appId,
    workspaceFingerprint,
    appMeta,
    previewCacheKey: buildPreviewCacheKey(workDir, baseUrl),
    navSignature: buildNavSignature(navElements),
    targetId: target?.id || 'app',
    targetKind: target?.kind || 'app',
    targetLabel: target?.label || appId,
    targetMaxCases: target?.maxCases || 1,
  });
}

export function buildPageVisibilityCacheKey({ previewCacheKey, appId, testPlan }) {
  return hashObject({
    previewCacheKey,
    appId,
    homePage: testPlan?.homePage || '',
    pages: (testPlan?.pages || []).map((page) => ({
      path: page.path,
      navPath: page.navPath || [],
    })),
  });
}

function stripGeneratedCaseInternals(testCases) {
  return (testCases || []).map((testCase) => ({
    ...testCase,
    steps: Array.isArray(testCase.steps) ? testCase.steps.map((step) => ({ ...step })) : [],
  }));
}

// ── Helpers (ported from autotest server.js) ──

function buildComponentInfoFromMetadata(metadata) {
  const interactiveElements = [];
  for (const sec of (metadata.sections || [])) {
    for (const el of (sec.interactiveElements || [])) {
      if (!el.instanceId) continue;
      interactiveElements.push({
        name: el.name, compId: el.instanceId, type: el.type,
        section: sec.sectionId, label: el.label || undefined,
        placeholder: el.placeholder || undefined, inputType: el.inputType || undefined,
      });
    }
  }
  for (const cp of (metadata.components || [])) {
    for (const el of (cp.interactiveElements || [])) {
      if (!el.instanceId) continue;
      interactiveElements.push({
        name: el.name, compId: el.instanceId, type: el.type,
        section: cp.componentId, label: el.label || undefined,
        placeholder: el.placeholder || undefined, inputType: el.inputType || undefined,
      });
    }
  }
  const bySection = {};
  for (const el of interactiveElements) {
    const sec = el.section || '_root';
    if (!bySection[sec]) bySection[sec] = [];
    bySection[sec].push(el);
  }
  const lines = [];
  for (const [section, els] of Object.entries(bySection)) {
    if (section !== '_root') lines.push(`## ${section}`);
    for (const el of els) {
      const parts = [`- ${el.name} (${el.type})`];
      if (el.compId) {
        parts.push(`id="${el.compId}" -> vlid:${el.compId}`);
      }
      if (el.label && !el.label.startsWith('$')) parts.push(`label="${el.label}"`);
      if (el.placeholder) parts.push(`placeholder="${el.placeholder}"`);
      if (el.inputType) parts.push(`inputType="${el.inputType}"`);
      lines.push(parts.join(' '));
    }
    lines.push('');
  }
  return { source: 'metadata', interactiveElements, elementPrompt: lines.join('\n') };
}

function filterMetadataForApp(metadata, targetApp) {
  const appSectionIds = new Set();
  for (const page of (targetApp.pages || [])) {
    for (const ref of (page.sectionRefs || [])) {
      if (ref.sectionId) appSectionIds.add(ref.sectionId);
    }
  }
  for (const sectionId of Object.values(targetApp.routeMap || {})) {
    appSectionIds.add(sectionId);
  }
  return {
    ...metadata,
    apps: [targetApp],
    sections: (metadata.sections || []).filter(s => appSectionIds.has(s.sectionId)),
  };
}

function extractMetadataFromWorkspace(projectContext, fileCache = null) {
  const VLMetadataExtractor = require('./autotest/vl-metadata-extractor.cjs');
  const files = projectContext.getAllFiles();
  const fileTree = {};
  for (const f of files) {
    try {
      const cached = fileCache?.getCached?.(f.path);
      fileTree[f.path] = cached != null ? cached : fs.readFileSync(f.fullPath, 'utf-8');
    } catch {}
  }
  return VLMetadataExtractor.extract(fileTree);
}

const PAGINATION_VLIDS = new Set([
  'nextButton',
  'prevButton',
  'nextPageBtn',
  'prevPageBtn',
  'nextPage',
  'prevPage',
]);

function extractVlidId(selector) {
  if (typeof selector !== 'string' || !selector.startsWith('vlid:')) return null;
  return selector.split(/\s+/)[0].slice(5).trim() || null;
}

async function discoverVisiblePageElements(testPlan, baseUrl, runnerConfig) {
  if (!testPlan?.pages?.length || !baseUrl) return {};

  const runner = new TestRunner({
    ...runnerConfig,
    headless: true,
    slowMo: 0,
    screenshotDir: path.join(runnerConfig.screenshotDir || '.', 'discovery'),
  });

  const pageVisibility = {};

  try {
    await runner.launch();

    for (const page of testPlan.pages) {
      const pageKey = (page.path || '').toLowerCase() || '/';
      await runner._navigateTo(baseUrl);

      for (const navEntry of (page.navPath || [])) {
        const locator = await runner._resolveSelector(navEntry);
        if (!locator) break;
        await locator.first().click({ force: true, timeout: 3000 }).catch(() => null);
        await runner.page.waitForTimeout(500);
      }

      const visibleIds = await runner.page.evaluate(() => {
        return Array.from(document.querySelectorAll('[data-vl-id]'))
          .filter(el => {
            const rect = el.getBoundingClientRect();
            const style = window.getComputedStyle(el);
            return rect.width > 0 && rect.height > 0 &&
              rect.bottom > 0 && rect.right > 0 &&
              style.display !== 'none' && style.visibility !== 'hidden' && style.opacity !== '0';
          })
          .map(el => el.getAttribute('data-vl-id'))
          .filter(Boolean);
      });

      pageVisibility[pageKey] = {
        visibleIds: [...new Set(visibleIds)],
      };
    }
  } finally {
    await runner.close().catch(() => {});
  }

  return pageVisibility;
}

export function filterInapplicableCasesForVisibility(testCases, pageVisibility = {}) {
  if (!Array.isArray(testCases) || testCases.length === 0) {
    return { testCases: [], dropped: [] };
  }

  const kept = [];
  const dropped = [];

  for (const testCase of testCases) {
    const pageKey = (testCase.page || '').toLowerCase();
    const visibleIds = new Set(pageVisibility[pageKey]?.visibleIds || []);

    const missingPagination = testCase.steps?.some(step => {
      const id = extractVlidId(step?.selector);
      return id && PAGINATION_VLIDS.has(id) && !visibleIds.has(id);
    });

    if (missingPagination) {
      dropped.push({
        id: testCase.id,
        page: testCase.page || '',
        reason: 'pagination_controls_not_visible',
      });
      continue;
    }

    kept.push(testCase);
  }

  return { testCases: kept, dropped };
}

export function filterUnreachableCasesForNavigation(testCases, testPlan = null) {
  if (!Array.isArray(testCases) || testCases.length === 0) {
    return { testCases: [], dropped: [] };
  }

  const homePage = String(testPlan?.homePage || '').toLowerCase();
  const pageMap = new Map(
    (testPlan?.pages || []).map((page) => [String(page.path || '').toLowerCase(), page])
  );

  const kept = [];
  const dropped = [];

  for (const testCase of testCases) {
    const pageKey = String(testCase.page || '').toLowerCase();
    const page = pageMap.get(pageKey);
    const isHomePage = pageKey && (pageKey === homePage || pageKey === '/');
    const hasNavPath = Array.isArray(page?.navPath) && page.navPath.length > 0;

    if (page && !isHomePage && !hasNavPath) {
      dropped.push({
        id: testCase.id,
        page: testCase.page || '',
        reason: 'page_navigation_not_available',
      });
      continue;
    }

    kept.push(testCase);
  }

  return { testCases: kept, dropped };
}

function dedupeGeneratedCases(testCases) {
  const seen = new Set();
  const deduped = [];

  for (const testCase of testCases || []) {
    const stepSig = Array.isArray(testCase?.steps)
      ? testCase.steps.map((step) => [
        step?.action || '',
        step?.selector || '',
        step?.value || '',
        step?.expected || '',
      ].join('|')).join('>')
      : '';
    const key = [
      String(testCase?.page || '').toLowerCase(),
      String(testCase?.name || '').trim().toLowerCase(),
      stepSig,
    ].join('::');
    if (seen.has(key)) continue;
    seen.add(key);
    deduped.push(testCase);
  }

  return deduped;
}

// ── Workflow Helpers ──

/** Build a workflow JSON for a set of test cases (per-app) */
function buildTestCaseWorkflow(appId, testCases, baseUrl) {
  const steps = [];
  // One node per test case — each is a Playwright test execution unit
  for (let i = 0; i < testCases.length; i++) {
    const tc = testCases[i];
    const stepId = tc.id || `TC_${String(i + 1).padStart(3, '0')}`;
    const isLast = i === testCases.length - 1;
    steps.push({
      id: stepId,
      type: 'Component_PlaywrightTestCase',
      meta: {
        title: `${tc.name || stepId}`,
        description: tc.expectedResult || '',
        priority: tc.priority || 'medium',
        page: tc.page || '',
        stepsCount: tc.steps?.length || 0,
        subWorkflow: `autotest-tc-${stepId.replace(/[^a-zA-Z0-9_-]/g, '_')}`,
      },
      in: {
        caseId: stepId,
        baseUrl: baseUrl ? `="${baseUrl}"` : '',
        steps: tc.steps || [],
      },
      out: { [`$result_${stepId}`]: '=_result' },
      next: isLast ? 'Stop_Done' : (testCases[i + 1].id || `TC_${String(i + 2).padStart(3, '0')}`),
    });
  }
  // Terminal node
  steps.push({ id: 'Stop_Done', type: 'Stop_', meta: { title: 'All Cases Done' } });
  return {
    version: '3.16',
    name: `AutoTest: ${appId} (${testCases.length} cases)`,
    registry: {
      params: [`baseUrl(STRING) = "${baseUrl || ''}"`],
      services: [],
      apis: [],
      components: ['PlaywrightTestCase(caseId(STRING), baseUrl(STRING), steps(OBJECT)) RETURN result(OBJECT)'],
      vars: testCases.map(tc => `$result_${tc.id || 'TC'}(OBJECT)`),
      files: { inputs: [], artifacts: [`/screenshots/${appId}/`] },
      docs: { description: `Automated test suite for ${appId} — ${testCases.length} test cases executed via Playwright` },
    },
    steps,
  };
}

/** Build a workflow JSON for a single test case (per-step nodes) — VL Workflow Spec 3.16 */
function buildTestStepWorkflow(testCase) {
  const steps = [];
  const tcSteps = testCase.steps || [];
  for (let i = 0; i < tcSteps.length; i++) {
    const s = tcSteps[i];
    const stepId = `STEP_${String(i + 1).padStart(3, '0')}`;
    const isLast = i === tcSteps.length - 1;
    // Map test action to a semantic node type
    const nodeType = s.action === 'navigate' ? 'Component_Navigate'
      : s.action === 'click' ? 'Component_Click'
      : s.action === 'fill' ? 'Component_Fill'
      : s.action === 'select' ? 'Component_Select'
      : s.action === 'screenshot' ? 'Component_Screenshot'
      : s.action === 'assert_visible' ? 'Component_AssertVisible'
      : s.action === 'wait' ? 'Component_Wait'
      : `Component_${s.action || 'Action'}`;
    const selectorShort = s.selector ? (s.selector.length > 50 ? s.selector.slice(0, 47) + '...' : s.selector) : '';
    const label = s.description || `${s.action}${selectorShort ? ' ' + selectorShort : ''}`;
    steps.push({
      id: stepId,
      type: nodeType,
      meta: {
        title: label,
        action: s.action,
        selector: s.selector || '',
        value: s.value || '',
        expected: s.expected || '',
        description: s.description || '',
      },
      in: {
        action: `="${s.action}"`,
        ...(s.selector ? { selector: `="${s.selector}"` } : {}),
        ...(s.value ? { value: `="${s.value}"` } : {}),
        ...(s.url ? { url: `="${s.url}"` } : {}),
      },
      out: { [`$step${i + 1}_result`]: '=_result' },
      next: isLast ? 'Stop_Done' : `STEP_${String(i + 2).padStart(3, '0')}`,
    });
  }
  // Terminal node
  steps.push({ id: 'Stop_Done', type: 'Stop_', meta: { title: 'Test Complete' } });
  // Build component registry from unique action types used
  const actionTypes = [...new Set(tcSteps.map(s => s.action || 'action'))];
  const components = actionTypes.map(a => {
    if (a === 'navigate') return 'Navigate(url(STRING)) RETURN result(OBJECT)';
    if (a === 'click') return 'Click(selector(STRING)) RETURN result(OBJECT)';
    if (a === 'fill') return 'Fill(selector(STRING), value(STRING)) RETURN result(OBJECT)';
    if (a === 'select') return 'Select(selector(STRING), value(STRING)) RETURN result(OBJECT)';
    if (a === 'screenshot') return 'Screenshot() RETURN result(OBJECT)';
    if (a === 'assert_visible') return 'AssertVisible(selector(STRING)) RETURN result(OBJECT)';
    return `${a.charAt(0).toUpperCase() + a.slice(1)}(selector(STRING)) RETURN result(OBJECT)`;
  });
  return {
    version: '3.16',
    name: `Test: ${testCase.name || testCase.id}`,
    registry: {
      params: ['baseUrl(STRING)'],
      services: [],
      apis: [],
      components,
      vars: tcSteps.map((_, i) => `$step${i + 1}_result(OBJECT)`),
      files: { inputs: [], artifacts: ['/screenshots/'] },
      docs: {
        description: testCase.expectedResult || `Test case: ${testCase.name}`,
        preconditions: testCase.preconditions || [],
      },
    },
    steps,
  };
}

/** Build the dynamic mother workflow based on actual apps — VL Workflow Spec 3.16 */
function buildMotherWorkflow(apps) {
  const steps = [
    { id: 'Local_Compile', type: 'Component_Compile', meta: { title: '1. Compile Preview' }, in: {}, out: { '$compileResult': '=_result' }, next: 'Extract_Metadata' },
    { id: 'Extract_Metadata', type: 'Component_ExtractMeta', meta: { title: '2. Extract Metadata' }, in: {}, out: { '$metadata': '=_result' }, next: 'Generate_Tests' },
    { id: 'Generate_Tests', type: 'LLM_GenerateTests', meta: { title: '3. Generate Test Cases' }, in: { messages: [{ role: 'user', content: '=$metadata' }], max_tokens: 16000 }, out: { '$testCases': '=_result' }, next: apps.length > 0 ? 'Set_RunStart' : 'Evaluate_Results' },
  ];
  // Fan-out node for parallel app runs (Spec 3.16: children for parallel execution)
  if (apps.length > 0) {
    steps.push({
      id: 'Set_RunStart', type: 'Set_', meta: { title: '4. Start Test Runs' },
      target: '$runStatus', value: '"running"',
      children: apps.map(a => `Run_${a}`),
      next: 'Evaluate_Results',
    });
    for (let i = 0; i < apps.length; i++) {
      steps.push({
        id: `Run_${apps[i]}`, type: 'Component_PlaywrightRun',
        meta: { title: `4${String.fromCharCode(97 + i)}. Run ${apps[i]}`, subWorkflow: `autotest-${apps[i].replace(/[^a-zA-Z0-9_-]/g, '_')}` },
        in: { appId: `="${apps[i]}"` }, out: { [`$runResult_${apps[i]}`]: '=_result' },
        next: 'RETURN',
      });
    }
  }
  steps.push(
    { id: 'Evaluate_Results', type: 'Component_Evaluate', meta: { title: '5. Evaluate Results' }, in: {}, out: { '$evaluations': '=_result' }, next: 'Branch_Pass_Fail' },
    { id: 'Branch_Pass_Fail', type: 'Branch_', meta: { title: '6. Pass / Fail?' }, in: { expression: '=$evaluations.allPassed' }, cases: [['true', 'Report_Success'], ['false', 'Analyze_Failures']] },
    { id: 'Analyze_Failures', type: 'LLM_AnalyzeFailures', meta: { title: '7a. Analyze Failures' }, in: { messages: [{ role: 'user', content: '=$evaluations' }], max_tokens: 16000 }, out: { '$fixResults': '=_result' }, next: 'User_Decision' },
    { id: 'User_Decision', type: 'Branch_', meta: { title: '8. Choose Action' }, in: { expression: '=$userChoice' }, cases: [['fix_and_rerun', 'Apply_Fixes'], ['report_only', 'Report_Failures'], ['skip', 'Stop_Pipeline']] },
    { id: 'Apply_Fixes', type: 'Component_ApplyFixes', meta: { title: '9. Apply VL Fixes' }, in: {}, next: 'Recompile' },
    { id: 'Recompile', type: 'Component_Compile', meta: { title: '10. Recompile' }, in: {}, next: apps.length > 0 ? 'Set_RunStart' : 'Evaluate_Results' },
    { id: 'Report_Success', type: 'Stop_', meta: { title: 'Report: All Passed' } },
    { id: 'Report_Failures', type: 'Stop_', meta: { title: 'Report: With Failures' } },
    { id: 'Stop_Pipeline', type: 'Stop_', meta: { title: 'Pipeline Stopped' } },
  );
  return {
    version: '3.16', name: 'AutoTest Pipeline',
    registry: {
      params: [],
      services: [],
      apis: [],
      components: [
        'Compile() RETURN compileResult(OBJECT)',
        'ExtractMeta() RETURN metadata(OBJECT)',
        'PlaywrightRun(appId(STRING)) RETURN result(OBJECT)',
        'Evaluate() RETURN evaluations(OBJECT)',
        'ApplyFixes() RETURN fixResult(OBJECT)',
      ],
      vars: ['$compileResult(OBJECT)', '$metadata(OBJECT)', '$testCases(OBJECT)', '$runStatus(STRING)', '$evaluations(OBJECT)', '$fixResults(OBJECT)', '$userChoice(STRING)',
        ...apps.map(a => `$runResult_${a}(OBJECT)`)],
      files: { inputs: [], artifacts: ['/screenshots/', '/.vl-code/autotest-results.json'] },
      docs: { description: 'AutoTest Pipeline: generate test cases, run Playwright tests, evaluate results, and auto-fix VL source on failure' },
    },
    steps,
  };
}

/** Save a workflow JSON to the project workflows directory */
function saveWorkflowJson(workDir, name, workflow) {
  const dir = path.join(workDir, '.vl-code', 'workflows');
  if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
  fs.writeFileSync(path.join(dir, name + '.json'), JSON.stringify(workflow, null, 2), 'utf-8');
}

async function compileForAutotest(config, bc) {
  bc({ type: 'workflow_node_update', nodeId: 'Local_Compile', status: 'running' });
  bc({ type: 'autotest_progress', phase: 'compile', status: 'running', message: 'Compiling current workspace...' });

  const compileTool = createVLCompileTool(config);
  const raw = await compileTool.execute({});
  let parsed;
  try {
    parsed = JSON.parse(raw);
  } catch {
    bc({ type: 'workflow_node_update', nodeId: 'Local_Compile', status: 'error' });
    throw new Error(`Compile returned invalid JSON: ${String(raw).slice(0, 200)}`);
  }

  if (!parsed.success) {
    bc({ type: 'workflow_node_update', nodeId: 'Local_Compile', status: 'error' });
    bc({
      type: 'autotest_detail',
      phase: 'compile',
      detailType: 'error',
      message: `Compile failed: ${parsed.error || `${parsed.errCount || 0} errors`}`,
      data: parsed.errList || [],
    });
    return {
      error: parsed.error || `Compile failed with ${parsed.errCount || 0} error(s)`,
      compile: parsed,
      baseUrl: '',
    };
  }

  const previewUrls = parsed.previewUrls || {};
  const baseUrl = Object.values(previewUrls)[0] || '';
  bc({ type: 'workflow_node_update', nodeId: 'Local_Compile', status: 'done' });
  bc({
    type: 'autotest_progress',
    phase: 'compile',
    status: 'done',
    message: `Compile succeeded${baseUrl ? `: ${baseUrl}` : ''}`,
  });

  if (!baseUrl) {
    return {
      error: 'Compilation succeeded but no preview URL was returned.',
      compile: parsed,
      baseUrl: '',
    };
  }

  return { compile: parsed, baseUrl };
}

// ── Tool Factory ──

export function createAutoTestPipelineTool(config, projectContext) {
  return {
    description: 'Automated testing pipeline for VL applications. Generates test cases, runs Playwright browser tests, evaluates results, and analyzes failures.',
    parameters: {
      type: 'object',
      properties: {
        action: {
          type: 'string',
          enum: ['full', 'generate', 'run', 'fix', 'fixVL', 'report', 'status'],
          description: 'Pipeline action: full (compile + generate + run), generate (create test cases), run (execute with Playwright), fix (analyze failures), fixVL (VL source fixes), report (markdown summary), status (current state)',
        },
        baseUrl: { type: 'string', description: 'Preview URL to test against (for generate/run). Uses last compiled URL if omitted.' },
        maxCases: { type: 'number', description: 'Max test cases to generate (default: from config)' },
        parallelWorkers: { type: 'number', description: 'Number of parallel browsers (1-20, default: from config)' },
        headless: { type: 'boolean', description: 'Run browser in headless mode (default: from config, typically false)' },
        caseIds: { type: 'array', items: { type: 'string' }, description: 'Filter: only run/fix specific test case IDs' },
      },
      required: ['action'],
    },

    execute: async (input) => {
      const autoConfig = config.autotest || {};
      const bc = (msg) => { try { config._broadcast?.(msg); } catch {} };
      ensureAutoTestCache(config.workDir);
      const trackedActions = new Set(['full', 'generate', 'run', 'fix', 'fixVL']);
      const activeToken = trackedActions.has(input.action)
        ? `${input.action}:${Date.now()}:${Math.random().toString(36).slice(2, 8)}`
        : null;

      if (activeToken) {
        _state.activeAction = input.action;
        _state.activeToken = activeToken;
      }

      try {
        switch (input.action) {

          // ────────── FULL PIPELINE ──────────
          case 'full': {
            log('Full', '=== Compile -> Generate -> Run ===');
            const compileResult = await compileForAutotest(config, bc);
            if (compileResult.error) {
              return {
                error: `AutoTest full failed during compile: ${compileResult.error}`,
                compile: compileResult.compile,
              };
            }

            const baseUrl = input.baseUrl || compileResult.baseUrl;
            const nestedTool = createAutoTestPipelineTool(config, projectContext);
            const generateResult = await nestedTool.execute({
              action: 'generate',
              baseUrl,
              maxCases: input.maxCases,
            });
            if (generateResult?.error) {
              return {
                error: generateResult.error,
                compile: compileResult.compile,
                generate: generateResult,
              };
            }

            const runResult = await nestedTool.execute({
              action: 'run',
              baseUrl,
              caseIds: input.caseIds,
              parallelWorkers: input.parallelWorkers,
              headless: input.headless,
            });
            if (runResult?.error) {
              return {
                error: runResult.error,
                compile: compileResult.compile,
                generate: generateResult,
                run: runResult,
              };
            }

            let fixResult = null;
            if ((runResult.failed || 0) > 0 || (runResult.softPassed || 0) > 0) {
              fixResult = await nestedTool.execute({ action: 'fix' });
            }
            const reportResult = await nestedTool.execute({ action: 'report' });

            return {
              result: `AutoTest pipeline complete: compile ok, ${runResult.passed || 0} passed, ${runResult.softPassed || 0} soft-passed, ${runResult.failed || 0} failed.`,
              compile: compileResult.compile,
              generate: generateResult,
              run: runResult,
              fix: fixResult,
              report: reportResult?.result || '',
            };
          }

          // ────────── GENERATE ──────────
          case 'generate': {
            log('Generate', '=== Generating test cases ===');
            bc({ type: 'workflow_node_update', nodeId: 'Extract_Metadata', status: 'running' });
            bc({ type: 'autotest_progress', phase: 'generate', status: 'running', message: 'Extracting metadata...' });

            // 1. Extract metadata from workspace
            _state.metadata = extractMetadataFromWorkspace(projectContext, config.fileCache || null);
            const apps = _state.metadata?.apps || [];
            log('Generate', `Metadata: ${apps.length} apps, ${_state.metadata?.sections?.length || 0} sections`);
            // Detailed metadata to detail panel
            bc({ type: 'autotest_detail', phase: 'metadata', message: `Extracted metadata: ${apps.length} apps, ${_state.metadata?.sections?.length || 0} sections, ${_state.metadata?.components?.length || 0} components, ${_state.metadata?.services?.length || 0} services`,
              data: { apps: apps.map(a => ({ appId: a.appId, pages: a.pages?.length || 0, routes: Object.keys(a.routeMap || {}).length })),
                sections: (_state.metadata?.sections || []).map(s => ({ id: s.sectionId, elements: s.interactiveElements?.length || 0 })),
              }});

            // 2. Get base URL from input, config, or project profile
            let baseUrl = input.baseUrl || '';
            if (!baseUrl) {
              try {
                const profilePath = path.join(config.workDir, '.vl-code', 'project.json');
                const profile = JSON.parse(fs.readFileSync(profilePath, 'utf-8'));
                const urls = profile.previewUrls || {};
                baseUrl = Object.values(urls)[0] || '';
              } catch {}
            }
            if (!baseUrl) return { error: 'No preview URL available. Compile the project first (VLCompile tool) or provide baseUrl.' };

            const previewCacheKey = buildPreviewCacheKey(config.workDir, baseUrl);
            const workspaceFingerprint = buildWorkspaceFingerprint(projectContext, config.fileCache || null);

            bc({ type: 'workflow_node_update', nodeId: 'Extract_Metadata', status: 'done' });
            bc({ type: 'workflow_node_update', nodeId: 'Generate_Tests', status: 'running' });

            // 3. Build component info from metadata
            let componentInfo = null;
            if (_state.metadata?.sections?.some(s => s.interactiveElements?.length > 0)) {
              componentInfo = buildComponentInfoFromMetadata(_state.metadata);
            }

            // 4. Generate test cases per app
            const maxCases = input.maxCases || autoConfig.maxCases || 8;
            const useWF = autoConfig.useWorkflowEngine !== false;
            const wfEngineUrl = autoConfig.wfEngineUrl || 'https://editor.visuallogic.ai/ih5/play/stream';

            let cookie = '';
            try {
              const profilePath = path.join(config.workDir, '.vl-code', 'project.json');
              const profile = JSON.parse(fs.readFileSync(profilePath, 'utf-8'));
              cookie = profile.cookie || '';
            } catch {}

            const generator = new TestGenerator(null);
            let sdkClient = null;
            const ensureSdkClient = () => {
              if (!sdkClient) sdkClient = createAutoTestSdkProvider(config);
              generator.client = sdkClient;
              return sdkClient;
            };

            if (_state.navCacheKey !== previewCacheKey) {
              _state.navElements = null;
              _state.navCacheKey = previewCacheKey;
            }

            if (_state.navElements === null) {
              try {
                const cachedNavElements = getCacheEntry(config.workDir, 'navDiscovery', previewCacheKey);
                if (cachedNavElements) {
                  _state.navElements = cachedNavElements;
                  bc({
                    type: 'autotest_detail',
                    phase: 'metadata',
                    detailType: 'success',
                    message: `Navigation discovery cache hit: ${_state.navElements.length} visible id-bearing element(s)`,
                  });
                } else {
                  bc({ type: 'autotest_detail', phase: 'metadata', message: 'Discovering real navigation controls from compiled app DOM...' });
                  _state.navElements = await TestRunner.discoverNavElementsOnce(baseUrl);
                  setCacheEntry(config.workDir, 'navDiscovery', previewCacheKey, _state.navElements);
                }
                bc({
                  type: 'autotest_detail',
                  phase: 'metadata',
                  detailType: 'success',
                  message: `Navigation discovery found ${_state.navElements.length} visible id-bearing element(s)`,
                  data: _state.navElements.map((el) => ({
                    instanceId: el.instanceId,
                    text: el.text,
                    position: el.position,
                    isNavButton: el.isNavButton,
                  })),
                });
              } catch (navErr) {
                _state.navElements = [];
                bc({
                  type: 'autotest_detail',
                  phase: 'metadata',
                  detailType: 'warn',
                  message: `Navigation discovery failed: ${navErr.message}`,
                });
              }
            }

            const postProcessAppCases = async (appId, appTestPlan, appTestCases) => {
              if (!appTestPlan || !appTestCases.length) return appTestCases;

              const visibilityCacheKey = buildPageVisibilityCacheKey({ previewCacheKey, appId, testPlan: appTestPlan });
              const cachedVisibility = getCacheEntry(config.workDir, 'pageVisibility', visibilityCacheKey);
              if (cachedVisibility) {
                _state.pageVisibility = cachedVisibility;
                bc({
                  type: 'autotest_detail',
                  phase: 'generate',
                  detailType: 'success',
                  message: `[Visibility] Cache hit for ${appId}`,
                });
              } else {
                _state.pageVisibility = await discoverVisiblePageElements(appTestPlan, baseUrl, {
                  baseUrl,
                  timeout: 10000,
                  viewport: { width: 1920, height: 1080 },
                  cookie: config.cookie || cookie || '',
                  screenshotDir: path.join(config.workDir, '.vl-code', 'screenshots'),
                }).catch(() => ({}));
                setCacheEntry(config.workDir, 'pageVisibility', visibilityCacheKey, _state.pageVisibility);
              }

              let filteredCases = generator._replaceAllInvalidSelectors(appTestCases);
              filteredCases = generator._fixNavigation(filteredCases, appTestPlan, baseUrl, _state.navElements);

              const navFiltered = filterUnreachableCasesForNavigation(filteredCases, appTestPlan);
              filteredCases = navFiltered.testCases;
              if (navFiltered.dropped.length > 0) {
                log('Generate', `[Nav] Dropped ${navFiltered.dropped.length} unreachable case(s) for ${appId}`);
                bc({
                  type: 'autotest_detail',
                  phase: 'generate',
                  detailType: 'warn',
                  message: `[Navigation] Dropped ${navFiltered.dropped.length} case(s) for pages without a reachable navPath`,
                  data: navFiltered.dropped,
                });
              }

              const visibilityFiltered = filterInapplicableCasesForVisibility(filteredCases, _state.pageVisibility);
              filteredCases = visibilityFiltered.testCases;
              if (visibilityFiltered.dropped.length > 0) {
                log('Generate', `[Visibility] Dropped ${visibilityFiltered.dropped.length} inapplicable case(s) for ${appId}`);
                bc({
                  type: 'autotest_detail',
                  phase: 'generate',
                  detailType: 'warn',
                  message: `[Visibility] Dropped ${visibilityFiltered.dropped.length} case(s) with pagination controls that are not visible on the compiled page`,
                  data: visibilityFiltered.dropped,
                });
              }

              return filteredCases;
            };

            _state.testCases = [];
            _state.testPlan = null;
            const appsToGenerate = apps.length > 0 ? apps : [{ appId: 'App', pages: [], routeMap: {} }];

            // Generate test cases for all apps in PARALLEL
            const generateForApp = async (app, i) => {
              const appId = app.appId;
              const appMeta = appsToGenerate.length > 1 ? filterMetadataForApp(_state.metadata, app) : _state.metadata;
              const promptCacheKey = buildPromptCacheKey({ appId, workspaceFingerprint, appMeta, navElements: _state.navElements });
              let metadataContext = getCacheEntry(config.workDir, 'promptFragments', promptCacheKey);
              if (metadataContext) {
                bc({
                  type: 'autotest_detail',
                  phase: 'generate',
                  detailType: 'success',
                  message: `[Prompt Cache] Metadata context hit for ${appId}`,
                });
              } else {
                metadataContext = generator.buildMetadataContext(appMeta, _state.navElements);
                setCacheEntry(config.workDir, 'promptFragments', promptCacheKey, metadataContext);
              }
              const compInfoStr = componentInfo?.elementPrompt || '';

              bc({ type: 'autotest_progress', phase: 'generate', status: 'running', message: `Generating tests for ${appId} (${i + 1}/${appsToGenerate.length})...` });
              bc({ type: 'autotest_detail', phase: 'generate', message: `App ${appId}: ${(appMeta.sections || []).length} sections, metadata context ${metadataContext.length} chars`, data: { appId, sections: (appMeta.sections || []).map(s => s.sectionId) } });

              let appTestPlan = null;
              let appTestCases = [];

              // Try Local JS Workflow Engine first (preferred)
              if (useWF) {
                try {
                  log('Generate', `[LocalWF] Using JS engine for ${appId}`);
                  const { WorkflowExecutor } = await import('../vl/workflow-executor.js');
                  const runLocalWorkflow = async (wfJson, params, workflowLabel) => {
                    const executor = new WorkflowExecutor(config);
                    await executor.execute(wfJson, params, {
                      onNodeStart: (info) => {
                        log('Generate', `[LocalWF] [${workflowLabel}] Node ${info.nodeId} (${info.type}) started`);
                        bc({ type: 'workflow_node_update', nodeId: info.nodeId, status: 'running', title: info.title || info.nodeId, nodeType: info.type });
                        bc({ type: 'autotest_detail', phase: 'generate', message: `[JS Engine] [${workflowLabel}] Node ${info.nodeId} (${info.type}) started`, detailType: 'node_start', data: { nodeId: info.nodeId, type: info.type, title: info.title } });
                      },
                      onNodeDone: (info) => {
                        log('Generate', `[LocalWF] [${workflowLabel}] Node ${info.nodeId} done`);
                        bc({ type: 'workflow_node_update', nodeId: info.nodeId, status: 'done', title: info.title || info.nodeId });
                        bc({ type: 'autotest_detail', phase: 'generate', message: `[JS Engine] [${workflowLabel}] Node ${info.nodeId} done`, detailType: 'node_done', data: { nodeId: info.nodeId } });
                      },
                      onToken: (token) => {
                        bc({ type: 'autotest_llm_token', token });
                      },
                      onFileWritten: (info) => {
                        bc({ type: 'autotest_detail', phase: 'generate', message: `[JS Engine] [${workflowLabel}] File written: ${info.path || info}`, detailType: 'file_write' });
                      },
                      onDone: () => {
                        log('Generate', `[LocalWF] [${workflowLabel}] Workflow done for ${appId}`);
                        bc({ type: 'autotest_detail', phase: 'generate', message: `[JS Engine] [${workflowLabel}] Workflow completed for ${appId}`, detailType: 'success' });
                      },
                      onError: (msg) => { throw new Error(msg); },
                      onVarsChanged: (stepId, vars) => {
                        const varNames = Object.keys(vars || {});
                        bc({ type: 'autotest_detail', phase: 'generate', message: `[JS Engine] [${workflowLabel}] Variables updated after ${stepId}: ${varNames.join(', ')}`, detailType: 'vars', data: { stepId, varNames } });
                      },
                    });
                    return executor._ctx;
                  };

                  const parseCacheKey = buildParseCacheKey({ appId, workspaceFingerprint, appMeta, navElements: _state.navElements });
                  const cachedPlan = getCacheEntry(config.workDir, 'parsedPlans', parseCacheKey);
                  if (cachedPlan) {
                    appTestPlan = cachedPlan;
                    bc({
                      type: 'autotest_detail',
                      phase: 'generate',
                      detailType: 'success',
                      message: `[Parse Cache] Hit for ${appId}`,
                    });
                  } else {
                    bc({ type: 'autotest_detail', phase: 'generate', message: `[JS Engine] Parsing app context for ${appId}...` });
                    const parseWorkflow = WorkflowBuilder.buildParseAppContextWorkflow({ metadataContext, appId });
                    const parseCtx = await runLocalWorkflow(parseWorkflow.wfJson, parseWorkflow.params, `${appId}:parse`);
                    const planVar = parseCtx?.variables?.['$planRaw'] || parseCtx?.variables?.['$testPlan'] || parseCtx?.variables?.['$appTestPlan'];
                    if (planVar) {
                      appTestPlan = typeof planVar === 'string' ? generator._parseJSON(planVar, 'TestPlan') : planVar;
                      setCacheEntry(config.workDir, 'parsedPlans', parseCacheKey, appTestPlan);
                    }
                  }
                  if (!appTestPlan) {
                    throw new Error('Parse workflow returned no TestPlan');
                  }

                  const allTargets = WorkflowBuilder.planCaseGenerationTargets(appTestPlan, maxCases);
                  const cachedCaseGroups = [];
                  const missingTargets = [];
                  const caseCacheKeyFor = (target) => buildGeneratedCasesCacheKey({
                    appId,
                    workspaceFingerprint,
                    appMeta,
                    baseUrl,
                    navElements: _state.navElements,
                    target,
                    workDir: config.workDir,
                  });

                  for (const target of allTargets) {
                    const cachedCases = getCacheEntry(config.workDir, 'generatedCases', caseCacheKeyFor(target));
                    if (cachedCases) {
                      cachedCaseGroups.push(...cachedCases.map((testCase) => ({
                        ...testCase,
                        _generationTargetId: testCase._generationTargetId || target.id,
                      })));
                    } else {
                      missingTargets.push(target);
                    }
                  }

                  if (cachedCaseGroups.length > 0) {
                    bc({
                      type: 'autotest_detail',
                      phase: 'generate',
                      detailType: 'success',
                      message: `[Case Cache] ${appId}: ${cachedCaseGroups.length} cached case(s) across ${allTargets.length - missingTargets.length} target(s)`,
                    });
                  }

                  if (missingTargets.length > 0) {
                    const caseWorkflow = WorkflowBuilder.buildCaseGenerationWorkflow({
                      testPlan: appTestPlan,
                      baseUrl,
                      maxCases,
                      appId,
                      componentInfo: compInfoStr,
                      targets: missingTargets,
                    });

                    if ((caseWorkflow.targets?.length || 0) > 1) {
                      bc({
                        type: 'autotest_detail',
                        phase: 'generate',
                        detailType: 'success',
                        message: `[JS Engine] Parallel case generation for ${appId}: ${caseWorkflow.targets.length} uncached target(s)`,
                        data: caseWorkflow.targets.map((target) => ({
                          kind: target.kind,
                          label: target.label,
                          maxCases: target.maxCases,
                        })),
                      });
                    }

                    const caseCtx = await runLocalWorkflow(caseWorkflow.wfJson, caseWorkflow.params, `${appId}:cases`);
                    const parsedGroups = [];
                    for (const [index, outputVar] of (caseWorkflow.outputVars || ['$casesRaw']).entries()) {
                      const rawCases = caseCtx?.variables?.[outputVar];
                      if (!rawCases) continue;
                      const target = caseWorkflow.targets?.[index] || missingTargets[index] || { id: 'app' };
                      try {
                        const parsed = typeof rawCases === 'string' ? generator._parseJSON(rawCases, 'TestCases') : rawCases;
                        const cases = parsed?.testCases || (Array.isArray(parsed) ? parsed : []);
                        if (Array.isArray(cases) && cases.length > 0) {
                          parsedGroups.push(...cases.map((testCase) => ({
                            ...testCase,
                            _generationTargetId: target.id,
                          })));
                        }
                      } catch (parseErr) {
                        log('Generate', `[LocalWF] Failed to parse ${outputVar} for ${appId}: ${parseErr.message}`);
                        bc({
                          type: 'autotest_detail',
                          phase: 'generate',
                          detailType: 'warn',
                          message: `[JS Engine] Could not parse ${outputVar} for ${appId}: ${parseErr.message}`,
                        });
                      }
                    }
                    appTestCases = dedupeGeneratedCases([...cachedCaseGroups, ...parsedGroups]);
                  } else {
                    appTestCases = dedupeGeneratedCases(cachedCaseGroups);
                  }

                  if (appTestPlan && appTestCases.length > 0) {
                    appTestCases = await postProcessAppCases(appId, appTestPlan, appTestCases);
                  }

                  for (const target of allTargets) {
                    const casesForTarget = appTestCases.filter((testCase) => (testCase._generationTargetId || 'app') === target.id);
                    setCacheEntry(
                      config.workDir,
                      'generatedCases',
                      caseCacheKeyFor(target),
                      stripGeneratedCaseInternals(casesForTarget)
                    );
                  }
                  log('Generate', `[LocalWF] ${appId}: ${appTestCases.length} cases`);
                  if (appTestCases.length === 0 && appTestPlan) {
                    // Plan parsed but no cases — treat as partial failure, allow fallback
                    log('Generate', `[LocalWF] ${appId}: plan found but 0 cases — will try fallback`);
                    bc({ type: 'autotest_detail', phase: 'generate', message: `[JS Engine] ${appId}: plan found but 0 test cases — trying fallback...`, detailType: 'warn' });
                    appTestPlan = null;
                  } else {
                    bc({ type: 'autotest_detail', phase: 'generate', message: `[JS Engine] ${appId}: generated ${appTestCases.length} test cases`, detailType: 'success',
                      data: { appId, caseCount: appTestCases.length, cases: appTestCases.map(tc => ({ id: tc.id, name: tc.name, priority: tc.priority })) } });
                  }
                } catch (localErr) {
                  log('Generate', `[LocalWF] Failed for ${appId}: ${localErr.message}, trying cloud...`);
                  bc({ type: 'autotest_detail', phase: 'generate', message: `[JS Engine] Failed: ${localErr.message}. Trying cloud engine...`, detailType: 'warn' });
                  appTestPlan = null;
                  appTestCases = [];
                }
              }

              // Try Cloud Workflow Engine (fallback from local)
              if (!appTestPlan && useWF && cookie) {
                try {
                  log('Generate', `[CloudWF] Using cloud engine for ${appId}`);
                  bc({ type: 'autotest_detail', phase: 'generate', message: `[Cloud Engine] Submitting workflow for ${appId}...` });
                  const runner = new WorkflowRunner(wfEngineUrl, cookie);
                  const { wfJson, params } = WorkflowBuilder.buildGenerationWorkflow({
                    metadataContext, baseUrl, maxCases, appId, componentInfo: compInfoStr,
                  });
                  if (!supportsCloudWorkflowSpecVersion(wfJson.version)) {
                    throw new Error(`Workflow spec ${wfJson.version} is not supported by cloud engine`);
                  }
                  const wfResult = await runner.run(wfJson, params, (evt) => {
                    bc({ type: 'autotest_workflow_event', event: evt });
                  });
                  if (!wfResult.success) throw new Error(wfResult.error?.message || 'Workflow failed');
                  const planRaw = wfResult.results['LLM_ParseAppContext']?._result;
                  const casesRaw = wfResult.results['LLM_GenTestCases']?._result;
                  if (planRaw) appTestPlan = generator._parseJSON(planRaw, 'TestPlan');
                  if (casesRaw) {
                    const parsed = generator._parseJSON(casesRaw, 'TestCases');
                    appTestCases = parsed.testCases || parsed || [];
                  }
                  if (appTestPlan && appTestCases.length > 0) {
                    appTestCases = await postProcessAppCases(appId, appTestPlan, appTestCases);
                  }
                  log('Generate', `[CloudWF] ${appId}: ${appTestCases.length} cases`);
                  bc({ type: 'autotest_detail', phase: 'generate', message: `[Cloud Engine] ${appId}: generated ${appTestCases.length} test cases`, detailType: 'success',
                    data: { appId, caseCount: appTestCases.length, cases: appTestCases.map(tc => ({ id: tc.id, name: tc.name, priority: tc.priority })) } });
                } catch (wfErr) {
                  log('Generate', `[CloudWF] Failed for ${appId}: ${wfErr.message}, falling back to SDK`);
                  bc({ type: 'autotest_detail', phase: 'generate', message: `[Cloud Engine] Failed for ${appId}: ${wfErr.message}. Falling back to Anthropic SDK...`, detailType: 'warn' });
                  appTestPlan = null;
                  appTestCases = [];
                }
              }

              // Fallback to Anthropic SDK
              if (!appTestPlan) {
                let client;
                try {
                  client = ensureSdkClient();
                } catch {
                  return { error: `Cannot generate tests for ${appId}: Workflow engines failed and no LLM provider is available for SDK fallback.` };
                }
                log('Generate', `[SDK] Using ${client.name || 'llm'} fallback for ${appId}`);
                bc({ type: 'autotest_detail', phase: 'generate', message: `[SDK Fallback] Generating tests for ${appId} via ${client.name || 'llm'}...` });
                appTestPlan = await generator.parseAppContext(appMeta, {}, () => {}, _state.navElements);
                appTestCases = await generator.generateTestCases(
                  appTestPlan,
                  { baseUrl, componentInfo, navElements: _state.navElements, maxCases },
                  () => {}
                );
                appTestCases = await postProcessAppCases(appId, appTestPlan, appTestCases);
                bc({ type: 'autotest_detail', phase: 'generate', message: `[Anthropic SDK] ${appId}: generated ${appTestCases.length} test cases`, detailType: 'success',
                  data: { appId, caseCount: appTestCases.length, cases: appTestCases.map(tc => ({ id: tc.id, name: tc.name, priority: tc.priority })) } });
              }

              if (!appTestPlan) {
                return { error: `Cannot generate tests for ${appId}: Workflow Engine failed and no API key configured.` };
              }

              return { appId, appTestPlan, appTestCases };
            };

            // Run all app generations in parallel
            const genResults = await Promise.all(
              appsToGenerate.map((app, i) => generateForApp(app, i))
            );

            // Collect results
            for (const result of genResults) {
              if (result.error) return result;
              if (!_state.testPlan) _state.testPlan = result.appTestPlan;
              const tagged = result.appTestCases.map((tc, idx) => ({
                ...tc, appId: result.appId,
                id: appsToGenerate.length > 1 ? `${result.appId}_TC_${String(idx + 1).padStart(3, '0')}` : tc.id,
              }));
              _state.testCases.push(...tagged);
            }

            log('Generate', `Total: ${_state.testCases.length} test cases`);
            bc({ type: 'workflow_node_update', nodeId: 'Generate_Tests', status: 'done' });
            bc({ type: 'autotest_progress', phase: 'generate', status: 'done', message: `Generated ${_state.testCases.length} test cases across ${appsToGenerate.length} app(s)` });

            // Save to file for persistence
            const resultPath = path.join(config.workDir, '.vl-code', 'autotest-cases.json');
            fs.writeFileSync(resultPath, JSON.stringify({ testCases: _state.testCases, testPlan: _state.testPlan }, null, 2));

            // ── Layer 1: Dynamic mother workflow (includes actual app names) ──
            const appGroups = {};
            for (const tc of _state.testCases) {
              const key = tc.appId || 'App';
              if (!appGroups[key]) appGroups[key] = [];
              appGroups[key].push(tc);
            }
            const motherWf = buildMotherWorkflow(Object.keys(appGroups));
            saveWorkflowJson(config.workDir, 'autotest-pipeline', motherWf);
            bc({ type: 'autotest_workflow_saved', name: 'autotest-pipeline', appId: '_pipeline', caseCount: _state.testCases.length, level: 'pipeline' });

            // ── Layer 2: Per-app test case workflows ──
            for (const [appId, cases] of Object.entries(appGroups)) {
              const wf = buildTestCaseWorkflow(appId, cases, input.baseUrl || '');
              const safeName = `autotest-${appId.replace(/[^a-zA-Z0-9_-]/g, '_')}`;
              saveWorkflowJson(config.workDir, safeName, wf);
              // Include test case details for the 3-layer UI
              bc({ type: 'autotest_workflow_saved', name: safeName, appId, caseCount: cases.length, level: 'app',
                cases: cases.map(tc => ({ id: tc.id, name: tc.name, priority: tc.priority, stepsCount: tc.steps?.length || 0 })) });
              log('Generate', `Saved workflow: ${safeName} (${cases.length} cases)`);
            }

            // ── Layer 3: Per-test-case step workflows ──
            for (const tc of _state.testCases) {
              if (!tc.steps || tc.steps.length === 0) continue;
              const stepWf = buildTestStepWorkflow(tc);
              const tcWfName = `autotest-tc-${tc.id.replace(/[^a-zA-Z0-9_-]/g, '_')}`;
              saveWorkflowJson(config.workDir, tcWfName, stepWf);
              bc({ type: 'autotest_workflow_saved', name: tcWfName, appId: tc.appId, caseId: tc.id, level: 'testcase',
                caseName: tc.name, stepsCount: tc.steps.length });
            }
            log('Generate', `Saved ${_state.testCases.length} test-case step workflows`);

            return {
              result: `Generated ${_state.testCases.length} test cases across ${appsToGenerate.length} app(s). 3-level workflow hierarchy saved: pipeline + ${Object.keys(appGroups).length} app workflows + ${_state.testCases.length} test-case workflows.`,
              testCases: _state.testCases.map(tc => ({
                id: tc.id, name: tc.name, priority: tc.priority, page: tc.page,
                stepsCount: tc.steps?.length || 0, appId: tc.appId,
              })),
              workflows: Object.keys(appGroups).map(appId => `autotest-${appId.replace(/[^a-zA-Z0-9_-]/g, '_')}`),
            };
          }

          // ────────── RUN ──────────
          case 'run': {
            hydrateAutoTestStateFromDisk(config.workDir);
            if (_state.testCases.length === 0) {
              // Try loading from file
              try {
                const casesPath = path.join(config.workDir, '.vl-code', 'autotest-cases.json');
                const saved = JSON.parse(fs.readFileSync(casesPath, 'utf-8'));
                _state.testCases = saved.testCases || [];
                _state.testPlan = saved.testPlan || null;
              } catch {}
            }
            if (_state.testCases.length === 0) return { error: 'No test cases. Run generate first.' };

            const cases = input.caseIds?.length
              ? _state.testCases.filter(tc => input.caseIds.includes(tc.id))
              : _state.testCases;
            if (cases.length === 0) return { error: 'No matching test cases found.' };

            // Server config (Settings UI) always wins over LLM-supplied input
            const headless = autoConfig.headless !== undefined ? autoConfig.headless : (input.headless ?? false);
            // Default workers: number of unique apps or 3, whichever is smaller — run tests in parallel by default
            const uniqueApps = new Set(cases.map(c => c.appId || 'App')).size;
            const defaultWorkers = Math.max(1, Math.min(uniqueApps, 3));
            const workers = Math.max(1, Math.min(input.parallelWorkers ?? autoConfig.parallelWorkers ?? defaultWorkers, cases.length, 20));
            const screenshotDir = path.join(config.workDir, '.vl-code', 'screenshots');

            // Get baseUrl
            let baseUrl = input.baseUrl || '';
            if (!baseUrl) {
              try {
                const profilePath = path.join(config.workDir, '.vl-code', 'project.json');
                const profile = JSON.parse(fs.readFileSync(profilePath, 'utf-8'));
                const urls = profile.previewUrls || {};
                baseUrl = Object.values(urls)[0] || '';
              } catch {}
            }
            if (!baseUrl) return { error: 'No preview URL. Compile first or provide baseUrl.' };

            const runnerConfig = {
              headless, baseUrl, screenshotDir, timeout: 15000,
              viewport: { width: 1920, height: 1080 },
              cookie: config.cookie || '',
              chromeProfile: config.autotest?.chromeProfile || '',
            };

            log('Run', `Executing ${cases.length} cases, ${workers} browser(s), headless=${headless}`);
            bc({ type: 'workflow_node_update', nodeId: 'Run_Playwright', status: 'running' });
            bc({ type: 'autotest_progress', phase: 'run', status: 'running', message: `Running ${cases.length} tests (${workers} browser${workers > 1 ? 's' : ''})...` });
            bc({ type: 'autotest_detail', phase: 'run', message: `Playwright config: ${workers} browser(s), headless=${headless}, timeout=15s, viewport=1920x1080`,
              data: { cases: cases.length, workers, headless, baseUrl, screenshotDir } });

            let completedCases = 0;
            const onCaseProgress = (p) => {
              if (p.status !== 'running') completedCases++;
              bc({ type: 'autotest_case_done', caseId: p.caseId, status: p.status, current: completedCases, total: cases.length });
              // Update per-case node in autotest workflow DAG
              const nodeStatus = p.status === 'passed' ? 'done' : p.status === 'soft_pass' ? 'skipped' : p.status === 'failed' ? 'error' : 'running';
              bc({ type: 'workflow_node_update', nodeId: p.caseId, status: nodeStatus });
              // Detail: case-level summary
              if (p.status !== 'running') {
                const tc = cases.find(c => c.id === p.caseId);
                const icon = p.status === 'passed' ? '✅' : p.status === 'soft_pass' ? '⚠️' : '❌';
                bc({ type: 'autotest_detail', phase: 'run', detailType: p.status === 'passed' ? 'success' : p.status === 'soft_pass' ? 'warn' : 'error',
                  message: `${icon} ${p.caseId}: ${tc?.name || p.caseId} — ${p.status}${p.result?.softPassCount ? ` (${p.result.softPassCount} soft-passed steps)` : ''}`,
                  data: p.error ? { error: p.error } : null });
              }
            };
            const onStepProgress = (p) => {
              const s = p.step || {};
              log('Run', `  Step ${p.stepIndex + 1}/${p.totalSteps}: ${s.action || '?'} "${s.selector || ''}" → ${s.status || '?'}`);
              // Update step node in the test-case workflow DAG
              const stepNodeId = `STEP_${String(p.stepIndex + 1).padStart(3, '0')}`;
              const stepStatus = s.status === 'passed' ? 'done' : s.status === 'soft_pass' ? 'skipped' : s.status === 'failed' ? 'error' : 'running';
              bc({ type: 'workflow_node_update', nodeId: stepNodeId, status: stepStatus, caseId: p.caseId });
              // Detail: step-level progress
              const icon = s.status === 'passed' ? '✅' : s.status === 'soft_pass' ? '⚠️' : s.status === 'failed' ? '❌' : '⏳';
              bc({ type: 'autotest_detail', phase: 'run',
                message: `[${p.caseId}] Step ${p.stepIndex + 1}/${p.totalSteps}: ${icon} ${s.action || '?'} "${s.selector || ''}" → ${s.status || 'running'}`,
                detailType: s.status === 'failed' ? 'error' : s.status === 'soft_pass' ? 'warn' : s.status === 'passed' ? 'success' : 'info',
                data: s.error ? { error: s.error, screenshot: s.screenshot || null } : null });
            };

            const runCaseIds = new Set(cases.map(c => c.id));
            _state.runResults = _state.runResults.filter(r => !runCaseIds.has(r.caseId));

            if (workers <= 1) {
              const runner = new TestRunner(runnerConfig);
              await runner.launch();
              const results = await runner.runAll(cases, onCaseProgress, onStepProgress);
              _state.runResults.push(...results);
              await runner.close();
            } else {
              const chunks = Array.from({ length: workers }, () => []);
              cases.forEach((tc, i) => chunks[i % workers].push({ ...tc, _origIndex: i }));
              const workerRunners = chunks.filter(c => c.length > 0).map(c => ({ chunk: c, runner: new TestRunner(runnerConfig) }));
              await Promise.all(workerRunners.map(w => w.runner.launch()));
              const workerResults = await Promise.all(
                workerRunners.map(({ runner, chunk }) => runner.runAll(chunk, onCaseProgress, onStepProgress))
              );
              await Promise.all(workerRunners.map(w => w.runner.close().catch(() => {})));
              const flat = [];
              workerRunners.forEach(({ chunk }, wi) => {
                workerResults[wi].forEach((result, ci) => flat.push({ result, origIndex: chunk[ci]._origIndex }));
              });
              flat.sort((a, b) => a.origIndex - b.origIndex);
              _state.runResults.push(...flat.map(f => f.result));
            }

            // Direct evaluation (with soft-pass awareness)
            _state.evaluations = _state.evaluations.filter(e => !runCaseIds.has(e.caseId));
            const newEvals = _state.runResults.filter(r => runCaseIds.has(r.caseId)).map(result => {
              const tc = cases.find(t => t.id === result.caseId);
              const failedSteps = result.steps?.filter(s => s.status === 'failed') || [];
              const softPassSteps = result.steps?.filter(s => s.status === 'soft_pass') || [];
              const isSoftPass = result.status === 'soft_pass';
              return {
                caseId: result.caseId, name: tc?.name || result.caseId,
                evaluation: {
                  pass: result.pass,
                  softPass: isSoftPass,
                  reason: isSoftPass
                    ? `Soft pass: ${softPassSteps.length} steps skipped (selectors not found in DOM)`
                    : result.pass ? 'All steps passed' : `Step failed: ${result.error || 'unknown'}`,
                  evidence: [
                    ...failedSteps.map(s => `❌ Step ${(s.index || 0) + 1} ${s.action} "${s.selector || ''}": ${s.error || 'failed'}`),
                    ...softPassSteps.map(s => `⚠️ Step ${(s.index || 0) + 1} ${s.action} "${s.selector || ''}": soft-pass (not found)`),
                  ].join('\n'),
                },
              };
            });
            _state.evaluations.push(...newEvals);

            const passed = _state.evaluations.filter(e => e.evaluation.pass && !e.evaluation.softPass).length;
            const softPassed = _state.evaluations.filter(e => e.evaluation.softPass).length;
            const failed = _state.evaluations.length - passed - softPassed;

            // Save results
            const resultsPath = path.join(config.workDir, '.vl-code', 'autotest-results.json');
            fs.writeFileSync(resultsPath, JSON.stringify({
              runResults: _state.runResults, evaluations: _state.evaluations,
              timestamp: new Date().toISOString(),
            }, null, 2));

            bc({ type: 'workflow_node_update', nodeId: 'Run_Playwright', status: 'done' });
            bc({ type: 'workflow_node_update', nodeId: 'Evaluate_Results', status: 'done' });
            bc({ type: 'workflow_node_update', nodeId: 'Branch_Pass_Fail', status: (failed > 0 || softPassed > 0) ? 'error' : 'done' });
            const statusMsg = `Done: ${passed} passed, ${softPassed} soft-passed, ${failed} failed out of ${_state.evaluations.length}`;
            bc({ type: 'autotest_progress', phase: 'run', status: 'done', message: statusMsg });
            log('Run', `Results: ${passed} passed, ${softPassed} soft-passed, ${failed} failed`);
            // Detail: full evaluation summary
            bc({ type: 'autotest_detail', phase: 'evaluate', detailType: (failed > 0 || softPassed > 0) ? 'warn' : 'success',
              message: `Evaluation: ${passed} passed, ${softPassed} soft-passed (no interaction), ${failed} failed`,
              data: _state.evaluations.map(e => ({ caseId: e.caseId, name: e.name, pass: e.evaluation.pass, softPass: e.evaluation.softPass, reason: e.evaluation.reason })) });

            const failureDetails = _state.evaluations.filter(e => !e.evaluation.pass).map(e => ({
              caseId: e.caseId, name: e.name, reason: e.evaluation.reason,
              evidence: e.evaluation.evidence, softPass: e.evaluation.softPass || false,
            }));

            // After run complete: prompt user for next action if there are failures or soft-passes
            if (failed > 0 || softPassed > 0) {
              bc({ type: 'workflow_node_update', nodeId: 'Analyze_Failures', status: 'running' });
              bc({ type: 'autotest_run_complete', passed, failed, softPassed, total: _state.evaluations.length, failures: failureDetails.slice(0, 8) });
            } else {
              bc({ type: 'workflow_node_update', nodeId: 'Report_Success', status: 'done' });
            }

            const summary = `Test execution complete: ${passed} passed, ${softPassed} soft-passed (no interaction), ${failed} failed out of ${_state.evaluations.length} total.`;
            return {
              result: summary + ((failed > 0 || softPassed > 0) ? ` Use action "fix" to analyze failures, or "fixVL" for VL source fixes. Soft-passed tests had selectors that could not be found in the DOM.` : ' All tests passed!'),
              passed, failed, softPassed, total: _state.evaluations.length,
              failures: failureDetails,
            };
          }

          // ────────── FIX (test-level) ──────────
          case 'fix': {
            hydrateAutoTestStateFromDisk(config.workDir);
            const failures = _state.evaluations.filter(e => !e.evaluation.pass);
            if (failures.length === 0) return { result: 'No failures to fix. All tests passed!' };

            const { createProvider } = await import('../core/llm-provider.js');
            let client;
            try { client = createProvider(config); } catch { return { error: 'LLM provider not available for failure analysis.' }; }
            const fixer = new AutoFixer(client);

            bc({ type: 'workflow_node_update', nodeId: 'Analyze_Failures', status: 'running' });
            bc({ type: 'autotest_progress', phase: 'fix', status: 'running', message: `Analyzing ${failures.length} failures...` });
            bc({ type: 'autotest_detail', phase: 'fix', message: `Analyzing ${failures.length} failed test case(s)...`,
              data: failures.map(f => ({ caseId: f.caseId, name: f.name, reason: f.evaluation.reason })) });

            _state.fixResults = await fixer.analyzeAllFailures(
              _state.testCases, _state.runResults, _state.evaluations, () => {}
            );

            bc({ type: 'workflow_node_update', nodeId: 'Analyze_Failures', status: 'done' });
            bc({ type: 'workflow_node_update', nodeId: 'User_Decision', status: 'running' });
            bc({ type: 'autotest_progress', phase: 'fix', status: 'done', message: `Analyzed ${_state.fixResults.length} failures` });
            bc({ type: 'autotest_detail', phase: 'fix', detailType: 'success',
              message: `Fix analysis complete: ${_state.fixResults.length} failure(s) analyzed`,
              data: _state.fixResults.map(f => ({ caseId: f.caseId, name: f.name,
                testFixes: f.fix?.testFixes?.length || 0, vlFixes: f.fix?.vlFixes?.length || 0 })) });

            return {
              result: `Analyzed ${_state.fixResults.length} test failure(s).`,
              fixes: _state.fixResults.map(f => ({
                caseId: f.caseId, name: f.name,
                testFixes: f.fix?.testFixes || [],
                vlFixes: f.fix?.vlFixes || [],
              })),
            };
          }

          // ────────── FIX VL SOURCE ──────────
          case 'fixVL': {
            if (_state.fixResults.length === 0) return { error: 'No fix analysis available. Run fix action first.' };

            const vlFixes = _state.fixResults.flatMap(f => f.fix?.vlFixes || []);
            if (vlFixes.length === 0) return { result: 'No VL source fixes needed. Issues are test-level only.' };

            bc({ type: 'workflow_node_update', nodeId: 'User_Decision', status: 'done' });
            bc({ type: 'workflow_node_update', nodeId: 'Apply_Fixes', status: 'running' });

            // Format VL fixes for LLM to apply
            const fixSummary = vlFixes.map(f => ({
              file: f.file, section: f.section, severity: f.severity,
              description: f.description, original: f.original, fixed: f.fixed,
            }));

            bc({ type: 'autotest_detail', phase: 'fixVL', detailType: 'warn',
              message: `${vlFixes.length} VL source fix(es) to apply`,
              data: fixSummary.map(f => ({ file: f.file, section: f.section, severity: f.severity, description: f.description })) });

            bc({ type: 'workflow_node_update', nodeId: 'Apply_Fixes', status: 'done' });
            bc({ type: 'workflow_node_update', nodeId: 'Recompile', status: 'running' });

            return {
              result: `Found ${vlFixes.length} VL source fix(es). Apply them using EditFile/WriteFile tools, then recompile.`,
              vlFixes: fixSummary,
            };
          }

          // ────────── REPORT ──────────
          case 'report': {
            hydrateAutoTestStateFromDisk(config.workDir);
            const passed = _state.evaluations.filter(e => e.evaluation.pass && !e.evaluation.softPass).length;
            const softPassed = _state.evaluations.filter(e => e.evaluation.softPass).length;
            const failed = _state.evaluations.length - passed - softPassed;
            const total = _state.evaluations.length;

            const lines = [
              `# AutoTest Report`,
              ``,
              `**Total**: ${total} | **Passed**: ${passed} | **Soft-Passed**: ${softPassed} | **Failed**: ${failed} | **Pass Rate**: ${total > 0 ? ((passed / total * 100).toFixed(0) + '%') : 'N/A'}`,
              ``,
            ];

            if (softPassed > 0) {
              lines.push(`## Soft-Passed (No Interaction)`);
              for (const e of _state.evaluations.filter(e => e.evaluation.softPass)) {
                lines.push(`### ${e.caseId}: ${e.name}`);
                lines.push(`- **Reason**: ${e.evaluation.reason}`);
                if (e.evaluation.evidence) lines.push(`- **Evidence**: ${e.evaluation.evidence}`);
                lines.push('');
              }
            }
            if (failed > 0) {
              lines.push(`## Failures`);
              for (const e of _state.evaluations.filter(e => !e.evaluation.pass && !e.evaluation.softPass)) {
                lines.push(`### ${e.caseId}: ${e.name}`);
                lines.push(`- **Reason**: ${e.evaluation.reason}`);
                if (e.evaluation.evidence) lines.push(`- **Evidence**: ${e.evaluation.evidence}`);
                lines.push('');
              }
            }

            return { result: lines.join('\n') };
          }

          // ────────── STATUS ──────────
          case 'status': {
            hydrateAutoTestStateFromDisk(config.workDir);
            const passed = _state.evaluations.filter(e => e.evaluation.pass && !e.evaluation.softPass).length;
            const softPassed = _state.evaluations.filter(e => e.evaluation.softPass).length;
            const failed = _state.evaluations.length - passed - softPassed;
            return {
              result: _state.activeAction
                ? `Pipeline is currently ${_state.activeAction}. In-memory state: ${_state.testCases.length} test cases, ${_state.runResults.length} results (${passed} passed, ${softPassed} soft-passed, ${failed} failed).`
                : _state.testCases.length === 0
                ? 'No test cases generated yet. Use action "generate" first.'
                : `Pipeline state: ${_state.testCases.length} test cases, ${_state.runResults.length} results (${passed} passed, ${softPassed} soft-passed, ${failed} failed).`,
              testCases: _state.testCases.length,
              runResults: _state.runResults.length,
              passed, softPassed, failed,
              activeAction: _state.activeAction,
            };
          }

          default:
            return { error: `Unknown action: ${input.action}. Use: full, generate, run, fix, fixVL, report, status` };
        }
      } catch (err) {
        log('Error', err.stack || err.message);
        bc({ type: 'autotest_progress', phase: input.action, status: 'error', message: err.message });
        bc({ type: 'autotest_detail', phase: input.action, detailType: 'error',
          message: `Pipeline error in ${input.action}: ${err.message}`,
          data: { stack: err.stack } });
        return { error: `AutoTest ${input.action} failed: ${err.message}` };
      } finally {
        if (activeToken && _state.activeToken === activeToken) {
          _state.activeAction = null;
          _state.activeToken = null;
        }
      }
    },
  };
}
