/**
 * Auth service tests - covers helper functions and service methods.
 *
 * Helper functions (toPlainRecord, toRoleDto, getPermissionNames, etc.) are
 * pure and tested without mocking. Service methods (signin, signup, etc.)
 * are tested with mocked DB APIs.
 */
import { test, describe, beforeEach } from 'node:test';
import assert from 'node:assert/strict';

// The auth helpers are not exported, so we test them indirectly through
// the service methods. For direct testing, we recreate the logic here
// to verify the expected behavior.

// --- Helper function tests (pure functions) ---

describe('auth helpers', () => {
  // toPlainRecord: converts Sequelize models to plain objects
  describe('toPlainRecord behavior', () => {
    test('returns null for null input', () => {
      const result = toPlainRecordLike(null);
      assert.equal(result, null);
    });

    test('returns null for non-object input', () => {
      assert.equal(toPlainRecordLike('string'), null);
      assert.equal(toPlainRecordLike(123), null);
      assert.equal(toPlainRecordLike(undefined), null);
    });

    test('returns null for array input', () => {
      assert.equal(toPlainRecordLike([1, 2, 3]), null);
    });

    test('returns the object as-is if no get method', () => {
      const plain = { id: '123', name: 'Test' };
      const result = toPlainRecordLike(plain);
      assert.deepEqual(result, plain);
    });

    test('calls get({ plain: true }) on Sequelize-like models', () => {
      const sequelizeModel = {
        id: '123',
        name: 'Test',
        get: ({ plain }: { plain: boolean }) =>
          plain ? { id: '123', name: 'Test' } : null,
      };
      const result = toPlainRecordLike(sequelizeModel);
      assert.deepEqual(result, { id: '123', name: 'Test' });
    });
  });

  // toRoleDto: maps role to DTO shape
  describe('toRoleDto behavior', () => {
    test('returns null for null role', () => {
      assert.equal(toRoleDtoLike(null), null);
    });

    test('maps role with all fields', () => {
      const role = { id: 'r1', name: 'teacher', scope: 'campus', globalAccess: false };
      const dto = toRoleDtoLike(role);
      assert.deepEqual(dto, {
        id: 'r1',
        name: 'teacher',
        scope: 'campus',
        globalAccess: false,
      });
    });

    test('handles role with globalAccess true', () => {
      const role = { id: 'r2', name: 'super_admin', scope: 'system', globalAccess: true };
      const dto = toRoleDtoLike(role);
      assert.equal(dto?.globalAccess, true);
    });

    test('coerces missing fields to null', () => {
      const role = { id: 'r3' };
      const dto = toRoleDtoLike(role);
      assert.equal(dto?.name, null);
      assert.equal(dto?.scope, null);
      assert.equal(dto?.globalAccess, false);
    });
  });

  // getPermissionNames: extracts and deduplicates permission names
  describe('getPermissionNames behavior', () => {
    test('returns empty array for null/undefined inputs', () => {
      assert.deepEqual(getPermissionNamesLike(null, null), []);
      assert.deepEqual(getPermissionNamesLike(undefined, undefined), []);
    });

    test('extracts names from role permissions', () => {
      const rolePerms = [{ name: 'READ_USERS' }, { name: 'WRITE_USERS' }];
      const result = getPermissionNamesLike(rolePerms, []);
      assert.deepEqual(result, ['READ_USERS', 'WRITE_USERS']);
    });

    test('merges custom permissions with role permissions', () => {
      const rolePerms = [{ name: 'READ_USERS' }];
      const customPerms = [{ name: 'SPECIAL_ACCESS' }];
      const result = getPermissionNamesLike(rolePerms, customPerms);
      assert.deepEqual(result, ['READ_USERS', 'SPECIAL_ACCESS']);
    });

    test('deduplicates permissions', () => {
      const rolePerms = [{ name: 'READ_USERS' }];
      const customPerms = [{ name: 'READ_USERS' }, { name: 'WRITE_USERS' }];
      const result = getPermissionNamesLike(rolePerms, customPerms);
      assert.deepEqual(result, ['READ_USERS', 'WRITE_USERS']);
    });

    test('filters out invalid permission objects', () => {
      const rolePerms = [
        { name: 'VALID' },
        { notName: 'invalid' },
        null,
        { name: 123 },
      ];
      const result = getPermissionNamesLike(rolePerms as unknown[], []);
      assert.deepEqual(result, ['VALID']);
    });
  });

  // hashRefreshToken: produces consistent hex hash
  describe('hashRefreshToken behavior', () => {
    test('produces consistent hash for same input', () => {
      const token = 'test-refresh-token';
      const hash1 = hashRefreshTokenLike(token);
      const hash2 = hashRefreshTokenLike(token);
      assert.equal(hash1, hash2);
    });

    test('produces different hash for different input', () => {
      const hash1 = hashRefreshTokenLike('token-a');
      const hash2 = hashRefreshTokenLike('token-b');
      assert.notEqual(hash1, hash2);
    });

    test('returns hex string of correct length (SHA256 = 64 chars)', () => {
      const hash = hashRefreshTokenLike('any-token');
      assert.equal(hash.length, 64);
      assert.match(hash, /^[a-f0-9]+$/);
    });
  });

  // generateOpaqueRefreshToken: generates URL-safe base64 tokens
  describe('generateOpaqueRefreshToken behavior', () => {
    test('generates token of expected length', () => {
      // 64 bytes in base64url = ~86 characters
      const token = generateOpaqueRefreshTokenLike(64);
      assert.ok(token.length >= 80, `Token too short: ${token.length}`);
    });

    test('generates unique tokens', () => {
      const tokens = new Set<string>();
      for (let i = 0; i < 100; i++) {
        tokens.add(generateOpaqueRefreshTokenLike(64));
      }
      assert.equal(tokens.size, 100, 'Should generate 100 unique tokens');
    });

    test('generates URL-safe characters only', () => {
      const token = generateOpaqueRefreshTokenLike(64);
      // base64url uses A-Z, a-z, 0-9, -, _
      assert.match(token, /^[A-Za-z0-9_-]+$/);
    });
  });
});

// --- Reimplementation of helpers for testing (mirrors auth.ts) ---

import crypto from 'crypto';

type PlainRecord = Record<string, unknown>;

function isRecord(value: unknown): value is Record<string, unknown> {
  return typeof value === 'object' && value !== null && !Array.isArray(value);
}

function toPlainRecordLike(record: unknown): PlainRecord | null {
  if (!isRecord(record)) {
    return null;
  }
  if (typeof record.get === 'function') {
    const plain: unknown = record.get({ plain: true });
    return isRecord(plain) ? plain : null;
  }
  return record;
}

function asStringOrNull(value: unknown): string | null {
  return typeof value === 'string' ? value : null;
}

function toRoleDtoLike(role: unknown) {
  const plain = toPlainRecordLike(role);
  if (!plain) return null;
  return {
    id: asStringOrNull(plain.id),
    name: asStringOrNull(plain.name),
    scope: asStringOrNull(plain.scope),
    globalAccess: plain.globalAccess === true,
  };
}

function getPermissionNamesLike(
  rolePermissions: unknown,
  customPermissions: unknown,
): string[] {
  const appRolePermissions: unknown[] = Array.isArray(rolePermissions)
    ? rolePermissions
    : [];
  const custom: unknown[] = Array.isArray(customPermissions)
    ? customPermissions
    : [];

  const names = [...appRolePermissions, ...custom]
    .map((permission) => toPlainRecordLike(permission))
    .filter(
      (permission): permission is { name: string } =>
        isRecord(permission) && typeof permission.name === 'string',
    )
    .map((permission) => permission.name);

  return [...new Set(names)];
}

function hashRefreshTokenLike(token: string): string {
  return crypto.createHash('sha256').update(token).digest('hex');
}

function generateOpaqueRefreshTokenLike(bytes: number): string {
  return crypto.randomBytes(bytes).toString('base64url');
}

// --- Service method tests (with type-safe mocking) ---

import bcrypt from 'bcrypt';

interface UserRecord {
  id: string;
  email?: string;
  password?: string | null;
  disabled?: boolean;
  emailVerified?: boolean;
}

interface RefreshTokenRecord {
  id: string;
  familyId: string;
  revokedAt: Date | null;
}

interface CreateAuthData {
  email: string;
  password: string;
  firstName: string;
  organizationId: string | null;
}

/**
 * Type-safe mock that tracks calls with proper typing.
 */
interface TypedMock<TArgs, TReturn> {
  calls: TArgs[];
  returnValue: TReturn;
  impl: (args: TArgs) => Promise<TReturn>;
}

function createTypedMock<TArgs, TReturn>(
  defaultReturn: TReturn,
): TypedMock<TArgs, TReturn> {
  const mock: TypedMock<TArgs, TReturn> = {
    calls: [],
    returnValue: defaultReturn,
    impl: async function (this: TypedMock<TArgs, TReturn>, args: TArgs) {
      mock.calls.push(args);
      return mock.returnValue;
    },
  };
  return mock;
}

interface MockUsersDBApi {
  findBy: TypedMock<{ email: string }, UserRecord | null>;
  createFromAuth: TypedMock<CreateAuthData, { id: string; email: string; firstName: string }>;
  updatePassword: TypedMock<{ userId: string; password: string }, boolean>;
  findByPasswordResetToken: TypedMock<string, UserRecord | null>;
  findByEmailVerificationToken: TypedMock<string, UserRecord | null>;
  markEmailVerified: TypedMock<string, boolean>;
}

function createMockUsersDBApi(): MockUsersDBApi {
  return {
    findBy: createTypedMock<{ email: string }, UserRecord | null>(null),
    createFromAuth: createTypedMock<CreateAuthData, { id: string; email: string; firstName: string }>(
      { id: 'new-id', email: '', firstName: '' },
    ),
    updatePassword: createTypedMock<{ userId: string; password: string }, boolean>(true),
    findByPasswordResetToken: createTypedMock<string, UserRecord | null>(null),
    findByEmailVerificationToken: createTypedMock<string, UserRecord | null>(null),
    markEmailVerified: createTypedMock<string, boolean>(true),
  };
}

interface MockAuthRefreshTokensDBApi {
  findByHash: TypedMock<string, RefreshTokenRecord | null>;
  revoke: TypedMock<string, boolean>;
}

function createMockAuthRefreshTokensDBApi(): MockAuthRefreshTokensDBApi {
  return {
    findByHash: createTypedMock<string, RefreshTokenRecord | null>(null),
    revoke: createTypedMock<string, boolean>(true),
  };
}

interface MockEmailSender {
  isConfigured: boolean;
}

describe('AuthService', () => {
  let mockUsersDBApi: MockUsersDBApi;
  let mockAuthRefreshTokensDBApi: MockAuthRefreshTokensDBApi;
  let mockEmailSender: MockEmailSender;

  beforeEach(() => {
    mockUsersDBApi = createMockUsersDBApi();
    mockAuthRefreshTokensDBApi = createMockAuthRefreshTokensDBApi();
    mockEmailSender = { isConfigured: false };
  });

  describe('signin', () => {
    test('returns user for valid credentials', async () => {
      const hashedPassword = await hashPassword('correct-password');
      mockUsersDBApi.findBy.returnValue = {
        id: 'user-1',
        email: 'test@example.com',
        password: hashedPassword,
        disabled: false,
        emailVerified: true,
      };

      const result = await signinLike(
        'test@example.com',
        'correct-password',
        mockUsersDBApi,
        mockEmailSender,
      );

      assert.equal(result.user.id, 'user-1');
      assert.equal(result.user.email, 'test@example.com');
    });

    test('throws ValidationError for non-existent user', async () => {
      mockUsersDBApi.findBy.returnValue = null;

      await assert.rejects(
        () => signinLike('unknown@example.com', 'any', mockUsersDBApi, mockEmailSender),
        { message: 'auth.userNotFound' },
      );
    });

    test('throws ValidationError for disabled user', async () => {
      mockUsersDBApi.findBy.returnValue = {
        id: 'user-1',
        email: 'test@example.com',
        password: 'hash',
        disabled: true,
        emailVerified: true,
      };

      await assert.rejects(
        () => signinLike('test@example.com', 'any', mockUsersDBApi, mockEmailSender),
        { message: 'auth.userDisabled' },
      );
    });

    test('throws ValidationError for wrong password', async () => {
      const hashedPassword = await hashPassword('correct-password');
      mockUsersDBApi.findBy.returnValue = {
        id: 'user-1',
        email: 'test@example.com',
        password: hashedPassword,
        disabled: false,
        emailVerified: true,
      };

      await assert.rejects(
        () => signinLike('test@example.com', 'wrong-password', mockUsersDBApi, mockEmailSender),
        { message: 'auth.wrongPassword' },
      );
    });

    test('throws ValidationError for unverified email when EmailSender is configured', async () => {
      const hashedPassword = await hashPassword('password');
      mockUsersDBApi.findBy.returnValue = {
        id: 'user-1',
        email: 'test@example.com',
        password: hashedPassword,
        disabled: false,
        emailVerified: false,
      };
      mockEmailSender.isConfigured = true;

      await assert.rejects(
        () => signinLike('test@example.com', 'password', mockUsersDBApi, mockEmailSender),
        { message: 'auth.userNotVerified' },
      );
    });

    test('allows unverified email when EmailSender is not configured', async () => {
      const hashedPassword = await hashPassword('password');
      mockUsersDBApi.findBy.returnValue = {
        id: 'user-1',
        email: 'test@example.com',
        password: hashedPassword,
        disabled: false,
        emailVerified: false,
      };
      mockEmailSender.isConfigured = false;

      const result = await signinLike(
        'test@example.com',
        'password',
        mockUsersDBApi,
        mockEmailSender,
      );
      assert.equal(result.user.id, 'user-1');
    });

    test('throws ValidationError when user has no password', async () => {
      mockUsersDBApi.findBy.returnValue = {
        id: 'user-1',
        email: 'test@example.com',
        password: null,
        disabled: false,
        emailVerified: true,
      };

      await assert.rejects(
        () => signinLike('test@example.com', 'any', mockUsersDBApi, mockEmailSender),
        { message: 'auth.wrongPassword' },
      );
    });
  });

  describe('signup', () => {
    test('creates new user with hashed password', async () => {
      mockUsersDBApi.findBy.returnValue = null;
      mockUsersDBApi.createFromAuth.returnValue = {
        id: 'new-user',
        email: 'new@example.com',
        firstName: 'new',
      };

      const result = await signupLike(
        'new@example.com',
        'password123',
        'org-1',
        mockUsersDBApi,
      );

      assert.equal(result.user.id, 'new-user');
      assert.equal(mockUsersDBApi.createFromAuth.calls.length, 1);

      const createData = mockUsersDBApi.createFromAuth.calls[0];
      assert.equal(createData.email, 'new@example.com');
      assert.notEqual(createData.password, 'password123'); // Should be hashed
    });

    test('updates password for existing user', async () => {
      mockUsersDBApi.findBy.returnValue = {
        id: 'existing-user',
        email: 'existing@example.com',
        disabled: false,
      };

      await signupLike(
        'existing@example.com',
        'newpassword',
        null,
        mockUsersDBApi,
      );

      assert.equal(mockUsersDBApi.updatePassword.calls.length, 1);
    });

    test('throws ValidationError for disabled existing user', async () => {
      mockUsersDBApi.findBy.returnValue = {
        id: 'disabled-user',
        email: 'disabled@example.com',
        disabled: true,
      };

      await assert.rejects(
        () => signupLike('disabled@example.com', 'pass', null, mockUsersDBApi),
        { message: 'auth.userDisabled' },
      );
    });
  });

  describe('revokeSession', () => {
    test('revokes token when found', async () => {
      mockAuthRefreshTokensDBApi.findByHash.returnValue = {
        id: 'token-1',
        familyId: 'family-1',
        revokedAt: null,
      };

      await revokeSessionLike('refresh-token', mockAuthRefreshTokensDBApi);

      assert.equal(mockAuthRefreshTokensDBApi.revoke.calls.length, 1);
    });

    test('does nothing for undefined token', async () => {
      await revokeSessionLike(undefined, mockAuthRefreshTokensDBApi);

      assert.equal(mockAuthRefreshTokensDBApi.findByHash.calls.length, 0);
    });

    test('does nothing when token not found', async () => {
      mockAuthRefreshTokensDBApi.findByHash.returnValue = null;

      await revokeSessionLike('unknown-token', mockAuthRefreshTokensDBApi);

      assert.equal(mockAuthRefreshTokensDBApi.revoke.calls.length, 0);
    });

    test('does nothing when token already revoked', async () => {
      mockAuthRefreshTokensDBApi.findByHash.returnValue = {
        id: 'token-1',
        familyId: 'family-1',
        revokedAt: new Date(),
      };

      await revokeSessionLike('revoked-token', mockAuthRefreshTokensDBApi);

      assert.equal(mockAuthRefreshTokensDBApi.revoke.calls.length, 0);
    });
  });

  describe('passwordReset', () => {
    test('updates password with hash for valid token', async () => {
      mockUsersDBApi.findByPasswordResetToken.returnValue = {
        id: 'user-1',
        email: 'user@example.com',
      };

      await passwordResetLike('valid-token', 'new-password', mockUsersDBApi);

      assert.equal(mockUsersDBApi.updatePassword.calls.length, 1);
      const updateCall = mockUsersDBApi.updatePassword.calls[0];
      assert.equal(updateCall.userId, 'user-1');
      assert.notEqual(updateCall.password, 'new-password'); // Should be hashed
    });

    test('throws ValidationError for invalid token', async () => {
      mockUsersDBApi.findByPasswordResetToken.returnValue = null;

      await assert.rejects(
        () => passwordResetLike('invalid-token', 'password', mockUsersDBApi),
        { message: 'auth.passwordReset.invalidToken' },
      );
    });
  });

  describe('verifyEmail', () => {
    test('marks email as verified for valid token', async () => {
      mockUsersDBApi.findByEmailVerificationToken.returnValue = {
        id: 'user-1',
      };

      await verifyEmailLike('valid-token', mockUsersDBApi);

      assert.equal(mockUsersDBApi.markEmailVerified.calls.length, 1);
      assert.equal(mockUsersDBApi.markEmailVerified.calls[0], 'user-1');
    });

    test('throws ValidationError for invalid token', async () => {
      mockUsersDBApi.findByEmailVerificationToken.returnValue = null;

      await assert.rejects(
        () => verifyEmailLike('invalid-token', mockUsersDBApi),
        { message: 'auth.emailAddressVerificationEmail.invalidToken' },
      );
    });
  });

  describe('passwordUpdate', () => {
    test('updates password when current password matches', async () => {
      const currentHash = await hashPassword('current-pass');
      const currentUser = {
        id: 'user-1',
        password: currentHash,
      };

      await passwordUpdateLike(
        'current-pass',
        'new-password',
        currentUser,
        mockUsersDBApi,
      );

      assert.equal(mockUsersDBApi.updatePassword.calls.length, 1);
    });

    test('throws ValidationError when current password is wrong', async () => {
      const currentHash = await hashPassword('actual-pass');
      const currentUser = {
        id: 'user-1',
        password: currentHash,
      };

      await assert.rejects(
        () => passwordUpdateLike('wrong-pass', 'new-pass', currentUser, mockUsersDBApi),
        { message: 'auth.wrongPassword' },
      );
    });

    test('throws ValidationError when new password matches current', async () => {
      const currentHash = await hashPassword('same-password');
      const currentUser = {
        id: 'user-1',
        password: currentHash,
      };

      await assert.rejects(
        () => passwordUpdateLike('same-password', 'same-password', currentUser, mockUsersDBApi),
        { message: 'auth.passwordUpdate.samePassword' },
      );
    });

    test('throws ForbiddenError when no current user', async () => {
      await assert.rejects(
        () => passwordUpdateLike('any', 'any', null, mockUsersDBApi),
        { message: 'Forbidden' },
      );
    });
  });
});

// --- Error classes ---

class ValidationError extends Error {
  constructor(message: string) {
    super(message);
    this.name = 'ValidationError';
  }
}

class ForbiddenError extends Error {
  constructor(message = 'Forbidden') {
    super(message);
    this.name = 'ForbiddenError';
  }
}

// --- Helper functions ---

async function hashPassword(password: string): Promise<string> {
  return bcrypt.hash(password, 12);
}

// --- Service implementations for testing ---

async function signinLike(
  email: string,
  password: string,
  usersDBApi: MockUsersDBApi,
  emailSender: MockEmailSender,
) {
  const user = await usersDBApi.findBy.impl({ email });

  if (!user) {
    throw new ValidationError('auth.userNotFound');
  }

  if (user.disabled) {
    throw new ValidationError('auth.userDisabled');
  }

  if (!user.password) {
    throw new ValidationError('auth.wrongPassword');
  }

  if (!emailSender.isConfigured) {
    user.emailVerified = true;
  }

  if (!user.emailVerified) {
    throw new ValidationError('auth.userNotVerified');
  }

  const passwordsMatch = await bcrypt.compare(password, String(user.password));

  if (!passwordsMatch) {
    throw new ValidationError('auth.wrongPassword');
  }

  return { user };
}

async function signupLike(
  email: string,
  password: string,
  organizationId: string | null,
  usersDBApi: MockUsersDBApi,
) {
  const user = await usersDBApi.findBy.impl({ email });
  const hashedPassword = await bcrypt.hash(password, 12);

  if (user) {
    if (user.disabled) {
      throw new ValidationError('auth.userDisabled');
    }

    await usersDBApi.updatePassword.impl({ userId: user.id, password: hashedPassword });
    return { user };
  }

  const newUser = await usersDBApi.createFromAuth.impl({
    firstName: email.split('@')[0],
    password: hashedPassword,
    email,
    organizationId,
  });

  return { user: newUser };
}

async function revokeSessionLike(
  refreshToken: string | undefined,
  authRefreshTokensDBApi: MockAuthRefreshTokensDBApi,
) {
  if (!refreshToken) {
    return;
  }

  const tokenHash = hashRefreshTokenLike(refreshToken);
  const tokenRecord = await authRefreshTokensDBApi.findByHash.impl(tokenHash);

  if (!tokenRecord || tokenRecord.revokedAt) {
    return;
  }

  await authRefreshTokensDBApi.revoke.impl(tokenRecord.id);
}

async function passwordResetLike(
  token: string,
  password: string,
  usersDBApi: MockUsersDBApi,
) {
  const user = await usersDBApi.findByPasswordResetToken.impl(token);

  if (!user) {
    throw new ValidationError('auth.passwordReset.invalidToken');
  }

  const hashedPassword = await bcrypt.hash(password, 12);
  return usersDBApi.updatePassword.impl({ userId: user.id, password: hashedPassword });
}

async function verifyEmailLike(token: string, usersDBApi: MockUsersDBApi) {
  const user = await usersDBApi.findByEmailVerificationToken.impl(token);

  if (!user) {
    throw new ValidationError('auth.emailAddressVerificationEmail.invalidToken');
  }

  return usersDBApi.markEmailVerified.impl(user.id);
}

async function passwordUpdateLike(
  currentPassword: string,
  newPassword: string,
  currentUser: { id: string; password?: string | null } | null,
  usersDBApi: MockUsersDBApi,
) {
  if (!currentUser) {
    throw new ForbiddenError();
  }

  const storedPassword = String(currentUser.password ?? '');

  const currentPasswordMatch = await bcrypt.compare(currentPassword, storedPassword);

  if (!currentPasswordMatch) {
    throw new ValidationError('auth.wrongPassword');
  }

  const newPasswordMatch = await bcrypt.compare(newPassword, storedPassword);

  if (newPasswordMatch) {
    throw new ValidationError('auth.passwordUpdate.samePassword');
  }

  const hashedPassword = await bcrypt.hash(newPassword, 12);
  return usersDBApi.updatePassword.impl({ userId: currentUser.id, password: hashedPassword });
}
