import type { JsonlDB } from "@alcalzone/jsonl-db";
import { TypedEventEmitter } from "@zwave-js/shared";
import type { CommandClasses } from "../capabilities/CommandClasses";
import { isZWaveError, ZWaveError, ZWaveErrorCodes } from "../error/ZWaveError";
import type { ValueMetadata } from "../values/Metadata";
import type {
	MetadataUpdatedArgs,
	SetValueOptions,
	ValueAddedArgs,
	ValueID,
	ValueNotificationArgs,
	ValueRemovedArgs,
	ValueUpdatedArgs,
} from "./_Types";

type ValueAddedCallback = (args: ValueAddedArgs) => void;
type ValueUpdatedCallback = (args: ValueUpdatedArgs) => void;
type ValueRemovedCallback = (args: ValueRemovedArgs) => void;
type ValueNotificationCallback = (args: ValueNotificationArgs) => void;
type MetadataUpdatedCallback = (args: MetadataUpdatedArgs) => void;

interface ValueDBEventCallbacks {
	"value added": ValueAddedCallback;
	"value updated": ValueUpdatedCallback;
	"value removed": ValueRemovedCallback;
	"value notification": ValueNotificationCallback;
	"metadata updated": MetadataUpdatedCallback;
}

type ValueDBEvents = Extract<keyof ValueDBEventCallbacks, string>;

export function isValueID(param: Record<any, any>): param is ValueID {
	// commandClass is mandatory and must be numeric
	if (typeof param.commandClass !== "number") return false;
	// property is mandatory and must be a number or string
	if (
		typeof param.property !== "number" &&
		typeof param.property !== "string"
	) {
		return false;
	}
	// propertyKey is optional and must be a number or string
	if (
		param.propertyKey != undefined &&
		typeof param.propertyKey !== "number" &&
		typeof param.propertyKey !== "string"
	) {
		return false;
	}
	// endpoint is optional and must be a number
	if (param.endpoint != undefined && typeof param.endpoint !== "number") {
		return false;
	}
	return true;
}

export function assertValueID(
	param: Record<any, any>,
): asserts param is ValueID {
	if (!isValueID(param)) {
		throw new ZWaveError(
			`Invalid ValueID passed!`,
			ZWaveErrorCodes.Argument_Invalid,
		);
	}
}

/**
 * Ensures all Value ID properties are in the same order and there are no extraneous properties.
 * A normalized value ID can be used as a database key */
export function normalizeValueID(valueID: ValueID): ValueID {
	// valueIdToString is used by all other methods of the Value DB.
	// Since those may be called by unsanitized value IDs, we need
	// to make sure we have a valid value ID at our hands
	assertValueID(valueID);
	const { commandClass, endpoint, property, propertyKey } = valueID;

	const jsonKey: ValueID = {
		commandClass,
		endpoint: endpoint ?? 0,
		property,
	};
	if (propertyKey != undefined) jsonKey.propertyKey = propertyKey;
	return jsonKey;
}

export function valueIdToString(valueID: ValueID): string {
	return JSON.stringify(normalizeValueID(valueID));
}

/**
 * The value store for a single node
 */
export class ValueDB extends TypedEventEmitter<ValueDBEventCallbacks> {
	// This is a wrapper around the driver's on-disk value and metadata key value stores

	/**
	 * @param nodeId The ID of the node this Value DB belongs to
	 * @param valueDB The DB instance which stores values
	 * @param metadataDB The DB instance which stores metadata
	 * @param ownKeys An optional pre-created index of this ValueDB's own keys
	 */
	public constructor(
		nodeId: number,
		valueDB: JsonlDB,
		metadataDB: JsonlDB<ValueMetadata>,
		ownKeys?: Set<string>,
	) {
		super();
		this.nodeId = nodeId;
		this._db = valueDB;
		this._metadata = metadataDB;

		this._index = ownKeys ?? this.buildIndex();
	}

	private nodeId: number;
	private _db: JsonlDB<unknown>;
	private _metadata: JsonlDB<ValueMetadata>;
	private _index: Set<string>;

	private buildIndex(): Set<string> {
		const ret = new Set<string>();
		for (const key of this._db.keys()) {
			if (compareDBKeyFast(key, this.nodeId)) ret.add(key);
		}
		for (const key of this._metadata.keys()) {
			if (!ret.has(key) && compareDBKeyFast(key, this.nodeId))
				ret.add(key);
		}
		return ret;
	}

	private valueIdToDBKey(valueID: ValueID): string {
		return JSON.stringify({
			nodeId: this.nodeId,
			...normalizeValueID(valueID),
		});
	}

	private dbKeyToValueId(key: string): { nodeId: number } & ValueID {
		try {
			// Try the dumb but fast way first
			return dbKeyToValueIdFast(key);
		} catch {
			// Fall back to JSON.parse if anything went wrong
			return JSON.parse(key);
		}
	}

	/**
	 * Stores a value for a given value id
	 */
	public setValue(
		valueId: ValueID,
		value: unknown,
		options: SetValueOptions = {},
	): void {
		let dbKey: string;
		try {
			dbKey = this.valueIdToDBKey(valueId);
		} catch (e) {
			if (
				isZWaveError(e) &&
				e.code === ZWaveErrorCodes.Argument_Invalid &&
				options.noThrow === true
			) {
				// ignore invalid value IDs
				return;
			}
			throw e;
		}

		if (options.stateful !== false) {
			const cbArg: ValueAddedArgs | ValueUpdatedArgs = {
				...valueId,
				newValue: value,
			};
			let event: ValueDBEvents;
			if (this._db.has(dbKey)) {
				event = "value updated";
				(cbArg as ValueUpdatedArgs).prevValue = this._db.get(dbKey);
				if (options.source)
					(cbArg as ValueUpdatedArgs).source = options.source;
			} else {
				event = "value added";
			}

			this._index.add(dbKey);
			this._db.set(dbKey, value);
			if (valueId.commandClass >= 0 && options.noEvent !== true) {
				this.emit(event, cbArg);
			}
		} else if (valueId.commandClass >= 0) {
			// For non-stateful values just emit a notification
			this.emit("value notification", {
				...valueId,
				value,
			});
		}
	}

	/**
	 * Removes a value for a given value id
	 */
	public removeValue(
		valueId: ValueID,
		options: SetValueOptions = {},
	): boolean {
		const dbKey: string = this.valueIdToDBKey(valueId);
		if (!this._metadata.has(dbKey)) {
			this._index.delete(dbKey);
		}
		if (this._db.has(dbKey)) {
			const prevValue = this._db.get(dbKey);
			this._db.delete(dbKey);

			if (valueId.commandClass >= 0 && options.noEvent !== true) {
				const cbArg: ValueRemovedArgs = {
					...valueId,
					prevValue,
				};
				this.emit("value removed", cbArg);
			}
			return true;
		}
		return false;
	}

	/**
	 * Retrieves a value for a given value id
	 */
	public getValue<T = unknown>(valueId: ValueID): T | undefined {
		const key = this.valueIdToDBKey(valueId);
		return this._db.get(key) as T | undefined;
	}

	/**
	 * Checks if a value for a given value id exists in this ValueDB
	 */
	public hasValue(valueId: ValueID): boolean {
		const key = this.valueIdToDBKey(valueId);
		return this._db.has(key);
	}

	/** Returns all values whose id matches the given predicate */
	public findValues(
		predicate: (id: ValueID) => boolean,
	): (ValueID & { value: unknown })[] {
		const ret: ReturnType<ValueDB["findValues"]> = [];
		for (const key of this._index) {
			if (!this._db.has(key)) continue;
			const { nodeId, ...valueId } = this.dbKeyToValueId(key);

			if (predicate(valueId)) {
				ret.push({ ...valueId, value: this._db.get(key) });
			}
		}
		return ret;
	}

	/** Returns all values that are stored for a given CC */
	public getValues(forCC: CommandClasses): (ValueID & { value: unknown })[] {
		const ret: ReturnType<ValueDB["getValues"]> = [];
		for (const key of this._index) {
			if (
				compareDBKeyFast(key, this.nodeId, { commandClass: forCC }) &&
				this._db.has(key)
			) {
				const { nodeId, ...valueId } = this.dbKeyToValueId(key);
				const value = this._db.get(key);
				ret.push({ ...valueId, value });
			}
		}
		return ret;
	}

	/** Clears all values from the value DB */
	public clear(options: SetValueOptions = {}): void {
		for (const key of this._index) {
			const { nodeId, ...valueId } = this.dbKeyToValueId(key);
			if (this._db.has(key)) {
				const prevValue = this._db.get(key);
				this._db.delete(key);

				if (valueId.commandClass >= 0 && options.noEvent !== true) {
					const cbArg: ValueRemovedArgs = {
						...valueId,
						prevValue,
					};
					this.emit("value removed", cbArg);
				}
			}
			if (this._metadata.has(key)) {
				this._metadata.delete(key);

				if (valueId.commandClass >= 0 && options.noEvent !== true) {
					const cbArg: MetadataUpdatedArgs = {
						...valueId,
						metadata: undefined,
					};
					this.emit("metadata updated", cbArg);
				}
			}
		}
		this._index.clear();
	}

	/**
	 * Stores metadata for a given value id
	 */
	public setMetadata(
		valueId: ValueID,
		metadata: ValueMetadata | undefined,
		options: SetValueOptions = {},
	): void {
		let dbKey: string;
		try {
			dbKey = this.valueIdToDBKey(valueId);
		} catch (e) {
			if (
				isZWaveError(e) &&
				e.code === ZWaveErrorCodes.Argument_Invalid &&
				options.noThrow === true
			) {
				// ignore invalid value IDs
				return;
			}
			throw e;
		}

		if (metadata) {
			this._index.add(dbKey);
			this._metadata.set(dbKey, metadata);
		} else {
			if (!this._db.has(dbKey)) {
				this._index.delete(dbKey);
			}
			this._metadata.delete(dbKey);
		}

		const cbArg: MetadataUpdatedArgs = {
			...valueId,
			metadata,
		};
		if (valueId.commandClass >= 0 && options.noEvent !== true) {
			this.emit("metadata updated", cbArg);
		}
	}

	/**
	 * Checks if metadata for a given value id exists in this ValueDB
	 */
	public hasMetadata(valueId: ValueID): boolean {
		const key = this.valueIdToDBKey(valueId);
		return this._metadata.has(key);
	}

	/**
	 * Retrieves metadata for a given value id
	 */
	public getMetadata(valueId: ValueID): ValueMetadata | undefined {
		const key = this.valueIdToDBKey(valueId);
		return this._metadata.get(key);
	}

	/** Returns all metadata that is stored for a given CC */
	public getAllMetadata(forCC: CommandClasses): (ValueID & {
		metadata: ValueMetadata;
	})[] {
		const ret: ReturnType<ValueDB["getAllMetadata"]> = [];
		for (const key of this._index) {
			if (
				compareDBKeyFast(key, this.nodeId, { commandClass: forCC }) &&
				this._metadata.has(key)
			) {
				const { nodeId, ...valueId } = this.dbKeyToValueId(key);
				const metadata = this._metadata.get(key)!;
				ret.push({ ...valueId, metadata });
			}
		}
		return ret;
	}

	/** Returns all values whose id matches the given predicate */
	public findMetadata(predicate: (id: ValueID) => boolean): (ValueID & {
		metadata: ValueMetadata;
	})[] {
		const ret: ReturnType<ValueDB["findMetadata"]> = [];
		for (const key of this._index) {
			if (!this._metadata.has(key)) continue;
			const { nodeId, ...valueId } = this.dbKeyToValueId(key);

			if (predicate(valueId)) {
				ret.push({ ...valueId, metadata: this._metadata.get(key)! });
			}
		}
		return ret;
	}
}

/**
 * Really dumb but very fast way to parse one-lined JSON strings of the following schema
 * {
 *     nodeId: number,
 *     commandClass: number,
 *     endpoint: number,
 *     property: string | number,
 *     propertyKey: string | number,
 * }
 *
 * In benchmarks this was about 58% faster than JSON.parse
 */
export function dbKeyToValueIdFast(key: string): { nodeId: number } & ValueID {
	let start = 10; // {"nodeId":
	if (key.charCodeAt(start - 1) !== 58) {
		console.error(key.slice(start - 1));
		throw new Error("Invalid input format!");
	}
	let end = start + 1;
	const len = key.length;

	while (end < len && key.charCodeAt(end) !== 44) end++;
	const nodeId = parseInt(key.slice(start, end));

	start = end + 16; // ,"commandClass":
	if (key.charCodeAt(start - 1) !== 58)
		throw new Error("Invalid input format!");
	end = start + 1;
	while (end < len && key.charCodeAt(end) !== 44) end++;
	const commandClass = parseInt(key.slice(start, end));

	start = end + 12; // ,"endpoint":
	if (key.charCodeAt(start - 1) !== 58)
		throw new Error("Invalid input format!");
	end = start + 1;
	while (end < len && key.charCodeAt(end) !== 44) end++;
	const endpoint = parseInt(key.slice(start, end));

	start = end + 12; // ,"property":
	if (key.charCodeAt(start - 1) !== 58)
		throw new Error("Invalid input format!");

	let property;
	if (key.charCodeAt(start) === 34) {
		start++; // skip leading "
		end = start + 1;
		while (end < len && key.charCodeAt(end) !== 34) end++;
		property = key.slice(start, end);
		end++; // skip trailing "
	} else {
		end = start + 1;
		while (
			end < len &&
			key.charCodeAt(end) !== 44 &&
			key.charCodeAt(end) !== 125
		)
			end++;
		property = parseInt(key.slice(start, end));
	}

	if (key.charCodeAt(end) !== 125) {
		let propertyKey;
		start = end + 15; // ,"propertyKey":
		if (key.charCodeAt(start - 1) !== 58)
			throw new Error("Invalid input format!");
		if (key.charCodeAt(start) === 34) {
			start++; // skip leading "
			end = start + 1;
			while (end < len && key.charCodeAt(end) !== 34) end++;
			propertyKey = key.slice(start, end);
			end++; // skip trailing "
		} else {
			end = start + 1;
			while (
				end < len &&
				key.charCodeAt(end) !== 44 &&
				key.charCodeAt(end) !== 125
			)
				end++;
			propertyKey = parseInt(key.slice(start, end));
		}
		return {
			nodeId,
			commandClass,
			endpoint,
			property,
			propertyKey,
		};
	} else {
		return {
			nodeId,
			commandClass,
			endpoint,
			property,
		};
	}
}

/** Used to filter DB entries without JSON parsing */
function compareDBKeyFast(
	key: string,
	nodeId: number,
	valueId?: Partial<ValueID>,
): boolean {
	if (-1 === key.indexOf(`{"nodeId":${nodeId},`)) return false;
	if (!valueId) return true;

	if ("commandClass" in valueId) {
		if (-1 === key.indexOf(`,"commandClass":${valueId.commandClass},`))
			return false;
	}
	if ("endpoint" in valueId) {
		if (-1 === key.indexOf(`,"endpoint":${valueId.endpoint},`))
			return false;
	}
	if (typeof valueId.property === "string") {
		if (-1 === key.indexOf(`,"property":"${valueId.property}"`))
			return false;
	} else if (typeof valueId.property === "number") {
		if (-1 === key.indexOf(`,"property":${valueId.property}`)) return false;
	}
	if (typeof valueId.propertyKey === "string") {
		if (-1 === key.indexOf(`,"propertyKey":"${valueId.propertyKey}"`))
			return false;
	} else if (typeof valueId.propertyKey === "number") {
		if (-1 === key.indexOf(`,"propertyKey":${valueId.propertyKey}`))
			return false;
	}
	return true;
}

/** Extracts an index for each node from one or more JSONL DBs */
export function indexDBsByNode(databases: JsonlDB[]): Map<number, Set<string>> {
	const indexes = new Map<number, Set<string>>();
	for (const db of databases) {
		for (const key of db.keys()) {
			const nodeId = extractNodeIdFromDBKeyFast(key);
			if (nodeId == undefined) continue;
			if (!indexes.has(nodeId)) {
				indexes.set(nodeId, new Set());
			}
			indexes.get(nodeId)!.add(key);
		}
	}
	return indexes;
}

function extractNodeIdFromDBKeyFast(key: string): number | undefined {
	const start = 10; // {"nodeId":
	if (key.charCodeAt(start - 1) !== 58) {
		// Invalid input format for a node value, assume it is for the driver
		return undefined;
	}
	let end = start + 1;
	const len = key.length;

	while (end < len && key.charCodeAt(end) !== 44) end++;
	return parseInt(key.slice(start, end));
}
