/**
 * Bridge command-event subscriber — the low-latency notification half of the
 * Layer 2 command transport. The bridge opens a long-lived outbound SSE stream
 * (GET .../commands/events, signed with the shared X-PatchTrail-Sync-* HMAC
 * scheme) and, on each notification frame, triggers a command pull. This keeps
 * the bridge loopback-only: it connects outbound and is notified to pull; the
 * backend never connects inbound.
 *
 * The stream is a hint, not the data — the durable command poller remains the
 * source of truth, so a missed or dropped notification only delays a pull until
 * the next poll tick. The subscriber reconnects with backoff until stopped and
 * never rejects to its caller.
 */
import { buildSignedRequestHeaders } from "./syncWorker.js";
/** Bounded sanitized reason codes; raw responses are never logged. */
export const COMMAND_EVENT_FAILURE_REASON_CODES = {
    not_ok: "command_events_not_ok",
    network_error: "command_events_network_error",
    stream_error: "command_events_stream_error",
};
const RECONNECT_DELAY_MS = 2_000;
const DEFAULT_FETCH = async (url, init) => {
    const response = await fetch(url, {
        method: init.method,
        headers: init.headers,
        signal: init.signal,
    });
    return { ok: response.ok, status: response.status, body: response.body };
};
function defaultSleep(ms, signal) {
    return new Promise((resolve) => {
        const timer = setTimeout(resolve, ms);
        timer.unref?.();
        signal.addEventListener("abort", () => {
            clearTimeout(timer);
            resolve();
        }, { once: true });
    });
}
function configReady(config) {
    return (config.enabled &&
        typeof config.endpointUrl === "string" &&
        typeof config.signingKey === "string" &&
        typeof config.keyId === "string" &&
        typeof config.audience === "string");
}
function eventsPath(deviceId) {
    return `/api/v1/sync/devices/${deviceId}/commands/events`;
}
/**
 * Extracts complete SSE frames from a buffer, returning the number of frames
 * seen and the unconsumed remainder. Any frame (ready/commands/heartbeat) is a
 * pull signal — we count frames rather than parse their data.
 */
export function countSseFrames(buffer) {
    let frames = 0;
    let cursor = 0;
    while (true) {
        const boundary = buffer.indexOf("\n\n", cursor);
        if (boundary === -1)
            break;
        const rawEvent = buffer.slice(cursor, boundary);
        cursor = boundary + 2;
        // A frame carrying a data line is a notification; bare comment-only
        // keep-alives (": ...") are ignored.
        if (rawEvent.split(/\r?\n/).some((line) => line.startsWith("data:"))) {
            frames += 1;
        }
    }
    return { frames, remainder: buffer.slice(cursor) };
}
export function createCommandEventSubscriber(options) {
    const fetchImpl = options.fetchImpl ?? DEFAULT_FETCH;
    const sleep = options.sleep ?? defaultSleep;
    const reconnectDelayMs = options.reconnectDelayMs ?? RECONNECT_DELAY_MS;
    let controller;
    let worker;
    async function streamOnce(deviceId, workspaceId, signal) {
        if (!configReady(options.config))
            return;
        const path = eventsPath(deviceId);
        const signedHeaders = buildSignedRequestHeaders({
            method: "GET",
            path,
            audience: options.config.audience,
            keyId: options.config.keyId,
            workspaceId,
            body: "",
            signingKey: options.config.signingKey,
        });
        // Layer 3 per-device transport auth: sign the HMAC signature value.
        const hmacSignature = signedHeaders["X-PatchTrail-Sync-Signature"];
        const deviceSignature = options.deviceSigner && hmacSignature
            ? await options.deviceSigner(deviceId, hmacSignature)
            : null;
        const headers = {
            ...signedHeaders,
            ...(deviceSignature
                ? { "X-PatchTrail-Device-Signature": deviceSignature }
                : {}),
            accept: "text/event-stream",
        };
        let response;
        try {
            response = await fetchImpl(`${new URL(options.config.endpointUrl).origin}${path}`, {
                method: "GET",
                headers,
                signal,
            });
        }
        catch {
            options.onError?.(COMMAND_EVENT_FAILURE_REASON_CODES.network_error);
            return;
        }
        if (!response.ok || !response.body) {
            options.onError?.(COMMAND_EVENT_FAILURE_REASON_CODES.not_ok);
            return;
        }
        const reader = response.body.getReader();
        const decoder = new TextDecoder();
        let buffer = "";
        try {
            while (!signal.aborted) {
                const { value, done } = await reader.read();
                if (done)
                    break;
                buffer += decoder.decode(value, { stream: true });
                const drained = countSseFrames(buffer);
                buffer = drained.remainder;
                for (let i = 0; i < drained.frames; i += 1) {
                    options.onNotify();
                }
            }
        }
        catch {
            if (!signal.aborted) {
                options.onError?.(COMMAND_EVENT_FAILURE_REASON_CODES.stream_error);
            }
        }
        finally {
            try {
                await reader.cancel();
            }
            catch {
                // already closed
            }
        }
    }
    async function run(signal) {
        while (!signal.aborted) {
            const devices = options.store.listDevices();
            if (devices.length > 0) {
                // One stream per device, sequentially — the foundation typically has a
                // single device, and a dropped stream falls back to the durable poller.
                for (const device of devices) {
                    if (signal.aborted)
                        break;
                    await streamOnce(device.id, device.workspaceId, signal);
                }
            }
            if (signal.aborted)
                break;
            await sleep(reconnectDelayMs, signal);
        }
    }
    return {
        ensureStarted() {
            if (worker)
                return;
            const next = new AbortController();
            controller = next;
            worker = run(next.signal);
        },
        async stop() {
            controller?.abort();
            try {
                await worker;
            }
            finally {
                worker = undefined;
                controller = undefined;
            }
        },
    };
}
