import { EventEmitter } from 'events';
import { createLogger } from '../common/logger';
import { HamokConnection } from './HamokConnection';
import * as Collections from '../common/Collections';
import { ConcurrentExecutor } from '../common/ConcurrentExecutor';
import { RemoteMap } from './RemoteMap';
import { HamokRemoteMapSnapshot } from '../HamokSnapshot';

const logger = createLogger('HamokRemoteMap');

export type HamokRemoteMapEventMap<K, V> = {
	'insert': [key: K, value: V],
	'update': [key: K, oldValue: V, newValue: V],
	'remove': [key: K, value: V],
	'clear': [],
	'close': [],
}

export declare interface HamokRemoteMap<K, V> {
	on<U extends keyof HamokRemoteMapEventMap<K, V>>(event: U, listener: (...args: HamokRemoteMapEventMap<K, V>[U]) => void): this;
	off<U extends keyof HamokRemoteMapEventMap<K, V>>(event: U, listener: (...args: HamokRemoteMapEventMap<K, V>[U]) => void): this;
	once<U extends keyof HamokRemoteMapEventMap<K, V>>(event: U, listener: (...args: HamokRemoteMapEventMap<K, V>[U]) => void): this;
	emit<U extends keyof HamokRemoteMapEventMap<K, V>>(event: U, ...args: HamokRemoteMapEventMap<K, V>[U]): boolean;
}

/**
 * A remote map is a map that is stored on a remote endpoint and magaged by Hamok instances
 */
// eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging
export class HamokRemoteMap<K, V> extends EventEmitter {
	private _closed = false;
	public equalValues: (a: V, b: V) => boolean;
	private readonly _executor = new ConcurrentExecutor(1);

	/**
	 * Flag indicate if the storage emit events and notify other storage to emit events (if this is the leader)
	 */
	public emitEvents = true;

	private _initializing?: Promise<this>;

	/**
	 * The last commit index that was applied to the map
	 */
	private _appliedCommitIndex = -1;

	/**
	 * Whether this endpoint is the leader
	 */
	private _leader = false;

	/**
	 * 
	 * @param supplier the supplied action has to be executed if this endpoint is the leader
	 * @param commitIndex the commit index that the action is associated with
	 * @param onCompleted callback if this endpoint is the leader and the action is executed
	 * @returns 
	 */
	private async _executeIfLeader<T>(supplier: () => Promise<T>, commitIndex?: number, onCompleted?: (input: T) => void) {
		if (this._closed) throw new Error(`Cannot execute on a closed storage (${this.id})`);
		
		logger.trace('Executing action for %s, appliedCommitIndex: %d, commitIndex: %d', 
			this.id,
			this._appliedCommitIndex,
			commitIndex
		);

		if (commitIndex === undefined) {
			return logger.warn('Cannot execute action in storage %s becasue the provided commit index undefined', this.id);
		} else if (!this._leader) {
			return logger.trace('Not the leader for %s', this.id);
		}

		logger.trace('Executing action for %s, commitIndex: %d', this.id, commitIndex);

		try {
			const input = await this._executor.execute(supplier);

			if (commitIndex <= this._appliedCommitIndex) {
				logger.warn('Commit index is less than the applied commit index for %s. appliedCommitIndex: %d, commitIndex: %d', this.id, this._appliedCommitIndex, commitIndex);
			}
			this._appliedCommitIndex = commitIndex;

			onCompleted?.(input);
		} catch (err) {
			logger.error('Error executing on %s', this.id, err);
		}
	}

	public constructor(
		public readonly connection: HamokConnection<K, V>,
		public readonly remoteMap: RemoteMap<K, V>,
		equalValues?: (a: V, b: V) => boolean,
	) {
		super();
		this.setMaxListeners(Infinity);
		
		this.equalValues = equalValues ?? ((a, b) => {
			// logger.info('Comparing values: %o (%s), %o (%s)', a, b, JSON.stringify(a), JSON.stringify(b));
			return JSON.stringify(a) === JSON.stringify(b);
		});

		this.connection
			// StorageAppliedCommitNotification is deprecated in favor of StorageState which contains the applied commit index
			// .on('StorageAppliedCommitNotification', (notification) => {
			// 	if (!this._leader) {
			// 		this._appliedCommitIndex = notification.appliedCommitIndex;
			// 	}
			// })
			.on('ClearEntriesRequest', (request, commitIndex) => {
				this._executeIfLeader(() => this.remoteMap.clear(), commitIndex, () => {
					this.connection.respond(
						'ClearEntriesResponse', 
						request.createResponse(), 
						request.sourceEndpointId
					);

					if (this.emitEvents) {
						this.connection.notifyClearEntries(this.connection.grid.remotePeerIds);
						this.emit('clear');
					}
				});
			})
			.on('ClearEntriesNotification', () => this.emit('clear'))
			.on('DeleteEntriesRequest', (request, commitIndex) => {
				this._executeIfLeader(() => this.remoteMap.removeAll(request.keys.values()), commitIndex, (removedEntries) => {
					this.connection.respond(
						'DeleteEntriesResponse', 
						request.createResponse(
							new Set(removedEntries.keys())
						), 
						request.sourceEndpointId
					);
					
					if (this.emitEvents) {
						this.connection.notifyEntriesRemoved(removedEntries, this.connection.grid.remotePeerIds);
						removedEntries.forEach((v, k) => this.emit('remove', k, v));
					}
				});
			})
			.on('InsertEntriesRequest', (request, commitIndex) => {
				this._executeIfLeader(async () => {
					const existingEntries = await this.remoteMap.getAll(request.entries.keys());
					const insertedEntries = new Map<K, V>();

					for (const [ key, value ] of request.entries) {
						if (existingEntries.has(key)) {
							continue;
						}
						insertedEntries.set(key, value);
					}

					await this.remoteMap.setAll(request.entries, () => void 0);
					
					return insertedEntries;
				}, commitIndex, (insertedEntries) => {
					this.connection.respond(
						'InsertEntriesResponse',
						request.createResponse(new Map()),
						request.sourceEndpointId
					);

					if (this.emitEvents) {
						this.connection.notifyEntriesInserted(insertedEntries, this.connection.grid.remotePeerIds);
						insertedEntries.forEach((value, key) => this.emit('insert', key, value));
					}
				});
			})
			.on('EntriesInsertedNotification', (insertedEntries) => insertedEntries.entries.forEach((v, k) => this.emit('insert', k, v)))
			.on('RemoveEntriesRequest', (request, commitIndex) => {
				this._executeIfLeader(() => this.remoteMap.removeAll(request.keys.values()), commitIndex, (removedEntries) => {
					this.connection.respond(
						'RemoveEntriesResponse', 
						request.createResponse(
							removedEntries
						), 
						request.sourceEndpointId
					);
					
					if (this.emitEvents) {
						this.connection.notifyEntriesRemoved(removedEntries, this.connection.grid.remotePeerIds);
						removedEntries.forEach((v, k) => this.emit('remove', k, v));
					}
				});
			})
			.on('EntriesRemovedNotification', (removedEntries) => {
				removedEntries.entries.forEach((v, k) => this.emit('remove', k, v));
			})
			.on('UpdateEntriesRequest', (request, commitIndex) => {
				// logger.warn('Accepting UpdateEntriesRequest %s, commitIndex: %d', request.requestId, commitIndex);
				this._executeIfLeader(async () => {
					// logger.warn('%s UpdateEntriesRequest: %o, %s, prevValue: %o', this.connection.grid.localPeerId, request, [ ...request.entries ].join(', '), request.prevValue);

					const updatedEntries: [K, V, V][] = [];
					const insertedEntries: [K, V][] = [];
	
					if (request.prevValue !== undefined) {
						// this is a conditional update
						if (request.entries.size !== 1) {
							// we let the request to timeout
							logger.trace('Conditional update request must have only one entry: %o', request);

							return { insertedEntries, updatedEntries };
						}
						const [ key, value ] = [ ...request.entries ][0];
	
						const existingValue = await this.remoteMap.get(key);
	
						// logger.warn('Conditional update request: %s, %s, %s, %s', key, value, existingValue, request.prevValue);
	
						if (existingValue && this.equalValues(existingValue, request.prevValue)) {
							await this.remoteMap.set(key, value, () => void 0);
							updatedEntries.push([ key, existingValue, value ]);
							// logger.warn('Conditional update request: %s, %s, %s, %s', key, value, existingValue, request.prevValue);
						}
					} else {
						await this.remoteMap.setAll(request.entries, ({ inserted, updated }) => {
							insertedEntries.push(...inserted);
							updatedEntries.push(...updated);
						});
					}

					return { insertedEntries, updatedEntries };
				}, commitIndex, ({ insertedEntries, updatedEntries }) => {
					this.connection.respond(
						'UpdateEntriesResponse',
						request.createResponse(new Map<K, V>(updatedEntries.map(([ key, oldValue ]) => [ key, oldValue ]))),
						request.sourceEndpointId
					);

					if (this.emitEvents) {
						logger.trace('Emitting events for %s, insertedEntries: %s, updatedEntries: %s', 
							this.connection.grid.localPeerId,
							insertedEntries.map(([ key, value ]) => `${key}=${value}`).join(', '),
							updatedEntries.map(([ key, oldValue, newValue ]) => `${key}=${JSON.stringify(oldValue)}=>${JSON.stringify(newValue)}`).join(', ')
						);
						insertedEntries.forEach(([ key, value ]) => this.emit('insert', key, value));
						this.connection.notifyEntriesInserted(new Map(insertedEntries), this.connection.grid.remotePeerIds);
	
						updatedEntries.forEach(([ key, oldValue, newValue ]) => {
							this.emit('update', key, oldValue, newValue);
							this.connection.notifyEntryUpdated(key, oldValue, newValue, this.connection.grid.remotePeerIds);
						});
					}
				});
				
			})
			.on('EntryUpdatedNotification', ({ key, newValue, oldValue }) => {
				this.emit('update', key, oldValue, newValue);
			})
			.on('leader-changed', (leaderId) => {
				// this is all we need to know
				this._leader = leaderId === this.connection.grid.localPeerId;
			})
			.on('StorageHelloNotification', (notification) => {
				// we only reply to it if this endpoint is the leader
				// if there is no leader in the grid the fetch on the remote endpoint should retry
				if (!this._leader) return;
				try {
					const snapshot = this.export();
					const serializedSnapshot = JSON.stringify(snapshot);
	
					this.connection.notifyStorageState(
						serializedSnapshot,
						// and this is the trick here since this storage is async.
						// the connection has it's own pace to emit commits, but we execute it async, so we need to know 
						// where this storage is at the moment. 
						this._appliedCommitIndex,
						notification.sourceEndpointId, 
					);
				} catch (err) {
					logger.error('Failed to send snapshot', err);
				}
			})
			.on('remote-snapshot', (serializedSnapshot, done) => {
				try {
					const snapshot = JSON.parse(serializedSnapshot) as HamokRemoteMapSnapshot;

					this._import(
						snapshot, 
					);
				} catch (err) {
					logger.error(`Failed to import to record ${this.id}. Error: ${err}`);
				} finally {
					done();
				}
			})
			.once('close', () => this.close())
		;

		this._initializing = new Promise((resolve) => setTimeout(resolve, 20))
			.then(() => this.connection.join())
			.then(() => this)
			.catch((err) => {
				logger.error('Error while initializing remote map', err);

				return this;
			})
			.finally(() => (this._initializing = undefined));
	}

	public get id(): string {
		return this.connection.config.storageId;
	}

	public get ready(): Promise<this> {
		return this._initializing ?? this.connection.grid.waitUntilCommitHead().then(() => this);
	}

	public get closed() {
		return this._closed;
	}

	public close(): void {
		if (this._closed) return;
		this._closed = true;

		this.connection.close();
		
		if (this.emitEvents) {
			this.emit('close');
		}
		this.removeAllListeners();
	}
    
	public size() {
		return this.remoteMap.size();
	}

	public async isEmpty(): Promise<boolean> {
		return await this.remoteMap.size() === 0;
	}

	public keys() {
		return this.remoteMap.keys();
	}

	public async clear(): Promise<void> {
		if (this._closed) throw new Error(`Cannot clear a closed storage (${this.id})`);
		
		return this.connection.requestClearEntries();
	}

	public async get(key: K): Promise<V | undefined> {
		if (this._closed) throw new Error(`Cannot get entries from a closed storage (${this.id})`);
		
		return this.remoteMap.get(key);
	}

	public async getAll(keys: IterableIterator<K> | K[]): Promise<ReadonlyMap<K, V>> {
		if (this._closed) throw new Error(`Cannot get entries from a closed storage (${this.id})`);

		if (Array.isArray(keys)) return this.remoteMap.getAll(keys.values());
		else return this.remoteMap.getAll(keys);
	}
    
	public async set(key: K, value: V): Promise<V | undefined> {
		if (this._closed) throw new Error(`Cannot set an entry on a closed storage (${this.id})`);

		const result = await this.setAll(
			Collections.mapOf([ key, value ])
		);
        
		return result.get(key);
	}
    
	public async setAll(entries: ReadonlyMap<K, V>): Promise<ReadonlyMap<K, V>> {
		if (this._closed) throw new Error(`Cannot set entries on a closed storage (${this.id})`);

		if (entries.size < 1) {
			return Collections.emptyMap<K, V>();
		}

		return this.connection.requestUpdateEntries(entries);
	}
    
	public async insert(key: K, value: V): Promise<V | undefined> {
		const result = await this.insertAll(
			Collections.mapOf([ key, value ])
		);
        
		return result.get(key);
	}
    
	public async insertAll(entries: ReadonlyMap<K, V> | [K, V][]): Promise<ReadonlyMap<K, V>> {
		if (this._closed) throw new Error(`Cannot insert entries on a closed storage (${this.id})`);

		if (Array.isArray(entries)) {
			if (entries.length < 1) return Collections.emptyMap<K, V>();
			entries = Collections.mapOf(...entries);
		}

		if (entries.size < 1) {
			return Collections.emptyMap<K, V>();
		}

		return this.connection.requestInsertEntries(entries);
	}
    
	public async delete(key: K): Promise<boolean> {
		const result = await this.deleteAll(
			Collections.setOf(key)
		);
        
		return result.has(key);
	}
    
	public async deleteAll(keys: ReadonlySet<K> | K[]): Promise<ReadonlySet<K>> {
		if (this._closed) throw new Error(`Cannot delete entries on a closed storage (${this.id})`);

		if (Array.isArray(keys)) {
			if (keys.length < 1) return Collections.emptySet<K>();
			keys = Collections.setOf(...keys);
		}
		if (keys.size < 1) {
			return Collections.emptySet<K>();
		}
		
		return this.connection.requestDeleteEntries(keys);
	}

	public async remove(key: K): Promise<boolean> {
		const result = await this.removeAll(
			Collections.setOf(key)
		);

		return result.has(key);
	}

	public async removeAll(keys: ReadonlySet<K> | K[]): Promise<ReadonlyMap<K, V>> {
		if (this._closed) throw new Error(`Cannot remove entries on a closed storage (${this.id})`);

		if (Array.isArray(keys)) {
			if (keys.length < 1) return Collections.emptyMap<K, V>();
			keys = Collections.setOf(...keys);
		}
		if (keys.size < 1) {
			return Collections.emptyMap<K, V>();
		}
        
		return this.connection.requestRemoveEntries(keys);
	}

	public async updateIf(key: K, value: V, oldValue: V): Promise<boolean> {
		if (this._closed) throw new Error(`Cannot update an entry on a closed storage (${this.id})`);

		logger.trace('%s UpdateIf: %s, %s, %s', this.connection.grid.localPeerId, key, value, oldValue);
		
		return (await this.connection.requestUpdateEntries(
			Collections.mapOf([ key, value ]),
			undefined,
			oldValue
		)).get(key) !== undefined;
	}
    
	public iterator(): AsyncIterableIterator<[K, V]> {
		return this.remoteMap.iterator();
	}

	/**
	 * Exports the storage data
	 */
	public export(): HamokRemoteMapSnapshot {
		if (this._closed) {
			throw new Error(`Cannot export data on a closed storage (${this.id})`);
		}
		const result: HamokRemoteMapSnapshot = {
			mapId: this.id,
			appliedCommitIndex: this._appliedCommitIndex,
		};

		return result;
	}

	public import(data: HamokRemoteMapSnapshot) {
		if (data.mapId !== this.id) {
			throw new Error(`Cannot import data from a different storage: ${data.mapId} !== ${this.id}`);
		} else if (this.connection.connected) {
			throw new Error('Cannot import data while connected');
		} else if (this._closed) {
			throw new Error(`Cannot import data on a closed storage (${this.id})`);
		}
	}

	private _import(snapshot: HamokRemoteMapSnapshot) {
		if (this._closed) {
			throw new Error(`Cannot import data on a closed storage (${this.id})`);
		}
		this._appliedCommitIndex = snapshot.appliedCommitIndex;
	}
}
