import { IPendingRequest, IWebSocketRequest, IWebSocketResponse } from "./schema";

export abstract class WebSocketClient
{
	/**
	 * The URL to connect to
	 */
	protected readonly WEBSOCKET_URL: string;

	/**
	 * Indicate if the websocket is ready
	 */
	protected isReady: boolean = false;

	/**
	 * Indicate if the websocket client is currently running
	 */
	protected running: boolean = false;

	/**
	 * The websocket instance
	 */
	protected socket: WebSocket|null = null;

	/**
	 * Store the timestamp of the most recent connection attempt
	 */
	#lastConnectAttempt: number = 0;

	/**
	 * Maintain a mapping of pending requests
	 */
	#pendingRequests: { [requestId: number]: IPendingRequest } = {};

	/**
	 * Store the next request ID
	 */
	#requestId: number = 0;

	/**
	 * The auth state change event handler
	 */
	protected onAuthStateChanged: (() => void)|null = null;

	public constructor(url: string) {
		this.WEBSOCKET_URL = url;
		this.setup();
		this.run();
	}

	// Overridable ---------------------------------------------------------------------------------

	/**
	 * Setup the socket
	 */
	protected abstract setup(): void;

	/**
	 * Get the app's current auth JWT
	 */
	protected abstract jwt(): string|null;

	/**
	 * Forget the JWT and log out of the app
	 */
	protected abstract forgetJwt(): void;

	// Websocket Service Interface -----------------------------------------------------------------

	/**
	 * Connect to the server
	 */
	public async run() {
		if (this.running) {
			return;
		}
		this.running = true;
		while (this.running) {
			this.close();
			await this.waitForAuthReady();
			try {
				await this.connect();
				await this.authenticate();
				await this.exec();
			} catch(e) {
				continue;
			}
		}
		this.close();
	}

	/**
	 * Close the connection if it exists
	 */
	public async close() {
		this.isReady = false;
		for (let requestId of Object.keys(this.#pendingRequests)) {
			this.#pendingRequests[<any>requestId].reject(new Error("closed"));
			delete this.#pendingRequests[<any>requestId];
		}
		if (this.socket === null) {
			return;
		}
		this.socket.onclose = null;
		this.socket.onerror = null;
		this.socket.onmessage = null;
		this.socket.close();
	}

	// Connection Handling -------------------------------------------------------------------------

	/**
	 * If authentication info isn't in the store, wait for it
	 */
	protected async waitForAuthReady() {
		while (!this.jwt()) {
			console.log("Waiting for auth state change...");
			await new Promise<void>(resolve => this.onAuthStateChanged = resolve);
		}
	}

	/**
	 * Connect to the server
	 */
	protected connect() {
		return new Promise<void>((resolve, reject) => {
			let timeout = setTimeout(() => {
				console.log("Connecting...");
				this.#lastConnectAttempt = Date.now();
				this.socket = new WebSocket(this.WEBSOCKET_URL);
				this.socket.onopen = () => resolve();
				this.socket.onerror = reject;
				this.socket.onclose = reject;
			}, this.#lastConnectAttempt - Date.now() + 5000);
			this.onAuthStateChanged = () => {
				clearTimeout(timeout);
				reject();
			};
		});
	}

	/**
	 * Authenticate the connection
	 */
	protected authenticate() {
		return new Promise<void>((resolve, reject) => {
			console.log("Authenticating...");

			let timeout = setTimeout(reject, 5000);
			this.onAuthStateChanged = this.socket!.onerror = this.socket!.onclose = () => {
				clearTimeout(timeout);
				reject();
			}
			this.socket!.onmessage = (event) => {
				clearTimeout(timeout);
				if (event.data !== "true") {
					this.forgetJwt();
					reject();
					return;
				}
				resolve();
			};
			this.socket!.send(this.jwt() ?? "");
		});
	}

	/**
	 * Connection established. Wait for close
	 */
	protected exec() {
		return new Promise<void>((resolve, reject) => {
			console.log("Connected");
			this.isReady = true;
			this.onAuthStateChanged = () => reject();
			this.socket!.onclose    = () => resolve();
			this.socket!.onerror    = () => reject();
			this.socket!.onmessage  = event => this.onMessage(event.data);
		});
	}

	/**
	 * Get the next request Id
	 */
	#nextRequestId() {
		this.#requestId = (this.#requestId + 1) % Number.MAX_SAFE_INTEGER;
		return this.#requestId;
	}

	/**
	 * Send a payload to the websocket server
	 */
	#send(payload: any) {
		this.socket!.send(JSON.stringify(payload));
	}

	// Event Handling ------------------------------------------------------------------------------

	/**
	 * Invoked when a message is received by the socket
	 */
	protected onMessage(data: string) {
		let parsed: IWebSocketRequest | IWebSocketResponse;
		try {
			parsed = JSON.parse(data);
		} catch(e) {
			console.warn("Failed to parse JSON response");
			return;
		}
		if (parsed.type === "request") {
			this.handleRequest(<IWebSocketRequest><any>parsed); // WAT!?!?
		} else {
			this.handleResponse(<IWebSocketResponse><any>parsed); // WHY?!?!
		}
	}

	/**
	 * Handle an incoming request from the server
	 */
	protected handleRequest(request: IWebSocketRequest) {

	}

	/**
	 * Handle an incoming response from the server
	 */
	protected handleResponse(response: IWebSocketResponse) {
		if (this.#pendingRequests[response.requestId] === undefined) {
			return;
		}
		let pendingRequest = this.#pendingRequests[response.requestId];
		pendingRequest.resolve(response.payload);
	}

	// Public Interface ----------------------------------------------------------------------------

	/**
	 * Notify the socket that the authentication state of the app has changed
	 */
	public authStateChanged() {
		if (this.onAuthStateChanged === null) {
			return;
		}
		this.onAuthStateChanged();
	}

	/**
	 * Send a payload to the server
	 */
	public send(method: string, payload?: any) {
		this.#send({
			requestId: this.#nextRequestId(),
			method,
			payload
		});
	}

	/**
	 * Request info from the server
	 */
	public request<T>(method: string, payload?: any) {
		const requestId = this.#nextRequestId();
		return new Promise<T>((resolve, reject) => {
			let timeout = setTimeout(() => {
				reject(new Error("timeout"));
				delete this.#pendingRequests[requestId];
			}, 5000);
			this.#pendingRequests[requestId] = {
				resolve: (data: T) => {
					clearTimeout(timeout);
					delete this.#pendingRequests[requestId];
					resolve(data);
				},
				reject: (error: any) => {
					clearTimeout(timeout);
					delete this.#pendingRequests[requestId];
					reject(error);
				}
			};
			this.#send({
				requestId,
				method,
				payload
			});
		});
	}
}
