import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { loadRuleMetrics, recordContextHit, recordEvaluation, recordSuppression, recordTrigger, saveRuleMetrics, } from './rule-metrics.js';
import { cleanTmpDir } from './test-utils.js';
// ─── Helpers ────────────────────────────────────────────
let tmpDir;
beforeEach(() => {
    tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'totem-rule-metrics-'));
});
afterEach(() => {
    cleanTmpDir(tmpDir);
});
function emptyMetrics() {
    return { version: 1, rules: {} };
}
// ─── loadRuleMetrics ────────────────────────────────────
describe('loadRuleMetrics', () => {
    it('returns empty metrics when file does not exist', () => {
        const metrics = loadRuleMetrics(tmpDir);
        expect(metrics).toEqual({ version: 1, rules: {} });
    });
    it('loads valid metrics from disk', () => {
        const data = {
            version: 1,
            rules: {
                abc123: {
                    triggerCount: 5,
                    suppressCount: 2,
                    lastTriggeredAt: '2026-01-01T00:00:00.000Z',
                    lastSuppressedAt: '2026-01-02T00:00:00.000Z',
                    evaluationCount: 0,
                },
            },
        };
        const dir = path.join(tmpDir, 'cache');
        fs.mkdirSync(dir, { recursive: true });
        fs.writeFileSync(path.join(dir, 'rule-metrics.json'), JSON.stringify(data));
        const metrics = loadRuleMetrics(tmpDir);
        expect(metrics).toEqual(data);
    });
    it('roundtrips through save and load', () => {
        const data = {
            version: 1,
            rules: {
                hash1: {
                    triggerCount: 3,
                    suppressCount: 1,
                    lastTriggeredAt: '2026-03-01T00:00:00.000Z',
                    lastSuppressedAt: null,
                    evaluationCount: 0,
                },
                hash2: {
                    triggerCount: 0,
                    suppressCount: 0,
                    lastTriggeredAt: null,
                    lastSuppressedAt: null,
                    evaluationCount: 0,
                },
            },
        };
        saveRuleMetrics(tmpDir, data);
        const loaded = loadRuleMetrics(tmpDir);
        expect(loaded).toEqual(data);
    });
    it('returns empty metrics and warns on corrupt JSON', () => {
        const dir = path.join(tmpDir, 'cache');
        fs.mkdirSync(dir, { recursive: true });
        fs.writeFileSync(path.join(dir, 'rule-metrics.json'), '{{{invalid json');
        const onWarn = vi.fn();
        const metrics = loadRuleMetrics(tmpDir, onWarn);
        expect(metrics).toEqual({ version: 1, rules: {} });
        expect(onWarn).toHaveBeenCalledOnce();
        expect(onWarn.mock.calls[0][0]).toContain('Could not load rule metrics');
    });
    it('returns empty metrics and warns on schema-invalid data', () => {
        const dir = path.join(tmpDir, 'cache');
        fs.mkdirSync(dir, { recursive: true });
        // Valid JSON but wrong schema (version: 2 instead of 1)
        fs.writeFileSync(path.join(dir, 'rule-metrics.json'), JSON.stringify({ version: 2, rules: {} }));
        const onWarn = vi.fn();
        const metrics = loadRuleMetrics(tmpDir, onWarn);
        expect(metrics).toEqual({ version: 1, rules: {} });
        expect(onWarn).toHaveBeenCalledOnce();
    });
    it('returns empty metrics silently when file is missing (no warn callback)', () => {
        // No onWarn provided — should not throw
        const metrics = loadRuleMetrics(path.join(tmpDir, 'nonexistent'));
        expect(metrics).toEqual({ version: 1, rules: {} });
    });
});
// ─── saveRuleMetrics ────────────────────────────────────
describe('saveRuleMetrics', () => {
    it('creates the cache directory if missing', () => {
        const data = emptyMetrics();
        saveRuleMetrics(tmpDir, data);
        const filePath = path.join(tmpDir, 'cache', 'rule-metrics.json');
        expect(fs.existsSync(filePath)).toBe(true);
    });
    it('writes valid JSON with trailing newline', () => {
        const data = {
            version: 1,
            rules: {
                abc: {
                    triggerCount: 1,
                    suppressCount: 0,
                    lastTriggeredAt: '2026-01-01T00:00:00.000Z',
                    lastSuppressedAt: null,
                    evaluationCount: 0,
                },
            },
        };
        saveRuleMetrics(tmpDir, data);
        const filePath = path.join(tmpDir, 'cache', 'rule-metrics.json');
        const raw = fs.readFileSync(filePath, 'utf-8');
        expect(raw.endsWith('\n')).toBe(true);
        expect(JSON.parse(raw)).toEqual(data);
    });
    it('overwrites existing file', () => {
        const data1 = {
            version: 1,
            rules: {
                a: {
                    triggerCount: 1,
                    suppressCount: 0,
                    lastTriggeredAt: null,
                    lastSuppressedAt: null,
                    evaluationCount: 0,
                },
            },
        };
        const data2 = {
            version: 1,
            rules: {
                b: {
                    triggerCount: 2,
                    suppressCount: 1,
                    lastTriggeredAt: null,
                    lastSuppressedAt: null,
                    evaluationCount: 0,
                },
            },
        };
        saveRuleMetrics(tmpDir, data1);
        saveRuleMetrics(tmpDir, data2);
        const loaded = loadRuleMetrics(tmpDir);
        expect(loaded).toEqual(data2);
        expect(loaded.rules['a']).toBeUndefined();
    });
});
// ─── recordTrigger ──────────────────────────────────────
describe('recordTrigger', () => {
    it('creates a new entry and increments triggerCount', () => {
        const metrics = emptyMetrics();
        recordTrigger(metrics, 'hash1');
        expect(metrics.rules['hash1']).toBeDefined();
        expect(metrics.rules['hash1'].triggerCount).toBe(1);
        expect(metrics.rules['hash1'].suppressCount).toBe(0);
        expect(metrics.rules['hash1'].lastTriggeredAt).toBeTruthy();
        expect(metrics.rules['hash1'].lastSuppressedAt).toBeNull();
    });
    it('increments existing entry', () => {
        const metrics = emptyMetrics();
        recordTrigger(metrics, 'hash1');
        recordTrigger(metrics, 'hash1');
        recordTrigger(metrics, 'hash1');
        expect(metrics.rules['hash1'].triggerCount).toBe(3);
    });
    it('sets lastTriggeredAt to a valid ISO timestamp', () => {
        const metrics = emptyMetrics();
        const before = new Date().toISOString();
        recordTrigger(metrics, 'hash1');
        const after = new Date().toISOString();
        const ts = metrics.rules['hash1'].lastTriggeredAt;
        expect(ts >= before).toBe(true);
        expect(ts <= after).toBe(true);
    });
    it('does not affect other hashes', () => {
        const metrics = emptyMetrics();
        recordTrigger(metrics, 'hash1');
        recordTrigger(metrics, 'hash2');
        expect(metrics.rules['hash1'].triggerCount).toBe(1);
        expect(metrics.rules['hash2'].triggerCount).toBe(1);
    });
});
// ─── recordSuppression ──────────────────────────────────
describe('recordSuppression', () => {
    it('creates a new entry and increments suppressCount', () => {
        const metrics = emptyMetrics();
        recordSuppression(metrics, 'hash1');
        expect(metrics.rules['hash1']).toBeDefined();
        expect(metrics.rules['hash1'].suppressCount).toBe(1);
        expect(metrics.rules['hash1'].triggerCount).toBe(0);
        expect(metrics.rules['hash1'].lastSuppressedAt).toBeTruthy();
        expect(metrics.rules['hash1'].lastTriggeredAt).toBeNull();
    });
    it('increments existing entry', () => {
        const metrics = emptyMetrics();
        recordSuppression(metrics, 'hash1');
        recordSuppression(metrics, 'hash1');
        expect(metrics.rules['hash1'].suppressCount).toBe(2);
    });
    it('sets lastSuppressedAt to a valid ISO timestamp', () => {
        const metrics = emptyMetrics();
        const before = new Date().toISOString();
        recordSuppression(metrics, 'hash1');
        const after = new Date().toISOString();
        const ts = metrics.rules['hash1'].lastSuppressedAt;
        expect(ts >= before).toBe(true);
        expect(ts <= after).toBe(true);
    });
    it('works independently from recordTrigger on the same hash', () => {
        const metrics = emptyMetrics();
        recordTrigger(metrics, 'hash1');
        recordTrigger(metrics, 'hash1');
        recordSuppression(metrics, 'hash1');
        expect(metrics.rules['hash1'].triggerCount).toBe(2);
        expect(metrics.rules['hash1'].suppressCount).toBe(1);
    });
});
// ─── recordContextHit ──────────────────────────────────
describe('recordContextHit', () => {
    it('initializes contextCounts on first hit (seeds unknown from triggerCount)', () => {
        const metrics = emptyMetrics();
        recordContextHit(metrics, 'hash1', 'code');
        // New rule: triggerCount is 0, so unknown seeds at 0
        expect(metrics.rules['hash1'].contextCounts).toEqual({
            code: 1,
            string: 0,
            comment: 0,
            regex: 0,
            unknown: 0,
        });
    });
    it('seeds unknown with historical triggerCount minus current hit', () => {
        const metrics = emptyMetrics();
        recordTrigger(metrics, 'hash1');
        recordTrigger(metrics, 'hash1');
        recordTrigger(metrics, 'hash1');
        // triggerCount is now 3. Current hit makes it 3 historical minus 1 = 2 seeded.
        recordContextHit(metrics, 'hash1', 'code');
        // sum(contextCounts) should equal triggerCount: 2 unknown + 1 code = 3
        expect(metrics.rules['hash1'].contextCounts).toEqual({
            code: 1,
            string: 0,
            comment: 0,
            regex: 0,
            unknown: 2,
        });
    });
    it('increments the correct context bucket', () => {
        const metrics = emptyMetrics();
        recordContextHit(metrics, 'hash1', 'code');
        recordContextHit(metrics, 'hash1', 'string');
        recordContextHit(metrics, 'hash1', 'string');
        recordContextHit(metrics, 'hash1', 'comment');
        expect(metrics.rules['hash1'].contextCounts).toEqual({
            code: 1,
            string: 2,
            comment: 1,
            regex: 0,
            unknown: 0,
        });
    });
    it('records undefined context as unknown', () => {
        const metrics = emptyMetrics();
        recordContextHit(metrics, 'hash1', undefined);
        expect(metrics.rules['hash1'].contextCounts.unknown).toBe(1);
    });
    it('preserves existing triggerCount and suppressCount', () => {
        const metrics = emptyMetrics();
        recordTrigger(metrics, 'hash1');
        recordSuppression(metrics, 'hash1');
        recordContextHit(metrics, 'hash1', 'code');
        expect(metrics.rules['hash1'].triggerCount).toBe(1);
        expect(metrics.rules['hash1'].suppressCount).toBe(1);
        expect(metrics.rules['hash1'].contextCounts.code).toBe(1);
    });
});
// ─── recordEvaluation (mmnto-ai/totem#1483) ────────────
describe('recordEvaluation', () => {
    it('creates a new entry with evaluationCount 1', () => {
        const metrics = emptyMetrics();
        recordEvaluation(metrics, 'hash1');
        expect(metrics.rules['hash1']).toBeDefined();
        expect(metrics.rules['hash1'].evaluationCount).toBe(1);
        expect(metrics.rules['hash1'].triggerCount).toBe(0);
        expect(metrics.rules['hash1'].suppressCount).toBe(0);
    });
    it('increments monotonically across calls on the same hash', () => {
        const metrics = emptyMetrics();
        recordEvaluation(metrics, 'hash1');
        recordEvaluation(metrics, 'hash1');
        recordEvaluation(metrics, 'hash1');
        expect(metrics.rules['hash1'].evaluationCount).toBe(3);
    });
    it('does not affect unrelated hashes', () => {
        const metrics = emptyMetrics();
        recordEvaluation(metrics, 'hash1');
        recordEvaluation(metrics, 'hash2');
        recordEvaluation(metrics, 'hash2');
        expect(metrics.rules['hash1'].evaluationCount).toBe(1);
        expect(metrics.rules['hash2'].evaluationCount).toBe(2);
    });
    it('does not affect triggerCount / suppressCount / contextCounts', () => {
        const metrics = emptyMetrics();
        recordTrigger(metrics, 'hash1');
        recordSuppression(metrics, 'hash1');
        recordContextHit(metrics, 'hash1', 'code');
        recordEvaluation(metrics, 'hash1');
        expect(metrics.rules['hash1'].triggerCount).toBe(1);
        expect(metrics.rules['hash1'].suppressCount).toBe(1);
        expect(metrics.rules['hash1'].contextCounts.code).toBe(1);
        expect(metrics.rules['hash1'].evaluationCount).toBe(1);
    });
});
// ─── Pre-#1483 migration ───────────────────────────────
describe('evaluationCount migration', () => {
    it('loads a pre-#1483 metrics file (no evaluationCount field) and defaults to 0', () => {
        // Pre-#1483 rule-metrics.json on disk had no evaluationCount field. The
        // Zod default keeps old files loading without a migration pass.
        const data = {
            version: 1,
            rules: {
                legacyHash: {
                    triggerCount: 7,
                    suppressCount: 2,
                    lastTriggeredAt: '2026-01-01T00:00:00.000Z',
                    lastSuppressedAt: null,
                },
            },
        };
        const dir = path.join(tmpDir, 'cache');
        fs.mkdirSync(dir, { recursive: true });
        fs.writeFileSync(path.join(dir, 'rule-metrics.json'), JSON.stringify(data));
        const loaded = loadRuleMetrics(tmpDir);
        expect(loaded.rules['legacyHash']).toBeDefined();
        expect(loaded.rules['legacyHash'].evaluationCount).toBe(0);
        expect(loaded.rules['legacyHash'].triggerCount).toBe(7);
        expect(loaded.rules['legacyHash'].suppressCount).toBe(2);
    });
    it('accrues evaluationCount after migration via recordEvaluation', () => {
        // The first lint run after upgrade should treat migrated rules as "just
        // loaded" — evaluationCount ticks up from 0 as normal.
        const data = {
            version: 1,
            rules: {
                legacyHash: {
                    triggerCount: 5,
                    suppressCount: 0,
                    lastTriggeredAt: null,
                    lastSuppressedAt: null,
                },
            },
        };
        const dir = path.join(tmpDir, 'cache');
        fs.mkdirSync(dir, { recursive: true });
        fs.writeFileSync(path.join(dir, 'rule-metrics.json'), JSON.stringify(data));
        const loaded = loadRuleMetrics(tmpDir);
        recordEvaluation(loaded, 'legacyHash');
        recordEvaluation(loaded, 'legacyHash');
        expect(loaded.rules['legacyHash'].evaluationCount).toBe(2);
    });
});
//# sourceMappingURL=rule-metrics.test.js.map