/**
 * Unit tests for send-otp handler — C-02
 *
 * After the fix, the OTP must be generated with Node.js `crypto.randomInt`
 * (range [100000, 999999]) and bcrypt must be imported at module level.
 *
 * NOTE: jest.config.ts has restoreMocks:true which resets all jest.fn()
 * implementations between tests. We therefore re-apply ALL mock implementations
 * in beforeEach rather than relying on factory defaults.
 */

// ── Captured mock references ─────────────────────────────────────────────────
const mockTransaction = {
	commit: jest.fn(),
	rollback: jest.fn(),
};

const mockUserGetAll = jest.fn();
const mockUserTransaction = jest.fn();
const mockUserLogError = jest.fn();
const mockCodeCreate = jest.fn();
const mockSendmail = jest.fn();
const mockGetPasswordResetOTPEmail = jest.fn();

jest.mock('#providers/UserProvider', () => ({
	UserProvider: jest.fn().mockImplementation(() => ({
		transaction: (...args: any[]) => mockUserTransaction(...args),
		getAll: (...args: any[]) => mockUserGetAll(...args),
		logError: (...args: any[]) => mockUserLogError(...args),
	})),
}));
jest.mock('#services/auth', () => ({
	PasswordService: {
		hashPassword: jest.fn().mockResolvedValue('$2b$10$abcdefghijklmnopqrstuvwxyz123456789012345678901234567'),
	},
}));
jest.mock('#services/file-system/logService', () => ({
	__esModule: true,
	default: jest.fn().mockImplementation(() => ({
		logErrorAsync: jest.fn().mockResolvedValue(undefined),
		logInfoAsync: jest.fn().mockResolvedValue(undefined),
	})),
}));
let cryptoCallCount = 0;
jest.mock('crypto', () => ({
	randomUUID: jest.fn(() => 'mock-uuid-123'),
	randomBytes: jest.fn(() => {
		const values = [123456, 234567, 345678, 456789, 567890];
		const index = cryptoCallCount % values.length;
		cryptoCallCount++;
		return {
			readUInt32BE: jest.fn(() => values[index]),
		};
	}),
}));
jest.mock('#providers/PasswordResetTokenProvider', () => ({
	PasswordResetTokenProvider: jest.fn().mockImplementation(() => ({
		create: (...args: any[]) => mockCodeCreate(...args),
	})),
}));
jest.mock('#services/notification/notificationEmailService', () => {
	const mockInstance = { sendmail: mockSendmail };
	const MockMailService = Object.assign(
		jest.fn().mockImplementation(() => mockInstance),
		{ getInstance: jest.fn().mockReturnValue(mockInstance) },
	);
	return { __esModule: true, default: MockMailService };
});
jest.mock('#templates/email/passwordResetOTP', () => ({
	getPasswordResetOTPEmail: (...args: any[]) => mockGetPasswordResetOTPEmail(...args),
}));
jest.mock('#middlewares/query-modifier', () => ({
	queryModifier: (_req: any, _res: any, next: any) => next(),
}));
jest.mock('#middlewares/validators', () => ({
	validateZod: () => (_req: any, _res: any, next: any) => next(),
}));
jest.mock('#middlewares/rate-limiter', () => ({
	authRateLimit: (req: any, res: any, next: any) => next(),
}));
jest.mock('#utils/responseUtils', () => ({
	sendSuccess: (res: any, data: any, vi: string, en: string) => {
		res.json({ success: true, responseData: data, message: { vi, en } });
	},
}));

// ── Helpers ──────────────────────────────────────────────────────────────────
import { createMockResponse } from '@helpers/request';

const MOCK_USER = { id: 'user-1', email: 'alice@example.com', username: 'alice' };

function makeReq(body: Record<string, any>) {
	return { body, query: {}, params: {}, headers: {}, cookies: {} } as any;
}

function makeRes() {
	const res = createMockResponse() as any;
	res.json = jest.fn().mockReturnThis();
	res.sendOk = jest.fn().mockReturnThis();
	res.error = jest.fn().mockReturnThis();
	return res;
}

import sendOtpModule from '#controllers/api/v1/auth/forgot-password/send-otp';

function getHandler() {
	const resource = sendOtpModule({} as any);
	return (resource.post! as any).handler;
}

// ── Tests ────────────────────────────────────────────────────────────────────
describe('send-otp › OTP generation (C-02)', () => {
	beforeEach(() => {
		// Re-apply ALL implementations because restoreMocks:true resets them
		mockTransaction.commit.mockResolvedValue(undefined);
		mockTransaction.rollback.mockResolvedValue(undefined);
		mockUserTransaction.mockResolvedValue(mockTransaction);
		mockUserGetAll.mockResolvedValue({ rows: [MOCK_USER] });
		mockUserLogError.mockResolvedValue(undefined);
		mockCodeCreate.mockResolvedValue(undefined);
		// Don't reset mockSendmail here - let tests control it for promise capturing
		mockGetPasswordResetOTPEmail.mockReturnValue('<html>OTP</html>');

		process.env.EMAIL_FROM = 'noreply@example.com';
	});

	it('sends OTP email when email exists', async () => {
		const req = makeReq({ email: MOCK_USER.email });
		const res = makeRes();

		mockSendmail.mockResolvedValue(undefined);

		await getHandler()(req, res);

		// Check if the handler completed successfully
		expect(res.json).toHaveBeenCalled();

		// Wait a tick for the fire-and-forget promise to execute
		await new Promise((resolve) => setImmediate(resolve));
		await new Promise((resolve) => setImmediate(resolve));

		expect(mockCodeCreate).toHaveBeenCalledTimes(1);
		expect(mockSendmail).toHaveBeenCalledTimes(1);
		expect(res.json).toHaveBeenCalledWith(
			expect.objectContaining({
				success: true,
				responseData: expect.objectContaining({ sent: true }),
			}),
		);
	});

	it('stores a bcrypt hash (not plaintext) in the DB', async () => {
		const req = makeReq({ email: MOCK_USER.email });
		const res = makeRes();

		mockSendmail.mockResolvedValue(undefined);

		await getHandler()(req, res);

		// Wait for the fire-and-forget email promise to execute
		await new Promise((resolve) => setImmediate(resolve));

		const createdRecord = mockCodeCreate.mock.calls[0][0];
		// A bcrypt hash starts with $2b$ and is 60 chars
		expect(createdRecord.token_hash).toMatch(/^\$2[ab]\$\d{2}\$/);
		expect(createdRecord.token_hash.length).toBe(60);
	});

	it('stores a 6-digit numeric OTP passed to the email template', async () => {
		const req = makeReq({ email: MOCK_USER.email });
		const res = makeRes();

		mockSendmail.mockResolvedValue(undefined);

		await getHandler()(req, res);

		// Wait for the fire-and-forget email promise to execute
		await new Promise((resolve) => setImmediate(resolve));

		const templateArg = mockGetPasswordResetOTPEmail.mock.calls[0][0];
		const code: string = templateArg.code;
		// Must be exactly 6 digits
		expect(code).toMatch(/^\d{6}$/);
		// Must be within valid range [100000, 999999]
		const num = parseInt(code, 10);
		expect(num).toBeGreaterThanOrEqual(100000);
		expect(num).toBeLessThanOrEqual(999999);
	});

	it('generates different codes across calls (crypto randomness)', async () => {
		const codes: string[] = [];
		for (let i = 0; i < 5; i++) {
			const req = makeReq({ email: MOCK_USER.email });
			const res = makeRes();

			mockSendmail.mockResolvedValue(undefined);

			await getHandler()(req, res);

			// Wait for the fire-and-forget email promise to execute
			await new Promise((resolve) => setImmediate(resolve));

			const code = mockGetPasswordResetOTPEmail.mock.calls[i][0].code;
			codes.push(code);
		}

		// All codes must be unique (probability of collision with crypto.randomInt is astronomically low)
		const uniqueCodes = new Set(codes);
		expect(uniqueCodes.size).toBe(5);
	});

	it('returns generic success when email does not exist (account enumeration guard)', async () => {
		mockUserGetAll.mockResolvedValue({ rows: [] });

		const req = makeReq({ email: 'nobody@example.com' });
		const res = makeRes();

		await getHandler()(req, res);

		// Must NOT throw 404 — silently returns generic response
		expect(res.json).toHaveBeenCalledWith(
			expect.objectContaining({
				success: true,
				responseData: expect.objectContaining({ sent: true }),
			}),
		);
		expect(res.error).not.toHaveBeenCalled();
		expect(mockSendmail).not.toHaveBeenCalled();
	});
});
