import { BRIDGE_CAPABILITIES, PARHELIA_EDITING_BRIDGE_VERSION, createBridgeEnvelope, formatBridgeVersion, isAllowedOrigin, isBridgeMessageFromHost, isCompatibleBridgeVersion, isKnownBridgeEventName, negotiateCapabilities, } from "./protocol";
// Capabilities this editor knows how to consume. The session set is the
// intersection of this and what the host advertises in `ready`.
const EDITOR_SUPPORTED_CAPABILITIES = Object.values(BRIDGE_CAPABILITIES);
const DEFAULT_READY_TIMEOUT_MS = 30_000;
export class BridgeClient {
    iframe;
    editorOrigin;
    allowedHostOrigins;
    onEvent;
    onError;
    onReadyChange;
    readyTimeoutMs;
    readyTimeoutId;
    hostOrigin;
    connected = false;
    ready = false;
    status = "connecting";
    hostVersion;
    compatible;
    hostAdvertisedCapabilities = [];
    acknowledgedCapabilities = [];
    hostRuntime;
    lastError;
    helloIntervalId;
    pendingRequests = new Map();
    constructor(options) {
        this.iframe = options.iframe;
        this.editorOrigin = options.editorOrigin;
        this.allowedHostOrigins = options.allowedHostOrigins.filter(Boolean);
        this.onEvent = options.onEvent;
        this.onError = options.onError;
        this.onReadyChange = options.onReadyChange;
        this.readyTimeoutMs = options.readyTimeoutMs ?? DEFAULT_READY_TIMEOUT_MS;
    }
    connect() {
        if (this.connected)
            return;
        this.connected = true;
        window.addEventListener("message", this.handleMessage);
        this.armReadyTimeout();
        this.armHelloHandshake();
        logBridgeDiagnostic("client connected", {
            iframeSrc: this.iframe.getAttribute("src"),
            allowedHostOrigins: this.allowedHostOrigins,
            configuredHostOrigin: this.getConfiguredHostOrigin(),
        });
    }
    disconnect() {
        if (!this.connected)
            return;
        this.connected = false;
        this.ready = false;
        this.status = "connecting";
        this.compatible = undefined;
        this.hostVersion = undefined;
        this.hostAdvertisedCapabilities = [];
        this.acknowledgedCapabilities = [];
        this.hostRuntime = undefined;
        this.rejectPendingRequests(new Error("The Parhelia bridge disconnected before the host responded."));
        this.clearReadyTimeout();
        this.clearHelloHandshake();
        this.onReadyChange?.(false);
        window.removeEventListener("message", this.handleMessage);
        logBridgeDiagnostic("client disconnected");
    }
    isReady() {
        return this.ready;
    }
    supportsCapability(capability) {
        return this.ready && this.acknowledgedCapabilities.includes(capability);
    }
    sendCommand(name, payload, options) {
        const targetWindow = this.iframe.contentWindow;
        const targetOrigin = this.hostOrigin ?? this.getConfiguredHostOrigin();
        if (!targetWindow || !targetOrigin) {
            logBridgeDiagnostic("command skipped", {
                name,
                hasTargetWindow: Boolean(targetWindow),
                targetOrigin,
                iframeSrc: this.iframe.getAttribute("src"),
            });
            return false;
        }
        const message = createBridgeEnvelope(name, payload, {
            requestId: options?.requestId,
        });
        logBridgeMessage("parent -> host", message);
        targetWindow.postMessage(message, targetOrigin);
        return true;
    }
    requestCommand(name, payload, expectedEventName, options) {
        const requestId = options?.requestId ??
            extractPayloadRequestId(payload) ??
            createRequestId();
        const timeoutMs = options?.timeoutMs ?? 10_000;
        return new Promise((resolve, reject) => {
            const timeoutId = window.setTimeout(() => {
                this.pendingRequests.delete(requestId);
                reject(new Error(`Timed out waiting for Parhelia bridge event '${expectedEventName}'.`));
            }, timeoutMs);
            this.pendingRequests.set(requestId, {
                expectedEventName,
                resolve: (payload) => resolve(payload),
                reject,
                timeoutId,
            });
            const sent = this.sendCommand(name, payload, { requestId });
            if (!sent) {
                window.clearTimeout(timeoutId);
                this.pendingRequests.delete(requestId);
                reject(new Error(`Could not send Parhelia bridge command '${name}' to the host.`));
            }
        });
    }
    getEditorOrigin() {
        return this.editorOrigin;
    }
    getAcknowledgedCapabilities() {
        return [...this.acknowledgedCapabilities];
    }
    getDiagnostics() {
        return {
            status: this.status,
            editorVersion: PARHELIA_EDITING_BRIDGE_VERSION,
            hostVersion: this.hostVersion,
            compatible: this.compatible,
            hostOrigin: this.hostOrigin,
            hostAdvertisedCapabilities: [...this.hostAdvertisedCapabilities],
            acknowledgedCapabilities: [...this.acknowledgedCapabilities],
            runtime: this.hostRuntime,
            lastError: this.lastError,
        };
    }
    handleMessage = (event) => {
        if (event.source !== this.iframe.contentWindow) {
            if (isBridgeMessageFromHost(event.data)) {
                logBridgeDiagnostic("event ignored: source mismatch", {
                    origin: event.origin,
                    name: event.data.name,
                });
            }
            return;
        }
        const allowedOrigins = this.getAllowedOrigins();
        if (allowedOrigins.length > 0 &&
            !allowedOrigins.some((origin) => isAllowedOrigin(event.origin, [origin]))) {
            if (isBridgeMessageFromHost(event.data)) {
                logBridgeDiagnostic("event ignored: origin rejected", {
                    origin: event.origin,
                    allowedOrigins,
                    name: event.data.name,
                });
            }
            return;
        }
        // Recognize any well-formed bridge message from the host (even with an
        // unknown name), so newer events surface in diagnostics rather than being
        // silently dropped.
        if (!isBridgeMessageFromHost(event.data)) {
            return;
        }
        this.hostOrigin = event.origin;
        // Dispatch only known event names; log + ignore unrecognized ones.
        if (!isKnownBridgeEventName(event.data.name)) {
            logBridgeDiagnostic("event ignored: unknown name", {
                origin: event.origin,
                name: event.data.name,
                hostVersion: event.data.version,
            });
            return;
        }
        const bridgeEvent = event.data;
        logBridgeMessage("host -> parent", bridgeEvent);
        this.resolvePendingRequest(bridgeEvent);
        if (bridgeEvent.name === "ready") {
            this.handleReady(bridgeEvent, event.origin);
        }
        this.onEvent?.(bridgeEvent, this);
    };
    handleReady(event, origin) {
        this.clearHelloHandshake();
        const payload = event.payload;
        const hostVersion = getReadyVersion(event);
        this.hostVersion = hostVersion;
        this.hostAdvertisedCapabilities = Array.isArray(payload.capabilities)
            ? payload.capabilities
            : [];
        this.hostRuntime = payload.runtime;
        if (!isCompatibleBridgeVersion(hostVersion)) {
            this.clearReadyTimeout();
            this.ready = false;
            this.compatible = false;
            this.status = "incompatible";
            this.acknowledgedCapabilities = [];
            const message = `Editing host bridge is v${formatBridgeVersion(hostVersion)}; this editor expects v${formatBridgeVersion(PARHELIA_EDITING_BRIDGE_VERSION)}. Redeploy the bridge to ${origin}.`;
            this.lastError = { message, at: Date.now() };
            logBridgeDiagnostic("ready rejected: version mismatch", {
                hostVersion,
                expectedVersion: PARHELIA_EDITING_BRIDGE_VERSION,
                origin,
            });
            this.onError?.(new Error(message), this);
            return;
        }
        this.clearReadyTimeout();
        this.compatible = true;
        this.status = "ready";
        this.acknowledgedCapabilities = negotiateCapabilities(EDITOR_SUPPORTED_CAPABILITIES, this.hostAdvertisedCapabilities);
        this.ready = true;
        this.lastError = undefined;
        this.onReadyChange?.(true);
    }
    resolvePendingRequest(event) {
        const requestId = getEventRequestId(event);
        if (!requestId)
            return;
        const pending = this.pendingRequests.get(requestId);
        if (!pending || pending.expectedEventName !== event.name)
            return;
        window.clearTimeout(pending.timeoutId);
        this.pendingRequests.delete(requestId);
        pending.resolve(event.payload);
    }
    rejectPendingRequests(error) {
        for (const [, pending] of this.pendingRequests) {
            window.clearTimeout(pending.timeoutId);
            pending.reject(error);
        }
        this.pendingRequests.clear();
    }
    armReadyTimeout() {
        this.clearReadyTimeout();
        if (this.readyTimeoutMs <= 0)
            return;
        this.readyTimeoutId = window.setTimeout(() => {
            this.readyTimeoutId = undefined;
            if (this.ready)
                return;
            this.clearHelloHandshake();
            this.status = "unreachable";
            const message = "Couldn't reach the editing host bridge - it may be missing or outdated.";
            this.lastError = { message, at: Date.now() };
            this.onError?.(new Error(message), this);
        }, this.readyTimeoutMs);
    }
    clearReadyTimeout() {
        if (this.readyTimeoutId === undefined)
            return;
        window.clearTimeout(this.readyTimeoutId);
        this.readyTimeoutId = undefined;
    }
    armHelloHandshake() {
        this.clearHelloHandshake();
        // The host posts `ready` only once on load. If this client attached after
        // that (a reconnect or a late attach), it would never see `ready` while
        // still receiving the ongoing event stream. Proactively ask the host to
        // re-announce by sending `hello` until we receive a `ready`.
        this.sendHello();
        this.helloIntervalId = window.setInterval(() => {
            if (this.ready) {
                this.clearHelloHandshake();
                return;
            }
            this.sendHello();
        }, 300);
    }
    sendHello() {
        this.sendCommand("hello", {});
    }
    clearHelloHandshake() {
        if (this.helloIntervalId === undefined)
            return;
        window.clearInterval(this.helloIntervalId);
        this.helloIntervalId = undefined;
    }
    getAllowedOrigins() {
        const configuredOrigin = this.getConfiguredHostOrigin();
        const origins = [...this.allowedHostOrigins];
        if (configuredOrigin)
            origins.push(configuredOrigin);
        if (this.hostOrigin)
            origins.push(this.hostOrigin);
        return origins.filter((origin, index) => origin &&
            origins.findIndex((candidate) => candidate === origin) === index);
    }
    getConfiguredHostOrigin() {
        const src = this.iframe.getAttribute("src");
        if (!src)
            return undefined;
        try {
            const origin = new URL(src, window.location.href).origin;
            if (this.allowedHostOrigins.length === 0 ||
                this.allowedHostOrigins.some((allowed) => isAllowedOrigin(origin, [allowed]))) {
                return origin;
            }
        }
        catch {
            return undefined;
        }
        return undefined;
    }
}
function getReadyVersion(event) {
    return event.payload.version ?? event.version;
}
function extractPayloadRequestId(payload) {
    if (!payload || typeof payload !== "object" || !("requestId" in payload)) {
        return undefined;
    }
    const requestId = payload.requestId;
    return typeof requestId === "string" && requestId.length > 0
        ? requestId
        : undefined;
}
function getEventRequestId(event) {
    const payloadRequestId = extractPayloadRequestId(event.payload);
    if (payloadRequestId)
        return payloadRequestId;
    return event.requestId;
}
function createRequestId() {
    const cryptoObject = globalThis.crypto;
    if (cryptoObject?.randomUUID) {
        return cryptoObject.randomUUID();
    }
    return `bridge-request-${Date.now().toString(36)}-${Math.random()
        .toString(36)
        .slice(2)}`;
}
function isBridgeDebugEnabled() {
    try {
        const params = new URLSearchParams(window.location.search);
        if (params.get("parhelia_bridge_debug") === "1")
            return true;
        const stored = window.localStorage.getItem("parhelia:bridge:debug");
        return stored === "1" || stored === "true";
    }
    catch {
        return false;
    }
}
function logBridgeMessage(direction, message) {
    if (!shouldLogBridgeMessage(message))
        return;
    const safeMessage = redactSensitiveBridgeFields(message);
    const name = safeMessage &&
        typeof safeMessage === "object" &&
        "name" in safeMessage &&
        typeof safeMessage.name === "string"
        ? `: ${safeMessage.name}`
        : "";
    console.log(`[Parhelia bridge message] ${direction}${name}`, safeMessage);
    console.groupCollapsed(`[Parhelia bridge] ${direction}${name}`);
    console.log(safeMessage);
    console.groupEnd();
}
function shouldLogBridgeMessage(message) {
    if (!isBridgeDebugEnabled())
        return false;
    if (!isHighFrequencyBridgeMessage(message))
        return true;
    return isVerboseBridgeDebugEnabled();
}
function isHighFrequencyBridgeMessage(message) {
    if (!message || typeof message !== "object" || !("name" in message)) {
        return false;
    }
    return (message.name === "scrollChanged" ||
        message.name === "geometryUpdated" ||
        message.name === "domUpdated" ||
        message.name === "selectionChanged");
}
function isVerboseBridgeDebugEnabled() {
    try {
        const params = new URLSearchParams(window.location.search);
        if (params.get("parhelia_bridge_debug") === "verbose")
            return true;
        if (params.get("parhelia_bridge_verbose") === "1")
            return true;
        const stored = window.localStorage.getItem("parhelia:bridge:debug:verbose");
        return stored === "1" || stored === "true";
    }
    catch {
        return false;
    }
}
function logBridgeDiagnostic(message, details) {
    if (!isBridgeDebugEnabled())
        return;
    if (details === undefined) {
        console.debug(`[Parhelia bridge] ${message}`);
        return;
    }
    console.debug(`[Parhelia bridge] ${message}`, redactSensitiveBridgeFields(details));
}
function redactSensitiveBridgeFields(value) {
    if (Array.isArray(value)) {
        return value.map(redactSensitiveBridgeFields);
    }
    if (!value || typeof value !== "object") {
        return value;
    }
    const result = {};
    for (const [key, nestedValue] of Object.entries(value)) {
        if (/token|secret|signedContext|sessionId/i.test(key)) {
            result[key] = "[redacted]";
        }
        else {
            result[key] = redactSensitiveBridgeFields(nestedValue);
        }
    }
    return result;
}
//# sourceMappingURL=BridgeClient.js.map