import Setting, { SettingItemType, SettingSectionSource, SettingStorage } from '../models/Setting';
import { setupDatabaseAndSynchronizer, switchClient, expectThrow, expectNotThrow, msleep } from '../testing/test-utils';
import { readFile, stat, mkdirp, writeFile, pathExists, readdir } from 'fs-extra';
import Logger from '@joplin/utils/Logger';
import { defaultProfileConfig } from '../services/profileConfig/types';
import { createNewProfile, saveProfileConfig } from '../services/profileConfig';
import initProfile from '../services/profileConfig/initProfile';
import { defaultPluginSetting } from '../services/plugins/PluginService';

// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
async function loadSettingsFromFile(): Promise<any> {
	return JSON.parse(await readFile(Setting.settingFilePath, 'utf8'));
}

const loadDefaultProfileSettings = async () => {
	return JSON.parse(await readFile(`${Setting.value('rootProfileDir')}/settings-1.json`, 'utf8'));
};

const switchToSubProfileSettings = async () => {
	await Setting.reset();
	const rootProfileDir = Setting.value('profileDir');
	const profileConfigPath = `${rootProfileDir}/profiles.json`;
	let profileConfig = defaultProfileConfig();
	const { newConfig, newProfile } = createNewProfile(profileConfig, 'Sub-profile');
	profileConfig = newConfig;
	profileConfig.currentProfileId = newProfile.id;
	await saveProfileConfig(profileConfigPath, profileConfig);
	const { profileDir } = await initProfile(rootProfileDir);
	await mkdirp(profileDir);
	await Setting.load();
};

describe('models/Setting', () => {

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

	it('should return only sub-values', (async () => {
		const settings = {
			'sync.5.path': 'http://example.com',
			'sync.5.username': 'testing',
		};

		let output = Setting.subValues('sync.5', settings);
		expect(output['path']).toBe('http://example.com');
		expect(output['username']).toBe('testing');

		output = Setting.subValues('sync.4', settings);
		expect('path' in output).toBe(false);
		expect('username' in output).toBe(false);
	}));

	it('should not fail when trying to load a key that no longer exist from the setting file', (async () => {
		// To handle the case where a setting value exists in the database but
		// the metadata has been removed in a new Joplin version.
		// https://github.com/laurent22/joplin/issues/5086

		Setting.setValue('sync.target', 9); // Saved to file
		await Setting.saveAll();

		const settingValues = await Setting.fileHandler.load();
		settingValues['itsgone'] = 'myvalue';
		await Setting.fileHandler.save(settingValues);

		await Setting.reset();

		await expectNotThrow(async () => Setting.load());
		await expectThrow(async () => Setting.value('itsgone'), 'unknown_key');
	}));

	it.each([
		SettingStorage.Database, SettingStorage.File,
	])('should allow registering new settings dynamically (storage: %d)', (async (storage) => {
		await expectThrow(async () => Setting.setValue('myCustom', '123'), 'unknown_key');

		await Setting.registerSetting('myCustom', {
			public: true,
			value: 'default',
			type: Setting.TYPE_STRING,
			storage,
		});

		expect(Setting.value('myCustom')).toBe('default');

		await expectNotThrow(async () => Setting.setValue('myCustom', '123'));

		expect(Setting.value('myCustom')).toBe('123');
	}));

	it.each([SettingStorage.Database, SettingStorage.File])('should not clear old custom settings if not registered immediately', (async (storage) => {
		// In general the following should work:
		//
		// - Plugin register a new setting
		// - User set new value for setting
		// - Settings are saved
		// - => App restart
		// - Plugin does not register setting again
		// - Settings are loaded
		// - Settings are saved
		// - Plugin register setting again
		// - Previous value set by user should still be there.
		//
		// In other words, once a custom setting has been set we don't clear it
		// even if registration doesn't happen immediately. That allows for example
		// to delay setting registration without a risk for the custom setting to be deleted.

		await Setting.registerSetting('myCustom', {
			public: true,
			value: 'default',
			type: Setting.TYPE_STRING,
			storage,
		});

		Setting.setValue('myCustom', '123');

		await Setting.saveAll();

		await Setting.reset();

		await Setting.load();

		await Setting.registerSetting('myCustom', {
			public: true,
			value: 'default',
			type: Setting.TYPE_STRING,
			storage,
		});

		await Setting.saveAll();

		expect(Setting.value('myCustom')).toBe('123');
	}));

	it.each([SettingStorage.Database, SettingStorage.File])('should not clear old custom settings if not registered until restart', async (storage) => {
		const registerCustom = async () => {
			await Setting.registerSetting('myCustom', {
				public: true,
				value: 'test',
				type: Setting.TYPE_STRING,
				storage,
			});
		};

		await registerCustom();
		Setting.setValue('myCustom', 'test2');
		await Setting.saveAll();

		await Setting.reset();
		await Setting.load();

		// Change a file setting
		Setting.setValue('sync.target', 9);

		await Setting.saveAll();
		await Setting.reset();
		await Setting.load();

		await registerCustom();
		expect(Setting.value('myCustom')).toBe('test2');
	});

	it('should return values with correct type for custom settings', (async () => {
		await Setting.registerSetting('myCustom', {
			public: true,
			value: 123,
			type: Setting.TYPE_INT,
		});

		Setting.setValue('myCustom', 456);

		await Setting.saveAll();
		await Setting.reset();
		await Setting.load();

		await Setting.registerSetting('myCustom', {
			public: true,
			value: 123,
			type: Setting.TYPE_INT,
		});

		expect(typeof Setting.value('myCustom')).toBe('number');
		expect(Setting.value('myCustom')).toBe(456);
	}));

	it('should validate registered keys', (async () => {
		const md = {
			public: true,
			value: 'default',
			type: Setting.TYPE_STRING,
		};

		await expectThrow(async () => await Setting.registerSetting('', md));
		await expectThrow(async () => await Setting.registerSetting('no spaces', md));
		await expectThrow(async () => await Setting.registerSetting('nospecialcharacters!!!', md));
		await expectThrow(async () => await Setting.registerSetting('Robert\'); DROP TABLE Students;--', md));

		await expectNotThrow(async () => await Setting.registerSetting('numbersareok123', md));
		await expectNotThrow(async () => await Setting.registerSetting('so-ARE-dashes_123', md));
	}));

	it('should register new sections', (async () => {
		await Setting.registerSection('mySection', SettingSectionSource.Default, {
			label: 'My section',
		});

		expect(Setting.sectionNameToLabel('mySection')).toBe('My section');
	}));


	it('should save and load settings from file', (async () => {
		Setting.setValue('sync.target', 9); // Saved to file
		Setting.setValue('encryption.passwordCache', {}); // Saved to keychain or db
		Setting.setValue('plugins.states', { test: defaultPluginSetting() }); // Always saved to db
		await Setting.saveAll();

		{
			const settings = await loadSettingsFromFile();
			expect(settings['sync.target']).toBe(9);
			expect(settings).not.toContain('encryption.passwordCache');
			expect(settings).not.toContain('plugins.states');
		}

		Setting.setValue('sync.target', 8);
		await Setting.saveAll();

		{
			const settings = await loadSettingsFromFile();
			expect(settings['sync.target']).toBe(8);
		}
	}));

	it('should not save to file if nothing has changed', (async () => {
		Setting.setValue('sync.mobileWifiOnly', true);
		await Setting.saveAll();

		{
			// Double-check that timestamp is indeed changed when the content is
			// changed.
			const beforeStat = await stat(Setting.settingFilePath);
			await msleep(1001);
			Setting.setValue('sync.mobileWifiOnly', false);
			await Setting.saveAll();
			const afterStat = await stat(Setting.settingFilePath);
			expect(afterStat.mtime.getTime()).toBeGreaterThan(beforeStat.mtime.getTime());
		}

		{
			const beforeStat = await stat(Setting.settingFilePath);
			await msleep(1001);
			Setting.setValue('sync.mobileWifiOnly', false);
			const afterStat = await stat(Setting.settingFilePath);
			await Setting.saveAll();
			expect(afterStat.mtime.getTime()).toBe(beforeStat.mtime.getTime());
		}
	}));

	it('should handle invalid JSON', (async () => {
		const badContent = '{ oopsIforgotTheQuotes: true}';
		await writeFile(Setting.settingFilePath, badContent, 'utf8');
		await Setting.reset();

		Logger.globalLogger.enabled = false;
		await Setting.load();
		Logger.globalLogger.enabled = true;

		// Invalid JSON file has been moved to .bak file
		expect(await pathExists(Setting.settingFilePath)).toBe(false);

		const files = await readdir(Setting.value('profileDir'));
		expect(files.length).toBe(1);
		expect(files[0].endsWith('.bak')).toBe(true);
		expect(await readFile(`${Setting.value('profileDir')}/${files[0]}`, 'utf8')).toBe(badContent);
	}));

	it('should allow applying default migrations', (async () => {
		await Setting.reset();

		expect(Setting.value('sync.target')).toBe(0);
		expect(Setting.value('style.editor.contentMaxWidth')).toBe(0);
		await Setting.applyMigrations();
		expect(Setting.value('sync.target')).toBe(7); // Changed
		expect(Setting.value('style.editor.contentMaxWidth')).toBe(600); // Changed
	}));

	it('should skip values that are already set', (async () => {
		await Setting.reset();

		Setting.setValue('sync.target', 9);
		await Setting.applyMigrations();
		expect(Setting.value('sync.target')).toBe(9); // Not changed
	}));

	it('should allow skipping default migrations', (async () => {
		await Setting.reset();

		expect(Setting.value('sync.target')).toBe(0);
		expect(Setting.value('style.editor.contentMaxWidth')).toBe(0);
		Setting.skipMigrations();
		await Setting.applyMigrations();
		expect(Setting.value('sync.target')).toBe(0); // Not changed
		expect(Setting.value('style.editor.contentMaxWidth')).toBe(0); // Not changed
	}));

	it('should not apply migrations that have already been applied', (async () => {
		await Setting.reset();

		Setting.setValue('lastSettingDefaultMigration', 0);
		expect(Setting.value('sync.target')).toBe(0);
		expect(Setting.value('style.editor.contentMaxWidth')).toBe(0);
		await Setting.applyMigrations();
		expect(Setting.value('sync.target')).toBe(0); // Not changed
		expect(Setting.value('style.editor.contentMaxWidth')).toBe(600); // Changed
	}));

	it('should migrate to new setting', (async () => {
		await Setting.reset();

		Setting.setValue('spellChecker.language', 'fr-FR');
		await Setting.applyMigrations();
		expect(Setting.value('spellChecker.languages')).toStrictEqual(['fr-FR']);

		// Also double-check that if the setting is changed and the migration applied again, the new
		// value will not be reverted back to the previous one.

		Setting.setValue('spellChecker.languages', ['en-GB']);
		await Setting.applyMigrations();
		expect(Setting.value('spellChecker.languages')).toStrictEqual(['en-GB']);
	}));

	it('should migrate to new setting - plugins', (async () => {
		await Setting.reset();

		await Setting.registerSetting('plugin-org.joplinapp.plugins.AbcSheetMusic.options', {
			type: SettingItemType.String,
			public: true,
			value: '',
			isGlobal: false,
			storage: SettingStorage.Database,
		});

		Setting.setValue('plugin-org.joplinapp.plugins.AbcSheetMusic.options', '{ scale: 2 }');
		await Setting.saveAll();
		await Setting.applyMigrations();
		expect(Setting.value('markdown.plugin.abc.options')).toBe('{ scale: 2 }');

		// Also double-check that if the setting is changed and the migration applied again, the new
		// value will not be reverted back to the previous one.

		Setting.setValue('markdown.plugin.abc.options', '{ scale: 3 }');
		await Setting.applyMigrations();
		expect(Setting.value('markdown.plugin.abc.options')).toBe('{ scale: 3 }');
	}));

	it('should not override new setting, if it already set', (async () => {
		await Setting.reset();

		Setting.setValue('spellChecker.languages', ['fr-FR', 'en-US']);
		Setting.setValue('spellChecker.language', 'fr-FR');
		await Setting.applyMigrations();
		expect(Setting.value('spellChecker.languages')).toStrictEqual(['fr-FR', 'en-US']);
	}));

	it('should not set new setting, if old setting is not set', (async () => {
		await Setting.reset();

		expect(Setting.isSet('spellChecker.language')).toBe(false);
		await Setting.applyMigrations();
		expect(Setting.isSet('spellChecker.languages')).toBe(false);
	}));

	it('should migrate global to local settings', (async () => {
		await Setting.reset();

		// Set an initial value -- should store in the global settings
		const initialLayout = { key: 'test' };
		Setting.setValue('ui.layout', initialLayout);
		await Setting.saveAll();

		await switchToSubProfileSettings();

		// The migrations should fetch the previous initial layout from the global settings
		expect(Setting.value('ui.layout')).toEqual({});
		await Setting.applyMigrations();
		expect(Setting.value('ui.layout')).toEqual(initialLayout);

		Setting.setValue('ui.layout', { key: 'test 2' });
		await Setting.saveAll();

		// Should not overwrite parent settings
		const globalSettings = await loadDefaultProfileSettings();
		expect(globalSettings['ui.layout']).toEqual(initialLayout);
	}));

	it('migrated settings should still be local even if global->local migrations are skipped', async () => {
		await Setting.reset();
		const defaultSettingValue = Setting.value('notes.listRendererId');

		// Set an initial value -- should store in the global settings
		Setting.setValue('notes.listRendererId', 'global-setting-value');
		await Setting.saveAll();

		await switchToSubProfileSettings();
		expect(Setting.value('notes.listRendererId')).toBe(defaultSettingValue);

		// .applyMigrations should not apply skipped migrations
		Setting.skipMigrations();
		await Setting.applyMigrations();
		expect(Setting.value('notes.listRendererId')).toBe(defaultSettingValue);

		// The setting should be local -- the parent setting should not be overwritten
		Setting.setValue('notes.listRendererId', 'some-other-value');
		await Setting.saveAll();

		const globalSettings = await loadDefaultProfileSettings();
		expect(globalSettings['notes.listRendererId']).toBe('global-setting-value');
	});

	it('should load sub-profile settings', async () => {
		await Setting.reset();

		await Setting.registerSetting('non_builtin', {
			public: true,
			storage: SettingStorage.File,
			isGlobal: true,
			type: SettingItemType.Bool,
			value: false,
		});

		Setting.setValue('locale', 'fr_FR'); // Global setting
		Setting.setValue('theme', Setting.THEME_DARK); // Global setting
		Setting.setValue('sync.target', 9); // Local setting
		Setting.setValue('non_builtin', true); // Local setting
		await Setting.saveAll();

		await switchToSubProfileSettings();

		expect(Setting.value('locale')).toBe('fr_FR'); // Should come from the root profile
		expect(Setting.value('theme')).toBe(Setting.THEME_DARK); // Should come from the root profile
		expect(Setting.value('sync.target')).toBe(0); // Should come from the local profile

		// Non-built-in variables are not copied
		expect(() => Setting.value('non_builtin')).toThrow();

		// Also check that the special loadOne() function works as expected

		expect((await Setting.loadOne('locale')).value).toBe('fr_FR');
		expect((await Setting.loadOne('theme')).value).toBe(Setting.THEME_DARK);
		expect((await Setting.loadOne('sync.target'))).toBe(null);
	});

	it('should save sub-profile settings', async () => {
		await Setting.reset();
		Setting.setValue('locale', 'fr_FR'); // Global setting
		Setting.setValue('theme', Setting.THEME_DARK); // Global setting
		await Setting.saveAll();

		await switchToSubProfileSettings();

		Setting.setValue('locale', 'en_GB'); // Should be saved to global
		Setting.setValue('sync.target', 8); // Should be saved to local

		await Setting.saveAll();
		await Setting.reset();
		await Setting.load();

		expect(Setting.value('locale')).toBe('en_GB');
		expect(Setting.value('theme')).toBe(Setting.THEME_DARK);
		expect(Setting.value('sync.target')).toBe(8);

		// Double-check that actual file content is correct

		const globalSettings = await loadDefaultProfileSettings();
		const localSettings = JSON.parse(await readFile(`${Setting.value('profileDir')}/settings-1.json`, 'utf8'));

		expect(globalSettings).toEqual({
			'$schema': 'https://joplinapp.org/schema/settings.json',
			locale: 'en_GB',
			theme: 2,
		});

		expect(localSettings).toEqual({
			'$schema': 'https://joplinapp.org/schema/settings.json',
			'sync.target': 8,
		});
	});

	it('should not erase settings of parent profile', async () => {
		// When a sub-profile settings are saved, we should ensure that the
		// local settings of the root profiles are not lost.
		// https://github.com/laurent22/joplin/issues/6459

		await Setting.reset();

		Setting.setValue('sync.target', 9); // Local setting (Root profile)
		await Setting.saveAll();

		await switchToSubProfileSettings();

		Setting.setValue('sync.target', 2); // Local setting (Sub-profile)
		await Setting.saveAll();

		const globalSettings = await loadDefaultProfileSettings();
		expect(globalSettings['sync.target']).toBe(9);
	});

	it('all global settings should be saved to file', async () => {
		for (const [k, v] of Object.entries(Setting.metadata())) {
			if (v.isGlobal && v.storage !== SettingStorage.File) throw new Error(`Setting "${k}" is global but storage is not "file"`);
		}
	});

	test('values should not be undefined when they are set', async () => {
		Setting.setValue('locale', undefined);
		expect(Setting.value('locale')).toBe('');
	});

	test('values should not be undefined when registering a setting', async () => {
		await Setting.registerSetting('myCustom', {
			public: true,
			value: undefined,
			type: Setting.TYPE_STRING,
		});

		expect(Setting.value('myCustom')).toBe('');
	});

	test('should not fail Sqlite UNIQUE constraint when re-registering saved settings', async () => {
		// Re-registering a saved database setting previously caused issues with saving.
		for (let i = 0; i < 2; i++) {
			await Setting.registerSetting('myCustom', {
				public: true,
				value: `${i}`,
				type: Setting.TYPE_STRING,
				storage: SettingStorage.Database,
			});
			Setting.setValue('myCustom', 'test');
			await Setting.saveAll();
		}
	});

	test('should enforce min and max values for when the setting is already in the cache and when it is not', async () => {
		await Setting.reset();
		Setting.setValue('revisionService.ttlDays', 0);
		expect(Setting.value('revisionService.ttlDays')).toBe(1);
		Setting.setValue('revisionService.ttlDays', 100000);
		expect(Setting.value('revisionService.ttlDays')).toBe(99999);

		await Setting.reset();
		Setting.setValue('revisionService.ttlDays', 100000);
		expect(Setting.value('revisionService.ttlDays')).toBe(99999);
		Setting.setValue('revisionService.ttlDays', 0);
		expect(Setting.value('revisionService.ttlDays')).toBe(1);
	});

	test('should not fail to save settings that can conflict with uninstalled plugin settings', async () => {
		Setting.setValue('editor.imageRendering', true);
		expect(Setting.value('editor.imageRendering')).toBe(true);
		Setting.setValue('editor.imageRendering', false);
		expect(Setting.value('editor.imageRendering')).toBe(false);
	});

	test('should adjust settings to avoid conflicts', async () => {
		const testSettingId = 'plugin-plugin.calebjohn.rich-markdown.inlineImages';
		await Setting.registerSetting(testSettingId, {
			public: true,
			value: false,
			type: SettingItemType.Bool,
			storage: SettingStorage.File,
		});

		Setting.setValue('editor.imageRendering', true);

		// Setting one conflicting setting should update the other
		Setting.setValue(testSettingId, true);
		expect(Setting.value('editor.imageRendering')).toBe(false);
		expect(Setting.value(testSettingId)).toBe(true);

		Setting.setValue('editor.imageRendering', true);
		expect(Setting.value('editor.imageRendering')).toBe(true);
		expect(Setting.value(testSettingId)).toBe(false);
	});
});
