import axios, { type AxiosRequestConfig } from 'axios';
import chalk from 'chalk';
import { EventEmitter } from 'events';
import { format, Logger, loggers } from 'winston';
import WebSocket from 'ws';
import { type ParserOptions, Parser } from 'xml2js';
import { parseBooleans, parseNumbers } from 'xml2js/lib/processors.js';
import { DeviceFactory } from './Devices/DeviceFactory.js';
import { ELKAlarmPanelDevice } from './Devices/Elk/ElkAlarmPanelDevice.js';
import { ElkAlarmSensorDevice } from './Devices/Elk/ElkAlarmSensorDevice.js';
import { Message } from './ISY.js';
import { IWebSocketMessageHandler, ISYWebSocketEndpoint } from './ISYWebSocketEndpoint.js';
import { EventType } from './Events/EventType.js';
import { ISYDevice } from './ISYDevice.js';
import { ISYNode } from './ISYNode.js';
import { ISYVariable } from './ISYVariable.js';
import type { NodeInfo } from './Model/NodeInfo.js';
import { ISYScene } from './Scenes/ISYScene.js';
import { VariableType } from './VariableType.js';

import * as Utils from './Utils.js';

import { X2jOptions, XMLParser } from 'fast-xml-parser';
import type { ClientRequestArgs } from 'http';
import path from 'path';

import type { JsonPrimitive } from 'type-fest';
import { Family, UnitOfMeasure } from './Definitions/index.js';
import { GenericNode } from './Devices/GenericNode.js';
import { ISYError, ISYInitializationError } from './ISYError.js';
import type { Config } from './Model/Config.js';
import { emphasize, isPrimitive, writeDebugFile } from './Utils.js';

type InitStep =
	| 'config'
	| 'loadNodes'
	| 'readFolders'
	| 'readDevices'
	| 'readScenes'
	| 'variables'
	| 'websocket'
	| 'refreshStatuses'
	| 'initialize';

const defaultParserOptions: ParserOptions = {
	explicitArray: false,
	mergeAttrs: true,

	attrValueProcessors: [parseNumbers, parseBooleans],
	valueProcessors: [parseNumbers, parseBooleans],
	tagNameProcessors: [tagName => (tagName === 'st' || tagName === 'cmd' || tagName === 'nodeDef' ? '' : tagName)],
};

const defaultXMLParserOptions: X2jOptions = {
	parseAttributeValue: true,
	allowBooleanAttributes: true,
	attributeNamePrefix: '',
	attributesGroupName: false,
	ignoreAttributes: false,
	// updateTag(tagName, jPath, attrs) {
	// 	//if(tagName === 'st' || tagName === 'cmd' || tagName === 'nodeDef')
	// 	//	return false;
	// 	//return tagName;
	// },
	textNodeName: '_',
	commentPropName: '$comment',
	cdataPropName: '$cdata',
	ignoreDeclaration: true,
	tagValueProcessor: (tagName, tagValue, jPath, hasAttributes, isLeafNode) => {
		if (tagValue === '') return null;
		return tagValue;
	},
	isArray(tagName, jPath, isLeafNode, isAttribute) {
		if (tagName === 'property') return true;
		return false;
	},
};

axios.defaults.transitional.forcedJSONParsing = false;

const parser = new Parser(defaultParserOptions);

export let Controls = {};

interface ISYConfig {
	// #region Properties (8)

	displayNameFormat?: string;
	elkEnabled?: boolean;
	enableWebSocket?: boolean;
	host: string;
	password: string;
	port: number;
	protocol: 'http' | 'https';
	username: string;
	socketPath?: string;
	axiosOptions?: AxiosRequestConfig;
	webSocketOptions?: WebSocket.ClientOptions & ClientRequestArgs & { guardianInterval?: number };

	apiOnly: boolean;

	logUnsubscribedNodeEvents?: boolean;
	// #endregion Properties (8)
}

export class ISY
	extends EventEmitter<{ nodeAdded: [ISYNode<any>]; initializeCompleted: [] }>
	implements Disposable, AsyncDisposable, IWebSocketMessageHandler
{
	// #region Properties (30)

	public readonly credentials: { username: string; password: string };

	public readonly devices: Map<string, ISYDevice<any, any, any, any>> = new Map();
	public readonly displayNameFormat: string;
	public readonly elkEnabled: boolean;
	public readonly enableWebSocket: boolean;
	public readonly folderMap: Map<string, string> = new Map();
	public readonly host: string;
	public readonly nodes: Map<string, ISYNode<any, any, any, any>> = new Map();
	public readonly port: number;
	public readonly protocol: string;
	public readonly scenes: Map<string, ISYScene> = new Map();
	public readonly storagePath: string;
	public readonly variables: Map<string, ISYVariable<VariableType>> = new Map();
	public readonly wsprotocol: 'ws' | 'wss' = 'ws';
	public readonly zoneMap: Map<string, ElkAlarmSensorDevice> = new Map();

	public static instance: ISY;

	public configInfo: Config;

	public readonly config: ISYConfig;
	public elkAlarmPanel: ELKAlarmPanelDevice;
	public id: string;
	public logger: Logger;
	public model: any;
	public nodesLoaded: boolean = false;
	public productId = 5226;
	public productName = 'eisy';
	public firmwareVersion: any;
	public vendorName = 'Universal Devices, Inc.';
	public webSocket: WebSocket;

	public apiVersion: string;

	socketPath: string;

	xmlParser: XMLParser;

	public axiosOptions: AxiosRequestConfig;
	guardianInterval: number;

	public logUnsubscribedNodeEvents: boolean = false;
	webSocketEndpoint: ISYWebSocketEndpoint;

	public static async create(config: ISYConfig, logger?: Logger, storagePath?: string): Promise<ISY> {
		if (!config.apiOnly) {
			if (!ISY.instance) {
				ISY.instance = new ISY(config, logger, storagePath);
				await ISY.instance.initialize();
			}
			return ISY.instance;
		}
		return new ISY(config, logger, storagePath);
	}

	// #endregion Properties (30)

	// #region Constructors (1)

	private constructor(config: ISYConfig, logger: Logger = new Logger(), storagePath?: string) {
		super();
		this.config = config;
		this.enableWebSocket = config.enableWebSocket ?? true;
		this.storagePath = storagePath ?? './';
		this.displayNameFormat = config.displayNameFormat ?? '${location ?? folder} ${spokenName ?? name}';
		this.host = config.host;
		this.port = config.port;
		this.guardianInterval = config.webSocketOptions?.guardianInterval ?? 60000;

		this.credentials = {
			username: config.username,
			password: config.password,
		};
		this.protocol = config.protocol;
		this.wsprotocol = config.protocol === 'https' ? 'wss' : 'ws';
		this.socketPath = config.socketPath;
		this.axiosOptions = {
			validateStatus: status => status >= 200 && status < 300,
		};

		if (this.socketPath) {
			this.axiosOptions.socketPath = this.socketPath;
			this.axiosOptions.baseURL = 'http://dummy/'; //Workaround for Bug with Axios 1.7.5+
		} else {
			((this.axiosOptions.baseURL = `${this.protocol}://${this.host}:${this.port}`),
				(this.axiosOptions.auth = { username: this.credentials.username, password: this.credentials.password }));
		}

		this.webSocketOptions = { origin: 'com.universal-devices.websockets.isy' };

		if (this.socketPath) {
			this.webSocketOptions.socketPath = this.socketPath;
		} else {
			this.webSocketOptions.auth = `${this.credentials.username}:${this.credentials.password}`;
		}
		this.xmlParser = new XMLParser({ ...defaultXMLParserOptions });

		//this.elkEnabled = config.elkEnabled ?? false;

		this.nodesLoaded = false;

		this.logger = loggers.add('isy', {
			transports: logger.transports,
			levels: logger.levels,
			format: format.label({ label: chalk.white.bold('ISY') }),
		});

		if (this.elkEnabled) {
			this.elkAlarmPanel = new ELKAlarmPanelDevice(this, 1);
		}
		//ISY.instance = this;
	}
	[Symbol.dispose](): void {
		if (ISY.instance === this) {
			ISY.instance = null;
		}
		this.close();
		this.logger.info(emphasize('ISY instance disposed.'));
	}

	async [Symbol.asyncDispose](): Promise<void> {
		return new Promise(async resolve => {
			this.logger.on('close', () => resolve());
			try {
				const p = new Promise<void>(resolve =>
					this.webSocket.once('close', (x, y) => {
						resolve();
					})
				);
				/*if (this.guardianTimer) {
					clearInterval(this.guardianTimer);
				}*/
				//this.webSocket?.close();
				await p;
				await this.webSocketEndpoint?.close();
				this.logger.info('WebSocket connection closed during async dispose.');
			} catch (e) {
				this.logger.error(`Error during async dispose: ${e.message}`);
			} finally {
				if (ISY.instance === this) {
					ISY.instance = null;
				}
				this.logger.info(emphasize('ISY instance asynchronously disposed.'));
				if (!this.logger.closed) {
					this.logger.close();
				} else {
					resolve();
				}
			}
		});
	}
	/*[Symbol.asyncDispose](): PromiseLike<void> {
		this.webSocket?.close();
		return Promise.resolve();
	}*/

	// #endregion Constructors (1)

	// #region Public Getters And Setters (2)

	public get address() {
		return `${this.host}:${this.port}`;
	}

	public get isDebugEnabled() {
		return this.logger?.isDebugEnabled();
	}

	// #endregion Public Getters And Setters (2)

	// #region Public Methods (24)

	/*[Symbol.dispose](): void {
		if (ISY.instance === this) {
			ISY.instance = null;
		}
		this.close();
	}*/

	/*public override emit(event: 'InitializeCompleted' | 'NodeAdded' | 'NodeRemoved' | 'NodeChanged', node?: ISYNode<any, any, any, any>): boolean {
		return super.emit(event, node);
	}*/

	public getDevice<T extends ISYDevice<any, any, any, any> = ISYDevice<any, any, any, any>>(
		address: string,
		parentsOnly = false
	): T {
		let s = this.devices.get(address);
		if (!parentsOnly) {
			if (s === null) {
				s = this.devices[`${address.substr(0, address.length - 1)} 1`];
			}
		} else {
			while (s?.parentAddress !== undefined && s?.parentAddress !== s?.address && s?.parentAddress !== null) {
				s = this.devices[s?.parentAddress];
			}
		}

		return s as T;
	}

	public getElkAlarmPanel() {
		return this.elkAlarmPanel;
	}

	public getNode<T extends ISYNode<any, any, any, any> = ISYNode<any, any, any, any>>(
		address: string,
		parentsOnly = false
	): T {
		let s = this.nodes.get(address);
		if (!parentsOnly) {
			if (s === null) {
				s = this.nodes[`${address.substr(0, address.length - 1)} 1`];
			}
		} else {
			while (s?.parentAddress !== undefined && s?.parentAddress !== s?.address && s?.parentAddress !== null) {
				s = this.nodes[s?.parentAddress];
			}
		}

		return s as T;
	}

	public getScene(address: string) {
		return this.scenes.get(address);
	}

	public getVariable<P extends VariableType>(type: P, id: number): ISYVariable<P> {
		const key = this.#createVariableKey(type, id);
		if (this.variables.has(key)) {
			return this.variables[key];
		}
		return null;
	}

	public getVariableList() {
		return this.variables;
	}

	public async handleInitializeError(step: string, reason: any): Promise<any> {
		this.logger.error(`Error initializing ISY (${step}): ${Utils.logStringify(reason)}`);

		return Promise.reject(reason);
	}

	public handleWebSocketMessage(event: { data: any }) {
		let that = this;
		parser.parseString(event.data, (err: any, res: { Event: Message<any> }) => {
			if (err) {
				that.logger.error(`Error parsing ISY WebSocket message: ${err} - ${event.data}`);
				return;
			}
			try {
				const evt = res?.Event;
				if (evt === undefined || evt.control === undefined) {
					return;
				}
				let actionValue = 0;
				if (evt.action instanceof Object) {
					actionValue = Number(evt.action._);
				} else if (typeof evt.action === 'string') {
					actionValue = Number(evt.action);
				}
				const stringControl = Number((evt.control as string)?.replace('_', ''));
				switch (stringControl) {
					case EventType.Elk:
						if (actionValue === 2) {
							this.elkAlarmPanel.handleEvent(event);
						} else if (actionValue === 3) {
							const zeElement = evt.eventInfo.ze;
							const zoneId = zeElement.zone;
							const zoneDevice = this.zoneMap[zoneId];
							if (zoneDevice !== null) {
								if (zoneDevice.handleEvent(event)) {
									this.nodeChangedHandler(zoneDevice);
								}
							}
						}

						break;

					case EventType.Trigger:
						if (actionValue === 6) {
							const varNode = evt.eventInfo.var;
							const id = varNode.id;
							const type = varNode.type;
							this.getVariable(type, id)?.handleEvent(evt);
						}
						break;
					case EventType.Heartbeat:
						this.logger.debug(`Received ${EventType[EventType.Heartbeat]} Signal from ISY: ${JSON.stringify(evt)}`);
						break;
					case EventType.SystemStatusChanged:
					case EventType.ProgressReport:
						this.logger.silly(`${EventType[stringControl]} Message: ${JSON.stringify(evt)}`);
						break;
					default:
						if (evt.node !== '' && evt.node !== undefined && evt.node !== null) {
							//
							const impactedNode = this.getNode(evt.node);
							if (impactedNode !== undefined && impactedNode !== null) {
								try {
									impactedNode.handleEvent(evt);
								} catch (e) {
									this.logger.error(`Error handling message for ${impactedNode.name}: ${e.message}`);
								}
							} else {
								this.logger.silly(`${stringControl} Message for Unidentified Device: ${JSON.stringify(evt)}`);
							}
						} else {
							/*else if (stringControl === EventType.NodeChanged) {
						this.logger.debug(`Received Node Change Event: ${JSON.stringify(evt)}. These are currently unsupported.`);*/
							this.logger.debug(`${EventType[stringControl]} Message: ${JSON.stringify(evt)}`);
						}

						break;
				}
			} catch (e) {
				this.logger.error(`Error handling WebSocket message: ${e.message} - ${JSON.stringify(event)}`);
			}
		});
	}

	public async initialize(): Promise<boolean> {
		const that = this;

		try {
			await this.loadConfig();
			await this.loadNodes();
			await this.refreshStatuses();
			await this.loadVariables(VariableType.Integer);
			await this.loadVariables(VariableType.State);
			await this.#finishInitialize(true);
			return true;
		} catch (e) {
			if (e instanceof ISYInitializationError) {
				if (e.step === 'variables') {
					this.logger.warn(`Error loading variables: ${e.message}`);
					await this.#finishInitialize(true);
					return true;
				}
				this.logger.error(`Error initializing ISY during (${e.step}): ${e.message}`);
				return false;
			} else {
				this.logger.error(`Error initializing ISY: ${e.message}`);
				return false;
			}
		} finally {
			if (this.nodesLoaded !== true) {
				await that.#finishInitialize(false);
			}
		}
	}

	webSocketOptions: WebSocket.ClientOptions & ClientRequestArgs;

	public async initializeWebSocket() {
		if (!this.enableWebSocket) {
			this.logger.warn('WebSocket is disabled. Skipping initialization.');
			return;
		}
		try {
			const webSocketConfig = {
				...this.config,
				socketPath: this.socketPath,
				enableWebSocket: true,
				webSocketOptions: this.webSocketOptions,
				wsprotocol: this.wsprotocol,
				credentials: this.credentials,
				guardianInterval: this.guardianInterval,
			};
			this.webSocketEndpoint = new ISYWebSocketEndpoint(webSocketConfig, this.logger, this);
			await this.webSocketEndpoint.initialize();
			/*const that = this;

			//const auth = `Basic ${Buffer.from(`${this.credentials.username}:${this.credentials.password}`).toString('base64')}`;
			let address = `${this.wsprotocol}://${this.address}/rest/subscribe`;
			if (this.socketPath) {
				address = `ws+unix:${this.socketPath}:/rest/subscribe`;
			}
			this.logger.info(`Opening webSocket: ${address}`);
			this.logger.info('Using the following websocket options: ' + JSON.stringify(this.webSocketOptions));
			if (this.webSocket) {
				try {
					if (this.webSocket.readyState === WebSocket.OPEN) this.webSocket.close();
					this.webSocket = null;
				} catch (e) {
					this.logger.warn(`Error closing existing websocket: ${e.message}`);
				}
			}

			let p = new Promise<WebSocket>((resolve, reject) => {
				let webSocket = new WebSocket(`${address}`, ['ISYSUB'], this.webSocketOptions);

				//this.webSocket.onmessage = (event) => {this.handleWebSocketMessage()
				webSocket
					.on('open', () => {
						this.logger.info(emphasize('Websocket subscription open'));

						this.guardianTimer = setTimeout(async () => {
							this.logger.warn('WebSocket Guardian Timer expired. Closing WebSocket.');
							webSocket.terminate();
							await this.initializeWebSocket();
						}, this.guardianInterval ?? 60000);
						resolve(webSocket);
					})
					.on('message', (data, b) => {
						that.logger.silly(`Received message: ${Utils.logStringify(data, 1)}`);
						this.guardianTimer.refresh();
						that.handleWebSocketMessage({ data: data });
					})
					.on('error', (err: any, response: any) => {
						that.logger.warn(`Websocket subscription error: ${err}`);
						reject(new ISYInitializationError('Websocket subscription error', 'websocket'));
					})
					.on('fail', (data: string, response: any) => {
						that.logger.warn(`Websocket subscription failure: ${data}`);
						reject(new Error('Websocket subscription failure'));
					})
					.on('abort', () => {
						that.logger.warn('Websocket subscription aborted.');
						throw new Error('Websocket subscription aborted.');
					})
					.on('timeout', (ms: string) => {
						that.logger.warn(`Websocket subscription timed out after ${ms} milliseconds.`);
						reject(new Error('Timeout contacting ISY'));
						//throw new Error('Timeout contacting ISY');
					})
					.on('close', (code: number, reason: string) => {
						that.logger.info(emphasize(`Websocket subscription closed: ${code} - ${reason}`));
						if (this.guardianTimer) {
							clearTimeout(this.guardianTimer);
						}
						if (code !== 1005) {
							this.logger.warn(`WebSocket closed with code ${code}: ${reason}. Forcing to terminate.`);
							if (webSocket) {
								webSocket.terminate();
							}
						}
						if (webSocket) {
							webSocket.removeAllListeners();
						}
					});
			});
			this.webSocket = await p;
			*/
		} catch (e) {
			throw new ISYInitializationError(e, 'websocket');
		}
	}

	public async loadConfig(): Promise<any> {
		try {
			this.logger.info('Loading ISY Config');
			const configuration = (await this.sendRequest('config')).configuration as Config;
			if (this.isDebugEnabled) {
				writeDebugFile(JSON.stringify(configuration), 'ISYConfig.json', this.logger, this.storagePath);
			}

			const controls = configuration.controls;
			this.model = configuration.deviceSpecs.model;
			this.firmwareVersion = configuration.app_full_version;
			this.vendorName = configuration.deviceSpecs.make;
			this.productId = configuration.product.id;
			this.productName = configuration.product.desc;
			this.id = configuration.root.id;
			// this.logger.info(result.configuration);
			if (controls !== undefined) {
				// this.logger.info(controls.control);
				// var arr = new Array(controls.control);
				for (const ctl of controls.control) {
					// this.logger.info(ctl);
					Controls[ctl.name] = ctl;
				}
			}
			return configuration;
		} catch (e) {
			throw new ISYInitializationError(e, 'config');
		}
	}

	public async loadNodes(): Promise<any> {
		try {
			this.logger.info('Loading ISY Nodes');
			const result = await this.sendRequest('nodes');
			if (this.isDebugEnabled)
				await writeDebugFile(JSON.stringify(result), 'ISYNodes.json', this.logger, this.storagePath);
			await this.#readFolderNodes(result);
			await this.#readDeviceNodes(result);
			await this.#readSceneNodes(result);
			return result;
		} catch (e) {
			if (e instanceof ISYInitializationError) {
				throw e;
			} else {
				throw new ISYInitializationError(e, 'loadNodes');
			}
		}
	}

	public async loadVariables(type: VariableType): Promise<any> {
		try {
			const that = this;
			this.logger.info(`Loading ISY Variables of type: ${type}`);
			let defs = await this.sendRequest(`vars/definitions/${type}`);
			this.#createVariables(type, defs);
			let vals = await this.sendRequest(`vars/get/${type}`);
			this.#setVariableValues(vals);
		} catch {
			this.logger.warn('error loading variables of type: ' + VariableType[type]);
		}
	}

	public nodeChangedHandler(node: ELKAlarmPanelDevice | ElkAlarmSensorDevice, propertyName = null) {
		const that = this;
		if (this.nodesLoaded) {
			// this.logger.info(`Node: ${node.address} changed`);
			// if (this.changeCallback !== undefined && this.changeCallback !== null) {
			// t//his.changeCallback(that, node, propertyName);
			// }
		}
	}

	/*public override on(event: 'initializeCompleted', listener: () => void): this;
	public override on(event: 'nodeAdded' | 'nodeRemoved' | 'nodeChanged', listener: (node?: ISYNode<any, any, any, any>) => void): this;
	override on(event: 'initializeCompleted' | 'nodeAdded' | 'nodeRemoved' | 'nodeChanged', listener: (node?: ISYNode<any, any, any, any>) => void): this {
		return super.on('nodeAdded',listener)
	}*/

	public async refreshStatuses() {
		try {
			this.logger.info('Refreshing ISY Node Statuses');
			const that = this;
			const result = await that.sendRequest('status');
			if (that.isDebugEnabled) {
				await writeDebugFile(result, 'ISYStatus.json', this.logger, this.storagePath);
			}
			//this.logger.debug(result);
			for (const node of result.nodes.node) {
				let device = that.getNode(node.id) as unknown as ISYNode<any, any, any, any>;
				if (device !== null && device !== undefined) {
					device.parseResult(node);
				}
			}
		} catch (e) {
			throw new Error(`Error refreshing statuses: ${JSON.stringify(e.message)}`);
		}
	}

	public async sendGetVariable(id: any, type: any, handleResult: (arg0: number, arg1: number) => void) {
		const uriToUse = `vars/get/${type}/${id}`;
		return this.sendRequest(uriToUse).then(p => handleResult(p.val, p.init));
	}

	public async sendISYCommand(path: string): Promise<any> {
		// const uriToUse = `${this.protocol}://${this.address}/rest/${path}`;
		this.logger.info(`Sending command...${path}`);

		return this.sendRequest(path);
	}
	#formatParameter(name: string, value?: Utils.MaybeWithUOM<JsonPrimitive>): string {
		if (value === undefined) return '';
		if (Utils.hasUOM(value)) {
			const [val, uom] = value;
			if (val === undefined || val === null) return '';
			return `${name}.uom${uom}=${this.#formatParamValue(val)}`;
		}
		return `${name}=${this.#formatParamValue(value)}`;
	}

	#formatDefaultParameter(value: Utils.MaybeWithUOM<JsonPrimitive>): string {
		if (value === undefined) return '';
		if (Utils.hasUOM(value)) {
			const [val, uom] = value;
			if (val === undefined || val === null) return '';
			return `${this.#formatParamValue(val, uom)}/${uom}`;
		}
		return `${this.#formatParamValue(value)}`;
	}
	#formatParamValue(param: JsonPrimitive, uom?: UnitOfMeasure): string {
		if (typeof param === 'string') return param;
		if (typeof param === 'number') return param.toString();
		if (typeof param === 'boolean') {
			if (uom == UnitOfMeasure.OffOn) return param ? '100' : '0';
			if (uom == UnitOfMeasure.Percent) return param ? '100' : '0';
			if (uom == UnitOfMeasure.LevelFrom0To255) return param ? '255' : '0';
			return param ? '1' : '0';
		}
	}
	public async sendNodeCommand<P extends string | symbol, N extends ISYNode<any, any, any, any>>(
		node: N,
		command: string,
		defaultParameter?: Utils.MaybeWithUOM,
		parameters?: Record<P, Utils.MaybeWithUOM<JsonPrimitive> | undefined> | Utils.MaybeWithUOM<JsonPrimitive>
	): Promise<any> {
		let uriToUse = `nodes/${node.address}/cmd/${command}?`;
		if (defaultParameter !== null && defaultParameter !== undefined) {
			if (Array.isArray(defaultParameter) || isPrimitive(defaultParameter)) {
				uriToUse += `/${this.#formatDefaultParameter(defaultParameter)}?`;
			}
		}
		if (parameters !== null && parameters !== undefined) {
			if (typeof parameters === 'object' && !Array.isArray(parameters)) {
				var q = parameters as Record<P, Utils.MaybeWithUOM<JsonPrimitive> | undefined>;
				for (const paramName in q) {
					let s = q[paramName];
					if (paramName === 'value' || paramName === 'default') {
						uriToUse += `/${this.#formatDefaultParameter(s)}?`;
						continue;
					} else {
						let p = this.#formatParameter(paramName, s);
						if (p === '') continue;
						uriToUse += `${p}&`;
					}
				}

				//uriToUse += `/${q[((p : Record<string,number|number>) => `${p[]}/${p.paramValue}` ).join('/')}`;
			} else {
				uriToUse += `/${this.#formatDefaultParameter(parameters)}`;
			}
		}
		uriToUse = uriToUse.endsWith('?')
			? uriToUse.slice(0, -1)
			: uriToUse.endsWith('&')
				? uriToUse.slice(0, -1)
				: uriToUse;
		uriToUse = uriToUse.replace('?/', '/');
		this.logger.info(`${node.name}: sending ${command} command: ${uriToUse}`);
		return this.sendRequest(uriToUse, { requestLogLevel: 'info' });
	}

	public async sendRequest<T = any>(
		url: string,
		options: {
			parserOptions?: ParserOptions | X2jOptions;
			trailingSlash?: boolean;
			requestLogLevel?: Utils.LogLevel;
			responseLogLevel?: Utils.LogLevel;
			errorLogLevel?: Utils.LogLevel;
			throwOnError?: boolean;
		} & Utils.ISYRequestConfig = { trailingSlash: true }
	): Promise<T> {
		const requestLogLevel = options.requestLogLevel ?? 'debug';
		const responseLogLevel = options.responseLogLevel ?? 'silly';
		url = `${url}${options.trailingSlash && !url.endsWith('/') ? '/' : ''}`;
		const finalUrl = `${this.protocol}://${this.address}/rest/${url}`;

		const reqOps = { ...this.axiosOptions, ...options };
		this.logger.log(
			requestLogLevel,
			`Sending request to ${path.join('/rest', url)} with options: ${Utils.logStringify(reqOps, 0)}`
		);
		/*{
				auth: { username: this.credentials.username, password: this.credentials.password },
				baseURL: `${this.protocol}://${this.address}`,

			}*/
		try {
			delete reqOps.trailingSlash;
			delete reqOps.requestLogLevel;
			const response = await axios.get(reqOps.socketPath ? path.join('/rest', url) : finalUrl, reqOps);
			if (response.data) {
				if (response.headers['content-type'].toString().includes('xml')) {
					let altParser = this.xmlParser;
					if (options.parserOptions) {
						if (typeof options.parserOptions === 'object') {
							this.logger.info('Using custom parser options');
							altParser = new XMLParser({ ...defaultXMLParserOptions, ...options.parserOptions });
						}
					}
					//	{curParser = new Parser({ ...defaultParserOptions, ...options.parserOptions });
					//var altParser = new XMLParser({ ...defaultXMLParserOptions, ...options.parserOptions });
					var s = altParser.parse(response.data);
					this.logger.log(responseLogLevel ?? 'debug', `Response: ${JSON.stringify(s)}`);
					return s;
				} else if (response.headers['content-type'].toString().includes('json')) {
					this.logger.log(responseLogLevel, `Response: ${JSON.stringify(response.data)}`);
					return JSON.parse(response.data);
				} else {
					this.logger.log(
						responseLogLevel,
						`Response Header: ${JSON.stringify(response.headers)} Response: ${JSON.stringify(response.data)}`
					);
					return response.data;
				}
			}
		} catch (error) {
			if (options.throwOnError) {
				throw error;
			} else {
				if (options.errorLogLevel) {
					this.logger.log(options.errorLogLevel, `Error sending request to ISY: ${error?.message}`);
				} else {
					this.logger.error(`Error sending request to ISY: ${error?.message}`);
				}
			}
		}
	}

	public async sendSetVariable(
		id: any,
		type: any,
		value: any,
		handleResult: { (success: any): void; (arg0: boolean): void; (arg0: boolean): void }
	) {
		const uriToUse = `/rest/vars/set/${type}/${id}/${value}`;
		this.logger.info(`Sending ISY command...${uriToUse}`);

		return this.sendRequest(uriToUse);
	}

	#variableChangedHandler(variable: { id: string; type: string }) {
		this.logger.info(`Variable: ${variable.id} (${variable.type}) changed`);
	}

	public async close() {
		try {
			/*let p = new Promise<void>(resolve =>
				this.webSocket?.once('close', (x, y) => {
					resolve();
				})
			);

			this.webSocket?.close();
			await p;*/
			await this.webSocketEndpoint?.close();
			this.logger.info('WebSocket connection closed.');
		} catch (e) {
			this.logger.error(`Error closing websocket: ${e.message}`);
		}
	}

	// #endregion Public Methods (24)

	// #region Private Methods (11)

	#checkForFailure(response: any): boolean {
		return (
			response === null ||
			response instanceof Error ||
			(response.RestResponse !== undefined && response.RestResponse.status !== 200)
		);
	}

	#createVariableKey(type: VariableType, id: number) {
		return `${type}:${id}`;
	}

	#createVariables(type: VariableType, result: any) {
		for (const variable of result.CList.e) {
			const id = Number(variable.id);
			const name = variable.name;
			const newVariable = new ISYVariable(this, id, name, type);
			this.variables.set(this.#createVariableKey(type, id), newVariable);
		}
	}

	async #finishInitialize(success: boolean) {
		if (!this.nodesLoaded) {
			this.nodesLoaded = true;
			//initializeCompleted();
			if (success) {
				// if (this.elkEnabled) {
				// 	this.deviceList[this.elkAlarmPanel.address] = this.elkAlarmPanel;
				// }
				if (this.enableWebSocket) {
					await this.initializeWebSocket();
					//this.guardianTimer = setInterval(this.#guardian.bind(this), this.guardianInterval);
				}
				this.emit('initializeCompleted');
			}
		}
	}

	async #readDeviceNodes(obj: { nodes: { node: NodeInfo[] } }) {
		try {
			this.logger.info('Reading Device Nodes');

			for (const nodeInfo of obj.nodes.node) {
				try {
					this.logger.debug(`Loading Device Node: ${JSON.stringify(nodeInfo)}`);
					if (nodeInfo.enabled) {
						let newDevice = await DeviceFactory.create(this, nodeInfo);
						if (!newDevice && nodeInfo.pnode == nodeInfo.address) {
							this.logger.warn(
								`Device type resolution failed for ${nodeInfo.name} with type: ${nodeInfo.type} and nodedef: ${nodeInfo.nodeDefId}`
							);
							newDevice = new GenericNode(this, nodeInfo);
						}
						if (newDevice) {
							if (this.devices.set) this.devices.set(newDevice.address, newDevice);
						}

						/* if (!this.deviceMap.has(nodeInfo.pnode)) {
						const address = nodeInfo.address;
						this.deviceMap[nodeInfo.pnode] = {
							address
						};
					} else {
						this.deviceMap[nodeInfo.pnode].push(nodeInfo.address);
					}
					let newDevice = null;

					// let deviceTypeInfo = this.isyTypeToTypeName(device.type, device.address);
					// this.logger.info(JSON.stringify(deviceTypeInfo));

					const enabled = nodeInfo.enabled ?? true;
					const d = await NodeFactory.get(nodeInfo);
					const m = DeviceFactory.getDeviceDetails(nodeInfo);
					const cls = m.class ?? d;
					nodeInfo.property = Array.isArray(nodeInfo.property) ? nodeInfo.property : [nodeInfo.property];
					nodeInfo.state = nodeInfo.property.reduce((acc, p) => {
						if (p && p?.id) {
							p.name = p.name == '' ? undefined : p.name;
							acc[p.id] = p;
						}
						return acc;
					}, {});

					if (cls) {
						try {
							newDevice = new cls(this, nodeInfo);
						} catch (e) {
							this.logger.error(`Error creating device ${nodeInfo.name} with type ${nodeInfo.type} and nodedef ${nodeInfo.nodeDefId}: ${e.message}`);
							continue;
						}
						//newDevice = new cls(this, nodeInfo) as ISYDeviceNode<any, any, any, any>;
					}

					if (m && newDevice) {
						newDevice.productName = m.name;
						newDevice.model = `(${m.modelNumber}) ${m.name} v.${m.version}`;
						newDevice.modelNumber = m.modelNumber;
						newDevice.version = m.version;
					}

					if (enabled) {
						if (newDevice === null) {
							this.logger.warn(`Device type resolution failed for ${nodeInfo.name} with type: ${nodeInfo.type} and nodedef: ${nodeInfo.nodeDefId}`);
							newDevice = new GenericNode(this, nodeInfo);
						} else if (newDevice !== null) {
							if (m.unsupported) {
								this.logger.warn('Device not currently supported: ' + JSON.stringify(nodeInfo) + ' /n It has been mapped to: ' + d.name);
							}
							try {
								//await newDevice.refreshNotes();
							} catch (e) {
								this.logger.debug('No notes found.');
							}

							// if (!newDevice.hidden) {
							// }

							// this.deviceList.push(newDevice);
						} else {
						}
						this.nodeMap.set(newDevice.address, CompositeDevice.isComposite(newDevice) ? newDevice.root : newDevice);
						if (CompositeDevice.isComposite(newDevice)) {
							if (this.deviceList.set) this.deviceList.set(newDevice.address, newDevice);
						} */
					} else {
						this.logger.info(`Ignoring disabled device: ${nodeInfo.name}`);
					}
				} catch (e) {
					this.logger.error(`Error loading device node: ${e.message}`);
				}
			}

			await DeviceFactory.addChildNodes(this);
			this.logger.info(`${this.nodes.size} nodes added.`);
			for (const node of this.nodes.values()) {
				try {
					//await node.refreshNotes();
				} catch (e) {
					this.logger.silly('No notes found.');
				}
			}
			/*for (const node of this.nodeMap.values()) {
				if (node.parentAddress !== node.address) {
					let parent = this.deviceList.get(node.parentAddress) as unknown as CompositeDevice<any, any>;
					if (parent && CompositeDevice.isComposite(parent)) parent.addNode(node);
				} else if (!this.deviceList.has(node.address)) {
					this.deviceList.set(node.address, node as unknown as ISYDevice<any, any, any, any>);
				}
			}*/
			this.logger.info(`${this.devices.size} unique devices added.`);
		} catch (e) {
			throw new ISYInitializationError(e, 'readDevices');
		}
	}

	async #readFolderNodes(result: { nodes: { folder: any } }) {
		try {
			this.logger.info('Reading Folder Nodes');
			if (result?.nodes?.folder) {
				for (const folder of result.nodes.folder) {
					this.logger.debug(`Loading Folder Node: ${JSON.stringify(folder)}`);
					this.folderMap.set(folder.address, folder.name);
				}
			}
		} catch (e) {
			throw new ISYInitializationError(e, 'readFolders');
		}
	}

	async #readSceneNodes(result: { nodes: { group: any } }) {
		try {
			this.logger.info('Loading Scene Nodes');
			for (const scene of result.nodes?.group) {
				if (scene.name === 'ISY' || scene.name === 'IoX' || scene.name === 'Auto DR') {
					continue;
				} // Skip ISY & Auto DR Scenes

				const newScene = new ISYScene(this, scene);
				try {
					//await newScene.refreshNotes();
				} catch (e) {
					this.logger.debug('No notes found.');
				}
				this.scenes.set(newScene.address, newScene);
			}
			this.logger.info(`${this.scenes.size} scenes added.`);
		} catch (e) {
			throw new ISYInitializationError(e, 'readScenes');
		}
	}

	#setVariableValues(result: any) {
		for (const vals of result.vars.var) {
			const type = Number(vals.type) as VariableType;
			const id = Number(vals.id);
			const variable = this.getVariable(type, id);
			if (variable) {
				variable.init = vals.init;
				variable.value = vals.val;
				variable.lastChanged = new Date(vals.ts);
			}
		}
	}

	// #endregion Private Methods (11)
}
export { CompositeDevice } from './Devices/CompositeDevice.js';
export { ISYDeviceNode as DeviceNode } from './Devices/ISYDeviceNode.js';
export { ISYNode as Node } from './ISYNode.js';

export * from './Converters.js';
export * from './Definitions/index.js';
export * as Devices from './Devices/index.js';
export * from './ISYError.js';
export { ISYError } from './ISYError.js';
export { ISYVariable as Variable } from './ISYVariable.js';
export * from './Model/index.js';
export { ISYScene as Scene } from './Scenes/ISYScene.js';
export * from './Utils.js';
export { VariableType } from './VariableType.js';

export namespace ISY {
	export interface Config extends ISYConfig {}

	export type Device<F extends Family, D, C, E> = ISYDevice<F, D, C, E>;
}
