import { AsyncLocalStorage } from "node:async_hooks";
import { randomUUID } from "node:crypto";
import { NoChatContext } from "../agent/agent-types.js";
export class CenterFrontend {
    agent;
    client;
    workerId;
    version;
    logger;
    /**
     * AsyncLocalStorage that carries the active chat scope. Sender/notifier
     * inspect `getStore()` and throw NoChatContext when absent — this lets
     * the Agent multiplexer skip our frontend for non-chat invocations
     * (e.g. scheduled task scope before/after M3, attachment.send from a
     * Telegram chat that hasn't routed through us).
     */
    chatScope = new AsyncLocalStorage();
    inFlight = null;
    configDirty = false;
    /**
     * Session-specific dirty flag (set on `config_invalidate { which:
     * includes "session" }`). Separate from `configDirty` so the
     * session swap path (re-bootstrap + Agent.swapSession) doesn't fire
     * for unrelated config changes like LLM key rotation.
     */
    sessionDirty = false;
    /** Single-flight guard so two task_dispatches don't race the swap. */
    swapInFlight = null;
    detachers = [];
    heartbeatTimer = null;
    currentStatus = "idle";
    currentTaskId = null;
    constructor(opts) {
        this.agent = opts.agent;
        this.client = opts.client;
        this.workerId = opts.workerId;
        this.version = opts.version ?? "unknown";
        this.logger = opts.logger ?? {
            info: (m) => console.log(m),
            warn: (m) => console.warn(m),
            error: (m) => console.error(m),
        };
    }
    /** Wire up Agent registration + center-client message routing. */
    async start() {
        this.agent.registerFrontend("center", {
            sender: async (_payload) => {
                const store = this.chatScope.getStore();
                if (!store) {
                    throw new NoChatContext("center sender called outside a chat scope (M6 will deliver via R2 + task_run.attachments)");
                }
                // M6: presign R2 upload, push attachment_upload event over WS. For
                // M3 we hard-error to surface "chat called too early".
                throw new Error("center attachment delivery not implemented yet (M6); attachment was generated but not sent.");
            },
            notifier: async (_text) => {
                const store = this.chatScope.getStore();
                if (!store) {
                    throw new NoChatContext("center notifier called outside a chat scope");
                }
                // M6: emit chat_message_chunk via center-client.send().
            },
        });
        const onMessage = (msg) => {
            // Fire-and-forget; we don't want a slow agent.chat() to block message
            // parsing. Errors inside the handler get logged but never break the
            // socket.
            this.handleIncoming(msg).catch((err) => {
                this.logger.error(`CenterFrontend handler crashed for ${msg.type}: ${err instanceof Error ? err.message : err}`);
            });
        };
        this.client.on("message", onMessage);
        this.detachers.push(() => {
            this.client.off("message", onMessage);
        });
        const onOpen = () => {
            this.sendHello().catch((err) => {
                this.logger.warn(`CenterFrontend hello send failed: ${err instanceof Error ? err.message : err}`);
            });
        };
        this.client.on("open", onOpen);
        this.detachers.push(() => {
            this.client.off("open", onOpen);
        });
        // 30s liveness ping — matches dp-director's cadence. Empty payload; the
        // envelope's arrival is the signal.
        this.heartbeatTimer = setInterval(() => {
            this.client
                .send({
                type: "worker_heartbeat",
                payload: {},
            })
                .catch((err) => console.warn(`[CenterFrontend] heartbeat send failed: ${err instanceof Error ? err.message : err}`));
        }, 30_000);
    }
    /** Tear down: deregister listeners and unhook from Agent. */
    async stop() {
        if (this.heartbeatTimer) {
            clearInterval(this.heartbeatTimer);
            this.heartbeatTimer = null;
        }
        for (const d of this.detachers)
            d();
        this.detachers = [];
        this.agent.unregisterFrontend("center");
        if (this.inFlight) {
            this.inFlight.controller.abort();
            this.inFlight = null;
        }
    }
    /** True when center has signalled a config change and we should re-bootstrap before the next task. */
    isConfigDirty() {
        return this.configDirty;
    }
    /** Clear the dirty flag once the caller has performed a fresh bootstrap. */
    clearConfigDirty() {
        this.configDirty = false;
    }
    async sendHello() {
        const extras = this.client.getExtras();
        await this.client.send({
            type: "hello",
            payload: {
                worker_id: this.workerId,
                version: this.version,
                config_etag: extras?.configEtag ?? "unknown",
                current_status: this.currentStatus,
                ...(this.currentTaskId ? { current_task_id: this.currentTaskId } : {}),
            },
        });
    }
    async reportRuntimeStatus(status, currentTaskId, reason) {
        this.currentStatus = status;
        this.currentTaskId = currentTaskId;
        await this.client
            .send({
            type: "status_change",
            payload: {
                status,
                ...(currentTaskId ? { current_task_id: currentTaskId } : {}),
                reason,
            },
        })
            .catch((err) => {
            this.logger.warn(`CenterFrontend status_change(${status}) send failed: ${err instanceof Error ? err.message : err}`);
        });
    }
    /** True when a session-class config_invalidate has been received and not yet applied. */
    isSessionDirty() {
        return this.sessionDirty;
    }
    /**
     * Re-bootstrap from center and apply the new session_blob via
     * Agent.swapSession. No-op if sessionDirty is false. Single-flighted
     * so a burst of dispatches doesn't fire concurrent swaps.
     *
     * Called inline from handleTaskDispatch / handleChatUserMessage
     * BEFORE the task or chat actually runs — that way the freshest
     * session is in place for the new work. The BrowserLock inside
     * Agent.swapSession serializes against the task that's about to
     * acquire it, so any contention resolves naturally.
     */
    async swapSessionIfDirty() {
        if (!this.sessionDirty)
            return;
        if (this.swapInFlight) {
            await this.swapInFlight;
            return;
        }
        const work = (async () => {
            try {
                const fresh = await this.client.bootstrap();
                const blob = fresh.extras.sessionBlob;
                if (!blob) {
                    // center has no session_blob (worker has no session_content_hash, or
                    // R2 isn't configured). Nothing to apply; clear the flag so
                    // we don't keep retrying.
                    this.sessionDirty = false;
                    return;
                }
                await this.agent.swapSession(blob);
                this.sessionDirty = false;
            }
            catch (err) {
                this.logger.warn(`CenterFrontend session swap failed: ${err instanceof Error ? err.message : err}`);
                // Leave sessionDirty set so the next dispatch retries.
            }
        })();
        this.swapInFlight = work;
        try {
            await work;
        }
        finally {
            this.swapInFlight = null;
        }
    }
    async handleIncoming(msg) {
        // zod's inferred discriminated union narrows `msg.type` but doesn't
        // co-narrow `msg.payload` (the payload becomes a union of every
        // variant's payload regardless of the type). Values are still
        // runtime-validated via dp-shared's .safeParse before we get here, so
        // the casts below are sound — they just patch the TS-side ergonomics.
        switch (msg.type) {
            case "task_dispatch":
                await this.handleTaskDispatch(msg.payload);
                return;
            case "chat_user_message":
                await this.handleChatUserMessage(msg.payload);
                return;
            case "config_invalidate": {
                const p = msg.payload;
                this.configDirty = true;
                if (p.which.includes("session")) {
                    this.sessionDirty = true;
                }
                this.logger.info(`CenterFrontend marked config_dirty (which=${p.which.join(",")}, new_etag=${p.new_config_etag})`);
                return;
            }
            case "cancel_task":
                this.handleCancelTask(msg.payload);
                return;
            case "ping_request": {
                const p = msg.payload;
                await this.client.send({
                    type: "pong",
                    payload: {
                        ref_id: p.ref_id,
                    },
                });
                return;
            }
            default:
                // Ack-only messages and worker→center echoes (shouldn't arrive here) are
                // ignored. Real wire never delivers those from center; this branch
                // keeps the switch exhaustive in case the union grows.
                return;
        }
    }
    async handleTaskDispatch(payload) {
        // Ack immediately so center marks dispatched.
        await this.client
            .send({
            type: "task_event",
            payload: {
                task_run_id: payload.task_run_id,
                event: "dispatched_ack",
            },
        })
            .catch((err) => {
            this.logger.warn(`CenterFrontend dispatched_ack send failed: ${err instanceof Error ? err.message : err}`);
        });
        // Pre-task hook: if center has pushed a new session, swap it in before
        // we run. Inside swapSession the BrowserLock serializes us against
        // anything already running.
        await this.swapSessionIfDirty();
        const controller = new AbortController();
        this.inFlight = { taskRunId: payload.task_run_id, controller };
        await this.reportRuntimeStatus("busy", payload.task_run_id, "task_dispatch_started");
        // Tell center we're running.
        await this.client
            .send({
            type: "task_event",
            payload: {
                task_run_id: payload.task_run_id,
                event: "started",
            },
        })
            .catch((err) => {
            this.logger.warn(`CenterFrontend started send failed: ${err instanceof Error ? err.message : err}`);
        });
        let outcome;
        try {
            outcome = await this.agent.enqueueTaskFromCenter(payload.task_run_id, payload.prompt, { signal: controller.signal });
        }
        catch (err) {
            outcome = {
                status: "failed",
                error: err instanceof Error ? err.message : String(err),
            };
        }
        finally {
            this.inFlight = null;
        }
        await this.client
            .send({
            type: "task_event",
            payload: {
                task_run_id: payload.task_run_id,
                event: "finished",
                status: outcome.status === "success"
                    ? "success"
                    : outcome.status === "cancelled"
                        ? "cancelled"
                        : "failed",
                ...(outcome.result ? { result_summary: outcome.result } : {}),
                ...(outcome.error ? { error: outcome.error } : {}),
            },
        })
            .catch((err) => {
            this.logger.error(`CenterFrontend finished send failed (task ${payload.task_run_id} done but center may not know): ${err instanceof Error ? err.message : err}`);
        });
        await this.reportRuntimeStatus("idle", null, "task_dispatch_finished");
    }
    /**
     * Run the user's chat through the Agent streaming path and emit the
     * full M6 event sequence:
     *
     *   1. (optional) `chat_message_queued` if the BrowserLock is held —
     *      tells the UI "worker busy, waiting in line".
     *   2. `chat_message_chunk` per text delta, in order.
     *   3. A terminal `chat_message_chunk` with done=true (empty delta) so
     *      the UI knows streaming is over before the persisted row lands.
     *   4. `chat_message_finalized` with the full collected content — center
     *      persists this in chat_messages with the assistant's UUID.
     *
     * The assistant message gets its own UUID (`assistantMessageId`); the
     * user's `payload.message_id` is the row center already persisted on
     * /chat/send and is NOT reused.
     */
    async handleChatUserMessage(payload) {
        const chatId = payload.chat_id;
        const assistantMessageId = randomUUID();
        const content = await this.materializeChatContent(payload.content);
        // Pre-chat hook: same idea as task_dispatch — if center has pushed a
        // new session, swap it in before we start the turn.
        await this.swapSessionIfDirty();
        // Surface queued state if the lock is held. We can't see the real
        // queue depth from outside BrowserLock, so position=1 is a
        // best-effort hint; the UI just uses it to render "queued".
        if (this.agent.isBusy()) {
            await this.client
                .send({
                type: "chat_message_queued",
                payload: {
                    chat_id: chatId,
                    message_id: assistantMessageId,
                    queue_position: 1,
                },
            })
                .catch((err) => {
                this.logger.warn(`CenterFrontend chat_message_queued send failed: ${err instanceof Error ? err.message : err}`);
            });
        }
        let collected = "";
        let errored = null;
        await this.chatScope.run({ chatId }, async () => {
            try {
                for await (const delta of this.agent.chatStream(content)) {
                    collected += delta;
                    // Sequential await preserves chunk order; center-client buffers if
                    // the WS is mid-flight.
                    await this.client
                        .send({
                        type: "chat_message_chunk",
                        payload: {
                            chat_id: chatId,
                            message_id: assistantMessageId,
                            delta,
                            done: false,
                        },
                    })
                        .catch((err) => {
                        this.logger.warn(`CenterFrontend chunk send failed: ${err instanceof Error ? err.message : err}`);
                    });
                }
            }
            catch (err) {
                errored = err instanceof Error ? err : new Error(String(err));
                this.logger.error(`CenterFrontend chatStream errored: ${errored.message}`);
            }
        });
        // Terminal chunk so the UI can flip its "streaming" indicator off
        // before the finalized row lands. Empty delta + done=true signals EOF.
        await this.client
            .send({
            type: "chat_message_chunk",
            payload: {
                chat_id: chatId,
                message_id: assistantMessageId,
                delta: "",
                done: true,
            },
        })
            .catch((err) => {
            this.logger.warn(`CenterFrontend terminal chunk send failed: ${err instanceof Error ? err.message : err}`);
        });
        // Persist via finalized. center's upsertAssistantMessage uses this
        // assistantMessageId as the chat_messages.id, so the UI's
        // streaming-rendered message and the eventual DB row share an id.
        const finalContent = errored
            ? `${collected}${collected ? "\n\n" : ""}[error: ${errored.message}]`
            : collected;
        await this.client
            .send({
            type: "chat_message_finalized",
            payload: {
                chat_id: chatId,
                message_id: assistantMessageId,
                role: "assistant",
                content: finalContent,
            },
        })
            .catch((err) => {
            this.logger.warn(`CenterFrontend chat_message_finalized send failed: ${err instanceof Error ? err.message : err}`);
        });
    }
    async materializeChatContent(content) {
        if (typeof content === "string")
            return content;
        const blocks = [];
        for (const part of content) {
            if (part.type === "text") {
                if (part.text?.trim())
                    blocks.push({ type: "text", text: part.text });
                continue;
            }
            const descriptor = [
                part.text,
                part.filename ? `filename: ${part.filename}` : undefined,
                part.mime ? `mime: ${part.mime}` : undefined,
                part.size_bytes ? `size: ${part.size_bytes} bytes` : undefined,
                part.url ? `download_url: ${part.url}` : undefined,
            ]
                .filter(Boolean)
                .join("\n");
            if (part.type === "image" &&
                part.url &&
                part.mime?.startsWith("image/")) {
                try {
                    const res = await fetch(part.url);
                    if (!res.ok)
                        throw new Error(`HTTP ${res.status}`);
                    const buf = Buffer.from(await res.arrayBuffer());
                    blocks.push({
                        type: "image",
                        imageData: buf.toString("base64"),
                        mediaType: part.mime,
                    });
                    if (descriptor)
                        blocks.push({ type: "text", text: descriptor });
                    continue;
                }
                catch (err) {
                    this.logger.warn(`CenterFrontend failed to load image attachment ${part.filename ?? part.attachment_id ?? "(unknown)"}: ${err instanceof Error ? err.message : err}`);
                }
            }
            if (descriptor)
                blocks.push({ type: "text", text: descriptor });
        }
        return blocks.length > 0 ? blocks : "";
    }
    handleCancelTask(payload) {
        if (this.inFlight && this.inFlight.taskRunId === payload.task_run_id) {
            this.inFlight.controller.abort();
            this.logger.info(`CenterFrontend aborting in-flight task ${payload.task_run_id}: ${payload.reason ?? "(no reason given)"}`);
        }
    }
}
//# sourceMappingURL=center-frontend.js.map