Source: Node.js

const WebSocket = require('ws');
const { EventEmitter } = require('node:events');
const { Player } = require('./Player');

/**
 * Represents a Player that connects to a node and interacts with a guild's session.
 * @extends EventEmitter
 * @class
 */
class Node extends EventEmitter {
	/**
	 * @param {Object} options - Object that contains the options for the Node
	 * @param {string} options.serverName - The name of the server
	 * @param {boolean} options.secure - Whether the connection is secure or not
	 * @param {string} options.host - Hostname
	 * @param {number} options.port - Port
	 * @param {string} options.pass - Password
	 * @param {string} options.userAgent - User-Agent
	 * @param {string} options.botId - Bot ID
	 */
	constructor(options) {
		super();

		/**
		 * This Node's name
		 * @type {String}
		 */
		this.serverName = options.serverName || 'Tsumi';

		/**
		 * This Node's WS URL
		 * @type {String}
		 */
		this.url = `ws${options.secure ? 's' : ''}://${options.host}:${options.port}`;

		/**
		 * This Node's API URL
		 * @type {String}
		 */
		this.fetchUrl = `http${options.secure ? 's' : ''}://${options.host}:${options.port}`;

		/**
		 * This Node's password
		 * @type {String}
		 */
		this.pass = options.pass;

		/**
		 * Useragent used to connect this node
		 * @type {String}
		 */
		this.userAgent = options.userAgent || 'Tsumi/0.0.1';

		/**
		 * This Node's ID
		 * @type {String}
		 */
		this.botId = options.botId;

		/**
		 * This Node's function to send payload to Discord server
		 * @type {Function}
		 */
		this.sendPayload = options.sendPayload;

		/**
		 * This Node's session ID
		 * @type {String}
		 */
		this.sessionId = null;
	}

	/**
	 * This Node's stats
	 * @type {Object}
	 */
	stats = {
		players: 0,
		playingPlayers: 0,
		uptime: 0,
		memory: {
			free: 0,
			used: 0,
			allocated: 0,
			reservable: 0,
		},
		cpu: { cores: 0, systemLoad: 0, lavalinkLoad: 0 },
		frameStats: { sent: 0, nulled: 0, expected: 0, deficit: 0 },
	};

	/**
	 * This Node's player
	 * @type {Object}
	 */
	players = {};

	/**
	 * Start WS with LavaLink server
	 * @function
	 * @return {Object} - This node instance
	 */
	startWs = async () => {
		try {
			await globalThis.fetch(`${this.fetchUrl}/`);
		} catch (_) {
			return false;
		}
		this.ws = new WebSocket(`${this.url}/v4/websocket`, {
			headers: {
				Authorization: this.pass,
				'User-Id': this.botId,
				'Client-name': this.userAgent,
			},
		});
		this.ws.on('message', (data) => {
			const parsedData = JSON.parse(data.toString());
			if (parsedData.op === 'ready') {
				this.emit('ready');
				this.sessionId = parsedData.sessionId;
			} else if (parsedData.op === 'event') {
				this.emit('event', parsedData);
				this.players[parsedData.guildId].handleEvents(parsedData);
			} else if (parsedData.op === 'stats') {
				this.emit('stats', parsedData);
				this.stats = parsedData;
			} else if (parsedData.op === 'playerUpdate') {
				this.emit('playerUpdate', parsedData);
				this.players[parsedData.guildId].handlePlayerUpdate(parsedData);
			}
		});
		this.ws.on('close', () => {
			this.emit('close', this);
		});
		return this;
	};

	/**
	 * Join voice channel
	 * @function
	 * @async
	 * @param {Object} options - Object that contains the options for the voice channel
	 * @param {String} options.guildId - Guild ID
	 * @param {String} options.channelId - Channel ID
	 * @param {Object} options.options - Options for the voice channel
	 * @return {Object} - Player instance
	 */
	joinVoiceChannel = async (options) => {
		if (!this.sessionId)
			throw new Error(
				'Node is not ready, please wait for it to receive session ID to work properly.'
			);
		const player = new Player({
			guildId: options.guildId,
			node: this,
		});
		this.players = { ...this.players, [options.guildId]: player };
		await this.sendPayload(options.guildId, {
			op: 4,
			d: {
				guild_id: options.guildId,
				channel_id: options.channelId,
				self_mute: options.options?.mute ?? false,
				self_deaf: options.options?.deaf ?? false,
			},
		});
		return player;
	};

	/**
	 * Leave voice channel
	 * @function
	 * @param {String} guildId - Guild ID
	 * @return {Object} - This node instance
	 */
	leaveVoiceChannel = async (guildId) => {
		if (!this.players[guildId]) throw new Error('Player not found');
		await this.players[guildId].destroy();
		await this.sendPayload(guildId, {
			op: 4,
			d: {
				guild_id: guildId,
				channel_id: null,
				self_mute: false,
				self_deaf: false,
			},
		});
		await this.players[guildId].removeAllListeners();
		delete this.players[guildId];
		return this;
	};

	/**
	 * Get players
	 * @function
	 * @return {Object} - Players
	 */
	getPlayers = () => {
		return this.players;
	};

	/**
	 * Get player on specified guild
	 * @function
	 * @param {String} guildId - Guild ID
	 * @return {Object} - The player for the guild
	 */
	getPlayer = (guildId) => {
		return this.players[guildId];
	};

	/**
	 * Load tracks
	 * @function
	 * @async
	 * @param {String} data - Data to load
	 * @return {Object} - Load results
	 */
	loadTracks = async (data) => {
		const res = await globalThis.fetch(`${this.fetchUrl}/v4/loadtracks?identifier=${data}`, {
			headers: {
				Authorization: this.pass,
			},
		});
		return await res.json();
	};

	/**
	 * Load lyrics
	 * @function
	 * @async
	 * @param {String} track - Track to load lyrics for
	 * @param {String} lang - Language to load lyrics in
	 * @return {Object} - Lyric
	 */
	loadLyrics = async (track, lang) => {
		if (!track) throw new Error('No track provided.');
		if (typeof track !== 'string') throw new Error('Track must be a string.');

		if (lang && typeof lang != 'string') throw new Error('Lang must be a string.');

		const res = await globalThis.fetch(
			`${this.fetchUrl}/v4/loadlyrics?encodedTrack=${encodeURIComponent(track)}${
				lang ? `&language=${lang}` : ''
			}`,
			{
				headers: {
					Authorization: this.pass,
				},
			}
		);
		return await res.json();
	};

	/**
	 * Get states
	 * @function
	 * @async
	 * @return {Object} - States
	 */
	getStats = async () => {
		const res = await globalThis.fetch(`${this.fetchUrl}/v4/stats`, {
			headers: {
				Authorization: this.pass,
			},
		});
		const data = await res.json();
		this.stats = data;
		return data;
	};

	/**
	 * Decode a track
	 * @function
	 * @async
	 * @param {String} track - Track to decode that is encoded to base64
	 * @return {Object} - Decoded track
	 */
	decodeTrack = async (track) => {
		const res = await globalThis.fetch(
			`${this.fetchUrl}/v4/decodetrack?encodedTrack=${track}`,
			{
				headers: {
					Authorization: this.pass,
				},
			}
		);
		return await res.json();
	};

	/**
	 * Decode multiple tracks
	 * @function
	 * @async
	 * @param {Array} tracks - Array of tracks to decode that are encoded to base64
	 * @return {Object} - Decoded tracks
	 */
	decodeTracks = async (tracks) => {
		const res = globalThis.fetch(`${this.fetchUrl}/v4/decodetracks`, tracks, {
			headers: {
				Authorization: this.pass,
			},
		});
		return await res.json();
	};

	/**
	 * Destroy this node
	 * @function
	 * @return {Boolean} - True if successful
	 */
	destroy = () => {
		this.ws.close();
		this.emit('destroyed');
		return true;
	};
}

module.exports = { Node };