import { EventEmitter } from "eventemitter3";
import { ChannelEvent } from "./types/ChannelEvent";

export type MagChannelState =
    | "initial"
    | "connected"
    | "exponentialBackoff"
    | "failed"
    | "closed";

export class MagEventChannel extends EventEmitter<{
    stateChange: MagChannelState;
    message: ChannelEvent;
    close: "cancelled";
}> {
    private readonly baseUrl: string;
    private attempts = 0;
    private readonly maxAttempts: number;
    private readonly token: string | null;
    private readonly backoffFactor: number;
    private readonly backoffBase: number;
    private readonly closePromise: Promise<"cancelled">;
    private _state: MagChannelState;

    public constructor(
        baseUrl: string,
        token: string | null,
        maxReconnectAttempts: number = 15,
        backoffFactor: number = 1.618,
        backoffBase: number = 500.0
    ) {
        super();

        this.baseUrl = baseUrl;
        this.token = token;
        this.maxAttempts = maxReconnectAttempts;
        this.backoffFactor = backoffFactor;
        this.backoffBase = backoffBase;
        this._state = "initial";
        this.updateState("initial");
        this.closePromise = new Promise((resolve) => {
            this.on("close", resolve);
        });
        this.connect().then();
    }

    public useFiltered<T extends ChannelEvent["type"]>(
        messageType: T,
        listener: (val: (ChannelEvent & { type: T })["body"]) => void
    ) {
        const cb = (val: ChannelEvent) => {
            if (val.type != messageType) return;

            listener((val as ChannelEvent & { type: T }).body);
        };
        this.on("message", cb);
        return cb;
    }

    public get state(): MagChannelState {
        return this._state;
    }

    private updateState(state: MagChannelState) {
        this._state = state;
        this.emit("stateChange", state);
    }

    private async connect() {
        if (this.attempts >= this.maxAttempts) {
            this.updateState("failed");
            return;
        }

        const authorization = this.token ? `Bearer ${this.token}` : undefined;
        const baseUrl = this.baseUrl.replace(/\/+$/, "");

        const response = await fetch(`${baseUrl}/streaming`, {
            method: "GET",
            headers: authorization ? { authorization } : {},
            credentials: "omit",
            cache: "no-cache",
        }).catch((e) => {
            console.error(e);
            return null;
        });

        if (
            response === null ||
            response.status >= 500 ||
            response.body === null
        ) {
            this.updateState("exponentialBackoff");
            setTimeout(
                () => this.connect(),
                this.backoffBase * Math.pow(this.backoffFactor, this.attempts)
            );
            this.attempts++;
            return;
        }

        if (response.status >= 400 && response.status < 500) {
            this.updateState("failed");
            return;
        }

        this.attempts = 0;

        const decoderStream = new TextDecoderStream();
        const reader = response.body.pipeThrough(decoderStream).getReader();
        this.updateState("connected");

        let buf = "";

        while (true) {
            const res = await Promise.race([reader.read(), this.closePromise]);

            if (res === "cancelled") break;

            if (res.done) {
                this.updateState("exponentialBackoff");
                setTimeout(
                    () => this.connect(),
                    this.backoffBase *
                    Math.pow(this.backoffFactor, this.attempts)
                );
                this.attempts++;
                break;
            }

            buf += res.value;

            const splitIndex = buf.indexOf("\n\n");
            if (splitIndex === -1) {
                continue;
            }

            const rawValue = buf.substring(0, splitIndex);
            buf = buf.substring(splitIndex + 2);

            if (rawValue.startsWith(":")) continue;

            const text = rawValue
                .split("\n")
                .filter((l) => l.startsWith("data: "))
                .map((l) => l.substring("data: ".length))
                .join("\n");

            if (!text) continue;

            const data = JSON.parse(text) as ChannelEvent;
            this.emit("message", data);
        }

        this.updateState("closed");
    }

    public async close() {
        this.emit("close", "cancelled");
    }
}
