import type * as AbstractSQLCompiler from '@resin/abstract-sql-compiler';
import type {
	ODataBinds,
	ODataOptions,
	ODataQuery,
	SupportedMethod,
} from '@balena/odata-parser';
import type { Tx } from '../database-layer/db';
import type { InstantiatedHooks } from './hooks';
import type { AnyObject } from './common-types';

import * as ODataParser from '@balena/odata-parser';
import * as Bluebird from 'bluebird';
export const SyntaxError = ODataParser.SyntaxError;
import { OData2AbstractSQL } from '@resin/odata-to-abstract-sql';
import * as _ from 'lodash';
import * as memoize from 'memoizee';
import memoizeWeak = require('memoizee/weak');

export { BadRequestError, ParsingError, TranslationError } from './errors';
import * as deepFreeze from 'deep-freeze';
import * as env from '../config-loader/env';
import {
	BadRequestError,
	ParsingError,
	PermissionError,
	TranslationError,
} from './errors';
import * as sbvrUtils from './sbvr-utils';

export type OdataBinds = ODataBinds;

export interface UnparsedRequest {
	method: string;
	url: string;
	data?: any;
	headers?: { [header: string]: string };
	changeSet?: UnparsedRequest[];
	_isChangeSet?: boolean;
}

export interface ODataRequest {
	method: SupportedMethod;
	url: string;
	odataQuery: ODataQuery;
	odataBinds: OdataBinds;
	values: AnyObject;
	abstractSqlModel?: AbstractSQLCompiler.AbstractSqlModel;
	abstractSqlQuery?: AbstractSQLCompiler.AbstractSqlQuery;
	sqlQuery?: AbstractSQLCompiler.SqlResult | AbstractSQLCompiler.SqlResult[];
	resourceName: string;
	vocabulary: string;
	_defer?: boolean;
	id?: number;
	custom: AnyObject;
	tx?: Tx;
	modifiedFields?: ReturnType<
		AbstractSQLCompiler.EngineInstance['getModifiedFields']
	>;
	hooks?: InstantiatedHooks<sbvrUtils.Hooks>;
	engine?: AbstractSQLCompiler.Engines;
}

// Converts a value to its string representation and tries to parse is as an
// OData bind
export const parseId = (b: any) => {
	const { tree, binds } = ODataParser.parse(String(b), {
		startRule: 'ProcessRule',
		rule: 'KeyBind',
	});
	return binds[tree.bind];
};

export const memoizedParseOdata = (() => {
	const parseOdata = (url: string) => {
		const odata = ODataParser.parse(url);
		// if we parse a canAccess action rewrite the resource to ensure we
		// do not run the resource hooks
		if (
			odata.tree.property != null &&
			odata.tree.property.resource === 'canAccess'
		) {
			odata.tree.resource =
				odata.tree.resource + '#' + odata.tree.property.resource;
		}
		return odata;
	};

	const _memoizedParseOdata = memoize(parseOdata, {
		primitive: true,
		max: env.cache.parseOData.max,
	});
	return (url: string) => {
		const queryParamsIndex = url.indexOf('?');
		if (queryParamsIndex !== -1) {
			if (/[?&(]@/.test(url)) {
				// Try to cache based on parameter aliases if there might be some
				const parameterAliases = new URLSearchParams();
				const queryParams = new URLSearchParams(url.slice(queryParamsIndex));
				Array.from(queryParams.entries()).forEach(([key, value]) => {
					if (key.startsWith('@')) {
						parameterAliases.append(key, value);
						queryParams.delete(key);
					}
				});
				const parameterAliasesString = parameterAliases.toString();
				if (parameterAliasesString !== '') {
					const parsed = _.cloneDeep(
						_memoizedParseOdata(
							url.slice(0, queryParamsIndex) +
								'?' +
								decodeURIComponent(queryParams.toString()),
						),
					);
					const parsedParams = ODataParser.parse(
						decodeURIComponent(parameterAliasesString),
						{
							startRule: 'ProcessRule',
							rule: 'QueryOptions',
						},
					);
					if (parsed.tree.options == null) {
						parsed.tree.options = {};
					}
					for (const key of Object.keys(parsedParams.tree)) {
						parsed.tree.options[key] = parsedParams.tree[key];
						parsed.binds[key] = parsedParams.binds[key];
					}
					return parsed;
				}
			}
			// If we're doing a complex url then skip caching due to # of permutations
			return parseOdata(url);
		} else if (url.includes('(')) {
			// We're parsing a url like `/pilot(1)` so whilst there are no query options
			// there are still enough permutations to ruin the hit %
			return parseOdata(url);
		} else {
			// Else if it's simple we can easily skip the parsing as we know we'll get a high % hit rate
			// We deep clone to avoid mutations polluting the cache
			return _.cloneDeep(_memoizedParseOdata(url));
		}
	};
})();

const memoizedGetOData2AbstractSQL = memoizeWeak(
	(abstractSqlModel: AbstractSQLCompiler.AbstractSqlModel) => {
		return new OData2AbstractSQL(abstractSqlModel);
	},
);

const memoizedOdata2AbstractSQL = (() => {
	const $memoizedOdata2AbstractSQL = memoizeWeak(
		(
			abstractSqlModel: AbstractSQLCompiler.AbstractSqlModel,
			odataQuery: ODataQuery,
			method: SupportedMethod,
			bodyKeys: string[],
			existingBindVarsLength: number,
		) => {
			try {
				const odata2AbstractSQL = memoizedGetOData2AbstractSQL(
					abstractSqlModel,
				);
				const abstractSql = odata2AbstractSQL.match(
					odataQuery,
					method,
					bodyKeys,
					existingBindVarsLength,
				);
				// We deep freeze to prevent mutations, which would pollute the cache
				deepFreeze(abstractSql);
				return abstractSql;
			} catch (e) {
				if (e instanceof PermissionError) {
					throw e;
				}
				console.error(
					'Failed to translate url: ',
					JSON.stringify(odataQuery, null, '\t'),
					method,
					e,
				);
				throw new TranslationError('Failed to translate url');
			}
		},
		{
			normalizer: (
				_abstractSqlModel: AbstractSQLCompiler.AbstractSqlModel,
				[odataQuery, method, bodyKeys, existingBindVarsLength]: [
					ODataQuery,
					SupportedMethod,
					string[],
					number,
				],
			) => {
				return (
					JSON.stringify(odataQuery) +
					method +
					bodyKeys +
					existingBindVarsLength
				);
			},
			max: env.cache.odataToAbstractSql.max,
		},
	);

	return (
		request: Pick<
			ODataRequest,
			| 'method'
			| 'odataQuery'
			| 'odataBinds'
			| 'values'
			| 'vocabulary'
			| 'abstractSqlModel'
		>,
	) => {
		const { method, odataBinds, values } = request;
		let { odataQuery } = request;
		const abstractSqlModel = sbvrUtils.getAbstractSqlModel(request);
		// Sort the body keys to improve cache hits
		const sortedBody = Object.keys(values).sort();
		// Remove unused options for odata-to-abstract-sql to improve cache hits
		if (odataQuery.options) {
			odataQuery = {
				...odataQuery,
				options: _.pick(
					odataQuery.options,
					'$select',
					'$filter',
					'$expand',
					'$orderby',
					'$top',
					'$skip',
					'$count',
					'$inlinecount',
					'$format',
				) as ODataOptions,
			};
		}
		const { tree, extraBodyVars, extraBindVars } = $memoizedOdata2AbstractSQL(
			abstractSqlModel,
			odataQuery,
			method,
			sortedBody,
			odataBinds.length,
		);
		Object.assign(values, extraBodyVars);
		odataBinds.push(...extraBindVars);
		return tree;
	};
})();

export const metadataEndpoints = ['$metadata', '$serviceroot'];

export function parseOData(
	b: UnparsedRequest & { _isChangeSet?: false },
): Bluebird<ODataRequest>;
export function parseOData(
	b: UnparsedRequest & { _isChangeSet: true },
): Bluebird<ODataRequest[]>;
export function parseOData(
	b: UnparsedRequest,
): Bluebird<ODataRequest | ODataRequest[]>;
export function parseOData(
	b: UnparsedRequest,
): Bluebird<ODataRequest | ODataRequest[]> {
	return Bluebird.try<ODataRequest | ODataRequest[]>(async () => {
		try {
			if (b._isChangeSet && b.changeSet != null) {
				// We sort the CS set once, we must assure that requests which reference
				// other requests in the changeset are placed last. Once they are sorted
				// Map will guarantee retrival of results in insertion order
				const sortedCS = _.sortBy(b.changeSet, (el) => el.url[0] !== '/');
				const csReferences = await Bluebird.reduce(
					sortedCS,
					parseODataChangeset,
					new Map<ODataRequest['id'], ODataRequest>(),
				);
				return Array.from(csReferences.values()) as ODataRequest[];
			} else {
				const { url, apiRoot } = splitApiRoot(b.url);
				const odata = memoizedParseOdata(url);

				return {
					method: b.method as SupportedMethod,
					url,
					vocabulary: apiRoot,
					resourceName: odata.tree.resource,
					odataBinds: odata.binds,
					odataQuery: odata.tree,
					values: b.data ?? {},
					custom: {},
					_defer: false,
				};
			}
		} catch (err) {
			if (err instanceof ODataParser.SyntaxError) {
				throw new BadRequestError(`Malformed url: '${b.url}'`);
			}
			if (!(err instanceof BadRequestError || err instanceof ParsingError)) {
				console.error('Failed to parse url: ', b.method, b.url, err);
				throw new ParsingError(`Failed to parse url: '${b.url}'`);
			}
			throw err;
		}
	});
}

const parseODataChangeset = (
	csReferences: Map<ODataRequest['id'], ODataRequest>,
	b: UnparsedRequest,
) => {
	const contentId: ODataRequest['id'] = mustExtractHeader(b, 'content-id');

	if (csReferences.has(contentId)) {
		throw new BadRequestError('Content-Id must be unique inside a changeset');
	}

	let defer: boolean;
	let odata;
	let apiRoot: string;
	let url;

	if (b.url[0] === '/') {
		({ url, apiRoot } = splitApiRoot(b.url));
		odata = memoizedParseOdata(url);
		defer = false;
	} else {
		url = b.url;
		odata = memoizedParseOdata(url);
		const { bind } = odata.tree.resource;
		const [, id] = odata.binds[bind];
		// Use reference to collect information
		const ref = csReferences.get(id);
		if (ref === undefined) {
			throw new BadRequestError('Content-Id refers to a non existent resource');
		}
		apiRoot = ref.vocabulary;
		// Update resource with actual resourceName
		odata.tree.resource = ref.resourceName;
		defer = true;
	}

	const parseResult: ODataRequest = {
		method: b.method as SupportedMethod,
		url,
		vocabulary: apiRoot,
		resourceName: odata.tree.resource,
		odataBinds: odata.binds,
		odataQuery: odata.tree,
		values: b.data ?? {},
		custom: {},
		id: contentId,
		_defer: defer,
	};
	csReferences.set(contentId, parseResult);
	return csReferences;
};

const splitApiRoot = (url: string) => {
	const urlParts = url.split('/');
	const apiRoot = urlParts[1];
	if (apiRoot == null) {
		throw new ParsingError(`No such api root: ${apiRoot}`);
	}
	url = '/' + urlParts.slice(2).join('/');
	return { url, apiRoot };
};

const mustExtractHeader = (
	body: { headers?: { [header: string]: string } },
	header: string,
) => {
	const h: any = body.headers?.[header]?.[0];
	if (_.isEmpty(h)) {
		throw new BadRequestError(`${header} must be specified`);
	}
	return h;
};

export const translateUri = <
	T extends Pick<
		ODataRequest,
		| 'method'
		| 'odataQuery'
		| 'odataBinds'
		| 'values'
		| 'vocabulary'
		| 'resourceName'
		| 'abstractSqlModel'
	>
>(
	request: T & {
		abstractSqlQuery?: ODataRequest['abstractSqlQuery'];
	},
): typeof request => {
	if (request.abstractSqlQuery != null) {
		return request;
	}
	const isMetadataEndpoint =
		metadataEndpoints.includes(request.resourceName) ||
		request.method === 'OPTIONS';
	if (!isMetadataEndpoint) {
		const abstractSqlQuery = memoizedOdata2AbstractSQL(request);
		request = { ...request };
		request.abstractSqlQuery = abstractSqlQuery;
		return request;
	}
	return request;
};
