// engine/validator/spec-compliance-runner.ts — SPEC-732: vitest-backed spec-compliance runner
//
// Replaces the text-matching approach in validateSpec with actual test execution.
// Parses the `tests` array from each frontmatter scenario, spawns vitest, and
// produces a per-scenario verdict based on real test outcomes.
import { spawn } from 'node:child_process';
import { access } from 'node:fs/promises';
import { join } from 'node:path';
// ---------------------------------------------------------------------------
// Frontmatter scenario parser
// ---------------------------------------------------------------------------
/**
 * Parse scenarios from a raw spec.md content string.
 * Handles the multi-line YAML block that the simple KV parser cannot handle.
 *
 * Expected frontmatter shape:
 * ```yaml
 * scenarios:
 *   - title: "Scenario A"
 *     done: false
 *     tests:
 *       - path: tests/foo.test.ts
 *         line: 42
 * ```
 */
// eslint-disable-next-line complexity, max-lines-per-function -- YAML frontmatter has many structural variants; each branch handles a distinct schema shape (object + string test forms — SPEC-777)
export function parseFrontmatterScenarios(raw) {
    const FRONTMATTER_BLOCK_RE = /^---\n([\s\S]*?)\n---\n/;
    const match = FRONTMATTER_BLOCK_RE.exec(raw);
    if (!match?.[1]) {
        return [];
    }
    const block = match[1];
    const lines = block.split('\n');
    const scenarios = [];
    let inScenarios = false;
    let currentScenario = null;
    let inTests = false;
    for (const line of lines) {
        const trimmed = line.trimEnd();
        // Top-level `scenarios:` key
        if (/^scenarios:\s*$/.test(trimmed)) {
            inScenarios = true;
            inTests = false;
            continue;
        }
        // Another top-level key — exit scenarios block
        if (inScenarios && /^\S/.test(trimmed) && !trimmed.startsWith('scenarios:')) {
            if (currentScenario) {
                scenarios.push(currentScenario);
                currentScenario = null;
            }
            inScenarios = false;
            inTests = false;
            continue;
        }
        if (!inScenarios) {
            continue;
        }
        // New scenario entry `  - title: ...`
        const titleMatch = /^\s{2}-\s+title:\s*["']?(.+?)["']?\s*$/.exec(trimmed);
        if (titleMatch?.[1]) {
            if (currentScenario) {
                scenarios.push(currentScenario);
            }
            currentScenario = { title: titleMatch[1], done: false };
            inTests = false;
            continue;
        }
        if (!currentScenario) {
            continue;
        }
        // `    done: true/false`
        const doneMatch = /^\s+done:\s*(true|false)\s*$/.exec(trimmed);
        if (doneMatch?.[1]) {
            currentScenario.done = doneMatch[1] === 'true';
            continue;
        }
        // `    tests:` sub-key
        if (/^\s+tests:\s*$/.test(trimmed)) {
            currentScenario.tests = currentScenario.tests ?? [];
            inTests = true;
            continue;
        }
        if (inTests) {
            // Test entry `      - path: ...` (object form)
            const pathMatch = /^\s+-\s+path:\s*["']?(.+?)["']?\s*$/.exec(trimmed);
            if (pathMatch?.[1]) {
                const testLink = { path: pathMatch[1] };
                currentScenario.tests ??= [];
                currentScenario.tests.push(testLink);
                continue;
            }
            // Test entry `      - "tests/foo.test.ts"` or `      - tests/foo.test.ts` (string form)
            const stringMatch = /^\s+-\s+["']?([^'":\s][^'"]*?)["']?\s*$/.exec(trimmed);
            if (stringMatch?.[1] && !stringMatch[1].includes(':')) {
                const testLink = { path: stringMatch[1] };
                currentScenario.tests ??= [];
                currentScenario.tests.push(testLink);
                continue;
            }
            // `        line: N` — attach to last test link
            const lineMatch = /^\s+line:\s*(\d+)\s*$/.exec(trimmed);
            if (lineMatch?.[1] && currentScenario.tests && currentScenario.tests.length > 0) {
                const last = currentScenario.tests[currentScenario.tests.length - 1];
                if (last) {
                    last.line = parseInt(lineMatch[1], 10);
                }
                continue;
            }
            // Another indented key that is not a test entry — exit inTests
            if (/^\s{4}\S/.test(line)) {
                inTests = false;
            }
        }
    }
    if (currentScenario) {
        scenarios.push(currentScenario);
    }
    return scenarios;
}
/**
 * Spawn vitest with --reporter=json and collect the JSON output.
 * Returns null on timeout or spawn error.
 */
function runVitest(testPaths, projectPath, timeoutMs = 120_000) {
    return new Promise((resolve) => {
        const args = ['--reporter=json', '--run', ...testPaths];
        const child = spawn('pnpm', ['exec', 'vitest', ...args], {
            cwd: projectPath,
            stdio: ['ignore', 'pipe', 'pipe'],
        });
        let stdout = '';
        child.stdout.on('data', (chunk) => {
            stdout += chunk.toString();
        });
        const timer = setTimeout(() => {
            child.kill('SIGTERM');
            resolve(null);
        }, timeoutMs);
        child.on('close', () => {
            clearTimeout(timer);
            try {
                // vitest --reporter=json may include non-JSON lines; find the JSON object
                const jsonStart = stdout.indexOf('{');
                if (jsonStart === -1) {
                    resolve(null);
                    return;
                }
                const parsed = JSON.parse(stdout.slice(jsonStart));
                resolve(parsed);
            }
            catch {
                resolve(null);
            }
        });
        child.on('error', () => {
            clearTimeout(timer);
            resolve(null);
        });
    });
}
function resolveVitestTestFilePath(result) {
    // Vitest JSON returns the file path under `testResults[].name` in v4.
    const candidates = [result.testFilePath, result.name, result.filepath, result.filePath];
    for (const candidate of candidates) {
        if (typeof candidate === 'string' && candidate.trim().length > 0) {
            return candidate;
        }
    }
    return null;
}
function toProjectRelativeTestPath(testFilePath, projectPath) {
    const prefix = projectPath.endsWith('/') ? projectPath : `${projectPath}/`;
    return testFilePath.startsWith(prefix) ? testFilePath.slice(prefix.length) : testFilePath;
}
function resolveScenarioTestPath(link) {
    return typeof link.path === 'string' && link.path.trim().length > 0 ? link.path : null;
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
/**
 * Run vitest-backed spec-compliance for the given spec.
 * Each frontmatter scenario must have a `tests` array; missing links count as `fail`.
 */
// eslint-disable-next-line max-lines-per-function, complexity -- spec compliance runner: each section handles a discrete verification phase (file access, scenario parsing, test execution, verdict aggregation)
export async function runSpecCompliance(spec, projectPath) {
    const { readFile } = await import('node:fs/promises');
    let raw;
    try {
        raw = await readFile(spec.specPath, 'utf-8');
    }
    catch {
        // No spec file — return empty compliance
        return { dimensionScore: 0, perScenario: [], command: '' };
    }
    const scenarios = parseFrontmatterScenarios(raw);
    if (scenarios.length === 0) {
        return { dimensionScore: 0, perScenario: [], command: '' };
    }
    // ── Phase 1: collect all unique test paths ────────────────────────────────
    const allTestPaths = new Set();
    for (const scenario of scenarios) {
        for (const link of scenario.tests ?? []) {
            const linkPath = resolveScenarioTestPath(link);
            if (linkPath) {
                allTestPaths.add(linkPath);
            }
        }
    }
    const command = allTestPaths.size > 0
        ? `vitest --reporter=json --run ${[...allTestPaths].join(' ')}`
        : 'vitest --reporter=json --run (no test files linked)';
    // ── Phase 2: check file existence ────────────────────────────────────────
    const existenceMap = new Map();
    for (const p of allTestPaths) {
        const fullPath = join(projectPath, p);
        try {
            await access(fullPath);
            existenceMap.set(p, true);
        }
        catch {
            existenceMap.set(p, false);
        }
    }
    // ── Phase 3: run vitest for existing paths ───────────────────────────────
    const existingPaths = [...allTestPaths].filter((p) => existenceMap.get(p) === true);
    let vitestResult = null;
    if (existingPaths.length > 0) {
        vitestResult = await runVitest(existingPaths, projectPath);
    }
    // Build a map: testFilePath -> pass/fail
    const testFileStatus = new Map();
    if (vitestResult) {
        for (const tr of vitestResult.testResults) {
            const testFilePath = resolveVitestTestFilePath(tr);
            if (!testFilePath) {
                continue;
            }
            const allPassed = tr.assertionResults.every((r) => r.status === 'passed');
            testFileStatus.set(testFilePath, allPassed ? 'pass' : 'fail');
            // Also index by relative path
            const relPath = toProjectRelativeTestPath(testFilePath, projectPath);
            testFileStatus.set(relPath, allPassed ? 'pass' : 'fail');
        }
    }
    // ── Phase 4: build per-scenario verdicts ─────────────────────────────────
    const perScenario = [];
    let passCount = 0;
    for (const scenario of scenarios) {
        const links = scenario.tests ?? [];
        const evidence = [];
        let scenarioVerdict;
        if (links.length === 0) {
            evidence.push('No test links — scenario has no tests array');
            scenarioVerdict = 'missing';
        }
        else {
            // Check all links
            const verdicts = [];
            for (const link of links) {
                const linkPath = resolveScenarioTestPath(link);
                if (!linkPath) {
                    evidence.push('Malformed test link: missing path');
                    verdicts.push('missing');
                    continue;
                }
                const exists = existenceMap.get(linkPath) !== false;
                if (!exists) {
                    evidence.push(`${linkPath}: file not found`);
                    verdicts.push('missing');
                    continue;
                }
                const status = testFileStatus.get(linkPath) ?? (vitestResult ? 'fail' : 'missing');
                if (status === 'pass') {
                    evidence.push(`${linkPath}: passed`);
                    verdicts.push('pass');
                }
                else if (status === 'missing') {
                    evidence.push(`${linkPath}: not found in vitest output`);
                    verdicts.push('missing');
                }
                else {
                    evidence.push(`${linkPath}: failed`);
                    verdicts.push('fail');
                }
            }
            // Scenario passes only when ALL linked tests pass
            if (verdicts.every((v) => v === 'pass')) {
                scenarioVerdict = 'pass';
            }
            else if (verdicts.some((v) => v === 'missing')) {
                scenarioVerdict = 'missing';
            }
            else {
                scenarioVerdict = 'fail';
            }
        }
        if (scenarioVerdict === 'pass') {
            passCount++;
        }
        perScenario.push({ title: scenario.title, verdict: scenarioVerdict, evidence });
    }
    const dimensionScore = scenarios.length > 0 ? Math.round((passCount / scenarios.length) * 100) : 0;
    return { dimensionScore, perScenario, command };
}
// ---------------------------------------------------------------------------
// check_readiness gate: scenarios_missing_tests
// ---------------------------------------------------------------------------
/**
 * Returns a list of scenario titles that lack a `tests` array.
 * Used by check-readiness to block `approved` when scenarios are untested.
 */
export function findScenariosWithoutTests(raw) {
    const scenarios = parseFrontmatterScenarios(raw);
    return scenarios.filter((s) => !s.tests || s.tests.length === 0).map((s) => s.title);
}
//# sourceMappingURL=spec-compliance-runner.js.map