/**
 * Copyright © Volker Schukai and all contributing authors, {{copyRightYear}}. All rights reserved.
 * Node module: @schukai/monster
 *
 * This source code is licensed under the GNU Affero General Public License version 3 (AGPLv3).
 * The full text of the license can be found at: https://www.gnu.org/licenses/agpl-3.0.en.html
 *
 * For those who do not wish to adhere to the AGPLv3, a commercial license is available.
 * Acquiring a commercial license allows you to use this software without complying with the AGPLv3 terms.
 * For more information about purchasing a commercial license, please contact Volker Schukai.
 *
 * SPDX-License-Identifier: AGPL-3.0
 */

import { instanceSymbol } from "../constants.mjs";
import { isInteger, isString } from "../types/is.mjs";
import { BaseWithOptions } from "../types/basewithoptions.mjs";
import { ObservableQueue } from "../types/observablequeue.mjs";
import { Message } from "./webconnect/message.mjs";
import { getGlobalFunction } from "../types/global.mjs";

export { WebConnect };

/**
 * @private
 * @type {symbol}
 */
const receiveQueueSymbol = Symbol("receiveQueue");

/**
 * @private
 * @type {symbol}
 *
 * hint: this name is used in the tests. if you want to change it, please change it in the tests as well.
 */
const connectionSymbol = Symbol("connection");

/**
 * @private
 * @type {symbol}
 */
const manualCloseSymbol = Symbol("manualClose");

/**
 * @private
 * @see https://www.rfc-editor.org/rfc/rfc6455.html#section-7.4.1
 * @type {Object}
 */
const connectionStatusCode = {
	1000: "Normal closure",
	1001: "Going away",
	1002: "Protocol error",
	1003: "Unsupported data",
	1004: "Reserved",
	1005: "No status code",
	1006: "Connection closed abnormally",
	1007: "Invalid frame payload data",
	1008: "Policy violation",
	1009: "The Message is too big",
	1010: "Mandatory extension",
	1011: "Internal server error",
	1015: "TLS handshake",
};

/**
 * @private
 * @this {WebConnect}
 * @throws {Error} No url defined for websocket datasource.
 */
function connectServer(resolve, reject) {
	const self = this;

	const url = self.getOption("url");
	if (!url) {
		reject(new Error("No url defined for web connect."));
		return;
	}

	let promiseAlreadyResolved = false;

	let connectionTimeout = self.getOption("connection.timeout");
	if (!isInteger(connectionTimeout) || connectionTimeout < 100) {
		connectionTimeout = 5000;
	}

	// Timeout Handling
	const timeoutId = setTimeout(() => {
		if (promiseAlreadyResolved) {
			return;
		}
		promiseAlreadyResolved = true;

		// Clean up hanging socket attempt
		if (self[connectionSymbol].socket) {
			try {
				self[connectionSymbol].socket.close();
			} catch (e) {
				// ignore
			}
		}

		reject(new Error("Connection timeout"));
	}, connectionTimeout);

	let reconnectTimeout = self.getOption("connection.reconnect.timeout");
	if (!isInteger(reconnectTimeout) || reconnectTimeout < 1000)
		reconnectTimeout = 1000;

	let reconnectAttempts = self.getOption("connection.reconnect.attempts");
	if (!isInteger(reconnectAttempts) || reconnectAttempts < 1)
		reconnectAttempts = 1;

	let reconnectEnabled = self.getOption("connection.reconnect.enabled");
	if (reconnectEnabled !== true) reconnectEnabled = false;

	self[manualCloseSymbol] = false;
	self[connectionSymbol].reconnectCounter++;

	// Cleanup existing socket
	if (
		self[connectionSymbol].socket &&
		self[connectionSymbol].socket.readyState < 2
	) {
		// Remove listeners to prevent side effects during close
		self[connectionSymbol].socket.onclose = null;
		self[connectionSymbol].socket.onerror = null;
		self[connectionSymbol].socket.onmessage = null;
		self[connectionSymbol].socket.onopen = null;
		self[connectionSymbol].socket.close();
	}
	self[connectionSymbol].socket = null;

	const WebSocket = getGlobalFunction("WebSocket");
	if (!WebSocket) {
		clearTimeout(timeoutId);
		reject(new Error("WebSocket is not available"));
		return;
	}

	try {
		self[connectionSymbol].socket = new WebSocket(url);
	} catch (error) {
		clearTimeout(timeoutId);
		if (!promiseAlreadyResolved) {
			promiseAlreadyResolved = true;
			reject(error);
		}
		return;
	}

	self[connectionSymbol].socket.onmessage = function (event) {
		if (event.data instanceof Blob) {
			const reader = new FileReader();
			reader.addEventListener("loadend", function () {
				self[receiveQueueSymbol].add(new Message(reader.result));
			});
			// Correctly pass the Blob, not a Message object
			reader.readAsText(event.data);
		} else {
			self[receiveQueueSymbol].add(Message.fromJSON(event.data));
		}
	};

	self[connectionSymbol].socket.onopen = function () {
		clearTimeout(timeoutId);
		self[connectionSymbol].reconnectCounter = 0;
		if (typeof resolve === "function" && !promiseAlreadyResolved) {
			promiseAlreadyResolved = true;
			resolve();
		}
	};

	// Internal helper to handle reconnects
	const handleReconnect = () => {
		if (self[manualCloseSymbol]) {
			self[manualCloseSymbol] = false;
			return;
		}

		if (
			reconnectEnabled &&
			self[connectionSymbol].reconnectCounter < reconnectAttempts
		) {
			setTimeout(() => {
				// catch potential unhandled promise rejections from the recursive call
				self.connect().catch(() => {});
			}, reconnectTimeout * self[connectionSymbol].reconnectCounter);
		}
	};

	// Use onclose event instead of overriding the close method
	self[connectionSymbol].socket.onclose = function (event) {
		handleReconnect();
	};

	self[connectionSymbol].socket.onerror = (error) => {
		if (!promiseAlreadyResolved) {
			clearTimeout(timeoutId);
			promiseAlreadyResolved = true;
			reject(error);
		} else {
			// If the connection was already established, treat error as potential disconnect
			// Usually onclose follows onerror, but we ensure we don't double-handle logic
			// typically we rely on onclose for reconnect logic.
		}
	};
}

/**
 * The RestAPI is a class that enables a REST API server.
 *
 * @fragments /fragments/libraries/net/webconnect/
 *
 * @externalExample ../../example/net/webconnect.mjs
 * @example /examples/libraries/net/webconnect/simple/ Connect and consume structured messages
 * @example /examples/libraries/net/webconnect/send-and-close/ Send structured messages and close the connection manually
 * @example /examples/libraries/net/webconnect/observer-queue/ Observe incoming queue updates through the receive queue observer API
 * @example /examples/libraries/net/webconnect/reconnect-options/ Configure reconnect and timeout options for resilient connections
 * @license AGPLv3
 * @since 3.1.0
 * @copyright Volker Schukai
 * @summary A WebSocket connection wrapper for sending, receiving and observing structured real-time messages.
 */
class WebConnect extends BaseWithOptions {
	/**
	 *
	 * @param {Object} [options] options contains definitions for the webconnect.
	 */
	constructor(options) {
		if (isString(options)) {
			options = { url: options };
		}

		super(options);

		this[receiveQueueSymbol] = new ObservableQueue();

		this[connectionSymbol] = {};
		this[connectionSymbol].socket = null;
		this[connectionSymbol].reconnectCounter = 0;
		this[manualCloseSymbol] = false;
	}

	/**
	 *
	 * @return {Promise}
	 */
	connect() {
		return new Promise((resolve, reject) => {
			connectServer.call(this, resolve, reject);
		});
	}

	/**
	 * @return {boolean}
	 */
	isConnected() {
		return this[connectionSymbol]?.socket?.readyState === 1;
	}

	/**
	 * This method is called by the `instanceof` operator.
	 * @return {symbol}
	 */
	static get [instanceSymbol]() {
		return Symbol.for("@schukai/monster/net/webconnect");
	}

	/**
	 * @property {string} url=undefined Defines the resource that you wish to fetch.
	 * @property {Object} connection
	 * @property {Object} connection.timeout=5000 Defines the timeout for the connection.
	 * @property {Number} connection.reconnect.timeout The timeout in milliseconds for the reconnect.
	 * @property {Number} connection.reconnect.attempts The maximum number of reconnects.
	 * @property {Bool} connection.reconnect.enabled If the reconnect is enabled.
	 */
	get defaults() {
		return Object.assign({}, super.defaults, {
			url: undefined,
			connection: {
				timeout: 5000,
				reconnect: {
					timeout: 1000,
					attempts: 1,
					enabled: false,
				},
			},
		});
	}

	/**
	 * This method closes the connection.
	 *
	 * @param {Number} [code=1000] The close code.
	 * @param {String} [reason=""] The close reason.
	 * @return {Promise}
	 * @see https://www.rfc-editor.org/rfc/rfc6455.html#section-7.4.1
	 */
	close(statusCode, reason) {
		if (!isInteger(statusCode) || statusCode < 1000 || statusCode > 4999) {
			statusCode = 1000;
		}
		if (!isString(reason)) {
			reason = "";
		}

		return new Promise((resolve, reject) => {
			try {
				// Set manual close flag BEFORE calling close() to prevent reconnect
				this[manualCloseSymbol] = true;
				if (this[connectionSymbol].socket) {
					this[connectionSymbol].socket.close(statusCode, reason);
				}
			} catch (error) {
				reject(error);
				return;
			}
			resolve();
		});
	}

	/**
	 * Polls the receive queue for new messages.
	 *
	 * @return {Message}
	 */
	poll() {
		return this[receiveQueueSymbol].poll();
	}

	/**
	 * Are there any messages in the receive queue?
	 *
	 * @return {boolean}
	 */
	dataReceived() {
		return !this[receiveQueueSymbol].isEmpty();
	}

	/**
	 * Get Message from the receive queue, but do not remove it.
	 *
	 * @return {Object}
	 */
	peek() {
		return this[receiveQueueSymbol].peek();
	}

	/**
	 * Attach a new observer
	 *
	 * @param {Observer} observer
	 * @return {ProxyObserver}
	 */
	attachObserver(observer) {
		this[receiveQueueSymbol].attachObserver(observer);
		return this;
	}

	/**
	 * Detach a observer
	 *
	 * @param {Observer} observer
	 * @return {ProxyObserver}
	 */
	detachObserver(observer) {
		this[receiveQueueSymbol].detachObserver(observer);
		return this;
	}

	/**
	 * @param {Observer} observer
	 * @return {boolean}
	 */
	containsObserver(observer) {
		return this[receiveQueueSymbol].containsObserver(observer);
	}

	/**
	 * @param {Message|Object} message
	 * @return {Promise}
	 */
	send(message) {
		return new Promise((resolve, reject) => {
			if (
				!this[connectionSymbol].socket ||
				this[connectionSymbol].socket.readyState !== 1
			) {
				reject(new Error("The socket is not ready"));
				return;
			}

			try {
				this[connectionSymbol].socket.send(JSON.stringify(message));
				resolve();
			} catch (e) {
				reject(e);
			}
		});
	}
}
