import { randomUUID } from "node:crypto";
import { mkdirSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { DatabaseSync } from "node:sqlite";
export class ChatEventLog {
    db;
    constructor(path) {
        const resolvedPath = path === ":memory:" ? path : resolve(path);
        if (resolvedPath !== ":memory:") {
            mkdirSync(dirname(resolvedPath), { recursive: true });
        }
        this.db = new DatabaseSync(resolvedPath);
        this.db.exec("PRAGMA busy_timeout = 5000");
        if (resolvedPath !== ":memory:")
            this.db.exec("PRAGMA journal_mode = WAL");
        this.db.exec(`
			CREATE TABLE IF NOT EXISTS chat_events (
				stream_id INTEGER PRIMARY KEY,
				room_id TEXT,
				pibo_session_id TEXT,
				event_id TEXT NOT NULL,
				event_type TEXT NOT NULL,
				actor_type TEXT,
				actor_id TEXT,
				client_txn_id TEXT,
				created_at TEXT NOT NULL,
				retention_class TEXT NOT NULL,
				payload_json TEXT NOT NULL
			);

			CREATE INDEX IF NOT EXISTS idx_chat_events_room_stream
				ON chat_events(room_id, stream_id);
			CREATE INDEX IF NOT EXISTS idx_chat_events_session_stream
				ON chat_events(pibo_session_id, stream_id);
			CREATE INDEX IF NOT EXISTS idx_chat_events_event_id
				ON chat_events(event_id);
			CREATE UNIQUE INDEX IF NOT EXISTS idx_chat_events_client_txn
				ON chat_events(room_id, actor_id, client_txn_id)
				WHERE client_txn_id IS NOT NULL;

			CREATE TABLE IF NOT EXISTS chat_retention_policies (
				id TEXT PRIMARY KEY,
				delete_live_deltas_after_ms INTEGER,
				delete_trace_events_after_ms INTEGER,
				delete_chat_messages_after_ms INTEGER
			);

			CREATE TABLE IF NOT EXISTS chat_session_reads (
				pibo_session_id TEXT NOT NULL,
				principal_id TEXT NOT NULL,
				last_read_stream_id INTEGER NOT NULL,
				updated_at TEXT NOT NULL,
				PRIMARY KEY(pibo_session_id, principal_id)
			);
		`);
    }
    appendEvent(input) {
        const eventId = input.eventId ?? `ce_${randomUUID()}`;
        const createdAt = input.createdAt ?? new Date().toISOString();
        const payloadJson = JSON.stringify(input.payload);
        const result = this.db
            .prepare(`
				INSERT OR IGNORE INTO chat_events (
					room_id,
					pibo_session_id,
					event_id,
					event_type,
					actor_type,
					actor_id,
					client_txn_id,
					created_at,
					retention_class,
					payload_json
				) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
			`)
            .run(input.roomId ?? null, input.piboSessionId ?? null, eventId, input.eventType, input.actorType ?? null, input.actorId ?? null, input.clientTxnId ?? null, createdAt, input.retentionClass, payloadJson);
        if (input.clientTxnId) {
            const existing = this.findByClientTxn(input.roomId, input.actorId, input.clientTxnId);
            if (existing)
                return existing;
        }
        const streamId = Number(result.lastInsertRowid);
        const stored = this.db.prepare("SELECT * FROM chat_events WHERE stream_id = ?").get(streamId);
        if (!stored)
            throw new Error(`Failed to append chat event "${eventId}"`);
        return eventFromRow(stored);
    }
    appendOutputEvent(event, input = {}) {
        return this.appendEvent({
            roomId: input.roomId,
            piboSessionId: event.piboSessionId,
            eventId: "eventId" in event && event.eventId ? `pibo:${event.piboSessionId}:${event.eventId}:${event.type}` : undefined,
            eventType: event.type,
            actorType: actorTypeForOutputEvent(event),
            actorId: input.actorId,
            retentionClass: retentionClassForOutputEvent(event),
            payload: event,
        });
    }
    listEvents(input = {}) {
        const clauses = [];
        const values = [];
        if (input.roomId) {
            clauses.push("room_id = ?");
            values.push(input.roomId);
        }
        if (input.piboSessionId) {
            clauses.push("pibo_session_id = ?");
            values.push(input.piboSessionId);
        }
        if (input.afterStreamId !== undefined) {
            clauses.push("stream_id > ?");
            values.push(input.afterStreamId);
        }
        const where = clauses.length ? `WHERE ${clauses.join(" AND ")}` : "";
        const limit = Math.max(1, Math.min(input.limit ?? 1000, 5000));
        const rows = this.db
            .prepare(`SELECT * FROM chat_events ${where} ORDER BY stream_id ASC LIMIT ?`)
            .all(...values, limit);
        return rows.map(eventFromRow);
    }
    getEvent(streamId) {
        const row = this.db.prepare("SELECT * FROM chat_events WHERE stream_id = ?").get(streamId);
        return row ? eventFromRow(row) : undefined;
    }
    findByClientTxn(roomId, actorId, clientTxnId) {
        if (!roomId || !actorId)
            return undefined;
        const row = this.db
            .prepare(`
				SELECT * FROM chat_events
				WHERE room_id = ? AND actor_id = ? AND client_txn_id = ?
				ORDER BY stream_id ASC
				LIMIT 1
			`)
            .get(roomId, actorId, clientTxnId);
        return row ? eventFromRow(row) : undefined;
    }
    getLatestStreamId(input = {}) {
        const clauses = [];
        const values = [];
        if (input.roomId) {
            clauses.push("room_id = ?");
            values.push(input.roomId);
        }
        if (input.piboSessionId) {
            clauses.push("pibo_session_id = ?");
            values.push(input.piboSessionId);
        }
        const where = clauses.length ? `WHERE ${clauses.join(" AND ")}` : "";
        const row = this.db.prepare(`SELECT MAX(stream_id) AS stream_id FROM chat_events ${where}`).get(...values);
        return row?.stream_id ?? undefined;
    }
    getSessionReadCursor(piboSessionId, principalId) {
        const row = this.db
            .prepare("SELECT * FROM chat_session_reads WHERE pibo_session_id = ? AND principal_id = ?")
            .get(piboSessionId, principalId);
        return row?.last_read_stream_id;
    }
    markSessionRead(piboSessionId, principalId, lastReadStreamId) {
        this.db
            .prepare(`
				INSERT INTO chat_session_reads (pibo_session_id, principal_id, last_read_stream_id, updated_at)
				VALUES (?, ?, ?, ?)
				ON CONFLICT(pibo_session_id, principal_id) DO UPDATE SET
					last_read_stream_id = MAX(chat_session_reads.last_read_stream_id, excluded.last_read_stream_id),
					updated_at = excluded.updated_at
			`)
            .run(piboSessionId, principalId, lastReadStreamId, new Date().toISOString());
    }
    countUnreadMessages(input) {
        const clauses = [
            "retention_class = 'chat_message'",
            "event_type IN ('user.message.accepted', 'assistant_message')",
            "(actor_type IS NULL OR actor_type != 'user' OR actor_id IS NULL OR actor_id != ?)",
        ];
        const values = [input.principalId];
        if (input.roomId) {
            clauses.push("room_id = ?");
            values.push(input.roomId);
        }
        if (input.piboSessionId) {
            clauses.push("pibo_session_id = ?");
            values.push(input.piboSessionId);
        }
        if (input.afterStreamId !== undefined) {
            clauses.push("stream_id > ?");
            values.push(input.afterStreamId);
        }
        const row = this.db
            .prepare(`SELECT COUNT(*) AS count FROM chat_events WHERE ${clauses.join(" AND ")}`)
            .get(...values);
        return Number(row?.count ?? 0);
    }
    deleteSessions(piboSessionIds) {
        if (!piboSessionIds.length)
            return 0;
        const placeholders = piboSessionIds.map(() => "?").join(", ");
        this.db.prepare(`DELETE FROM chat_session_reads WHERE pibo_session_id IN (${placeholders})`).run(...piboSessionIds);
        const result = this.db.prepare(`DELETE FROM chat_events WHERE pibo_session_id IN (${placeholders})`).run(...piboSessionIds);
        return Number(result.changes ?? 0);
    }
    deleteRooms(roomIds) {
        if (!roomIds.length)
            return 0;
        const placeholders = roomIds.map(() => "?").join(", ");
        const result = this.db.prepare(`DELETE FROM chat_events WHERE room_id IN (${placeholders})`).run(...roomIds);
        return Number(result.changes ?? 0);
    }
    upsertRetentionPolicy(policy) {
        this.db
            .prepare(`
				INSERT INTO chat_retention_policies (
					id,
					delete_live_deltas_after_ms,
					delete_trace_events_after_ms,
					delete_chat_messages_after_ms
				) VALUES (?, ?, ?, ?)
				ON CONFLICT(id) DO UPDATE SET
					delete_live_deltas_after_ms = excluded.delete_live_deltas_after_ms,
					delete_trace_events_after_ms = excluded.delete_trace_events_after_ms,
					delete_chat_messages_after_ms = excluded.delete_chat_messages_after_ms
			`)
            .run(policy.id, policy.deleteLiveDeltasAfterMs ?? null, policy.deleteTraceEventsAfterMs ?? null, policy.deleteChatMessagesAfterMs ?? null);
        return this.getRetentionPolicy(policy.id) ?? policy;
    }
    getRetentionPolicy(id) {
        const row = this.db.prepare("SELECT * FROM chat_retention_policies WHERE id = ?").get(id);
        return row ? policyFromRow(row) : undefined;
    }
    purgeExpired(input = {}) {
        const now = input.now ?? new Date();
        const policy = input.policy ?? this.getRetentionPolicy("default") ?? { id: "default" };
        const batchSize = Math.max(1, Math.min(input.batchSize ?? 500, 5000));
        const clauses = [];
        const values = [];
        addRetentionClause(clauses, values, "live_delta", policy.deleteLiveDeltasAfterMs, now);
        addRetentionClause(clauses, values, "trace_event", policy.deleteTraceEventsAfterMs, now);
        addRetentionClause(clauses, values, "chat_message", policy.deleteChatMessagesAfterMs, now);
        if (!clauses.length)
            return 0;
        const result = this.db
            .prepare(`
				DELETE FROM chat_events
				WHERE stream_id IN (
					SELECT stream_id FROM chat_events
					WHERE ${clauses.join(" OR ")}
					ORDER BY stream_id ASC
					LIMIT ${batchSize}
				)
			`)
            .run(...values);
        return Number(result.changes ?? 0);
    }
    close() {
        this.db.close();
    }
}
export function createDefaultChatEventLog(cwd = process.cwd()) {
    return new ChatEventLog(resolve(cwd, ".pibo/web-chat.sqlite"));
}
function eventFromRow(row) {
    return {
        streamId: row.stream_id,
        roomId: row.room_id ?? undefined,
        piboSessionId: row.pibo_session_id ?? undefined,
        eventId: row.event_id,
        eventType: row.event_type,
        actorType: row.actor_type,
        actorId: row.actor_id ?? undefined,
        clientTxnId: row.client_txn_id ?? undefined,
        createdAt: row.created_at,
        retentionClass: row.retention_class,
        payload: JSON.parse(row.payload_json),
    };
}
function policyFromRow(row) {
    return {
        id: row.id,
        deleteLiveDeltasAfterMs: row.delete_live_deltas_after_ms ?? undefined,
        deleteTraceEventsAfterMs: row.delete_trace_events_after_ms ?? undefined,
        deleteChatMessagesAfterMs: row.delete_chat_messages_after_ms ?? undefined,
    };
}
function retentionClassForOutputEvent(event) {
    if (event.type === "assistant_delta" || event.type === "thinking_delta")
        return "live_delta";
    if (event.type === "assistant_message" || event.type === "message_started" || event.type === "message_finished") {
        return "chat_message";
    }
    return "trace_event";
}
function actorTypeForOutputEvent(event) {
    if (event.type === "assistant_delta" || event.type === "assistant_message" || event.type.startsWith("thinking_")) {
        return "assistant";
    }
    if (event.type === "message_queued" || event.type === "message_started" || event.type === "message_finished") {
        return "user";
    }
    return "agent";
}
function addRetentionClause(clauses, values, retentionClass, ageMs, now) {
    if (!ageMs || ageMs <= 0)
        return;
    clauses.push("(retention_class = ? AND created_at < ?)");
    values.push(retentionClass, new Date(now.getTime() - ageMs).toISOString());
}
