import Note from '../../models/Note';
import { createFolderTree, encryptionService, loadEncryptionMasterKey, msleep, resourceService, setupDatabaseAndSynchronizer, simulateReadOnlyShareEnv, supportDir, switchClient, synchronizerStart } from '../../testing/test-utils';
import ShareService, { ApiShare } from './ShareService';
import { NoteEntity, ResourceEntity } from '../database/types';
import Folder from '../../models/Folder';
import { localSyncInfo, setEncryptionEnabled, setPpk } from '../synchronizer/syncInfoUtils';
import { generateKeyPair } from '../e2ee/ppk/ppk';
import MasterKey from '../../models/MasterKey';
import { MasterKeyEntity } from '../e2ee/types';
import { generateMasterKeyAndEnableEncryption, loadMasterKeysFromSettings, setupAndEnableEncryption, updateMasterPassword } from '../e2ee/utils';
import Logger, { LogLevel } from '@joplin/utils/Logger';
import shim from '../../shim';
import Resource from '../../models/Resource';
import { readFile } from 'fs-extra';
import BaseItem from '../../models/BaseItem';
import ResourceService from '../ResourceService';
import Setting from '../../models/Setting';
import { ModelType } from '../../BaseModel';
import { remoteNotesFoldersResources } from '../../testing/test-utils-synchronizer';
import mockShareService from '../../testing/share/mockShareService';

const testImagePath = `${supportDir}/photo.jpg`;


const mockServiceForNoteSharing = () => {
	return mockShareService({
		getShares: async () => {
			return { items: [] };
		},
		postShares: async () => null,
		getShareInvitations: async () => null,
	});
};

describe('ShareService', () => {

	beforeEach(async () => {
		await setupDatabaseAndSynchronizer(1);
		await switchClient(1);
	});

	it('should not change the note user timestamps when sharing or unsharing', async () => {
		let note = await Note.save({});
		const service = mockServiceForNoteSharing();
		await msleep(1);
		await service.shareNote(note.id, false);

		function checkTimestamps(previousNote: NoteEntity, newNote: NoteEntity) {
			// After sharing or unsharing, only the updated_time property should
			// be updated, for sync purposes. All other timestamps shouldn't
			// change.
			expect(previousNote.user_created_time).toBe(newNote.user_created_time);
			expect(previousNote.user_updated_time).toBe(newNote.user_updated_time);
			expect(previousNote.updated_time < newNote.updated_time).toBe(true);
			expect(previousNote.created_time).toBe(newNote.created_time);
		}

		{
			const noteReloaded = await Note.load(note.id);
			checkTimestamps(note, noteReloaded);
			note = noteReloaded;
		}

		await msleep(1);
		await service.unshareNote(note.id);

		{
			const noteReloaded = await Note.load(note.id);
			checkTimestamps(note, noteReloaded);
		}
	});

	it('should not encrypt items that are shared', async () => {
		const folder = await Folder.save({});
		const note = await Note.save({ parent_id: folder.id });
		await shim.attachFileToNote(note, testImagePath);

		const service = mockServiceForNoteSharing();

		setEncryptionEnabled(true);
		await loadEncryptionMasterKey();

		await synchronizerStart();

		let previousBlobUpdatedTime = Infinity;
		{
			const allItems = await remoteNotesFoldersResources();
			expect(allItems.map(it => it.encryption_applied)).toEqual([1, 1, 1]);
			previousBlobUpdatedTime = allItems.find(it => it.type_ === ModelType.Resource).blob_updated_time;
		}

		await service.shareNote(note.id, false);
		await msleep(1);
		await Folder.updateAllShareIds(resourceService(), []);

		await synchronizerStart();

		{
			const allItems = await remoteNotesFoldersResources();
			expect(allItems.find(it => it.type_ === ModelType.Note).encryption_applied).toBe(0);
			expect(allItems.find(it => it.type_ === ModelType.Folder).encryption_applied).toBe(1);

			const resource: ResourceEntity = allItems.find(it => it.type_ === ModelType.Resource);
			expect(resource.encryption_applied).toBe(0);

			// Indicates that both the metadata and blob have been decrypted on
			// the sync target.
			expect(resource.blob_updated_time).toBe(resource.updated_time);
			expect(resource.blob_updated_time).toBeGreaterThan(previousBlobUpdatedTime);
		}
	});

	// eslint-disable-next-line @typescript-eslint/ban-types -- Old code before rule was applied
	function testShareFolderService(extraExecHandlers: Record<string, Function> = {}) {
		let nextShareId = 1;
		let shares: ApiShare[] = [];
		const shareByFolderId = (folderId: string) => {
			return shares.find(share => share.folder_id === folderId);
		};

		return mockShareService({
			// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
			onExec: async (method: string, path: string, query: Record<string, any>, body: any) => {
				if (extraExecHandlers[`${method} ${path}`]) return extraExecHandlers[`${method} ${path}`](query, body);

				if (method === 'GET' && path === 'api/shares') {
					return {
						items: [...shares],
					};
				}

				if (method === 'POST' && path === 'api/shares') {
					// Return the existing share, if it exists. This is to match the behavior
					// of Joplin Server.
					const existingShare = shareByFolderId(body.folder_id);
					if (existingShare) {
						return existingShare;
					}

					// Use a predictable ID:
					const id = `share_${nextShareId++}`;

					const share = {
						id,
						master_key_id: body.master_key_id,
						folder_id: body.folder_id,
					};
					shares.push(share);
					return share;
				}

				if (method === 'DELETE' && path.startsWith('api/shares/')) {
					const id = path.replace(/^api\/shares\//, '');
					shares = shares.filter(share => share.id !== id);
					return;
				}

				throw new Error(`Unhandled: ${method} ${path}`);
			},
		});
	}

	const prepareNoteFolderResource = async () => {
		const folder = await Folder.save({});
		let note = await Note.save({ parent_id: folder.id });
		note = await shim.attachFileToNote(note, testImagePath);
		const resourceId = (await Note.linkedResourceIds(note.body))[0];
		const resource = await Resource.load(resourceId);

		await resourceService().indexNoteResources();

		return { folder, note, resource };
	};

	async function testShareFolder(service: ShareService) {
		const { folder, note, resource } = await prepareNoteFolderResource();
		const share = await service.shareFolder(folder.id);
		expect(share.id).toBe('share_1');
		expect((await Folder.load(folder.id)).share_id).toBe('share_1');
		expect((await Note.load(note.id)).share_id).toBe('share_1');
		expect((await Resource.load(resource.id)).share_id).toBe('share_1');

		return { share, folder, note, resource };
	}

	it('should share a folder', async () => {
		await testShareFolder(testShareFolderService());
	});

	it('should share a folder - E2EE', async () => {
		const masterKey = await loadEncryptionMasterKey();
		await setupAndEnableEncryption(encryptionService(), masterKey, '111111');
		const ppk = await generateKeyPair(encryptionService(), '111111');
		setPpk(ppk);

		const shareService = testShareFolderService();

		expect(await MasterKey.count()).toBe(1);

		let { folder, note, resource } = await prepareNoteFolderResource();

		BaseItem.shareService_ = shareService;
		Resource.shareService_ = shareService;

		await shareService.shareFolder(folder.id);

		await Folder.updateAllShareIds(resourceService(), []);

		// The share service should automatically create a new encryption key
		// specifically for that shared folder
		expect(await MasterKey.count()).toBe(2);

		folder = await Folder.load(folder.id);
		note = await Note.load(note.id);
		resource = await Resource.load(resource.id);

		// The key that is not the master key is the folder key
		const folderKey = (await MasterKey.all()).find(mk => mk.id !== masterKey.id);

		// Double-check that it's going to encrypt the folder using the shared
		// key (and not the user's own master key)
		expect(folderKey.id).not.toBe(masterKey.id);
		expect(folder.master_key_id).toBe(folderKey.id);

		await loadMasterKeysFromSettings(encryptionService());

		try {
			const serializedNote = await Note.serializeForSync(note);
			expect(serializedNote).toContain(folderKey.id);

			// The resource should be encrypted using the above key (if it is,
			// the key ID will be in the header).
			const result = await Resource.fullPathForSyncUpload(resource);
			const content = await readFile(result.path, 'utf8');
			expect(content).toContain(folderKey.id);

			{
				await synchronizerStart();
				const remoteItems = await remoteNotesFoldersResources();
				expect(remoteItems.map(it => it.encryption_applied)).toEqual([1, 1, 1]);
			}
		} finally {
			BaseItem.shareService_ = shareService;
			Resource.shareService_ = null;
		}
	});

	it('should add a recipient', async () => {
		setEncryptionEnabled(true);
		await updateMasterPassword('', '111111');
		const ppk = await generateKeyPair(encryptionService(), '111111');
		setPpk(ppk);
		const recipientPpk = await generateKeyPair(encryptionService(), '222222');
		expect(ppk.id).not.toBe(recipientPpk.id);

		let uploadedEmail = '';
		let uploadedMasterKey: MasterKeyEntity = null;

		const service = testShareFolderService({
			// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
			'GET api/users/toto%40example.com/public_key': async (_query: Record<string, any>, _body: any) => {
				return recipientPpk;
			},
			// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
			'POST api/shares/share_1/users': async (_query: Record<string, any>, body: any) => {
				uploadedEmail = body.email;
				uploadedMasterKey = JSON.parse(body.master_key);
			},
		});

		const { share } = await testShareFolder(service);

		await service.addShareRecipient(share.id, share.master_key_id, 'toto@example.com', { can_read: 1, can_write: 1 });

		expect(uploadedEmail).toBe('toto@example.com');

		const content = JSON.parse(uploadedMasterKey.content);
		expect(recipientPpk.id).toBe(content.ppkId);
	});

	it('should not update the folder\'s master_key_id when shareFolder is called multiple times', async () => {
		await generateMasterKeyAndEnableEncryption(encryptionService(), 'testing!');
		const ppk = await generateKeyPair(encryptionService(), '111111');
		setPpk(ppk);

		expect(localSyncInfo().e2ee).toBe(true);
		expect(localSyncInfo().masterKeys).toHaveLength(1);

		const service = testShareFolderService();
		const { share, folder } = await testShareFolder(service);
		// Should have created a new master key for the share
		expect(localSyncInfo().masterKeys).toHaveLength(2);
		expect(
			localSyncInfo().masterKeys.some(key => key.id === share.master_key_id),
		).toBe(true);

		// Sharing an already-shared folder should keep the existing key:
		const share2 = await service.shareFolder(folder.id);
		expect(share2.master_key_id).toBe(share.master_key_id);
		expect(await Folder.load(folder.id)).toHaveProperty('master_key_id', share.master_key_id);
		// Should not have added a new master key
		expect(localSyncInfo().masterKeys).toHaveLength(2);
	});

	it('should use a different master key when folders are unshared, then shared again', async () => {
		await generateMasterKeyAndEnableEncryption(encryptionService(), 'testing!');
		const ppk = await generateKeyPair(encryptionService(), '111111');
		setPpk(ppk);

		const service = testShareFolderService();
		const { share, folder } = await testShareFolder(service);
		await service.refreshShares();
		await service.unshareFolder(folder.id);
		const share2 = await service.shareFolder(folder.id);

		expect(share2.master_key_id).toBeTruthy();
		expect(share2.folder_id).toBe(folder.id);
		expect(share.master_key_id).not.toBe(share2.master_key_id);
	});

	it('should leave folders that are no longer with the user', async () => {
		// `checkShareConsistency` will emit a warning so we need to silent it
		// in tests.
		const previousLogLevel = Logger.globalLogger.setLevel(LogLevel.Error);

		// checkShareConsistency only runs when the sync target supports
		// sharing (Joplin Server/Cloud).
		Setting.setValue('sync.target', 9);

		const service = testShareFolderService({
			// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
			'GET api/shares': async (_query: Record<string, any>, _body: any): Promise<any> => {
				return {
					items: [],
					has_more: false,
				};
			},
		});

		const folder = await Folder.save({ share_id: 'nolongershared' });
		await service.checkShareConsistency();
		expect(await Folder.load(folder.id)).toBeFalsy();

		Logger.globalLogger.setLevel(previousLogLevel);
	});

	it('should skip share consistency check when not using a share-compatible sync target', async () => {
		// Simulate a non-Joplin Server sync target (e.g. WebDAV = 6).
		// This reproduces the scenario where a user previously used Joplin
		// Server (which set share_id on folders), then switched to WebDAV.
		// checkShareConsistency() should not attempt to call the Joplin
		// Server API since we are no longer on a share-capable sync target.
		Setting.setValue('sync.target', 6);

		const service = mockShareService({
			onExec: async () => {
				throw new Error('Should not call the API when share is not enabled');
			},
		});

		// Create a folder with a stale share_id leftover from Joplin Server
		const folder = await Folder.save({ share_id: 'stale_share_id' });

		// This should not throw or attempt any API calls
		await service.checkShareConsistency();

		// The folder should still exist since we cannot verify shares
		// without a Joplin Server API connection
		expect(await Folder.load(folder.id)).toBeTruthy();
	});

	it('should leave a shared folder', async () => {
		const folder1 = await createFolderTree('', [
			{
				title: 'folder 1',
				children: [
					{
						title: 'note 1',
					},
					{
						title: 'note 2',
					},
				],
			},
		]);

		const resourceService = new ResourceService();
		await Folder.save({ id: folder1.id, share_id: '123456789' });
		await Folder.updateAllShareIds(resourceService, []);

		const cleanup = simulateReadOnlyShareEnv('123456789');

		const shareService = testShareFolderService();
		await shareService.leaveSharedFolder(folder1.id, 'somethingrandom');

		expect(await Folder.count()).toBe(0);
		expect(await Note.count()).toBe(0);

		const deletedItems = await BaseItem.deletedItems(Setting.value('sync.target'));

		expect(deletedItems.length).toBe(1);
		expect(deletedItems[0].item_type).toBe(ModelType.Folder);
		expect(deletedItems[0].item_id).toBe(folder1.id);

		cleanup();
	});

});
