/**
 * Bridge command poller — the receiving half of the Layer 2 transport
 * foundation. When sync is enabled, the bridge (the outbound party) pulls
 * pending commands for its registered device from the hosted backend over the
 * shared X-PatchTrail-Sync-* HMAC scheme, stores each in the local command
 * inbox (idempotent ingest), and acknowledges it.
 *
 * This is transport only. Pulled commands are persisted via
 * `store.receiveCommand` for Layer 3 to act on later — the poller never
 * executes a command. Failures use bounded sanitized reason codes and the
 * poller never logs raw responses.
 *
 * The backend never connects inbound; the bridge stays loopback-only and is
 * the connecting party in every backend interaction.
 */
import { buildSignedRequestHeaders } from "./syncWorker.js";
export const COMMAND_POLL_FAILURE_REASON_CODES = {
    http_4xx: "command_poll_http_4xx",
    http_5xx: "command_poll_http_5xx",
    network_error: "command_poll_network_error",
    malformed_response: "command_poll_malformed_response",
    ack_failed: "command_poll_ack_failed",
};
/** Default outbound command-channel request timeout: 10 seconds. */
export const DEFAULT_COMMAND_POLL_TIMEOUT_MS = 10_000;
const DEFAULT_REQUESTER = async (url, init) => {
    const controller = new AbortController();
    const timeout = setTimeout(() => controller.abort(), DEFAULT_COMMAND_POLL_TIMEOUT_MS);
    try {
        const response = await fetch(url, {
            method: init.method,
            headers: init.headers,
            signal: controller.signal,
        });
        let json = null;
        try {
            json = await response.json();
        }
        catch {
            json = null;
        }
        return { ok: response.ok, status: response.status, json };
    }
    catch {
        return { ok: false, status: 0, json: null };
    }
    finally {
        clearTimeout(timeout);
    }
};
/**
 * Derives the backend origin from the configured sync endpoint, which is
 * normalized to `${origin}/api/v1/sync/projections`.
 */
function originFromSyncEndpoint(endpointUrl) {
    return new URL(endpointUrl).origin;
}
/**
 * Attaches the per-device transport signature (Decision 2): the device signs
 * the request's HMAC signature value, transitively binding the whole request.
 * Without a signer (or key) the header is omitted and the backend rejects a
 * paired device — fail-closed.
 */
async function withDeviceSignature(headers, deviceId, deviceSigner) {
    if (!deviceSigner) {
        return headers;
    }
    const hmacSignature = headers["X-PatchTrail-Sync-Signature"];
    if (!hmacSignature) {
        return headers;
    }
    const deviceSignature = await deviceSigner(deviceId, hmacSignature);
    if (!deviceSignature) {
        return headers;
    }
    return { ...headers, "X-PatchTrail-Device-Signature": deviceSignature };
}
function commandPullPath(deviceId) {
    return `/api/v1/sync/devices/${deviceId}/commands/pending`;
}
function commandAckPath(deviceId, commandId) {
    return `/api/v1/sync/devices/${deviceId}/commands/${commandId}/ack`;
}
function isRecord(value) {
    return typeof value === "object" && value !== null && !Array.isArray(value);
}
/**
 * Parses the backend pull response fail-closed. Unknown shapes yield an empty
 * list rather than throwing, so a malformed response is a no-op, not a crash.
 */
function parsePulledCommands(json) {
    if (!isRecord(json) || !Array.isArray(json.commands)) {
        return [];
    }
    const out = [];
    for (const entry of json.commands) {
        if (!isRecord(entry))
            continue;
        const commandId = entry.commandId;
        const commandType = entry.commandType;
        const payloadJson = entry.payloadJson;
        if (typeof commandId === "string" &&
            commandId.length > 0 &&
            typeof commandType === "string" &&
            commandType.length > 0 &&
            typeof payloadJson === "string") {
            out.push({ commandId, commandType, payloadJson });
        }
    }
    return out;
}
function configReady(config) {
    return (config.enabled &&
        typeof config.endpointUrl === "string" &&
        typeof config.signingKey === "string" &&
        typeof config.keyId === "string" &&
        typeof config.audience === "string");
}
/**
 * Pulls pending commands for every registered device once, ingesting and
 * acknowledging each. No-op (with a finite skip reason) when sync is disabled
 * or the device has not been registered yet.
 */
export async function pollCommandsOnce(store, config, options = {}) {
    const result = {
        enabled: config.enabled,
        pulled: 0,
        ingested: 0,
        acked: 0,
        failed: 0,
    };
    if (!configReady(config)) {
        result.skippedReason = "sync_disabled";
        return result;
    }
    const devices = store.listDevices();
    if (devices.length === 0) {
        result.skippedReason = "no_registered_device";
        return result;
    }
    const requester = options.requester ?? DEFAULT_REQUESTER;
    const origin = originFromSyncEndpoint(config.endpointUrl);
    for (const device of devices) {
        const pullPath = commandPullPath(device.id);
        const pullHeaders = await withDeviceSignature(buildSignedRequestHeaders({
            method: "GET",
            path: pullPath,
            audience: config.audience,
            keyId: config.keyId,
            workspaceId: device.workspaceId,
            body: "",
            signingKey: config.signingKey,
        }), device.id, options.deviceSigner);
        let pull;
        try {
            pull = await requester(`${origin}${pullPath}`, {
                method: "GET",
                headers: pullHeaders,
            });
        }
        catch {
            result.failed += 1;
            continue;
        }
        if (!pull.ok) {
            result.failed += 1;
            continue;
        }
        const commands = parsePulledCommands(pull.json);
        result.pulled += commands.length;
        for (const command of commands) {
            // Idempotent ingest: receiveCommand uses INSERT OR IGNORE, so a command
            // redelivered before its ack lands is stored exactly once.
            try {
                store.receiveCommand({
                    commandId: command.commandId,
                    commandType: command.commandType,
                    payloadJson: command.payloadJson,
                });
                result.ingested += 1;
            }
            catch {
                result.failed += 1;
                continue;
            }
            const ackPath = commandAckPath(device.id, command.commandId);
            const ackHeaders = await withDeviceSignature(buildSignedRequestHeaders({
                method: "POST",
                path: ackPath,
                audience: config.audience,
                keyId: config.keyId,
                workspaceId: device.workspaceId,
                body: "",
                signingKey: config.signingKey,
            }), device.id, options.deviceSigner);
            try {
                const ack = await requester(`${origin}${ackPath}`, {
                    method: "POST",
                    headers: ackHeaders,
                });
                if (ack.ok) {
                    result.acked += 1;
                }
                else {
                    result.failed += 1;
                }
            }
            catch {
                result.failed += 1;
            }
        }
    }
    return result;
}
