// src/services/userService.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import type { Address, UserProfile } from '../types';
import { createMockUserProfile } from '../tests/utils/mockFactories';
import * as bcrypt from 'bcrypt';
import { ValidationError, NotFoundError } from './db/errors.db';
import { DatabaseError } from './processingErrors';
import type { Job } from 'bullmq';
import type { TokenCleanupJobData } from '../types/job-data';
import { getTestBaseUrl } from '../tests/utils/testHelpers';

// Un-mock the service under test to ensure we are testing the real implementation,
// not the global mock from `tests/setup/tests-setup-unit.ts`.
vi.unmock('./userService');

// --- Hoisted Mocks ---
const mocks = vi.hoisted(() => {
  // Create mock implementations for the repository methods we'll be using.
  const mockUpsertAddress = vi.fn();
  const mockUpdateUserProfile = vi.fn();
  const mockDeleteExpiredResetTokens = vi.fn();
  const mockUpdateUserPassword = vi.fn();
  const mockFindUserWithPasswordHashById = vi.fn();
  const mockDeleteUserById = vi.fn();
  const mockGetAddressById = vi.fn();

  return {
    // Mock the withTransaction helper to immediately execute the callback.
    mockWithTransaction: vi.fn().mockImplementation(async (callback) => {
      return callback({});
    }),
    // FIX: Change this from an arrow function to a standard function
    // so that it can be instantiated with `new`.
    MockUserRepository: vi.fn(function () {
      return {
        updateUserProfile: mockUpdateUserProfile,
      };
    }),
    // Expose the method mocks for assertions.
    mockUpsertAddress,
    mockUpdateUserProfile,
    mockDeleteExpiredResetTokens,
    mockUpdateUserPassword,
    mockFindUserWithPasswordHashById,
    mockDeleteUserById,
    mockGetAddressById,
  };
});

// --- Mock Modules ---

vi.mock('bcrypt', () => ({
  hash: vi.fn(),
  compare: vi.fn(),
}));

vi.mock('./db/index.db', () => ({
  withTransaction: mocks.mockWithTransaction,
  userRepo: {
    deleteExpiredResetTokens: mocks.mockDeleteExpiredResetTokens,
    updateUserProfile: mocks.mockUpdateUserProfile,
    updateUserPassword: mocks.mockUpdateUserPassword,
    findUserWithPasswordHashById: mocks.mockFindUserWithPasswordHashById,
    deleteUserById: mocks.mockDeleteUserById,
  },
  addressRepo: {
    getAddressById: mocks.mockGetAddressById,
  },
}));

// This mock is correct, using a standard function for the constructor.
vi.mock('./db/address.db', () => {
  return {
    // We define this as a standard function (not an arrow function)
    // so that it can be instantiated with 'new AddressRepository()'
    AddressRepository: vi.fn(function () {
      return {
        upsertAddress: mocks.mockUpsertAddress,
      };
    }),
  };
});

// This now correctly uses the hoisted mock which is a valid constructor.
vi.mock('./db/user.db', () => ({
  UserRepository: mocks.MockUserRepository,
}));

vi.mock('./logger.server', () => ({
  // Provide a default mock for the logger
  logger: {
    info: vi.fn(),
    error: vi.fn(),
    warn: vi.fn(),
    debug: vi.fn(),
    child: vi.fn().mockReturnThis(),
  },
}));

// Import the service to be tested AFTER all mocks are set up.
import { userService } from './userService';

describe('UserService', () => {
  beforeEach(() => {
    // Clear call history for all mocks before each test.
    vi.clearAllMocks();
  });

  describe('upsertUserAddress', () => {
    it('should create a new address and link it to a user who has no address', async () => {
      const { logger } = await import('./logger.server');
      // Arrange: A user profile without an existing address_id.
      const user = createMockUserProfile({
        user: { user_id: 'user-123', email: 'test@example.com' },
        address_id: null,
      });
      const addressData: Partial<Address> = { address_line_1: '123 New St', city: 'Newville' };

      // Mock the address repository to return a new address ID.
      const newAddressId = 99;
      mocks.mockUpsertAddress.mockResolvedValue(newAddressId);

      // Act: Call the service method.
      const result = await userService.upsertUserAddress(user, addressData, logger);

      // Assert
      expect(result).toBe(newAddressId);
      // 1. Verify the transaction helper was called.
      expect(mocks.mockWithTransaction).toHaveBeenCalledTimes(1);
      // 2. Verify the address was upserted with the correct data.
      expect(mocks.mockUpsertAddress).toHaveBeenCalledWith(
        {
          ...addressData,
          address_id: undefined, // user.address_id was null, so it should be undefined.
        },
        logger,
      );
      // 3. Verify the user's profile was updated to link the new address ID.
      expect(mocks.mockUpdateUserProfile).toHaveBeenCalledTimes(1);
      expect(mocks.mockUpdateUserProfile).toHaveBeenCalledWith(
        user.user.user_id,
        { address_id: newAddressId },
        logger,
      );
    });

    it('should update an existing address and NOT link it if the ID does not change', async () => {
      const { logger } = await import('./logger.server');
      // Arrange: A user profile with an existing address_id.
      const existingAddressId = 42;
      const user = createMockUserProfile({
        user: { user_id: 'user-123', email: 'test@example.com' },
        address_id: existingAddressId,
      });
      const addressData: Partial<Address> = {
        address_line_1: '123 Updated St',
        city: 'Updateville',
      };

      // Mock the address repository to return the SAME address ID.
      mocks.mockUpsertAddress.mockResolvedValue(existingAddressId);

      // Act: Call the service method.
      const result = await userService.upsertUserAddress(user, addressData, logger);

      // Assert
      expect(result).toBe(existingAddressId);
      // 1. Verify the transaction helper was called.
      expect(mocks.mockWithTransaction).toHaveBeenCalledTimes(1);
      // 2. Verify the address was upserted with the existing ID.
      expect(mocks.mockUpsertAddress).toHaveBeenCalledWith(
        {
          ...addressData,
          address_id: existingAddressId,
        },
        logger,
      );
      // 3. Since the address ID did not change, the user profile should NOT be updated.
      expect(mocks.mockUpdateUserProfile).not.toHaveBeenCalled();
    });

    it('should throw a DatabaseError if the transaction fails', async () => {
      const { logger } = await import('./logger.server');
      const user = createMockUserProfile({
        user: { user_id: 'user-123' },
        address_id: null,
      });
      const addressData: Partial<Address> = { address_line_1: '123 Fail St' };
      const dbError = new Error('DB connection lost');

      // Simulate a failure within the transaction (e.g., upsertAddress fails)
      mocks.mockUpsertAddress.mockRejectedValue(dbError);

      // Act & Assert
      // The service should wrap the generic error in a `DatabaseError`.
      await expect(userService.upsertUserAddress(user, addressData, logger)).rejects.toBeInstanceOf(DatabaseError);

      // Assert that the error was logged correctly
      expect(logger.error).toHaveBeenCalledWith(
        { err: dbError, userId: user.user.user_id },
        `Transaction to upsert user address failed: ${dbError.message}`,
      );
    });
  });

  describe('processTokenCleanupJob', () => {
    it('should delete expired tokens and return the count', async () => {
      const job = {
        id: 'job-1',
        name: 'token-cleanup',
        attemptsMade: 1,
      } as Job<TokenCleanupJobData>;

      mocks.mockDeleteExpiredResetTokens.mockResolvedValue(5);

      const result = await userService.processTokenCleanupJob(job);

      expect(result).toEqual({ deletedCount: 5 });
      expect(mocks.mockDeleteExpiredResetTokens).toHaveBeenCalled();
    });

    it('should log error and rethrow if cleanup fails', async () => {
      const { logger } = await import('./logger.server');
      const job = {
        id: 'job-1',
        name: 'token-cleanup',
        attemptsMade: 1,
      } as Job<TokenCleanupJobData>;
      const error = new Error('DB Error');

      mocks.mockDeleteExpiredResetTokens.mockRejectedValue(error);

      await expect(userService.processTokenCleanupJob(job)).rejects.toThrow('DB Error');
      expect(logger.error).toHaveBeenCalledWith(
        expect.objectContaining({ err: error }),
        `Expired token cleanup job failed: ${error.message}`,
      );
    });
  });

  describe('updateUserAvatar', () => {
    it('should construct avatar URL and update profile', async () => {
      const { logger } = await import('./logger.server');
      const testBaseUrl = getTestBaseUrl();
      vi.stubEnv('FRONTEND_URL', testBaseUrl);

      const userId = 'user-123';
      const file = { filename: 'avatar.jpg' } as Express.Multer.File;
      const expectedUrl = `${testBaseUrl}/uploads/avatars/${file.filename}`;

      mocks.mockUpdateUserProfile.mockResolvedValue({} as any);

      await userService.updateUserAvatar(userId, file, logger);

      expect(mocks.mockUpdateUserProfile).toHaveBeenCalledWith(
        userId,
        { avatar_url: expectedUrl },
        logger,
      );

      vi.unstubAllEnvs();
    });

    it('should re-throw NotFoundError if user profile does not exist', async () => {
      const { logger } = await import('./logger.server');
      const userId = 'user-not-found';
      const file = { filename: 'avatar.jpg' } as Express.Multer.File;
      const notFoundError = new NotFoundError('User not found');

      mocks.mockUpdateUserProfile.mockRejectedValue(notFoundError);

      await expect(userService.updateUserAvatar(userId, file, logger)).rejects.toThrow(
        NotFoundError,
      );
    });

    it('should wrap generic errors in a DatabaseError', async () => {
      const { logger } = await import('./logger.server');
      const userId = 'user-123';
      const file = { filename: 'avatar.jpg' } as Express.Multer.File;
      const genericError = new Error('DB connection failed');

      mocks.mockUpdateUserProfile.mockRejectedValue(genericError);

      await expect(userService.updateUserAvatar(userId, file, logger)).rejects.toThrow(
        DatabaseError,
      );
      expect(logger.error).toHaveBeenCalledWith(expect.any(Object), `Failed to update user avatar: ${genericError.message}`);
    });
  });

  describe('updateUserPassword', () => {
    it('should hash password and update user', async () => {
      const { logger } = await import('./logger.server');
      const userId = 'user-123';
      const newPassword = 'new-password';
      const hashedPassword = 'hashed-password';

      vi.mocked(bcrypt.hash).mockImplementation(async () => hashedPassword);

      await userService.updateUserPassword(userId, newPassword, logger);

      expect(bcrypt.hash).toHaveBeenCalledWith(newPassword, 10);
      expect(mocks.mockUpdateUserPassword).toHaveBeenCalledWith(userId, hashedPassword, logger);
    });

    it('should wrap generic errors in a DatabaseError', async () => {
      const { logger } = await import('./logger.server');
      const userId = 'user-123';
      const newPassword = 'new-password';
      const genericError = new Error('DB write failed');

      vi.mocked(bcrypt.hash).mockResolvedValue();
      mocks.mockUpdateUserPassword.mockRejectedValue(genericError);

      await expect(userService.updateUserPassword(userId, newPassword, logger)).rejects.toThrow(DatabaseError);
      expect(logger.error).toHaveBeenCalledWith(expect.any(Object), `Failed to update user password: ${genericError.message}`);
    });
  });

  describe('deleteUserAccount', () => {
    it('should delete user if password matches', async () => {
      const { logger } = await import('./logger.server');
      const userId = 'user-123';
      const password = 'password';
      const hashedPassword = 'hashed-password';

      mocks.mockFindUserWithPasswordHashById.mockResolvedValue({
        user_id: userId,
        password_hash: hashedPassword,
      });
      vi.mocked(bcrypt.compare).mockImplementation(async () => true);

      await userService.deleteUserAccount(userId, password, logger);

      expect(mocks.mockDeleteUserById).toHaveBeenCalledWith(userId, logger);
    });

    it('should throw NotFoundError if user not found', async () => {
      const { logger } = await import('./logger.server');
      mocks.mockFindUserWithPasswordHashById.mockResolvedValue(null);

      await expect(
        userService.deleteUserAccount('user-123', 'password', logger),
      ).rejects.toThrow(NotFoundError);
    });

    it('should throw ValidationError if password does not match', async () => {
      const { logger } = await import('./logger.server');
      mocks.mockFindUserWithPasswordHashById.mockResolvedValue({
        user_id: 'user-123',
        password_hash: 'hashed',
      });
      vi.mocked(bcrypt.compare).mockImplementation(async () => false);

      await expect(
        userService.deleteUserAccount('user-123', 'wrong-password', logger),
      ).rejects.toThrow(ValidationError);
      expect(mocks.mockDeleteUserById).not.toHaveBeenCalled();
    });

    it('should wrap generic errors in a DatabaseError', async () => {
      const { logger } = await import('./logger.server');
      const userId = 'user-123';
      const password = 'password';
      const genericError = new Error('Something went wrong');

      mocks.mockFindUserWithPasswordHashById.mockResolvedValue({
        user_id: userId,
        password_hash: 'hashed-password',
      });
      vi.mocked(bcrypt.compare).mockRejectedValue(genericError);

      await expect(userService.deleteUserAccount(userId, password, logger)).rejects.toThrow(DatabaseError);
      expect(logger.error).toHaveBeenCalledWith(expect.any(Object), `Failed to delete user account: ${genericError.message}`);
    });
  });

  describe('getUserAddress', () => {
    it('should return address if user is authorized', async () => {
      const { logger } = await import('./logger.server');
      const userProfile = { address_id: 123 } as UserProfile;
      const address = { address_id: 123, address_line_1: 'Test St' } as Address;

      mocks.mockGetAddressById.mockResolvedValue(address);

      const result = await userService.getUserAddress(userProfile, 123, logger);

      expect(result).toEqual(address);
      expect(mocks.mockGetAddressById).toHaveBeenCalledWith(123, logger);
    });

    it('should throw ValidationError if address IDs do not match', async () => {
      const { logger } = await import('./logger.server');
      const userProfile = { address_id: 123 } as UserProfile;

      await expect(userService.getUserAddress(userProfile, 456, logger)).rejects.toThrow(
        ValidationError,
      );
      expect(mocks.mockGetAddressById).not.toHaveBeenCalled();
    });
  });

  describe('deleteUserAsAdmin', () => {
    it('should delete user if deleter is not the target', async () => {
      const { logger } = await import('./logger.server');
      const deleterId = 'admin-1';
      const targetId = 'user-2';

      await userService.deleteUserAsAdmin(deleterId, targetId, logger);

      expect(mocks.mockDeleteUserById).toHaveBeenCalledWith(targetId, logger);
    });

    it('should throw ValidationError if admin tries to delete themselves', async () => {
      const { logger } = await import('./logger.server');
      const adminId = 'admin-1';

      await expect(userService.deleteUserAsAdmin(adminId, adminId, logger)).rejects.toThrow(
        ValidationError,
      );
      expect(mocks.mockDeleteUserById).not.toHaveBeenCalled();
    });

    it('should wrap generic errors in a DatabaseError', async () => {
      const { logger } = await import('./logger.server');
      const deleterId = 'admin-1';
      const targetId = 'user-2';
      const genericError = new Error('DB write failed');

      mocks.mockDeleteUserById.mockRejectedValue(genericError);

      await expect(userService.deleteUserAsAdmin(deleterId, targetId, logger)).rejects.toThrow(DatabaseError);
      expect(logger.error).toHaveBeenCalledWith(expect.any(Object), `Admin failed to delete user account: ${genericError.message}`);
    });
  });
});
