import { Container } from '@n8n/di';
import chardet from 'chardet';
import FileType from 'file-type';
import { IncomingMessage } from 'http';
import iconv from 'iconv-lite';
import get from 'lodash/get';
import { extension, lookup } from 'mime-types';
import type { StringValue as TimeUnitValue } from 'ms';
import type {
	BinaryHelperFunctions,
	IBinaryData,
	INode,
	ITaskDataConnections,
	IWorkflowExecuteAdditionalData,
	WorkflowSettingsBinaryMode,
} from 'n8n-workflow';
import {
	NodeOperationError,
	fileTypeFromMimeType,
	ApplicationError,
	UnexpectedError,
	isBinaryValue,
	BINARY_MODE_COMBINED,
	BINARY_MODE_SEPARATE,
	sanitizeFilename,
} from 'n8n-workflow';
import path from 'path';
import type { Readable } from 'stream';
import { URL } from 'url';

import { BinaryDataService } from '@/binary-data/binary-data.service';
import type { BinaryData } from '@/binary-data/types';
import { binaryToBuffer } from '@/binary-data/utils';

import { parseIncomingMessage } from './parse-incoming-message';

export async function binaryToString(body: Buffer | Readable, encoding?: string) {
	if (!encoding && body instanceof IncomingMessage) {
		parseIncomingMessage(body);
		encoding = body.encoding;
	}
	const buffer = await binaryToBuffer(body);
	return iconv.decode(buffer, encoding ?? 'utf-8');
}

function getBinaryPath(binaryDataId: string): string {
	return Container.get(BinaryDataService).getPath(binaryDataId);
}

/**
 * Returns binary file metadata
 */
async function getBinaryMetadata(binaryDataId: string): Promise<BinaryData.Metadata> {
	return await Container.get(BinaryDataService).getMetadata(binaryDataId);
}

/**
 * Returns binary file stream for piping
 */
async function getBinaryStream(binaryDataId: string, chunkSize?: number): Promise<Readable> {
	return await Container.get(BinaryDataService).getAsStream(binaryDataId, chunkSize);
}

/**
 * If parameterData is a string, returns the binary data the given item index and
 * property name from the input data.
 * Else if parameterData is a binary data object, returns the binary data object.
 */
export function assertBinaryData(
	inputData: ITaskDataConnections,
	node: INode,
	itemIndex: number,
	parameterData: string | IBinaryData,
	inputIndex: number,
	binaryMode: WorkflowSettingsBinaryMode = BINARY_MODE_SEPARATE,
): IBinaryData {
	if (isBinaryValue(parameterData)) {
		return parameterData;
	}
	if (typeof parameterData !== 'string') {
		throw new NodeOperationError(
			node,
			'Provided parameter is not a string or binary data object.',
			{
				itemIndex,
				description:
					'Specify the property name of the binary data in input item or use an expression to access the binary data in previous nodes, e.g. "{{ $(_target_node_).item.binary[_binary_property_name_] }}"',
			},
		);
	}

	if (binaryMode === BINARY_MODE_COMBINED) {
		const itemData = inputData.main[inputIndex]![itemIndex].json;
		const binaryData = get(itemData, parameterData);
		if (!isBinaryValue(binaryData)) {
			throw new NodeOperationError(
				node,
				`The path '${parameterData}' does not resolve to a binary data object in the input data [item ${itemIndex}]`,
				{
					itemIndex,
					description: `Check that the path '${parameterData}' is correct, and that it resolves to a binary data object in the input data`,
				},
			);
		}
		return binaryData;
	} else {
		const binaryKeyData = inputData.main[inputIndex]![itemIndex].binary;
		if (binaryKeyData === undefined) {
			throw new NodeOperationError(
				node,
				`This operation expects the node's input data to contain a binary file '${parameterData}', but none was found [item ${itemIndex}]`,
				{
					itemIndex,
					description: 'Make sure that the previous node outputs a binary file',
				},
			);
		}

		const binaryPropertyData = binaryKeyData[parameterData];
		if (binaryPropertyData === undefined) {
			throw new NodeOperationError(
				node,
				`The item has no binary field '${parameterData}' [item ${itemIndex}]`,
				{
					itemIndex,
					description:
						'Check that the parameter where you specified the input binary field name is correct, and that it matches a field in the binary input',
				},
			);
		}

		return binaryPropertyData;
	}
}

/**
 * If parameterData is a string, returns the binary data buffer for the given item index and
 * property name from the input data.
 * Else returns the binary data buffer for the given binary data object.
 */
export async function getBinaryDataBuffer(
	inputData: ITaskDataConnections,
	itemIndex: number,
	parameterData: string | IBinaryData,
	inputIndex: number,
	binaryMode: WorkflowSettingsBinaryMode = BINARY_MODE_SEPARATE,
): Promise<Buffer> {
	let binaryData: IBinaryData;

	if (isBinaryValue(parameterData)) {
		binaryData = parameterData;
	} else if (typeof parameterData === 'string' && binaryMode !== BINARY_MODE_COMBINED) {
		binaryData = inputData.main[inputIndex]![itemIndex].binary![parameterData];
	} else if (typeof parameterData === 'string') {
		const itemData = inputData.main[inputIndex]![itemIndex].json;
		const data = get(itemData, parameterData);
		if (!isBinaryValue(data)) {
			throw new UnexpectedError('Provided parameter is not a string or binary data object.');
		}
		binaryData = data;
	} else {
		throw new UnexpectedError('Provided parameter is not a string or binary data object.');
	}

	return await Container.get(BinaryDataService).getAsBuffer(binaryData);
}

export function detectBinaryEncoding(buffer: Buffer): string {
	return chardet.detect(buffer) as string;
}

/**
 * Store an incoming IBinaryData & related buffer using the configured binary data manager.
 *
 * @export
 * @param {IBinaryData} binaryData
 * @param {Buffer | Readable} bufferOrStream
 * @returns {Promise<IBinaryData>}
 */
export async function setBinaryDataBuffer(
	binaryData: IBinaryData,
	bufferOrStream: Buffer | Readable,
	workflowId: string,
	executionId: string,
): Promise<IBinaryData> {
	return await Container.get(BinaryDataService).store(
		{ type: 'execution', workflowId, executionId },
		bufferOrStream,
		binaryData,
	);
}

export async function copyBinaryFile(
	workflowId: string,
	executionId: string,
	filePath: string,
	fileName: string,
	mimeType?: string,
): Promise<IBinaryData> {
	let fileExtension: string | undefined;
	if (!mimeType) {
		// If no mime type is given figure it out

		if (filePath) {
			// Use file path to guess mime type
			const mimeTypeLookup = lookup(filePath);
			if (mimeTypeLookup) {
				mimeType = mimeTypeLookup;
			}
		}

		if (!mimeType) {
			// read the first bytes of the file to guess mime type
			const fileTypeData = await FileType.fromFile(filePath);
			if (fileTypeData) {
				mimeType = fileTypeData.mime;
				fileExtension = fileTypeData.ext;
			}
		}
	}

	if (!fileExtension && mimeType) {
		fileExtension = extension(mimeType) || undefined;
	}

	if (!mimeType) {
		// Fall back to text
		mimeType = 'text/plain';
	}

	const returnData: IBinaryData = {
		mimeType,
		fileType: fileTypeFromMimeType(mimeType),
		fileExtension,
		data: '',
	};

	if (fileName) {
		returnData.fileName = sanitizeFilename(fileName);
	} else if (filePath) {
		returnData.fileName = sanitizeFilename(filePath);
	}

	return await Container.get(BinaryDataService).copyBinaryFile(
		{ type: 'execution', workflowId, executionId },
		returnData,
		filePath,
	);
}

/**
 * Takes a buffer and converts it into the format n8n uses. It encodes the binary data as
 * base64 and adds metadata.
 */

export async function prepareBinaryData(
	binaryData: Buffer | Readable,
	executionId: string,
	workflowId: string,
	filePath?: string,
	mimeType?: string,
): Promise<IBinaryData> {
	let fileExtension: string | undefined;
	let fullUrl: string | undefined;
	if (binaryData instanceof IncomingMessage) {
		if (!filePath) {
			try {
				const { responseUrl } = binaryData;
				let sanitizedUrl: string | undefined;
				let urlPathname: string | undefined;
				if (responseUrl) {
					const parsed = new URL(responseUrl);
					parsed.username = '';
					parsed.password = '';
					sanitizedUrl = parsed.toString();
					urlPathname = parsed.pathname;
				}
				filePath =
					binaryData.contentDisposition?.filename ??
					(urlPathname ?? binaryData.req?.path)?.slice(1);
				fullUrl = sanitizedUrl;
			} catch {}
		}
		if (!mimeType) {
			mimeType = binaryData.contentType;
		}
	}

	if (!mimeType) {
		// If no mime type is given figure it out

		if (filePath) {
			// Use file path to guess mime type
			const mimeTypeLookup = lookup(filePath);
			if (mimeTypeLookup) {
				mimeType = mimeTypeLookup;
			}
		}

		if (!mimeType) {
			if (Buffer.isBuffer(binaryData)) {
				// Use buffer to guess mime type
				const fileTypeData = await FileType.fromBuffer(binaryData);
				if (fileTypeData) {
					mimeType = fileTypeData.mime;
					fileExtension = fileTypeData.ext;
				}
			} else if (binaryData instanceof IncomingMessage) {
				mimeType = binaryData.headers['content-type'];
			} else {
				// TODO: detect filetype from other kind of streams
			}
		}
	}

	if (!fileExtension && mimeType) {
		fileExtension = extension(mimeType) || undefined;
	}

	if (!mimeType) {
		// Fall back to text
		mimeType = 'text/plain';
	}

	const returnData: IBinaryData = {
		mimeType,
		fileType: fileTypeFromMimeType(mimeType),
		fileExtension,
		data: '',
	};

	if (filePath) {
		const filePathParts = path.parse(filePath);

		if (fullUrl) {
			returnData.directory = fullUrl;
		} else if (filePathParts.dir !== '') {
			returnData.directory = filePathParts.dir;
		}

		returnData.fileName = filePathParts.base;

		// Remove the dot
		fileExtension = filePathParts.ext.slice(1);
		if (fileExtension) {
			returnData.fileExtension = fileExtension;
		}
	}

	return await setBinaryDataBuffer(returnData, binaryData, workflowId, executionId);
}

export const getBinaryHelperFunctions = (
	{ executionId, restApiUrl }: IWorkflowExecuteAdditionalData,
	workflowId: string,
): BinaryHelperFunctions => ({
	getBinaryPath,
	getBinaryStream,
	getBinaryMetadata,
	binaryToBuffer,
	binaryToString,
	createBinarySignedUrl(binaryData: IBinaryData, expiresIn?: TimeUnitValue) {
		const token = Container.get(BinaryDataService).createSignedToken(binaryData, expiresIn);
		return `${restApiUrl}/binary-data/signed?token=${token}`;
	},
	prepareBinaryData: async (binaryData, filePath, mimeType) =>
		await prepareBinaryData(binaryData, executionId!, workflowId, filePath, mimeType),
	setBinaryDataBuffer: async (data, binaryData) =>
		await setBinaryDataBuffer(data, binaryData, workflowId, executionId!),
	copyBinaryFile: async () => {
		throw new ApplicationError('`copyBinaryFile` has been removed. Please upgrade this node.');
	},
});
