import { Store } from 'redux';
import JoplinServerApi from '../../JoplinServerApi';
import { _ } from '../../locale';
import Logger from '@joplin/utils/Logger';
import Folder from '../../models/Folder';
import MasterKey from '../../models/MasterKey';
import Note from '../../models/Note';
import Setting from '../../models/Setting';
import { FolderEntity } from '../database/types';
import EncryptionService from '../e2ee/EncryptionService';
import { PublicPrivateKeyPair, mkReencryptFromPasswordToPublicKey, mkReencryptFromPublicKeyToPassword, supportsPpkAlgorithm } from '../e2ee/ppk/ppk';
import { MasterKeyEntity } from '../e2ee/types';
import { getMasterPassword } from '../e2ee/utils';
import ResourceService from '../ResourceService';
import { addMasterKey, getEncryptionEnabled, localSyncInfo } from '../synchronizer/syncInfoUtils';
import { ShareInvitation, SharePermissions, State, stateRootKey, StateShare } from './reducer';
import PerformanceLogger from '../../PerformanceLogger';

const logger = Logger.create('ShareService');
const perfLogger = PerformanceLogger.create();

export interface ApiShare {
	id: string;
	master_key_id: string;
	folder_id: string;
}

// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
function formatShareInvitations(invitations: any[]): ShareInvitation[] {
	return invitations.map(inv => {
		return {
			...inv,
			master_key: inv.master_key ? JSON.parse(inv.master_key) : null,
		};
	});
}

export default class ShareService {

	private static instance_: ShareService;
	private api_: JoplinServerApi = null;
	// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
	private store_: Store<any> = null;
	private encryptionService_: EncryptionService = null;
	private initialized_ = false;

	public static instance(): ShareService {
		if (this.instance_) return this.instance_;
		this.instance_ = new ShareService();
		return this.instance_;
	}

	// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
	public initialize(store: Store<any>, encryptionService: EncryptionService, api: JoplinServerApi = null) {
		this.initialized_ = true;
		this.store_ = store;
		this.encryptionService_ = encryptionService;
		this.api_ = api;
	}

	public get enabled(): boolean {
		if (!this.initialized_) return false;
		return [9, 10, 11].includes(Setting.value('sync.target')); // Joplin Server, Joplin Cloud targets
	}

	// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
	private get store(): Store<any> {
		return this.store_;
	}

	public get state(): State {
		return this.store.getState()[stateRootKey] as State;
	}

	public get userId(): string {
		return this.api() ? this.api().userId : '';
	}

	private api(): JoplinServerApi {
		if (this.api_) return this.api_;

		const syncTargetId = Setting.value('sync.target');

		this.api_ = new JoplinServerApi({
			baseUrl: () => Setting.value(`sync.${syncTargetId}.path`),
			userContentBaseUrl: () => Setting.value(`sync.${syncTargetId}.userContentPath`),
			username: () => Setting.value(`sync.${syncTargetId}.username`),
			password: () => Setting.value(`sync.${syncTargetId}.password`),
			apiKey: () => Setting.value(`sync.${syncTargetId}.apiKey`),
			session: () => {
				if (syncTargetId === 11) {
					return {
						id: Setting.value('sync.11.id'),
						user_id: Setting.value('sync.11.userId'),
					};
				} else {
					return null;
				}
			},
		});

		return this.api_;
	}

	public async shareFolder(folderId: string): Promise<ApiShare> {
		const folder = await Folder.load(folderId);
		if (!folder) throw new Error(`No such folder: ${folderId}`);

		let folderMasterKey: MasterKeyEntity = null;

		if (getEncryptionEnabled()) {
			const syncInfo = localSyncInfo();

			// Shouldn't happen
			if (!syncInfo.ppk) throw new Error('Cannot share notebook because E2EE is enabled and no Public Private Key pair exists.');

			// This can happen if using an outdated Joplin client, where the PPK
			// was generated on a different device with a newer PPK implementation.
			if (!supportsPpkAlgorithm(syncInfo.ppk)) {
				throw new Error('The local public private key pair uses an unsupported algorithm. It may be necessary to upgrade Joplin or share from a different device.');
			}

			if (folder.master_key_id) {
				logger.info(`Folder ${folderId}'s already has a master key. Not creating a new one.`);
			} else {
				// TODO: handle "undefinedMasterPassword" error - show master password dialog
				folderMasterKey = await this.encryptionService_.generateMasterKey(getMasterPassword());
				folderMasterKey = await MasterKey.save(folderMasterKey);

				addMasterKey(syncInfo, folderMasterKey);
			}
		}

		const newFolderProps: FolderEntity = {};

		if (folder.parent_id) newFolderProps.parent_id = '';
		if (folderMasterKey) newFolderProps.master_key_id = folderMasterKey.id;

		if (Object.keys(newFolderProps).length) {
			await Folder.save({
				id: folder.id,
				...newFolderProps,
			});
		}

		const share = await this.api().exec('POST', 'api/shares', {}, {
			folder_id: folderId,
			master_key_id: folderMasterKey ? folderMasterKey.id : '',
		});

		// Note: race condition if the share is created but the app crashes
		// before setting share_id on the folder. See unshareFolder() for info.
		await Folder.save({ id: folder.id, share_id: share.id });
		await Folder.updateAllShareIds(ResourceService.instance(), this.shares);

		return share;
	}

	// This allows the notebook owner to stop sharing it. For a recipient to
	// leave the shared notebook, see the leaveSharedFolder command.
	public async unshareFolder(folderId: string) {
		const folder = await Folder.load(folderId);
		if (!folder) throw new Error(`No such folder: ${folderId}`);

		const share = this.shares.find(s => s.folder_id === folderId);
		if (!share) throw new Error(`No share for folder: ${folderId}`);

		// First, delete the share - which in turns is going to remove the items
		// for all users, except the owner.
		await this.deleteShare(share.id);

		// Clear the master_key_id so that the folder uses a new master key if
		// shared again.
		// TODO: Remove the now-unused master key from local sync info.
		await Folder.save({ id: folderId, master_key_id: '' });

		// Then reset the "share_id" field for the folder and all sub-items.
		// This could potentially be done server-side, when deleting the share,
		// but since clients are normally responsible for maintaining the
		// share_id property, we do it here for consistency. It will also avoid
		// conflicts because changes will come only from the clients.
		//
		// Note that there could be a race condition here if the share is
		// deleted, but the app crashes just before setting share_id to "". It's
		// very unlikely to happen so we leave like this for now.
		//
		// We could potentially have a clean up process at some point:
		//
		// - It would download all share objects
		// - Then look for all items where the share_id is not in any of these
		//   shares objects
		// - And set those to ""
		//
		// Likewise, it could apply the share_id to folders based on
		// share.folder_id
		//
		// Setting the share_id is not critical - what matters is that when the
		// share is deleted, other users no longer have access to the item, so
		// can't change or read them.
		await Folder.save({ id: folder.id, share_id: '' });

		// It's ok if updateAllShareIds() doesn't run because it's executed on
		// each sync too.
		await Folder.updateAllShareIds(ResourceService.instance(), this.shares);
	}

	// This is when a share recipient decides to leave the shared folder.
	//
	// In that case we delete the root folder. Deleting the folder tells the
	// server that we want to leave the share.
	//
	// We also immediately delete the children, but we do not sync the changes
	// otherwise it would delete the items for other users too.
	//
	// If we do not delete them now it would also cause all kind of issues with
	// read-only shares, because the read-only status will be lost after the
	// deletion of the root folder, which means various services may modify the
	// data. The changes will then be rejected by the sync target and cause
	// conflicts.
	//
	// We do not need to sync the children deletion, because the server will
	// take care of deleting all associated user_items. So eventually all the
	// notebook content will also be deleted for the current user.
	//
	// If `folderShareUserId` is provided, the function will check that the user
	// does not own the share. It would be an error to leave such a folder
	// (instead "unshareFolder" should be called).
	public async leaveSharedFolder(folderId: string, folderShareUserId: string = null): Promise<void> {
		if (folderShareUserId !== null) {
			const userId = Setting.value('sync.userId');
			if (folderShareUserId === userId) throw new Error('Cannot leave own notebook');
		}

		const folder = await Folder.load(folderId);

		// We call this to make sure all items are correctly linked before we
		// call deleteAllByShareId()
		await Folder.updateAllShareIds(ResourceService.instance(), this.shares);

		const source = 'ShareService.leaveSharedFolder';
		await Folder.delete(folderId, { deleteChildren: false, disableReadOnlyCheck: true, sourceDescription: source });
		await Folder.deleteAllByShareId(folder.share_id, { disableReadOnlyCheck: true, trackDeleted: false, sourceDescription: source });
	}

	// Finds any folder that is associated with a share, but the user no longer
	// has access to the share, and remove these folders. This check is
	// necessary otherwise sync will try to update items that are not longer
	// accessible and will throw the error "Could not find share with ID: xxxx")
	public async checkShareConsistency() {
		// Sharing is only supported on Joplin Server/Cloud. If the user is
		// using a different sync target, there is no share API to query and
		// any share_id values on folders are stale leftovers from a previous
		// sync target configuration.
		if (!this.enabled) return;

		const rootSharedFolders = await Folder.rootSharedFolders(this.shares);
		let hasRefreshedShares = false;
		let shares = this.shares;

		for (const folder of rootSharedFolders) {
			let share = shares.find(s => s.id === folder.share_id);

			if (!share && !hasRefreshedShares) {
				shares = await this.refreshShares();
				share = shares.find(s => s.id === folder.share_id);
				hasRefreshedShares = true;
			}

			if (!share) {
				// This folder is a associated with a share, but the user no
				// longer has access to this share. It can happen for two
				// reasons:
				//
				// - It no longer exists
				// - Or the user rejected that share from a different device,
				//   and the folder was not deleted as it should have been.
				//
				// In that case we need to leave the notebook.
				logger.warn(`Found a folder that was associated with a share, but the user not longer has access to the share - leaving the folder. Folder: ${folder.title} (${folder.id}). Share: ${folder.share_id}`);
				await this.leaveSharedFolder(folder.id);
			}
		}
	}

	public async shareNote(noteId: string, recursive: boolean): Promise<StateShare> {
		const note = await Note.load(noteId);
		if (!note) throw new Error(`No such note: ${noteId}`);

		const share = await this.api().exec('POST', 'api/shares', {}, {
			note_id: noteId,
			recursive: recursive ? 1 : 0,
		});

		await Note.save({
			id: note.id,
			parent_id: note.parent_id,
			is_shared: 1,
			updated_time: Date.now(),
		}, {
			autoTimestamp: false,
		});

		return share;
	}

	public async unshareNote(noteId: string) {
		const note = await Note.load(noteId);
		if (!note) throw new Error(`No such note: ${noteId}`);

		const shares = await this.refreshShares();
		const noteShares = shares.filter(s => s.note_id === noteId);

		const promises: Promise<void>[] = [];

		for (const share of noteShares) {
			promises.push(this.deleteShare(share.id));
		}

		await Promise.all(promises);

		await Note.save({
			id: note.id,
			parent_id: note.parent_id,
			is_shared: 0,
			updated_time: Date.now(),
		}, {
			autoTimestamp: false,
		});
	}

	public shareUrl(userId: string, share: StateShare): string {
		return `${this.api().personalizedUserContentBaseUrl(userId)}/shares/${share.id}`;
	}

	public folderShare(folderId: string): StateShare {
		return this.shares.find(s => s.folder_id === folderId);
	}

	public isSharedFolderOwner(folderId: string, userId: string = null): boolean {
		if (userId === null) userId = this.userId;

		const share = this.folderShare(folderId);
		if (!share) throw new Error(`Cannot find share associated with folder: ${folderId}`);
		return share.user.id === userId;
	}

	public get shares() {
		return this.state.shares;
	}

	public get shareLinkNoteIds(): string[] {
		return this.shares.filter(s => !!s.note_id).map(s => s.note_id);
	}

	public get shareInvitations() {
		return this.state.shareInvitations;
	}

	private async userPublicKey(userEmail: string): Promise<PublicPrivateKeyPair|''> {
		return await this.api().exec('GET', `api/users/${encodeURIComponent(userEmail)}/public_key`);
	}

	public async addShareRecipient(shareId: string, masterKeyId: string, recipientEmail: string, permissions: SharePermissions) {
		let recipientMasterKey: MasterKeyEntity = null;

		if (getEncryptionEnabled()) {
			if (!recipientEmail) throw new Error(_('Please provide the recipient email'));
			const syncInfo = localSyncInfo();
			const masterKey = syncInfo.masterKeys.find(m => m.id === masterKeyId);
			if (!masterKey) throw new Error(`Cannot find master key with ID "${masterKeyId}"`);

			const recipientPublicKey = await this.userPublicKey(recipientEmail);
			if (!recipientPublicKey) throw new Error(_('Cannot share encrypted notebook with recipient %s because they have not enabled end-to-end encryption. They may do so from the screen Configuration > Encryption.', recipientEmail));

			logger.info('Reencrypting master key with recipient public key', recipientPublicKey);

			recipientMasterKey = await mkReencryptFromPasswordToPublicKey(
				this.encryptionService_,
				masterKey,
				getMasterPassword(),
				recipientPublicKey,
			);
		}

		return this.api().exec('POST', `api/shares/${shareId}/users`, {}, {
			email: recipientEmail,
			master_key: JSON.stringify(recipientMasterKey),
			...permissions,
		});
	}

	public async deleteShareRecipient(shareUserId: string) {
		await this.api().exec('DELETE', `api/share_users/${shareUserId}`);
	}

	public async deleteShare(shareId: string) {
		await this.api().exec('DELETE', `api/shares/${shareId}`);
	}

	private async loadShares() {
		return this.api().exec('GET', 'api/shares');
	}

	private async loadShareUsers(shareId: string) {
		return this.api().exec('GET', `api/shares/${shareId}/users`);
	}

	private async loadShareInvitations() {
		return this.api().exec('GET', 'api/share_users');
	}

	public setProcessingShareInvitationResponse(v: boolean) {
		this.store.dispatch({
			type: 'SHARE_INVITATION_RESPONSE_PROCESSING',
			value: v,
		});
	}

	public async setPermissions(shareId: string, shareUserId: string, permissions: SharePermissions) {
		logger.info('setPermissions: ', shareUserId, permissions);

		await this.api().exec('PATCH', `api/share_users/${shareUserId}`, null, {
			can_read: 1,
			can_write: permissions.can_write,
		});

		this.store.dispatch({
			type: 'SHARE_USER_UPDATE_ONE',
			shareId: shareId,
			shareUser: {
				id: shareUserId,
				...permissions,
			},
		});
	}


	public async respondInvitation(shareUserId: string, masterKey: MasterKeyEntity, accept: boolean) {
		logger.info('respondInvitation: ', shareUserId, accept);

		if (accept) {
			if (masterKey) {
				const getReEncryptedKey = async (ppkCandidates: PublicPrivateKeyPair[]) => {
					let lastError: Error = null;
					for (const ppk of ppkCandidates) {
						lastError = null;
						try {
							return await mkReencryptFromPublicKeyToPassword(
								this.encryptionService_,
								masterKey,
								ppk,
								getMasterPassword(),
								getMasterPassword(),
							);
						} catch (error) {
							logger.warn('Failed to decrypt master key. Has the public key been migrated since the item was shared? Error:', error);
							lastError = error;
						}
					}

					throw lastError;
				};

				// The invitation's masterKey may be encrypted with either the current PPK or a PPK from
				// before a recent migration. Check the old PPK to prevent the sharer from having to
				// create a new invitation just after the recipient runs a migration.
				const ppkCandidates = [localSyncInfo().ppk];

				const cachedPpk = Setting.value('encryption.cachedPpk');
				if ('ppk' in cachedPpk) {
					ppkCandidates.push(cachedPpk.ppk);
				}

				const reencryptedMasterKey = await getReEncryptedKey(ppkCandidates);
				logger.info('respondInvitation: Key has been reencrypted using master password', reencryptedMasterKey);

				await MasterKey.save(reencryptedMasterKey);
			}

			await this.api().exec('PATCH', `api/share_users/${shareUserId}`, null, { status: 1 });
		} else {
			await this.api().exec('PATCH', `api/share_users/${shareUserId}`, null, { status: 2 });
		}
	}

	public async refreshShareInvitations() {
		const result = await this.loadShareInvitations();

		const invitations = formatShareInvitations(result.items);
		logger.info('Refresh share invitations:', invitations);

		this.store.dispatch({
			type: 'SHARE_INVITATION_SET',
			shareInvitations: invitations,
		});
	}

	public async shareById(id: string) {
		const stateShare = this.state.shares.find(s => s.id === id);
		if (stateShare) return stateShare;

		const refreshedShares = await this.refreshShares();
		const refreshedShare = refreshedShares.find(s => s.id === id);
		if (!refreshedShare) throw new Error(`Could not find share with ID: ${id}`);
		return refreshedShare;
	}

	// In most cases the share objects will already be part of the state, so
	// this function checks there first. If the required share objects are not
	// present, it refreshes them from the API.
	public async sharesByIds(ids: string[]) {
		const buildOutput = async (shares: StateShare[]) => {
			const output: Record<string, StateShare> = {};
			for (const share of shares) {
				if (ids.includes(share.id)) output[share.id] = share;
			}
			return output;
		};

		let output = await buildOutput(this.state.shares);
		if (Object.keys(output).length === ids.length) return output;

		const refreshedShares = await this.refreshShares();
		output = await buildOutput(refreshedShares);

		if (Object.keys(output).length !== ids.length) {
			logger.error('sharesByIds: Need:', ids);
			logger.error('sharesByIds: Got:', Object.keys(refreshedShares));
			throw new Error('Could not retrieve required share objects');
		}

		return output;
	}

	public async refreshShares(): Promise<StateShare[]> {
		const result = await this.loadShares();

		logger.info('Refreshed shares:', result);

		this.store.dispatch({
			type: 'SHARE_SET',
			shares: result.items,
		});

		return result.items;
	}

	public async refreshShareUsers(shareId: string) {
		const result = await this.loadShareUsers(shareId);

		logger.info('Refreshed share users:', result);

		this.store.dispatch({
			type: 'SHARE_USER_SET',
			shareId: shareId,
			shareUsers: result.items,
		});
	}

	private async updateNoLongerSharedItems() {
		const shareIds = this.shares.map(share => share.id).concat(this.shareInvitations.map(si => si.share.id));
		await Folder.updateNoLongerSharedItems(shareIds);
	}

	public async maintenance() {
		const task = perfLogger.taskStart('ShareService/maintenance');
		if (this.enabled) {
			let hasError = false;

			try {
				await this.refreshShareInvitations();
			} catch (error) {
				hasError = true;
				logger.error('Maintenance: Failed to update share invitations:', error);
			}

			try {
				await this.refreshShares();
			} catch (error) {
				hasError = true;
				logger.error('Maintenance: Failed to refresh shares:', error);
			}

			Setting.setValue('sync.userId', this.api().userId);

			// If there was no errors, it means we have all the share objects,
			// so we can run the clean up function.
			if (!hasError) await this.updateNoLongerSharedItems();
		}
		task.onEnd();
	}

}
