import { Test, TestingModule } from '@nestjs/testing';
import { UnauthorizedException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { ConfigService } from '@nestjs/config';
import * as bcrypt from 'bcrypt';
import { AccountStatus, EstateStatus, UserRole } from '@prisma/client';
import { AuthService } from './auth.service';
import { PrismaService } from '../database/prisma.service';
import { AuditService } from '../audit/audit.service';
import { OtpService } from './otp.service';

jest.mock('bcrypt');

describe('AuthService', () => {
  let service: AuthService;
  let prisma: PrismaService;

  const mockPrisma = {
    user: {
      findUnique: jest.fn(),
      update: jest.fn(),
      count: jest.fn()
    },
    estate: {
      findUnique: jest.fn()
    },
    resident: {
      count: jest.fn()
    },
    guard: {
      count: jest.fn()
    },
    $transaction: jest.fn()
  };

  const mockJwtService = {
    signAsync: jest.fn(),
    verifyAsync: jest.fn()
  };

  const mockConfigService = {
    get: jest.fn((key: string, defaultValue?: any) => {
      const config: Record<string, any> = {
        'OTP_DEV_CODE': '123456',
        'JWT_ACCESS_SECRET': 'test-access-secret',
        'JWT_REFRESH_SECRET': 'test-refresh-secret'
      };
      return config[key] ?? defaultValue;
    })
  };

  const mockAudit = {
    record: jest.fn()
  };

  const mockOtp = {
    sendOtp: jest.fn(),
    verifyOtp: jest.fn()
  };

  beforeEach(async () => {
    const module: TestingModule = await Test.createTestingModule({
      providers: [
        AuthService,
        { provide: PrismaService, useValue: mockPrisma },
        { provide: JwtService, useValue: mockJwtService },
        { provide: ConfigService, useValue: mockConfigService },
        { provide: AuditService, useValue: mockAudit },
        { provide: OtpService, useValue: mockOtp }
      ]
    }).compile();

    service = module.get<AuthService>(AuthService);
    prisma = module.get<PrismaService>(PrismaService);

    jest.clearAllMocks();
  });

  it('should be defined', () => {
    expect(service).toBeDefined();
  });

  describe('login', () => {
    it('should return OTP message for valid phone', async () => {
      const mockUser = {
        id: 'user-1',
        phone: '+2348000000001',
        name: 'Test User',
        role: UserRole.RESIDENT,
        estateId: 'estate-1',
        estate: { status: EstateStatus.ACTIVE },
        status: AccountStatus.ACTIVE,
        refreshToken: null,
        createdAt: new Date(),
        updatedAt: new Date()
      };

      mockPrisma.user.findUnique.mockResolvedValue(mockUser);
      mockOtp.sendOtp.mockResolvedValue({
        message: 'OTP sent',
        provider: 'DEV',
        expiresInSeconds: 300,
        devOtp: '123456'
      });

      const result = await service.login({ phone: '+2348000000001' });

      expect(result).toHaveProperty('message', 'OTP sent');
      expect(result).toHaveProperty('devOtp', '123456');
      expect(mockOtp.sendOtp).toHaveBeenCalledWith('+2348000000001', 'Test User');
      expect(prisma.user.findUnique).toHaveBeenCalledWith({
        where: { phone: '+2348000000001' },
        include: { estate: { select: { status: true } } }
      });
    });

    it('should throw error for non-existent phone', async () => {
      mockPrisma.user.findUnique.mockResolvedValue(null);

      await expect(service.login({ phone: '+2348000000999' })).rejects.toThrow(
        UnauthorizedException
      );
    });

    it('should throw error for inactive account', async () => {
      mockPrisma.user.findUnique.mockResolvedValue({
        id: 'user-1',
        phone: '+2348000000001',
        role: UserRole.RESIDENT,
        status: AccountStatus.DISABLED,
        estate: { status: EstateStatus.ACTIVE }
      });

      await expect(service.login({ phone: '+2348000000001' })).rejects.toThrow(
        UnauthorizedException
      );
    });
  });

  describe('verifyOtp', () => {
    it('should return tokens for valid OTP', async () => {
      const mockUser = {
        id: 'user-1',
        phone: '+2348000000001',
        name: 'Test User',
        role: UserRole.RESIDENT,
        estateId: 'estate-1',
        estate: { status: EstateStatus.ACTIVE },
        status: AccountStatus.ACTIVE,
        refreshToken: null,
        createdAt: new Date(),
        updatedAt: new Date()
      };

      mockPrisma.user.findUnique.mockResolvedValue(mockUser);
      mockOtp.verifyOtp.mockReturnValue(true);
      mockJwtService.signAsync
        .mockResolvedValueOnce('access-token-123')
        .mockResolvedValueOnce('refresh-token-456');
      (bcrypt.hash as jest.Mock).mockResolvedValueOnce('hashed-refresh-token');
      mockPrisma.user.update.mockResolvedValue(mockUser);

      const result = await service.verifyOtp({
        phone: '+2348000000001',
        otp: '123456'
      });

      expect(result).toHaveProperty('accessToken', 'access-token-123');
      expect(result).toHaveProperty('refreshToken', 'refresh-token-456');
      expect(prisma.user.update).toHaveBeenCalled();
      expect(bcrypt.hash).toHaveBeenCalledWith('refresh-token-456', 10);
    });

    it('should throw error for invalid OTP', async () => {
      mockPrisma.user.findUnique.mockResolvedValue({
        id: 'user-1',
        phone: '+2348000000001',
        role: UserRole.RESIDENT,
        estateId: 'estate-1',
        status: AccountStatus.ACTIVE,
        estate: { status: EstateStatus.ACTIVE }
      });
      mockOtp.verifyOtp.mockReturnValue(false);

      await expect(
        service.verifyOtp({
          phone: '+2348000000001',
          otp: 'wrong-otp'
        })
      ).rejects.toThrow(UnauthorizedException);
    });

    it('should throw error for non-existent user', async () => {
      mockPrisma.user.findUnique.mockResolvedValue(null);
      mockOtp.verifyOtp.mockReturnValue(true);

      await expect(
        service.verifyOtp({
          phone: '+2348000000999',
          otp: '123456'
        })
      ).rejects.toThrow(UnauthorizedException);
    });
  });

  describe('refresh', () => {
    it('should return new tokens for valid refresh token', async () => {
      const payload = {
        sub: 'user-1',
        estateId: 'estate-1',
        role: 'RESIDENT'
      };

      const mockUser = {
        id: 'user-1',
        phone: '+2348000000001',
        name: 'Test User',
        role: UserRole.RESIDENT,
        estateId: 'estate-1',
        estate: { status: EstateStatus.ACTIVE },
        status: AccountStatus.ACTIVE,
        refreshToken: 'hashed-old-token',
        createdAt: new Date(),
        updatedAt: new Date()
      };

      mockJwtService.verifyAsync.mockResolvedValueOnce(payload);
      mockPrisma.user.findUnique.mockResolvedValueOnce(mockUser);
      (bcrypt.compare as jest.Mock).mockResolvedValueOnce(true);
      mockJwtService.signAsync
        .mockResolvedValueOnce('new-access-token')
        .mockResolvedValueOnce('new-refresh-token');
      (bcrypt.hash as jest.Mock).mockResolvedValueOnce('hashed-new-token');
      mockPrisma.user.update.mockResolvedValue(mockUser);

      const result = await service.refresh({ refreshToken: 'old-token' });

      expect(result).toHaveProperty('accessToken', 'new-access-token');
      expect(result).toHaveProperty('refreshToken', 'new-refresh-token');
      expect(bcrypt.compare).toHaveBeenCalledWith('old-token', 'hashed-old-token');
    });

    it('should throw error for invalid refresh token', async () => {
      mockJwtService.verifyAsync.mockRejectedValueOnce(new Error('Invalid token'));

      await expect(service.refresh({ refreshToken: 'invalid-token' })).rejects.toThrow(
        UnauthorizedException
      );
    });

    it('should throw error when refresh token hash does not match', async () => {
      const payload = {
        sub: 'user-1',
        estateId: 'estate-1',
        role: 'RESIDENT'
      };

      const mockUser = {
        id: 'user-1',
        role: UserRole.RESIDENT,
        status: AccountStatus.ACTIVE,
        estate: { status: EstateStatus.ACTIVE },
        refreshToken: 'hashed-old-token'
      };

      mockJwtService.verifyAsync.mockResolvedValueOnce(payload);
      mockPrisma.user.findUnique.mockResolvedValueOnce(mockUser);
      (bcrypt.compare as jest.Mock).mockResolvedValueOnce(false);

      await expect(service.refresh({ refreshToken: 'tampered-token' })).rejects.toThrow(
        UnauthorizedException
      );
    });
  });

  describe('signup', () => {
    it('should create a resident account and audit the action', async () => {
      const actor = {
        id: 'admin-1',
        role: UserRole.ESTATE_ADMIN,
        estateId: 'estate-1'
      };

      const account = {
        id: 'user-new',
        estateId: 'estate-1',
        phone: '+2348000000100',
        name: 'New Resident',
        role: UserRole.RESIDENT,
        status: AccountStatus.ACTIVE,
        refreshToken: null,
        createdAt: new Date(),
        updatedAt: new Date()
      };
      const resident = {
        id: 'resident-new',
        estateId: 'estate-1',
        userId: account.id,
        phone: account.phone,
        name: account.name,
        unit: 'B14',
        status: AccountStatus.ACTIVE,
        createdAt: new Date(),
        updatedAt: new Date()
      };

      mockPrisma.estate.findUnique.mockResolvedValue({
        subscriptionPlan: { limits: { residents: 100 } }
      });
      mockPrisma.resident.count.mockResolvedValue(2);
      mockPrisma.$transaction.mockImplementation(async (callback: any) =>
        callback({
          user: { create: jest.fn().mockResolvedValue(account) },
          resident: { create: jest.fn().mockResolvedValue(resident) },
          guard: { create: jest.fn() }
        })
      );
      mockAudit.record.mockResolvedValue(undefined);

      const result = await service.signup(
        {
          name: 'New Resident',
          phone: '+2348000000100',
          role: UserRole.RESIDENT,
          estateId: 'estate-1',
          unit: 'B14'
        },
        actor
      );

      expect(result).toEqual({ user: account, resident });
      expect(mockAudit.record).toHaveBeenCalledWith(
        expect.objectContaining({
          action: 'USER_SIGNED_UP',
          estateId: 'estate-1'
        })
      );
    });
  });
});
