import time from '../../time';
import { allNotesFolders, localNotesFoldersSameAsRemote } from '../../testing/test-utils-synchronizer';
import { synchronizerStart, setupDatabaseAndSynchronizer, sleep, switchClient, syncTargetId, loadEncryptionMasterKey, decryptionWorker } from '../../testing/test-utils';
import Folder from '../../models/Folder';
import Note from '../../models/Note';
import BaseItem from '../../models/BaseItem';
import { setEncryptionEnabled } from '../synchronizer/syncInfoUtils';
import { NoteEntity } from '../database/types';

describe('Synchronizer.conflicts', () => {

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

	it('should resolve note conflicts', (async () => {
		const folder1 = await Folder.save({ title: 'folder1' });
		const note1 = await Note.save({ title: 'un', parent_id: folder1.id });
		await synchronizerStart();

		await switchClient(2);

		await synchronizerStart();

		let syncItem = await BaseItem.syncItem(syncTargetId(), note1.id, { fields: ['sync_time'] });
		expect(syncItem.sync_time).toBe(note1.updated_time);

		let note2 = await Note.load(note1.id);
		note2.title = 'Updated on client 2';
		await Note.save(note2);
		note2 = await Note.load(note2.id);
		await synchronizerStart();

		await switchClient(1);

		let note2conf = await Note.load(note1.id);
		note2conf.title = 'Updated on client 1';
		await Note.save(note2conf);
		note2conf = await Note.load(note1.id);
		await synchronizerStart();

		syncItem = await BaseItem.syncItem(syncTargetId(), note2.id, { fields: ['sync_time'] });
		expect(syncItem.sync_time).toBe(note2.updated_time);

		const conflictedNotes = await Note.conflictedNotes();
		expect(conflictedNotes.length).toBe(1);

		// Other than the id (since the conflicted note is a duplicate), and the is_conflict property
		// the conflicted and original note must be the same in every way, to make sure no data has been lost.
		const conflictedNote = conflictedNotes[0];
		expect(conflictedNote.id === note2conf.id).toBe(false);
		expect(conflictedNote.conflict_original_id).toBe(note2conf.id);
		for (const n in conflictedNote) {
			if (!conflictedNote.hasOwnProperty(n)) continue;
			if (n === 'id' || n === 'is_conflict' || n === 'conflict_original_id') continue;
			// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
			expect(conflictedNote[n]).toBe((note2conf as any)[n]);
		}

		const noteUpdatedFromRemote = await Note.load(note1.id);
		for (const n in noteUpdatedFromRemote) {
			if (!noteUpdatedFromRemote.hasOwnProperty(n)) continue;
			// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
			expect((noteUpdatedFromRemote as any)[n]).toBe((note2 as any)[n]);
		}
	}));

	it('should cap sync_time to the current device time in the delta step', (async () => {
		const folder1 = await Folder.save({ title: 'folder1' });
		const note1 = await Note.save({ title: 'un', parent_id: folder1.id, updated_time: time.unixMs() + 100_000 }, { autoTimestamp: false });
		await synchronizerStart();

		await switchClient(2);

		await synchronizerStart();

		const syncItem = await BaseItem.syncItem(syncTargetId(), note1.id, { fields: ['sync_time'] });
		expect(syncItem.sync_time).toBeLessThan(note1.updated_time);
	}));

	it('should resolve folders conflicts', (async () => {
		const folder1 = await Folder.save({ title: 'folder1' });
		await Note.save({ title: 'un', parent_id: folder1.id });
		await synchronizerStart();

		await switchClient(2); // ----------------------------------

		await synchronizerStart();

		await sleep(0.1);

		let folder1_modRemote = await Folder.load(folder1.id);
		folder1_modRemote.title = 'folder1 UPDATE CLIENT 2';
		await Folder.save(folder1_modRemote);
		folder1_modRemote = await Folder.load(folder1_modRemote.id);

		await synchronizerStart();

		await switchClient(1); // ----------------------------------

		await sleep(0.1);

		let folder1_modLocal = await Folder.load(folder1.id);
		folder1_modLocal.title = 'folder1 UPDATE CLIENT 1';
		await Folder.save(folder1_modLocal);
		folder1_modLocal = await Folder.load(folder1.id);

		await synchronizerStart();

		const folder1_final = await Folder.load(folder1.id);
		expect(folder1_final.title).toBe(folder1_modRemote.title);
	}));

	it('should resolve conflict if remote folder has been deleted, but note has been added to folder locally', (async () => {
		const folder1 = await Folder.save({ title: 'folder1' });
		await synchronizerStart();

		await switchClient(2);

		await synchronizerStart();
		await Folder.delete(folder1.id);
		await synchronizerStart();

		await switchClient(1);

		await Note.save({ title: 'note1', parent_id: folder1.id });
		await synchronizerStart();
		const items: NoteEntity[] = await allNotesFolders();
		expect(items.length).toBe(1);
		expect(items[0].title).toBe('note1');
		expect(items[0].is_conflict).toBe(1);
	}));

	it('should resolve conflict if note has been deleted remotely and locally', (async () => {
		const folder = await Folder.save({ title: 'folder' });
		const note = await Note.save({ title: 'note', parent_id: folder.title });
		await synchronizerStart();

		await switchClient(2);

		await synchronizerStart();
		await Note.delete(note.id);
		await synchronizerStart();

		await switchClient(1);

		await Note.delete(note.id);
		await synchronizerStart();

		const items = await allNotesFolders();
		expect(items.length).toBe(1);
		expect(items[0].title).toBe('folder');

		await localNotesFoldersSameAsRemote(items, expect);
	}));

	it('should handle conflict when remote note is deleted then local note is modified', (async () => {
		const folder1 = await Folder.save({ title: 'folder1' });
		const note1 = await Note.save({ title: 'un', parent_id: folder1.id });
		await synchronizerStart();

		await switchClient(2);

		await synchronizerStart();

		await sleep(0.1);

		await Note.delete(note1.id);

		await synchronizerStart();

		await switchClient(1);

		const newTitle = 'Modified after having been deleted';
		await Note.save({ id: note1.id, title: newTitle });

		await synchronizerStart();

		const conflictedNotes = await Note.conflictedNotes();

		expect(conflictedNotes.length).toBe(1);
		expect(conflictedNotes[0].title).toBe(newTitle);

		const unconflictedNotes = await Note.unconflictedNotes();

		expect(unconflictedNotes.length).toBe(0);
	}));

	it('should handle conflict when remote folder is deleted then local folder is renamed', (async () => {
		const folder1 = await Folder.save({ title: 'folder1' });
		const folder2 = await Folder.save({ title: 'folder2', parent_id: folder1.id });
		const note1 = await Note.save({ title: 'note1', parent_id: folder2.id });
		const note2 = await Note.save({ title: 'note2', parent_id: folder1.id });
		await synchronizerStart();

		await switchClient(2);

		await synchronizerStart();

		await sleep(0.1);

		await Folder.delete(folder1.id, { deleteChildren: false });

		await synchronizerStart();

		await switchClient(1);

		await sleep(0.1);

		const newTitle = 'Modified after having been deleted';
		await Folder.save({ id: folder1.id, title: newTitle });

		await synchronizerStart();

		const remainingFolder1 = await Folder.load(folder1.id);
		const remainingFolder2 = await Folder.load(folder2.id);
		const remainingNote1 = await Note.load(note1.id);
		const remainingNote2 = await Note.load(note2.id);

		expect(remainingFolder1).toBeUndefined();
		expect(remainingFolder2?.id).toBe(folder2.id);
		expect(remainingNote1?.id).toBe(note1.id);
		expect(remainingNote2?.id).toBe(note2.id);
	}));

	it('should not sync notes with conflicts', (async () => {
		const f1 = await Folder.save({ title: 'folder' });
		await Note.save({ title: 'mynote', parent_id: f1.id, is_conflict: 1 });
		await synchronizerStart();

		await switchClient(2);

		await synchronizerStart();
		const notes = await Note.all();
		const folders = await Folder.all();
		expect(notes.length).toBe(0);
		expect(folders.length).toBe(1);
	}));

	it('should not try to delete on remote conflicted notes that have been deleted', (async () => {
		const f1 = await Folder.save({ title: 'folder' });
		const n1 = await Note.save({ title: 'mynote', parent_id: f1.id });
		await synchronizerStart();

		await switchClient(2);

		await synchronizerStart();
		await Note.save({ id: n1.id, is_conflict: 1 });
		await Note.delete(n1.id);
		const deletedItems = await BaseItem.deletedItems(syncTargetId());

		expect(deletedItems.length).toBe(0);
	}));

	async function ignorableNoteConflictTest(withEncryption: boolean) {
		if (withEncryption) {
			setEncryptionEnabled(true);
			await loadEncryptionMasterKey();
		}

		const folder1 = await Folder.save({ title: 'folder1' });
		const note1 = await Note.save({ title: 'un', is_todo: 1, parent_id: folder1.id });
		await synchronizerStart();

		await switchClient(2);

		await synchronizerStart();
		if (withEncryption) {
			await loadEncryptionMasterKey(null, true);
			await decryptionWorker().start();
		}
		let note2 = await Note.load(note1.id);
		note2.todo_completed = time.unixMs() - 1;
		await Note.save(note2);
		note2 = await Note.load(note2.id);
		await synchronizerStart();

		await switchClient(1);

		let note2conf = await Note.load(note1.id);
		note2conf.todo_completed = time.unixMs();
		await Note.save(note2conf);
		note2conf = await Note.load(note1.id);
		await synchronizerStart();

		if (!withEncryption) {
			// That was previously a common conflict:
			// - Client 1 mark todo as "done", and sync
			// - Client 2 doesn't sync, mark todo as "done" todo. Then sync.
			// In theory it is a conflict because the todo_completed dates are different
			// but in practice it doesn't matter, we can just take the date when the
			// todo was marked as "done" the first time.

			const conflictedNotes = await Note.conflictedNotes();
			expect(conflictedNotes.length).toBe(0);

			const notes = await Note.all();
			expect(notes.length).toBe(1);
			expect(notes[0].id).toBe(note1.id);
			expect(notes[0].todo_completed).toBe(note2.todo_completed);
		} else {
			// If the notes are encrypted however it's not possible to do this kind of
			// smart conflict resolving since we don't know the content, so in that
			// case it's handled as a regular conflict.

			const conflictedNotes = await Note.conflictedNotes();
			expect(conflictedNotes.length).toBe(1);

			const notes = await Note.all();
			expect(notes.length).toBe(2);
		}
	}

	it('should not consider it is a conflict if neither the title nor body of the note have changed', (async () => {
		await ignorableNoteConflictTest(false);
	}));

	it('should always handle conflict if local or remote are encrypted', (async () => {
		await ignorableNoteConflictTest(true);
	}));

});
