// engine/verifier/criterion-matcher.ts — Keyword extraction + codebase search (SPEC-406 + SPEC-786)
import { readFile } from 'node:fs/promises';
import { glob } from 'glob';
import { matchKeyword } from '../keyword-matcher/index.js';
import { BDD_STOP_WORDS, extractBddEntities } from './bdd-entity-extractor.js';
export { extractBddEntities } from './bdd-entity-extractor.js';
// Common English stop words to exclude from keyword extraction
const STOP_WORDS = new Set([
    'the',
    'a',
    'an',
    'and',
    'or',
    'but',
    'in',
    'on',
    'at',
    'to',
    'for',
    'of',
    'with',
    'by',
    'from',
    'is',
    'are',
    'was',
    'were',
    'be',
    'been',
    'have',
    'has',
    'had',
    'do',
    'does',
    'did',
    'will',
    'would',
    'could',
    'should',
    'may',
    'might',
    'must',
    'shall',
    'can',
    'need',
    'that',
    'this',
    'it',
    'its',
    'when',
    'if',
    'then',
    'else',
    'not',
    'no',
    'yes',
    'all',
    'each',
    'every',
    'any',
    'some',
    'which',
    'who',
    'what',
    'where',
    'how',
    'so',
    'as',
    'than',
    'into',
    'about',
    'after',
    'before',
    'between',
    'such',
    'only',
    'also',
    'return',
    'returns',
    'given',
    'there',
    'their',
    'they',
    'them',
    'user',
    'users',
    'system',
    'data',
    'value',
    'values',
    'true',
    'false',
    'null',
    'undefined',
    'new',
    'type',
    'types',
]);
/**
 * Extracts meaningful keywords from a criterion text.
 * Captures: camelCase/PascalCase identifiers, route patterns,
 * quoted strings, and significant technical terms.
 */
export function extractKeywords(criterion) {
    const keywords = new Set();
    // Extract quoted strings (single or double quotes)
    const quotedPattern = /["']([^"']{2,})['"]/g;
    let match;
    while ((match = quotedPattern.exec(criterion)) !== null) {
        const val = match[1];
        if (val !== undefined && val.trim().length > 1) {
            keywords.add(val.trim());
        }
    }
    // Extract route patterns like /api/users or /v1/endpoint
    const routePattern = /\/[a-zA-Z0-9_/-]{2,}/g;
    while ((match = routePattern.exec(criterion)) !== null) {
        keywords.add(match[0]);
    }
    // Extract camelCase identifiers (at least 2 segments)
    const camelCasePattern = /\b[a-z][a-zA-Z0-9]*[A-Z][a-zA-Z0-9]*\b/g;
    while ((match = camelCasePattern.exec(criterion)) !== null) {
        keywords.add(match[0]);
    }
    // Extract PascalCase identifiers
    const pascalCasePattern = /\b[A-Z][a-zA-Z0-9]{2,}\b/g;
    while ((match = pascalCasePattern.exec(criterion)) !== null) {
        keywords.add(match[0]);
    }
    // Extract remaining significant words (length >= 4, not stop words)
    const wordPattern = /\b[a-zA-Z_][a-zA-Z0-9_]{3,}\b/g;
    while ((match = wordPattern.exec(criterion)) !== null) {
        const word = match[0].toLowerCase();
        if (!STOP_WORDS.has(word) && match[0].length >= 4) {
            keywords.add(match[0]);
        }
    }
    return Array.from(keywords).slice(0, 20);
}
/**
 * Source file glob patterns to include in the codebase scan.
 */
const SOURCE_GLOBS = ['**/*.ts', '**/*.js', '**/*.py', '**/*.go', '**/*.java', '**/*.rs'];
/**
 * Directories to exclude from scanning.
 */
const IGNORE_PATTERNS = [
    '**/node_modules/**',
    '**/dist/**',
    '**/.git/**',
    '**/build/**',
    '**/__pycache__/**',
];
/**
 * Maximum number of files to scan per matchCriteria call.
 * Prevents O(N*F) explosion on large repos.
 */
const MAX_FILES = 300;
/**
 * Build a shared file-contents cache by scanning the project once.
 * Caps the scan at MAX_FILES to bound memory and I/O.
 */
export async function buildFileContentsCache(projectPath, signal) {
    const files = await glob(SOURCE_GLOBS, {
        cwd: projectPath,
        ignore: IGNORE_PATTERNS,
        absolute: true,
    });
    const capped = files.slice(0, MAX_FILES);
    const cache = new Map();
    await Promise.all(capped.map(async (file) => {
        if (signal?.aborted) {
            return;
        }
        try {
            const content = await readFile(file, 'utf-8');
            cache.set(file, content);
        }
        catch {
            // Skip unreadable files
        }
    }));
    return cache;
}
/**
 * Determines whether a file path entity matches against scanned file paths / content.
 * Checks: path containment first, then stem-name presence in file content.
 */
function matchPathEntity(entity, file, content) {
    const entityNormalized = entity.replace(/\\/g, '/');
    if (file.includes(entityNormalized) || file.endsWith(entityNormalized)) {
        return true;
    }
    const filename = entity.split('/').pop() ?? entity;
    const stem = filename.replace(/\.test\.[a-z]+$/, '').replace(/\.[a-z]+$/, '');
    return (stem.length > 2 &&
        matchKeyword(content, stem, {
            minWordLength: 2,
            requireBoundary: false,
            skipActionVerbs: false,
            skipStopwords: false,
        }));
}
/**
 * Scans the file contents cache for a set of BDD entities.
 * Returns { found, evidenceFiles } where found is the count of matched entities.
 */
function scanEntities(entities, fileContents) {
    const foundEntities = new Set();
    const evidenceFiles = [];
    for (const [file, content] of fileContents) {
        for (const entity of entities) {
            const entityFound = entity.includes('/')
                ? matchPathEntity(entity, file, content)
                : matchKeyword(content, entity, {
                    minWordLength: 2,
                    requireBoundary: false,
                    skipActionVerbs: false,
                    skipStopwords: false,
                });
            if (entityFound) {
                foundEntities.add(entity);
                if (!evidenceFiles.includes(file)) {
                    evidenceFiles.push(file);
                }
            }
        }
    }
    return { found: foundEntities.size, evidenceFiles };
}
/**
 * Classic keyword scan (SPEC-406 backward compat): boundary-aware matching.
 * Returns { found, evidenceFiles } where found is the count of matched keywords.
 */
function scanKeywords(keywords, fileContents) {
    const foundKeywords = new Set();
    const evidenceFiles = [];
    for (const [file, content] of fileContents) {
        for (const kw of keywords) {
            if (matchKeyword(content, kw, {
                minWordLength: 4,
                requireBoundary: true,
                skipActionVerbs: false,
                skipStopwords: false,
            })) {
                foundKeywords.add(kw);
                if (!evidenceFiles.includes(file)) {
                    evidenceFiles.push(file);
                }
            }
        }
    }
    return { found: foundKeywords.size, evidenceFiles };
}
/**
 * Scans the codebase for evidence matching a single criterion.
 * Reuses a pre-built fileContents cache to avoid re-reading files per criterion.
 *
 * Entity-based scoring (SPEC-786):
 *   - When BDD entities > 0: score by entity coverage (all found → verified, any found → partial, none → unverified)
 *   - When BDD entities = 0 AND all keywords are BDD prose: criterion is a behavioural assertion → partial
 *   - When BDD entities = 0 AND criterion is empty: → unverified
 *   - Otherwise: fall back to classic keyword scoring (2+ → verified, 1 → partial, 0 → unverified)
 */
export function matchCriterionFromCache(criterion, fileContents) {
    const keywords = extractKeywords(criterion);
    const bddEntities = extractBddEntities(criterion);
    // Detect pure behavioural prose with no technical entities (SPEC-786).
    if (bddEntities.length === 0) {
        if (criterion.trim().length === 0) {
            return { criterion, status: 'unverified', evidence: [], keywords: [] };
        }
        const allBddProse = keywords.every((kw) => BDD_STOP_WORDS.has(kw.toLowerCase()));
        if (allBddProse) {
            return { criterion, status: 'partial', evidence: [], keywords };
        }
        // Has technical keywords → classic scoring
        const { found, evidenceFiles } = scanKeywords(keywords, fileContents);
        const status = found >= 2 ? 'verified' : found === 1 ? 'partial' : 'unverified';
        return { criterion, status, evidence: evidenceFiles.slice(0, 10), keywords };
    }
    // Entity-based scoring path.
    const { found, evidenceFiles } = scanEntities(bddEntities, fileContents);
    const total = bddEntities.length;
    const status = found >= total ? 'verified' : found > 0 ? 'partial' : 'unverified';
    return {
        criterion,
        status,
        evidence: evidenceFiles.slice(0, 10),
        keywords: bddEntities,
    };
}
/**
 * Scans the codebase for evidence matching the criterion.
 * Accepts an optional pre-built fileContents cache to avoid redundant I/O.
 * When fileContents is not provided, performs a single glob + read (capped at MAX_FILES).
 *
 * Prefer passing fileContents when checking multiple criteria against the same project
 * to share a single read pass across all criteria.
 */
export async function matchCriterion(criterion, projectPath, signal, fileContents) {
    const cache = fileContents ?? (await buildFileContentsCache(projectPath, signal));
    return matchCriterionFromCache(criterion, cache);
}
/**
 * Match multiple criteria against the same project in a single file-read pass.
 * This is the preferred entry point when checking N criteria — O(F) instead of O(N×F).
 */
export async function matchCriteria(criteria, projectPath, signal, fileContents) {
    if (criteria.length === 0) {
        return [];
    }
    const cache = fileContents ?? (await buildFileContentsCache(projectPath, signal));
    return criteria.map((criterion) => matchCriterionFromCache(criterion, cache));
}
//# sourceMappingURL=criterion-matcher.js.map