import * as crypto from 'node:crypto';
import { PROMOTION_THRESHOLD } from './exemption-schema.js';
// ─── Stopwords ──────────────────────────────────────────
const STOPWORDS = new Set([
    'a',
    'an',
    'the',
    'is',
    'are',
    'was',
    'were',
    'be',
    'been',
    'being',
    'have',
    'has',
    'had',
    'do',
    'does',
    'did',
    'will',
    'would',
    'shall',
    'should',
    'may',
    'might',
    'must',
    'can',
    'could',
    'to',
    'of',
    'in',
    'for',
    'on',
    'with',
    'at',
    'by',
    'from',
    'as',
    'or',
    'and',
    'but',
    'not',
    'this',
    'that',
    'it',
    'its',
    'no',
    'if',
    'so',
    'up',
    'out',
    'about',
]);
function extractKeywords(message) {
    return message
        .toLowerCase()
        .replace(/[^a-z0-9\s]/g, ' ')
        .split(/\s+/)
        .filter((w) => w.length >= 3 && !STOPWORDS.has(w))
        .sort();
}
export function computePatternId(message) {
    const keywords = extractKeywords(message);
    const source = keywords.length > 0 ? keywords.join(':') : message.toLowerCase().trim();
    const hash = crypto.createHash('sha256').update(source).digest('hex');
    return `shield:${hash}`;
}
export function recordFalsePositive(local, patternId, source, message) {
    const existing = local.patterns[patternId] ?? {
        count: 0,
        sources: [],
        lastSeenAt: new Date().toISOString(),
        sampleMessages: [],
    };
    const count = existing.count + 1;
    const sources = existing.sources.includes(source)
        ? existing.sources
        : [...existing.sources, source];
    const sampleMessages = existing.sampleMessages.length < 3 && !existing.sampleMessages.includes(message)
        ? [...existing.sampleMessages, message]
        : existing.sampleMessages;
    const updated = {
        count,
        sources,
        lastSeenAt: new Date().toISOString(),
        sampleMessages,
    };
    const updatedLocal = {
        patterns: { ...local.patterns, [patternId]: updated },
    };
    return { updatedLocal, promoted: count === PROMOTION_THRESHOLD };
}
export function promoteToShared(shared, patternId, localPattern) {
    if (shared.exemptions.some((e) => e.patternId === patternId)) {
        return shared;
    }
    const keywords = localPattern.sampleMessages[0]
        ? extractKeywords(localPattern.sampleMessages[0]).slice(0, 5).join(' ')
        : '';
    const label = keywords || patternId;
    const entry = {
        patternId,
        label,
        reason: `Auto-promoted after ${localPattern.count} false positives`,
        promotedAt: new Date().toISOString(),
        promotedBy: 'auto',
        sampleMessages: localPattern.sampleMessages,
    };
    return {
        ...shared,
        exemptions: [...shared.exemptions, entry],
    };
}
function escapeRegExp(s) {
    return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function buildManualLabelMatchers(shared) {
    return shared.exemptions
        .filter((e) => e.patternId.startsWith('manual:'))
        .map((e) => new RegExp('\\b' + escapeRegExp(e.label.toLowerCase()) + '\\b', 'i'));
}
export function filterExemptedFindings(findings, shared) {
    const autoExemptedIds = new Set(shared.exemptions.filter((e) => !e.patternId.startsWith('manual:')).map((e) => e.patternId));
    const manualLabelPatterns = buildManualLabelMatchers(shared);
    const filtered = [];
    const exempted = [];
    for (const finding of findings) {
        const pid = computePatternId(finding.message);
        const matchesAuto = autoExemptedIds.has(pid);
        const matchesManual = manualLabelPatterns.some((re) => re.test(finding.message));
        if (matchesAuto || matchesManual) {
            exempted.push({ ...finding, severity: 'INFO' });
        }
        else {
            filtered.push(finding);
        }
    }
    return { filtered, exempted };
}
export function isExempted(patternId, exemptions) {
    return exemptions.exemptions.some((e) => e.patternId === patternId);
}
export function addManualSuppression(shared, label, reason) {
    const trimmed = label.trim();
    if (!trimmed)
        return shared;
    const patternId = `manual:${trimmed}`;
    if (shared.exemptions.some((e) => e.patternId === patternId)) {
        return shared;
    }
    const entry = {
        patternId,
        label: trimmed,
        reason,
        promotedAt: new Date().toISOString(),
        promotedBy: 'manual',
        sampleMessages: [],
    };
    return {
        ...shared,
        exemptions: [...shared.exemptions, entry],
    };
}
/**
 * Track false positives for a batch of findings. Returns updated local/shared
 * state and a list of promoted pattern messages (for logging).
 */
export function trackFalsePositives(findings, source, local, shared) {
    let currentLocal = local;
    let currentShared = shared;
    const promoted = [];
    const seenPatternIds = new Set();
    const manualPatterns = buildManualLabelMatchers(shared);
    for (const finding of findings) {
        const pid = computePatternId(finding.message);
        if (seenPatternIds.has(pid))
            continue;
        seenPatternIds.add(pid);
        if (isExempted(pid, currentShared))
            continue;
        if (manualPatterns.some((re) => re.test(finding.message)))
            continue;
        const { updatedLocal, promoted: didPromote } = recordFalsePositive(currentLocal, pid, source, finding.message);
        currentLocal = updatedLocal;
        if (didPromote) {
            currentShared = promoteToShared(currentShared, pid, updatedLocal.patterns[pid]);
            promoted.push(finding.message.slice(0, 80));
        }
    }
    return { local: currentLocal, shared: currentShared, promoted };
}
//# sourceMappingURL=exemption-engine.js.map