import BaseItem, { ItemsThatNeedDecryptionResult } from '../models/BaseItem';
import BaseModel from '../BaseModel';
import MasterKey from '../models/MasterKey';
import Resource from '../models/Resource';
import ResourceService from './ResourceService';
import Logger from '@joplin/utils/Logger';
import shim from '../shim';
import KvStore from './KvStore';
import EncryptionService from './e2ee/EncryptionService';
import PerformanceLogger from '../PerformanceLogger';
import AsyncActionQueue from '../AsyncActionQueue';

const EventEmitter = require('events');
const perfLogger = PerformanceLogger.create();

interface DecryptionResult {
	skippedItemCount?: number;
	decryptedItemCounts?: Record<number, number>;
	decryptedItemCount?: number;
	error: Error | null;
}

// Key for use with the KvStore.
const decryptionErrorKeyPrefix = 'decryptErrorLabel:';
const decryptionErrorKey = (type: number, id: string) => {
	return `${decryptionErrorKeyPrefix}${type}:${id}`;
};
const decryptionCounterKeyPrefix = 'decrypt:';
const decryptionCounterKey = (type: number, id: string) => {
	return `${decryptionCounterKeyPrefix}${type}:${id}`;
};

export default class DecryptionWorker {

	public static instance_: DecryptionWorker = null;

	private state_ = 'idle';
	private logger_: Logger;
	// eslint-disable-next-line @typescript-eslint/ban-types -- Old code before rule was applied
	public dispatch: Function = () => {};
	private scheduleId_: ReturnType<typeof setTimeout> | null = null;
	private eventEmitter_: InstanceType<typeof EventEmitter>;
	private kvStore_: KvStore = null;
	private maxDecryptionAttempts_ = 2;
	private taskQueue_: AsyncActionQueue = new AsyncActionQueue();
	private encryptionService_: EncryptionService = null;

	public constructor() {
		this.state_ = 'idle';
		this.logger_ = new Logger();
		this.eventEmitter_ = new EventEmitter();
	}

	public setLogger(l: Logger) {
		this.logger_ = l;
	}

	public logger() {
		return this.logger_;
	}

	// eslint-disable-next-line @typescript-eslint/ban-types -- Old code before rule was applied
	public on(eventName: string, callback: Function) {
		return this.eventEmitter_.on(eventName, callback);
	}

	// eslint-disable-next-line @typescript-eslint/ban-types -- Old code before rule was applied
	public off(eventName: string, callback: Function) {
		return this.eventEmitter_.removeListener(eventName, callback);
	}

	public static instance() {
		if (DecryptionWorker.instance_) return DecryptionWorker.instance_;
		DecryptionWorker.instance_ = new DecryptionWorker();
		return DecryptionWorker.instance_;
	}

	public setEncryptionService(v: EncryptionService) {
		this.encryptionService_ = v;
	}

	public setKvStore(v: KvStore) {
		this.kvStore_ = v;
	}

	public encryptionService() {
		if (!this.encryptionService_) throw new Error('DecryptionWorker.encryptionService_ is not set!!');
		return this.encryptionService_;
	}

	public kvStore() {
		if (!this.kvStore_) throw new Error('DecryptionWorker.kvStore_ is not set!!');
		return this.kvStore_;
	}

	public async scheduleStart() {
		if (this.scheduleId_) return;

		this.scheduleId_ = shim.setTimeout(() => {
			this.scheduleId_ = null;
			void this.start({
				masterKeyNotLoadedHandler: 'dispatch',
			});
		}, 1000);
	}

	public async decryptionDisabledItems() {
		let items = await this.kvStore().searchByPrefix(decryptionCounterKeyPrefix);
		items = items.filter(item => item.value > this.maxDecryptionAttempts_);
		return await Promise.all(items.map(async item => {
			const s = item.key.split(':');
			const type_ = Number(s[1]);
			const id = s[2];
			const storedError = await this.kvStore().value(decryptionErrorKey(type_, id));
			const errorDescription = typeof storedError === 'string' ? storedError : null;
			return {
				type_,
				id,
				reason: errorDescription,
			};
		}));
	}

	public async clearDisabledItem(typeId: number, itemId: string) {
		await this.kvStore().deleteValue(decryptionCounterKey(typeId, itemId));
		await this.kvStore().deleteValue(decryptionErrorKey(typeId, itemId));
	}

	public async clearDisabledItems() {
		await this.kvStore().deleteByPrefix(decryptionCounterKeyPrefix);
		await this.kvStore().deleteByPrefix(decryptionErrorKeyPrefix);
	}

	// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
	public dispatchReport(report: any) {
		const action = { ...report };
		action.type = 'DECRYPTION_WORKER_SET';
		this.dispatch(action);
	}

	// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
	private async start_(options: any = null): Promise<DecryptionResult> {
		if (options === null) options = {};
		if (!('masterKeyNotLoadedHandler' in options)) options.masterKeyNotLoadedHandler = 'throw';
		if (!('errorHandler' in options)) options.errorHandler = 'log';

		if (this.state_ !== 'idle') {
			const msg = `DecryptionWorker: cannot start because state is "${this.state_}"`;
			this.logger().debug(msg);
			return { error: new Error(msg) };
		}

		// Note: the logic below is an optimisation to avoid going through the loop if no master key exists
		// or if none is loaded. It means this logic needs to be duplicate a bit what's in the loop, like the
		// "throw" and "dispatch" logic.
		const loadedMasterKeyCount = await this.encryptionService().loadedMasterKeysCount();
		if (!loadedMasterKeyCount) {
			const msg = 'DecryptionWorker: cannot start because no master key is currently loaded.';
			this.logger().info(msg);
			const ids = await MasterKey.allIds();

			// Note that the current implementation means that a warning will be
			// displayed even if the user has no encrypted note. Just having
			// encrypted master key is sufficient. Not great but good enough for
			// now.

			if (ids.length) {
				if (options.masterKeyNotLoadedHandler === 'throw') {
					// By trying to load the master key here, we throw the "masterKeyNotLoaded" error
					// which the caller needs.
					await this.encryptionService().loadedMasterKey(ids[0]);
				} else {
					this.dispatch({
						type: 'MASTERKEY_SET_NOT_LOADED',
						ids: ids,
					});
				}
			}
			return { error: new Error(msg) };
		}

		this.logger().info('DecryptionWorker: starting decryption...');

		this.state_ = 'started';

		const excludedIds = [];
		const decryptedItemCounts: Record<number, number> = {};
		let skippedItemCount = 0;

		this.dispatch({ type: 'ENCRYPTION_HAS_DISABLED_ITEMS', value: false });
		this.dispatchReport({ state: 'started' });

		const decryptItemsTask = perfLogger.taskStart('DecryptionWorker/decryptItems');
		try {
			const notLoadedMasterKeyDispatches = [];

			while (true) {
				const result: ItemsThatNeedDecryptionResult = await BaseItem.itemsThatNeedDecryption(excludedIds);
				const items = result.items;

				for (let i = 0; i < items.length; i++) {
					const item = items[i];

					const ItemClass = BaseItem.itemClass(item);

					this.dispatchReport({
						itemIndex: i,
						itemCount: items.length,
					});

					const counterKey = decryptionCounterKey(item.type_, item.id);
					const errorKey = decryptionErrorKey(item.type_, item.id);

					const clearDecryptionCounter = async () => {
						await this.kvStore().deleteValue(counterKey);
						// The decryption error key stores the reason for the decryption counter's value.
						// As such, the error should be reset when the decryption counter is reset:
						await this.kvStore().deleteValue(errorKey);
					};

					// Don't log in production as it results in many messages when importing many items
					// this.logger().debug('DecryptionWorker: decrypting: ' + item.id + ' (' + ItemClass.tableName() + ')');
					try {
						const decryptCounter = await this.kvStore().incValue(counterKey);
						if (decryptCounter > this.maxDecryptionAttempts_) {
							this.logger().debug(`DecryptionWorker: ${BaseModel.modelTypeToName(item.type_)} ${item.id}: Decryption has failed more than 2 times - skipping it`);
							this.dispatch({ type: 'ENCRYPTION_HAS_DISABLED_ITEMS', value: true });
							skippedItemCount++;
							excludedIds.push(item.id);
							continue;
						}

						const decryptedItem = await ItemClass.decrypt(item);

						await clearDecryptionCounter();

						if (!decryptedItemCounts[decryptedItem.type_]) decryptedItemCounts[decryptedItem.type_] = 0;

						decryptedItemCounts[decryptedItem.type_]++;

						if (decryptedItem.type_ === Resource.modelType() && !!decryptedItem.encryption_blob_encrypted) {
							// itemsThatNeedDecryption() will return the resource again if the blob has not been decrypted,
							// but that will result in an infinite loop if the blob simply has not been downloaded yet.
							// So skip the ID for now, and the service will try to decrypt the blob again the next time.
							excludedIds.push(decryptedItem.id);
						}

						if (decryptedItem.type_ === Resource.modelType() && !decryptedItem.encryption_blob_encrypted) {
							this.eventEmitter_.emit('resourceDecrypted', { id: decryptedItem.id });
						}

						if (decryptedItem.type_ === Resource.modelType() && !decryptedItem.encryption_applied && !!decryptedItem.encryption_blob_encrypted) {
							this.eventEmitter_.emit('resourceMetadataButNotBlobDecrypted', { id: decryptedItem.id });
						}
					} catch (error) {
						excludedIds.push(item.id);

						if (error.code === 'masterKeyNotLoaded' && options.masterKeyNotLoadedHandler === 'dispatch') {
							if (notLoadedMasterKeyDispatches.indexOf(error.masterKeyId) < 0) {
								this.dispatch({
									type: 'MASTERKEY_ADD_NOT_LOADED',
									id: error.masterKeyId,
								});
								notLoadedMasterKeyDispatches.push(error.masterKeyId);
							}
							await clearDecryptionCounter();
							continue;
						}

						if (error.code === 'masterKeyNotLoaded' && options.masterKeyNotLoadedHandler === 'throw') {
							await clearDecryptionCounter();
							throw error;
						}

						await this.kvStore().setValue(errorKey, String(error));

						if (options.errorHandler === 'log') {
							this.logger().warn(`DecryptionWorker: error for: ${item.id} (${ItemClass.tableName()})`, error);
							this.logger().debug('Item with error:', item);
						} else {
							throw error;
						}
					}
				}

				if (!result.hasMore) break;
			}
		} catch (error) {
			this.logger().error('DecryptionWorker:', error);
			this.state_ = 'idle';
			this.dispatchReport({ state: 'idle' });
			throw error;
		} finally {
			decryptItemsTask.onEnd();
		}

		// 2019-05-12: Temporary to set the file size of the resources
		// that weren't set in migration/20.js due to being on the sync target
		await ResourceService.autoSetFileSizes();

		this.logger().info('DecryptionWorker: completed decryption.');

		const downloadedButEncryptedBlobCount = await Resource.downloadedButEncryptedBlobCount(excludedIds);

		this.state_ = 'idle';

		let decryptedItemCount = 0;
		for (const itemType in decryptedItemCounts) decryptedItemCount += decryptedItemCounts[itemType];

		const finalReport: DecryptionResult = {
			skippedItemCount: skippedItemCount,
			decryptedItemCounts: decryptedItemCounts,
			decryptedItemCount: decryptedItemCount,
			error: null,
		};

		this.dispatchReport({ ...finalReport, state: 'idle' });

		if (downloadedButEncryptedBlobCount) {
			this.logger().info(`DecryptionWorker: Some resources have been downloaded but are not decrypted yet. Scheduling another decryption. Resource count: ${downloadedButEncryptedBlobCount}`);
			void this.scheduleStart();
		}

		return finalReport;
	}

	// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
	public async start(options: any = {}): Promise<DecryptionResult> {
		let output = null;
		let lastError: Error;

		// Use taskQueue_ to ensure that only one decryption task is running at a time.
		this.taskQueue_.push(async () => {
			const startTask = perfLogger.taskStart('DecryptionWorker/start');
			try {
				output = await this.start_(options);
			} catch (error) {
				lastError = error;
			} finally {
				startTask.onEnd();
			}
		});
		await this.taskQueue_.processAllNow();

		if (lastError) {
			throw lastError;
		}

		// If this task was skipped due to a concurrent start() call, return an empty
		// DecryptionResult instead of null. AsyncActionQueue drops earlier tasks when
		// multiple are queued, but start() guarantees Promise<DecryptionResult> and
		// must not resolve null.
		if (!output) {
			return { error: null, decryptedItemCount: 0, skippedItemCount: 0 };
		}

		return output;
	}

	public async destroy() {
		this.eventEmitter_.removeAllListeners();
		if (this.scheduleId_) {
			shim.clearTimeout(this.scheduleId_);
			this.scheduleId_ = null;
		}
		this.eventEmitter_ = null;
		DecryptionWorker.instance_ = null;

		await this.taskQueue_.waitForAllDone();
	}
}
