import { SimpleEventEmitter } from '@mary-ext/simple-event-emitter';

import type { JsonObject } from 'type-fest';

const SEP = ':';

let store: globalThis.Storage | undefined;
try {
	// the localStorage getter itself throws when storage is denied (blocked cookies, partitioned iframes).
	store = localStorage;
} catch {}

export class Storage<Scopes extends unknown[], Schema extends JsonObject> {
	protected cache = new Map<string, Schema[keyof Schema] | undefined>();
	protected emitters = new Map<string, SimpleEventEmitter<[]>>();
	protected id: string;

	constructor({ id }: { id: string }) {
		this.id = id;

		window.addEventListener('storage', (event) => {
			if (event.storageArea !== store) {
				return;
			}

			if (event.key == null) {
				this.cache.clear();
				for (const emitter of this.emitters.values()) {
					emitter.emit();
				}
				return;
			}

			if (event.key.startsWith(`${this.id}${SEP}`)) {
				this.cache.delete(event.key);
				this.emitters.get(event.key)?.emit();
			}
		});
	}

	protected scopedKey(scopes: unknown[]): string {
		return `${this.id}${SEP}${scopes.join(SEP)}`;
	}

	protected emit(key: string) {
		this.emitters.get(key)?.emit();
	}

	/**
	 * stores a value in storage for a given scope path.
	 *
	 * @param scopes array of scope identifiers leading to the key
	 * @param data value to persist
	 */
	set<Key extends keyof Schema>(scopes: [...Scopes, Key], data: Schema[Key]): void {
		// stored as `{ data: <value> }` structure to ease stringification
		const key = this.scopedKey(scopes);

		try {
			store?.setItem(key, JSON.stringify({ data }));
			this.cache.set(key, data);
			this.emit(key);
		} catch {
			// quota exceeded
		}
	}

	/**
	 * retrieves a value from storage for a given scope path.
	 *
	 * @param scopes array of scope identifiers leading to the key
	 * @returns stored value, or undefined if missing or invalid
	 */
	get<Key extends keyof Schema>(scopes: [...Scopes, Key]): Schema[Key] | undefined {
		const key = this.scopedKey(scopes);

		if (this.cache.has(key)) {
			// oxlint-disable-next-line typescript/no-unsafe-type-assertion -- map values are stored with `Schema[Key]` type
			return this.cache.get(key) as Schema[Key] | undefined;
		}

		let value: Schema[Key] | undefined;
		try {
			const raw = store?.getItem(key) || '{}';

			// oxlint-disable-next-line typescript/no-unsafe-type-assertion
			const parsed = JSON.parse(raw) as { data: Schema[Key] | undefined };

			value = parsed.data;
		} catch {
			// malformed value
		}

		this.cache.set(key, value);
		return value;
	}

	/**
	 * removes a value from storage for a given scope path.
	 *
	 * @param scopes array of scope identifiers leading to the key
	 */
	remove(scopes: [...Scopes, keyof Schema]) {
		const key = this.scopedKey(scopes);

		store?.removeItem(key);
		this.cache.set(key, undefined);
		this.emit(key);
	}

	/**
	 * removes multiple values under the specified scope by keys.
	 *
	 * @param scopes array of scope identifiers
	 * @param keys keys to remove under the scope
	 */
	removeMany(scopes: [...Scopes], keys: (keyof Schema)[]) {
		for (const key of keys) {
			this.remove([...scopes, key]);
		}
	}

	/**
	 * removes stored entries matching a predicate.
	 *
	 * @param predicate selects values to remove
	 */
	removeWhere(predicate: (data: Schema[keyof Schema]) => boolean) {
		const prefix = `${this.id}${SEP}`;
		const keys = new Set<string>();

		for (const key of this.cache.keys()) {
			if (key.startsWith(prefix)) {
				keys.add(key);
			}
		}

		if (store) {
			for (let idx = 0, len = store.length; idx < len; idx++) {
				const key = store.key(idx);
				if (key?.startsWith(prefix)) {
					keys.add(key);
				}
			}
		}

		for (const key of keys) {
			let value = this.cache.get(key);
			if (!this.cache.has(key)) {
				try {
					const raw = store?.getItem(key) || '{}';
					// oxlint-disable-next-line typescript/no-unsafe-type-assertion
					const parsed = JSON.parse(raw) as { data: Schema[keyof Schema] | undefined };
					value = parsed.data;
				} catch {
					// malformed value
				}
			}

			if (value !== undefined && predicate(value)) {
				store?.removeItem(key);
				this.cache.delete(key);
				this.emit(key);
			}
		}
	}

	protected removeByPrefix(prefix: string) {
		const keys = new Set<string>();

		// clear memory even without backing storage.
		for (const key of this.cache.keys()) {
			if (key.startsWith(prefix)) {
				keys.add(key);
			}
		}

		if (store) {
			for (let idx = 0, len = store.length; idx < len; idx++) {
				const key = store.key(idx);
				if (key?.startsWith(prefix)) {
					keys.add(key);
				}
			}
		}

		for (const key of keys) {
			store?.removeItem(key);
			this.cache.delete(key);
			this.emit(key);
		}
	}

	/**
	 * removes all values in a scope.
	 *
	 * @param scopes scope path
	 */
	removeScope(scopes: [...Scopes]) {
		this.removeByPrefix(`${this.scopedKey(scopes)}${SEP}`);
	}

	/** removes all stored keys belonging to this storage instance. */
	removeAll() {
		this.removeByPrefix(`${this.id}${SEP}`);
	}

	/**
	 * attaches a listener for changes to a given scope path.
	 *
	 * @param scopes array of scope identifiers leading to the key
	 * @param callback function invoked when the value changes
	 * @returns function to unsubscribe the listener
	 */
	onScopeChange(scopes: [...Scopes, keyof Schema], callback: () => void) {
		const key = this.scopedKey(scopes);
		let emitter = this.emitters.get(key);
		if (!emitter) {
			emitter = new SimpleEventEmitter();
			this.emitters.set(key, emitter);
		}

		return emitter.subscribe(callback);
	}
}
