import { EventEmitter } from 'events';
import Imap, { type Box, type MailBoxes } from 'imap';
import { Readable } from 'stream';
import type { Mocked } from 'vitest';
import { mock } from 'vitest-mock-extended';

import { ImapSimple } from './imap-simple';
import { PartData } from './part-data';

type MockImap = EventEmitter & {
	connect: Mocked<() => unknown>;
	fetch: Mocked<() => unknown>;
	end: Mocked<() => unknown>;
	search: Mocked<(...args: Parameters<Imap['search']>) => unknown>;
	sort: Mocked<(...args: Parameters<Imap['sort']>) => unknown>;
	openBox: Mocked<
		(boxName: string, onOpen: (error: Error | null, box?: Box) => unknown) => unknown
	>;
	closeBox: Mocked<(...args: Parameters<Imap['closeBox']>) => unknown>;
	getBoxes: Mocked<(onBoxes: (error: Error | null, boxes?: MailBoxes) => unknown) => unknown>;
	addFlags: Mocked<(...args: Parameters<Imap['addFlags']>) => unknown>;
};

vi.mock('imap', () => {
	return {
		default: class InlineMockImap extends EventEmitter implements MockImap {
			connect = vi.fn();
			fetch = vi.fn();
			end = vi.fn();
			search = vi.fn();
			sort = vi.fn();
			openBox = vi.fn();
			closeBox = vi.fn();
			addFlags = vi.fn();
			getBoxes = vi.fn();
		},
	};
});

vi.mock('./part-data', () => ({
	// eslint-disable-next-line @typescript-eslint/naming-convention
	PartData: { fromData: vi.fn(() => 'decoded') },
}));

describe('ImapSimple', () => {
	function createImap() {
		const imap = new Imap({ user: 'testuser', password: 'testpass' });
		return { imapSimple: new ImapSimple(imap), mockImap: imap as unknown as MockImap };
	}

	describe('constructor', () => {
		it('should forward nonerror events', () => {
			const { imapSimple, mockImap } = createImap();
			const onMail = vi.fn();
			imapSimple.on('mail', onMail);
			mockImap.emit('mail', 3);
			expect(onMail).toHaveBeenCalledWith(3);
		});

		it('should suppress errors after end() by removing forwarding listeners', () => {
			const { imapSimple, mockImap } = createImap();
			const onError = vi.fn();
			imapSimple.on('error', onError);
			imapSimple.end();

			// After end(), forwarding listeners are removed so errors
			// on the raw imap no longer propagate to imapSimple
			mockImap.emit('error', { message: 'reset', code: 'ECONNRESET' });
			expect(onError).not.toHaveBeenCalled();
		});

		it('should forward all errors before end() is called', () => {
			const { imapSimple, mockImap } = createImap();
			const onError = vi.fn();
			imapSimple.on('error', onError);

			const error = { message: 'reset', code: 'ECONNRESET' };
			mockImap.emit('error', error);
			expect(onError).toHaveBeenCalledWith(error);
		});
	});

	describe('event listener cleanup', () => {
		it('should remove forwarding listeners and keep only close+error on end()', () => {
			const { imapSimple, mockImap } = createImap();

			// Verify listeners were added by ImapSimple constructor
			expect(mockImap.listenerCount('mail')).toBe(1);
			expect(mockImap.listenerCount('error')).toBe(1);

			imapSimple.end();

			// All forwarding listeners should be removed
			expect(mockImap.listenerCount('mail')).toBe(0);
			expect(mockImap.listenerCount('alert')).toBe(0);

			// A no-op error handler suppresses errors during disconnect
			expect(mockImap.listenerCount('error')).toBe(1);

			// A once-close handler forwards the final close event
			expect(mockImap.listenerCount('close')).toBe(1);
		});

		it('should still forward the close event after end()', () => {
			const { imapSimple, mockImap } = createImap();
			const onClose = vi.fn();
			imapSimple.on('close', onClose);

			imapSimple.end();
			mockImap.emit('close', false);

			expect(onClose).toHaveBeenCalledWith(false);
		});

		it('should auto-remove the close handler after it fires once', () => {
			const { imapSimple, mockImap } = createImap();

			imapSimple.end();
			mockImap.emit('close', false);

			// once handler auto-removed after firing
			expect(mockImap.listenerCount('close')).toBe(0);
		});
	});

	describe('search', () => {
		it('should not throw if fetch emits error after end', async () => {
			const { imapSimple, mockImap } = createImap();

			const fetchEmitter = new EventEmitter();
			vi.mocked(mockImap.search).mockImplementation((_criteria, onResult) =>
				onResult(null as unknown as Error, [1]),
			);
			mockImap.fetch = vi.fn(() => fetchEmitter);

			const searchPromise = imapSimple.search(['UNSEEN'], { bodies: ['BODY'] });

			const messageEmitter = new EventEmitter();
			const bodyStream = Readable.from('body');
			fetchEmitter.emit('message', messageEmitter, 1);
			messageEmitter.emit('body', bodyStream, { which: 'TEXT', size: 4 });
			messageEmitter.emit('attributes', { uid: 1 });
			await new Promise((resolve) => bodyStream.on('end', resolve));
			messageEmitter.emit('end');
			fetchEmitter.emit('end');

			await searchPromise;

			// Error after fetchOnEnd must not throw an uncaught exception
			expect(() => fetchEmitter.emit('error', new Error('late error'))).not.toThrow();
		});

		it('should resolve with messages returned from fetch', async () => {
			const { imapSimple, mockImap } = createImap();

			const fetchEmitter = new EventEmitter();
			const mockMessages = [{ uid: 1 }, { uid: 2 }, { uid: 3 }];
			vi.mocked(mockImap.search).mockImplementation((_criteria, onResult) =>
				onResult(
					null as unknown as Error,
					mockMessages.map((m) => m.uid),
				),
			);
			mockImap.fetch = vi.fn(() => fetchEmitter);

			const searchPromise = imapSimple.search(['UNSEEN', ['FROM', 'test@n8n.io']], {
				bodies: ['BODY'],
			});
			expect(mockImap.search).toHaveBeenCalledWith(
				['UNSEEN', ['FROM', 'test@n8n.io']],
				expect.any(Function),
			);

			for (const message of mockMessages) {
				const messageEmitter = new EventEmitter();
				const body = 'body' + message.uid;
				const bodyStream = Readable.from(body);
				fetchEmitter.emit('message', messageEmitter, message.uid);
				messageEmitter.emit('body', bodyStream, { which: 'TEXT', size: Buffer.byteLength(body) });
				messageEmitter.emit('attributes', { uid: message.uid });
				await new Promise((resolve) => {
					bodyStream.on('end', resolve);
				});
				messageEmitter.emit('end');
			}

			fetchEmitter.emit('end');

			const messages = await searchPromise;

			expect(messages).toEqual([
				{
					attributes: { uid: 1 },
					parts: [{ body: 'body1', size: 5, which: 'TEXT' }],
					seqNo: 1,
				},
				{
					attributes: { uid: 2 },
					parts: [{ body: 'body2', size: 5, which: 'TEXT' }],
					seqNo: 2,
				},
				{
					attributes: { uid: 3 },
					parts: [{ body: 'body3', size: 5, which: 'TEXT' }],
					seqNo: 3,
				},
			]);
		});
	});

	describe('getPartData', () => {
		it('should not throw if fetch emits error after end', async () => {
			const { imapSimple, mockImap } = createImap();

			const fetchEmitter = new EventEmitter();
			mockImap.fetch = vi.fn(() => fetchEmitter);

			const message = { attributes: { uid: 123 } };
			const part = { partID: '1.2', encoding: 'BASE64' };
			const partDataPromise = imapSimple.getPartData(mock(message), mock(part));

			const messageEmitter = new EventEmitter();
			const bodyStream = Readable.from('body');
			fetchEmitter.emit('message', messageEmitter);
			messageEmitter.emit('body', bodyStream, { which: part.partID, size: 4 });
			messageEmitter.emit('attributes', {});
			await new Promise((resolve) => bodyStream.on('end', resolve));
			messageEmitter.emit('end');
			fetchEmitter.emit('end');

			await partDataPromise;

			// Error after fetchOnEnd must not throw an uncaught exception
			expect(() => fetchEmitter.emit('error', new Error('late error'))).not.toThrow();
		});

		it('should return decoded part data', async () => {
			const { imapSimple, mockImap } = createImap();

			const fetchEmitter = new EventEmitter();
			mockImap.fetch = vi.fn(() => fetchEmitter);

			const message = { attributes: { uid: 123 } };
			const part = { partID: '1.2', encoding: 'BASE64' };

			const partDataPromise = imapSimple.getPartData(mock(message), mock(part));

			const body = 'encoded-body';
			const messageEmitter = new EventEmitter();
			const bodyStream = Readable.from(body);

			fetchEmitter.emit('message', messageEmitter);

			messageEmitter.emit('body', bodyStream, {
				which: part.partID,
				size: Buffer.byteLength(body),
			});
			messageEmitter.emit('attributes', {});
			await new Promise((resolve) => bodyStream.on('end', resolve));
			messageEmitter.emit('end');

			fetchEmitter.emit('end');

			const result = await partDataPromise;
			expect(PartData.fromData).toHaveBeenCalledWith('encoded-body', 'BASE64');
			expect(result).toBe('decoded');
		});
	});

	describe('openBox', () => {
		it('should open the mailbox', async () => {
			const { imapSimple, mockImap } = createImap();
			const box = mock<Box>({ name: 'INBOX' });
			vi.mocked(mockImap.openBox).mockImplementation((_boxName, onOpen) =>
				onOpen(null as unknown as Error, box),
			);
			await expect(imapSimple.openBox('INBOX')).resolves.toEqual(box);
		});

		it('should reject on error', async () => {
			const { imapSimple, mockImap } = createImap();
			vi.mocked(mockImap.openBox).mockImplementation((_boxName, onOpen) =>
				onOpen(new Error('nope')),
			);
			await expect(imapSimple.openBox('INBOX')).rejects.toThrow('nope');
		});
	});

	describe('closeBox', () => {
		it('should close the mailbox with default autoExpunge=true', async () => {
			const { imapSimple, mockImap } = createImap();
			vi.mocked(mockImap.closeBox).mockImplementation((_expunge, onClose) =>
				onClose(null as unknown as Error),
			);
			await expect(imapSimple.closeBox()).resolves.toBeUndefined();
			expect(mockImap.closeBox).toHaveBeenCalledWith(true, expect.any(Function));
		});

		it('should close the mailbox with autoExpunge=false', async () => {
			const { imapSimple, mockImap } = createImap();
			vi.mocked(mockImap.closeBox).mockImplementation((_expunge, onClose) =>
				onClose(null as unknown as Error),
			);
			await expect(imapSimple.closeBox(false)).resolves.toBeUndefined();
			expect(mockImap.closeBox).toHaveBeenCalledWith(false, expect.any(Function));
		});

		it('should reject on error', async () => {
			const { imapSimple, mockImap } = createImap();
			vi.mocked(mockImap.closeBox).mockImplementation((_expunge, onClose) =>
				onClose(new Error('fail')),
			);
			await expect(imapSimple.closeBox()).rejects.toThrow('fail');
		});
	});

	describe('addFlags', () => {
		it('should add flags to messages and resolve', async () => {
			const { imapSimple, mockImap } = createImap();
			vi.mocked(mockImap.addFlags).mockImplementation((_uids, _flags, onAdd) =>
				onAdd(null as unknown as Error),
			);

			await expect(imapSimple.addFlags([1, 2], ['\\Seen'])).resolves.toBeUndefined();
			expect(mockImap.addFlags).toHaveBeenCalledWith([1, 2], ['\\Seen'], expect.any(Function));
		});

		it('should reject on error', async () => {
			const { imapSimple, mockImap } = createImap();
			vi.mocked(mockImap.addFlags).mockImplementation((_uids, _flags, onAdd) =>
				onAdd(new Error('add flags failed')),
			);

			await expect(imapSimple.addFlags([1], '\\Seen')).rejects.toThrow('add flags failed');
		});
	});

	describe('getBoxes', () => {
		it('should resolve with list of mailboxes', async () => {
			const { imapSimple, mockImap } = createImap();
			// eslint-disable-next-line @typescript-eslint/naming-convention
			const boxes = mock<MailBoxes>({ INBOX: {}, Archive: {} });

			vi.mocked(mockImap.getBoxes).mockImplementation((onBoxes) =>
				onBoxes(null as unknown as Error, boxes),
			);

			await expect(imapSimple.getBoxes()).resolves.toEqual(boxes);
			expect(mockImap.getBoxes).toHaveBeenCalledWith(expect.any(Function));
		});

		it('should reject on error', async () => {
			const { imapSimple, mockImap } = createImap();

			vi.mocked(mockImap.getBoxes).mockImplementation((onBoxes) =>
				onBoxes(new Error('getBoxes failed')),
			);

			await expect(imapSimple.getBoxes()).rejects.toThrow('getBoxes failed');
		});
	});
});
