import { Test } from '@nestjs/testing';
import { JwtModule, JwtService } from '@nestjs/jwt';
import { ConflictException, ForbiddenException, UnauthorizedException } from '@nestjs/common';
import * as bcrypt from 'bcrypt';
import { AuthService } from './auth.service';
import { PrismaService } from '../prisma/prisma.service';

describe('AuthService', () => {
  let service: AuthService;
  let jwt: JwtService;
  let prisma: {
    user: {
      count: jest.Mock;
      findUnique: jest.Mock;
      create: jest.Mock;
      update: jest.Mock;
    };
  };

  const HASH = bcrypt.hashSync('plain-pwd', 10);
  const dbUser = {
    id: 1n,
    username: 'alice',
    passwordHash: HASH,
    role: 'ADMIN' as const,
    tokenVersion: 0,
    createdAt: new Date('2026-01-01T00:00:00Z'),
  };

  beforeAll(async () => {
    prisma = {
      user: {
        count: jest.fn(),
        findUnique: jest.fn(),
        create: jest.fn(),
        update: jest.fn(),
      },
    };
    const moduleRef = await Test.createTestingModule({
      imports: [
        JwtModule.register({
          secret: 'a'.repeat(32),
          signOptions: { expiresIn: '1h' },
        }),
      ],
      providers: [AuthService, { provide: PrismaService, useValue: prisma }],
    }).compile();
    service = moduleRef.get(AuthService);
    jwt = moduleRef.get(JwtService);
  });

  beforeEach(() => {
    jest.clearAllMocks();
  });

  describe('register', () => {
    it('creates the first user with a hashed password', async () => {
      prisma.user.count.mockResolvedValueOnce(0);
      prisma.user.findUnique.mockResolvedValueOnce(null);
      prisma.user.create.mockResolvedValueOnce(dbUser);

      const user = await service.register({ username: 'alice', password: 'plain-pwd' });

      expect(user.username).toBe('alice');
      const created = prisma.user.create.mock.calls[0][0].data;
      expect(created.passwordHash).not.toBe('plain-pwd');
      await expect(bcrypt.compare('plain-pwd', created.passwordHash)).resolves.toBe(true);
    });

    it('refuses registration once a user exists (bootstrap lock)', async () => {
      prisma.user.count.mockResolvedValueOnce(1);
      await expect(
        service.register({ username: 'mallory', password: 'evil-pwd' }),
      ).rejects.toBeInstanceOf(ForbiddenException);
      expect(prisma.user.create).not.toHaveBeenCalled();
    });

    it('throws ConflictException for duplicate usernames', async () => {
      prisma.user.count.mockResolvedValueOnce(0);
      prisma.user.findUnique.mockResolvedValueOnce(dbUser);
      await expect(
        service.register({ username: 'alice', password: 'pwd5678' }),
      ).rejects.toBeInstanceOf(ConflictException);
    });
  });

  describe('login', () => {
    it('returns access + refresh tokens with tokenVersion and type', async () => {
      prisma.user.findUnique.mockResolvedValueOnce(dbUser);
      const result = await service.login({ username: 'alice', password: 'plain-pwd' });

      expect(result.user.username).toBe('alice');
      const access = jwt.decode(result.accessToken) as Record<string, unknown>;
      expect(access.typ).toBe('access');
      expect(access.tv).toBe(0);
      const refresh = jwt.decode(result.refreshToken) as Record<string, unknown>;
      expect(refresh.typ).toBe('refresh');
      expect(refresh.tv).toBe(0);
    });

    it('throws UnauthorizedException for wrong password', async () => {
      prisma.user.findUnique.mockResolvedValueOnce(dbUser);
      await expect(
        service.login({ username: 'alice', password: 'wrong-pwd' }),
      ).rejects.toBeInstanceOf(UnauthorizedException);
    });

    it('throws UnauthorizedException for unknown user', async () => {
      prisma.user.findUnique.mockResolvedValueOnce(null);
      await expect(
        service.login({ username: 'no-such-user', password: 'whatever' }),
      ).rejects.toBeInstanceOf(UnauthorizedException);
    });
  });

  describe('refresh', () => {
    it('rotates a valid refresh token', async () => {
      const refreshToken = await jwt.signAsync({
        sub: '1',
        username: 'alice',
        role: 'ADMIN',
        tv: 0,
        typ: 'refresh',
      });
      prisma.user.findUnique.mockResolvedValueOnce(dbUser);

      const result = await service.refresh(refreshToken);
      expect(result.user.username).toBe('alice');
      expect(result.accessToken).not.toBe(refreshToken);
    });

    it('rejects access tokens used as refresh tokens', async () => {
      const accessToken = await jwt.signAsync({
        sub: '1',
        username: 'alice',
        role: 'ADMIN',
        tv: 0,
        typ: 'access',
      });
      await expect(service.refresh(accessToken)).rejects.toBeInstanceOf(UnauthorizedException);
    });

    it('rejects refresh tokens with a stale tokenVersion (revoked)', async () => {
      const refreshToken = await jwt.signAsync({
        sub: '1',
        username: 'alice',
        role: 'ADMIN',
        tv: 0,
        typ: 'refresh',
      });
      // User logged out elsewhere: tokenVersion bumped to 1
      prisma.user.findUnique.mockResolvedValueOnce({ ...dbUser, tokenVersion: 1 });
      await expect(service.refresh(refreshToken)).rejects.toBeInstanceOf(UnauthorizedException);
    });
  });

  describe('logout', () => {
    it('bumps tokenVersion to revoke all tokens', async () => {
      prisma.user.update.mockResolvedValueOnce({ ...dbUser, tokenVersion: 1 });
      await service.logout(1n);
      expect(prisma.user.update).toHaveBeenCalledWith({
        where: { id: 1n },
        data: { tokenVersion: { increment: 1 } },
      });
    });
  });
});
