import { groupBy } from "../../../../shared/src/arrays.js";
import { assert } from "../../../../shared/src/asserts.js";
import { must } from "../../../../shared/src/must.js";
import { Queue } from "../../../../shared/src/queue.js";
import * as v from "../../../../shared/src/valita.js";
import { ErrorKind } from "../../../../zero-protocol/src/error-kind.js";
import { pushResponseSchema, } from "../../../../zero-protocol/src/push.js";
import {} from "../../config/zero-config.js";
import { fetchFromAPIServer, compileUrlPattern } from "../../custom/fetch.js";
import { getOrCreateCounter } from "../../observability/metrics.js";
import { recordMutation } from "../../server/anonymous-otel-start.js";
import { ErrorForClient } from "../../types/error-for-client.js";
import { upstreamSchema } from "../../types/shards.js";
import { Subscription } from "../../types/subscription.js";
/**
 * Receives push messages from zero-client and forwards
 * them the the user's API server.
 *
 * If the user's API server is taking too long to process
 * the push, the PusherService will add the push to a queue
 * and send pushes in bulk the next time the user's API server
 * is available.
 *
 * - One PusherService exists per client group.
 * - Mutations for a given client are always sent in-order
 * - Mutations for different clients in the same group may be interleaved
 */
export class PusherService {
    id;
    #pusher;
    #queue;
    #pushConfig;
    #upstream;
    #config;
    #stopped;
    #refCount = 0;
    #isStopped = false;
    constructor(upstream, appConfig, pushConfig, lc, clientGroupID) {
        this.#config = appConfig;
        this.#upstream = upstream;
        this.#queue = new Queue();
        this.#pusher = new PushWorker(appConfig, lc, pushConfig.url, pushConfig.apiKey, this.#queue);
        this.id = clientGroupID;
        this.#pushConfig = pushConfig;
    }
    get pushURL() {
        return this.#pusher.pushURL[0];
    }
    initConnection(clientID, wsID, userPushURL) {
        return this.#pusher.initConnection(clientID, wsID, userPushURL);
    }
    enqueuePush(clientID, push, jwt, httpCookie) {
        if (!this.#pushConfig.forwardCookies) {
            httpCookie = undefined; // remove cookies if not forwarded
        }
        this.#queue.enqueue({ push, jwt, clientID, httpCookie });
        return {
            type: 'ok',
        };
    }
    async ackMutationResponses(upToID) {
        // delete the relevant rows from the `mutations` table
        const sql = this.#upstream;
        await sql `DELETE FROM ${sql(upstreamSchema({
            appID: this.#config.app.id,
            shardNum: this.#config.shard.num,
        }))}.mutations WHERE "clientGroupID" = ${this.id} AND "clientID" = ${upToID.clientID} AND "mutationID" <= ${upToID.id}`;
    }
    ref() {
        assert(!this.#isStopped, 'PusherService is already stopped');
        ++this.#refCount;
    }
    unref() {
        assert(!this.#isStopped, 'PusherService is already stopped');
        --this.#refCount;
        if (this.#refCount <= 0) {
            void this.stop();
        }
    }
    hasRefs() {
        return this.#refCount > 0;
    }
    run() {
        this.#stopped = this.#pusher.run();
        return this.#stopped;
    }
    stop() {
        if (this.#isStopped) {
            return must(this.#stopped, 'Stop was called before `run`');
        }
        this.#isStopped = true;
        this.#queue.enqueue('stop');
        return must(this.#stopped, 'Stop was called before `run`');
    }
}
/**
 * Awaits items in the queue then drains and sends them all
 * to the user's API server.
 */
class PushWorker {
    #pushURLs;
    #pushURLPatterns;
    #apiKey;
    #queue;
    #lc;
    #config;
    #clients;
    #userPushURL;
    #customMutations = getOrCreateCounter('mutation', 'custom', 'Number of custom mutations processed');
    #pushes = getOrCreateCounter('mutation', 'pushes', 'Number of pushes processed by the pusher');
    constructor(config, lc, pushURL, apiKey, queue) {
        this.#pushURLs = pushURL;
        this.#lc = lc.withContext('component', 'pusher');
        this.#pushURLPatterns = pushURL.map(compileUrlPattern);
        this.#apiKey = apiKey;
        this.#queue = queue;
        this.#config = config;
        this.#clients = new Map();
    }
    get pushURL() {
        return this.#pushURLs;
    }
    /**
     * Returns a new downstream stream if the clientID,wsID pair has not been seen before.
     * If a clientID already exists with a different wsID, that client's downstream is cancelled.
     */
    initConnection(clientID, wsID, userPushURL) {
        const existing = this.#clients.get(clientID);
        if (existing && existing.wsID === wsID) {
            // already initialized for this socket
            throw new Error('Connection was already initialized');
        }
        // client is back on a new connection
        if (existing) {
            existing.downstream.cancel();
        }
        // Handle client group level URL parameters
        if (this.#userPushURL === undefined) {
            // First client in the group - store its URL
            this.#userPushURL = userPushURL;
        }
        else {
            // Validate that subsequent clients have compatible parameters
            if (this.#userPushURL !== userPushURL) {
                this.#lc.error?.('Client provided different mutate parameters than client group', {
                    clientID,
                    clientURL: userPushURL,
                    clientGroupURL: this.#userPushURL,
                });
            }
        }
        const downstream = Subscription.create({
            cleanup: () => {
                this.#clients.delete(clientID);
            },
        });
        this.#clients.set(clientID, { wsID, downstream });
        return downstream;
    }
    async run() {
        for (;;) {
            const task = await this.#queue.dequeue();
            const rest = this.#queue.drain();
            const [pushes, terminate] = combinePushes([task, ...rest]);
            for (const push of pushes) {
                const response = await this.#processPush(push);
                await this.#fanOutResponses(response);
            }
            if (terminate) {
                break;
            }
        }
    }
    /**
     * 1. If the entire `push` fails, we send the error to relevant clients.
     * 2. If the push succeeds, we look for any mutation failure that should cause the connection to terminate
     *  and terminate the connection for those clients.
     */
    #fanOutResponses(response) {
        const responses = [];
        const connectionTerminations = [];
        // if the entire push failed, send that to the client.
        if ('error' in response) {
            this.#lc.warn?.('The server behind ZERO_PUSH_URL returned a push error.', response);
            const groupedMutationIDs = groupBy(response.mutationIDs ?? [], m => m.clientID);
            for (const [clientID, mutationIDs] of groupedMutationIDs) {
                const client = this.#clients.get(clientID);
                if (!client) {
                    continue;
                }
                // We do not resolve mutations on the client if the push fails
                // as those mutations will be retried.
                if (response.error === 'unsupportedPushVersion' ||
                    response.error === 'unsupportedSchemaVersion') {
                    client.downstream.fail(new ErrorForClient({
                        kind: ErrorKind.InvalidPush,
                        message: response.error,
                    }));
                }
                else if (response.error === 'forClient') {
                    client.downstream.fail(response.cause);
                }
                else {
                    responses.push(client.downstream.push([
                        'pushResponse',
                        {
                            ...response,
                            mutationIDs,
                        },
                    ]).result);
                }
            }
        }
        else {
            // Look for mutations results that should cause us to terminate the connection
            const groupedMutations = groupBy(response.mutations, m => m.id.clientID);
            for (const [clientID, mutations] of groupedMutations) {
                const client = this.#clients.get(clientID);
                if (!client) {
                    continue;
                }
                let failure;
                let i = 0;
                for (; i < mutations.length; i++) {
                    const m = mutations[i];
                    if ('error' in m.result) {
                        this.#lc.warn?.('The server behind ZERO_PUSH_URL returned a mutation error.', m.result);
                    }
                    if ('error' in m.result && m.result.error === 'oooMutation') {
                        failure = new ErrorForClient({
                            kind: ErrorKind.InvalidPush,
                            message: 'mutation was out of order',
                        });
                        break;
                    }
                }
                if (failure && i < mutations.length - 1) {
                    this.#lc.error?.('push-response contains mutations after a mutation which should fatal the connection');
                }
                if (failure) {
                    connectionTerminations.push(() => client.downstream.fail(failure));
                }
            }
        }
        connectionTerminations.forEach(cb => cb());
    }
    async #processPush(entry) {
        this.#customMutations.add(entry.push.mutations.length, {
            clientGroupID: entry.push.clientGroupID,
        });
        this.#pushes.add(1, {
            clientGroupID: entry.push.clientGroupID,
        });
        // Record custom mutations for telemetry
        recordMutation('custom', entry.push.mutations.length);
        const url = this.#userPushURL ??
            must(this.#pushURLs[0], 'ZERO_MUTATE_URL is not set');
        this.#lc.debug?.('pushing to', url, 'with', entry.push.mutations.length, 'mutations');
        try {
            const response = await fetchFromAPIServer(this.#lc, url, this.#pushURLPatterns, {
                appID: this.#config.app.id,
                shardNum: this.#config.shard.num,
            }, {
                apiKey: this.#apiKey,
                token: entry.jwt,
                cookie: entry.httpCookie,
            }, entry.push);
            if (!response.ok) {
                return {
                    error: 'http',
                    status: response.status,
                    details: await response.text(),
                    mutationIDs: entry.push.mutations.map(m => ({
                        id: m.id,
                        clientID: m.clientID,
                    })),
                };
            }
            const json = await response.json();
            try {
                return v.parse(json, pushResponseSchema);
            }
            catch (e) {
                this.#lc.error?.('failed to parse push response', JSON.stringify(json));
                throw e;
            }
        }
        catch (e) {
            const mutationIDs = entry.push.mutations.map(m => ({
                id: m.id,
                clientID: m.clientID,
            }));
            this.#lc.error?.('failed to push', e);
            if (e instanceof ErrorForClient) {
                return {
                    error: 'forClient',
                    cause: e,
                    mutationIDs,
                };
            }
            // We do not kill the pusher on error.
            // If the user's API server is down, the mutations will never be acknowledged
            // and the client will eventually retry.
            return {
                error: 'zeroPusher',
                details: String(e),
                mutationIDs,
            };
        }
    }
}
/**
 * Pushes for different clientIDs could theoretically be interleaved.
 *
 * In order to do efficient batching to the user's API server,
 * we collect all pushes for the same clientID into a single push.
 */
export function combinePushes(entries) {
    const pushesByClientID = new Map();
    function collect() {
        const ret = [];
        for (const entries of pushesByClientID.values()) {
            const composite = {
                ...entries[0],
                push: {
                    ...entries[0].push,
                    mutations: [],
                },
            };
            ret.push(composite);
            for (const entry of entries) {
                assertAreCompatiblePushes(composite, entry);
                composite.push.mutations.push(...entry.push.mutations);
            }
        }
        return ret;
    }
    for (const entry of entries) {
        if (entry === 'stop' || entry === undefined) {
            return [collect(), true];
        }
        const { clientID } = entry;
        const existing = pushesByClientID.get(clientID);
        if (existing) {
            existing.push(entry);
        }
        else {
            pushesByClientID.set(clientID, [entry]);
        }
    }
    return [collect(), false];
}
// These invariants should always be true for a given clientID.
// If they are not, we have a bug in the code somewhere.
function assertAreCompatiblePushes(left, right) {
    assert(left.clientID === right.clientID, 'clientID must be the same for all pushes');
    assert(left.jwt === right.jwt, 'jwt must be the same for all pushes with the same clientID');
    assert(left.push.schemaVersion === right.push.schemaVersion, 'schemaVersion must be the same for all pushes with the same clientID');
    assert(left.push.pushVersion === right.push.pushVersion, 'pushVersion must be the same for all pushes with the same clientID');
}
//# sourceMappingURL=pusher.js.map