/**
 * `totem stats --pattern-recurrence` — cross-PR recurrence clustering.
 *
 * Substrate of mmnto-ai/totem#1715. Fetches bot-review findings across
 * the most recent N merged PRs (configurable via --history-depth, default
 * 50, capped at 200), folds in trap-ledger override events as co-equal
 * findings, clusters by normalized signature, filters out clusters
 * already covered by an existing compiled rule, and writes the survivors
 * at `.totem/recurrence-stats.json` plus a stdout summary.
 *
 * No LLM. No GitHub API writes. Stateless per invocation.
 */
// totem-context: type-only imports above are erased at compile time and don't
// violate the lazy-import command policy (mmnto-ai/totem#1729 CR R1). All
// runtime imports (node:fs, node:path, zod, @mmnto/totem, helpers, adapters)
// are dynamic — see runRecurrenceStats body.
// ─── Constants ───────────────────────────────────────
const TAG = 'Recurrence';
const DEFAULT_THRESHOLD = 5;
const DEFAULT_HISTORY_DEPTH = 50;
const MAX_HISTORY_DEPTH = 200;
const MAX_SAMPLE_BODIES = 3;
const MAX_PATHS = 10;
const COVERAGE_JACCARD_THRESHOLD = 0.6;
// ─── Main entrypoint ────────────────────────────────
export async function runRecurrenceStats(options = {}) {
    // Dynamic imports per `packages/cli/src/commands/**` lazy-import policy
    // (mmnto-ai/totem#1729 CR R1).
    const fs = await import('node:fs');
    const path = await import('node:path');
    const { z } = await import('zod');
    const { GitHubCliPrAdapter } = await import('../adapters/github-cli-pr.js');
    const { handleGhError, ghFetchAndParse } = await import('../adapters/gh-utils.js');
    const { log } = await import('../ui.js');
    const { isBotComment, detectBot, parseSeverityForTool, stripHtmlWrappers, extractSuggestion, extractReviewBodyFindings, } = await import('../parsers/bot-review-parser.js');
    const { computeSignature, jaccard, loadCompiledRules, normalizeFindingBody, readLedgerEvents, toSeverityBucket, tokenizeForJaccard, } = await import('@mmnto/totem');
    const { loadConfig, resolveConfigPath } = await import('../utils.js');
    // Schema is local to the run path so the zod import stays lazy.
    const GhMergedPrListItemSchema = z.object({
        number: z.number(),
        mergedAt: z.string().nullable().optional(),
    });
    const cwd = process.cwd();
    // 1. Validate + clamp options
    const threshold = clampThreshold(options.threshold);
    let historyDepth = clampHistoryDepth(options.historyDepth);
    if (options.historyDepth !== undefined && options.historyDepth > MAX_HISTORY_DEPTH) {
        log.warn(TAG, `--history-depth ${options.historyDepth} exceeds cap of ${MAX_HISTORY_DEPTH}; using ${MAX_HISTORY_DEPTH} instead.`);
        historyDepth = MAX_HISTORY_DEPTH;
    }
    log.info(TAG, `Scanning up to ${historyDepth} merged PR(s); threshold=${threshold}.`);
    // 2. Fetch the most recent merged PRs via gh
    let prList = [];
    try {
        prList = ghFetchAndParse([
            'pr',
            'list',
            '--state',
            'merged',
            '--limit',
            String(historyDepth),
            '--json',
            'number,mergedAt',
        ], z.array(GhMergedPrListItemSchema), 'merged PR list', cwd);
    }
    catch (err) {
        // ghFetchAndParse already wraps via handleGhError on its own throw path,
        // but if anything slipped through wrap it here for the same hint surface.
        handleGhError(err, 'merged PR list');
        throw err;
    }
    log.info(TAG, `Found ${prList.length} merged PR(s).`);
    // 3. Per-PR fetch + finding extraction
    const adapter = new GitHubCliPrAdapter(cwd);
    const annotated = [];
    const prsScanned = [];
    for (const pr of prList) {
        const prNum = pr.number;
        try {
            const prData = adapter.fetchPr(prNum);
            const reviewComments = adapter.fetchReviewComments(prNum);
            // Group threads by root id
            const threads = groupThreadsByRoot(reviewComments);
            const botThreads = threads.filter((t) => t.comments.length > 0 && isBotComment(t.comments[0].author));
            // Inline bot findings
            const inlineFindings = [];
            for (const thread of botThreads) {
                const botComment = thread.comments[0];
                if (!botComment)
                    continue;
                const tool = detectBot(botComment.author);
                const severity = parseSeverityForTool(tool, botComment.body);
                const body = stripHtmlWrappers(botComment.body);
                const suggestion = extractSuggestion(botComment.body);
                const hunkMatch = thread.diffHunk.match(/@@ .+?\+(\d+)/);
                const line = hunkMatch ? parseInt(hunkMatch[1], 10) : undefined;
                inlineFindings.push({
                    tool,
                    severity,
                    file: thread.path,
                    line,
                    body,
                    suggestion,
                    resolutionSignal: 'none',
                    rootCommentId: botComment.id,
                });
            }
            // Review-body findings (CR outside-diff + nits)
            const reviewBodyFindings = extractReviewBodyFindings(prData.reviews);
            const allFindings = [...inlineFindings, ...reviewBodyFindings];
            // Use the PR's mergedAt as observation timestamp (best available).
            const observedAt = pr.mergedAt ?? new Date().toISOString();
            for (const f of allFindings) {
                annotated.push({
                    finding: f,
                    prNumber: String(prNum),
                    observedAt,
                });
            }
            prsScanned.push(String(prNum));
            // totem-context: per-PR catch swallows transient errors by design — log + continue with partial data per the mmnto-ai/totem#1715 failure-mode table; the scan must not abort on rate-limit / adapter failures, and prsScanned < historyDepth surfaces partial coverage in the final report.
        }
        catch (err) {
            const msg = err instanceof Error ? err.message : String(err);
            log.warn(TAG, `PR #${prNum}: skipped (${msg})`);
            continue;
        }
    }
    log.info(TAG, `Scanned ${prsScanned.length}/${prList.length} PR(s); collected ${annotated.length} bot finding(s).`);
    // 4. Trap-ledger overrides as co-equal findings (Q4)
    const config = await loadConfig(resolveConfigPath(cwd));
    const totemDir = path.join(cwd, config.totemDir);
    const ledgerEvents = readLedgerEvents(totemDir, (msg) => log.warn(TAG, msg));
    const overrideEvents = ledgerEvents.filter((e) => e.type === 'override');
    for (const event of overrideEvents) {
        // Override events carry `file` by writer discipline; A.3.a relaxed the schema-level requirement.
        if (!event.file)
            continue;
        const synthetic = {
            tool: 'unknown',
            severity: 'medium',
            file: event.file,
            line: event.line,
            body: event.justification,
            resolutionSignal: 'none',
        };
        annotated.push({
            finding: synthetic,
            prNumber: undefined,
            observedAt: event.timestamp,
        });
    }
    if (overrideEvents.length > 0) {
        log.info(TAG, `Folded in ${overrideEvents.length} trap-ledger override event(s).`);
    }
    const clusters = new Map();
    for (const a of annotated) {
        const isOverride = a.prNumber === undefined;
        const tool = isOverride ? 'override' : a.finding.tool;
        const normalized = normalizeFindingBody(a.finding.body);
        if (normalized.length === 0)
            continue;
        const signature = computeSignature(normalized);
        const bucket = toSeverityBucket(tool, a.finding.severity);
        let cluster = clusters.get(signature);
        if (!cluster) {
            cluster = {
                signature,
                tools: new Set(),
                severityBuckets: [],
                occurrences: 0,
                prs: new Set(),
                sampleBodies: [],
                firstSeen: a.observedAt,
                lastSeen: a.observedAt,
                paths: new Set(),
                normalizedBody: normalized,
            };
            clusters.set(signature, cluster);
        }
        cluster.tools.add(tool);
        cluster.severityBuckets.push(bucket);
        cluster.occurrences += 1;
        if (a.prNumber !== undefined)
            cluster.prs.add(a.prNumber);
        if (cluster.sampleBodies.length < MAX_SAMPLE_BODIES) {
            cluster.sampleBodies.push(a.finding.body);
        }
        if (a.observedAt < cluster.firstSeen)
            cluster.firstSeen = a.observedAt;
        if (a.observedAt > cluster.lastSeen)
            cluster.lastSeen = a.observedAt;
        if (a.finding.file && a.finding.file !== '(review body)') {
            if (cluster.paths.size < MAX_PATHS)
                cluster.paths.add(a.finding.file);
        }
    }
    // 6. Coverage filter against compiled rules
    const rulesPath = path.join(totemDir, 'compiled-rules.json');
    let compiledRules = [];
    try {
        compiledRules = loadCompiledRules(rulesPath);
        // totem-context: missing/malformed compiled-rules.json disables coverage routing only — every cluster lands in headline `patterns` with `coveredByRule: false` per the mmnto-ai/totem#1715 failure-mode table. The scan must not abort on a missing manifest.
    }
    catch (err) {
        const msg = err instanceof Error ? err.message : String(err);
        log.warn(TAG, `Could not load compiled rules: ${msg} — coverage check disabled.`);
        compiledRules = [];
    }
    // Pre-tokenize rule messages once.
    const ruleTokenSets = compiledRules.map((r) => tokenizeForJaccard(r.message ?? ''));
    // 7. Materialize patterns
    const allPatterns = [];
    for (const cluster of clusters.values()) {
        const tools = [...cluster.tools];
        const tool = tools.length > 1 ? 'mixed' : (tools[0] ?? 'unknown');
        const severityBucket = pickDominantSeverity(cluster.severityBuckets);
        // Coverage heuristic: max Jaccard across all rule messages ≥ 0.6
        const findingTokens = tokenizeForJaccard(cluster.normalizedBody);
        let maxJaccard = 0;
        for (const ruleTokens of ruleTokenSets) {
            const v = jaccard(findingTokens, ruleTokens);
            if (v > maxJaccard)
                maxJaccard = v;
        }
        const coveredByRule = maxJaccard >= COVERAGE_JACCARD_THRESHOLD;
        const prs = [...cluster.prs].sort((a, b) => Number(a) - Number(b));
        const paths = [...cluster.paths].sort((a, b) => a.localeCompare(b));
        allPatterns.push({
            signature: cluster.signature,
            tool,
            severityBucket,
            occurrences: cluster.occurrences,
            prs,
            sampleBodies: cluster.sampleBodies,
            firstSeen: cluster.firstSeen,
            lastSeen: cluster.lastSeen,
            paths,
            coveredByRule,
        });
    }
    // 8. Split into headline + covered, apply threshold to headline only
    const headlinePatterns = allPatterns
        .filter((p) => !p.coveredByRule)
        .filter((p) => p.occurrences >= threshold)
        .sort((a, b) => b.occurrences - a.occurrences);
    const coveredPatterns = allPatterns
        .filter((p) => p.coveredByRule)
        .sort((a, b) => b.occurrences - a.occurrences);
    // 9. Output preparation
    const outputPath = path.join(totemDir, 'recurrence-stats.json');
    const lastUpdated = new Date().toISOString();
    const stats = {
        version: 1,
        lastUpdated,
        thresholdApplied: threshold,
        historyDepth,
        prsScanned,
        patterns: headlinePatterns,
        coveredPatterns,
    };
    // 10. Overwrite confirmation if file exists with newer lastUpdated
    if (fs.existsSync(outputPath)) {
        let existingNewer = false;
        try {
            const raw = fs.readFileSync(outputPath, 'utf-8');
            const parsed = JSON.parse(raw);
            if (typeof parsed.lastUpdated === 'string') {
                existingNewer = parsed.lastUpdated > lastUpdated;
            }
            // totem-context: best-effort read — an unparseable existing file is treated as "not newer" so the overwrite path proceeds rather than erroring. Atomic temp+rename below protects user data; log.warn surfaces parse failures for diagnostics.
        }
        catch (err) {
            const msg = err instanceof Error ? err.message : String(err);
            log.warn(TAG, `Could not read existing recurrence-stats.json: ${msg}`);
        }
        if (existingNewer) {
            const proceed = await confirmOverwrite(outputPath, options.yes ?? false, log);
            if (!proceed) {
                log.warn(TAG, 'Overwrite declined; not writing recurrence-stats.json.');
                return;
            }
        }
    }
    // 11. Atomic write — unique temp filename (PID + epoch ms + random) so
    //     concurrent invocations don't race on the same .tmp path
    //     (mmnto-ai/totem#1729 CR R1 + R2). PID alone is identical across
    //     in-process awaits and Date.now() can collide on the same ms; the
    //     random suffix closes both gaps deterministically.
    fs.mkdirSync(totemDir, { recursive: true });
    const crypto = await import('node:crypto');
    const tmp = `${outputPath}.${process.pid}.${Date.now()}.${crypto.randomUUID()}.tmp`;
    try {
        fs.writeFileSync(tmp, JSON.stringify(stats, null, 2) + '\n', 'utf-8');
        fs.renameSync(tmp, outputPath);
    }
    finally {
        // Defensive cleanup if rename failed mid-flight.
        if (fs.existsSync(tmp))
            fs.rmSync(tmp, { force: true });
    }
    // 12. Stdout summary (via log → stderr; mirrors statsCommand voice)
    log.info(TAG, `Wrote ${outputPath}`);
    log.info(TAG, `Patterns at-or-above threshold ${threshold}: ${headlinePatterns.length} (covered: ${coveredPatterns.length}, scanned PRs: ${prsScanned.length}/${historyDepth}).`);
    if (headlinePatterns.length > 0) {
        log.info(TAG, 'Top recurrences:');
        for (const p of headlinePatterns.slice(0, 5)) {
            const snippet = (p.sampleBodies[0] ?? '').replace(/\s+/g, ' ').slice(0, 80);
            log.dim(TAG, `  [${p.signature}] ${p.occurrences}x across ${p.prs.length} PR(s): ${snippet}`);
        }
    }
    else {
        log.dim(TAG, '  (none — all clusters below threshold or covered by existing rules)');
    }
}
function groupThreadsByRoot(comments) {
    const byId = new Map();
    for (const c of comments)
        byId.set(c.id, c);
    const threadMap = new Map();
    for (const c of comments) {
        const rootId = c.inReplyToId ?? c.id;
        const thread = threadMap.get(rootId) ?? [];
        thread.push(c);
        threadMap.set(rootId, thread);
    }
    const threads = [];
    for (const [rootId, threadComments] of threadMap) {
        threadComments.sort((a, b) => {
            if (!a.createdAt || !b.createdAt)
                return 0;
            return a.createdAt.localeCompare(b.createdAt);
        });
        const root = byId.get(rootId) ?? threadComments[0];
        threads.push({
            path: root.path,
            diffHunk: root.diffHunk,
            comments: threadComments.map((c) => ({ id: c.id, author: c.author, body: c.body })),
        });
    }
    return threads;
}
function clampThreshold(input) {
    if (input === undefined || !Number.isFinite(input) || input < 1)
        return DEFAULT_THRESHOLD;
    return Math.floor(input);
}
function clampHistoryDepth(input) {
    if (input === undefined || !Number.isFinite(input) || input < 1)
        return DEFAULT_HISTORY_DEPTH;
    return Math.min(MAX_HISTORY_DEPTH, Math.floor(input));
}
/** Pick the highest-severity bucket present in the cluster. */
function pickDominantSeverity(buckets) {
    const order = ['critical', 'high', 'medium', 'low', 'nit'];
    for (const candidate of order) {
        if (buckets.includes(candidate))
            return candidate;
    }
    return 'nit';
}
async function confirmOverwrite(outputPath, yesFlag, log) {
    if (yesFlag)
        return true;
    if (!process.stdin.isTTY) {
        log.warn(TAG, `Existing ${outputPath} is newer; pass --yes to overwrite in non-interactive mode.`);
        return false;
    }
    const { confirm, isCancel } = await import('@clack/prompts');
    const ans = await confirm({
        message: `Existing ${outputPath} is newer. Overwrite?`,
        initialValue: false,
    });
    if (isCancel(ans))
        return false;
    return ans === true;
}
//# sourceMappingURL=recurrence-stats.js.map