
import { Inject, Injectable, forwardRef } from '@nestjs/common';
import { UsersService } from '../users/users.service';
import { JwtService } from '@nestjs/jwt';
import * as md5 from 'md5';
import * as argon2 from 'argon2';
import { Request, Response } from 'express';
import { cookieOptions } from '../common/constants/cookieOptions.constant';
import type { CookieOptions } from 'express-serve-static-core';
import { RoleName } from 'src/roles/roles.service';
import { PrismaService } from 'src/prisma/prisma.service';

@Injectable()
/*
 * @description This class controls user authentication and issuance of the
 * access and refresh tokens in repsonse to client requests.
 * https://docs.nestjs.com/recipes/passport#enable-authentication-globally
 * https://www.elvisduru.com/blog/nestjs-jwt-authentication-refresh-token
 * https://romain-kelifa.medium.com/definitive-guide-for-nest-js-guards-and-passport-57915cfb6fd
 */
export class AuthService {
  constructor(
    private prisma: PrismaService,
    @Inject(forwardRef(() => UsersService))
    private usersService: UsersService,
    private jwtService: JwtService
  ) {}

  getCookieName(key: string): string {
    return `__${process.env.DOMAIN}-${key}`
  }

  setClientCookies(res: Response, tokens: { access_token: string, refresh_token: string }): void {
    // https://medium.com/lightrail/getting-token-authentication-right-in-a-stateless-single-page-application-57d0c6474e3
    // https://security.stackexchange.com/questions/226906/double-jwt-submit-method
    // Overall:
    // Split the signatures and store them in a same-site, secure, httpOnly cookie with the expiration of the total
    // length of the login session, for instance 1 day.
    // The payloads should be stored in seperate cookies with seperate expiry times, secure, but without httpOnly
    // as the client needs to be able to access it.

    const [accessTokenHeader,accessTokenPayload,accessTokenSignature] = tokens.access_token.split('.')
    const [refreshTokenHeader,refreshTokenPayload,refreshTokenSignature] = tokens.refresh_token.split('.')

    // Build the auth cookie (contains the signatures)
    const options = <CookieOptions>cookieOptions(1000 * 60 * 60 * 24 * 1) // 1 day
    options.httpOnly = true // never available to the client
    res.cookie(this.getCookieName('auth'), `${accessTokenSignature}.${refreshTokenSignature}`, options)

    // Build the payload cookies (header and payload portions)
    options.httpOnly = false
    options.signed = false
    res.cookie(this.getCookieName('refresh'), `${refreshTokenHeader}.${refreshTokenPayload}`, options)
    options.maxAge = 1000 * 60 * 2 // 15 minutes
    res.cookie(this.getCookieName('access'), `${accessTokenHeader}.${accessTokenPayload}`, options)
  }

  async clearClientCookies(res: Response) {
    const options = <CookieOptions>cookieOptions()
    delete options.maxAge // expires, maxAge must be removed to clear the cookie value
    options.httpOnly = true
    res.clearCookie(this.getCookieName('auth'), options)
    options.httpOnly = false
    options.signed = false
    res.clearCookie(this.getCookieName('refresh'), options)
    res.clearCookie(this.getCookieName('access'), options)
    res.clearCookie(this.getCookieName('x-csrf-token'), options)
  }

  parseReqToken(req: Request, refreshToken: boolean = false): string | null {
    let token = null;
    if (req && req.signedCookies) {
      // Get signature from httpOnly cookie
      const signatures = req.signedCookies[this.getCookieName('auth')];
      if (!signatures) return null;
      // console.log('sigs',signatures)
      const signatureParts = signatures.split('.');
      const signature = !refreshToken ? signatureParts[0] : signatureParts[1];
      // Get header and payload from auth header
      const AuthorizationHeader = req.header('Authorization');
      if (!AuthorizationHeader) return null;
      const portion = AuthorizationHeader.replace('Bearer', '').trim();
      // console.log('portion', portion)
      if (!portion) return null;
      const [header,payload,...rest] = portion.split('.');
      // Reform the token
      token = [header,payload,signature].join('.');
    }
    // console.log('parsed token', refreshToken ? '(refresh)' : 'access', token)
    return token;
  }

  async createMd5Hash (
    plain: string
  ) {
    return md5(plain)
  }

  async createHash (
    plain: string
  ) {
    // The new hashing function as recommended by the OWASP password storage cheat sheet.
    // https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html
    return await argon2.hash(plain)
  }

  async isHashValid (
    plain: string,
    hash: string
  ) {
    return await argon2.verify(hash,plain);
  }

  /*
   * @description Check if a password has been stored in the new format.
   * @return A boolean result
   */
  async newPasswordFormatSet(user_id: number) {
    const countOfRecords = await this.prisma.user_password.count({
      where: { user_id }
    });
    return countOfRecords > 0;
  }

  /*
   * @description Save the users password using he new format.
   * @return The record id.
   */
  async setNewPassword(user_id: number, password: string) {
    const result = await this.prisma.user_password.create({
      data: {
        user_id,
        password: await this.createHash(password)
      }
    });
    return result ? result.user_password_id : null;
  }

  async checkNewPasswordStorage(username: string, password: string) {
    // Compare the input and database using the new hasing function, argon2
    // https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html#argon2id
    const matchedUsers = await this.prisma.users.findMany({
      where: {
        OR: [
          { email: username },
          { username }
        ],
        status: 1,
        suspended: 0
      },
    });
    const countOfMatchedUsers = matchedUsers.length;
    if (countOfMatchedUsers === 0) return null;
    const userIds = matchedUsers.map(r => r.user_id);
    // Get the corresponding new password records
    const matchedUserPasswords = await this.prisma.user_password.findMany({
      where: { OR: userIds.map((id: number) => ({ user_id: id })) },
    });
    const countOfMatchedNewPasswords = matchedUserPasswords.length;
    if (countOfMatchedNewPasswords === 0) return null;
    let user = null;
    for (let i = 0; i < countOfMatchedNewPasswords; i++) {
      const record = matchedUserPasswords[i];
      const isPasswordValid = await this.isHashValid(password, record.password);
      if (!isPasswordValid) continue;
      user = matchedUsers.find(u => u.user_id === record.user_id)!;
      break;
    }
    // Return the user record
    return user;
  }

  async checkOldPasswordStorage(username: string, password: string) {
    // The legacy system used a basic check the users with this email/password combination
    // $stmt = $dbo->prepare("select user_id, firstname from users where username = '$username' and password = '$password' and status = 1 and suspended = 0;");
    const user = await this.prisma.users.findFirst({
      where: {
        OR: [
          { email: username },
          { username }
        ],
        password: await this.createMd5Hash(password),
        status: 1,
        suspended: 0
      }
    });
    // Compare the input and database using the new hasing function, md5
    // TODO: See the legacy update instructions regarding the updating of the users password
    // this has been allowed for in the user_password table with a boolean force_password_date flag
    // The flag or the implementation of forcing the user to update their password has not been utilised
    // within the codebase as of yet
    // https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html#upgrading-legacy-hashes
    if (!user) return null;
    // Store the password in the new format is it if not yet set
    const isNewPasswordSet = await this.newPasswordFormatSet(user.user_id);
    if (!isNewPasswordSet) await this.setNewPassword(user.user_id, password);
    // Return the user record
    return user;
  }

  /*
   * @description This function is called by the local authentication strategy
   * It performs a comparison against the user supplied username and password.
   * @return The user model, without the password.
   * null = failed validation
   */
  async validateUser(username: string, password: string): Promise<any> {
    // argon2 hash
    let result = await this.checkNewPasswordStorage(username, password);
    // md5 gre
    result ??= await this.checkOldPasswordStorage(username, password);
    if(!result) return null;
    await this.usersService.update(result.user_id, { last_logon_date: new Date() });
    return this.usersService.censorCredentialsOne(result);
  }

  /*
   * @description This function is called by the local authentication strategy
   * It performs a comparison against the user supplied username and password.
   * @return The user model, without the password.
   * null = failed validation
   * true = successful validation
   */
  // async validateUser(username: string, password: string): Promise<any> {
  //   const user = await this.usersService.unsafeFindByUsernameOrEmailString(username);
  //   // The old hashing function from the Joomla / PHP version
  //   // TODO: See the legacy update instructions:
  //   // https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html#upgrading-legacy-hashes
  //   if (!user) return null;
  //   // Check the passwords
  //   let isPasswordValid = false;
  //   if (user.password.startsWith('$argon2id')) isPasswordValid = await this.isHashValid(password, user.password);
  //   else {
  //     isPasswordValid = user.password === md5(password);
  //     // Update the hash to the new format
  //     // TODO: this works fine but should be enabled after a full move to the full platform
  //     // either that or utilise a new password field in the database alongside the old one
  //     // use that for comparisons too, would need to adjust any code that removes the password from the user object
  //     // to ensure that this alternative password is also removed
  //     //if (isPasswordValid) await this.usersService.update(user.user_id, { password: await this.createHash(password) });
  //   }
  //   if (!isPasswordValid) return null;
  //   const { password: unused, refresh_token, ...result } = user;
  //   return result;
  // }

  /*
   * @description Issues a set of authentication tokens once the request been validated.
   * It also acts as validation for the user account due to null return validator
   * @return A signed set of access and refresh tokens.
   */
  async refreshTokens(
    user_id: number,
    username: string,
    user_class_id: number,
    roles: RoleName[],
    refresh_token: string
  ): Promise<{ access_token: string, refresh_token: string } | null> {
    // console.log('dbRefreshToken b4', user_id, username, refresh_token)
    const dbRefreshToken = await this.usersService.getRefreshToken(user_id);
    // console.log('dbRefreshToken', dbRefreshToken)
    if (!dbRefreshToken) return null;
    const isHashValid = await this.isHashValid(refresh_token, dbRefreshToken);
    // console.log('isHashValid', isHashValid)
    if (!isHashValid) return null;
    return this.getTokens(user_id, username, user_class_id, roles);
  }

  // async refreshTokens(user_id: number, refresh_token: string) {
  //   const user = await this.usersService.unsafeFindById(user_id);
  //   if (!user || !user.refresh_token) throw new UnauthorizedException();
  //   const isRefreshTokenValid = await await this.isHashValid(user.refresh_token, refresh_token);
  //   if (!isRefreshTokenValid) throw new UnauthorizedException();
  //   return this.getTokens(user.user_id, user.username);
  // }

  /*
   * @description Hashes the password for database storage.
   * @return undefined
   */
  async updatePassword(user_id: number, password: string) {
    const hash = await this.createMd5Hash(password);
    await this.usersService.update(user_id, { password: hash });
  }

  /*
   * @description Hashes the refresh token for database storage.
   * @return undefined
   */
  async updateRefreshToken(user_id: number, refreshToken: string) {
    const countOfFoundRecords = await this.prisma.user_refresh_token.count({
      where: {
        user_id
      }
    });
    const hash = await this.createHash(refreshToken);
    if (countOfFoundRecords === 0) {
      await this.prisma.user_refresh_token.create({
        data: {
          refresh_token: hash,
          user_id
        }
      });
    } else {
      await this.prisma.user_refresh_token.update({
        where: {
          user_id,
        },
        data: {
          refresh_token: hash
        },
      });
    }
  }

  /*
   * @description Issues a set of authentication tokens once the form submission been validated.
   * Relies on the local authenticaiton strategy to validate the user input.
   * https://docs.nestjs.com/recipes/passport#enable-authentication-globally
   * https://www.elvisduru.com/blog/nestjs-jwt-authentication-refresh-token
   * @return A set of signed access and refresh tokens.
   */
  async getTokens(user_id: number, username: string, user_class_id: number, roles: RoleName[]): Promise<{ access_token: string, refresh_token: string }> {
    // Regarding the access and refresh tokens:
    // https://www.rfc-editor.org/rfc/rfc6749#section-5.1
    const access_token = await this.jwtService.signAsync({
      // Regarding the signing of the tokens:
      // https://github.com/nestjs/jwt#jwtservicesignpayload-string--object--buffer-options-jwtsignoptions-string
      sub: user_id,
      username,
      user_class_id,
      roles
    },{
      secret: process.env.JWT_ACCESS_TOKEN_SECRET,
      // Regarding the expiry times:
      // https://security.stackexchange.com/a/120227/40579
      expiresIn: '15m'
    });
    // console.log('Generated new access_token', access_token)

    const refresh_token = await this.jwtService.signAsync({
      sub: user_id,
      username,
      user_class_id,
      roles
    },{
      secret: process.env.JWT_REFRESH_TOKEN_SECRET,
      expiresIn: '1d'
    });
    // console.log('Generated new refresh_token', refresh_token)

    // Why it is important to store the refresh token, see "Addressing some of the potential issues":
    // https://wanago.io/2020/09/21/api-nestjs-refresh-tokens-jwt/
    await this.updateRefreshToken(user_id, refresh_token);

    return { access_token, refresh_token };
  }

  /*
   * @description This function sets the users refresh token value to null.
   * @return The user model.
   */
  async logout(user_id: number) {
    // Why it is important to clear the refresh token, see "Logging out":
    // https://wanago.io/2020/09/21/api-nestjs-refresh-tokens-jwt/
    return this.prisma.user_refresh_token.update({
      where: {
        user_id: user_id,
      },
      data: {
        refresh_token: null
      },
    });
  }
}
