import { UserEntity } from '../../domain/entities/user.entity';
import { type IUserRepository } from '../../domain/repositories/user.repository';
import { Email } from '../../domain/value-objects/email.vo';
import { type HashedPassword } from '../../domain/value-objects/hashed-password.vo';
import { Phone } from '../../domain/value-objects/phone.vo';
import { UpdateProfileCommand } from '../commands/update-profile/update-profile.command';
import { UpdateProfileHandler } from '../commands/update-profile/update-profile.handler';

function createTestUser(overrides?: Partial<{ email: string; id: string }>): UserEntity {
  const phone = Phone.create('0912345678').unwrap();
  const pw = { value: 'hashed' } as HashedPassword;
  const email = overrides?.email ? Email.create(overrides.email).unwrap() : null;
  return new UserEntity(overrides?.id ?? 'user-1', {
    email,
    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('UpdateProfileHandler', () => {
  let handler: UpdateProfileHandler;
  let mockUserRepo: { [K in keyof IUserRepository]: ReturnType<typeof vi.fn> };
  let mockCache: { invalidate: ReturnType<typeof vi.fn> };
  let mockRedis: { set: ReturnType<typeof vi.fn>; get: ReturnType<typeof vi.fn>; del: 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(),
    };
    mockCache = { invalidate: vi.fn().mockResolvedValue(undefined) };
    mockRedis = {
      set: vi.fn().mockResolvedValue(undefined),
      get: vi.fn().mockResolvedValue(null),
      del: vi.fn().mockResolvedValue(undefined),
    };
    mockEventBus = { publish: vi.fn() };

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

  it('updates fullName and invalidates cache', async () => {
    const user = createTestUser();
    mockUserRepo.findById.mockResolvedValue(user);
    mockUserRepo.update.mockResolvedValue(undefined);

    const command = new UpdateProfileCommand('user-1', 'Tran Van B');
    const result = await handler.execute(command);

    expect(mockUserRepo.findById).toHaveBeenCalledWith('user-1');
    expect(mockUserRepo.update).toHaveBeenCalledWith(user);
    expect(result.fullName).toBe('Tran Van B');
    expect(result.id).toBe('user-1');
    expect(mockCache.invalidate).toHaveBeenCalledWith(
      expect.stringContaining('user-1'),
    );
  });

  it('updates avatarUrl', async () => {
    const user = createTestUser();
    mockUserRepo.findById.mockResolvedValue(user);
    mockUserRepo.update.mockResolvedValue(undefined);

    const command = new UpdateProfileCommand('user-1', undefined, 'https://cdn.example.com/avatar.jpg');
    const result = await handler.execute(command);

    expect(result.avatarUrl).toBe('https://cdn.example.com/avatar.jpg');
    expect(mockUserRepo.update).toHaveBeenCalledWith(user);
  });

  it('defers email change via OTP instead of updating directly', async () => {
    const user = createTestUser();
    mockUserRepo.findById.mockResolvedValue(user);
    mockUserRepo.findByEmail.mockResolvedValue(null);
    mockUserRepo.update.mockResolvedValue(undefined);

    const command = new UpdateProfileCommand('user-1', undefined, undefined, 'new@example.com');
    const result = await handler.execute(command);

    // Email should NOT be updated yet — it is deferred pending OTP
    expect(result.email).toBeNull();
    expect(result.emailChangePending).toBe(true);

    // OTP stored in Redis
    expect(mockRedis.set).toHaveBeenCalledWith(
      'auth:email_change_otp:user-1',
      expect.stringContaining('new@example.com'),
      600,
    );

    // Event emitted for notification
    expect(mockEventBus.publish).toHaveBeenCalledWith(
      expect.objectContaining({
        eventName: 'user.email_change_requested',
        newEmail: 'new@example.com',
      }),
    );
  });

  it('throws ConflictException when new email is already taken', async () => {
    const user = createTestUser();
    const otherUser = createTestUser({ id: 'user-2', email: 'taken@example.com' });
    mockUserRepo.findById.mockResolvedValue(user);
    mockUserRepo.findByEmail.mockResolvedValue(otherUser);

    const command = new UpdateProfileCommand('user-1', undefined, undefined, 'taken@example.com');
    await expect(handler.execute(command)).rejects.toThrow('Email đã được sử dụng');
  });

  it('skips OTP when email is unchanged', async () => {
    const user = createTestUser({ email: 'same@example.com' });
    mockUserRepo.findById.mockResolvedValue(user);
    mockUserRepo.update.mockResolvedValue(undefined);

    const command = new UpdateProfileCommand('user-1', undefined, undefined, 'same@example.com');
    const result = await handler.execute(command);

    expect(mockRedis.set).not.toHaveBeenCalled();
    expect(mockEventBus.publish).not.toHaveBeenCalled();
    expect(result.emailChangePending).toBeUndefined();
  });

  it('throws NotFoundException when user does not exist', async () => {
    mockUserRepo.findById.mockResolvedValue(null);

    const command = new UpdateProfileCommand('non-existent', 'New Name');
    await expect(handler.execute(command)).rejects.toThrow('Người dùng');
  });

  it('does not call update or invalidate when user is not found', async () => {
    mockUserRepo.findById.mockResolvedValue(null);

    const command = new UpdateProfileCommand('non-existent', 'New Name');
    await expect(handler.execute(command)).rejects.toThrow();

    expect(mockUserRepo.update).not.toHaveBeenCalled();
    expect(mockCache.invalidate).not.toHaveBeenCalled();
  });

  it('throws ValidationException for invalid email format', async () => {
    const user = createTestUser();
    mockUserRepo.findById.mockResolvedValue(user);

    const command = new UpdateProfileCommand('user-1', undefined, undefined, 'not-an-email');
    await expect(handler.execute(command)).rejects.toThrow('Email không hợp lệ');
  });

  it('updates fullName and avatarUrl while deferring email', async () => {
    const user = createTestUser();
    mockUserRepo.findById.mockResolvedValue(user);
    mockUserRepo.findByEmail.mockResolvedValue(null);
    mockUserRepo.update.mockResolvedValue(undefined);

    const command = new UpdateProfileCommand(
      'user-1',
      'Le Thi C',
      'https://cdn.example.com/new.jpg',
      'new@example.com',
    );
    const result = await handler.execute(command);

    expect(result.fullName).toBe('Le Thi C');
    expect(result.avatarUrl).toBe('https://cdn.example.com/new.jpg');
    // Email deferred
    expect(result.email).toBeNull();
    expect(result.emailChangePending).toBe(true);
    expect(mockUserRepo.update).toHaveBeenCalledWith(user);
    expect(mockCache.invalidate).toHaveBeenCalled();
  });

  it('defers phone change via SMS OTP instead of updating directly', async () => {
    const user = createTestUser();
    mockUserRepo.findById.mockResolvedValue(user);
    mockUserRepo.findByPhone.mockResolvedValue(null);
    mockUserRepo.update.mockResolvedValue(undefined);

    const command = new UpdateProfileCommand(
      'user-1',
      undefined,
      undefined,
      undefined,
      '0987654321',
    );
    const result = await handler.execute(command);

    // Phone should NOT change yet — deferred pending OTP
    expect(result.phoneNumber).toBe('+84912345678');
    expect(result.phoneChangePending).toBe(true);

    expect(mockRedis.set).toHaveBeenCalledWith(
      'auth:phone_change_otp:user-1',
      expect.stringContaining('+84987654321'),
      600,
    );
    expect(mockEventBus.publish).toHaveBeenCalledWith(
      expect.objectContaining({
        eventName: 'user.phone_change_requested',
        newPhone: '+84987654321',
      }),
    );
  });

  it('throws ConflictException when new phone is already taken', async () => {
    const user = createTestUser();
    const otherUser = createTestUser({ id: 'user-2' });
    mockUserRepo.findById.mockResolvedValue(user);
    mockUserRepo.findByPhone.mockResolvedValue(otherUser);

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

  it('skips SMS OTP when phone is unchanged', async () => {
    const user = createTestUser();
    mockUserRepo.findById.mockResolvedValue(user);
    mockUserRepo.update.mockResolvedValue(undefined);

    const command = new UpdateProfileCommand(
      'user-1',
      undefined,
      undefined,
      undefined,
      '0912345678',
    );
    const result = await handler.execute(command);

    expect(mockRedis.set).not.toHaveBeenCalled();
    expect(mockEventBus.publish).not.toHaveBeenCalled();
    expect(result.phoneChangePending).toBeUndefined();
  });

  it('throws ValidationException for invalid phone format', async () => {
    const user = createTestUser();
    mockUserRepo.findById.mockResolvedValue(user);

    const command = new UpdateProfileCommand(
      'user-1',
      undefined,
      undefined,
      undefined,
      'not-a-phone',
    );
    await expect(handler.execute(command)).rejects.toThrow('Số điện thoại');
  });
});
