import { camelCase } from 'change-case';
import type {
	JsonObject,
	IDataObject,
	IExecuteFunctions,
	IExecuteSingleFunctions,
	IHttpRequestMethods,
	IHttpRequestOptions,
	ILoadOptionsFunctions,
	INodeExecutionData,
	IN8nHttpFullResponse,
	INodeListSearchResult,
	INodeListSearchItems,
	INodeParameterResourceLocator,
	ICredentialDataDecryptedObject,
	IRequestOptions,
} from 'n8n-workflow';
import { NodeApiError, sanitizeXmlName } from 'n8n-workflow';
import { Parser } from 'xml2js';
import { firstCharLowerCase, parseBooleans, parseNumbers } from 'xml2js/lib/processors';

import { getUrl } from '@credentials/common/http';

import { compareHeader } from './compare-header';

export const XMsVersion = '2021-12-02';

export const HeaderConstants = {
	AUTHORIZATION: 'authorization',
	CONTENT_ENCODING: 'content-encoding',
	CONTENT_LANGUAGE: 'content-language',
	CONTENT_LENGTH: 'content-length',
	CONTENT_MD5: 'content-md5',
	CONTENT_TYPE: 'content-Type',
	DATE: 'date',
	ETAG: 'etag',
	IF_MATCH: 'if-match',
	IF_MODIFIED_SINCE: 'if-Modified-since',
	IF_NONE_MATCH: 'if-none-match',
	IF_UNMODIFIED_SINCE: 'if-unmodified-since',
	ORIGIN: 'origin',
	RANGE: 'range',
	X_MS_COPY_SOURCE: 'x-ms-copy-source',
	X_MS_DATE: 'x-ms-date',
	X_MS_VERSION: 'x-ms-version',
	X_MS_BLOB_TYPE: 'x-ms-blob-type',
	X_MS_BLOB_CONTENT_DISPOSITION: 'x-ms-blob-content-disposition',
	X_MS_BLOB_PUBLIC_ACCESS: 'x-ms-blob-public-access',
	X_MS_HAS_IMMUTABILITY_POLICY: 'x-ms-has-immutability-policy',
	X_MS_HAS_LEGAL_HOLD: 'x-ms-has-legal-hold',
	X_MS_CONTENT_CRC64: 'x-ms-content-crc64',
	X_MS_REQUEST_SERVER_ENCRYPTED: 'x-ms-request-server-encrypted',
	X_MS_ENCRYPTION_SCOPE: 'x-ms-encryption-scope',
	X_MS_VERSION_ID: 'x-ms-version-id',
	X_MS_TAG_COUNT: 'x-ms-tag-count',
	X_MS_COPY_PROGRESS: 'x-ms-copy-progress',
	X_MS_INCREMENTAL_COPY: 'x-ms-incremental-copy',
	X_MS_BLOB_SEQUENCE_NUMBER: 'x-ms-blob-sequence-number',
	X_MS_BLOB_COMMITTED_BLOCK_COUNT: 'x-ms-blob-committed-block-count',
	X_MS_SERVER_ENCRYPTED: 'x-ms-server-encrypted',
	X_MS_ENCRYPTION_CONTEXT: 'x-ms-encryption-context',
	X_MS_BLOB_CONTENT_MD5: 'x-ms-blob-content-md5',
	X_MS_BLOB_SEALED: 'x-ms-blob-sealed',
	X_MS_IMMUTABILITY_POLICY_UNTIL_DATE: 'x-ms-immutability-policy-until-date',
	X_MS_IMMUTABILITY_POLICY_MODE: 'x-ms-immutability-policy-mode',
	X_MS_LEGAL_HOLD: 'x-ms-legal-hold',
	X_MS_DELETE_TYPE_PERMANENT: 'x-ms-delete-type-permanent',
	X_MS_ACCESS_TIER: 'x-ms-access-tier',
	X_MS_BLOB_CACHE_CONTROL: 'x-ms-blob-cache-control',
	X_MS_LEASE_ID: 'x-ms-lease-id',
	X_MS_BLOB_CONTENT_ENCODING: 'x-ms-blob-content-encoding',
	X_MS_BLOB_CONTENT_LANGUAGE: 'x-ms-blob-content-language',
	X_MS_BLOB_CONTENT_TYPE: 'x-ms-blob-content-type',
	X_MS_EXPIRY_OPTION: 'x-ms-expiry-option',
	X_MS_EXPIRY_TIME: 'x-ms-expiry-time',
	X_MS_TAGS: 'x-ms-tags',
	X_MS_UPN: 'x-ms-upn',
	PREFIX_X_MS: 'x-ms-',
	PREFIX_X_MS_META: 'x-ms-meta-',
};

export async function azureStorageApiRequest(
	this: IExecuteFunctions | IExecuteSingleFunctions | ILoadOptionsFunctions,
	method: IHttpRequestMethods,
	endpoint: string,
	body: IDataObject = {},
	qs?: IDataObject,
	headers?: IDataObject,
	url?: string,
): Promise<string> {
	const authentication = this.getNodeParameter('authentication', 0) as 'oAuth2' | 'sharedKey';
	const credentialsType =
		authentication === 'oAuth2' ? 'azureStorageOAuth2Api' : 'azureStorageSharedKeyApi';
	const credentials = await this.getCredentials<{
		baseUrl: string;
	}>(credentialsType);

	const options: IHttpRequestOptions = {
		method,
		url: url ?? `${credentials.baseUrl}${endpoint}`,
		headers,
		body,
		qs,
	};

	options.headers ??= {};
	options.headers[HeaderConstants.X_MS_DATE] = new Date().toUTCString();
	options.headers[HeaderConstants.X_MS_VERSION] = XMsVersion;

	// XML response
	const response = (await this.helpers.requestWithAuthentication.call(
		this,
		credentialsType,
		options,
	)) as string;

	return response;
}

export async function handleErrorPostReceive(
	this: IExecuteSingleFunctions,
	data: INodeExecutionData[],
	response: IN8nHttpFullResponse,
): Promise<INodeExecutionData[]> {
	if (String(response.statusCode).startsWith('4') || String(response.statusCode).startsWith('5')) {
		const resource = this.getNodeParameter('resource') as string;
		const operation = this.getNodeParameter('operation') as string;

		const parser = new Parser({
			explicitArray: false,
			tagNameProcessors: [sanitizeXmlName, firstCharLowerCase],
			attrNameProcessors: [sanitizeXmlName],
		});
		const { error } =
			((await parser.parseStringPromise(data[0].json as unknown as string)) as {
				error: {
					code: string;
					message: string;
					headerName?: string;
					headerValue?: string;
				};
			}) ?? {};

		if (
			error?.code === 'InvalidAuthenticationInfo' &&
			((error as IDataObject)?.authenticationErrorDetail as string) ===
				'Lifetime validation failed. The token is expired.'
		) {
			throw new NodeApiError(this.getNode(), error as JsonObject, {
				message: 'Lifetime validation failed. The token is expired.',
				description: 'Please check your credentials and try again',
			});
		}

		if (resource === 'blob') {
			if (error?.code === 'ContainerNotFound') {
				throw new NodeApiError(this.getNode(), error as JsonObject, {
					message: "The required container doesn't match any existing one",
					description: "Double-check the value in the parameter 'Container Name' and try again",
				});
			}

			if (operation === 'create') {
				if (
					this.getNodeParameter('from') === 'url' &&
					((error?.code === 'InvalidHeaderValue' &&
						error?.headerName === HeaderConstants.X_MS_COPY_SOURCE) ||
						error?.code === 'CannotVerifyCopySource')
				) {
					throw new NodeApiError(this.getNode(), error as JsonObject, {
						message: 'The provided URL is invalid',
						description: "Double-check the value in the parameter 'URL' and try again",
					});
				}
			} else if (operation === 'delete') {
				if (error?.code === 'BlobNotFound') {
					throw new NodeApiError(this.getNode(), error as JsonObject, {
						message: "The required blob doesn't match any existing one",
						description: "Double-check the value in the parameter 'Blob to Delete' and try again",
					});
				}
			} else if (operation === 'get') {
				if (error?.code === 'BlobNotFound') {
					throw new NodeApiError(this.getNode(), error as JsonObject, {
						message: "The required blob doesn't match any existing one",
						description: "Double-check the value in the parameter 'Blob to Get' and try again",
					});
				}
			} else if (operation === 'getAll') {
				if (
					error?.code === 'InvalidQueryParameterValue' &&
					(this.getNodeParameter('fields', []) as string[]).includes('permissions')
				) {
					throw new NodeApiError(this.getNode(), error as JsonObject, {
						message:
							'Permissions field is only supported for accounts with a hierarchical namespace enabled',
						description: "Exclude 'Permissions' from 'Fields' and try again",
					});
				}
				if (
					error?.code === 'UnsupportedQueryParameter' &&
					(this.getNodeParameter('fields', []) as string[]).includes('deleted') &&
					(this.getNodeParameter('filter', []) as string[]).includes('deleted')
				) {
					throw new NodeApiError(this.getNode(), error as JsonObject, {
						message: "Including 'Deleted' field and filter is not supported",
						description: "Exclude 'Deleted' from 'Fields' or 'Filter' and try again",
					});
				}
			}
		} else if (resource === 'container') {
			if (operation === 'create') {
				if (error?.code === 'ContainerAlreadyExists') {
					throw new NodeApiError(this.getNode(), error as JsonObject, {
						message: 'The specified container already exists',
						description: "Use a unique value for 'Container Name' and try again",
					});
				}
				if (error?.code === 'PublicAccessNotPermitted') {
					throw new NodeApiError(this.getNode(), error as JsonObject, {
						message: 'Public access is not permitted on this storage account',
						description: "Check the 'Access Level' and try again",
					});
				}
			} else if (operation === 'delete') {
				if (error?.code === 'ContainerNotFound') {
					throw new NodeApiError(this.getNode(), error as JsonObject, {
						message: "The required container doesn't match any existing one",
						description:
							"Double-check the value in the parameter 'Container to Delete' and try again",
					});
				}
			} else if (operation === 'get') {
				if (error?.code === 'ContainerNotFound') {
					throw new NodeApiError(this.getNode(), error as JsonObject, {
						message: "The required container doesn't match any existing one",
						description: "Double-check the value in the parameter 'Container to Get' and try again",
					});
				}
			} else if (operation === 'getAll') {
			}
		}

		if (error) {
			throw new NodeApiError(this.getNode(), response as unknown as JsonObject, {
				message: error.code,
				description: error.message,
			});
		} else {
			throw new NodeApiError(this.getNode(), response as unknown as JsonObject, {
				parseXml: true,
			});
		}
	}

	return data;
}

export function getCanonicalizedHeadersString(
	requestOptions: IHttpRequestOptions | IRequestOptions,
): string {
	let headersArray: Array<{ name: string; value: string }> = [];
	// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
	for (const [name, value] of Object.entries(requestOptions.headers!) as unknown as [
		string,
		string,
	]) {
		if (name.toLowerCase().startsWith(HeaderConstants.PREFIX_X_MS)) {
			headersArray.push({ name, value });
		}
	}

	headersArray.sort((a, b): number => {
		return compareHeader(a.name.toLowerCase(), b.name.toLowerCase());
	});

	// Remove duplicate headers
	headersArray = headersArray.filter((value, index, array) => {
		if (index > 0 && value.name.toLowerCase() === array[index - 1].name.toLowerCase()) {
			return false;
		}
		return true;
	});

	let canonicalizedHeadersStringToSign: string = '';
	headersArray.forEach((header) => {
		canonicalizedHeadersStringToSign += `${header.name
			.toLowerCase()
			.trimEnd()}:${header.value.trimStart()}\n`;
	});

	return canonicalizedHeadersStringToSign;
}

export function getCanonicalizedResourceString(
	requestOptions: IHttpRequestOptions | IRequestOptions,
	credentials: ICredentialDataDecryptedObject,
): string {
	const path: string = new URL(getUrl(requestOptions)).pathname || '/';
	let canonicalizedResourceString = `/${credentials.account as string}${path}`;
	if (requestOptions.qs && Object.keys(requestOptions.qs).length > 0) {
		canonicalizedResourceString +=
			'\n' +
			Object.keys(requestOptions.qs)
				.sort()
				.map(
					// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
					(key) => `${key.toLowerCase()}:${decodeURIComponent(requestOptions.qs![key] as string)}`,
				)
				.join('\n');
	}

	return canonicalizedResourceString;
}

export function parseHeaders(headers: IDataObject) {
	const parseBooleanHeaders = [
		HeaderConstants.X_MS_DELETE_TYPE_PERMANENT,
		HeaderConstants.X_MS_INCREMENTAL_COPY,
		HeaderConstants.X_MS_SERVER_ENCRYPTED,
		HeaderConstants.X_MS_BLOB_SEALED,
		HeaderConstants.X_MS_REQUEST_SERVER_ENCRYPTED,
		HeaderConstants.X_MS_HAS_IMMUTABILITY_POLICY,
		HeaderConstants.X_MS_HAS_LEGAL_HOLD,
	];
	const parseNumberHeaders = [
		HeaderConstants.X_MS_TAG_COUNT,
		HeaderConstants.CONTENT_LENGTH,
		HeaderConstants.X_MS_BLOB_SEQUENCE_NUMBER,
		HeaderConstants.X_MS_COPY_PROGRESS,
		HeaderConstants.X_MS_BLOB_COMMITTED_BLOCK_COUNT,
	];

	const result: IDataObject = {};

	const metadataKeys = Object.keys(headers).filter((x) =>
		x.startsWith(HeaderConstants.PREFIX_X_MS_META),
	);

	for (const key in headers) {
		if (metadataKeys.includes(key)) {
			continue;
		}

		let newKey = key.startsWith(HeaderConstants.PREFIX_X_MS)
			? camelCase(key.replace(HeaderConstants.PREFIX_X_MS, ''))
			: camelCase(key);
		newKey = newKey.replace('-', '');

		const newValue = parseBooleanHeaders.includes(key)
			? parseBooleans(headers[key] as string)
			: parseNumberHeaders.includes(key)
				? parseNumbers(headers[key] as string)
				: headers[key];

		result[newKey] = newValue;
	}

	if (metadataKeys.length > 0) {
		result.metadata = {};
		for (const key of metadataKeys) {
			(result.metadata as IDataObject)[key.replace(HeaderConstants.PREFIX_X_MS_META, '')] =
				headers[key];
		}
	}

	return result;
}

export async function parseBlobList(
	xml: string,
): Promise<{ blobs: IDataObject[]; maxResults?: number; nextMarker?: string }> {
	const parser = new Parser({
		explicitArray: false,
		tagNameProcessors: [sanitizeXmlName, firstCharLowerCase, (name) => name.replace('-', '')],
		attrNameProcessors: [sanitizeXmlName],
		valueProcessors: [
			function (value, name) {
				if (
					[
						'deleted',
						'isCurrentVersion',
						'serverEncrypted',
						'incrementalCopy',
						'accessTierInferred',
						'isSealed',
						'legalHold',
					].includes(name)
				) {
					return parseBooleans(value);
				} else if (
					[
						'maxResults',
						'contentLength',
						'blobSequenceNumber',
						'remainingRetentionDays',
						'tagCount',
						'content-Length',
					].includes(name)
				) {
					return parseNumbers(value);
				}
				return value;
			},
		],
	});
	const data = (await parser.parseStringPromise(xml)) as {
		enumerationResults: {
			blobs: { blob: IDataObject | IDataObject[] };
			maxResults: number;
			nextMarker: string;
			prefix: string;
		};
	};

	if (typeof data.enumerationResults.blobs !== 'object') {
		// No items
		return { blobs: [] };
	}

	if (!Array.isArray(data.enumerationResults.blobs.blob)) {
		// Single item
		data.enumerationResults.blobs.blob = [data.enumerationResults.blobs.blob];
	}

	for (const blob of data.enumerationResults.blobs.blob) {
		if (blob.tags) {
			if (!Array.isArray(((blob.tags as IDataObject).tagSet as IDataObject).tag)) {
				((blob.tags as IDataObject).tagSet as IDataObject).tag = [
					((blob.tags as IDataObject).tagSet as IDataObject).tag,
				];
			}
			blob.tags = ((blob.tags as IDataObject).tagSet as IDataObject).tag;
		}
	}

	for (const container of data.enumerationResults.blobs.blob) {
		if (container.metadata === '') {
			delete container.metadata;
		}
		if (container.orMetadata === '') {
			delete container.orMetadata;
		}
	}

	return {
		blobs: data.enumerationResults.blobs.blob,
		maxResults: data.enumerationResults.maxResults,
		nextMarker: data.enumerationResults.nextMarker,
	};
}

export async function parseContainerList(
	xml: string,
): Promise<{ containers: IDataObject[]; maxResults?: number; nextMarker?: string }> {
	const parser = new Parser({
		explicitArray: false,
		tagNameProcessors: [sanitizeXmlName, firstCharLowerCase, (name) => name.replace('-', '')],
		attrNameProcessors: [sanitizeXmlName],
		valueProcessors: [
			function (value, name) {
				if (
					[
						'deleted',
						'hasImmutabilityPolicy',
						'hasLegalHold',
						'preventEncryptionScopeOverride',
						'isImmutableStorageWithVersioningEnabled',
					].includes(name)
				) {
					return parseBooleans(value);
				} else if (['maxResults', 'remainingRetentionDays'].includes(name)) {
					return parseNumbers(value);
				}
				return value;
			},
		],
	});
	const data = (await parser.parseStringPromise(xml)) as {
		enumerationResults: {
			containers: { container: IDataObject | IDataObject[] };
			maxResults: number;
			nextMarker: string;
			prefix: string;
		};
	};

	if (typeof data.enumerationResults.containers !== 'object') {
		// No items
		return { containers: [] };
	}

	if (!Array.isArray(data.enumerationResults.containers.container)) {
		// Single item
		data.enumerationResults.containers.container = [data.enumerationResults.containers.container];
	}

	for (const container of data.enumerationResults.containers.container) {
		if (container.metadata === '') {
			delete container.metadata;
		}
	}

	return {
		containers: data.enumerationResults.containers.container,
		maxResults: data.enumerationResults.maxResults,
		nextMarker: data.enumerationResults.nextMarker,
	};
}

export async function getBlobs(
	this: ILoadOptionsFunctions,
	filter?: string,
	paginationToken?: string,
): Promise<INodeListSearchResult> {
	const container = this.getNodeParameter('container') as INodeParameterResourceLocator;

	let response: string;

	const qs: IDataObject = {
		restype: 'container',
		comp: 'list',
	};

	if (paginationToken) {
		qs.marker = paginationToken;
		response = await azureStorageApiRequest.call(this, 'GET', `/${container.value}`, {}, qs);
	} else {
		qs.maxresults = 5000;
		if (filter) {
			qs.prefix = filter;
		}
		response = await azureStorageApiRequest.call(this, 'GET', `/${container.value}`, {}, qs);
	}

	const data = await parseBlobList(response);

	const results: INodeListSearchItems[] = data.blobs
		.map((c) => ({
			name: c.name as string,
			value: c.name as string,
		}))
		.sort((a, b) =>
			a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: 'base' }),
		);

	return {
		results,
		paginationToken: data.nextMarker,
	};
}

export async function getContainers(
	this: ILoadOptionsFunctions,
	filter?: string,
	paginationToken?: string,
): Promise<INodeListSearchResult> {
	let response: string;

	const qs: IDataObject = {
		comp: 'list',
	};

	if (paginationToken) {
		qs.marker = paginationToken;
		response = await azureStorageApiRequest.call(this, 'GET', '/', {}, qs);
	} else {
		qs.maxresults = 5000;
		if (filter) {
			qs.prefix = filter;
		}
		response = await azureStorageApiRequest.call(this, 'GET', '/', {}, qs);
	}

	const data = await parseContainerList(response);

	const results: INodeListSearchItems[] = data.containers
		.map((c) => ({
			name: c.name as string,
			value: c.name as string,
		}))
		.sort((a, b) =>
			a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: 'base' }),
		);

	return {
		results,
		paginationToken: data.nextMarker,
	};
}
