import { Test, type TestingModule } from '@nestjs/testing';
import { UnauthorizedException, ForbiddenException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { getRepositoryToken } from '@nestjs/typeorm';
import * as bcrypt from 'bcrypt';
import { AuthService } from './auth.service';
import { UserEntity } from '../entities/user.entity';
import { TenantEntity } from '../entities/tenant.entity';
import { TenantSettingsEntity } from '../entities/tenant-settings.entity';
import { RolePermissionEntity } from '../entities/role-permission.entity';
import { PasswordResetEntity } from '../entities/password-reset.entity';
import { RefreshTokenEntity } from '../entities/refresh-token.entity';
import { RedisService } from '../redis/redis.service';
import { MailerService } from '../modules/mailer/mailer.service';
import { ConfigService } from '@nestjs/config';

jest.mock('bcrypt');

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

  const mockUserRepo = {
    findOne: jest.fn(),
    update: jest.fn(),
    // Workspace hydration in getProfile() runs a raw SQL against
    // the EntityManager — stub a no-op so tests pass without a DB.
    manager: { query: jest.fn().mockResolvedValue([]) },
  };

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

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

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

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

  const mockRedisService = {
    get: jest.fn(),
    set: jest.fn(),
    del: jest.fn(),
  };

  const mockResetRepo = {
    findOne: jest.fn(),
    update: jest.fn(),
    save: jest.fn(),
  };

  const mockRefreshRepo = {
    findOne: jest.fn(),
    update: jest.fn(),
    save: jest.fn(),
  };

  const mockMailer = {
    isConfigured: jest.fn().mockReturnValue(true),
    send: jest.fn().mockResolvedValue({ messageId: 'm1' }),
  };

  const mockConfigService = {
    // Values used by refresh-token TTL parsing.
    get: jest.fn((key: string) => {
      if (key === 'REFRESH_TOKEN_TTL') return '7d';
      if (key === 'JWT_EXPIRES_IN') return '1h';
      return undefined;
    }),
  };

  beforeEach(async () => {
    const module: TestingModule = await Test.createTestingModule({
      providers: [
        AuthService,
        { provide: getRepositoryToken(UserEntity), useValue: mockUserRepo },
        { provide: getRepositoryToken(TenantEntity), useValue: mockTenantRepo },
        { provide: getRepositoryToken(TenantSettingsEntity), useValue: mockSettingsRepo },
        { provide: getRepositoryToken(RolePermissionEntity), useValue: mockRolePermRepo },
        { provide: getRepositoryToken(PasswordResetEntity), useValue: mockResetRepo },
        { provide: getRepositoryToken(RefreshTokenEntity), useValue: mockRefreshRepo },
        { provide: JwtService, useValue: mockJwtService },
        { provide: RedisService, useValue: mockRedisService },
        { provide: MailerService, useValue: mockMailer },
        { provide: ConfigService, useValue: mockConfigService },
      ],
    }).compile();

    service = module.get<AuthService>(AuthService);
  });

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

  describe('login', () => {
    const loginDto = { email: 'admin@test.com', password: 'Password123' };
    const tenantId = 'tenant-1';

    it('should return auth response on successful login', async () => {
      mockRedisService.get.mockResolvedValue(null); // no lockout
      mockUserRepo.findOne.mockResolvedValue({
        id: 'u1',
        name: 'Admin',
        email: 'admin@test.com',
        password_hash: 'hashed',
        is_active: true,
        tenant_id: tenantId,
        role_id: 'r1',
        role: { name: 'Admin' },
      });
      (bcrypt.compare as jest.Mock).mockResolvedValue(true);
      mockJwtService.sign.mockReturnValue('jwt-token');
      mockTenantRepo.findOne.mockResolvedValue({ name: 'Test DMC' });
      mockSettingsRepo.findOne.mockResolvedValue({
        primary_color: '#10b981',
        secondary_color: '#1e293b',
        logo_url: null,
      });
      mockRolePermRepo.find.mockResolvedValue([{ module: 'users', access_level: 'delete' }]);
      mockRedisService.del.mockResolvedValue(undefined);
      mockUserRepo.update.mockResolvedValue(undefined);

      const result = await service.login(loginDto, tenantId);

      expect(result.access_token).toBe('jwt-token');
      expect(result.user.email).toBe('admin@test.com');
      expect(result.user.permissions).toHaveProperty('users', 'delete');
      expect(result.theme).toBeDefined();
    });

    it('should prefer Settings company_name over the onboarding tenant name', async () => {
      mockRedisService.get.mockResolvedValue(null);
      mockUserRepo.findOne.mockResolvedValue({
        id: 'u1',
        name: 'Admin',
        email: 'admin@test.com',
        password_hash: 'hashed',
        is_active: true,
        tenant_id: tenantId,
        role_id: null,
        role: null,
      });
      (bcrypt.compare as jest.Mock).mockResolvedValue(true);
      mockJwtService.sign.mockReturnValue('jwt-token');
      mockTenantRepo.findOne.mockResolvedValue({ name: 'Onboarding Name' });
      mockSettingsRepo.findOne.mockResolvedValue({
        company_name: 'Settings Co',
        primary_color: '#10b981',
        secondary_color: '#1e293b',
        logo_url: null,
      });

      const result = await service.login(loginDto, tenantId);

      expect(result.user.tenant_name).toBe('Settings Co');
    });

    it('should throw UnauthorizedException when user not found', async () => {
      mockRedisService.get.mockResolvedValue(null);
      mockUserRepo.findOne.mockResolvedValue(null);
      mockRedisService.set.mockResolvedValue(undefined);

      await expect(service.login(loginDto, tenantId)).rejects.toThrow(UnauthorizedException);
    });

    it('should throw UnauthorizedException when account is deactivated', async () => {
      mockRedisService.get.mockResolvedValue(null);
      mockUserRepo.findOne.mockResolvedValue({
        id: 'u1',
        is_active: false,
        password_hash: 'h',
      });

      await expect(service.login(loginDto, tenantId)).rejects.toThrow(UnauthorizedException);
    });

    it('should throw UnauthorizedException when password is invalid', async () => {
      mockRedisService.get.mockResolvedValue(null);
      mockUserRepo.findOne.mockResolvedValue({
        id: 'u1',
        is_active: true,
        password_hash: 'h',
      });
      (bcrypt.compare as jest.Mock).mockResolvedValue(false);
      mockRedisService.set.mockResolvedValue(undefined);

      await expect(service.login(loginDto, tenantId)).rejects.toThrow(UnauthorizedException);
    });

    it('should throw ForbiddenException when account is locked', async () => {
      const futureDate = new Date(Date.now() + 60000).toISOString();
      mockRedisService.get.mockResolvedValue(
        JSON.stringify({ attempts: 5, lockedUntil: futureDate }),
      );

      await expect(service.login(loginDto, tenantId)).rejects.toThrow(ForbiddenException);
    });

    it('should record failed attempt on wrong credentials', async () => {
      mockRedisService.get.mockResolvedValue(null);
      mockUserRepo.findOne.mockResolvedValue(null);
      mockRedisService.set.mockResolvedValue(undefined);

      await expect(service.login(loginDto, tenantId)).rejects.toThrow(UnauthorizedException);
      // recordFailedAttempt reads then writes
      expect(mockRedisService.set).toHaveBeenCalled();
    });
  });

  describe('validateUserFromToken', () => {
    it('should return user when valid token payload', async () => {
      const user = { id: 'u1', is_active: true };
      mockUserRepo.findOne.mockResolvedValue(user);

      const result = await service.validateUserFromToken({
        sub: 'u1',
        email: 'a@b.com',
        tenant_id: 't1',
      });

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

    it('should return null when user not found', async () => {
      mockUserRepo.findOne.mockResolvedValue(null);

      const result = await service.validateUserFromToken({
        sub: 'u1',
        email: 'a@b.com',
        tenant_id: 't1',
      });

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

  describe('getProfile', () => {
    it('should return profile with permissions', async () => {
      mockUserRepo.findOne.mockResolvedValue({
        id: 'u1',
        name: 'Admin',
        email: 'admin@test.com',
        phone: null,
        tenant_id: 't1',
        role_id: 'r1',
        role: { name: 'Admin' },
      });
      mockRolePermRepo.find.mockResolvedValue([{ module: 'users', access_level: 'delete' }]);

      const result = await service.getProfile('u1', 't1');

      expect(result.id).toBe('u1');
      expect(result.permissions).toHaveProperty('users', 'delete');
    });

    it('should throw UnauthorizedException when user not found', async () => {
      mockUserRepo.findOne.mockResolvedValue(null);

      await expect(service.getProfile('u1', 't1')).rejects.toThrow(UnauthorizedException);
    });

    it('should return empty permissions when user has no role', async () => {
      mockUserRepo.findOne.mockResolvedValue({
        id: 'u1',
        name: 'Admin',
        email: 'admin@test.com',
        phone: null,
        tenant_id: 't1',
        role_id: null,
        role: null,
      });

      const result = await service.getProfile('u1', 't1');

      expect(result.permissions).toEqual({});
    });
  });

  // ── Password reset ────────────────────────────────────────────
  // Locks the security contract for forgot-password / reset-password.
  // Per Q4 (security.md A07) the account-enumeration test is the
  // single most important one — a regression here leaks user data.

  describe('forgotPassword', () => {
    const ctx = {
      ip: '1.2.3.4',
      userAgent: 'jest',
      resetUrlBase: 'https://t.dmcops.com/reset-password',
    };

    it('returns silently and does NOT send email when the user does not exist (Q4 enumeration)', async () => {
      mockUserRepo.findOne.mockResolvedValue(null);
      await expect(
        service.forgotPassword({ email: 'ghost@x.com' }, 'tenant-1', ctx),
      ).resolves.toBeUndefined();
      expect(mockMailer.send).not.toHaveBeenCalled();
      expect(mockResetRepo.save).not.toHaveBeenCalled();
    });

    it('returns silently and does NOT send email when the user is deactivated', async () => {
      mockUserRepo.findOne.mockResolvedValue({
        id: 'u1',
        email: 'a@b.com',
        name: 'A',
        is_active: false,
      });
      await expect(
        service.forgotPassword({ email: 'a@b.com' }, 'tenant-1', ctx),
      ).resolves.toBeUndefined();
      expect(mockMailer.send).not.toHaveBeenCalled();
    });

    it('persists a hashed token row and sends the reset email when the user is active', async () => {
      mockUserRepo.findOne.mockResolvedValue({
        id: 'u1',
        email: 'a@b.com',
        name: 'A',
        is_active: true,
      });
      mockSettingsRepo.findOne.mockResolvedValue(null);
      mockTenantRepo.findOne.mockResolvedValue({ name: 'Acme DMC' });
      mockResetRepo.update.mockResolvedValue({ affected: 0 });
      mockResetRepo.save.mockResolvedValue({ id: 'r1' });

      await service.forgotPassword({ email: 'a@b.com' }, 'tenant-1', ctx);

      // Prior unused tokens for this user are soft-invalidated first.
      expect(mockResetRepo.update).toHaveBeenCalledWith(
        expect.objectContaining({ user_id: 'u1' }),
        expect.objectContaining({ is_deleted: true }),
      );
      // New token row saved with token_hash (not raw token).
      const saveCall = mockResetRepo.save.mock.calls[0][0];
      expect(saveCall.user_id).toBe('u1');
      expect(saveCall.tenant_id).toBe('tenant-1');
      expect(saveCall.token_hash).toMatch(/^[0-9a-f]{64}$/); // SHA-256 hex
      expect(saveCall.used_at).toBeNull();
      expect(saveCall.expires_at).toBeInstanceOf(Date);
      // Email sent with reset URL.
      expect(mockMailer.send).toHaveBeenCalledWith(
        expect.objectContaining({
          to: { email: 'a@b.com', name: 'A' },
          subject: expect.stringContaining('Acme DMC'),
          html: expect.stringContaining('https://t.dmcops.com/reset-password?token='),
        }),
      );
    });

    it('uses the Settings company_name in the reset email when set', async () => {
      mockUserRepo.findOne.mockResolvedValue({
        id: 'u1',
        email: 'a@b.com',
        name: 'A',
        is_active: true,
      });
      mockSettingsRepo.findOne.mockResolvedValue({ company_name: 'Settings Co' });
      mockTenantRepo.findOne.mockResolvedValue({ name: 'Onboarding Name' });
      mockResetRepo.update.mockResolvedValue({ affected: 0 });
      mockResetRepo.save.mockResolvedValue({ id: 'r1' });

      await service.forgotPassword({ email: 'a@b.com' }, 'tenant-1', ctx);

      expect(mockSettingsRepo.findOne).toHaveBeenCalledWith(
        expect.objectContaining({
          where: expect.objectContaining({ tenant_id: 'tenant-1' }),
        }),
      );
      expect(mockMailer.send).toHaveBeenCalledWith(
        expect.objectContaining({
          subject: expect.stringContaining('Settings Co'),
          fromName: 'Settings Co',
        }),
      );
    });

    it('still resolves silently when the mailer fails (Q4 — no leakage)', async () => {
      mockUserRepo.findOne.mockResolvedValue({
        id: 'u1',
        email: 'a@b.com',
        name: 'A',
        is_active: true,
      });
      mockTenantRepo.findOne.mockResolvedValue({ name: 'Acme' });
      mockResetRepo.save.mockResolvedValue({ id: 'r1' });
      mockMailer.send.mockRejectedValueOnce(new Error('Brevo down'));
      await expect(
        service.forgotPassword({ email: 'a@b.com' }, 'tenant-1', ctx),
      ).resolves.toBeUndefined();
    });
  });

  describe('resetPassword', () => {
    const ctx = { ip: '1.2.3.4', userAgent: 'jest' };

    it('rejects an unknown token with a generic error', async () => {
      mockResetRepo.findOne.mockResolvedValue(null);
      await expect(
        service.resetPassword({ token: 'bogus', new_password: 'Newpass123' }, ctx),
      ).rejects.toThrow('This reset link is invalid or has expired.');
    });

    it('rejects a token whose user is now deactivated', async () => {
      mockResetRepo.findOne.mockResolvedValue({ id: 'r1', user_id: 'u1', tenant_id: 't1' });
      mockUserRepo.findOne.mockResolvedValue({ id: 'u1', is_active: false });
      await expect(
        service.resetPassword({ token: 'x', new_password: 'Newpass123' }, ctx),
      ).rejects.toThrow('This reset link is invalid or has expired.');
    });

    it('hashes the new password and marks the token used on success', async () => {
      mockResetRepo.findOne.mockResolvedValue({ id: 'r1', user_id: 'u1', tenant_id: 't1' });
      mockUserRepo.findOne.mockResolvedValue({
        id: 'u1',
        tenant_id: 't1',
        email: 'a@b.com',
        is_active: true,
      });
      (bcrypt.hash as jest.Mock).mockResolvedValue('hashed-pw');
      await service.resetPassword({ token: 'x', new_password: 'Newpass123' }, ctx);
      // Password updated with the bcrypt hash, not the raw.
      expect(mockUserRepo.update).toHaveBeenCalledWith(
        'u1',
        expect.objectContaining({ password_hash: 'hashed-pw' }),
      );
      // Token marked single-used.
      expect(mockResetRepo.update).toHaveBeenCalledWith(
        'r1',
        expect.objectContaining({ used_at: expect.any(Date) }),
      );
      // Lockout cleared.
      expect(mockRedisService.del).toHaveBeenCalled();
    });
  });

  // ── Tenant isolation guard (per backend.md rule) ──────────────
  it('reset query is scoped by tenant via the row, not the request', async () => {
    // Token row says tenant=t1; user lookup MUST use that tenant_id,
    // not anything from the request. This is the cross-tenant
    // redemption defense — a token from tenant A should never be
    // redeemable as a user in tenant B even if the request hits the
    // wrong subdomain.
    mockResetRepo.findOne.mockResolvedValue({ id: 'r1', user_id: 'u1', tenant_id: 't-from-token' });
    mockUserRepo.findOne.mockResolvedValue({
      id: 'u1',
      tenant_id: 't-from-token',
      email: 'a@b.com',
      is_active: true,
    });
    (bcrypt.hash as jest.Mock).mockResolvedValue('h');
    await service.resetPassword(
      { token: 'x', new_password: 'Newpass123' },
      { ip: null, userAgent: null },
    );
    expect(mockUserRepo.findOne).toHaveBeenCalledWith(
      expect.objectContaining({ where: expect.objectContaining({ tenant_id: 't-from-token' }) }),
    );
  });
});
