import Database from 'better-sqlite3';
import { join } from 'node:path';
import { existsSync, mkdirSync, chmodSync, readFileSync, writeFileSync } from 'node:fs';
/**
 * AI usage ledger — persists one row per model inference call so spend can be
 * attributed per feature/model/day (cost-control Phase 0). Writes are
 * fire-and-forget: `record()` and the module-level `recordAiUsage()` never
 * throw into the inference path. Raw rows are pruned after a retention window
 * (default 180 days; rows are ~100 bytes, so even heavy use stays small).
 */
const SCHEMA_VERSION = 1;
const DEFAULT_RETENTION_DAYS = 180;
// Hardcoded — group_by values must never be interpolated from user input directly.
const GROUP_BY_SQL = {
    feature: 'feature',
    model: 'model_id',
    day: "strftime('%Y-%m-%d', ts/1000, 'unixepoch')",
};
const AGGREGATE_SQL = `
  COUNT(*) AS calls,
  SUM(input_tokens) AS input_tokens,
  SUM(output_tokens) AS output_tokens,
  SUM(cache_read_tokens) AS cache_read_tokens,
  SUM(cache_write_tokens) AS cache_write_tokens,
  SUM(total_tokens) AS total_tokens,
  SUM(COALESCE(cost_usd, 0)) AS cost_usd
`;
function numberOrZero(value) {
    return typeof value === 'number' && Number.isFinite(value) ? value : 0;
}
/** Keep the ledger DB out of the .studiograph config repo (it carries user ids,
 *  session ids, and spend metadata). Mirrors auth-service.ensureGitignore(). */
function ensureGitignore(sgDir) {
    try {
        const gitignorePath = join(sgDir, '.gitignore');
        const entries = ['ai-usage.db', 'ai-usage.db-wal', 'ai-usage.db-shm'];
        const content = existsSync(gitignorePath) ? readFileSync(gitignorePath, 'utf-8') : '';
        const missing = entries.filter((e) => !content.includes(e));
        if (missing.length > 0) {
            const suffix = (content && !content.endsWith('\n') ? '\n' : '') +
                '# AI usage ledger — local telemetry, never committed\n' +
                missing.join('\n') + '\n';
            writeFileSync(gitignorePath, content + suffix, 'utf-8');
        }
    }
    catch { /* non-fatal — ledger still works without the gitignore */ }
}
function buildWhere(filter) {
    const where = [];
    const params = [];
    if (typeof filter.since === 'number') {
        where.push('ts >= ?');
        params.push(filter.since);
    }
    if (typeof filter.until === 'number') {
        where.push('ts <= ?');
        params.push(filter.until);
    }
    if (filter.feature) {
        where.push('feature = ?');
        params.push(filter.feature);
    }
    return { sql: where.length ? ` WHERE ${where.join(' AND ')}` : '', params };
}
function toRollupRow(raw, groupBy) {
    const input = numberOrZero(raw.input_tokens);
    const cacheRead = numberOrZero(raw.cache_read_tokens);
    const denom = input + cacheRead;
    const row = {
        calls: numberOrZero(raw.calls),
        inputTokens: input,
        outputTokens: numberOrZero(raw.output_tokens),
        cacheReadTokens: cacheRead,
        cacheWriteTokens: numberOrZero(raw.cache_write_tokens),
        totalTokens: numberOrZero(raw.total_tokens),
        costUsd: numberOrZero(raw.cost_usd),
        cacheHitRate: denom > 0 ? cacheRead / denom : 0,
    };
    if (groupBy.includes('feature'))
        row.feature = raw.g_feature;
    if (groupBy.includes('model'))
        row.modelId = raw.g_model;
    if (groupBy.includes('day'))
        row.day = raw.g_day;
    return row;
}
export class AiUsageLedger {
    db;
    closed = false;
    insertStmt;
    constructor(workspacePath) {
        const sgDir = join(workspacePath, '.studiograph');
        if (!existsSync(sgDir))
            mkdirSync(sgDir, { recursive: true });
        ensureGitignore(sgDir);
        const dbPath = join(sgDir, 'ai-usage.db');
        this.db = new Database(dbPath);
        if (process.platform !== 'win32') {
            try {
                chmodSync(dbPath, 0o600);
            }
            catch { /* non-fatal */ }
        }
        this.db.pragma('journal_mode = WAL');
        this.db.exec(`
      CREATE TABLE IF NOT EXISTS usage_events (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        ts INTEGER NOT NULL,
        feature TEXT NOT NULL,
        provider TEXT NOT NULL,
        model_id TEXT NOT NULL,
        input_tokens INTEGER NOT NULL DEFAULT 0,
        output_tokens INTEGER NOT NULL DEFAULT 0,
        cache_read_tokens INTEGER NOT NULL DEFAULT 0,
        cache_write_tokens INTEGER NOT NULL DEFAULT 0,
        total_tokens INTEGER NOT NULL DEFAULT 0,
        cost_usd REAL,
        user_id INTEGER,
        session_id TEXT,
        duration_ms INTEGER
      )
    `);
        this.db.exec(`CREATE INDEX IF NOT EXISTS idx_usage_events_ts ON usage_events(ts)`);
        this.db.exec(`CREATE INDEX IF NOT EXISTS idx_usage_events_feature_ts ON usage_events(feature, ts)`);
        this.db.exec(`
      CREATE TABLE IF NOT EXISTS meta (
        key TEXT PRIMARY KEY,
        value TEXT NOT NULL
      )
    `);
        this.runMigrations();
        // Hot-path statement, prepared once (one insert per assistant message).
        this.insertStmt = this.db.prepare(`
      INSERT INTO usage_events (
        ts, feature, provider, model_id,
        input_tokens, output_tokens, cache_read_tokens, cache_write_tokens, total_tokens,
        cost_usd, user_id, session_id, duration_ms
      ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
    `);
        this.prune();
    }
    runMigrations() {
        const row = this.db.prepare("SELECT value FROM meta WHERE key='schema_version'").get();
        const current = row ? parseInt(row.value, 10) || 0 : 0;
        if (current >= SCHEMA_VERSION)
            return;
        this.db.prepare('INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)').run('schema_version', String(SCHEMA_VERSION));
    }
    /** Persist one inference call. NEVER throws — failure degrades to a one-time warning. */
    record(input) {
        try {
            if (this.closed)
                return;
            const u = (input.usage ?? {});
            const cost = (u.cost ?? null);
            const costTotal = cost && typeof cost.total === 'number' && Number.isFinite(cost.total) ? cost.total : null;
            this.insertStmt.run(Date.now(), input.feature, input.provider, input.modelId, numberOrZero(u.input), numberOrZero(u.output), numberOrZero(u.cacheRead), numberOrZero(u.cacheWrite), numberOrZero(u.totalTokens), costTotal, input.userId ?? null, input.sessionId ?? null, input.durationMs ?? null);
        }
        catch (err) {
            warnOnce(err);
        }
    }
    queryTotals(filter = {}) {
        const { sql, params } = buildWhere(filter);
        const raw = this.db.prepare(`SELECT ${AGGREGATE_SQL} FROM usage_events${sql}`).get(...params);
        return toRollupRow(raw, []);
    }
    queryRollup(opts) {
        const groupBy = opts.groupBy.filter((g, i) => GROUP_BY_SQL[g] && opts.groupBy.indexOf(g) === i);
        if (groupBy.length === 0)
            return [this.queryTotals(opts)];
        const selectCols = groupBy
            .map((g) => `${GROUP_BY_SQL[g]} AS g_${g === 'model' ? 'model' : g}`)
            .join(', ');
        const groupCols = groupBy.map((g) => GROUP_BY_SQL[g]).join(', ');
        const { sql, params } = buildWhere(opts);
        const rows = this.db.prepare(`SELECT ${selectCols}, ${AGGREGATE_SQL} FROM usage_events${sql} GROUP BY ${groupCols} ORDER BY ${groupCols}`).all(...params);
        return rows.map((r) => toRollupRow(r, groupBy));
    }
    queryRecent(opts) {
        const { sql, params } = buildWhere(opts);
        const totalRow = this.db.prepare(`SELECT COUNT(*) AS n FROM usage_events${sql}`).get(...params);
        const rows = this.db.prepare(`SELECT * FROM usage_events${sql} ORDER BY ts DESC, id DESC LIMIT ? OFFSET ?`).all(...params, opts.limit, opts.offset);
        return {
            total: totalRow.n,
            items: rows.map((r) => ({
                id: r.id,
                ts: r.ts,
                feature: r.feature,
                provider: r.provider,
                modelId: r.model_id,
                inputTokens: r.input_tokens,
                outputTokens: r.output_tokens,
                cacheReadTokens: r.cache_read_tokens,
                cacheWriteTokens: r.cache_write_tokens,
                totalTokens: r.total_tokens,
                costUsd: r.cost_usd,
                userId: r.user_id,
                sessionId: r.session_id,
                durationMs: r.duration_ms,
            })),
        };
    }
    /** Delete rows older than the retention window. Returns the number deleted. */
    prune(maxAgeDays) {
        try {
            const envDays = Number.parseInt(process.env.STUDIOGRAPH_AI_USAGE_RETENTION_DAYS ?? '', 10);
            const days = maxAgeDays ?? (Number.isFinite(envDays) && envDays > 0 ? envDays : DEFAULT_RETENTION_DAYS);
            const cutoff = Date.now() - days * 24 * 60 * 60 * 1000;
            const res = this.db.prepare('DELETE FROM usage_events WHERE ts < ?').run(cutoff);
            return res.changes;
        }
        catch (err) {
            warnOnce(err);
            return 0;
        }
    }
    close() {
        this.closed = true;
        try {
            this.db.close();
        }
        catch { /* non-fatal */ }
    }
}
// ── Module-level singleton ───────────────────────────────────────────────────
// Access pattern for callers that aren't constructor-wired to the server
// (asset-caption-service, event-processor): the server sets the instance at
// startup; recordAiUsage() is a safe no-op when unset (CLI one-offs, tests).
let ledgerInstance = null;
let warned = false;
function warnOnce(err) {
    if (warned)
        return;
    warned = true;
    console.warn('[ai-usage-ledger] recording failed (further failures silenced):', err instanceof Error ? err.message : err);
}
export function setAiUsageLedger(ledger) {
    ledgerInstance = ledger;
}
/** Fire-and-forget recording hook. No-op when no ledger is set; never throws. */
export function recordAiUsage(input) {
    try {
        ledgerInstance?.record(input);
    }
    catch (err) {
        warnOnce(err);
    }
}
//# sourceMappingURL=ai-usage-ledger.js.map