// src/services/authService.test.ts
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import type { UserProfile } from '../types';
import type * as jsonwebtoken from 'jsonwebtoken';
import type { Logger } from 'pino';

const { transactionalUserRepoMocks, transactionalAdminRepoMocks } = vi.hoisted(() => {
  return {
    transactionalUserRepoMocks: {
      updateUserPassword: vi.fn(),
      deleteResetToken: vi.fn(),
      createPasswordResetToken: vi.fn(),
      createUser: vi.fn(),
    },
    transactionalAdminRepoMocks: {
      logActivity: vi.fn(),
    },
  };
});

vi.mock('./db/user.db', () => ({
  UserRepository: vi.fn().mockImplementation(function () {
    return transactionalUserRepoMocks;
  }),
}));
vi.mock('./db/admin.db', () => ({
  AdminRepository: vi.fn().mockImplementation(function () {
    return transactionalAdminRepoMocks;
  }),
}));

describe('AuthService', () => {
  let authService: typeof import('./authService').authService;
  let bcrypt: typeof import('bcrypt');
  let jwt: typeof jsonwebtoken & { default: typeof jsonwebtoken };
  let userRepo: typeof import('./db/index.db').userRepo;
  let _adminRepo: typeof import('./db/index.db').adminRepo;
  let logger: typeof import('./logger.server').logger;
  let sendPasswordResetEmail: typeof import('./emailService.server').sendPasswordResetEmail;
  let DatabaseError: typeof import('./processingErrors').DatabaseError;
  let UniqueConstraintError: typeof import('./db/errors.db').UniqueConstraintError;
  let RepositoryError: typeof import('./db/errors.db').RepositoryError;
  let ValidationError: typeof import('./db/errors.db').ValidationError;
  let withTransaction: typeof import('./db/index.db').withTransaction;

  // Mock request logger with all required Logger methods
  const reqLog = {
    info: vi.fn(),
    error: vi.fn(),
    warn: vi.fn(),
    debug: vi.fn(),
    trace: vi.fn(),
    fatal: vi.fn(),
    silent: vi.fn(),
    level: 'info',
    child: vi.fn().mockReturnThis(),
  } as unknown as Logger;
  const mockUser = {
    user_id: 'user-123',
    email: 'test@example.com',
    password_hash: 'hashed-password',
    failed_login_attempts: 0,
    last_failed_login: null,
    created_at: new Date().toISOString(),
    updated_at: new Date().toISOString(),
    refresh_token: null,
  };
  const mockUserProfile: UserProfile = {
    user: mockUser,
    role: 'user',
  } as unknown as UserProfile;

  beforeEach(async () => {
    vi.clearAllMocks();
    vi.resetModules();

    // Set environment variables before any modules are imported
    vi.stubEnv('JWT_SECRET', 'test-secret');
    vi.stubEnv('FRONTEND_URL', 'https://example.com');

    // Mock all dependencies before dynamically importing the service
    // Core modules like bcrypt, jsonwebtoken, and crypto are now mocked globally in tests-setup-unit.ts
    vi.mock('bcrypt');
    vi.mock('./db/index.db', () => ({
      withTransaction: vi.fn(),
      userRepo: {
        createUser: vi.fn(),
        saveRefreshToken: vi.fn(),
        findUserByEmail: vi.fn(),
        createPasswordResetToken: vi.fn(),
        getValidResetTokens: vi.fn(),
        updateUserPassword: vi.fn(),
        deleteResetToken: vi.fn(),
        findUserByRefreshToken: vi.fn(),
        findUserProfileById: vi.fn(),
        deleteRefreshToken: vi.fn(),
      },
      adminRepo: {
        logActivity: vi.fn(),
      },
    }));
    vi.mock('./logger.server', () => ({
      logger: { info: vi.fn(), error: vi.fn(), warn: vi.fn(), debug: vi.fn() },
    }));
    vi.mock('./emailService.server', () => ({
      sendPasswordResetEmail: vi.fn(),
    }));
    vi.mock('./db/connection.db', () => ({ getPool: vi.fn() }));
    vi.mock('../utils/authUtils', () => ({ validatePasswordStrength: vi.fn() }));

    // Dynamically import modules to get the mocked versions and the service instance
    authService = (await import('./authService')).authService;
    bcrypt = await import('bcrypt');
    jwt = (await import('jsonwebtoken')) as typeof jwt;
    const dbModule = await import('./db/index.db');
    userRepo = dbModule.userRepo;
    _adminRepo = dbModule.adminRepo;
    logger = (await import('./logger.server')).logger;
    withTransaction = (await import('./db/index.db')).withTransaction;
    vi.mocked(withTransaction).mockImplementation(async (callback: any) => {
      return callback({}); // Mock client
    });
    const { validatePasswordStrength } = await import('../utils/authUtils');
    vi.mocked(validatePasswordStrength).mockReturnValue({ isValid: true, feedback: '' });
    sendPasswordResetEmail = (await import('./emailService.server')).sendPasswordResetEmail;
    DatabaseError = (await import('./processingErrors')).DatabaseError;
    UniqueConstraintError = (await import('./db/errors.db')).UniqueConstraintError;
    RepositoryError = (await import('./db/errors.db')).RepositoryError;
    ValidationError = (await import('./db/errors.db')).ValidationError;
  });

  afterEach(() => {
    vi.unstubAllEnvs();
  });

  describe('registerUser', () => {
    it('should successfully register a new user', async () => {
      vi.mocked(bcrypt.hash).mockImplementation(async () => 'hashed-password');
      vi.mocked(transactionalUserRepoMocks.createUser).mockResolvedValue(mockUserProfile);

      const result = await authService.registerUser(
        'test@example.com',
        'password123',
        'Test User',
        undefined,
        reqLog,
      );

      expect(bcrypt.hash).toHaveBeenCalledWith('password123', 10);
      expect(transactionalUserRepoMocks.createUser).toHaveBeenCalledWith(
        'test@example.com',
        'hashed-password',
        { full_name: 'Test User', avatar_url: undefined },
        reqLog,
      );
      expect(transactionalAdminRepoMocks.logActivity).toHaveBeenCalledWith(
        expect.objectContaining({
          action: 'user_registered',
          userId: 'user-123',
        }),
        reqLog,
      );
      expect(result).toEqual(mockUserProfile);
    });

    it('should throw UniqueConstraintError if email already exists', async () => {
      vi.mocked(bcrypt.hash).mockImplementation(async () => 'hashed-password');
      const error = new UniqueConstraintError('Email exists');
      vi.mocked(withTransaction).mockRejectedValue(error);

      await expect(
        authService.registerUser('test@example.com', 'password123', undefined, undefined, reqLog),
      ).rejects.toThrow(UniqueConstraintError);

      expect(logger.error).not.toHaveBeenCalled();
    });

    it('should log and re-throw generic errors on registration failure', async () => {
      vi.mocked(bcrypt.hash).mockImplementation(async () => 'hashed-password');
      const error = new Error('Database failed');
      vi.mocked(withTransaction).mockRejectedValue(error);

      await expect(
        authService.registerUser('test@example.com', 'password123', undefined, undefined, reqLog),
      ).rejects.toThrow(DatabaseError);

      expect(logger.error).toHaveBeenCalledWith(
        { error, email: 'test@example.com' },
        `User registration failed with an unexpected error.`,
      );
    });

    it('should throw ValidationError if password is weak', async () => {
      const { validatePasswordStrength } = await import('../utils/authUtils');
      vi.mocked(validatePasswordStrength).mockReturnValue({
        isValid: false,
        feedback: 'Password too weak',
      });

      await expect(
        authService.registerUser('test@example.com', 'weak', 'Test User', undefined, reqLog),
      ).rejects.toThrow(ValidationError);
    });
  });

  describe('registerAndLoginUser', () => {
    it('should register user and return tokens', async () => {
      // Mock registerUser logic (since we can't easily spy on the same class instance method without prototype spying, we rely on the underlying calls)
      vi.mocked(bcrypt.hash).mockImplementation(async () => 'hashed-password');
      vi.mocked(transactionalUserRepoMocks.createUser).mockResolvedValue(mockUserProfile);
      // FIX: The global mock for jsonwebtoken provides a `default` export.
      // The code under test (`authService`) uses `import jwt from 'jsonwebtoken'`, so it gets the default export.
      // We must mock `jwt.default.sign` to affect the code under test.
      vi.mocked(jwt.default.sign).mockImplementation(() => 'access-token');

      const result = await authService.registerAndLoginUser(
        'test@example.com',
        'password123',
        'Test User',
        undefined,
        reqLog,
      );

      expect(result).toEqual({
        newUserProfile: mockUserProfile,
        accessToken: 'access-token',
        refreshToken: 'mocked_random_id',
      });
      expect(userRepo.saveRefreshToken).toHaveBeenCalledWith(
        'user-123',
        'mocked_random_id',
        reqLog,
      );
    });
  });

  describe('generateAuthTokens', () => {
    it('should generate access and refresh tokens', () => {
      // FIX: The global mock for jsonwebtoken provides a `default` export.
      // The code under test (`authService`) uses `import jwt from 'jsonwebtoken'`, so it gets the default export.
      // We must mock `jwt.default.sign` to affect the code under test.
      vi.mocked(jwt.default.sign).mockImplementation(() => 'access-token');

      const result = authService.generateAuthTokens(mockUserProfile);

      expect(vi.mocked(jwt.default.sign)).toHaveBeenCalledWith(
        {
          user_id: 'user-123',
          email: 'test@example.com',
          role: 'user',
        },
        'test-secret',
        { expiresIn: '15m' },
      );
      expect(result).toEqual({
        accessToken: 'access-token',
        refreshToken: 'mocked_random_id',
      });
    });
  });

  describe('saveRefreshToken', () => {
    it('should save refresh token to db', async () => {
      await authService.saveRefreshToken('user-123', 'token', reqLog);
      expect(userRepo.saveRefreshToken).toHaveBeenCalledWith('user-123', 'token', reqLog);
    });

    it('should propagate the error from the repository on failure', async () => {
      const error = new Error('DB Error');
      vi.mocked(userRepo.saveRefreshToken).mockRejectedValue(error);

      // The service method now directly propagates the error from the repo.
      await expect(authService.saveRefreshToken('user-123', 'token', reqLog)).rejects.toThrow(
        error,
      );
      expect(logger.error).not.toHaveBeenCalled();
    });
  });

  describe('resetPassword', () => {
    it('should process password reset for existing user', async () => {
      vi.mocked(userRepo.findUserByEmail).mockResolvedValue(mockUser);
      vi.mocked(bcrypt.hash).mockImplementation(async () => 'hashed-token');

      const result = await authService.resetPassword('test@example.com', reqLog);

      expect(transactionalUserRepoMocks.createPasswordResetToken).toHaveBeenCalledWith(
        'user-123',
        'hashed-token',
        expect.any(Date),
        reqLog,
        {},
      );
      expect(sendPasswordResetEmail).toHaveBeenCalledWith(
        'test@example.com',
        expect.stringContaining('/reset-password/mocked_random_id'),
        reqLog,
      );
      expect(result).toBe('mocked_random_id');
    });

    it('should log warning and return undefined for non-existent user', async () => {
      vi.mocked(userRepo.findUserByEmail).mockResolvedValue(undefined);

      const result = await authService.resetPassword('unknown@example.com', reqLog);

      expect(logger.warn).toHaveBeenCalledWith(
        expect.stringContaining('Password reset requested for non-existent email'),
      );
      expect(sendPasswordResetEmail).not.toHaveBeenCalled();
      expect(result).toBeUndefined();
    });

    it('should log error and throw on failure', async () => {
      const error = new Error('DB Error');
      vi.mocked(userRepo.findUserByEmail).mockRejectedValue(error);

      await expect(authService.resetPassword('test@example.com', reqLog)).rejects.toThrow(
        'DB Error',
      );
      expect(logger.error).toHaveBeenCalled();
    });

    it('should log error if sending email fails but still return token', async () => {
      vi.mocked(userRepo.findUserByEmail).mockResolvedValue(mockUser);
      vi.mocked(bcrypt.hash).mockImplementation(async () => 'hashed-token');
      const emailError = new Error('Email failed');
      vi.mocked(sendPasswordResetEmail).mockRejectedValue(emailError);

      const result = await authService.resetPassword('test@example.com', reqLog);

      expect(logger.error).toHaveBeenCalledWith(
        { emailError },
        `Email send failure during password reset for user`,
      );
      expect(result).toBe('mocked_random_id');
    });

    it('should re-throw RepositoryError', async () => {
      const repoError = new RepositoryError('Repo error', 500);
      vi.mocked(userRepo.findUserByEmail).mockRejectedValue(repoError);

      await expect(authService.resetPassword('test@example.com', reqLog)).rejects.toThrow(
        repoError,
      );
    });
  });

  describe('updatePassword', () => {
    it('should update password if token is valid and wrap operations in a transaction', async () => {
      const mockTokenRecord = {
        user_id: 'user-123',
        token_hash: 'hashed-token',
      };
      vi.mocked(userRepo.getValidResetTokens).mockResolvedValue([mockTokenRecord] as any);
      vi.mocked(bcrypt.compare).mockImplementation(async () => true);
      vi.mocked(bcrypt.hash).mockImplementation(async () => 'new-hashed-password');

      const result = await authService.updatePassword('valid-token', 'newPassword', reqLog);

      expect(withTransaction).toHaveBeenCalledTimes(1);

      expect(transactionalUserRepoMocks.updateUserPassword).toHaveBeenCalledWith(
        'user-123',
        'new-hashed-password',
        reqLog,
      );
      expect(transactionalUserRepoMocks.deleteResetToken).toHaveBeenCalledWith(
        'hashed-token',
        reqLog,
      );
      expect(transactionalAdminRepoMocks.logActivity).toHaveBeenCalledWith(
        expect.objectContaining({ action: 'password_reset' }),
        reqLog,
      );
      expect(result).toBe(true);
    });

    it('should log and re-throw an error if the transaction fails', async () => {
      const mockTokenRecord = { user_id: 'user-123', token_hash: 'hashed-token' };
      vi.mocked(userRepo.getValidResetTokens).mockResolvedValue([mockTokenRecord] as any);
      vi.mocked(bcrypt.compare).mockImplementation(async () => true);
      const dbError = new Error('Transaction failed');
      vi.mocked(withTransaction).mockRejectedValue(dbError);

      await expect(
        authService.updatePassword('valid-token', 'newPassword', reqLog),
      ).rejects.toThrow(DatabaseError);

      expect(logger.error).toHaveBeenCalledWith(
        { error: dbError },
        `An unexpected error occurred during password update.`,
      );
    });

    it('should return null if token is invalid or not found', async () => {
      vi.mocked(userRepo.getValidResetTokens).mockResolvedValue([]);

      const result = await authService.updatePassword('invalid-token', 'newPassword', reqLog);

      expect(transactionalUserRepoMocks.updateUserPassword).not.toHaveBeenCalled();
      expect(result).toBeNull();
    });

    it('should throw ValidationError if new password is weak', async () => {
      const { validatePasswordStrength } = await import('../utils/authUtils');
      vi.mocked(validatePasswordStrength).mockReturnValue({
        isValid: false,
        feedback: 'Password too weak',
      });

      await expect(authService.updatePassword('token', 'weak', reqLog)).rejects.toThrow(
        ValidationError,
      );
    });

    it('should re-throw RepositoryError from transaction', async () => {
      const repoError = new RepositoryError('Repo error', 500);
      vi.mocked(withTransaction).mockRejectedValue(repoError);

      await expect(authService.updatePassword('token', 'newPass', reqLog)).rejects.toThrow(
        repoError,
      );
    });
  });

  describe('getUserByRefreshToken', () => {
    it('should return user profile if token exists', async () => {
      vi.mocked(userRepo.findUserByRefreshToken).mockResolvedValue({
        user_id: 'user-123',
        email: 'test@example.com',
        created_at: new Date().toISOString(),
        updated_at: new Date().toISOString(),
      });
      vi.mocked(userRepo.findUserProfileById).mockResolvedValue(mockUserProfile);

      const result = await authService.getUserByRefreshToken('valid-token', reqLog);

      expect(result).toEqual(mockUserProfile);
    });

    it('should return null if token not found', async () => {
      vi.mocked(userRepo.findUserByRefreshToken).mockResolvedValue(undefined);

      const result = await authService.getUserByRefreshToken('invalid-token', reqLog);

      expect(result).toBeNull();
    });

    it('should throw a DatabaseError if finding the user fails with a generic error', async () => {
      const dbError = new Error('DB connection failed');
      vi.mocked(userRepo.findUserByRefreshToken).mockRejectedValue(dbError);

      // Use a try-catch to assert on the error instance properties, which is more robust
      // than `toBeInstanceOf` in some complex module mocking scenarios in Vitest.
      try {
        await authService.getUserByRefreshToken('any-token', reqLog);
        expect.fail('Expected an error to be thrown');
      } catch (error: any) {
        expect(error.name).toBe('DatabaseError');
        expect(error.message).toBe('DB connection failed');
        expect(logger.error).toHaveBeenCalledWith(
          { error: dbError, refreshToken: 'any-token' },
          'An unexpected error occurred while fetching user by refresh token.',
        );
      }
    });

    it('should re-throw a RepositoryError if finding the user fails with a known error', async () => {
      const repoError = new RepositoryError('Some repo error', 500);
      vi.mocked(userRepo.findUserByRefreshToken).mockRejectedValue(repoError);

      await expect(authService.getUserByRefreshToken('any-token', reqLog)).rejects.toThrow(
        repoError,
      );
      // The original error is re-thrown, so the generic wrapper log should not be called.
      expect(logger.error).not.toHaveBeenCalledWith(
        expect.any(Object),
        'An unexpected error occurred while fetching user by refresh token.',
      );
    });
  });

  describe('logout', () => {
    it('should delete refresh token', async () => {
      await authService.logout('token', reqLog);
      expect(userRepo.deleteRefreshToken).toHaveBeenCalledWith('token', reqLog);
    });

    it('should propagate the error from the repository on failure', async () => {
      const error = new Error('DB Error');
      vi.mocked(userRepo.deleteRefreshToken).mockRejectedValue(error);

      await expect(authService.logout('token', reqLog)).rejects.toThrow(error);
      expect(logger.error).not.toHaveBeenCalled();
    });
  });

  describe('refreshAccessToken', () => {
    it('should return new access token if user found', async () => {
      vi.mocked(userRepo.findUserByRefreshToken).mockResolvedValue({
        user_id: 'user-123',
        email: 'test@example.com',
        created_at: new Date().toISOString(),
        updated_at: new Date().toISOString(),
      });
      vi.mocked(userRepo.findUserProfileById).mockResolvedValue(mockUserProfile);
      // FIX: The global mock for jsonwebtoken provides a `default` export.
      // The code under test (`authService`) uses `import jwt from 'jsonwebtoken'`, so it gets the default export.
      // We must mock `jwt.default.sign` to affect the code under test.
      vi.mocked(jwt.default.sign).mockImplementation(() => 'new-access-token');

      const result = await authService.refreshAccessToken('valid-token', reqLog);

      expect(result).toEqual({ accessToken: 'new-access-token' });
    });

    it('should return null if user not found', async () => {
      vi.mocked(userRepo.findUserByRefreshToken).mockResolvedValue(undefined);
      const result = await authService.refreshAccessToken('invalid-token', reqLog);
      expect(result).toBeNull();
    });

    it('should propagate errors from getUserByRefreshToken', async () => {
      const dbError = new DatabaseError('Underlying DB call failed');
      // We mock the service's own method since refreshAccessToken calls it directly.
      vi.spyOn(authService, 'getUserByRefreshToken').mockRejectedValue(dbError);

      await expect(authService.refreshAccessToken('any-token', reqLog)).rejects.toThrow(dbError);
    });
  });
});
