// Audit log — lightweight invocation tracking via SQLite
//
// Schema: one row per agent invocation with trace id, user, platform, cost, duration etc.
// Reuses the traceId from structured logging.
import { join } from 'node:path';
import { logger as rootLogger } from './logger.js';
import { recordInvocation, recordAuditPruneFailure } from './metrics.js';
import { eventBus } from './event-bus.js';
import { createSqliteHelper } from './sqlite-helper.js';
import { AGIM_HOME } from './agim-paths.js';
const log = rootLogger.child({ component: 'audit-log' });
const AUDIT_DB = join(AGIM_HOME, 'audit.db');
/** Default number of days to keep audit rows. Override via env. */
function resolveRetentionDays() {
    const raw = process.env.IM_HUB_AUDIT_RETENTION_DAYS;
    if (raw) {
        const n = parseInt(raw, 10);
        if (Number.isFinite(n) && n > 0)
            return n;
    }
    return 30;
}
/** R12 ⑤ — events table retention. Default 180d is GDPR-friendly and
 *  satisfies 等保 2.0 三级 (≥ 6 months). 0 = permanent. */
function resolveAuditEventsRetentionDays() {
    const raw = process.env.IMHUB_AUDIT_EVENTS_RETENTION_DAYS;
    if (raw === undefined)
        return 180;
    const n = parseInt(raw, 10);
    if (!Number.isFinite(n) || n < 0)
        return 180;
    return n;
}
/** How often to run the retention sweep (epoch-ms). 0 disables. */
const RETENTION_SWEEP_INTERVAL_MS = 6 * 60 * 60 * 1000; // 6 hours
let sweepTimer = null;
const helper = createSqliteHelper({
    file: AUDIT_DB,
    component: 'audit-log',
    logger: rootLogger,
    schema: `
    CREATE TABLE IF NOT EXISTS invocations (
      id        INTEGER PRIMARY KEY AUTOINCREMENT,
      trace_id  TEXT    NOT NULL,
      ts        TEXT    NOT NULL DEFAULT (datetime('now')),
      user_id   TEXT    NOT NULL DEFAULT '',
      platform  TEXT    NOT NULL DEFAULT '',
      agent     TEXT    NOT NULL,
      intent    TEXT    NOT NULL DEFAULT 'default',
      prompt_len INTEGER NOT NULL DEFAULT 0,
      response_len INTEGER NOT NULL DEFAULT 0,
      duration_ms INTEGER NOT NULL DEFAULT 0,
      cost      REAL    NOT NULL DEFAULT 0.0,
      success   INTEGER NOT NULL DEFAULT 1,
      error     TEXT
    );
    CREATE INDEX IF NOT EXISTS idx_inv_ts ON invocations(ts);
    CREATE INDEX IF NOT EXISTS idx_inv_agent ON invocations(agent);
    CREATE INDEX IF NOT EXISTS idx_inv_trace ON invocations(trace_id);
    CREATE INDEX IF NOT EXISTS idx_inv_user ON invocations(user_id);
    CREATE INDEX IF NOT EXISTS idx_inv_platform ON invocations(platform);
    CREATE INDEX IF NOT EXISTS idx_inv_intent ON invocations(intent);

    -- R12 ⑤ — persistent audit table for governance events (approvals,
    -- admin elevations, config / env / token / workspace mutations).
    -- Separate from invocations because retention requirements differ
    -- (compliance asks for ≥ 6 months on access-control events; calls
    -- are typically 30 days).
    CREATE TABLE IF NOT EXISTS audit_events (
      id         INTEGER PRIMARY KEY AUTOINCREMENT,
      ts         TEXT    NOT NULL DEFAULT (datetime('now')),
      trace_id   TEXT    NOT NULL DEFAULT '',
      event_type TEXT    NOT NULL,
      actor      TEXT    NOT NULL DEFAULT '',
      target     TEXT    NOT NULL DEFAULT '',
      outcome    TEXT    NOT NULL DEFAULT 'ok',
      details    TEXT    NOT NULL DEFAULT '{}'
    );
    CREATE INDEX IF NOT EXISTS idx_audit_events_ts ON audit_events(ts);
    CREATE INDEX IF NOT EXISTS idx_audit_events_type ON audit_events(event_type);
    CREATE INDEX IF NOT EXISTS idx_audit_events_actor ON audit_events(actor);
  `,
    init: (d) => {
        pruneExpired(d);
        pruneExpiredEvents(d);
        if (RETENTION_SWEEP_INTERVAL_MS > 0 && sweepTimer === null) {
            sweepTimer = setInterval(() => {
                const live = helper.get();
                if (!live)
                    return;
                pruneExpired(live);
                pruneExpiredEvents(live);
            }, RETENTION_SWEEP_INTERVAL_MS);
            if (typeof sweepTimer === 'object' && sweepTimer && 'unref' in sweepTimer) {
                sweepTimer.unref();
            }
        }
    },
});
function getDb() {
    return helper.get();
}
export function logInvocation(rec) {
    // Always update in-memory metrics first so /api/metrics works even when
    // the SQLite layer is degraded (e.g. bun runtime, disk full).
    recordInvocation({
        agent: rec.agent,
        intent: rec.intent,
        platform: rec.platform,
        durationMs: rec.durationMs,
        cost: rec.cost,
        success: rec.success,
    });
    // Real-time fan-out for the dashboard's SSE consumer. Best-effort —
    // event-bus swallows listener errors so a buggy subscriber can't break
    // the audit hot path.
    eventBus.publish({
        type: 'audit',
        ts: new Date().toISOString(),
        agent: rec.agent,
        intent: rec.intent,
        platform: rec.platform,
        durationMs: rec.durationMs,
        cost: rec.cost,
        success: rec.success,
        traceId: rec.traceId,
        userId: rec.userId,
    });
    const d = getDb();
    if (!d)
        return;
    try {
        const stmt = d.prepare(`
      INSERT INTO invocations (trace_id, user_id, platform, agent, intent, prompt_len, response_len, duration_ms, cost, success, error)
      VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
    `);
        stmt.run(rec.traceId, rec.userId, rec.platform, rec.agent, rec.intent, rec.promptLen, rec.responseLen, rec.durationMs, rec.cost, rec.success ? 1 : 0, rec.error || null);
    }
    catch {
        // Best-effort logging; never fail the request because of audit IO
    }
}
export function queryInvocations(opts = {}) {
    const d = getDb();
    if (!d)
        return [];
    const conditions = [];
    const params = [];
    if (opts.agent) {
        conditions.push('agent = ?');
        params.push(opts.agent);
    }
    if (opts.platform) {
        conditions.push('platform = ?');
        params.push(opts.platform);
    }
    if (opts.userId) {
        conditions.push('user_id = ?');
        params.push(opts.userId);
    }
    if (opts.intent) {
        conditions.push('intent = ?');
        params.push(opts.intent);
    }
    if (opts.traceId) {
        conditions.push('trace_id = ?');
        params.push(opts.traceId);
    }
    if (opts.tracePrefix) {
        conditions.push('trace_id LIKE ?');
        params.push(opts.tracePrefix + '%');
    }
    if (opts.days && Number.isFinite(opts.days) && opts.days > 0) {
        conditions.push("ts >= datetime('now', ?)");
        params.push(`-${Math.floor(opts.days)} days`);
    }
    const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : '';
    const rawLimit = opts.limit;
    const limit = Number.isFinite(rawLimit) && rawLimit > 0 ? Math.min(Math.floor(rawLimit), 1000) : 20;
    try {
        return d.prepare(`SELECT * FROM invocations ${where} ORDER BY ts DESC LIMIT ?`)
            .all(...params, limit);
    }
    catch {
        return [];
    }
}
/**
 * Delete rows older than the retention window. Returns number of deleted
 * rows so callers / tests can assert behavior.
 */
export function pruneExpired(d = getDb()) {
    if (!d)
        return 0;
    const days = resolveRetentionDays();
    try {
        const info = d.prepare("DELETE FROM invocations WHERE ts < datetime('now', ?)")
            .run(`-${days} days`);
        if (info.changes > 0) {
            log.info({ event: 'audit-log.pruned', deleted: info.changes, days }, `Pruned ${info.changes} audit row(s) older than ${days}d`);
        }
        return info.changes;
    }
    catch (err) {
        const msg = err instanceof Error ? err.message : String(err);
        log.warn({ event: 'audit-log.prune.failed', error: msg }, 'audit-log retention sweep failed');
        // M7: surface as Prometheus counter so a stuck retention loop becomes
        // observable beyond a stderr warn line. Alert on any non-zero rate.
        recordAuditPruneFailure();
        return 0;
    }
}
export function logAuditEvent(ev) {
    const d = getDb();
    if (!d)
        return;
    try {
        const detailsJson = JSON.stringify(ev.details ?? {});
        d.prepare(`
      INSERT INTO audit_events (trace_id, event_type, actor, target, outcome, details)
      VALUES (?, ?, ?, ?, ?, ?)
    `).run(ev.traceId ?? '', ev.eventType, ev.actor, ev.target ?? '', ev.outcome ?? 'ok', detailsJson);
    }
    catch (err) {
        // Best-effort — audit logging must never break the caller's request.
        log.warn({ event: 'audit-events.write_failed', err: String(err) });
    }
}
export function queryAuditEvents(opts = {}) {
    const d = getDb();
    if (!d)
        return [];
    const conditions = [];
    const params = [];
    if (opts.types && opts.types.length > 0) {
        const placeholders = opts.types.map(() => '?').join(',');
        conditions.push(`event_type IN (${placeholders})`);
        params.push(...opts.types);
    }
    if (opts.actor) {
        conditions.push('actor = ?');
        params.push(opts.actor);
    }
    if (opts.days && Number.isFinite(opts.days) && opts.days > 0) {
        conditions.push("ts >= datetime('now', ?)");
        params.push(`-${Math.floor(opts.days)} days`);
    }
    const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : '';
    const rawLimit = opts.limit;
    const limit = Number.isFinite(rawLimit) && rawLimit > 0 ? Math.min(Math.floor(rawLimit), 1000) : 100;
    try {
        return d.prepare(`SELECT * FROM audit_events ${where} ORDER BY ts DESC LIMIT ?`)
            .all(...params, limit);
    }
    catch {
        return [];
    }
}
export function pruneExpiredEvents(d = getDb()) {
    if (!d)
        return 0;
    const days = resolveAuditEventsRetentionDays();
    if (days <= 0)
        return 0;
    try {
        const info = d.prepare("DELETE FROM audit_events WHERE ts < datetime('now', ?)")
            .run(`-${days} days`);
        if (info.changes > 0) {
            log.info({ event: 'audit-events.pruned', deleted: info.changes, days }, `Pruned ${info.changes} audit event row(s) older than ${days}d`);
        }
        return info.changes;
    }
    catch (err) {
        log.warn({ event: 'audit-events.prune.failed', error: String(err) });
        return 0;
    }
}
/** Stop the periodic retention sweep (used by tests and graceful shutdown). */
export function stopRetentionSweep() {
    if (sweepTimer) {
        clearInterval(sweepTimer);
        sweepTimer = null;
    }
}
/** Close the underlying SQLite handle. Pairs with stopRetentionSweep on
 *  graceful shutdown so WAL is checkpointed cleanly (L2). */
export function closeAuditDb() {
    helper.close();
}
// ─── v1.3 — Cost & Health aggregations ─────────────────────────────────
//
// Shared SQL helpers backing /api/health/*. All accept a `days` window
// (relative to now()) and return small JSON-friendly objects.
function safeDays(d) {
    if (!Number.isFinite(d) || d <= 0)
        return 7;
    return Math.min(Math.floor(d), 365);
}
export function getHealthSummary(days) {
    const window = safeDays(days);
    const now = new Date();
    const until = now.toISOString().slice(0, 10);
    const since = new Date(now.getTime() - window * 86400_000).toISOString().slice(0, 10);
    const empty = {
        since, until, days: window,
        totals: { calls: 0, cost: 0, errors: 0, errorRate: 0, avgLatencyMs: 0, p95LatencyMs: 0 },
        byDay: [],
    };
    const d = getDb();
    if (!d)
        return empty;
    try {
        const tot = d.prepare(`
      SELECT
        COUNT(*) AS calls,
        COALESCE(SUM(cost), 0) AS cost,
        COALESCE(SUM(CASE WHEN success = 0 THEN 1 ELSE 0 END), 0) AS errors,
        COALESCE(AVG(duration_ms), 0) AS avg_latency
      FROM invocations
      WHERE ts >= datetime('now', ?)
    `).get(`-${window} days`);
        // p95 — pull all latencies (capped) into JS for percentile calc. For
        // huge tables this is suboptimal; switch to per-bucket histogram later.
        const lats = d.prepare(`
      SELECT duration_ms FROM invocations
      WHERE ts >= datetime('now', ?) AND duration_ms > 0
      ORDER BY duration_ms ASC LIMIT 100000
    `).all(`-${window} days`);
        let p95 = 0;
        if (lats.length > 0) {
            const idx = Math.max(0, Math.floor(lats.length * 0.95) - 1);
            p95 = lats[idx].duration_ms;
        }
        const dayRows = d.prepare(`
      SELECT
        substr(ts, 1, 10) AS date,
        COUNT(*) AS calls,
        COALESCE(SUM(cost), 0) AS cost,
        COALESCE(SUM(CASE WHEN success = 0 THEN 1 ELSE 0 END), 0) AS errors,
        COALESCE(AVG(duration_ms), 0) AS avg_latency
      FROM invocations
      WHERE ts >= datetime('now', ?)
      GROUP BY date
      ORDER BY date ASC
    `).all(`-${window} days`);
        return {
            since, until, days: window,
            totals: {
                calls: tot.calls,
                cost: Math.round(tot.cost * 10000) / 10000,
                errors: tot.errors,
                errorRate: tot.calls > 0 ? Math.round((tot.errors / tot.calls) * 10000) / 10000 : 0,
                avgLatencyMs: Math.round(tot.avg_latency),
                p95LatencyMs: p95,
            },
            byDay: dayRows.map((r) => ({
                date: r.date,
                calls: r.calls,
                cost: Math.round(r.cost * 10000) / 10000,
                errors: r.errors,
                avgLatencyMs: Math.round(r.avg_latency),
            })),
        };
    }
    catch (err) {
        log.warn({ event: 'audit-log.summary_failed', err: String(err) });
        return empty;
    }
}
/**
 * Top-N grouped by `dim`, sorted by `metric` desc. Used to surface
 * "which users / agents / platforms drove the most cost / errors".
 */
export function getTopN(dim, metric, days, limit = 10) {
    const window = safeDays(days);
    const lim = Math.min(Math.max(Math.floor(limit), 1), 100);
    const d = getDb();
    if (!d)
        return [];
    const col = { user: 'user_id', agent: 'agent', platform: 'platform', intent: 'intent' }[dim];
    const orderCol = {
        cost: 'SUM(cost)',
        calls: 'COUNT(*)',
        errors: 'SUM(CASE WHEN success = 0 THEN 1 ELSE 0 END)',
        avg_latency: 'AVG(duration_ms)',
    }[metric];
    try {
        const rows = d.prepare(`
      SELECT
        ${col} AS key,
        COUNT(*) AS calls,
        COALESCE(SUM(cost), 0) AS cost,
        COALESCE(SUM(CASE WHEN success = 0 THEN 1 ELSE 0 END), 0) AS errors,
        COALESCE(AVG(duration_ms), 0) AS avg_latency
      FROM invocations
      WHERE ts >= datetime('now', ?)
      GROUP BY ${col}
      ORDER BY ${orderCol} DESC
      LIMIT ?
    `).all(`-${window} days`, lim);
        return rows.map((r) => ({
            key: r.key || '(empty)',
            calls: r.calls,
            cost: Math.round(r.cost * 10000) / 10000,
            errors: r.errors,
            avgLatencyMs: Math.round(r.avg_latency),
        }));
    }
    catch (err) {
        log.warn({ event: 'audit-log.topn_failed', err: String(err) });
        return [];
    }
}
export function getStats() {
    const d = getDb();
    if (!d)
        return { total: 0, byAgent: {}, totalCost: 0 };
    try {
        const total = d.prepare('SELECT COUNT(*) as n FROM invocations').get().n;
        const rows = d.prepare('SELECT agent, COUNT(*) as n, SUM(cost) as total_cost FROM invocations GROUP BY agent').all();
        const byAgent = {};
        let totalCost = 0;
        for (const r of rows) {
            byAgent[r.agent] = r.n;
            totalCost += r.total_cost || 0;
        }
        return { total, byAgent, totalCost };
    }
    catch {
        return { total: 0, byAgent: {}, totalCost: 0 };
    }
}
//# sourceMappingURL=audit-log.js.map