// Orchestration unit tests for the E5 pair runner.
//
// Scope:
//   - Enumerate handler keys programmatically from the module (never hard-coded).
//   - Assert every manifest stage is wired to a handler.
//   - Verify the stage-loop propagates errors and preserves declared order.

import { test } from 'node:test';
import assert from 'node:assert/strict';
import { mkdtemp, mkdir, readFile, readdir, writeFile } from 'node:fs/promises';
import { dirname, join, resolve } from 'node:path';
import { tmpdir } from 'node:os';
import { fileURLToPath } from 'node:url';
import { spawn } from 'node:child_process';

import { createStageHandlers, runStages, createPlan, formatPlan } from './run-pair.mjs';

function runNode(args, cwd) {
  return new Promise((resolvePromise) => {
    const child = spawn(process.execPath, args, {
      cwd,
      stdio: ['ignore', 'pipe', 'pipe'],
      windowsHide: true,
    });
    let stdout = '';
    let stderr = '';
    child.stdout.on('data', (c) => (stdout += c.toString()));
    child.stderr.on('data', (c) => (stderr += c.toString()));
    child.on('close', (code) => resolvePromise({ code, stdout, stderr }));
  });
}

const __dirname = dirname(fileURLToPath(import.meta.url));
const repoRoot = resolve(__dirname, '..', '..', '..');
const pairsDir = join(
  repoRoot,
  'experiments',
  'results',
  'e5-maintenance',
  'batches',
  'e5-b01-mv-gpt52-pilot',
  'pairs',
);

async function loadManifests() {
  const entries = await readdir(pairsDir);
  const jsonFiles = entries.filter((e) => e.endsWith('.json'));
  const manifests = [];
  for (const f of jsonFiles) {
    const full = join(pairsDir, f);
    const raw = await readFile(full, 'utf8');
    manifests.push({ file: f, path: full, manifest: JSON.parse(raw) });
  }
  return manifests;
}

async function createRunDir(workspaces = ['codex-workspace', 'pl-workspace']) {
  const runDir = await mkdtemp(join(tmpdir(), 'run-pair-'));
  for (const workspace of workspaces) {
    await mkdir(join(runDir, workspace), { recursive: true });
  }
  return runDir;
}

async function createHarnessScript(report, exitCode = 0) {
  const dir = await mkdtemp(join(tmpdir(), 'gate-harness-'));
  const scriptPath = join(dir, 'harness.mjs');
  const script = [
    '#!/usr/bin/env node',
    `process.stdout.write(${JSON.stringify(`${JSON.stringify(report)}\n`)});`,
    `process.exit(${exitCode});`,
  ].join('\n');
  await writeFile(scriptPath, script, 'utf8');
  return scriptPath;
}

test('createStageHandlers returns exactly the handler keys declared in the module', () => {
  const handlers = createStageHandlers();
  const keys = Object.keys(handlers).sort();
  // Enumerated programmatically — this assertion protects against silent
  // key drift. If this list changes, update the manifests or add a handler.
  assert.ok(keys.length > 0, 'handler map should not be empty');
  for (const key of keys) {
    assert.equal(
      typeof handlers[key],
      'function',
      `handler for ${key} should be a function, got ${typeof handlers[key]}`,
    );
  }
});

test('every stage.stage in each pair manifest is wired to a handler', async () => {
  const handlers = createStageHandlers();
  const handlerKeys = new Set(Object.keys(handlers));
  const manifests = await loadManifests();
  assert.ok(manifests.length >= 2, 'expected at least 2 pair manifests');
  for (const { file, manifest } of manifests) {
    assert.ok(Array.isArray(manifest.stages), `${file}: stages must be an array`);
    for (const stage of manifest.stages) {
      assert.ok(
        handlerKeys.has(stage.stage),
        `${file}: stage "${stage.stage}" has no handler (known: ${[...handlerKeys].join(', ')})`,
      );
    }
  }
});

test('pair manifests place cross-family-review immediately after gate-family-1-2-3', async () => {
  const manifests = await loadManifests();
  for (const { file, manifest } of manifests) {
    const stages = manifest.stages.map((stage) => stage.stage);
    const gateIndex = stages.indexOf('gate-family-1-2-3');
    const reviewIndex = stages.indexOf('cross-family-review');
    assert.notEqual(gateIndex, -1, `${file}: gate-family-1-2-3 missing`);
    assert.equal(reviewIndex, gateIndex + 1, `${file}: cross-family-review must follow gate`);
  }
});

test('runStages propagates handler errors', async () => {
  const manifest = {
    stages: [{ stage: 'alpha' }, { stage: 'beta' }],
  };
  const boom = new Error('handler-failed');
  const handlers = {
    alpha: async () => {
      throw boom;
    },
    beta: async () => {
      throw new Error('should-not-run');
    },
  };
  await assert.rejects(() => runStages(manifest, handlers, {}, {}), /handler-failed/);
});

test('runStages throws on unknown stage key', async () => {
  const manifest = { stages: [{ stage: 'does-not-exist' }] };
  await assert.rejects(() => runStages(manifest, {}, {}, {}), /unknown stage: does-not-exist/);
});

test('runStages dispatches handlers in declared manifest order', async () => {
  const manifests = await loadManifests();
  for (const { file, manifest } of manifests) {
    const calls = [];
    const spyHandlers = {};
    for (const stage of manifest.stages) {
      spyHandlers[stage.stage] = async (s) => {
        calls.push(s.stage);
      };
    }
    await runStages(manifest, spyHandlers, {}, {});
    const expected = manifest.stages.map((s) => s.stage);
    assert.deepEqual(calls, expected, `${file}: call order mismatch`);
  }
});

test('runStages honours dryRun by skipping handler invocation', async () => {
  const manifest = { stages: [{ stage: 'alpha' }, { stage: 'beta' }] };
  const calls = [];
  const handlers = {
    alpha: async () => calls.push('alpha'),
    beta: async () => calls.push('beta'),
  };
  await runStages(manifest, handlers, {}, { dryRun: true });
  assert.deepEqual(calls, [], 'handlers must not be called in dry-run');
});

test('createPlan emits stage descriptions and workspace paths', async () => {
  const manifests = await loadManifests();
  const { manifest } = manifests[0];
  const runDir = '/tmp/fake-run-dir';
  const plan = createPlan(manifest, runDir);
  assert.equal(plan.pairId, manifest.pairId);
  assert.equal(plan.runDir, runDir);
  assert.equal(plan.stages.length, manifest.stages.length);
  for (const [i, s] of plan.stages.entries()) {
    assert.equal(s.stage, manifest.stages[i].stage);
    assert.equal(s.known, true, `stage ${s.stage} should have a registered description`);
    assert.ok(s.description.length > 0);
  }
  assert.ok(plan.workspaces.some((w) => w.includes('codex-workspace')));
  assert.ok(plan.workspaces.some((w) => w.includes('pl-workspace-stripped')));
  const text = formatPlan(plan);
  assert.match(text, /pair:/);
  assert.match(text, /stages:/);
  assert.match(text, /workspace paths/);
});

test('gate-family-1-2-3 fails loudly when the harness is missing', async () => {
  const handlers = createStageHandlers();
  const runDir = await createRunDir();
  const missingHarness = join(runDir, 'missing-harness.mjs');
  await assert.rejects(
    () =>
      handlers['gate-family-1-2-3'](
        {
          stage: 'gate-family-1-2-3',
          appliesTo: ['codex-workspace'],
          harness: missingHarness,
        },
        { runDir, log: () => {} },
      ),
    /harness-missing/,
  );

  const artifact = JSON.parse(await readFile(join(runDir, 'gate-report.json'), 'utf8'));
  assert.equal(artifact.overallStatus, 'failed');
  assert.equal(artifact.targets['codex-workspace'].gateStatus, 'harness-missing');
});

test('gate-family-1-2-3 keeps pending-manual-review distinct from a true pass', async () => {
  const handlers = createStageHandlers();
  const runDir = await createRunDir();
  const harness = await createHarnessScript({
    gateStatus: 'pending-manual-review',
    notes: 'manual evidence still required',
  });

  await assert.rejects(
    () =>
      handlers['gate-family-1-2-3'](
        {
          stage: 'gate-family-1-2-3',
          appliesTo: ['codex-workspace'],
          harness,
        },
        { runDir, log: () => {} },
      ),
    /pending-manual-review/,
  );

  const artifact = JSON.parse(await readFile(join(runDir, 'gate-report.json'), 'utf8'));
  assert.equal(artifact.overallStatus, 'blocked-manual-review');
  assert.equal(artifact.targets['codex-workspace'].gateStatus, 'pending-manual-review');
});

test('gate-family-1-2-3 rejects a harness that exits non-zero even if it prints JSON', async () => {
  const handlers = createStageHandlers();
  const runDir = await createRunDir();
  const harness = await createHarnessScript({ gateStatus: 'passed' }, 7);

  await assert.rejects(
    () =>
      handlers['gate-family-1-2-3'](
        {
          stage: 'gate-family-1-2-3',
          appliesTo: ['codex-workspace'],
          harness,
        },
        { runDir, log: () => {} },
      ),
    /harness-exit-nonzero/,
  );

  const artifact = JSON.parse(await readFile(join(runDir, 'gate-report.json'), 'utf8'));
  assert.equal(artifact.overallStatus, 'failed');
  assert.equal(artifact.targets['codex-workspace'].exitCode, 7);
});

test('cross-family-review aborts on same-family pairing and writes a stage artifact', async () => {
  const handlers = createStageHandlers();
  const runDir = await createRunDir(['codex-workspace']);
  await writeFile(join(runDir, 'codex-workspace', 'README.md'), '# sample workspace\n', 'utf8');

  await assert.rejects(
    () =>
      handlers['cross-family-review'](
        {
          stage: 'cross-family-review',
          reviewer: {
            family: 'openai',
            model: 'gpt-5.2',
          },
          appliesTo: [
            {
              workspace: 'codex-workspace',
              factoryFamily: 'openai',
              factoryModel: 'gpt-5.2',
            },
          ],
        },
        { runDir, log: () => {} },
      ),
    /Family separation violated|cross-family-review aborted/,
  );

  const artifact = JSON.parse(
    await readFile(join(runDir, 'cross-family-review-report.json'), 'utf8'),
  );
  assert.equal(artifact.overallStatus, 'failed');
  assert.equal(artifact.results['codex-workspace'].verdict, 'error');
  assert.equal(artifact.results['codex-workspace'].factoryFamily, 'openai');
});

test('--plan subprocess prints stage list for each pair manifest and exits 0', async () => {
  const manifests = await loadManifests();
  const runnerPath = join(__dirname, 'run-pair.mjs');
  for (const { file, manifest } of manifests) {
    // Manifest path passed relative to repoRoot (matching the runner's CLI).
    const relManifest = join(
      'experiments',
      'results',
      'e5-maintenance',
      'batches',
      'e5-b01-mv-gpt52-pilot',
      'pairs',
      file,
    );
    const { code, stdout, stderr } = await runNode([runnerPath, relManifest, '--plan'], repoRoot);
    assert.equal(code, 0, `--plan should exit 0 for ${file}; stderr=${stderr} stdout=${stdout}`);
    assert.match(stdout, /pair:\s*\S+/, `missing pair line for ${file}`);
    assert.match(stdout, /stages:/);
    assert.match(stdout, /workspace paths/);
    for (const stage of manifest.stages) {
      assert.ok(
        stdout.includes(stage.stage),
        `--plan output should mention stage "${stage.stage}" for ${file}`,
      );
    }
  }
});
