import { UserEntity } from '../../domain/entities/user.entity';
import { PhoneChangedEvent } from '../../domain/events/phone-changed.event';
import { type IUserRepository } from '../../domain/repositories/user.repository';
import { type HashedPassword } from '../../domain/value-objects/hashed-password.vo';
import { Phone } from '../../domain/value-objects/phone.vo';
import { VerifyPhoneChangeCommand } from '../commands/verify-phone-change/verify-phone-change.command';
import { VerifyPhoneChangeHandler } from '../commands/verify-phone-change/verify-phone-change.handler';

function createTestUser(overrides?: Partial<{ id: string; phone: string }>): UserEntity {
  const phone = Phone.create(overrides?.phone ?? '0912345678').unwrap();
  const pw = { value: 'hashed' } as HashedPassword;
  return new UserEntity(overrides?.id ?? 'user-1', {
    email: null,
    phone,
    passwordHash: pw,
    fullName: 'Nguyen Van A',
    avatarUrl: null,
    role: 'BUYER',
    kycStatus: 'NONE',
    kycData: null,
    isActive: true,
    totpSecret: null,
    totpEnabled: false,
    totpBackupCodes: [],
    totpEnabledAt: null,
  });
}

describe('VerifyPhoneChangeHandler', () => {
  let handler: VerifyPhoneChangeHandler;
  let mockUserRepo: { [K in keyof IUserRepository]: ReturnType<typeof vi.fn> };
  let mockRedis: { get: ReturnType<typeof vi.fn>; del: ReturnType<typeof vi.fn>; set: ReturnType<typeof vi.fn> };
  let mockCache: { invalidate: ReturnType<typeof vi.fn> };
  let mockEventBus: { publish: ReturnType<typeof vi.fn> };

  beforeEach(() => {
    mockUserRepo = {
      findById: vi.fn(),
      findByPhone: vi.fn(),
      findByEmail: vi.fn(),
      save: vi.fn(),
      update: vi.fn(),
      updateMfaSecret: vi.fn(),
      updateMfaEnabled: vi.fn(),
      updateMfaDisabled: vi.fn(),
      updateBackupCodes: vi.fn(),
    };
    mockRedis = {
      get: vi.fn(),
      del: vi.fn().mockResolvedValue(undefined),
      set: vi.fn().mockResolvedValue(undefined),
    };
    mockCache = { invalidate: vi.fn().mockResolvedValue(undefined) };
    mockEventBus = { publish: vi.fn() };

    handler = new VerifyPhoneChangeHandler(
      mockUserRepo as any,
      mockRedis as any,
      mockCache as any,
      mockEventBus as any,
      { error: vi.fn() } as any,
    );
  });

  it('verifies SMS OTP and updates phone', async () => {
    const user = createTestUser();
    const payload = JSON.stringify({ newPhone: '+84987654321', code: '123456' });
    mockRedis.get.mockResolvedValue(payload);
    mockUserRepo.findById.mockResolvedValue(user);
    mockUserRepo.findByPhone.mockResolvedValue(null);
    mockUserRepo.update.mockResolvedValue(undefined);

    const command = new VerifyPhoneChangeCommand('user-1', '123456');
    const result = await handler.execute(command);

    expect(result.phoneNumber).toBe('+84987654321');
    expect(result.id).toBe('user-1');
    expect(mockRedis.del).toHaveBeenCalledWith('auth:phone_change_otp:user-1');
    expect(mockUserRepo.update).toHaveBeenCalledWith(user);
    expect(mockCache.invalidate).toHaveBeenCalledWith(
      expect.stringContaining('user-1'),
    );
    expect(mockEventBus.publish).toHaveBeenCalledWith(expect.any(PhoneChangedEvent));
    const published = mockEventBus.publish.mock.calls[0][0] as PhoneChangedEvent;
    expect(published.aggregateId).toBe('user-1');
    expect(published.newPhone).toBe('+84987654321');
    expect(published.oldPhone).toBe('+84912345678');
  });

  it('throws ValidationException when OTP has expired', async () => {
    mockRedis.get.mockResolvedValue(null);

    const command = new VerifyPhoneChangeCommand('user-1', '123456');
    await expect(handler.execute(command)).rejects.toThrow('hết hạn');
  });

  it('throws ValidationException when OTP code is wrong', async () => {
    const payload = JSON.stringify({ newPhone: '+84987654321', code: '123456' });
    mockRedis.get.mockResolvedValue(payload);

    const command = new VerifyPhoneChangeCommand('user-1', '999999');
    await expect(handler.execute(command)).rejects.toThrow('không đúng');
  });

  it('throws ConflictException when phone was taken since OTP was issued', async () => {
    const user = createTestUser();
    const otherUser = createTestUser({ id: 'user-2', phone: '0987654321' });
    const payload = JSON.stringify({ newPhone: '+84987654321', code: '123456' });
    mockRedis.get.mockResolvedValue(payload);
    mockUserRepo.findById.mockResolvedValue(user);
    mockUserRepo.findByPhone.mockResolvedValue(otherUser);

    const command = new VerifyPhoneChangeCommand('user-1', '123456');
    await expect(handler.execute(command)).rejects.toThrow('Số điện thoại đã được sử dụng');

    // OTP should be cleaned up on conflict
    expect(mockRedis.del).toHaveBeenCalledWith('auth:phone_change_otp:user-1');
  });

  it('throws NotFoundException when user does not exist', async () => {
    const payload = JSON.stringify({ newPhone: '+84987654321', code: '123456' });
    mockRedis.get.mockResolvedValue(payload);
    mockUserRepo.findById.mockResolvedValue(null);

    const command = new VerifyPhoneChangeCommand('user-1', '123456');
    await expect(handler.execute(command)).rejects.toThrow('Người dùng');
  });
});
