import { DbConnection, connectDb, disconnectDb, truncateTables } from '../../db';
import { User, Session, Item, Uuid } from '../../services/database/types';
import { createDb, CreateDbOptions } from '../../tools/dbTools';
import modelFactory from '../../models/factory';
import { AppContext, DatabaseConfigClient, Env } from '../types';
import config, { initConfig } from '../../config';
import Logger from '@joplin/utils/Logger';
import FakeCookies from './koa/FakeCookies';
import FakeRequest from './koa/FakeRequest';
import FakeResponse from './koa/FakeResponse';
import * as httpMocks from 'node-mocks-http';
import * as crypto from 'crypto';
import * as path from 'path';
import * as fs from 'fs-extra';
import * as jsdom from 'jsdom';
import setupAppContext from '../setupAppContext';
import { ApiError } from '../errors';
import { getApi, putApi, deleteApi, ExecRequestOptions } from './apiUtils';
import { FolderEntity, NoteEntity, ResourceEntity } from '@joplin/lib/services/database/types';
import { ModelType } from '@joplin/lib/BaseModel';
import { initializeJoplinUtils } from '../joplinUtils';
import MustacheService from '../../services/MustacheService';
import { uuidgen } from '@joplin/lib/uuid';
import { createCsrfToken } from '../csrf';
import { cookieSet } from '../cookies';
import { parseEnv } from '../../env';
import { URL } from 'url';
import { AccountType } from '../../models/UserModel';
import initLib from '@joplin/lib/initLib';
import { makeFolderSerializedBody, makeNoteSerializedBody, makeResourceSerializedBody } from './serializedItems';
import { AppAuthResponse } from '../../models/ApplicationModel';

// Takes into account the fact that this file will be inside the /dist directory
// when it runs.
export const packageRootDir = path.dirname(path.dirname(path.dirname(__dirname)));

let db_: DbConnection = null;
let dbSlave_: DbConnection = null;

// require('source-map-support').install();

export function randomHash(): string {
	return crypto.createHash('md5').update(`${Date.now()}-${Math.random()}`).digest('hex');
}

export function tempDirPath(): string {
	return `${packageRootDir}/temp/${randomHash()}`;
}

let tempDir_: string = null;
export async function tempDir(subDir: string = null): Promise<string> {
	if (!tempDir_) tempDir_ = tempDirPath();
	const fullDir = tempDir_ + (subDir ? `/${subDir}` : '');
	await fs.mkdirp(fullDir);
	return fullDir;
}

export async function makeTempFileWithContent(content: string | Buffer): Promise<string> {
	const d = await tempDir();
	const filePath = `${d}/${randomHash()}`;
	if (typeof content === 'string') {
		await fs.writeFile(filePath, content, 'utf8');
	} else {
		await fs.writeFile(filePath, content);
	}
	return filePath;
}

function initGlobalLogger() {
	const globalLogger = new Logger();
	Logger.initializeGlobalLogger(globalLogger);
	initLib(globalLogger);
}

export const getDatabaseClientType = () => {
	if (process.env.JOPLIN_TESTS_SERVER_DB === 'pg') return DatabaseConfigClient.PostgreSQL;
	return DatabaseConfigClient.SQLite;
};

let createdDbPath_: string = null;
let createdDbSlavePath_: string = null;
export async function beforeAllDb(unitName: string, createDbOptions: CreateDbOptions = null) {
	unitName = unitName.replace(/\//g, '_');

	const useDbSlave = createDbOptions?.envValues && createDbOptions?.envValues.DB_USE_SLAVE === '1';

	createdDbPath_ = `${packageRootDir}/db-test-${unitName}.sqlite`;
	await fs.remove(createdDbPath_);

	createdDbSlavePath_ = `${packageRootDir}/db-slave-test-${unitName}.sqlite`;
	await fs.remove(createdDbSlavePath_);

	const tempDir = `${packageRootDir}/temp/test-${unitName}`;
	await fs.mkdirp(tempDir);

	// To run the tests with Postgres, first run this:
	//
	//     docker compose -f docker-compose.db-dev.yml up
	//
	// Then this:
	//
	//     JOPLIN_TESTS_SERVER_DB=pg yarn test

	if (getDatabaseClientType() === DatabaseConfigClient.PostgreSQL) {
		await initConfig(Env.Dev, parseEnv({
			DB_CLIENT: 'pg',

			POSTGRES_DATABASE: unitName,
			POSTGRES_USER: 'joplin',
			POSTGRES_PASSWORD: 'joplin',

			SLAVE_POSTGRES_DATABASE: unitName,
			SLAVE_POSTGRES_USER: 'joplin',
			SLAVE_POSTGRES_PASSWORD: 'joplin',

			SUPPORT_EMAIL: 'testing@localhost',
			...createDbOptions?.envValues,
		}), {
			tempDir: tempDir,
		});
	} else {
		await initConfig(Env.Dev, parseEnv({
			SQLITE_DATABASE: createdDbPath_,
			SLAVE_SQLITE_DATABASE: createdDbSlavePath_,
			SUPPORT_EMAIL: 'testing@localhost',
			...createDbOptions?.envValues,
		}), {
			tempDir: tempDir,
		});
	}

	initGlobalLogger();

	await createDb(config().database, { dropIfExists: true, ...createDbOptions });
	db_ = await connectDb(config().database);

	if (useDbSlave) {
		await createDb(config().databaseSlave, { dropIfExists: true, ...createDbOptions });
		dbSlave_ = await connectDb(config().databaseSlave);
	} else {
		dbSlave_ = db_;
	}

	const mustache = new MustacheService(config().viewDir, config().baseUrl);
	await mustache.loadPartials();

	await initializeJoplinUtils(config(), models(), mustache);
}

export const createdDbPath = () => {
	return createdDbPath_;
};

export async function afterAllTests() {
	if (db_) {
		await disconnectDb(db_);
		db_ = null;
	}

	if (dbSlave_) {
		await disconnectDb(dbSlave_);
		dbSlave_ = null;
	}

	if (tempDir_) {
		await fs.remove(tempDir_);
		tempDir_ = null;
	}

	if (createdDbPath_) {
		await fs.remove(createdDbPath_);
		createdDbPath_ = null;
	}
}

export async function beforeEachDb() {
	await truncateTables(db_);
}

export interface AppContextTestOptions {
	// owner?: User;
	sessionId?: string;
	// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
	request?: any;
	ip?: string;
	baseAppContext?: AppContext;
}

export function msleep(ms: number) {
	// It seems setTimeout can sometimes last less time than the provided
	// interval:
	//
	// https://stackoverflow.com/a/50912029/561309
	//
	// This can cause issues in tests where we expect the actual duration to be
	// the same as the provided interval or more, but not less. So the code
	// below check that the elapsed time is no less than the provided interval,
	// and if it is, it waits a bit longer.
	const startTime = Date.now();
	return new Promise((resolve) => {
		setTimeout(() => {
			if (Date.now() - startTime < ms) {
				const iid = setInterval(() => {
					if (Date.now() - startTime >= ms) {
						clearInterval(iid);
						resolve(null);
					}
				}, 2);
			} else {
				resolve(null);
			}
		}, ms);
	});
}

export const createBaseAppContext = () => {
	const appLogger = Logger.create('AppTest');
	// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
	return setupAppContext({} as any, Env.Dev, db_, dbSlave_, () => appLogger);
};

export async function koaAppContext(options: AppContextTestOptions = null): Promise<AppContext> {
	if (!db_) throw new Error('Database must be initialized first');

	options = {
		...options,
	};

	initGlobalLogger();

	const reqOptions = {
		...options.request,
	};

	const owner = options.sessionId ? await models().session().sessionUser(options.sessionId) : null;

	// To pass the CSRF check, we create the token here and assign it
	// automatically if it's a POST request with a body.
	const csrfToken = owner ? await createCsrfToken(models(), owner) : '';
	if (typeof reqOptions.body === 'object') reqOptions.body._csrf = csrfToken;

	if (!reqOptions.method) reqOptions.method = 'GET';
	if (!reqOptions.url) reqOptions.url = '/home';
	if (!reqOptions.headers) reqOptions.headers = {};
	if (!reqOptions.headers['content-type']) reqOptions.headers['content-type'] = 'application/json';

	if (options.sessionId) {
		reqOptions.headers['x-api-auth'] = options.sessionId;
	}

	const req = httpMocks.createRequest(reqOptions);
	req.__isMocked = true;

	const baseAppContext = options.baseAppContext ?? await createBaseAppContext();

	// Set type to "any" because the Koa context has many properties and we
	// don't need to mock all of them.
	// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
	const appContext: any = {
		joplinBase: baseAppContext.joplinBase,
		baseAppContext,
		joplin: {
			...baseAppContext.joplinBase,
			env: Env.Dev,
			db: db_,
			models: models(),
			owner: owner,
		},
		path: req.url,
		cookies: new FakeCookies(),
		request: new FakeRequest(req),
		response: new FakeResponse(),
		headers: { ...reqOptions.headers },
		req: req,
		query: req.query,
		method: req.method,
		redirect: () => {},
		URL: new URL(config().baseUrl), // origin
		ip: options.ip,
	};

	if (options.sessionId) {
		cookieSet(appContext, 'sessionId', options.sessionId);
	}

	return appContext as AppContext;
}

export function koaNext(): Promise<void> {
	return Promise.resolve();
}

export const testAssetDir = `${packageRootDir}/assets/tests`;

export interface UserAndSession {
	user: User;
	session: Session;
	password: string;
}

export function db() {
	return db_;
}

export function dbSlave() {
	return dbSlave_;
}

export function dbSlaveSync() {

}

export function models() {
	return modelFactory(db(), dbSlave(), config());
}

export function parseHtml(html: string): Document {
	const dom = new jsdom.JSDOM(html);
	return dom.window.document;
}

interface CreateUserAndSessionOptions {
	email?: string;
	password?: string;
	account_type?: AccountType;
}

export const createUserAndSession = async function(index = 1, isAdmin = false, options: CreateUserAndSessionOptions = null): Promise<UserAndSession> {
	const password = uuidgen();

	options = {
		email: `user${index}@localhost`,
		password,
		...options,
	};

	let user: User = {
		email: options.email,
		password: options.password,
		is_admin: isAdmin ? 1 : 0,
	};

	if (options.account_type) user.account_type = options.account_type;

	user = await models().user().save(user, { skipValidation: true });
	const session = await models().session().authenticate(options.email, options.password, '');

	return {
		user: await models().user().load(user.id),
		session,
		password,
	};
};

export const createUser = async function(index = 1, isAdmin = false): Promise<User> {
	return models().user().save({ email: `user${index}@localhost`, password: '123456', is_admin: isAdmin ? 1 : 0 }, { skipValidation: true });
};

// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
export async function createItemTree(userId: Uuid, parentFolderId: string, tree: any): Promise<void> {
	const itemModel = models().item();

	for (const jopId in tree) {
		// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
		const children: any = tree[jopId];
		const isFolder = children !== null;

		const newItem: Item = await itemModel.saveForUser(userId, {
			jop_parent_id: parentFolderId,
			jop_id: jopId,
			jop_type: isFolder ? ModelType.Folder : ModelType.Note,
			name: `${jopId}.md`,
			content: Buffer.from(`{"title":"Item ${jopId}"}`),
		});

		if (isFolder && Object.keys(children).length) await createItemTree(userId, newItem.jop_id, children);
	}
}

// export async function createItemTree2(userId: Uuid, parentFolderId: string, tree: any[]): Promise<void> {
// 	const itemModel = models().item();
// 	const user = await models().user().load(userId);

// 	for (const jopItem of tree) {
// 		const isFolder = !!jopItem.children;
// 		const serializedBody = isFolder ?
// 			makeFolderSerializedBody({ ...jopItem, parent_id: parentFolderId }) :
// 			makeNoteSerializedBody({ ...jopItem, parent_id: parentFolderId });
// 		const result = await itemModel.saveFromRawContent(user, [{ name: `${jopItem.id}.md`, body: Buffer.from(serializedBody) }]);
// 		const newItem = result[`${jopItem.id}.md`].item;
// 		if (isFolder && jopItem.children.length) await createItemTree2(userId, newItem.jop_id, jopItem.children);
// 	}
// }

// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
export async function createItemTree3(userId: Uuid, parentFolderId: string, shareId: Uuid, tree: any[]): Promise<void> {
	const itemModel = models().item();
	const user = await models().user().load(userId);

	for (const jopItem of tree) {
		const isFolder = !!jopItem.children;
		const serializedBody = isFolder ?
			makeFolderSerializedBody({ ...jopItem, parent_id: parentFolderId, share_id: shareId }) :
			makeNoteSerializedBody({ ...jopItem, parent_id: parentFolderId, share_id: shareId });
		const result = await itemModel.saveFromRawContent(user, [{ name: `${jopItem.id}.md`, body: Buffer.from(serializedBody) }]);
		const newItem = result[`${jopItem.id}.md`].item;
		if (isFolder && jopItem.children.length) await createItemTree3(userId, newItem.jop_id, shareId, jopItem.children);
	}
}

export async function getItem(sessionId: string, path: string): Promise<string> {
	const item: Buffer = await getApi(sessionId, `items/${path}/content`);
	return item.toString();
}

export async function createItem(sessionId: string, path: string, content: string | Buffer, options: ExecRequestOptions = null): Promise<Item> {
	const tempFilePath = await makeTempFileWithContent(content);
	const item: Item = await putApi(sessionId, `items/${path}/content`, null, { filePath: tempFilePath, ...options });
	await fs.remove(tempFilePath);
	return models().item().load(item.id);
}

export async function updateItem(sessionId: string, path: string, content: string): Promise<Item> {
	const tempFilePath = await makeTempFileWithContent(content);
	const item: Item = await putApi(sessionId, `items/${path}/content`, null, { filePath: tempFilePath });
	await fs.remove(tempFilePath);
	return models().item().load(item.id);
}

export async function deleteItem(sessionId: string, jopId: string, baseAppContext?: AppContext): Promise<void> {
	await deleteApi(sessionId, `items/root:/${jopId}.md:`, { baseAppContext });
}

export async function createNote(sessionId: string, note: NoteEntity): Promise<Item> {
	note = {
		id: '00000000000000000000000000000001',
		title: 'Note title',
		body: 'Note body',
		...note,
	};

	return createItem(sessionId, `root:/${note.id}.md:`, makeNoteSerializedBody(note));
}

export async function deleteNoteBySession(sessionId: string, noteJopId: string) {
	await deleteApi(sessionId, `items/root:/${noteJopId}.md:`);
}

export async function updateNote(sessionId: string, note: NoteEntity): Promise<Item> {
	return updateItem(sessionId, `root:/${note.id}.md:`, makeNoteSerializedBody(note));
}

export async function deleteNote(userId: Uuid, noteJopId: string): Promise<void> {
	const item = await models().item().loadByJopId(userId, noteJopId, { fields: ['id'] });
	await models().item().delete(item.id);
}

export async function updateFolder(sessionId: string, folder: FolderEntity): Promise<Item> {
	return updateItem(sessionId, `root:/${folder.id}.md:`, makeFolderSerializedBody(folder));
}

export async function deleteFolder(userId: string, folderJopId: string): Promise<void> {
	const item = await models().item().loadByJopId(userId, folderJopId, { fields: ['id'] });
	await models().item().delete(item.id);
}

export async function createFolder(sessionId: string, folder: FolderEntity): Promise<Item> {
	folder = {
		id: '000000000000000000000000000000F1',
		title: 'Folder title',
		...folder,
	};

	return createItem(sessionId, `root:/${folder.id}.md:`, makeFolderSerializedBody(folder));
}

export const createResourceContent = async (sessionId: string, resourceId: string, content: string, options: ExecRequestOptions = null) => {
	return await createItem(sessionId, `root:/.resource/${resourceId}:`, content, options);
};

export async function createResource(sessionId: string, resource: ResourceEntity, content: string): Promise<Item> {
	resource = {
		id: '000000000000000000000000000000E1',
		mime: 'plain/text',
		file_extension: 'txt',
		size: content.length,
		...resource,
	};

	const serializedBody = makeResourceSerializedBody(resource);

	const resourceItem = await createItem(sessionId, `root:/${resource.id}.md:`, serializedBody);
	await createResourceContent(sessionId, resource.id, content);
	return resourceItem;
}

export function checkContextError(context: AppContext) {
	if (context.response.status >= 400) {
		// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
		const body: any = context.response?.body || {};
		throw new ApiError(`${context.method} ${context.path} ${JSON.stringify(context.response)}`, context.response.status, body.code);
	}
}

export async function credentialFile(filename: string): Promise<string> {
	const filePath = `${require('os').homedir()}/joplin-credentials/${filename}`;
	if (await fs.pathExists(filePath)) return filePath;
	return '';
}

export async function readCredentialFile(filename: string, defaultValue: string = null) {
	const filePath = await credentialFile(filename);
	if (!filePath) {
		if (defaultValue === null) throw new Error(`File not found: ${filename}`);
		return defaultValue;
	}

	const r = await fs.readFile(filePath);
	return r.toString();
}

export function credentialFileSync(filename: string): string {
	const filePath = `${require('os').homedir()}/joplin-credentials/${filename}`;
	if (fs.pathExistsSync(filePath)) return filePath;
	return '';
}

export function readCredentialFileSync(filename: string, defaultValue: string = null) {
	const filePath = credentialFileSync(filename);
	if (!filePath) {
		if (defaultValue === null) throw new Error(`File not found: ${filename}`);
		return defaultValue;
	}

	const r = fs.readFileSync(filePath);
	return r.toString();
}

// eslint-disable-next-line @typescript-eslint/ban-types, @typescript-eslint/no-explicit-any -- Old code before rule was applied, Old code before rule was applied
export async function checkThrowAsync(asyncFn: Function): Promise<any> {
	try {
		await asyncFn();
	} catch (error) {
		return error;
	}
	return null;
}

// eslint-disable-next-line @typescript-eslint/ban-types, @typescript-eslint/no-explicit-any -- Old code before rule was applied, Old code before rule was applied
export async function expectThrow(asyncFn: Function, errorCode: any = undefined): Promise<any> {
	let hasThrown = false;
	let thrownError = null;
	try {
		await asyncFn();
	} catch (error) {
		hasThrown = true;
		thrownError = error;
	}

	if (!hasThrown) {
		expect('not throw').toBe('throw');
	} else if (errorCode !== undefined && thrownError.code !== errorCode) {
		console.error(thrownError);
		expect(`error code: ${thrownError.code}`).toBe(`error code: ${errorCode}`);
	} else {
		expect(true).toBe(true);
	}

	return thrownError;
}

// eslint-disable-next-line @typescript-eslint/ban-types -- Old code before rule was applied
export async function expectHttpError(asyncFn: Function, expectedHttpCode: number, expectedErrorCode: string = null): Promise<void> {
	let thrownError = null;

	try {
		await asyncFn();
	} catch (error) {
		thrownError = error;
	}

	if (!thrownError) {
		expect('not throw').toBe('throw');
	} else {
		expect(thrownError.httpCode).toBe(expectedHttpCode);

		if (expectedErrorCode !== null) {
			expect(thrownError.code).toBe(expectedErrorCode);
		}
	}
}

// eslint-disable-next-line @typescript-eslint/ban-types -- Old code before rule was applied
export async function expectNoHttpError(asyncFn: Function): Promise<void> {
	let thrownError = null;

	try {
		await asyncFn();
	} catch (error) {
		thrownError = error;
	}

	if (thrownError) {
		expect('throw').toBe('not throw');
	} else {
		expect(true).toBe(true);
	}
}

// eslint-disable-next-line @typescript-eslint/ban-types -- Old code before rule was applied
export async function expectNotThrow(asyncFn: Function) {
	let thrownError = null;
	try {
		await asyncFn();
	} catch (error) {
		thrownError = error;
	}

	if (thrownError) {
		console.error(thrownError);
		expect(thrownError.message).toBe('');
	} else {
		expect(true).toBe(true);
	}
}

export async function createApplicationCredentials(userId: string, applicationAuthId: string) {
	await models().application().createPreLoginRecord(
		applicationAuthId,
		'',
		undefined,
		undefined,
		undefined,
	);
	await models().application().onAuthorizeUse(applicationAuthId, userId);

	const appAuthResponse: AppAuthResponse = await getApi('', `application_auth/${applicationAuthId}`);

	return appAuthResponse;

}
