/*!
 * Copyright (c) 2026 Vertex Software LLC. All rights reserved.
 */
import { Dimensions } from "@vertexvis/geometry";
import { currentDateAsProtoTimestamp, StreamApi, StreamRequestError, } from "@vertexvis/stream-api";
import { Async, Color, EventDispatcher, Mapper, Objects, Uri, } from "@vertexvis/utils";
import deepEqual from "fast-deep-equal";
import { parseAndValidateConfig } from "../config";
import { CustomError, SceneRenderError, WebsocketConnectionError, } from "../errors";
import { fromPbFrameOrThrow, fromPbReconnectResponseOrThrow, fromPbRefreshTokenResponseOrThrow, fromPbStartStreamResponseOrThrow, fromPbSyncTimeResponseOrThrow, toPbCameraType, toPbRGBi, toPbStreamAttributes, } from "../mappers";
import { acknowledgeFrameRequests } from "../rendering";
import { LoadableResource, SynchronizedClock, } from "../types";
import { retryIfNotAborted } from "./utils";
export class ViewerStream extends StreamApi {
    constructor(ws, opts = {}) {
        var _a, _b, _c, _d, _e;
        super(ws, { loggingEnabled: opts.loggingEnabled });
        this.state = { type: 'disconnected' };
        this.stateChanged = new EventDispatcher();
        this.dimensions = Dimensions.create(0, 0);
        this.streamAttributes = {};
        this.enableTemporalRefinement = (_a = opts.enableTemporalRefinement) !== null && _a !== void 0 ? _a : true;
        this.frameBgColor = Color.create(255, 255, 255);
        this.config = parseAndValidateConfig('platprod');
        this.options = {
            tokenRefreshOffsetInSeconds: (_b = opts.tokenRefreshOffsetInSeconds) !== null && _b !== void 0 ? _b : 30,
            offlineThresholdInSeconds: (_c = opts.offlineThresholdInSeconds) !== null && _c !== void 0 ? _c : 30,
            loadTimeoutInSeconds: (_d = opts.loadTimeoutInSeconds) !== null && _d !== void 0 ? _d : 15,
            enableTemporalRefinement: (_e = opts.enableTemporalRefinement) !== null && _e !== void 0 ? _e : true,
        };
    }
    isPaused() {
        return this.pausedState != null;
    }
    getState() {
        return this.state;
    }
    disconnect() {
        if (this.state.type !== 'disconnected' &&
            this.state.type !== 'connection-failed') {
            console.debug('Disconnecting websocket');
            this.state.connection.dispose();
            this.updateState({ type: 'disconnected' });
        }
    }
    async load(urn, clientId, deviceId, config = parseAndValidateConfig('platprod'), cameraType) {
        this.clientId = clientId;
        this.deviceId = deviceId;
        this.config = config;
        if (this.state.type === 'disconnected') {
            await this.loadIfDisconnected(urn, cameraType);
        }
        else if (this.state.type === 'connection-failed') {
            await this.loadIfDisconnected(urn, cameraType);
        }
        else {
            await this.loadIfConnectingOrConnected(urn, this.state, cameraType);
        }
        return this.state;
    }
    update(fields) {
        this.frameBgColor = fields.frameBgColor
            ? fields.frameBgColor
            : this.frameBgColor;
        if (fields.dimensions != null &&
            !deepEqual(fields.dimensions, this.dimensions)) {
            this.dimensions = fields.dimensions;
            this.ifState('connected', () => this.updateDimensions({ dimensions: this.getDimensions() }));
        }
        const streamAttributesAreDifferent = !deepEqual(this.streamAttributes, fields.streamAttributes);
        if (fields.streamAttributes != null && streamAttributesAreDifferent) {
            this.streamAttributes = fields.streamAttributes;
            this.ifState('connected', () => this.updateStream({
                streamAttributes: toPbStreamAttributesOrThrow(this.streamAttributes),
            }));
        }
        if (fields.enableTemporalRefinement != null &&
            fields.enableTemporalRefinement !== this.enableTemporalRefinement) {
            this.enableTemporalRefinement = fields.enableTemporalRefinement;
            if (this.state.type === 'connected') {
                this.closeAndReconnect(this.state);
            }
        }
    }
    pause() {
        if (this.state.type === 'connected') {
            this.pausedState = this.state;
            console.debug(`Stream paused [stream-id=${this.pausedState.streamId}, scene-id=${this.pausedState.sceneId}, scene-view-id=${this.pausedState.sceneViewId}]`);
            this.disconnect();
        }
    }
    async resume() {
        if (this.pausedState != null) {
            console.debug(`Stream resumed [stream-id=${this.pausedState.streamId}, scene-id=${this.pausedState.sceneId}, scene-view-id=${this.pausedState.sceneViewId}]`);
            await this.connectToExistingStream(this.pausedState);
            this.pausedState = undefined;
        }
    }
    async loadIfConnectingOrConnected(urn, state, cameraType) {
        var _a;
        const { resource: pResource, subResource: pSubResource } = state.resource;
        const resource = LoadableResource.fromUrn(urn);
        const hasResourceChanged = !Objects.isEqual(pResource, resource.resource);
        const hasSubResourceChanged = !Objects.isEqual(pSubResource, resource.subResource);
        const isConnecting = state.type === 'connecting' || state.type === 'reconnecting';
        const isConnected = state.type === 'connected';
        if (hasResourceChanged || (isConnecting && hasSubResourceChanged)) {
            this.disconnect();
            return this.loadIfDisconnected(urn);
        }
        else if (isConnected &&
            hasSubResourceChanged &&
            ((_a = resource.subResource) === null || _a === void 0 ? void 0 : _a.type) === 'scene-view-state') {
            const suppliedIdQuery = resource.queries.find((q) => q.type === 'supplied-id');
            const payload = {
                ...(resource.subResource.id != null
                    ? { sceneViewStateId: { hex: resource.subResource.id } }
                    : {}),
                ...(suppliedIdQuery != null
                    ? { sceneViewStateSuppliedId: { value: suppliedIdQuery.id } }
                    : {}),
                ...(cameraType != null
                    ? { cameraType: toPbCameraTypeOrThrow(cameraType) }
                    : {}),
            };
            await this.loadSceneViewState(payload);
            this.updateState({ ...state, resource });
        }
    }
    async loadIfDisconnected(urn, cameraType) {
        try {
            await this.connectWithNewStream(LoadableResource.fromUrn(urn), cameraType);
        }
        catch (e) {
            if (e instanceof CustomError) {
                this.updateState({
                    type: 'connection-failed',
                    message: `Cannot load scene. ${e.message}`,
                    error: e,
                });
            }
            else if (e instanceof StreamRequestError) {
                this.updateState({
                    type: 'connection-failed',
                    message: `Cannot load scene. Stream request failed to start stream.`,
                    error: e,
                });
            }
            else {
                this.updateState({
                    type: 'connection-failed',
                    message: `Cannot load scene for unknown reason. See console logs.`,
                    error: e,
                });
            }
            throw e;
        }
    }
    connectWithNewStream(resource, cameraType) {
        return this.openWebsocketStream(resource, 'connecting', () => this.requestNewStream(resource, cameraType));
    }
    connectToExistingStream(state) {
        return this.openWebsocketStream(state.resource, 'reconnecting', () => this.requestReconnectStream(state), { maxRetries: Number.POSITIVE_INFINITY });
    }
    async openWebsocketStream(resource, type, requestStream, { maxRetries = 3 } = {}) {
        var _a;
        const descriptor = getWebsocketDescriptor(getWebsocketUri(this.config, resource.resource, this.clientId, this.deviceId));
        console.debug(`Initiating WS connection [uri=${descriptor.url}]`);
        // Ensure that any previous attempt to initiate a WebSocket connection has been
        // aborted prior to attempting to create a new connection. This prevents scenarios
        // where a previously initiated connection attempt is still in the `connecting`
        // state and gets closed, and the `Async.retry` utility attempts to connect again.
        (_a = this.abortController) === null || _a === void 0 ? void 0 : _a.abort();
        const newAbortController = new AbortController();
        this.abortController = newAbortController;
        const settings = getStreamSettings(this.config);
        this.updateState({
            type,
            resource,
            connection: {
                dispose: () => {
                    this.dispose();
                    newAbortController.abort();
                },
            },
        });
        const connection = await Async.abort(newAbortController.signal, retryIfNotAborted(newAbortController.signal, () => this.connect(descriptor, settings), {
            dispose: () => {
                // Intentional empty dispose to prevent typing mismatches.
            },
        }, {
            maxRetries,
            delaysInMs: ViewerStream.WS_RECONNECT_DELAYS,
        })).catch((e) => {
            throw new WebsocketConnectionError('Websocket connection failed.', e instanceof Error ? e : undefined);
        });
        if (!connection.aborted) {
            return this.requestNewOrExistingStream(resource, connection.result, requestStream);
        }
        else {
            this.updateState({ type: 'disconnected' });
        }
    }
    async requestNewOrExistingStream(resource, connection, requestStream) {
        const pendingClock = this.requestClock();
        const stream = await requestStream();
        console.debug(`Stream connected [stream-id=${stream.streamId}, scene-id=${stream.sceneId}, scene-view-id=${stream.sceneViewId}]`);
        const onRequest = this.onRequest((msg) => {
            const req = msg.request.drawFrame;
            if (req != null) {
                const frame = fromPbFrameOrThrow(stream.worldOrientation)(req);
                if (this.state.type === 'connected') {
                    if (frame.depthBufferBytes != null ||
                        this.state.frame.temporalRefinementCorrelationId !==
                            frame.temporalRefinementCorrelationId) {
                        this.updateState({
                            ...this.state,
                            frame: frame,
                            fallbackDepthBufferBytes: frame.depthBufferBytes,
                        });
                    }
                    else {
                        this.updateState({
                            ...this.state,
                            frame: frame.copy({
                                depthBufferBytes: this.state.fallbackDepthBufferBytes,
                            }),
                        });
                    }
                }
            }
        });
        const reconnect = this.reconnectWhenNeeded();
        const refreshToken = this.refreshTokenWhenExpired(stream.token);
        const acknowledgeFrameRequests = this.acknowledgeFrameRequests();
        const frame = stream.frame == null
            ? await this.waitForFrame(stream.worldOrientation, this.options.loadTimeoutInSeconds)
            : stream.frame;
        const clock = await pendingClock;
        console.debug(`Synchronized clocks [local-time=${clock.knownLocalTime.toISOString()}, remote-time=${clock.knownRemoteTime.toISOString()}]`);
        this.updateState({
            type: 'connected',
            connection: {
                dispose: () => {
                    reconnect.dispose();
                    onRequest.dispose();
                    refreshToken.dispose();
                    acknowledgeFrameRequests.dispose();
                    connection.dispose();
                },
            },
            resource,
            streamId: stream.streamId,
            deviceId: stream.deviceId,
            sceneId: stream.sceneId,
            sceneViewId: stream.sceneViewId,
            worldOrientation: stream.worldOrientation,
            token: stream.token,
            frame,
            clock,
            fallbackDepthBufferBytes: undefined,
        });
    }
    async requestNewStream(resource, cameraType) {
        var _a, _b;
        const suppliedIdQuery = resource.queries.find((q) => q.type === 'supplied-id');
        const res = fromPbStartStreamResponseOrThrow(await this.startStream({
            streamKey: { value: resource.resource.id },
            dimensions: this.getDimensions(),
            frameBackgroundColor: toPbColorOrThrow(this.frameBgColor),
            clientSupportsTemporalRefinement: this.enableTemporalRefinement,
            streamAttributes: toPbStreamAttributesOrThrow(this.streamAttributes),
            sceneViewStateId: ((_a = resource.subResource) === null || _a === void 0 ? void 0 : _a.type) === 'scene-view-state' &&
                resource.subResource.id != null
                ? { hex: resource.subResource.id }
                : undefined,
            sceneViewStateSuppliedId: ((_b = resource.subResource) === null || _b === void 0 ? void 0 : _b.type) === 'scene-view-state' &&
                suppliedIdQuery != null
                ? { value: suppliedIdQuery.id }
                : undefined,
            cameraType: cameraType != null ? toPbCameraTypeOrThrow(cameraType) : undefined,
        }));
        return {
            resource: resource,
            streamId: res.streamId,
            sceneId: res.sceneId,
            sceneViewId: res.sceneViewId,
            deviceId: res.sessionId,
            token: res.token,
            worldOrientation: res.worldOrientation,
            frame: undefined,
            fallbackDepthBufferBytes: undefined,
        };
    }
    async requestReconnectStream(state) {
        const res = fromPbReconnectResponseOrThrow(await this.reconnect({
            streamId: { hex: state.streamId },
            dimensions: this.getDimensions(),
            frameBackgroundColor: toPbColorOrThrow(this.frameBgColor),
            streamAttributes: toPbStreamAttributesOrThrow(this.streamAttributes),
            clientSupportsTemporalRefinement: this.enableTemporalRefinement,
        }));
        return { ...state, token: res.token };
    }
    async requestClock() {
        const remoteTime = fromPbSyncTimeResponseOrThrow(await this.syncTime({
            requestTime: currentDateAsProtoTimestamp(),
        }));
        return new SynchronizedClock(remoteTime);
    }
    reconnectWhenNeeded() {
        const whenDisconnected = this.onClose(() => {
            if (this.state.type === 'connected') {
                this.closeAndReconnect(this.state);
            }
        });
        const whenRequested = this.onRequest((msg) => {
            const isReconnectMsg = msg.request.gracefulReconnection != null;
            if (isReconnectMsg && this.state.type === 'connected') {
                console.debug('Received request for graceful reconnect. Closing connection and attempting reconnect.');
                this.closeAndReconnect(this.state);
            }
        });
        const whenOffline = this.reconnectWhenOffline();
        return {
            dispose: () => {
                whenDisconnected.dispose();
                whenRequested.dispose();
                whenOffline.dispose();
            },
        };
    }
    refreshTokenWhenExpired(token) {
        let timer;
        const startTimer = (token) => {
            const { tokenRefreshOffsetInSeconds } = this.options;
            const ms = token.remainingTimeInMs(tokenRefreshOffsetInSeconds);
            timer = window.setTimeout(async () => {
                const res = await this.refreshToken();
                const newToken = fromPbRefreshTokenResponseOrThrow(res);
                startTimer(newToken);
                if (this.state.type === 'connected') {
                    this.updateState({ ...this.state, token: newToken });
                }
            }, ms);
        };
        startTimer(token);
        return { dispose: () => clearTimeout(timer) };
    }
    reconnectWhenOffline() {
        let timer;
        const clearTimer = () => window.clearTimeout(timer);
        const restartTimer = () => {
            clearTimer();
            const delayInSec = this.options.offlineThresholdInSeconds;
            console.debug(`Detected that host is offline. Will attempt reconnect in ${delayInSec}s.`);
            timer = window.setTimeout(() => {
                if (this.state.type === 'connected') {
                    this.closeAndReconnect(this.state);
                }
            }, delayInSec * 1000);
        };
        const handleOnline = () => {
            console.debug('Detected that host is online.');
            clearTimer();
        };
        const handleOffline = () => restartTimer();
        window.addEventListener('offline', handleOffline);
        window.addEventListener('online', handleOnline);
        return {
            dispose: () => {
                clearTimer();
                window.removeEventListener('offline', handleOffline);
                window.removeEventListener('online', handleOnline);
            },
        };
    }
    closeAndReconnect(state) {
        state.connection.dispose();
        return this.connectToExistingStream(state);
    }
    async waitForFrame(worldOrientation, timeoutInSeconds) {
        let disposable;
        try {
            return await Async.timeout(timeoutInSeconds * 1000, new Promise((resolve) => {
                disposable = this.onRequest((msg) => {
                    try {
                        const req = msg.request.drawFrame;
                        if (req != null) {
                            const frame = fromPbFrameOrThrow(worldOrientation)(req);
                            resolve(frame);
                        }
                    }
                    finally {
                        disposable === null || disposable === void 0 ? void 0 : disposable.dispose();
                    }
                });
            }));
        }
        catch (e) {
            throw new SceneRenderError(`Frame timed out after ${timeoutInSeconds}s`, e instanceof Error ? e : undefined);
        }
        finally {
            disposable === null || disposable === void 0 ? void 0 : disposable.dispose();
        }
    }
    acknowledgeFrameRequests() {
        return this.onRequest(acknowledgeFrameRequests(this, () => this.state.type === 'connected' ? this.state.clock : undefined));
    }
    updateState(state) {
        if (this.state !== state) {
            this.state = state;
            this.stateChanged.emit(this.state);
        }
    }
    getDimensions() {
        if (Dimensions.area(this.dimensions) === 0) {
            // Ensure we always request at least a 1-pixel frame, even if the dimensions
            // haven't been set higher than zero.
            return Dimensions.create(1, 1);
        }
        return this.dimensions;
    }
    ifState(state, f) {
        if (this.state.type === state) {
            return f();
        }
    }
    onStateChanged(listener) {
        return this.stateChanged.on(listener);
    }
}
ViewerStream.WS_RECONNECT_DELAYS = [0, 1000, 1000, 5000];
const toPbStreamAttributesOrThrow = Mapper.ifInvalidThrow(toPbStreamAttributes);
const toPbColorOrThrow = Mapper.ifInvalidThrow(toPbRGBi);
const toPbCameraTypeOrThrow = Mapper.ifInvalidThrow(toPbCameraType);
function getStreamSettings(config) {
    return {
        EXPERIMENTAL_frameDelivery: {
            ...config.EXPERIMENTAL_frameDelivery,
            rateLimitingEnabled: config.flags.throttleFrameDelivery,
        },
        EXPERIMENTAL_adaptiveRendering: {
            ...config.EXPERIMENTAL_adaptiveRendering,
            enabled: config.flags.adaptiveRendering,
        },
        EXPERIMENTAL_qualityOfService: {
            ...config.EXPERIMENTAL_qualityOfService,
        },
    };
}
function getWebsocketDescriptor(uri) {
    return {
        url: Uri.toString(uri),
        protocols: ['ws.vertexvis.com'],
    };
}
function getWebsocketUri(config, resource, clientId, deviceId) {
    if (clientId != null) {
        return Uri.appendPath(Uri.toString(Uri.parseAndAddParams('/ws', {
            clientId,
            deviceId,
        })), Uri.parse(config.network.renderingHost));
    }
    else {
        return Uri.appendPath(`/stream-keys/${resource.id}/session`, Uri.parse(config.network.renderingHost));
    }
}
//# sourceMappingURL=stream.js.map
