/**
 * @internal
 * constitution/match.mjs — principle-vs-spec matcher (C14 subtask 2).
 *
 * matchPrinciple(principle, specMarkdown) -> Match
 *
 * Match = {
 *   status:    'covered' | 'na' | 'missing',
 *   evidence: {
 *     matched_keywords: string[],
 *     lines:            number[],
 *     na_reason?:       string,
 *   }
 * }
 *
 * Algorithm (order matters):
 *
 *   1. If spec.md contains a `## Constitution Coverage` section and
 *      that section has a line of the exact form
 *      `- [{principle.id}] N/A: <reason>`  → status = 'na' (na_reason set).
 *      A `covered:` opt-in in the same section is informational only — the
 *      keyword heuristic still runs so we don't trust unverified author claims.
 *
 *   2. Count distinct keyword hits (case-insensitive, word-boundary) over the
 *      full spec. A principle is `covered` when
 *        hits >= max(2, ceil(principle.keywords.length * 0.3)).
 *
 *   3. Single-keyword principles fall back to an exact title-phrase match
 *      (case-insensitive substring) — keyword overlap would otherwise gate
 *      on a single hit which is too noisy.
 *
 *   4. Otherwise → `missing`.
 */
/**
 * Escape a string for safe use inside a RegExp literal.
 */
function escapeRegExp(str) {
    return String(str).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
/**
 * Locate the `## Constitution Coverage` section boundary and return the
 * 1-indexed [start, end) line range that contains its body (not the heading).
 * Returns null when no such section exists.
 */
function findCoverageSection(specLines) {
    const headingRe = /^#{1,6}\s+constitution\s+coverage\s*$/i;
    for (let i = 0; i < specLines.length; i++) {
        if (headingRe.test(specLines[i].trim())) {
            // Find the next heading of the same or shallower depth, or EOF.
            const headingLevel = (specLines[i].match(/^#+/) ?? [''])[0].length;
            let end = specLines.length;
            for (let j = i + 1; j < specLines.length; j++) {
                const m = specLines[j].match(/^(#+)\s+/);
                if (m && m[1].length <= headingLevel) {
                    end = j;
                    break;
                }
            }
            return { start: i + 1, end };
        }
    }
    return null;
}
/**
 * Look for an N/A opt-out bullet `- [{id}] N/A: <reason>` inside the
 * Constitution Coverage section. Returns the trimmed reason (possibly '')
 * when found, or null when absent.
 */
function findNaOptOut(specLines, principleId) {
    const section = findCoverageSection(specLines);
    if (!section)
        return null;
    const idEscaped = escapeRegExp(principleId);
    const re = new RegExp(`^\\s*-\\s*\\[${idEscaped}\\]\\s*N\\/A\\s*:\\s*(.*)$`, 'i');
    for (let i = section.start; i < section.end; i++) {
        const m = re.exec(specLines[i]);
        if (m)
            return m[1].trim();
    }
    return null;
}
/**
 * Match a single principle against a spec.md markdown string.
 *
 * @param {{
 *   id: string,
 *   title: string,
 *   text?: string,
 *   keywords: string[],
 *   severity?: string,
 * }} principle
 * @param {string} specMarkdown
 * @returns {{
 *   status: 'covered' | 'na' | 'missing',
 *   evidence: { matched_keywords: string[], lines: number[], na_reason?: string }
 * }}
 */
export function matchPrinciple(principle, specMarkdown) {
    const specText = typeof specMarkdown === 'string' ? specMarkdown : '';
    const specLines = specText.split(/\r?\n/);
    // Rule 1 — explicit N/A opt-out under ## Constitution Coverage.
    const naReason = findNaOptOut(specLines, principle.id);
    if (naReason !== null) {
        return {
            status: 'na',
            evidence: {
                matched_keywords: [],
                lines: [],
                na_reason: naReason,
            },
        };
    }
    const keywords = Array.isArray(principle.keywords) ? principle.keywords : [];
    const matchedSet = new Set();
    const matchedLines = new Set();
    // Rule 2 / 3 — keyword-overlap scan. Case-insensitive, word-boundary.
    for (const kw of keywords) {
        if (!kw || typeof kw !== 'string')
            continue;
        const re = new RegExp(`\\b${escapeRegExp(kw)}\\b`, 'i');
        for (let i = 0; i < specLines.length; i++) {
            if (re.test(specLines[i])) {
                matchedSet.add(kw.toLowerCase());
                matchedLines.add(i + 1);
                break; // one hit per keyword is enough; move on
            }
        }
    }
    const distinctHits = matchedSet.size;
    const threshold = Math.max(2, Math.ceil(keywords.length * 0.3));
    if (keywords.length >= 2 && distinctHits >= threshold) {
        return {
            status: 'covered',
            evidence: {
                matched_keywords: [...matchedSet],
                lines: [...matchedLines].sort((a, b) => a - b),
            },
        };
    }
    // Rule 3 — single-keyword fallback: try an exact title-phrase substring.
    if (keywords.length <= 1 && typeof principle.title === 'string' && principle.title.length > 0) {
        const needle = principle.title.trim().toLowerCase();
        for (let i = 0; i < specLines.length; i++) {
            if (specLines[i].toLowerCase().includes(needle)) {
                return {
                    status: 'covered',
                    evidence: {
                        matched_keywords: keywords.map((k) => k.toLowerCase()),
                        lines: [i + 1],
                    },
                };
            }
        }
    }
    return {
        status: 'missing',
        evidence: {
            matched_keywords: [...matchedSet],
            lines: [...matchedLines].sort((a, b) => a - b),
        },
    };
}
//# sourceMappingURL=match.mjs.map