import fs, { PathLike } from 'fs';
import { hostname } from 'os';
import { Stream } from 'stream';
import type { Readable } from 'stream';

import { addressparser } from './address';
import { getRFC2822Date } from './date';
import { mimeWordEncode } from './mime';

const CRLF = '\r\n' as const;

/**
 * MIME standard wants 76 char chunks when sending out.
 */
export const MIMECHUNK = 76 as const;

/**
 * meets both base64 and mime divisibility
 */
export const MIME64CHUNK = (MIMECHUNK * 6) as 456;

/**
 * size of the message stream buffer
 */
export const BUFFERSIZE = (MIMECHUNK * 24 * 7) as 12768;

export interface MessageAttachmentHeaders {
	[index: string]: string | undefined;
	'content-type'?: string;
	'content-transfer-encoding'?: BufferEncoding | '7bit' | '8bit';
	'content-disposition'?: string;
}

export interface MessageAttachment {
	[index: string]:
		| string
		| boolean
		| MessageAttachment
		| MessageAttachment[]
		| MessageAttachmentHeaders
		| Readable
		| PathLike
		| undefined;
	name?: string;
	headers?: MessageAttachmentHeaders;
	inline?: boolean;
	alternative?: MessageAttachment | boolean;
	related?: MessageAttachment[];
	data?: string;
	encoded?: boolean;
	stream?: Readable;
	path?: PathLike;
	type?: string;
	charset?: string;
	method?: string;
}

export interface MessageHeaders {
	[index: string]:
		| boolean
		| string
		| string[]
		| null
		| MessageAttachment
		| MessageAttachment[];
	'content-type': string;
	'message-id': string;
	'return-path': string | null;
	date: string;
	from: string | string[];
	to: string | string[];
	cc: string | string[];
	bcc: string | string[];
	subject: string;
	text: string | null;
	attachment: MessageAttachment | MessageAttachment[];
}

let counter = 0;

function generateBoundary() {
	let text = '';
	const possible =
		"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'()+_,-./:=?";

	for (let i = 0; i < 69; i++) {
		text += possible.charAt(Math.floor(Math.random() * possible.length));
	}

	return text;
}

function convertPersonToAddress(person: string | string[]) {
	return addressparser(person)
		.map(({ name, address }) => {
			return name
				? `${mimeWordEncode(name).replace(/,/g, '=2C')} <${address}>`
				: address;
		})
		.join(', ');
}

function convertDashDelimitedTextToSnakeCase(text: string) {
	return text
		.toLowerCase()
		.replace(/^(.)|-(.)/g, (match) => match.toUpperCase());
}

export class Message {
	public readonly attachments: MessageAttachment[] = [];
	public readonly header: Partial<MessageHeaders> = {
		'message-id': `<${new Date().getTime()}.${counter++}.${
			process.pid
		}@${hostname()}>`,
		date: getRFC2822Date(),
	};
	public readonly content: string = 'text/plain; charset=utf-8';
	public readonly text?: string;
	public alternative: MessageAttachment | null = null;

	/**
	 * Construct an rfc2822-compliant message object.
	 *
	 * Special notes:
	 * - The `from` field is required.
	 * - At least one `to`, `cc`, or `bcc` header is also required.
	 * - You can also add whatever other headers you want.
	 *
	 * @see https://tools.ietf.org/html/rfc2822
	 * @param {Partial<MessageHeaders>} headers Message headers
	 */
	constructor(headers: Partial<MessageHeaders>) {
		for (const header in headers) {
			// allow user to override default content-type to override charset or send a single non-text message
			if (/^content-type$/i.test(header)) {
				this.content = headers[header] as string;
			} else if (header === 'text') {
				this.text = headers[header] as string;
			} else if (
				header === 'attachment' &&
				typeof headers[header] === 'object'
			) {
				const attachment = headers[header];
				if (Array.isArray(attachment)) {
					for (let i = 0; i < attachment.length; i++) {
						this.attach(attachment[i]);
					}
				} else if (attachment != null) {
					this.attach(attachment);
				}
			} else if (header === 'subject') {
				this.header.subject = mimeWordEncode(headers.subject as string);
			} else if (/^(cc|bcc|to|from)/i.test(header)) {
				this.header[header.toLowerCase()] = convertPersonToAddress(
					headers[header] as string | string[]
				);
			} else {
				// allow any headers the user wants to set??
				this.header[header.toLowerCase()] = headers[header];
			}
		}
	}

	/**
	 * Attach a file to the message.
	 *
	 * Can be called multiple times, each adding a new attachment.
	 *
	 * @public
	 * @param {MessageAttachment} options attachment options
	 * @returns {Message} the current instance for chaining
	 */
	public attach(options: MessageAttachment) {
		// sender can specify an attachment as an alternative
		if (options.alternative) {
			this.alternative = options;
			this.alternative.charset = options.charset || 'utf-8';
			this.alternative.type = options.type || 'text/html';
			this.alternative.inline = true;
		} else {
			this.attachments.push(options);
		}

		return this;
	}

	/**
	 * @public
	 * @param {function(isValid: boolean, invalidReason: string): void} callback .
	 * @returns {void}
	 */
	public valid(callback: (isValid: boolean, invalidReason?: string) => void) {
		if (
			typeof this.header.from !== 'string' &&
			Array.isArray(this.header.from) === false
		) {
			callback(false, 'Message must have a `from` header');
		} else if (
			typeof this.header.to !== 'string' &&
			Array.isArray(this.header.to) === false &&
			typeof this.header.cc !== 'string' &&
			Array.isArray(this.header.cc) === false &&
			typeof this.header.bcc !== 'string' &&
			Array.isArray(this.header.bcc) === false
		) {
			callback(
				false,
				'Message must have at least one `to`, `cc`, or `bcc` header'
			);
		} else if (this.attachments.length === 0) {
			callback(true, undefined);
		} else {
			const failed: string[] = [];

			this.attachments.forEach((attachment) => {
				if (attachment.path) {
					if (fs.existsSync(attachment.path) == false) {
						failed.push(`${attachment.path} does not exist`);
					}
				} else if (attachment.stream) {
					if (!attachment.stream.readable) {
						failed.push('attachment stream is not readable');
					}
				} else if (!attachment.data) {
					failed.push('attachment has no data associated with it');
				}
			});

			callback(failed.length === 0, failed.join(', '));
		}
	}

	/**
	 * @public
	 * @returns {MessageStream} a stream of the current message
	 */
	public stream() {
		return new MessageStream(this);
	}

	/**
	 * @public
	 * @param {function(Error, string): void} callback the function to call with the error and buffer
	 * @returns {void}
	 */
	public read(callback: (err: Error, buffer: string) => void) {
		let buffer = '';
		const str = this.stream();
		str.on('data', (data) => (buffer += data));
		str.on('end', (err) => callback(err, buffer));
		str.on('error', (err) => callback(err, buffer));
	}
}

class MessageStream extends Stream {
	readable = true;
	paused = false;
	buffer: Buffer | null = Buffer.alloc(MIMECHUNK * 24 * 7);
	bufferIndex = 0;

	/**
	 * @param {Message} message the message to stream
	 */
	constructor(private message: Message) {
		super();

		/**
		 * @param {string} [data] the data to output
		 * @param {Function} [callback] the function
		 * @param {any[]} [args] array of arguments to pass to the callback
		 * @returns {void}
		 */
		const output = (data: string) => {
			// can we buffer the data?
			if (this.buffer != null) {
				const bytes = Buffer.byteLength(data);

				if (bytes + this.bufferIndex < this.buffer.length) {
					this.buffer.write(data, this.bufferIndex);
					this.bufferIndex += bytes;
				}
				// we can't buffer the data, so ship it out!
				else if (bytes > this.buffer.length) {
					if (this.bufferIndex) {
						this.emit(
							'data',
							this.buffer.toString('utf-8', 0, this.bufferIndex)
						);
						this.bufferIndex = 0;
					}

					const loops = Math.ceil(data.length / this.buffer.length);
					let loop = 0;
					while (loop < loops) {
						this.emit(
							'data',
							data.substring(
								this.buffer.length * loop,
								this.buffer.length * (loop + 1)
							)
						);
						loop++;
					}
				} // we need to clean out the buffer, it is getting full
				else {
					if (!this.paused) {
						this.emit(
							'data',
							this.buffer.toString('utf-8', 0, this.bufferIndex)
						);
						this.buffer.write(data, 0);
						this.bufferIndex = bytes;
					} else {
						// we can't empty out the buffer, so let's wait till we resume before adding to it
						this.once('resume', () => output(data));
					}
				}
			}
		};

		/**
		 * @param {MessageAttachment} [attachment] the attachment whose headers you would like to output
		 * @returns {void}
		 */
		const outputAttachmentHeaders = (attachment: MessageAttachment) => {
			let data: string[] = [];
			const headers: Partial<MessageHeaders> = {
				'content-type':
					attachment.type +
					(attachment.charset ? `; charset=${attachment.charset}` : '') +
					(attachment.method ? `; method=${attachment.method}` : ''),
				'content-transfer-encoding': 'base64',
				'content-disposition': attachment.inline
					? 'inline'
					: `attachment; filename="${mimeWordEncode(
							attachment.name as string
					  )}"`,
			};

			// allow sender to override default headers
			if (attachment.headers != null) {
				for (const header in attachment.headers) {
					headers[header.toLowerCase()] = attachment.headers[header];
				}
			}

			for (const header in headers) {
				data = data.concat([
					convertDashDelimitedTextToSnakeCase(header),
					': ',
					headers[header] as string,
					CRLF,
				]);
			}

			output(data.concat([CRLF]).join(''));
		};

		/**
		 * @param {string} data the data to output as base64
		 * @param {function(): void} [callback] the function to call after output is finished
		 * @returns {void}
		 */
		const outputBase64 = (data: string, callback?: () => void) => {
			const loops = Math.ceil(data.length / MIMECHUNK);
			let loop = 0;
			while (loop < loops) {
				output(data.substring(MIMECHUNK * loop, MIMECHUNK * (loop + 1)) + CRLF);
				loop++;
			}
			if (callback) {
				callback();
			}
		};

		const outputFile = (
			attachment: MessageAttachment,
			next: (err: NodeJS.ErrnoException | null) => void
		) => {
			const chunk = MIME64CHUNK * 16;
			const buffer = Buffer.alloc(chunk);
			const closed = (fd: number) => fs.closeSync(fd);

			/**
			 * @param {Error} err the error to emit
			 * @param {number} fd the file descriptor
			 * @returns {void}
			 */
			const opened = (err: NodeJS.ErrnoException | null, fd: number) => {
				if (!err) {
					const read = (err: NodeJS.ErrnoException | null, bytes: number) => {
						if (!err && this.readable) {
							let encoding =
								attachment && attachment.headers
									? attachment.headers['content-transfer-encoding'] || 'base64'
									: 'base64';
							if (encoding === 'ascii' || encoding === '7bit') {
								encoding = 'ascii';
							} else if (encoding === 'binary' || encoding === '8bit') {
								encoding = 'binary';
							} else {
								encoding = 'base64';
							}
							// guaranteed to be encoded without padding unless it is our last read
							outputBase64(buffer.toString(encoding, 0, bytes), () => {
								if (bytes == chunk) {
									// we read a full chunk, there might be more
									fs.read(fd, buffer, 0, chunk, null, read);
								} // that was the last chunk, we are done reading the file
								else {
									this.removeListener('error', closed);
									fs.close(fd, next);
								}
							});
						} else {
							this.emit(
								'error',
								err || { message: 'message stream was interrupted somehow!' }
							);
						}
					};

					fs.read(fd, buffer, 0, chunk, null, read);
					this.once('error', closed);
				} else {
					this.emit('error', err);
				}
			};

			fs.open(attachment.path as PathLike, 'r', opened);
		};

		/**
		 * @param {MessageAttachment} attachment the metadata to use as headers
		 * @param {function(): void} callback the function to call after output is finished
		 * @returns {void}
		 */
		const outputStream = (
			attachment: MessageAttachment,
			callback: () => void
		) => {
			const { stream } = attachment;
			if (stream?.readable) {
				let previous = Buffer.alloc(0);

				stream.resume();

				stream.on('end', () => {
					outputBase64(previous.toString('base64'), callback);
					this.removeListener('pause', stream.pause);
					this.removeListener('resume', stream.resume);
					this.removeListener('error', stream.resume);
				});

				stream.on('data', (buff) => {
					// do we have bytes from a previous stream data event?
					let buffer = Buffer.isBuffer(buff) ? buff : Buffer.from(buff);

					if (previous.byteLength > 0) {
						buffer = Buffer.concat([previous, buffer]);
					}

					const padded = buffer.length % MIME64CHUNK;
					previous = Buffer.alloc(padded);

					// encode as much of the buffer to base64 without empty bytes
					if (padded > 0) {
						// copy dangling bytes into previous buffer
						buffer.copy(previous, 0, buffer.length - padded);
					}
					outputBase64(buffer.toString('base64', 0, buffer.length - padded));
				});

				this.on('pause', stream.pause);
				this.on('resume', stream.resume);
				this.on('error', stream.resume);
			} else {
				this.emit('error', { message: 'stream not readable' });
			}
		};

		const outputAttachment = (
			attachment: MessageAttachment,
			callback: () => void
		) => {
			const build = attachment.path
				? outputFile
				: attachment.stream
				? outputStream
				: outputData;
			outputAttachmentHeaders(attachment);
			build(attachment, callback);
		};

		/**
		 * @param {string} boundary the boundary text between outputs
		 * @param {MessageAttachment[]} list the list of potential messages to output
		 * @param {number} index the index of the list item to output
		 * @param {function(): void} callback the function to call if index is greater than upper bound
		 * @returns {void}
		 */
		const outputMessage = (
			boundary: string,
			list: MessageAttachment[],
			index: number,
			callback: () => void
		) => {
			if (index < list.length) {
				output(`--${boundary}${CRLF}`);
				if (list[index].related) {
					outputRelated(list[index], () =>
						outputMessage(boundary, list, index + 1, callback)
					);
				} else {
					outputAttachment(list[index], () =>
						outputMessage(boundary, list, index + 1, callback)
					);
				}
			} else {
				output(`${CRLF}--${boundary}--${CRLF}${CRLF}`);
				callback();
			}
		};

		const outputMixed = () => {
			const boundary = generateBoundary();
			output(
				`Content-Type: multipart/mixed; boundary="${boundary}"${CRLF}${CRLF}--${boundary}${CRLF}`
			);

			if (this.message.alternative == null) {
				outputText(this.message);
				outputMessage(boundary, this.message.attachments, 0, close);
			} else {
				outputAlternative(
					// typescript bug; should narrow to { alternative: MessageAttachment }
					this.message as Parameters<typeof outputAlternative>[0],
					() => outputMessage(boundary, this.message.attachments, 0, close)
				);
			}
		};

		/**
		 * @param {MessageAttachment} attachment the metadata to use as headers
		 * @param {function(): void} callback the function to call after output is finished
		 * @returns {void}
		 */
		const outputData = (
			attachment: MessageAttachment,
			callback: () => void
		) => {
			outputBase64(
				attachment.encoded
					? attachment.data ?? ''
					: Buffer.from(attachment.data ?? '').toString('base64'),
				callback
			);
		};

		/**
		 * @param {Message} message the message to output
		 * @returns {void}
		 */
		const outputText = (message: Message) => {
			let data: string[] = [];

			data = data.concat([
				'Content-Type:',
				message.content,
				CRLF,
				'Content-Transfer-Encoding: 7bit',
				CRLF,
			]);
			data = data.concat(['Content-Disposition: inline', CRLF, CRLF]);
			data = data.concat([message.text || '', CRLF, CRLF]);

			output(data.join(''));
		};

		/**
		 * @param {MessageAttachment} message the message to output
		 * @param {function(): void} callback the function to call after output is finished
		 * @returns {void}
		 */
		const outputRelated = (
			message: MessageAttachment,
			callback: () => void
		) => {
			const boundary = generateBoundary();
			output(
				`Content-Type: multipart/related; boundary="${boundary}"${CRLF}${CRLF}--${boundary}${CRLF}`
			);
			outputAttachment(message, () => {
				outputMessage(boundary, message.related ?? [], 0, () => {
					output(`${CRLF}--${boundary}--${CRLF}${CRLF}`);
					callback();
				});
			});
		};

		/**
		 * @param {Message} message the message to output
		 * @param {function(): void} callback the function to call after output is finished
		 * @returns {void}
		 */
		const outputAlternative = (
			message: Message & { alternative: MessageAttachment },
			callback: () => void
		) => {
			const boundary = generateBoundary();
			output(
				`Content-Type: multipart/alternative; boundary="${boundary}"${CRLF}${CRLF}--${boundary}${CRLF}`
			);
			outputText(message);
			output(`--${boundary}${CRLF}`);

			/**
			 * @returns {void}
			 */
			const finish = () => {
				output([CRLF, '--', boundary, '--', CRLF, CRLF].join(''));
				callback();
			};

			if (message.alternative.related) {
				outputRelated(message.alternative, finish);
			} else {
				outputAttachment(message.alternative, finish);
			}
		};

		const close = (err?: Error) => {
			if (err) {
				this.emit('error', err);
			} else {
				this.emit(
					'data',
					this.buffer?.toString('utf-8', 0, this.bufferIndex) ?? ''
				);
				this.emit('end');
			}
			this.buffer = null;
			this.bufferIndex = 0;
			this.readable = false;
			this.removeAllListeners('resume');
			this.removeAllListeners('pause');
			this.removeAllListeners('error');
			this.removeAllListeners('data');
			this.removeAllListeners('end');
		};

		/**
		 * @returns {void}
		 */
		const outputHeaderData = () => {
			if (this.message.attachments.length || this.message.alternative) {
				output(`MIME-Version: 1.0${CRLF}`);
				outputMixed();
			} // you only have a text message!
			else {
				outputText(this.message);
				close();
			}
		};

		/**
		 * @returns {void}
		 */
		const outputHeader = () => {
			let data: string[] = [];

			for (const header in this.message.header) {
				// do not output BCC in the headers (regex) nor custom Object.prototype functions...
				if (
					!/bcc/i.test(header) &&
					Object.prototype.hasOwnProperty.call(this.message.header, header)
				) {
					data = data.concat([
						convertDashDelimitedTextToSnakeCase(header),
						': ',
						this.message.header[header] as string,
						CRLF,
					]);
				}
			}

			output(data.join(''));
			outputHeaderData();
		};

		this.once('destroy', close);
		process.nextTick(outputHeader);
	}

	/**
	 * @public
	 * pause the stream
	 * @returns {void}
	 */
	public pause() {
		this.paused = true;
		this.emit('pause');
	}

	/**
	 * @public
	 * resume the stream
	 * @returns {void}
	 */
	public resume() {
		this.paused = false;
		this.emit('resume');
	}

	/**
	 * @public
	 * destroy the stream
	 * @returns {void}
	 */
	public destroy() {
		this.emit(
			'destroy',
			this.bufferIndex > 0 ? { message: 'message stream destroyed' } : null
		);
	}

	/**
	 * @public
	 * destroy the stream at first opportunity
	 * @returns {void}
	 */
	public destroySoon() {
		this.emit('destroy');
	}
}
