import * as fs from 'node:fs';
import * as path from 'node:path';
import { matchAstGrepPattern } from './ast-grep-query.js';
import { applyRulesToAdditions, loadCompiledRules } from './compiler.js';
import { getErrorMessage } from './errors.js';
// ─── Types ───────────────────────────────────────────
/**
 * Valid values for the `surface:` frontmatter field on a test fixture.
 *
 * Per ADR-104 § Convergence: the existing single-file fixture schema is
 * extended additively with `surface: rules | hooks`. The field dispatches
 * which matcher consumes the fixture. Defaults to `'rules'` when absent
 * (backwards-compat for existing fixtures predating the hook engine).
 */
export const FIXTURE_SURFACES = ['rules', 'hooks'];
/**
 * Valid values for the `corpus:` frontmatter field on a test fixture.
 *
 * Per ADR-104 § Convergence: tags a fixture as a positive (`pass`) or
 * negative (`fail`) case. Defaults to `'pass'` when absent (backwards-compat
 * for existing dual-section rules-surface fixtures, where the pass/fail
 * semantic was carried by ## section headings inside the fixture body).
 */
export const FIXTURE_CORPORA = ['pass', 'fail'];
// ─── Fixture parsing ─────────────────────────────────
const FRONTMATTER_RE = /^---\s*\n([\s\S]*?)\n---/;
const FAIL_SECTION_RE = /##\s*[Ss]hould\s+fail\s*\n+```[^\n]*\n([\s\S]*?)```/;
const PASS_SECTION_RE = /##\s*[Ss]hould\s+pass\s*\n+```[^\n]*\n([\s\S]*?)```/;
const TODO_MARKER = '// TODO: add code that should';
/**
 * Extract a simple `key: value` from frontmatter using line iteration —
 * avoids needing a full YAML parser and stays consistent with the existing
 * regex-based field extraction.
 *
 * Returns `undefined` ONLY when the key is absent from the frontmatter.
 * Returns `''` (empty string) when the key is present but its value is empty
 * — so callers can distinguish "omitted, apply default" from "explicit empty,
 * reject as invalid." Without this distinction, `surface:` (blank) would
 * silently route to the default and bypass the enum typo guard.
 */
function extractFrontmatterField(meta, key) {
    const prefix = `${key}:`;
    for (const line of meta.split('\n')) {
        const trimmed = line.trim();
        if (trimmed.startsWith(prefix)) {
            return trimmed.slice(prefix.length).trim();
        }
    }
    return undefined;
}
/** Return true when every line in the fixture is a scaffolding placeholder. */
export function isTodoFixture(fixture) {
    const allLines = [...fixture.failLines, ...fixture.passLines];
    return allLines.length === 0 || allLines.every((l) => l.includes(TODO_MARKER));
}
// totem-ignore-next-line — .exec() calls below are for trusted local fixture parsing, not security-sensitive
export function parseFixture(content, fixturePath) {
    const frontmatter = FRONTMATTER_RE.exec(content); // totem-ignore
    if (!frontmatter)
        return null;
    const meta = frontmatter[1];
    const ruleMatch = /rule:\s*(.+)/.exec(meta); // totem-ignore
    const fileMatch = /file:\s*(.+)/.exec(meta); // totem-ignore
    if (!ruleMatch)
        return null;
    const ruleHash = ruleMatch[1].trim();
    const filePath = fileMatch ? fileMatch[1].trim() : 'src/example.ts'; // totem-ignore — default fixture path
    const failMatch = FAIL_SECTION_RE.exec(content); // totem-ignore
    const passMatch = PASS_SECTION_RE.exec(content); // totem-ignore
    const failLines = failMatch ? failMatch[1].split('\n').filter((l) => l.trim().length > 0) : [];
    const passLines = passMatch ? passMatch[1].split('\n').filter((l) => l.trim().length > 0) : [];
    // ADR-104 § Convergence — additive `surface:` and `corpus:` frontmatter
    // fields. Both default when absent (backwards-compat with existing
    // fixtures predating the hook engine). Invalid values reject the fixture
    // entirely rather than defaulting silently — a typo in the enum would
    // otherwise misroute the matcher dispatch in PR-1's follow-on.
    const surfaceRaw = extractFrontmatterField(meta, 'surface');
    let surface = 'rules';
    if (surfaceRaw !== undefined) {
        // Explicit-empty is a fixture-authoring mistake (`surface:` with no value)
        // — fail loud rather than silently defaulting, same as a typo.
        if (!FIXTURE_SURFACES.includes(surfaceRaw)) {
            return null;
        }
        surface = surfaceRaw;
    }
    const corpusRaw = extractFrontmatterField(meta, 'corpus');
    let corpus = 'pass';
    if (corpusRaw !== undefined) {
        if (!FIXTURE_CORPORA.includes(corpusRaw)) {
            return null;
        }
        corpus = corpusRaw;
    }
    return { ruleHash, filePath, failLines, passLines, fixturePath, surface, corpus };
}
// ─── Fixture scaffolding ────────────────────────────
/** Generate a markdown test fixture skeleton compatible with {@link parseFixture}. */
export function scaffoldFixture(opts) {
    const filePath = opts.filePath ?? 'src/example.ts';
    const failContent = opts.failLines && opts.failLines.length > 0
        ? opts.failLines.join('\n')
        : '// TODO: add code that should trigger this rule';
    const passContent = opts.passLines && opts.passLines.length > 0
        ? opts.passLines.join('\n')
        : '// TODO: add code that should NOT trigger this rule';
    const heading = opts.heading ? [`<!-- ${opts.heading} -->`, ''] : [];
    return [
        '---',
        `rule: ${opts.ruleHash}`,
        `file: ${filePath}`,
        '---',
        '',
        ...heading,
        '## Should fail',
        '',
        '```ts',
        failContent,
        '```',
        '',
        '## Should pass',
        '',
        '```ts',
        passContent,
        '```',
        '',
    ].join('\n');
}
/** Return the canonical fixture path for a given rule hash: `<testsDir>/test-<hash>.md`. */
export function scaffoldFixturePath(testsDir, ruleHash) {
    return path.join(testsDir, `test-${ruleHash}.md`);
}
// ─── Test execution ──────────────────────────────────
function linesToAdditions(lines, filePath) {
    return lines.map((line, i) => ({
        file: filePath,
        line,
        lineNumber: i + 1,
        precedingLine: i > 0 ? lines[i - 1] : null,
    }));
}
/**
 * Existing test infrastructure for compiled rule verification.
 * Validates regex patterns against hit/miss examples from fixture files.
 * Use this instead of building new verification functions.
 */
export function testRule(rule, fixture) {
    const result = {
        ruleHash: rule.lessonHash,
        ruleHeading: rule.lessonHeading,
        fixturePath: fixture.fixturePath,
        missedFails: [],
        falsePositives: [],
        passed: true,
    };
    // Either a flat astGrepPattern (string) or a compound astGrepYamlRule
    // (NapiConfig object). Mutual exclusion is enforced by the schema
    // superRefine; the test runner just picks whichever shape is present.
    // mmnto/totem#1408 adds the compound path.
    const astGrepRule = rule.engine === 'ast-grep'
        ? (rule.astGrepPattern ?? rule.astGrepYamlRule)
        : undefined;
    const isAstGrep = astGrepRule !== undefined;
    if (isAstGrep) {
        const ext = path.extname(fixture.filePath) || '.ts';
        const pattern = astGrepRule;
        // Test fail block — parse all fail lines as one snippet; expect at least one match
        if (fixture.failLines.length > 0) {
            const content = fixture.failLines.join('\n');
            const allLineNums = fixture.failLines.map((_, i) => i + 1);
            try {
                const matches = matchAstGrepPattern(content, ext, pattern, allLineNums);
                if (matches.length === 0) {
                    result.missedFails.push(fixture.failLines.join('\n'));
                }
            }
            catch (err) {
                result.missedFails.push(`[ast-grep error] ${getErrorMessage(err)}`);
            }
        }
        // Test pass block — parse all pass lines as one snippet; expect zero matches
        if (fixture.passLines.length > 0) {
            const content = fixture.passLines.join('\n');
            const allLineNums = fixture.passLines.map((_, i) => i + 1);
            try {
                const matches = matchAstGrepPattern(content, ext, pattern, allLineNums);
                if (matches.length > 0) {
                    result.falsePositives.push(fixture.passLines.join('\n'));
                }
            }
            catch (err) {
                result.falsePositives.push(`[ast-grep error] ${getErrorMessage(err)}`);
            }
        }
    }
    else {
        // Regex-engine rules — test line by line. Fixture runs are synthetic;
        // any shield-context: deprecation that slips through goes to a no-op
        // logger to keep fixture semantics isolated from production warnings.
        const ctx = {
            logger: { warn: () => { } },
            state: { hasWarnedShieldContext: false },
        };
        for (const line of fixture.failLines) {
            const additions = linesToAdditions([line], fixture.filePath);
            const violations = applyRulesToAdditions(ctx, [rule], additions);
            if (violations.length === 0) {
                result.missedFails.push(line);
            }
        }
        for (const line of fixture.passLines) {
            const additions = linesToAdditions([line], fixture.filePath);
            const violations = applyRulesToAdditions(ctx, [rule], additions);
            if (violations.length > 0) {
                result.falsePositives.push(line);
            }
        }
    }
    result.passed = result.missedFails.length === 0 && result.falsePositives.length === 0;
    return result;
}
// ─── Runner ──────────────────────────────────────────
export function loadFixtures(testsDir) {
    if (!fs.existsSync(testsDir))
        return [];
    const files = fs.readdirSync(testsDir).filter((f) => f.endsWith('.md'));
    const fixtures = [];
    for (const file of files) {
        const filePath = path.join(testsDir, file);
        const content = fs.readFileSync(filePath, 'utf-8');
        const fixture = parseFixture(content, filePath);
        if (fixture)
            fixtures.push(fixture);
    }
    return fixtures;
}
export function runRuleTests(rulesPath, testsDir) {
    const rules = loadCompiledRules(rulesPath);
    // ADR-104 § Convergence — the hooks-surface dispatch lives in CLI's hook
    // test runner (avoids cross-package import from core to the CLI foundation
    // engine). Filter to rules-surface fixtures here so a hooks-surface fixture
    // does not surface as `(unknown — hash X not found in compiled rules)`
    // under `totem rule test`. Defaults to `'rules'` when surface is absent
    // (backwards-compat with fixtures predating the hook engine).
    const fixtures = loadFixtures(testsDir).filter((f) => (f.surface ?? 'rules') === 'rules');
    if (fixtures.length === 0) {
        return { total: 0, passed: 0, failed: 0, skipped: 0, skippedFixtures: [], results: [] };
    }
    const ruleMap = new Map(rules.map((r) => [r.lessonHash, r]));
    const results = [];
    const skippedFixtures = [];
    for (const fixture of fixtures) {
        if (isTodoFixture(fixture)) {
            const heading = ruleMap.get(fixture.ruleHash)?.lessonHeading ?? '';
            skippedFixtures.push({
                path: fixture.fixturePath,
                ruleHash: fixture.ruleHash,
                ruleHeading: heading,
            });
            continue;
        }
        const rule = ruleMap.get(fixture.ruleHash);
        if (!rule) {
            results.push({
                ruleHash: fixture.ruleHash,
                ruleHeading: `(unknown — hash ${fixture.ruleHash} not found in compiled rules)`,
                fixturePath: fixture.fixturePath,
                missedFails: fixture.failLines,
                falsePositives: [],
                passed: false,
            });
            continue;
        }
        results.push(testRule(rule, fixture));
    }
    const passed = results.filter((r) => r.passed).length;
    return {
        total: results.length,
        passed,
        failed: results.length - passed,
        skipped: skippedFixtures.length,
        skippedFixtures,
        results,
    };
}
//# sourceMappingURL=rule-tester.js.map