// engine/drift-monitor.ts — Drift Monitor business logic (SPEC-348, SPEC-525)
import fs from 'node:fs';
import { readFile, stat } from 'node:fs/promises';
import path from 'node:path';
import { fastScanProjectMetadata, isNativeActive, fastDetectDriftParallel, } from './core-bridge.js';
import { readSpecTechnicalSection } from './spec-format/read-technical-section.js';
// ---------------------------------------------------------------------------
// Config builder
// ---------------------------------------------------------------------------
export function buildDriftMonitorConfig(projectPath, existing, enabled, scheduleHours, threshold) {
    const now = new Date().toISOString();
    return {
        projectPath,
        enabled,
        scheduleHours,
        threshold,
        createdAt: existing?.createdAt ?? now,
        updatedAt: now,
    };
}
// ---------------------------------------------------------------------------
// Alert rules builder
// ---------------------------------------------------------------------------
export function buildAlertRules(rules) {
    const now = new Date().toISOString();
    return rules.map((r, i) => ({
        id: `rule-${Date.now()}-${i}`,
        name: r.name,
        specIdPattern: r.specIdPattern,
        thresholdOverride: r.thresholdOverride,
        enabled: r.enabled,
        createdAt: now,
    }));
}
// ---------------------------------------------------------------------------
// Summary report (on-demand, pure computation)
// ---------------------------------------------------------------------------
export async function computeSummaryReport(projectPath, specList, config, rules, specTechnicalPaths) {
    const now = new Date().toISOString();
    const threshold = config?.threshold ?? 50;
    const alerts = [];
    let drifted = 0;
    let clean = 0;
    // Layer 3 (AC coverage) Optimization:
    // If native is active, we can do a single parallel scan for keywords across ALL specs.
    // This is much faster than doing it one-by-one.
    const nativeActive = isNativeActive();
    const metadataMap = nativeActive
        ? new Map(fastScanProjectMetadata(projectPath).map((m) => [m.path, m]))
        : undefined;
    for (const specId of specList) {
        const technicalPath = specTechnicalPaths?.get(specId);
        const { score: healthScore } = await computeDriftScore(projectPath, specId, undefined, technicalPath, metadataMap);
        // Convert health score (100=clean) to drift score (100=drifted) for threshold comparison
        const driftScore = 100 - healthScore;
        const effectiveThreshold = resolveThreshold(specId, rules, threshold);
        if (driftScore > effectiveThreshold) {
            drifted++;
            const matchedRule = rules.find((r) => r.enabled && matchPattern(specId, r.specIdPattern));
            alerts.push({
                specId,
                driftScore,
                ruleId: matchedRule?.id,
                ruleName: matchedRule?.name,
                triggeredAt: now,
            });
        }
        else {
            clean++;
        }
    }
    const overallDriftScore = specList.length > 0 ? Math.round((drifted / specList.length) * 100) : 0;
    return {
        projectPath,
        generatedAt: now,
        totalSpecs: specList.length,
        driftedSpecs: drifted,
        cleanSpecs: clean,
        unreachableSpecs: 0,
        overallDriftScore,
        alerts,
    };
}
// ---------------------------------------------------------------------------
// Core drift computation — 3-layer algorithm (SPEC-525)
// ---------------------------------------------------------------------------
/** Days after which a file modified post-approval is considered drifted. */
const TEMPORAL_DRIFT_DAYS = 30;
/**
 * Compute a real drift score (0-100) for a spec using a 3-layer algorithm:
 *
 * Layer 1 — File existence (weight 40%):
 *   fileScore = (filesPresent / totalFiles) × 100
 *   Returns 100 when no files are listed (cannot assess → assume clean).
 *
 * Layer 2 — Temporal drift (weight 40%):
 *   Files modified more than TEMPORAL_DRIFT_DAYS days after spec approval are
 *   treated as drift signals.
 *   temporalScore = 100 - (driftedFiles / totalFiles × 100)
 *   Returns 100 when no files are listed or no approval date is known.
 *
 * Layer 3 — AC coverage (weight 20%):
 *   Key terms from criteria are grepped across implementation files.
 *   acScore = (coveredCriteria / totalCriteria) × 100
 *   Returns 100 when criteria list is empty.
 *
 * Final score: fileScore×0.4 + temporalScore×0.4 + acScore×0.2, clamped [0,100].
 * Note: score represents HEALTH (100 = no drift). Callers may invert if needed.
 */
export async function computeDriftScore(projectPath, specId, spec, technicalPath, metadataMap) {
    const resolvedSpec = resolveSpecPath(projectPath, specId, technicalPath);
    let content = '';
    if (resolvedSpec !== null) {
        try {
            content = await readSpecTechnicalSection({ specPath: resolvedSpec });
        }
        catch {
            // cannot read spec.md technical section — treat all layers as fully healthy
        }
    }
    const filePaths = extractFilePaths(content, projectPath);
    const totalFiles = filePaths.length;
    // Layer 1: File existence
    const { fileScore, filesPresent } = computeFileExistenceScore(filePaths, metadataMap);
    // Layer 2: Temporal drift
    const approvalDateStr = spec?.approvedAt ?? spec?.updatedAt;
    const temporalScore = await computeTemporalScore(filePaths, approvalDateStr, metadataMap);
    // Layer 3: AC coverage
    const criteria = spec?.criteria ?? [];
    const acScore = await computeAcCoverageScore(criteria, filePaths);
    const rawScore = fileScore * 0.4 + temporalScore * 0.4 + acScore * 0.2;
    const score = Math.round(Math.min(100, Math.max(0, rawScore)));
    return {
        score,
        breakdown: {
            fileScore: Math.round(fileScore),
            temporalScore: Math.round(temporalScore),
            acScore: Math.round(acScore),
            filesPresent,
            totalFiles,
        },
    };
}
// ---------------------------------------------------------------------------
// Layer 1: File existence
// ---------------------------------------------------------------------------
function computeFileExistenceScore(filePaths, metadataMap) {
    if (filePaths.length === 0) {
        return { fileScore: 100, filesPresent: 0 };
    }
    const filesPresent = filePaths.filter((p) => {
        if (metadataMap) {
            return metadataMap.has(p);
        }
        return fs.existsSync(p);
    }).length;
    const fileScore = (filesPresent / filePaths.length) * 100;
    return { fileScore, filesPresent };
}
// ---------------------------------------------------------------------------
// Layer 2: Temporal drift
// ---------------------------------------------------------------------------
async function computeTemporalScore(filePaths, approvalDateStr, metadataMap) {
    if (filePaths.length === 0 || approvalDateStr === undefined) {
        return 100;
    }
    const approvalTime = new Date(approvalDateStr).getTime();
    /* v8 ignore next 3 */
    if (isNaN(approvalTime)) {
        return 100;
    }
    const driftThresholdMs = TEMPORAL_DRIFT_DAYS * 24 * 60 * 60 * 1000;
    let driftedFiles = 0;
    for (const filePath of filePaths) {
        try {
            let modifiedTime;
            const metadata = metadataMap?.get(filePath);
            if (metadata) {
                modifiedTime = metadata.mtime;
            }
            else {
                const stats = await stat(filePath);
                modifiedTime = stats.mtime.getTime();
            }
            const deltaMs = modifiedTime - approvalTime;
            /* v8 ignore next 3 */
            if (deltaMs > driftThresholdMs) {
                driftedFiles++;
            }
        }
        catch {
            // file missing — skip temporal check for this file (Layer 1 handles it)
        }
    }
    return 100 - (driftedFiles / filePaths.length) * 100;
}
// ---------------------------------------------------------------------------
// Layer 3: AC coverage
// ---------------------------------------------------------------------------
/** Extract 2-3 meaningful key terms from a criterion text. */
function extractKeyTerms(criterion) {
    // Remove common filler words and symbols
    const STOP_WORDS = new Set([
        'the',
        'a',
        'an',
        'is',
        'it',
        'in',
        'on',
        'at',
        'to',
        'of',
        'and',
        'or',
        'not',
        'be',
        'can',
        'for',
        'with',
        'that',
        'this',
        'as',
        'by',
        'given',
        'when',
        'then',
        'should',
        'must',
        'shall',
        'will',
        'are',
        'has',
        'have',
        'its',
        'if',
        'so',
        'do',
        'no',
        'any',
        'all',
        'from',
    ]);
    const words = criterion
        .toLowerCase()
        .replace(/[^a-z0-9\s_-]/g, ' ')
        .split(/\s+/)
        .filter((w) => w.length > 3 && !STOP_WORDS.has(w));
    // Return up to 3 unique terms
    return [...new Set(words)].slice(0, 3);
}
async function computeAcCoverageScore(criteria, filePaths) {
    if (criteria.length === 0) {
        return 100;
    }
    const existingFiles = filePaths.filter((p) => fs.existsSync(p));
    /* v8 ignore next 3 */
    if (existingFiles.length === 0) {
        return 0;
    }
    // Collect all unique terms from all criteria
    const criterionTermsMap = new Map();
    const allTerms = new Set();
    for (const criterion of criteria) {
        const terms = extractKeyTerms(criterion);
        criterionTermsMap.set(criterion, terms);
        for (const term of terms) {
            allTerms.add(term);
        }
    }
    if (allTerms.size === 0) {
        return 100;
    }
    let foundTerms;
    if (isNativeActive()) {
        // Use Rust to find which terms appear in implementation files across the WHOLE project.
        // Note: Rust already does parallel walking and multi-threaded search.
        // We pass any directory/file from the spec, but Rust will scan efficiently.
        const firstFile = existingFiles[0];
        if (!firstFile) {
            return 100;
        }
        const rootPath = path.dirname(firstFile); // Use first file's dir as heuristic
        const result = fastDetectDriftParallel(rootPath, [...allTerms]);
        foundTerms = new Set(result.foundKeywords);
    }
    else {
        // Fallback: Read all implementation files once
        const fileContents = [];
        for (const filePath of existingFiles) {
            try {
                const content = await readFile(filePath, 'utf8');
                fileContents.push(content.toLowerCase());
            }
            catch {
                // skip unreadable files
            }
        }
        const combinedContent = fileContents.join('\n');
        foundTerms = new Set([...allTerms].filter((t) => combinedContent.includes(t.toLowerCase())));
    }
    let coveredCriteria = 0;
    for (const criterion of criteria) {
        const terms = criterionTermsMap.get(criterion) ?? [];
        // A criterion is "covered" if it has no meaningful terms or if at least one key term is found
        if (terms.length === 0 || terms.some((term) => foundTerms.has(term))) {
            coveredCriteria++;
        }
    }
    return (coveredCriteria / criteria.length) * 100;
}
// ---------------------------------------------------------------------------
// Internal helpers
// ---------------------------------------------------------------------------
/** Resolve the path to a canonical spec.md file. */
function resolveSpecPath(projectPath, specId, technicalPath) {
    /* v8 ignore next 5 */
    if (technicalPath !== undefined && fs.existsSync(technicalPath)) {
        const specPathFromLegacyHint = path.join(path.dirname(technicalPath), 'spec.md');
        return fs.existsSync(specPathFromLegacyHint) ? specPathFromLegacyHint : null;
    }
    // Convention: planu/specs/<specId>-<slug>/spec.md
    const specsDir = path.join(projectPath, 'planu', 'specs');
    if (!fs.existsSync(specsDir)) {
        return null;
    }
    let entries;
    try {
        entries = fs.readdirSync(specsDir);
    }
    catch {
        /* v8 ignore next */
        return null;
    }
    const match = entries.find((e) => e === specId || e.startsWith(`${specId}-`));
    if (match === undefined) {
        return null;
    }
    const candidate = path.join(specsDir, match, 'spec.md');
    return fs.existsSync(candidate) ? candidate : null;
}
/** Extract implementation file paths from a spec's inline ## Technical content. */
function extractFilePaths(content, projectPath) {
    const FILE_PATTERN = /\b([\w./-]+\.(?:ts|tsx|js|jsx|py|go|rs|java|rb|cs))\b/g;
    const found = new Set();
    for (const line of content.split('\n')) {
        // Skip markdown headings and comment-only lines
        if (line.trimStart().startsWith('#')) {
            continue;
        }
        let match;
        FILE_PATTERN.lastIndex = 0;
        while ((match = FILE_PATTERN.exec(line)) !== null) {
            const raw = match[1];
            /* v8 ignore next 2 */
            if (raw === undefined) {
                continue;
            }
            // Skip very short matches that are likely not real paths (e.g. "a.ts")
            /* v8 ignore next 2 */
            if (raw.length < 5) {
                continue;
            }
            // Resolve relative to project root
            const resolved = path.isAbsolute(raw) ? raw : path.join(projectPath, raw);
            found.add(resolved);
        }
    }
    return [...found];
}
function matchPattern(specId, pattern) {
    if (pattern === '*' || pattern === 'SPEC-*') {
        return true;
    }
    /* v8 ignore next 3 */
    if (pattern.endsWith('*')) {
        return specId.startsWith(pattern.slice(0, -1));
    }
    return specId === pattern;
}
function resolveThreshold(specId, rules, defaultThreshold) {
    const match = rules.find((r) => r.enabled && r.thresholdOverride !== undefined && matchPattern(specId, r.specIdPattern));
    return match?.thresholdOverride ?? defaultThreshold;
}
//# sourceMappingURL=drift-monitor.js.map