// engine/spec-versioning/differ.ts — Diff two spec versions at AC-level granularity
import { getVersion } from './version-store.js';
import { extractAcceptanceCriteria } from '../shared/ac-extractor.js';
import { computeLineDiff } from '../shared/line-diff.js';
export { extractAcceptanceCriteria, computeLineDiff };
/** Extract section headers from spec content. */
function extractSectionHeaders(content) {
    return content
        .split('\n')
        .filter((line) => /^#+\s/.test(line.trim()))
        .map((line) => line.trim());
}
/** Compute AC-level diff between two sets of criteria. */
export function computeAcDiff(fromCriteria, toCriteria) {
    const fromSet = new Set(fromCriteria);
    const toSet = new Set(toCriteria);
    const added = [];
    const removed = [];
    const modified = [];
    // Find removed and potentially modified
    for (const criterion of fromCriteria) {
        if (!toSet.has(criterion)) {
            // Check if it was modified (high similarity match)
            const similar = findSimilar(criterion, toCriteria, fromCriteria);
            if (similar !== null && !fromSet.has(similar)) {
                modified.push({ before: criterion, after: similar });
                toSet.delete(similar); // Mark as consumed
            }
            else {
                removed.push(criterion);
            }
        }
    }
    // Find added (those in toCriteria not matched yet)
    for (const criterion of toCriteria) {
        if (!fromSet.has(criterion) && !modified.some((m) => m.after === criterion)) {
            added.push(criterion);
        }
    }
    return { added, removed, modified };
}
/** Find the most similar string in a list (similarity > 0.5). Returns null if none. */
function findSimilar(target, candidates, excludeFrom) {
    const excludeSet = new Set(excludeFrom);
    let bestScore = 0.5; // threshold
    let bestMatch = null;
    for (const candidate of candidates) {
        if (excludeSet.has(candidate)) {
            continue;
        }
        const score = bigramSimilarity(target, candidate);
        if (score > bestScore) {
            bestScore = score;
            bestMatch = candidate;
        }
    }
    return bestMatch;
}
/** Compute bigram similarity between two strings (0–1). */
function bigramSimilarity(a, b) {
    if (a === b) {
        return 1;
    }
    if (a.length < 2 || b.length < 2) {
        return 0;
    }
    const buildBigrams = (str) => {
        const bigrams = new Set();
        for (let i = 0; i < str.length - 1; i++) {
            bigrams.add(str.slice(i, i + 2));
        }
        return bigrams;
    };
    const bigramsA = buildBigrams(a.toLowerCase());
    const bigramsB = buildBigrams(b.toLowerCase());
    let intersection = 0;
    for (const bigram of bigramsA) {
        if (bigramsB.has(bigram)) {
            intersection++;
        }
    }
    return (2 * intersection) / (bigramsA.size + bigramsB.size);
}
/** Detect which sections changed between two spec contents. */
export function detectSectionChanges(fromContent, toContent) {
    const fromHeaders = new Set(extractSectionHeaders(fromContent));
    const toHeaders = new Set(extractSectionHeaders(toContent));
    const changes = [];
    for (const header of toHeaders) {
        if (!fromHeaders.has(header)) {
            changes.push(`Added section: ${header}`);
        }
    }
    for (const header of fromHeaders) {
        if (!toHeaders.has(header)) {
            changes.push(`Removed section: ${header}`);
        }
    }
    return changes;
}
/**
 * Diff two spec versions stored on disk.
 * Throws if either version tag is not found.
 */
export async function diffVersions(dataDir, specId, fromTag, toTag) {
    const [fromVersion, toVersion] = await Promise.all([
        getVersion(dataDir, specId, fromTag),
        getVersion(dataDir, specId, toTag),
    ]);
    if (fromVersion === null) {
        throw new Error(`Version "${fromTag}" not found for spec ${specId}.`);
    }
    if (toVersion === null) {
        throw new Error(`Version "${toTag}" not found for spec ${specId}.`);
    }
    const { added, removed } = computeLineDiff(fromVersion.content, toVersion.content);
    const fromCriteria = extractAcceptanceCriteria(fromVersion.content);
    const toCriteria = extractAcceptanceCriteria(toVersion.content);
    const criteriaChanges = computeAcDiff(fromCriteria, toCriteria);
    const sectionChanges = detectSectionChanges(fromVersion.content, toVersion.content);
    return {
        fromTag,
        toTag,
        specId,
        added,
        removed,
        criteriaChanges,
        sectionChanges,
    };
}
/**
 * Diff two spec content strings directly (no disk lookup).
 * Used before merging to show what will change.
 */
export function diffContents(specId, fromTag, toTag, fromContent, toContent) {
    const { added, removed } = computeLineDiff(fromContent, toContent);
    const fromCriteria = extractAcceptanceCriteria(fromContent);
    const toCriteria = extractAcceptanceCriteria(toContent);
    const criteriaChanges = computeAcDiff(fromCriteria, toCriteria);
    const sectionChanges = detectSectionChanges(fromContent, toContent);
    return {
        fromTag,
        toTag,
        specId,
        added,
        removed,
        criteriaChanges,
        sectionChanges,
    };
}
//# sourceMappingURL=differ.js.map