// engine/github/spec-ac-analyzer.ts — Cross-reference PR diff against spec acceptance criteria.
// Extracts the spec ID from the PR branch, loads its ACs, and determines coverage from the diff.
import { readFile } from 'node:fs/promises';
import { join } from 'node:path';
import { stripFrontmatter } from '../frontmatter-parser.js';
import { extractSection, extractListItems } from '../validator/extractors.js';
// Pattern: feat/SPEC-123-slug or fix/SPEC-123-slug
const BRANCH_SPEC_PATTERN = /(?:feat|fix|chore|docs|refactor)\/SPEC-(\d+)/i;
/**
 * Extract a SPEC-NNN id from a PR branch name.
 * Returns null when the branch does not follow the SPEC-XXX convention.
 */
export function extractSpecIdFromBranch(branch) {
    const match = BRANCH_SPEC_PATTERN.exec(branch);
    if (!match?.[1]) {
        return null;
    }
    return `SPEC-${match[1]}`;
}
/**
 * Locate the spec.md file for the given specId inside the planu/specs directory.
 * Returns null when no matching directory is found.
 */
export async function findSpecFile(specId, specsRootDir) {
    const { readdir } = await import('node:fs/promises');
    let entries;
    try {
        entries = await readdir(specsRootDir);
    }
    catch {
        return null;
    }
    const prefix = specId.toUpperCase() + '-';
    const dir = entries.find((e) => e.toUpperCase().startsWith(prefix));
    if (!dir) {
        return null;
    }
    return join(specsRootDir, dir, 'spec.md');
}
/**
 * Parse acceptance criteria from a spec.md file content.
 * Supports lean format (YAML criteria:) and old format (## Acceptance Criteria).
 */
export function parseAcceptanceCriteria(specContent) {
    // SPEC-461: Try lean format first (criteria in YAML frontmatter)
    const fmMatch = /^---\n([\s\S]*?)\n---/m.exec(specContent);
    if (fmMatch?.[1]?.includes('criteria:')) {
        const acs = [];
        const criteriaRe = /- text: "(.+?)"/g;
        let match = criteriaRe.exec(fmMatch[1]);
        while (match) {
            if (match[1]) {
                acs.push(match[1].replace(/\\"/g, '"'));
            }
            match = criteriaRe.exec(fmMatch[1]);
        }
        if (acs.length > 0) {
            return acs;
        }
    }
    // Fallback: old format
    const body = stripFrontmatter(specContent);
    const section = extractSection(body, 'acceptance criteria', 'criterios de aceptaci');
    if (!section) {
        return [];
    }
    return extractListItems(section);
}
/**
 * Build a set of keywords from a diff that can be matched against an AC text.
 * Extracts meaningful tokens from added lines only (+lines).
 */
export function buildDiffKeywords(diff) {
    const keywords = new Set();
    for (const line of diff.split('\n')) {
        if (!line.startsWith('+') || line.startsWith('+++')) {
            continue;
        }
        // Extract identifier-like tokens (camelCase, snake_case, kebab-case, plain words)
        const tokens = line.slice(1).match(/[a-zA-Z][a-zA-Z0-9_-]{2,}/g);
        if (tokens) {
            for (const tok of tokens) {
                keywords.add(tok.toLowerCase());
            }
        }
    }
    return keywords;
}
/**
 * Determine coverage status of a single AC text against the diff keywords.
 * Heuristic: extract meaningful words from the AC text and check how many
 * appear in the diff keywords set. Score thresholds:
 *   >= 0.5 → covered
 *   >= 0.2 → partial
 *   < 0.2  → missing
 */
export function assessAcCoverage(acText, diffKeywords) {
    // Normalize: strip checkbox prefix, lowercase
    const normalized = acText.replace(/^\[[ xX]?\]\s*/, '').toLowerCase();
    // Extract meaningful words (>3 chars, not common stop-words)
    const STOP_WORDS = new Set([
        'the',
        'and',
        'for',
        'with',
        'that',
        'this',
        'are',
        'have',
        'from',
        'not',
        'all',
        'any',
        'its',
        'can',
        'will',
        'when',
        'been',
        'than',
        'also',
        'into',
        'over',
        'after',
        'some',
    ]);
    const words = (normalized.match(/[a-z][a-z0-9]{2,}/g) ?? []).filter((w) => !STOP_WORDS.has(w));
    if (words.length === 0) {
        return 'missing';
    }
    const matched = words.filter((w) => diffKeywords.has(w)).length;
    const ratio = matched / words.length;
    if (ratio >= 0.5) {
        return 'covered';
    }
    if (ratio >= 0.2) {
        return 'partial';
    }
    return 'missing';
}
/**
 * Full analysis: given a diff and a specId, loads the AC list and returns
 * per-criterion coverage plus an overall compliance score.
 *
 * @param diff       Raw unified diff text from the PR.
 * @param specId     Spec identifier, e.g. "SPEC-155".
 * @param specsRoot  Absolute path to the `planu/specs/` directory.
 */
export async function analyzeSpecCoverage(diff, specId, specsRoot) {
    const specFile = await findSpecFile(specId, specsRoot);
    if (!specFile) {
        return {
            specId,
            found: false,
            criteria: [],
            coveredCount: 0,
            partialCount: 0,
            missingCount: 0,
            complianceScore: 0,
        };
    }
    let specContent;
    try {
        specContent = await readFile(specFile, 'utf-8');
    }
    catch {
        return {
            specId,
            found: false,
            criteria: [],
            coveredCount: 0,
            partialCount: 0,
            missingCount: 0,
            complianceScore: 0,
        };
    }
    const acList = parseAcceptanceCriteria(specContent);
    const diffKeywords = buildDiffKeywords(diff);
    const criteria = acList.map((text) => {
        const status = assessAcCoverage(text, diffKeywords);
        return { text, status };
    });
    const coveredCount = criteria.filter((c) => c.status === 'covered').length;
    const partialCount = criteria.filter((c) => c.status === 'partial').length;
    const missingCount = criteria.filter((c) => c.status === 'missing').length;
    const total = criteria.length;
    // Score: covered = 1 point, partial = 0.5 points
    const score = total > 0 ? Math.round(((coveredCount + partialCount * 0.5) / total) * 100) : 0;
    return {
        specId,
        found: true,
        criteria,
        coveredCount,
        partialCount,
        missingCount,
        complianceScore: score,
    };
}
//# sourceMappingURL=spec-ac-analyzer.js.map