import websocket from '@fastify/websocket';
import { LogContext } from '@rocicorp/logger';
import { IncomingMessage } from 'node:http';
import WebSocket from 'ws';
import { assert } from "../../../../shared/src/asserts.js";
import { must } from "../../../../shared/src/must.js";
import { pgClient } from "../../types/pg.js";
import {} from "../../types/processes.js";
import {} from "../../types/shards.js";
import { streamIn, streamOut } from "../../types/streams.js";
import { URLParams } from "../../types/url-params.js";
import { installWebSocketReceiver } from "../../types/websocket-handoff.js";
import { closeWithError, PROTOCOL_ERROR } from "../../types/ws.js";
import { HttpService } from "../http-service.js";
import { downstreamSchema, PROTOCOL_VERSION, } from "./change-streamer.js";
import { discoverChangeStreamerAddress } from "./schema/tables.js";
import { snapshotMessageSchema } from "./snapshot.js";
const MIN_SUPPORTED_PROTOCOL_VERSION = 1;
const SNAPSHOT_PATH_PATTERN = '/replication/:version/snapshot';
const CHANGES_PATH_PATTERN = '/replication/:version/changes';
const PATH_REGEX = /\/replication\/v(?<version>\d+)\/(changes|snapshot)$/;
const SNAPSHOT_PATH = `/replication/v${PROTOCOL_VERSION}/snapshot`;
const CHANGES_PATH = `/replication/v${PROTOCOL_VERSION}/changes`;
export class ChangeStreamerHttpServer extends HttpService {
    id = 'change-streamer-http-server';
    #changeStreamer;
    #backupMonitor;
    constructor(lc, config, opts, parent, changeStreamer, backupMonitor) {
        super('change-streamer-http-server', lc, opts, async (fastify) => {
            const websocketOptions = {};
            if (config.websocketCompression) {
                if (config.websocketCompressionOptions) {
                    try {
                        websocketOptions.perMessageDeflate = JSON.parse(config.websocketCompressionOptions);
                    }
                    catch (e) {
                        throw new Error(`Failed to parse ZERO_WEBSOCKET_COMPRESSION_OPTIONS: ${String(e)}. Expected valid JSON.`);
                    }
                }
                else {
                    websocketOptions.perMessageDeflate = true;
                }
            }
            await fastify.register(websocket, {
                options: websocketOptions,
            });
            fastify.get(CHANGES_PATH_PATTERN, { websocket: true }, this.#subscribe);
            fastify.get(SNAPSHOT_PATH_PATTERN, { websocket: true }, this.#reserveSnapshot);
            installWebSocketReceiver(lc, fastify.websocketServer, this.#receiveWebsocket, parent);
        });
        this.#changeStreamer = changeStreamer;
        this.#backupMonitor = backupMonitor;
    }
    #getBackupMonitor() {
        return must(this.#backupMonitor, 'replication-manager is not configured with a ZERO_LITESTREAM_BACKUP_URL');
    }
    // Called when receiving a web socket via the main dispatcher handoff.
    #receiveWebsocket = (ws, action, msg) => {
        switch (action) {
            case 'snapshot':
                return this.#reserveSnapshot(ws, msg);
            case 'changes':
                return this.#subscribe(ws, msg);
            default:
                closeWithError(this._lc, ws, `invalid action "${action}" received in handoff`);
                return;
        }
    };
    #reserveSnapshot = (ws, req) => {
        try {
            const url = new URL(req.url ?? '', req.headers.origin ?? 'http://localhost');
            checkProtocolVersion(url.pathname);
            const taskID = url.searchParams.get('taskID');
            if (!taskID) {
                throw new Error('Missing taskID in snapshot request');
            }
            const downstream = this.#getBackupMonitor().startSnapshotReservation(taskID);
            void streamOut(this._lc, downstream, ws);
        }
        catch (err) {
            closeWithError(this._lc, ws, err, PROTOCOL_ERROR);
        }
    };
    #subscribe = async (ws, req) => {
        try {
            const ctx = getSubscriberContext(req);
            const downstream = await this.#changeStreamer.subscribe(ctx);
            if (ctx.initial && ctx.taskID && this.#backupMonitor) {
                // Now that the change-streamer knows about the subscriber and watermark,
                // end the reservation to safely resume scheduling cleanup.
                this.#backupMonitor.endReservation(ctx.taskID);
            }
            void streamOut(this._lc, downstream, ws);
        }
        catch (err) {
            closeWithError(this._lc, ws, err, PROTOCOL_ERROR);
        }
    };
}
export class ChangeStreamerHttpClient {
    #lc;
    #shardID;
    #changeDB;
    #changeStreamerURI;
    constructor(lc, shardID, changeDB, changeStreamerURI) {
        this.#lc = lc;
        this.#shardID = shardID;
        // Create a pg client with a single short-lived connection for the purpose
        // of change-streamer discovery (i.e. ChangeDB as DNS).
        this.#changeDB = pgClient(lc, changeDB, {
            max: 1,
            ['idle_timeout']: 15,
            connection: { ['application_name']: 'change-streamer-discovery' },
        });
        this.#changeStreamerURI = changeStreamerURI;
    }
    async #resolveChangeStreamer(path) {
        let baseURL = this.#changeStreamerURI;
        if (!baseURL) {
            const address = await discoverChangeStreamerAddress(this.#shardID, this.#changeDB);
            if (!address) {
                throw new Error(`no change-streamer is running`);
            }
            baseURL = address.includes('://') ? `${address}/` : `ws://${address}/`;
        }
        const uri = new URL(path, baseURL);
        this.#lc.info?.(`connecting to change-streamer@${uri}`);
        return uri;
    }
    async reserveSnapshot(taskID) {
        const uri = await this.#resolveChangeStreamer(SNAPSHOT_PATH);
        const params = new URLSearchParams({ taskID });
        const ws = new WebSocket(uri + `?${params.toString()}`);
        return streamIn(this.#lc, ws, snapshotMessageSchema);
    }
    async subscribe(ctx) {
        const uri = await this.#resolveChangeStreamer(CHANGES_PATH);
        const params = getParams(ctx);
        const ws = new WebSocket(uri + `?${params.toString()}`);
        return streamIn(this.#lc, ws, downstreamSchema);
    }
}
export function getSubscriberContext(req) {
    const url = new URL(req.url ?? '', req.headers.origin ?? 'http://localhost');
    const protocolVersion = checkProtocolVersion(url.pathname);
    const params = new URLParams(url);
    return {
        protocolVersion,
        id: params.get('id', true),
        taskID: params.get('taskID', false),
        mode: params.get('mode', false) === 'backup' ? 'backup' : 'serving',
        replicaVersion: params.get('replicaVersion', true),
        watermark: params.get('watermark', true),
        initial: params.getBoolean('initial'),
    };
}
function checkProtocolVersion(pathname) {
    const match = PATH_REGEX.exec(pathname);
    if (!match) {
        throw new Error(`invalid path: ${pathname}`);
    }
    const v = Number(match.groups?.version);
    if (Number.isNaN(v) ||
        v > PROTOCOL_VERSION ||
        v < MIN_SUPPORTED_PROTOCOL_VERSION) {
        throw new Error(`Cannot service client at protocol v${v}. ` +
            `Supported protocols: [v${MIN_SUPPORTED_PROTOCOL_VERSION} ... v${PROTOCOL_VERSION}]`);
    }
    return v;
}
// This is called from the client-side (i.e. the replicator).
function getParams(ctx) {
    // The protocolVersion is hard-coded into the CHANGES_PATH.
    const { protocolVersion, ...stringParams } = ctx;
    assert(protocolVersion === PROTOCOL_VERSION, `replicator should be setting protocolVersion to ${PROTOCOL_VERSION}`);
    return new URLSearchParams({
        ...stringParams,
        taskID: ctx.taskID ? ctx.taskID : '',
        initial: ctx.initial ? 'true' : 'false',
    });
}
//# sourceMappingURL=change-streamer-http.js.map