"use strict";

const DNS_RE = /^([a-z0-9_-]+\.)+[a-z]{2,}$/i;
const UUID_RE = /^[0-9a-f]{8}(-[0-9a-f]{4}){3}-[0-9a-f]{12}$/i;

const WEBSOCKET_KEEP_ALIVE = 5 * 60;  // [sec]

type DomainJson = {
    name: string,
    id: string,
}

function isDomainJson(json: any): json is DomainJson {
    return (
        json !== null && typeof json === 'object' && json.constructor === Object &&
        typeof json.name === 'string' && typeof json.id === 'string' &&
        DNS_RE.test(json.name) && UUID_RE.test(json.id)
    );
}

type DomainServerJson = {
    host: string,
    id: string,
    priority: number,
    weight: number,
    protocols: {
        https?: number | null,
        wss?: number | null,
        http?: number | null,
        ws?: number | null,
        udp?: number | null
    }
}

function isDomainServerJson(json: any): json is DomainServerJson {
    if (
        json === null || typeof json !== 'object' || json.constructor !== Object ||
        typeof json.host !== 'string' || typeof json.id !== 'string' || typeof json.priority !== 'number' ||
        typeof json.weight !== 'number' ||
        json.protocols === null || typeof json.protocols !== 'object' || json.protocols.constructor !== Object ||
        !DNS_RE.test(json.host) || !UUID_RE.test(json.id) || json.priority < 0 || json.weight < 0
    ) return false;


    return (
        (json.protocols.https === undefined || json.protocols.https === null || (
            typeof json.protocols.https === 'number' && json.protocols.https > 0 && json.protocols.https <= 65535
        )) && (json.protocols.wss === undefined || json.protocols.wss === null || (
            typeof json.protocols.wss === 'number' && json.protocols.wss > 0 && json.protocols.wss <= 65535
        )) && (json.protocols.http === undefined || json.protocols.http === null || (
            typeof json.protocols.http === 'number' && json.protocols.http > 0 && json.protocols.http <= 65535
        )) && (json.protocols.ws === undefined || json.protocols.ws === null || (
            typeof json.protocols.ws === 'number' && json.protocols.ws > 0 && json.protocols.ws <= 65535
        )) && (json.protocols.udp === undefined || json.protocols.udp === null || (
            typeof json.protocols.udp === 'number' && json.protocols.udp > 0 && json.protocols.udp <= 65535
        ))
    );
}

type WellKnownJson = {
    domain: DomainJson,
    domain_servers: DomainServerJson[],
}

function isWellKnownJson(json: any): json is WellKnownJson {
    if (
        json === null || typeof json !== 'object' || json.constructor !== Object ||
        !json.hasOwnProperty('domain') || !json.hasOwnProperty('domain_servers') ||
        json.domain_servers === null || typeof json.domain_servers !== 'object' ||
        json.domain_servers.constructor !== Array
    ) return false;

    return isDomainJson(json.domain) && json.domain_servers.every((item: unknown) => isDomainServerJson(item));
}

type OutputEnvelopeJson<T> = {
    status: string,
    request_nr: number,
    data: T,
    action?: string | null,
    error?: {
        code: string,
        message: string | null,
        description: string | null,
    } | null,
}

function isOutputEnvelopeJson<T>(json: any): json is OutputEnvelopeJson<T> {
    if (
        json === null || typeof json !== 'object' || json.constructor !== Object ||
        typeof json.status !== 'string' || typeof json.request_nr !== 'number' || json.request_nr < 0 ||
        (typeof json.action !== 'string' && json.action !== null && json.action === undefined) ||
        json.data === null || typeof json.data !== 'object' || json.data.constructor !== Object
    ) return false;

    if (json.error !== null && json.error !== undefined) {
        if (
            typeof json.error !== 'object' || json.error.constructor !== Object ||
            typeof json.error.code !== 'string' ||
            (typeof json.error.message !== 'string' && json.error.message !== null) ||
            (typeof json.error.description !== 'string' && json.error.description !== null)
        ) return false;
    }

    return true;
}

type EventPushEnvelopeJson = {
    events: EventJson[],
}

function isEventPushEnvelopeJson(json: any): json is EventPushEnvelopeJson {
    if (
        json === null || typeof json !== 'object' || json.constructor !== Object ||
        json.events === null || typeof json.events !== 'object' || json.events.constructor !== Array
    ) return false;

    return json.events.every((item: unknown) => isEventJson(item));
}

type EventJson = {
    id: string,
    data: {
        message: string,
    },
}

function isEventJson(json: any): json is EventJson {
    return !(
        json === null || typeof json !== 'object' || json.constructor !== Object ||
        typeof json.id !== 'string' || !UUID_RE.test(json.id) ||
        json.data === null || typeof json.data !== 'object' || json.data.constructor !== Object ||
        typeof json.data.message !== 'string'
    );
}

export class Domain {
    name: string;
    id: string;
    servers: DomainServer[];
    invalidServers: DomainServer[];

    toString() {
        return `[${this.name}]`;
    }

    constructor(json: DomainJson, domainServers: DomainServerJson[]) {
        this.name = json.name;
        this.id = json.id;
        this.servers = [];
        for (const domainServer of domainServers) {
            this.servers.push(new DomainServer(domainServer));
        }
        this.invalidServers = [];
    }

    static async fromName(domainName: string): Promise<Domain> {
        const controller = new AbortController();
        const timer = setTimeout(() => controller.abort(), 2000);

        const response = await fetch(`https://${domainName}/.well-known/usimp.json`, {
            redirect: "manual",
            signal: controller.signal,
        });

        clearTimeout(timer);
        if (!response.ok) {
            throw new Error("Invalid response");
        }

        const data = await response.json();
        if (isWellKnownJson(data)) {
            return new Domain(data.domain, data.domain_servers);
        } else {
            throw Error("Invalid contents in usimp.json");
        }
    }

    chooseDomainServer(): DomainServer {
        if (this.servers.length === 0) throw new Error("No domain servers specified");

        const servers = this.servers.filter(srv => !this.invalidServers.map(srv => srv.id).includes(srv.id));
        if (servers.length === 0) throw new Error("No domain servers reachable");
        if (servers.length === 1 && servers[0]) return servers[0];

        const priority = servers.reduce((min, srv) => Math.min(min, srv.priority), Infinity);
        const domainServers = servers.filter(srv => srv.priority === priority);
        if (domainServers.length === 1 && servers[0]) return servers[0];

        const totalWeight = domainServers.reduce((total, srv) => total + srv.weight, 0);
        const w = Math.floor(Math.random() * totalWeight);

        let accumulator = 0;
        for (const srv of domainServers) {
            accumulator += srv.weight;
            if (w < accumulator) return srv;
        }

        throw new Error("Domain server selection did not work correctly");
    }
}

export class DomainServer {
    host: string;
    id: string;
    priority: number;
    weight: number;
    protocols: {
        https: number | null,
        wss: number | null,
        http: number | null,
        ws: number | null,
        udp: number | null
    };

    toString() {
        return `[${this.host}/${this.priority}/${this.weight}]`;
    }

    constructor(json: DomainServerJson) {
        this.host = json.host;
        this.id = json.id;
        this.priority = json.priority;
        this.weight = json.weight;
        this.protocols = {
            https: json.protocols.https || null,
            wss: json.protocols.wss || null,
            http: json.protocols.http || null,
            ws: json.protocols.ws || null,
            udp: json.protocols.udp || null,
        };
    }
}

export class Account {

}

export class Member {

}

export class Room {

}

export class Event {
    id: string;
    data: {
        message: string;
    };

    constructor(json: EventJson) {
        this.data = {
            message: json.data.message,
        };
        this.id = json.id;
    }
}

type EventHandler = (event: Event) => void;

export class OutputEnvelope<T> {
    status: string;
    requestNr: number;
    data: T;
    action: string | null;
    error: {
        code: string,
        message: string | null,
        description: string | null,
    } | null;

    constructor(json: OutputEnvelopeJson<T>) {
        this.status = json.status;
        this.requestNr = json.request_nr;
        this.data = json.data;
        this.action = json.action || null;
        if (json.error) {
            this.error = {
                code: json.error.code,
                message: json.error.message,
                description: json.error.description,
            };
        } else {
            this.error = null;
        }
    }
}

type OutputEnvelopeHandler<T> = (envelope: OutputEnvelope<T>) => void;

export class Session {
    domain: Domain;
    server: DomainServer | null;
    token: string | null;

    httpBaseUrl: string | null;
    websocket: WebSocket | null;
    websocketPingInterval: number | null;
    numRequests: number;
    requestNumDiscriminator: number;
    subscriptions: {
        callback: EventHandler,
        requestNr: number | undefined,
        abortController: AbortController | undefined,
    }[];
    subscriptionEndpoints: {
        callback: EventHandler,
    }[];

    constructor(domain: Domain) {
        this.domain = domain;
        this.numRequests = 0;
        this.requestNumDiscriminator = Session.genRequestNumDiscriminator();
        this.server = null;
        this.token = null;
        this.httpBaseUrl = null;
        this.websocket = null;
        this.websocketPingInterval = null;
        this.subscriptions = [];
        this.subscriptionEndpoints = [];
    }

    private static genRequestNumDiscriminator(): number {
        // JS uses 64-bit double precision floating point numbers.
        // These can accurately represent integers up to 53 bit.
        // In JS bitwise operators only operate on 32-bit integers.
        // Here a 48-bit integer number is generated.
        return Math.floor(Math.random() * 0x1000000) * 0x1000000;
    }

    async close(keepEndpoints: boolean = false) {
        await this.unsubscribeAll(keepEndpoints);
        if (this.websocket && (this.websocket.readyState !== WebSocket.CLOSING && this.websocket.readyState !== WebSocket.CLOSED)) {
            this.closeWebsocket()
            this.subscriptions = [];
            this.server = null;
        }
    }

    private setWebsocketNull() {
        if (this.websocketPingInterval) clearInterval(this.websocketPingInterval);
        this.websocket = null;
        this.websocketPingInterval = null;
    }

    closeWebsocket() {
        if (this.websocket) {
            this.websocket.close();
            this.setWebsocketNull();
        }
    }

    hasWebsocket(): boolean {
        return this.websocket !== null;
    }

    async sleep() {
        await this.close(true);
    }

    async wakeup() {
        await this.chooseDomainServer();
        await this.ping();
        for (const endpoint of this.subscriptionEndpoints) {
            await this._subscribe(endpoint.callback);
        }
    }

    async chooseDomainServer(): Promise<{ http: number | null, ws: number | null } | undefined> {
        while (!this.server) {
            this.server = this.domain.chooseDomainServer();

            const host = this.server.host;
            const protocols = this.server.protocols;

            if ("wss" in protocols) {
                this.websocket = new WebSocket(`wss://${host}:${protocols.wss}/_usimp/websocket`, ["usimp"]);
                this.websocket.addEventListener("error", (error) => {
                    console.error(error);
                    this.setWebsocketNull();
                });
                this.websocketPingInterval = setInterval(() => {
                    if (this.hasWebsocket()) this.send("ping", {})
                }, WEBSOCKET_KEEP_ALIVE * 1000);
            }

            if ("https" in protocols) {
                this.httpBaseUrl = `https://${host}:${protocols.https}/_usimp`;
                try {
                    return await this.ping();
                } catch {
                    if (this.server !== null) {
                        this.domain.invalidServers.push(this.server);
                        this.server = null;
                    }
                }
            } else {
                console.warn(`Domain server ${this.server} does not support 'https' transport protocol`);
                this.domain.invalidServers.push(this.server);
                this.server = null;
            }
        }
        return undefined;
    }

    waitForWebSocket(timeout: number) {
        if (this.websocket === null) throw new Error("websocket not initialized");
        return new Promise<void>((resolve, reject) => {
            if (this.websocket?.readyState === this.websocket?.OPEN) {
                resolve();
            } else {
                const handlerOpen = () => {
                    clearTimeout(timer);
                    this.websocket?.removeEventListener("open", handlerOpen);
                    this.websocket?.removeEventListener("error", handlerError);
                    resolve();
                };
                const handlerError = (error: any) => {
                    clearTimeout(timer);
                    this.websocket?.removeEventListener("open", handlerOpen);
                    this.websocket?.removeEventListener("error", handlerError);
                    reject(error);
                }
                const handlerTimeout = () => {
                    this.websocket?.removeEventListener("open", handlerOpen);
                    this.websocket?.removeEventListener("error", handlerError);
                    reject(new Error("timeout"));
                }
                const timer = setTimeout(handlerTimeout, timeout);
                this.websocket?.addEventListener("open", handlerOpen);
                this.websocket?.addEventListener("error", handlerError);
            }
        });
    }

    waitForWebSocketResponse(req_nr: number, timeout: number) {
        if (this.websocket === null) throw new Error("websocket not initialized");
        return new Promise<any>((resolve, reject) => {
            const handlerMsg = (msg: any) => {
                const data = JSON.parse(msg.data);
                if (data['request_nr'] === req_nr) {
                    clearTimeout(timer);
                    this.websocket?.removeEventListener("message", handlerMsg);
                    this.websocket?.removeEventListener("error", handlerError);
                    resolve(data);
                }
            }
            const handlerError = (error: any) => {
                clearTimeout(timer);
                this.websocket?.removeEventListener("message", handlerMsg);
                this.websocket?.removeEventListener("error", handlerError);
                reject(error);
            }
            const handlerTimeout = () => {
                this.websocket?.removeEventListener("message", handlerMsg);
                this.websocket?.removeEventListener("error", handlerError);
                reject(new Error("timeout"));
            }
            const timer = setTimeout(handlerTimeout, timeout);
            this.websocket?.addEventListener("message", handlerMsg);
            this.websocket?.addEventListener("error", handlerError);
        });
    }

    async send(
        endpoint: string,
        data: Object,
        timeout: number = 2000,
        forceHttp: boolean = false,
        abortController: AbortController | undefined = undefined,
        cb: OutputEnvelopeHandler<unknown> | undefined = undefined,
    ) {
        this.numRequests++;
        while (true) {
            try {
                if (!forceHttp && this.websocket !== null) {
                    const req_nr = this.numRequests + this.requestNumDiscriminator;
                    const startTime = performance.now();

                    await this.waitForWebSocket(timeout);

                    let response;
                    if (cb === undefined) {
                        response = this.waitForWebSocketResponse(req_nr, timeout);
                    } else {
                        this.websocket.addEventListener("message", (msg) => {
                            const data = JSON.parse(msg.data);
                            if (data !== null && data.constructor === Object && data['request_nr'] === req_nr) {
                                if (isOutputEnvelopeJson<unknown>(data)) {
                                    cb(new OutputEnvelope<unknown>(data));
                                } else {
                                    throw new Error("Invalid server response");
                                }
                            }
                        });
                    }

                    await this.websocket.send(JSON.stringify({
                        'request_nr': req_nr,
                        'endpoint': endpoint,
                        'to_domain': this.domain.id,
                        'token': this.token,
                        'data': data
                    }));

                    if (cb === undefined) {
                        const responseData = await response;
                        const endTime = performance.now();
                        responseData.duration = endTime - startTime;
                        return responseData;
                    } else {
                        return await this.waitForWebSocketResponse(req_nr, timeout);
                    }
                } else if (this.httpBaseUrl) {
                    const controller = abortController || new AbortController();
                    const timer = setTimeout(() => controller.abort(), timeout);

                    let headers: Record<string, string> = {
                        'Content-Type': 'application/json',
                        'To-Domain': this.domain.id,
                    };
                    if (this.token) {
                        headers['Authorization'] = `usimp ${this.token}`;
                    }

                    const startTime = performance.now();
                    const response = await fetch(`${this.httpBaseUrl}/${endpoint}`, {
                        method: "POST",
                        headers: headers,
                        body: JSON.stringify(data),
                        signal: controller.signal,
                    });
                    clearTimeout(timer);
                    const responseData = await response.json();

                    const endTime = performance.now();
                    responseData.duration = endTime - startTime;
                    return responseData;
                } else {
                    throw new Error("No domain server chosen");
                }
            } catch (error) {
                console.error(error);
                if (this.hasWebsocket()) this.closeWebsocket();

                this.httpBaseUrl = null;
                if (this.server !== null) {
                    this.domain.invalidServers.push(this.server);
                    this.server = null;
                }
                await this.chooseDomainServer();
            }
        }
    }

    async ping() {
        let result = {"http": null, "ws": null};
        const resHttp = await this.send("ping", {}, undefined, true);
        result.http = resHttp.duration;

        if (this.hasWebsocket()) {
            const resWs = await this.send("ping", {});
            result.ws = resWs.duration;
        }

        return result;
    }

    async authenticate(accountName: string, password: string) {
        const response = await this.send("authenticate", {
            type: "password",
            name: accountName,
            password: password,
        });
        if (response.status === "success") {
            this.token = response.data.token;
        }
        return response;
    }

    async newEvent(roomId: string, data: Object, id: string | undefined = undefined) {
        return this.send("new_event", {
            room_id: roomId,
            events: [{
                data: data,
                id: id,
            }],
        });
    }

    async subscribe(cb: EventHandler) {
        this.subscriptionEndpoints.push({callback: cb});
        await this._subscribe(cb);
    }

    private async _subscribe(cb: EventHandler) {
        if (this.hasWebsocket()) {
            const subscription = await this.send('subscribe', {}, 60_000, false, undefined, (res) => {
                if (res.action === 'push') {
                    if (isEventPushEnvelopeJson(res.data)) {
                        for (const event of res.data.events) {
                            cb(new Event(event));
                        }
                    } else {
                        throw new Error("Invalid server response");
                    }
                }
            });
            this.subscriptions.push(subscription);
            return subscription;
        } else {
            const controller = new AbortController();
            this.subscriptions.push({callback: cb, requestNr: undefined, abortController: controller});
            this.send('subscribe', {}, 60_000, false, controller).then((res) => {
                this.subscriptions = [];
                if (res.status === "success") {
                    this._subscribe(cb);
                    cb(res);
                } else {
                    setTimeout(() => {
                        this._subscribe(cb);
                    }, 1000);
                }
            }).catch(() => {
                this.subscriptions = [];
                setTimeout(() => {
                    this._subscribe(cb);
                }, 1000);
            });
            return null;
        }
    }

    async unsubscribeAll(keepEndpoints: boolean = false) {
        for (const sub of this.subscriptions) {
            await this.unsubscribe(sub.requestNr, sub.abortController);
        }
        this.subscriptions = [];
        if (!keepEndpoints) this.subscriptionEndpoints = [];
    }

    private async unsubscribe(requestNr: number | undefined = undefined, abortController: AbortController | undefined = undefined) {
        if (this.hasWebsocket()) {
            await this.send('unsubscribe', {'request_nr': requestNr})
        } else {
            if (abortController) abortController.abort('unsubscribe');
        }
    }
}
