import { isRecord } from '@/shared/object';
import logger from '@/shared/logger';
import crypto from 'crypto';
import bcrypt from 'bcrypt';
import type { Request } from 'express';
import UsersDBApi from '@/db/api/users';
import AuthRefreshTokensDBApi from '@/db/api/auth_refresh_tokens';
import ValidationError from '@/shared/errors/validation';
import ForbiddenError from '@/shared/errors/forbidden';
import EmailAddressVerificationEmail from '@/services/email/list/addressVerification';
import InvitationEmail from '@/services/email/list/invitation';
import PasswordResetEmail from '@/services/email/list/passwordReset';
import EmailSender from '@/services/email';
import config from '@/shared/config';
import { jwtSign } from '@/shared/jwt';
import db from '@/db/models';
import type {
  AuthenticatedUser,
  CurrentUser,
  DbApiOptions,
} from '@/db/api/types';
import type {
  SessionOptions,
  RoleDto,
  OrganizationDto,
  CampusDto,
  StaffProfileDto,
} from '@/services/auth.types';

type PlainRecord = Record<string, unknown>;

/** Minimal user shape needed to mint a session (accepts a record or instance). */
type SessionUser = Pick<AuthenticatedUser, 'id' | 'email' | 'organizationId'>;

type CreateFromAuthData = Parameters<typeof UsersDBApi.createFromAuth>[0];
type UpdateProfileData = Parameters<typeof UsersDBApi.update>[1];

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

function asId(value: unknown): string {
  return typeof value === 'string' ? value : '';
}

/** Returns the plain attributes of a Sequelize instance, or the record itself. */
function toPlainRecord(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 toRoleDto(role: unknown): RoleDto | null {
  const plain = toPlainRecord(role);
  if (!plain) return null;
  return {
    id: asStringOrNull(plain.id),
    name: asStringOrNull(plain.name),
    scope: asStringOrNull(plain.scope),
    globalAccess: plain.globalAccess === true,
  };
}

function toOrganizationDto(organization: unknown): OrganizationDto | null {
  const plain = toPlainRecord(organization);
  const id = asStringOrNull(plain?.id);
  if (!plain || !id) return null;
  return {
    id,
    name: asStringOrNull(plain.name),
  };
}

function toCampusDto(campus: unknown): CampusDto | null {
  const plain = toPlainRecord(campus);
  const id = asStringOrNull(plain?.id);
  if (!plain || !id) return null;
  return {
    id,
    name: asStringOrNull(plain.name),
    code: asStringOrNull(plain.code),
  };
}

function toStaffProfileDto(staffProfile: unknown): StaffProfileDto | null {
  const plain = toPlainRecord(staffProfile);
  const id = asStringOrNull(plain?.id);
  if (!plain || !id) return null;
  return {
    id,
    employee_number: asStringOrNull(plain.employee_number),
    job_title: asStringOrNull(plain.job_title),
    staff_type: asStringOrNull(plain.staff_type),
    status: asStringOrNull(plain.status),
    organizationId: asStringOrNull(plain.organizationId),
    campusId: asStringOrNull(plain.campusId),
    userId: asStringOrNull(plain.userId),
  };
}

function getPermissionNames(
  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) => toPlainRecord(permission))
    .filter(
      (permission): permission is { name: string } =>
        isRecord(permission) && typeof permission.name === 'string',
    )
    .map((permission) => permission.name);

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

function getTokenPayload(user: SessionUser) {
  return {
    user: {
      id: user.id,
      email: user.email,
    },
  };
}

function getRequestUserAgent(req: Request): string | null {
  const header = req.headers['user-agent'];
  return typeof header === 'string' ? header : null;
}

function getRequestIp(req: Request): string | null {
  return req.ip || req.socket?.remoteAddress || null;
}

function generateOpaqueRefreshToken(): string {
  return crypto
    .randomBytes(config.auth.refreshTokenBytes)
    .toString('base64url');
}

function hashRefreshToken(token: string): string {
  return crypto
    .createHash(config.auth.refreshTokenHashAlgorithm)
    .update(token)
    .digest('hex');
}

function getRefreshTokenExpiry(): Date {
  return new Date(Date.now() + config.auth.refreshTokenMaxAgeMs);
}

class Auth {
  static async currentUserProfile(currentUser?: CurrentUser) {
    if (!currentUser || !currentUser.id) {
      throw new ForbiddenError();
    }

    const user = await UsersDBApi.findProfileById(currentUser.id);

    if (!user) {
      throw new ForbiddenError();
    }

    const staffProfile: unknown =
      user.staff_user.length > 0 ? user.staff_user[0] : null;
    const staffProfileDto = toStaffProfileDto(staffProfile);
    const campusDto = toCampusDto(user.staff_campus);

    return {
      id: user.id,
      email: user.email,
      name_prefix: user.name_prefix ?? null,
      firstName: user.firstName,
      lastName: user.lastName,
      organizationId: user.organizationId,
      organizations: toOrganizationDto(user.organizations),
      app_role: toRoleDto(user.app_role),
      staffProfile: staffProfileDto,
      campus: campusDto,
      campusId: campusDto ? campusDto.id : (staffProfileDto?.campusId ?? null),
      permissions: getPermissionNames(
        user.app_role_permissions,
        user.custom_permissions,
      ),
    };
  }

  static async createSession(
    user: SessionUser,
    req: Request,
    options: SessionOptions = {},
  ) {
    const refreshToken = generateOpaqueRefreshToken();
    const familyId = options.familyId || crypto.randomUUID();
    const tokenRecord = await AuthRefreshTokensDBApi.create(
      {
        userId: asId(user.id),
        organizationId: asStringOrNull(user.organizationId),
        tokenHash: hashRefreshToken(refreshToken),
        familyId,
        previousTokenId: options.previousTokenId || null,
        userAgent: getRequestUserAgent(req),
        ipAddress: getRequestIp(req),
        expiresAt: getRefreshTokenExpiry(),
      },
      options,
    );

    return {
      accessToken: jwtSign(getTokenPayload(user)),
      refreshToken,
      refreshTokenRecord: tokenRecord,
      user,
    };
  }

  static async refreshSession(refreshToken: string | undefined, req: Request) {
    if (!refreshToken) {
      throw new ForbiddenError();
    }

    const tokenHash = hashRefreshToken(refreshToken);
    const transaction = await db.sequelize.transaction();
    let committed = false;

    try {
      const existingToken = await AuthRefreshTokensDBApi.findByHash(tokenHash, {
        transaction,
      });

      if (!existingToken) {
        throw new ForbiddenError();
      }

      if (existingToken.revokedAt) {
        await AuthRefreshTokensDBApi.revokeFamily(existingToken.familyId, {
          transaction,
        });
        await transaction.commit();
        committed = true;
        throw new ForbiddenError();
      }

      if (new Date(existingToken.expiresAt).getTime() <= Date.now()) {
        await AuthRefreshTokensDBApi.revoke(existingToken.id, null, {
          transaction,
        });
        await transaction.commit();
        committed = true;
        throw new ForbiddenError();
      }

      const user = await UsersDBApi.findBy({ id: existingToken.userId });

      if (!user || user.disabled) {
        await AuthRefreshTokensDBApi.revokeFamily(existingToken.familyId, {
          transaction,
        });
        await transaction.commit();
        committed = true;
        throw new ForbiddenError();
      }

      const nextSession = await this.createSession(user, req, {
        familyId: existingToken.familyId,
        previousTokenId: existingToken.id,
        transaction,
      });

      await AuthRefreshTokensDBApi.revoke(
        existingToken.id,
        nextSession.refreshTokenRecord.id,
        { transaction },
      );

      await transaction.commit();
      committed = true;
      return nextSession;
    } catch (error) {
      if (!committed) {
        await transaction.rollback();
      }
      throw error;
    }
  }

  static async revokeSession(refreshToken: string | undefined) {
    if (!refreshToken) {
      return;
    }

    const tokenRecord = await AuthRefreshTokensDBApi.findByHash(
      hashRefreshToken(refreshToken),
    );

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

    await AuthRefreshTokensDBApi.revoke(tokenRecord.id, null);
  }

  static async signup(
    email: string,
    password: string,
    organizationId: string | null,
    options: DbApiOptions = {},
    host?: string,
  ) {
    const user = await UsersDBApi.findBy({ email });

    const hashedPassword = await bcrypt.hash(password, config.bcrypt.saltRounds);

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

      await UsersDBApi.updatePassword(asId(user.id), hashedPassword, options);

      if (EmailSender.isConfigured) {
        await this.sendEmailAddressVerificationEmail(email, host);
      }

      return { user };
    }

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

    if (EmailSender.isConfigured) {
      await this.sendEmailAddressVerificationEmail(email, host);
    }

    return { user: newUser };
  }

  static async signin(email: string, password: string) {
    const user = await UsersDBApi.findBy({ 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 };
  }

  static async sendEmailAddressVerificationEmail(email: string, host?: string) {
    let link: string;
    try {
      const token = await UsersDBApi.generateEmailVerificationToken(email);
      link = `${host}/verify-email?token=${token}`;
    } catch (error) {
      logger.error('Failed to generate email verification token', error);
      throw new ValidationError('auth.emailAddressVerificationEmail.error');
    }

    const emailAddressVerificationEmail = new EmailAddressVerificationEmail(
      email,
      link,
    );

    return new EmailSender(emailAddressVerificationEmail).send();
  }

  static async sendPasswordResetEmail(
    email: string,
    type: 'register' | 'invitation' = 'register',
    host?: string,
  ) {
    let link: string;
    try {
      const token = await UsersDBApi.generatePasswordResetToken(email);
      link = `${host}/password-reset?token=${token}`;
    } catch (error) {
      logger.error('Failed to generate password reset token', error);
      throw new ValidationError('auth.passwordReset.error');
    }

    const passwordResetEmail =
      type === 'invitation'
        ? new InvitationEmail(email, link)
        : new PasswordResetEmail(email, link);

    return new EmailSender(passwordResetEmail).send();
  }

  static async verifyEmail(token: string, options: DbApiOptions = {}) {
    const user = await UsersDBApi.findByEmailVerificationToken(token, options);

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

    return UsersDBApi.markEmailVerified(user.id, options);
  }

  static async passwordUpdate(
    currentPassword: string,
    newPassword: string,
    options: DbApiOptions,
  ) {
    const currentUser = options.currentUser ?? null;
    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,
      config.bcrypt.saltRounds,
    );

    return UsersDBApi.updatePassword(asId(currentUser.id), hashedPassword, options);
  }

  static async passwordReset(
    token: string,
    password: string,
    options: DbApiOptions = {},
  ) {
    const user = await UsersDBApi.findByPasswordResetToken(token, options);

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

    const hashedPassword = await bcrypt.hash(password, config.bcrypt.saltRounds);

    return UsersDBApi.updatePassword(user.id, hashedPassword, options);
  }

  static async updateProfile(data: UpdateProfileData, currentUser: CurrentUser) {
    const transaction = await db.sequelize.transaction();

    try {
      await UsersDBApi.update(asId(currentUser.id), data, false, {
        currentUser,
        transaction,
      });

      await transaction.commit();
    } catch (error) {
      await transaction.rollback();
      throw error;
    }
  }
}

export default Auth;
