/**
 * Workflow Editor HTML — Logic Tests
 * Tests the JavaScript logic extracted from workflow-editor.html
 * without requiring a browser DOM.
 */

const fs = require('fs');
const path = require('path');

// Extract JS from the HTML file
const html = fs.readFileSync(path.join(__dirname, '../ui/workflow-editor.html'), 'utf8');
const scriptMatch = html.match(/<script>([\s\S]*)<\/script>/);
if (!scriptMatch) { console.error('Cannot extract <script> from workflow-editor.html'); process.exit(1); }
const jsCode = scriptMatch[1];

// ===== Minimal DOM shim =====
const elements = {};
function $(id) { return elements[id] || (elements[id] = createEl(id)); }
function createEl(id) {
  return {
    id, textContent: '', style: { display: '', left: '', top: '' },
    innerHTML: '', className: '', value: '',
    classList: { add(){}, remove(){}, contains(){ return false; } },
    offsetLeft: 100, offsetTop: 100, offsetWidth: 240, offsetHeight: 120,
    scrollIntoView() {},
    addEventListener() {},
    querySelectorAll() { return []; },
    setAttribute() {},
    appendChild() {},
    click() {},
    getContext() {
      return {
        clearRect(){}, beginPath(){}, moveTo(){}, lineTo(){}, stroke(){},
        fillRect(){}, fill(){}, fillText(){}, arc(){}, bezierCurveTo(){},
        quadraticCurveTo(){}, closePath(){}, setLineDash(){}, strokeRect(){},
        scale(){}, set globalAlpha(v){}, get globalAlpha(){ return 1; },
        set fillStyle(v){}, get fillStyle(){ return ''; },
        set strokeStyle(v){}, get strokeStyle(){ return ''; },
        set lineWidth(v){}, set font(v){}, set textAlign(v){},
      };
    },
    get width() { return 360; }, set width(v) {},
    get height() { return 200; }, set height(v) {},
    get offsetWidth() { return 180; },
    get clientWidth() { return 800; },
    get clientHeight() { return 600; },
    get scrollLeft() { return 0; },
    get scrollTop() { return 0; },
    get files() { return []; },
  };
}

// Patch globals
global.document = {
  getElementById: (id) => $(id),
  createElement: (tag) => createEl(tag),
  createElementNS: (ns, tag) => createEl(tag),
  addEventListener() {},
};
global.window = {
  parent: { postMessage() {} },
  addEventListener() {},
  innerWidth: 1200,
  innerHeight: 800,
};
global.navigator = { clipboard: { writeText() {} } };
global.localStorage = {
  _store: {},
  getItem(k) { return this._store[k] || null; },
  setItem(k, v) { this._store[k] = v; },
  removeItem(k) { delete this._store[k]; },
};
global.URL = { createObjectURL() { return 'blob:test'; } };
global.Blob = class Blob { constructor() {} };
global.requestAnimationFrame = (fn) => fn();
global.setTimeout = (fn, ms) => fn();
global.fetch = async () => ({ ok: true, json: async () => ({}), body: { getReader() { return { read: async () => ({ done: true }) }; } } });

// Evaluate the editor JS in this context
const fn = new Function('$', jsCode);
// We need $ to be our DOM lookup
// But the code defines its own $ — so we need to let it run
eval(`(function(){
  ${jsCode}
  // Export to global for testing
  global._editor = {
    parseWorkflow, getStepType, autoLayout, computeIO, state,
    RESERVED_NEXT, KNOWN_TYPES, NODE_ICONS,
    renderLoopBody, renderDownloadBody, renderUnzipBody,
    renderLLMBody, renderBranchBody, renderPauseBody,
    rerunFromStep, saveCheckpointToStorage, restoreFromStorage,
    _currentWorkflowJson: null,
    getState: () => state,
    setCheckpoint: (cp) => { _lastCheckpoint = cp; },
    getCheckpoint: () => _lastCheckpoint,
    setRunID: (id) => { _currentRunID = id; },
  };
  // Patch the global references
  global._editor._currentWorkflowJson = _currentWorkflowJson;
})()`);

const editor = global._editor;

// ===== Test Runner =====
let passed = 0, failed = 0;
function test(name, fn) {
  try {
    fn();
    console.log(`  ✓ ${name}`);
    passed++;
  } catch (e) {
    console.log(`  ✗ ${name}: ${e.message}`);
    failed++;
  }
}
function assert(cond, msg) { if (!cond) throw new Error(msg || 'Assertion failed'); }
function assertEqual(a, b, msg) {
  if (a !== b) throw new Error(msg || `Expected ${JSON.stringify(b)}, got ${JSON.stringify(a)}`);
}

// ===== Test Workflow =====
const testWorkflow = {
  name: 'TestWorkflow',
  version: '3.16',
  registry: {
    params: ['projectName:string'],
    vars: ['$plan:object', '$code:string', '$review:object'],
    docs: { 1: 'VL Syntax Rules', 2: 'Theme Spec' },
    services: [{ id: 'svc_compile', method: 'compile', desc: 'Compile VL' }],
    apis: [{ id: 'api_github', method: 'POST', url: 'https://api.github.com/repos', desc: 'GitHub API' }],
  },
  steps: [
    { id: 'LLM_Analyze', meta: { title: 'Analyze Request' }, in: { model: 'anthropic/claude-opus-4-6', messages: [{ role: 'user', content: 'hello' }], docs: [1, 2], max_tokens: 4096 }, out: { '$plan': '=_result' }, next: 'Branch_Route', if: '=$projectName != ""' },
    { id: 'Branch_Route', meta: { title: 'Route by plan' }, cases: { '=$plan.action == "add"': 'LLM_Generate', '=$plan.action == "fix"': 'Service_Fix' }, next: 'Stop_End' },
    { id: 'LLM_Generate', meta: { title: 'Generate Code' }, model: 'anthropic/claude-opus-4-6', in: { messages: [{ role: 'user', content: '=$plan' }] }, out: { '$code': '=_result' }, next: 'Loop_Review' },
    { id: 'Service_Fix', serviceId: 'svc_compile', in: { code: '=$code' }, out: { '$code': '=_result' }, next: 'Stop_End' },
    { id: 'Loop_Review', meta: { title: 'Review Loop' }, while: '=$review.approved != true', maxIterations: 5, mode: 'serial', children: ['LLM_ReviewStep'], next: 'Write_Output' },
    { id: 'LLM_ReviewStep', meta: { title: 'Review Step' }, in: { messages: [{ role: 'user', content: 'review' }] }, out: { '$review': '=_result' }, next: 'BREAK' },
    { id: 'Write_Output', target: '/output/code.vx', value: '=$code', mode: 'overwrite', next: 'Download_Assets' },
    { id: 'Download_Assets', meta: { title: 'Download Assets' }, source: { url: 'https://cdn.example.com/assets.zip', headers: { Authorization: 'Bearer xxx' } }, target: '/assets/', routeByExt: { '.png': 'Images/', '.css': 'Styles/' }, defaultDir: 'Other/', next: 'Unzip_Package' },
    { id: 'Unzip_Package', meta: { title: 'Unzip Package' }, source: '=$downloadPath', routeByExt: { '.vx': 'Apps/', '.sc': 'Sections/' }, defaultDir: 'Misc/', overwrite: true, next: 'Set_Status' },
    { id: 'Set_Status', target: '$status', value: '="completed"', next: 'API_Notify' },
    { id: 'API_Notify', apiId: 'api_github', in: { body: '=$plan' }, out: { '$response': '=_result' }, next: 'Pause_Approval' },
    { id: 'Pause_Approval', meta: { title: 'Wait for approval' }, reason: 'Needs manager approval', in: { message: 'Please approve', display: { plan: '=$plan' } }, out: { '$approved': '=_result.approved' }, next: 'Fork_Deploy' },
    { id: 'Fork_Deploy', source: '=$targets', children: ['Component_Deploy1', 'Component_Deploy2'], next: 'Stop_End' },
    { id: 'Component_Deploy1', componentId: 'comp_deploy', in: { env: 'staging' }, out: { '$deployResult': '=_result' }, next: 'RETURN' },
    { id: 'Component_Deploy2', componentId: 'comp_deploy', in: { env: 'production' }, next: 'RETURN' },
    { id: 'Stop_End', meta: { title: 'Done' } },
  ]
};

// ===== Tests =====

console.log('\n── Workflow Parsing ──');

test('parse workflow creates correct number of nodes', () => {
  editor.parseWorkflow(testWorkflow);
  const s = editor.getState();
  assertEqual(s.nodes.length, 16);
});

test('parse workflow creates correct number of connections', () => {
  const s = editor.getState();
  // Count expected: next connections (excluding RETURN, BREAK, Stop_End has no next) + children + cases
  // Let's just verify it's reasonable
  assert(s.connections.length > 0, 'Should have connections');
  assert(s.connections.length < 30, 'Should not have too many connections');
});

console.log('\n── BREAK/RETURN Handling (Bug Fix #1) ──');

test('BREAK does not create phantom connection', () => {
  const s = editor.getState();
  const breakConns = s.connections.filter(c => c.to === 'BREAK');
  assertEqual(breakConns.length, 0, 'No connections should point to BREAK');
});

test('RETURN does not create phantom connection', () => {
  const s = editor.getState();
  const returnConns = s.connections.filter(c => c.to === 'RETURN');
  assertEqual(returnConns.length, 0, 'No connections should point to RETURN');
});

test('RESERVED_NEXT contains both RETURN and BREAK', () => {
  assert(editor.RESERVED_NEXT.has('RETURN'));
  assert(editor.RESERVED_NEXT.has('BREAK'));
});

test('LLM_ReviewStep has no outgoing serial connection (next is BREAK)', () => {
  const s = editor.getState();
  const reviewConns = s.connections.filter(c => c.from === 'LLM_ReviewStep' && c.type === 'serial');
  assertEqual(reviewConns.length, 0, 'BREAK should not produce a serial connection');
});

console.log('\n── Node Type Detection (Bug Fix #3) ──');

test('KNOWN_TYPES includes all 13 types', () => {
  assertEqual(editor.KNOWN_TYPES.length, 13);
  assert(editor.KNOWN_TYPES.includes('Download'));
  assert(editor.KNOWN_TYPES.includes('Unzip'));
});

test('getStepType detects Download_Assets as Download', () => {
  assertEqual(editor.getStepType({ id: 'Download_Assets' }), 'Download');
});

test('getStepType detects Unzip_Package as Unzip', () => {
  assertEqual(editor.getStepType({ id: 'Unzip_Package' }), 'Unzip');
});

test('getStepType respects explicit type field', () => {
  assertEqual(editor.getStepType({ id: 'Custom_123', type: 'LLM' }), 'LLM');
});

test('getStepType preserves unknown custom prefix', () => {
  assertEqual(editor.getStepType({ id: 'Unknown_123' }), 'Unknown');
});

test('NODE_ICONS has Download and Unzip', () => {
  assertEqual(editor.NODE_ICONS.Download, 'DL');
  assertEqual(editor.NODE_ICONS.Unzip, 'UZ');
});

console.log('\n── Loop While Mode (Bug Fix #2) ──');

test('renderLoopBody shows while expression for while-mode loop', () => {
  const body = editor.renderLoopBody({ while: '=$review.approved != true', maxIterations: 5, mode: 'serial' });
  assert(body.includes('while'), 'Should contain while label');
  assert(body.includes('=$review.approved'), 'Should contain while expression');
  assert(body.includes('5'), 'Should show maxIterations');
  assert(!body.includes('source'), 'Should NOT show source label');
});

test('renderLoopBody shows source for source-mode loop', () => {
  const body = editor.renderLoopBody({ source: '=$items', mode: 'parallel' });
  assert(body.includes('source'), 'Should contain source label');
  assert(body.includes('=$items'), 'Should contain source expression');
  assert(!body.includes('while'), 'Should NOT show while label');
});

test('renderLoopBody shows maxIterations for source-mode too', () => {
  const body = editor.renderLoopBody({ source: '=$items', mode: 'parallel', maxIterations: 100 });
  assert(body.includes('100'), 'Should show maxIterations');
});

console.log('\n── computeIO (Bug Fix #4) ──');

test('computeIO extracts vars from while expression', () => {
  const io = editor.computeIO({ while: '=$review.approved != true', mode: 'serial', maxIterations: 5 });
  assert(io.varsIn.some(v => v.includes('$review')), 'Should extract $review from while expression');
});

test('computeIO extracts vars from source', () => {
  const io = editor.computeIO({ source: '=$items' });
  assert(io.varsIn.includes('$items'), 'Should extract $items from source');
});

test('computeIO extracts output vars', () => {
  const io = editor.computeIO({ out: { '$plan': '=_result', '/output.vx': '=$code' } });
  assert(io.varsOut.includes('$plan'), 'Should have $plan in varsOut');
  assert(io.files.includes('/output.vx'), 'Should have /output.vx in files');
});

test('computeIO extracts docs', () => {
  const io = editor.computeIO({ in: { docs: [1, 2], messages: [] } });
  assertEqual(io.docs.length, 2);
});

console.log('\n── Download/Unzip Renderers (Bug Fix #3) ──');

test('renderDownloadBody shows source URL', () => {
  const body = editor.renderDownloadBody({
    source: { url: 'https://cdn.example.com/file.zip' },
    target: '/assets/',
    routeByExt: { '.png': 'Images/' },
    defaultDir: 'Other/'
  });
  // URL is inside a JSON.stringify'd object, then HTML-escaped and truncated
  // Check for key structural elements
  assert(body.includes('Download'), 'Should have Download title');
  assert(body.includes('source'), 'Should show source label');
  assert(body.includes('/assets/'), 'Should show target');
  assert(body.includes('1 ext rules'), 'Should show routeByExt count');
  assert(body.includes('Other/'), 'Should show defaultDir');
});

test('renderUnzipBody shows source and overwrite', () => {
  const body = editor.renderUnzipBody({
    source: '=$zipPath',
    routeByExt: { '.vx': 'Apps/', '.sc': 'Sections/' },
    defaultDir: 'Misc/',
    overwrite: true
  });
  assert(body.includes('$zipPath'), 'Should show source');
  assert(body.includes('2 ext rules'), 'Should show 2 ext rules');
  assert(body.includes('true'), 'Should show overwrite');
});

console.log('\n── LLM Body Renderer ──');

test('renderLLMBody shows model from both data.model and data.in.model', () => {
  const body1 = editor.renderLLMBody({ model: 'claude-opus', in: {} });
  assert(body1.includes('claude-opus'), 'Should show model from data.model');

  const body2 = editor.renderLLMBody({ in: { model: 'gpt-4' } });
  assert(body2.includes('gpt-4'), 'Should show model from data.in.model');
});

test('renderLLMBody shows docs, tokens, messages', () => {
  const body = editor.renderLLMBody({
    in: { docs: [1], model: 'test', max_tokens: 4096, messages: [{ role: 'user', content: 'hi' }] }
  });
  assert(body.includes('4096'), 'Should show token count');
  assert(body.includes('1 messages'), 'Should show message count');
});

console.log('\n── Branch Body Renderer ──');

test('renderBranchBody handles cases object', () => {
  const body = editor.renderBranchBody({ cases: { '=$a > 0': 'StepA', '=$a <= 0': 'StepB' } });
  assert(body.includes('$a > 0'), 'Should show case expression');
  assert(body.includes('StepA'), 'Should show target');
});

test('renderBranchBody handles branches array', () => {
  const body = editor.renderBranchBody({ branches: [['=$x', 'S1'], ['=$y', 'S2']] });
  assert(body.includes('$x'), 'Should show branch expression');
});

console.log('\n── Pause Body Renderer ──');

test('renderPauseBody shows reason field (Spec 3.16)', () => {
  const body = editor.renderPauseBody({ reason: 'Manager approval needed' });
  assert(body.includes('Manager approval'), 'Should show reason as message');
});

console.log('\n── Connection Generation ──');

test('serial connections are created for normal next', () => {
  const s = editor.getState();
  const conn = s.connections.find(c => c.from === 'LLM_Analyze' && c.to === 'Branch_Route');
  assert(conn, 'Should have LLM_Analyze → Branch_Route');
  assertEqual(conn.type, 'serial');
});

test('parallel connections are created for children', () => {
  const s = editor.getState();
  const conns = s.connections.filter(c => c.from === 'Fork_Deploy' && c.type === 'parallel');
  assertEqual(conns.length, 2, 'Fork should have 2 parallel children');
});

test('branch-case connections are created for cases', () => {
  const s = editor.getState();
  const conns = s.connections.filter(c => c.from === 'Branch_Route' && c.type === 'branch-case');
  assertEqual(conns.length, 2, 'Branch should have 2 case connections');
  assert(conns[0].label, 'Case connections should have labels');
});

test('Loop_Review children connection exists', () => {
  const s = editor.getState();
  const conn = s.connections.find(c => c.from === 'Loop_Review' && c.to === 'LLM_ReviewStep');
  assert(conn, 'Loop should connect to its children');
  assertEqual(conn.type, 'parallel');
});

console.log('\n── Auto Layout ──');

test('autoLayout assigns positions to all nodes', () => {
  const s = editor.getState();
  const positioned = s.nodes.filter(n => n.x > 0 || n.y > 0);
  assertEqual(positioned.length, s.nodes.length, 'All nodes should have positions');
});

test('no two nodes overlap exactly', () => {
  const s = editor.getState();
  const positions = new Set();
  for (const n of s.nodes) {
    const key = `${n.x},${n.y}`;
    assert(!positions.has(key), `Nodes overlap at ${key}: ${n.id}`);
    positions.add(key);
  }
});

console.log('\n── Checkpoint Persistence ──');

test('saveCheckpointToStorage saves to localStorage', () => {
  // parseWorkflow sets _currentWorkflowJson internally
  editor.parseWorkflow(testWorkflow);
  // Use the exported setCheckpoint and saveCheckpointToStorage
  editor.setCheckpoint({ currentStepID: 'LLM_Generate', variables: { '$plan': {} } });
  editor.setRunID('run_123');
  editor.saveCheckpointToStorage();

  const stored = localStorage.getItem('wf_cp_TestWorkflow');
  assert(stored, 'Should save to localStorage');
  const data = JSON.parse(stored);
  assertEqual(data.checkpoint.currentStepID, 'LLM_Generate');
  assertEqual(data.runID, 'run_123');
});

test('restoreFromStorage restores node statuses', () => {
  // Set some node statuses in storage
  const s = editor.getState();
  const storageData = {
    checkpoint: { currentStepID: 'LLM_Generate' },
    nodeStatuses: [
      { id: 'LLM_Analyze', status: 'done' },
      { id: 'Branch_Route', status: 'done' },
      { id: 'LLM_Generate', status: 'error' },
    ],
    runID: 'run_456',
    ts: Date.now()
  };
  localStorage.setItem('wf_cp_TestWorkflow', JSON.stringify(storageData));

  // Clear current statuses
  for (const n of s.nodes) n.status = null;

  const restored = editor.restoreFromStorage();
  assert(restored, 'Should return true');

  const analyze = s.nodes.find(n => n.id === 'LLM_Analyze');
  assertEqual(analyze.status, 'done');
  const generate = s.nodes.find(n => n.id === 'LLM_Generate');
  assertEqual(generate.status, 'error');
});

test('restoreFromStorage rejects expired checkpoints (>24h)', () => {
  const storageData = {
    checkpoint: { currentStepID: 'LLM_Generate' },
    nodeStatuses: [{ id: 'LLM_Analyze', status: 'done' }],
    runID: 'run_old',
    ts: Date.now() - 90000000 // >24h ago
  };
  localStorage.setItem('wf_cp_TestWorkflow', JSON.stringify(storageData));

  // Clear statuses
  for (const n of editor.getState().nodes) n.status = null;

  const restored = editor.restoreFromStorage();
  assertEqual(restored, false, 'Should reject expired checkpoint');
});

console.log('\n── Node Type Coverage ──');

test('all 13 node types are in test workflow', () => {
  const s = editor.getState();
  const types = new Set(s.nodes.map(n => n.type));
  const expected = ['LLM', 'Branch', 'Service', 'Loop', 'Write', 'Download', 'Unzip', 'Set', 'API', 'Pause', 'Fork', 'Component', 'Stop'];
  for (const t of expected) {
    assert(types.has(t), `Missing type: ${t}`);
  }
});

test('all nodes have valid type from KNOWN_TYPES', () => {
  const s = editor.getState();
  for (const n of s.nodes) {
    assert(editor.KNOWN_TYPES.includes(n.type), `Node ${n.id} has unknown type: ${n.type}`);
  }
});

console.log('\n── Edge Cases ──');

test('parse empty workflow does not crash', () => {
  editor.parseWorkflow({ steps: [] });
  assertEqual(editor.getState().nodes.length, 0);
});

test('parse workflow with no registry', () => {
  editor.parseWorkflow({ steps: [{ id: 'Stop_1' }] });
  assertEqual(editor.getState().nodes.length, 1);
});

test('parse workflow restores after re-parse', () => {
  editor.parseWorkflow(testWorkflow);
  assertEqual(editor.getState().nodes.length, 16);
});

test('computeIO handles null step gracefully', () => {
  const io = editor.computeIO(null);
  assertEqual(io.varsIn.length, 0);
  assertEqual(io.varsOut.length, 0);
});

test('computeIO handles step with no in/out', () => {
  const io = editor.computeIO({ id: 'Stop_1' });
  assertEqual(io.docs.length, 0);
});

// ===== Results =====
console.log(`\n── Results ──\n\n  ${passed} passed, ${failed} failed\n`);
process.exit(failed > 0 ? 1 : 0);
