import { randomUUID } from "node:crypto";
import { buildGatewaySessionKey, buildGatewaySessionSource } from "../session.js";
import { acquireConversationLock, appendConversationRecord, buildBaseRecordFields, ensureConversationDirs, materializeAttachments, nextMessageId, normalizeInboundMessage, readConversationLog, releaseConversationLock, } from "./log.js";
function isDMConversation(conversation) {
    return conversation.channel.dm ?? false;
}
function toGuestDisplayPath(conversation, localPath) {
    void conversation;
    return localPath;
}
function formatTranscriptRecord(conversation, record) {
    if (record.type !== "inbound")
        return [];
    const lines = [
        `- [${record.timestamp}] [uid:${record.userId}] ${record.userName ?? "unknown"}: ${record.text || "(no text)"}`,
    ];
    if (record.attachments.length > 0) {
        lines.push("  attachments:");
        for (const attachment of record.attachments)
            lines.push(`  - ${toGuestDisplayPath(conversation, attachment.localPath)}${attachment.mimeType ? ` (${attachment.mimeType})` : ""}`);
    }
    return lines;
}
function getLatestTriggerRecord(records, job) {
    const triggerRecord = records.find((record) => record.recordId === job.triggerRecordId);
    if (!triggerRecord || triggerRecord.type !== "inbound")
        return undefined;
    return triggerRecord;
}
function escapeRegExp(value) {
    return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
export class ConversationRuntime {
    conversation;
    ownerId;
    records = [];
    nextRecordId = 1;
    pendingJobs = [];
    activeJob;
    armedAfterRecordId;
    constructor(conversation, ownerId) {
        this.conversation = conversation;
        this.ownerId = ownerId;
    }
    static async connect(conversation, ownerId) {
        const runtime = new ConversationRuntime(conversation, ownerId);
        await runtime.initialize();
        return runtime;
    }
    async initialize() {
        await ensureConversationDirs(this.conversation);
        await acquireConversationLock(this.conversation, this.ownerId);
        this.records = await readConversationLog(this.conversation);
        this.nextRecordId = this.records.reduce((max, record) => Math.max(max, record.recordId), 0) + 1;
    }
    armAfterCurrentTail() {
        this.armedAfterRecordId = this.records.at(-1)?.recordId ?? 0;
    }
    isArmed() {
        return this.armedAfterRecordId !== undefined;
    }
    async disconnect() {
        await releaseConversationLock(this.conversation);
    }
    async appendRecord(record) {
        this.records.push(record);
        this.nextRecordId = Math.max(this.nextRecordId, record.recordId + 1);
        await appendConversationRecord(this.conversation, record);
    }
    getLastQueuedTriggerRecordId() {
        let last = 0;
        for (const record of this.records) {
            if (record.type === "job_queued")
                last = Math.max(last, record.triggerRecordId);
        }
        return last;
    }
    getLastCompletedTriggerRecordId() {
        let last = 0;
        for (const record of this.records) {
            if (record.type !== "job_completed")
                continue;
            last = Math.max(last, record.triggerRecordId);
        }
        return last;
    }
    isAllowedInput(message) {
        const access = this.conversation.access;
        if ((message.isBot ?? false) && (access.ignoreBots ?? true))
            return false;
        if (access.allowedUserIds?.length && !access.allowedUserIds.includes(message.userId))
            return false;
        if (access.allowedRoleIds?.length) {
            const roleIds = message.roleIds ?? [];
            if (!roleIds.some((roleId) => access.allowedRoleIds?.includes(roleId)))
                return false;
        }
        return true;
    }
    isAllowedMessage(message) {
        return this.isAllowedInput(message);
    }
    parseControlCommand(input) {
        const normalized = normalizeInboundMessage(input, this.conversation.botName);
        if (!this.isAllowedInput(normalized))
            return undefined;
        const account = this.conversation.account;
        let text = normalized.text;
        const botUserId = "botUserId" in account ? account.botUserId : undefined;
        if (botUserId)
            text = text.replace(new RegExp(`<@!?${escapeRegExp(botUserId)}>`, "g"), " ");
        const aliases = [this.conversation.botName, "botUsername" in account ? account.botUsername : undefined].filter(Boolean);
        for (const alias of aliases)
            text = text.replace(new RegExp(`@${escapeRegExp(alias || "")}\\b`, "ig"), " ");
        const command = text.replace(/\s+/g, " ").trim().toLowerCase();
        if (command === "stop" || command === "/stop")
            return "stop";
        if (command === "new" || command === "/new")
            return "new";
        if (command === "compact" || command === "/compact")
            return "compact";
        if (command === "status" || command === "/status")
            return "status";
        if (command === "help" || command === "/help")
            return "help";
        return undefined;
    }
    matchesStopCommand(input) {
        return this.parseControlCommand(input) === "stop";
    }
    shouldTriggerJob(message) {
        if (!this.isAllowedMessage(message))
            return false;
        if (isDMConversation(this.conversation))
            return "dm";
        if ((this.conversation.access.trigger ?? "mention") === "message")
            return "mention";
        return message.mentionedBot ? "mention" : false;
    }
    shouldQueueTrigger(recordId) {
        if (this.armedAfterRecordId === undefined)
            return false;
        return recordId > Math.max(this.armedAfterRecordId, this.getLastQueuedTriggerRecordId());
    }
    getLastCheckpoint() {
        for (let index = this.records.length - 1; index >= 0; index--) {
            const record = this.records[index];
            if (record?.type === "checkpoint")
                return { cursor: record.cursor, messageId: record.messageId };
        }
        return {};
    }
    async noteCheckpoint(checkpoint) {
        const previous = this.getLastCheckpoint();
        if (previous.cursor === checkpoint.cursor && previous.messageId === checkpoint.messageId)
            return;
        const record = {
            type: "checkpoint",
            ...buildBaseRecordFields(this.conversation, this.nextRecordId),
            cursor: checkpoint.cursor,
            messageId: checkpoint.messageId,
        };
        await this.appendRecord(record);
    }
    async ingestInbound(input, checkpoint) {
        const normalized = normalizeInboundMessage(input, this.conversation.botName);
        const messageId = normalized.messageId || nextMessageId(this.conversation.service);
        const sessionSource = normalized.sessionSource || buildGatewaySessionSource(this.conversation, normalized);
        const sessionKey = normalized.sessionKey || buildGatewaySessionKey(sessionSource);
        const attachments = await materializeAttachments(this.conversation, messageId, normalized.attachments);
        const record = {
            type: "inbound",
            ...buildBaseRecordFields(this.conversation, this.nextRecordId, { sessionKey, sessionSource }),
            messageId,
            userId: normalized.userId,
            userName: normalized.userName,
            roleIds: normalized.roleIds,
            text: normalized.text,
            mentionedBot: normalized.mentionedBot ?? false,
            isBot: normalized.isBot ?? false,
            attachments,
        };
        await this.appendRecord(record);
        if (checkpoint)
            await this.noteCheckpoint(checkpoint);
        const trigger = this.shouldTriggerJob(record);
        if (!trigger || !this.shouldQueueTrigger(record.recordId))
            return { record, jobQueued: false };
        const queuedRecord = {
            type: "job_queued",
            ...buildBaseRecordFields(this.conversation, this.nextRecordId, { sessionKey, sessionSource }),
            jobId: randomUUID(),
            trigger,
            triggerRecordId: record.recordId,
        };
        await this.appendRecord(queuedRecord);
        this.pendingJobs.push({
            jobId: queuedRecord.jobId,
            trigger: queuedRecord.trigger,
            triggerRecordId: queuedRecord.triggerRecordId,
            queuedRecordId: queuedRecord.recordId,
            sessionKey,
            sessionSource,
        });
        return { record, jobQueued: true };
    }
    beginNextJob() {
        if (this.activeJob || this.pendingJobs.length === 0)
            return undefined;
        const job = this.pendingJobs.shift();
        if (!job)
            return undefined;
        this.activeJob = job;
        const triggerRecord = getLatestTriggerRecord(this.records, job);
        return {
            job,
            prompt: this.buildPrompt(job),
            triggerMessageId: triggerRecord?.messageId,
            sessionKey: job.sessionKey || triggerRecord?.sessionKey,
            sessionSource: job.sessionSource || triggerRecord?.sessionSource,
        };
    }
    buildPrompt(job) {
        if (job.overridePrompt !== undefined)
            return job.overridePrompt;
        const completedBoundary = this.getLastCompletedTriggerRecordId();
        const slice = this.records.filter((record) => record.recordId > completedBoundary && record.recordId <= job.triggerRecordId && record.type === "inbound");
        const lines = [];
        for (const record of slice)
            lines.push(...formatTranscriptRecord(this.conversation, record));
        return lines.join("\n").trim();
    }
    /**
     * Directly queue a proactive (nudge) job without an inbound user message.
     * Returns false if there is already an active job or pending jobs in the queue.
     */
    queueProactiveJob(prompt) {
        if (this.activeJob || this.pendingJobs.length > 0)
            return false;
        const sessionSource = buildGatewaySessionSource(this.conversation);
        const sessionKey = buildGatewaySessionKey(sessionSource);
        const queuedRecordId = this.nextRecordId;
        this.nextRecordId++;
        const job = {
            jobId: `nudge-${Date.now()}`,
            trigger: "nudge",
            triggerRecordId: this.records.at(-1)?.recordId ?? 0,
            queuedRecordId,
            sessionKey,
            sessionSource,
            overridePrompt: prompt,
        };
        this.pendingJobs.push(job);
        return true;
    }
    async completeActiveJob(text, remoteMessageId, attachmentPaths) {
        const job = this.activeJob;
        if (!job)
            return;
        let outboundRecordId;
        const trimmed = text.trim();
        if (trimmed.length > 0 || (attachmentPaths?.length ?? 0) > 0) {
            const triggerRecord = getLatestTriggerRecord(this.records, job);
            const outbound = {
                type: "outbound",
                ...buildBaseRecordFields(this.conversation, this.nextRecordId, {
                    sessionKey: job.sessionKey || triggerRecord?.sessionKey,
                    sessionSource: job.sessionSource || triggerRecord?.sessionSource,
                }),
                messageId: remoteMessageId || nextMessageId(this.conversation.service),
                text: trimmed,
                replyToMessageId: triggerRecord?.messageId,
                jobId: job.jobId,
                attachments: attachmentPaths?.length ? [...attachmentPaths] : undefined,
            };
            outboundRecordId = outbound.recordId;
            await this.appendRecord(outbound);
        }
        await this.appendRecord({
            type: "job_completed",
            ...buildBaseRecordFields(this.conversation, this.nextRecordId, {
                sessionKey: job.sessionKey,
                sessionSource: job.sessionSource,
            }),
            jobId: job.jobId,
            triggerRecordId: job.triggerRecordId,
            outboundRecordId,
        });
        this.activeJob = undefined;
    }
    async failActiveJob(error) {
        const job = this.activeJob;
        if (!job)
            return;
        await this.appendRecord({
            type: "job_failed",
            ...buildBaseRecordFields(this.conversation, this.nextRecordId, {
                sessionKey: job.sessionKey,
                sessionSource: job.sessionSource,
            }),
            jobId: job.jobId,
            triggerRecordId: job.triggerRecordId,
            error,
        });
        this.activeJob = undefined;
    }
    async appendError(message) {
        await this.appendRecord({
            type: "error",
            ...buildBaseRecordFields(this.conversation, this.nextRecordId),
            message,
        });
    }
    findHistory(options) {
        const query = options.query?.toLowerCase();
        const after = options.after ? Date.parse(options.after) : undefined;
        const before = options.before ? Date.parse(options.before) : undefined;
        const limit = Math.max(1, Math.min(options.limit ?? 20, 200));
        const filtered = this.records.filter((record) => {
            if (record.type !== "inbound" && record.type !== "outbound")
                return false;
            const ts = Date.parse(record.timestamp);
            if (after !== undefined && !(ts >= after))
                return false;
            if (before !== undefined && !(ts <= before))
                return false;
            if (!query)
                return true;
            const speaker = record.type === "inbound" ? (record.userName ?? record.userId) : "assistant";
            return `${speaker}\n${record.text}`.toLowerCase().includes(query);
        });
        return filtered.slice(Math.max(0, filtered.length - limit));
    }
    getStatus() {
        return {
            conversationId: this.conversation.conversationId,
            conversationName: this.conversation.conversationName,
            logPath: this.conversation.logPath,
            queueLength: this.pendingJobs.length,
            hasActiveJob: this.activeJob !== undefined,
            recordCount: this.records.length,
            lastRecordId: this.records.at(-1)?.recordId ?? 0,
            lastSessionKey: [...this.records].reverse().find((record) => record.sessionKey)?.sessionKey,
        };
    }
}
//# sourceMappingURL=runtime.js.map