import { Test, type TestingModule } from '@nestjs/testing';
import {
  ConflictException,
  NotFoundException,
  BadRequestException,
  ForbiddenException,
} from '@nestjs/common';
import { getRepositoryToken } from '@nestjs/typeorm';
import { ClsService } from 'nestjs-cls';
import { RoleService } from './role.service';
import { RoleHierarchyService } from './role-hierarchy.service';
import { RoleRepository } from './repositories/role.repository';
import { RolePermissionEntity, MASTER_MODULES_VIEW_DEFAULT } from '../../entities/role-permission.entity';
import { UserEntity } from '../../entities/user.entity';
import { RedisService } from '../../redis/redis.service';
import { CLS_TENANT_ID, CLS_USER_ID } from '../../common/cls/cls.constants';

describe('RoleService', () => {
  let service: RoleService;

  const mockRoleRepo = {
    findAllWithPermissions: jest.fn(),
    findPaginated: jest.fn(),
    findByIdWithPermissions: jest.fn(),
    findByName: jest.fn(),
    save: jest.fn(),
    update: jest.fn(),
    softDelete: jest.fn(),
  };

  const mockRolePermRepo = {
    createQueryBuilder: jest.fn(),
    save: jest.fn(),
  };

  const mockUserRepo = {
    findOne: jest.fn(),
  };

  const mockRedisService = {
    invalidateByPattern: jest.fn(),
  };

  const mockCls = {
    get: jest.fn(),
  };

  const mockHierarchyService = {
    ensureRoot: jest.fn(),
    detach: jest.fn(),
    setParent: jest.fn(),
  };

  beforeEach(async () => {
    const module: TestingModule = await Test.createTestingModule({
      providers: [
        RoleService,
        { provide: RoleRepository, useValue: mockRoleRepo },
        { provide: getRepositoryToken(RolePermissionEntity), useValue: mockRolePermRepo },
        { provide: getRepositoryToken(UserEntity), useValue: mockUserRepo },
        { provide: RedisService, useValue: mockRedisService },
        { provide: ClsService, useValue: mockCls },
        { provide: RoleHierarchyService, useValue: mockHierarchyService },
      ],
    }).compile();

    service = module.get<RoleService>(RoleService);
    mockCls.get.mockImplementation((key: string) => {
      if (key === CLS_TENANT_ID) return 'tenant-1';
      if (key === CLS_USER_ID) return 'user-1';
      return null;
    });
  });

  afterEach(() => jest.clearAllMocks());

  describe('findAll', () => {
    it('should return all roles mapped to response', async () => {
      const roles = [
        {
          id: 'r1',
          name: 'Admin',
          description: 'Full access',
          is_system: true,
          created_at: new Date(),
          updated_at: new Date(),
          permissions: [],
        },
      ];
      mockRoleRepo.findPaginated.mockResolvedValue({
        items: roles,
        meta: { total: 1, page: 1, limit: 10, totalPages: 1 },
      });

      const result = await service.findAll();

      expect(result.items).toHaveLength(1);
      expect(result.items[0].name).toBe('Admin');
    });

    it('should return empty paginated result when no roles exist', async () => {
      mockRoleRepo.findPaginated.mockResolvedValue({
        items: [],
        meta: { total: 0, page: 1, limit: 10, totalPages: 0 },
      });
      const result = await service.findAll();
      expect(result.items).toEqual([]);
    });
  });

  describe('findById', () => {
    it('should return role response when found', async () => {
      const role = {
        id: 'r1',
        name: 'Admin',
        description: null,
        is_system: true,
        created_at: new Date(),
        updated_at: new Date(),
        permissions: [],
      };
      mockRoleRepo.findByIdWithPermissions.mockResolvedValue(role);

      const result = await service.findById('r1');

      expect(result.id).toBe('r1');
    });

    it('should throw NotFoundException when role not found', async () => {
      mockRoleRepo.findByIdWithPermissions.mockResolvedValue(null);

      await expect(service.findById('999')).rejects.toThrow(NotFoundException);
    });
  });

  describe('create', () => {
    const createDto = { name: 'Custom Role', description: 'Custom' };

    it('should create role and return success', async () => {
      mockRoleRepo.findByName.mockResolvedValue(null);
      mockRoleRepo.save.mockResolvedValue({ id: 'new-r1' });

      const result = await service.create(createDto);

      expect(result).toEqual({ id: 'new-r1', message: 'Role created successfully' });
      expect(mockRoleRepo.save).toHaveBeenCalledWith(
        expect.objectContaining({ name: 'Custom Role', is_system: false }),
      );
      expect(mockHierarchyService.ensureRoot).toHaveBeenCalledWith('new-r1');
    });

    it('should give a new role VIEW on the masters moved out of Settings', async () => {
      mockRoleRepo.findByName.mockResolvedValue(null);
      mockRoleRepo.save.mockResolvedValue({ id: 'new-r1' });

      await service.create(createDto);

      const calls = mockRolePermRepo.save.mock.calls;
      const rows = calls[calls.length - 1][0];
      expect(rows.map((r: { module: string }) => r.module).sort()).toEqual(
        [...MASTER_MODULES_VIEW_DEFAULT].sort(),
      );
      expect(rows.every((r: { access_level: string }) => r.access_level === 'view')).toBe(true);
      expect(rows.every((r: { role: { id: string } }) => r.role.id === 'new-r1')).toBe(true);
    });

    it('should throw ConflictException when role name already exists', async () => {
      mockRoleRepo.findByName.mockResolvedValue({ id: 'existing' });

      await expect(service.create(createDto as any)).rejects.toThrow(ConflictException);
    });
  });

  describe('update', () => {
    const existingRole = { id: 'r1', name: 'Custom', permissions: [] };

    it('should update and return success response', async () => {
      mockRoleRepo.findByIdWithPermissions.mockResolvedValue(existingRole);
      mockRoleRepo.update.mockResolvedValue(undefined);

      const result = await service.update('r1', { description: 'Updated' });

      expect(result).toEqual({ id: 'r1', message: 'Role updated successfully' });
    });

    it('should throw NotFoundException when role not found', async () => {
      mockRoleRepo.findByIdWithPermissions.mockResolvedValue(null);

      await expect(service.update('999', {} as any)).rejects.toThrow(NotFoundException);
    });

    it('should throw ConflictException when new name conflicts', async () => {
      mockRoleRepo.findByIdWithPermissions.mockResolvedValue(existingRole);
      mockRoleRepo.findByName.mockResolvedValue({ id: 'other-id' });

      await expect(service.update('r1', { name: 'Taken Name' } as any)).rejects.toThrow(
        ConflictException,
      );
    });
  });

  describe('remove', () => {
    it('should soft delete role and invalidate cache', async () => {
      mockUserRepo.findOne.mockResolvedValue({ role_id: 'different-role' });
      mockRoleRepo.findByIdWithPermissions.mockResolvedValue({ id: 'r1', is_system: false });
      mockRoleRepo.softDelete.mockResolvedValue(undefined);
      mockRedisService.invalidateByPattern.mockResolvedValue(undefined);

      const result = await service.remove('r1');

      expect(result).toEqual({ message: 'Role deleted successfully' });
      expect(mockHierarchyService.detach).toHaveBeenCalledWith('r1');
      expect(mockRedisService.invalidateByPattern).toHaveBeenCalledWith('perm:role:tenant-1:r1');
    });

    it('should throw NotFoundException when role not found', async () => {
      mockUserRepo.findOne.mockResolvedValue({ role_id: 'different-role' });
      mockRoleRepo.findByIdWithPermissions.mockResolvedValue(null);

      await expect(service.remove('999')).rejects.toThrow(NotFoundException);
    });

    it('should throw BadRequestException when deleting a system role', async () => {
      mockUserRepo.findOne.mockResolvedValue({ role_id: 'different-role' });
      mockRoleRepo.findByIdWithPermissions.mockResolvedValue({ id: 'r1', is_system: true });

      await expect(service.remove('r1')).rejects.toThrow(BadRequestException);
    });

    it('should throw ForbiddenException when deleting own role', async () => {
      mockUserRepo.findOne.mockResolvedValue({ role_id: 'r1' });

      await expect(service.remove('r1')).rejects.toThrow(ForbiddenException);
    });
  });

  describe('assignPermissions', () => {
    it('should replace permissions and invalidate cache', async () => {
      mockUserRepo.findOne.mockResolvedValue({ role_id: 'different-role' });
      mockRoleRepo.findByIdWithPermissions.mockResolvedValue({ id: 'r1', permissions: [] });

      const mockQb = {
        delete: jest.fn().mockReturnThis(),
        where: jest.fn().mockReturnThis(),
        execute: jest.fn().mockResolvedValue(undefined),
      };
      mockRolePermRepo.createQueryBuilder.mockReturnValue(mockQb);
      mockRolePermRepo.save.mockResolvedValue([]);
      mockRedisService.invalidateByPattern.mockResolvedValue(undefined);

      const dto = {
        permissions: [{ module: 'settings', access_level: 'view' }],
      };

      const result = await service.assignPermissions('r1', dto as any);

      expect(result).toEqual({ message: 'Permissions assigned successfully' });
      expect(mockRedisService.invalidateByPattern).toHaveBeenCalled();
    });

    it('should throw ForbiddenException when modifying own role permissions', async () => {
      mockUserRepo.findOne.mockResolvedValue({ role_id: 'r1' });

      await expect(service.assignPermissions('r1', { permissions: [] } as any)).rejects.toThrow(
        ForbiddenException,
      );
    });

    it('should throw NotFoundException when role not found', async () => {
      mockUserRepo.findOne.mockResolvedValue({ role_id: 'different-role' });
      mockRoleRepo.findByIdWithPermissions.mockResolvedValue(null);

      await expect(service.assignPermissions('999', { permissions: [] } as any)).rejects.toThrow(
        NotFoundException,
      );
    });

    it('should throw BadRequestException for invalid module name', async () => {
      mockUserRepo.findOne.mockResolvedValue({ role_id: 'different-role' });
      mockRoleRepo.findByIdWithPermissions.mockResolvedValue({ id: 'r1' });

      const dto = {
        permissions: [{ module: 'invalid_module', access_level: 'view' }],
      };

      await expect(service.assignPermissions('r1', dto as any)).rejects.toThrow(
        BadRequestException,
      );
    });
  });

  describe('getPermissionsMatrix', () => {
    it('should return full module matrix with existing permissions filled', async () => {
      const role = {
        id: 'r1',
        permissions: [{ module: 'settings', access_level: 'edit' }],
      };
      mockRoleRepo.findByIdWithPermissions.mockResolvedValue(role);

      const result = await service.getPermissionsMatrix('r1');

      expect(Array.isArray(result)).toBe(true);
      // Each module should have an entry
      const settingsEntry = result.find((r: any) => r.module === 'settings');
      expect(settingsEntry?.access_level).toBe('edit');
    });

    it('should throw NotFoundException when role not found', async () => {
      mockRoleRepo.findByIdWithPermissions.mockResolvedValue(null);

      await expect(service.getPermissionsMatrix('999')).rejects.toThrow(NotFoundException);
    });
  });

  describe('invalidateAllTenantPermissions', () => {
    it('should call redis invalidateByPattern with tenant wildcard', async () => {
      mockRedisService.invalidateByPattern.mockResolvedValue(undefined);

      await service.invalidateAllTenantPermissions();

      expect(mockRedisService.invalidateByPattern).toHaveBeenCalledWith('perm:role:tenant-1:*');
    });
  });
});
