import { HttpException, HttpStatus, Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import jwt from 'jsonwebtoken';
import jwkToPem from 'jwk-to-pem';
import { makeErrorResponse } from '../../common/error/makeErrorResponse';
import { sign } from '../../common/jwt';
import {
  getPrivateKey,
  getPublicKey,
  isVerifyError,
  verify,
} from '../../common/jwt/jwt';
import {
  AccessToken,
  IDToken,
  JwkSignKey,
  RefreshToken,
  isIDToken,
} from '../../common/token';
import { ADMIN_ROLES, TIERS, USER_ROLES } from '../../constants';
import { AdB2cService } from '../../gateways/adb2c/adb2c.service';
import { UsersRepositoryService } from '../../repositories/users/users.repository.service';
import { Context } from '../../common/log';
import {
  AccountNotFoundError,
  AdminUserNotFoundError,
} from '../../repositories/accounts/errors/types';
import {
  DelegationNotAllowedError,
  UserNotFoundError,
} from '../../repositories/users/errors/types';
import {
  InvalidTokenFormatError,
  RoleUnexpectedError,
  TierUnexpectedError,
} from './errors/types';

@Injectable()
export class AuthService {
  private readonly refreshTokenLifetimeWeb =
    this.configService.getOrThrow<number>('REFRESH_TOKEN_LIFETIME_WEB');
  private readonly refreshTokenLifetimeDefault =
    this.configService.getOrThrow<number>('REFRESH_TOKEN_LIFETIME_DEFAULT');
  private readonly accessTokenlifetime = this.configService.getOrThrow<number>(
    'ACCESS_TOKEN_LIFETIME_WEB',
  );
  constructor(
    private readonly adB2cService: AdB2cService,
    private readonly configService: ConfigService,
    private readonly usersRepository: UsersRepositoryService,
  ) {}
  private readonly logger = new Logger(AuthService.name);
  /**
   * Determines whether verified user is
   * @param idToken AzureAD B2Cにより発行されたIDトークン
   * @returns verified user
   */
  async isVerifiedUser(context: Context, idToken: IDToken): Promise<boolean> {
    this.logger.log(
      `[IN] [${context.getTrackingId()}] ${this.isVerifiedUser.name}`,
    );
    try {
      // IDトークンのユーザーがDBに登録されていてメール認証が完了しているユーザーか検証
      const user = await this.usersRepository.findVerifiedUser(
        context,
        idToken.sub,
      );

      return user !== undefined;
    } catch (e) {
      this.logger.error(`[${context.getTrackingId()}] error=${e}`);
      throw new HttpException(
        makeErrorResponse('E009999'),
        HttpStatus.INTERNAL_SERVER_ERROR,
      );
    } finally {
      this.logger.log(
        `[OUT] [${context.getTrackingId()}] ${this.isVerifiedUser.name}`,
      );
    }
  }

  /**
   * Generates refresh token
   * @param idToken AzureAD B2Cにより発行されたIDトークン
   * @param type 環境を表す文字列(web/desktop/mobile)
   * @returns refresh token
   */
  async generateRefreshToken(
    context: Context,
    idToken: IDToken,
    type: string,
  ): Promise<string> {
    this.logger.log(
      `[IN] [${context.getTrackingId()}] ${
        this.generateRefreshToken.name
      } | params: { type: ${type} };`,
    );

    // ユーザー情報とユーザーが属しているアカウント情報を取得
    try {
      const user = await this.usersRepository.findUserByExternalId(
        context,
        idToken.sub,
      );
      if (!user.account) {
        throw new Error('Account information not found');
      }

      // Tierのチェック
      const minTier = 1;
      const maxTier = 5;
      const userTier = user.account.tier;
      if (userTier < minTier || userTier > maxTier) {
        throw new TierUnexpectedError(
          `Tier from DB is unexpected value. tier=${user.account.tier}`,
        );
      }

      // 要求された環境用トークンの寿命を決定
      const refreshTokenLifetime =
        type === 'web'
          ? this.refreshTokenLifetimeWeb
          : this.refreshTokenLifetimeDefault;
      const privateKey = getPrivateKey(this.configService);

      // ユーザーのロールを設定
      const role = this.getUserRole(context, user.role);

      const token = sign<RefreshToken>(
        {
          //ユーザーの属しているアカウントの管理者にユーザーが設定されていればadminをセットする
          role: `${role} ${
            user.account.primary_admin_user_id === user.id ||
            user.account.secondary_admin_user_id === user.id
              ? ADMIN_ROLES.ADMIN
              : ADMIN_ROLES.STANDARD
          }`,
          tier: user.account.tier,
          userId: idToken.sub,
        },
        refreshTokenLifetime,
        privateKey,
      );

      return token;
    } catch (e) {
      this.logger.error(`[${context.getTrackingId()}] error=${e}`);
      if (e instanceof Error) {
        switch (e.constructor) {
          case TierUnexpectedError:
            throw new HttpException(
              makeErrorResponse('E010206'),
              HttpStatus.INTERNAL_SERVER_ERROR,
            );
          case RoleUnexpectedError:
            throw new HttpException(
              makeErrorResponse('E010205'),
              HttpStatus.INTERNAL_SERVER_ERROR,
            );
          default:
            break;
        }
      }
      throw new HttpException(
        makeErrorResponse('E009999'),
        HttpStatus.INTERNAL_SERVER_ERROR,
      );
    } finally {
      this.logger.log(
        `[OUT] [${context.getTrackingId()}] ${this.generateRefreshToken.name}`,
      );
    }
  }

  /**
   * Generates access token
   * @param refreshToken リフレッシュトークン(jwt)
   * @returns access token(jwt)
   */
  async generateAccessToken(
    context: Context,
    refreshToken: string,
  ): Promise<string> {
    this.logger.log(
      `[IN] [${context.getTrackingId()}] ${this.generateAccessToken.name}`,
    );

    const privateKey = getPrivateKey(this.configService);
    const pubkey = getPublicKey(this.configService);

    const token = verify<RefreshToken>(refreshToken, pubkey);
    if (isVerifyError(token)) {
      this.logger.error(
        `[${context.getTrackingId()}] ${token.reason} | ${token.message}`,
      );
      this.logger.log(
        `[OUT] [${context.getTrackingId()}] ${this.generateAccessToken.name}`,
      );
      throw new HttpException(
        makeErrorResponse('E000101'),
        HttpStatus.UNAUTHORIZED,
      );
    }

    const accessToken = sign<AccessToken>(
      {
        role: token.role,
        tier: token.tier,
        userId: token.userId,
      },
      this.accessTokenlifetime,
      privateKey,
    );

    this.logger.log(
      `[OUT] [${context.getTrackingId()}] ${this.generateAccessToken.name}`,
    );
    return accessToken;
  }

  /**
   * 代行操作用のリフレッシュトークンを生成します
   * @param context
   * @param delegateUserExternalId 代行操作者の外部認証サービスの識別子
   * @param originAccountId 代行操作対象アカウントのID
   * @returns delegation refresh token
   */
  async generateDelegationRefreshToken(
    context: Context,
    delegateUserExternalId: string,
    originAccountId: number,
  ): Promise<string> {
    this.logger.log(
      `[IN] [${context.getTrackingId()}] ${
        this.generateDelegationRefreshToken.name
      } | params: { ` +
        `delegateUserExternalId: ${delegateUserExternalId}, ` +
        `originAccountId: ${originAccountId}, };`,
    );

    // ユーザー情報とユーザーが属しているアカウント情報を取得
    try {
      const user = await this.usersRepository.findUserByExternalId(
        context,
        delegateUserExternalId,
      );

      // 代行操作対象アカウントの管理者ユーザーを取得
      const adminUser = await this.usersRepository.findDelegateUser(
        context,
        user.account_id,
        originAccountId,
      );

      // 要求された環境用トークンの寿命を決定
      const refreshTokenLifetime = this.refreshTokenLifetimeWeb;
      const privateKey = getPrivateKey(this.configService);

      // ユーザーのロールを設定
      const role = this.getUserRole(context, adminUser.role);

      const token = sign<RefreshToken>(
        {
          role: `${role} ${ADMIN_ROLES.ADMIN}`,
          tier: TIERS.TIER5,
          userId: adminUser.external_id,
          delegateUserId: delegateUserExternalId,
        },
        refreshTokenLifetime,
        privateKey,
      );

      return token;
    } catch (e) {
      this.logger.error(`[${context.getTrackingId()}] error=${e}`);
      if (e instanceof Error) {
        switch (e.constructor) {
          case AccountNotFoundError:
          case AdminUserNotFoundError:
            throw new HttpException(
              makeErrorResponse('E010501'),
              HttpStatus.BAD_REQUEST,
            );
          case DelegationNotAllowedError:
            throw new HttpException(
              makeErrorResponse('E010503'),
              HttpStatus.BAD_REQUEST,
            );
          case RoleUnexpectedError:
            throw new HttpException(
              makeErrorResponse('E010205'),
              HttpStatus.INTERNAL_SERVER_ERROR,
            );
          default:
            break;
        }
      }
      throw new HttpException(
        makeErrorResponse('E009999'),
        HttpStatus.INTERNAL_SERVER_ERROR,
      );
    } finally {
      this.logger.log(
        `[OUT] [${context.getTrackingId()}] ${
          this.generateDelegationRefreshToken.name
        }`,
      );
    }
  }

  /**
   * 代行操作アクセストークンの更新
   * @param context
   * @param refreshToken
   * @returns delegation access token
   */
  async generateDelegationAccessToken(
    context: Context,
    refreshToken: string,
  ): Promise<string> {
    this.logger.log(
      `[IN] [${context.getTrackingId()}] ${
        this.generateDelegationAccessToken.name
      }`,
    );

    const privateKey = getPrivateKey(this.configService);
    const pubkey = getPublicKey(this.configService);

    const token = verify<RefreshToken>(refreshToken, pubkey);
    if (isVerifyError(token)) {
      this.logger.error(
        `[${context.getTrackingId()}] ${token.reason} | ${token.message}`,
      );
      this.logger.log(
        `[OUT] [${context.getTrackingId()}] ${
          this.generateDelegationAccessToken.name
        }`,
      );
      throw new HttpException(
        makeErrorResponse('E000101'),
        HttpStatus.UNAUTHORIZED,
      );
    }

    const accessToken = sign<AccessToken>(
      {
        role: token.role,
        tier: token.tier,
        userId: token.userId,
        delegateUserId: token.delegateUserId,
      },
      this.accessTokenlifetime,
      privateKey,
    );

    this.logger.log(
      `[OUT] [${context.getTrackingId()}] ${
        this.generateDelegationAccessToken.name
      }`,
    );
    return accessToken;
  }

  /**
   * 代行操作用アクセストークンを更新する
   * @param context
   * @param delegateUserExternalId
   * @param originUserExternalId
   * @param refreshToken
   * @returns delegation access token
   */
  async updateDelegationAccessToken(
    context: Context,
    delegateUserExternalId: string | undefined,
    originUserExternalId: string,
    refreshToken: string,
  ): Promise<string> {
    this.logger.log(
      `[IN] [${context.getTrackingId()}] ${
        this.updateDelegationAccessToken.name
      } | params: { ` +
        `delegateUserExternalId: ${delegateUserExternalId}, ` +
        `originUserExternalId: ${originUserExternalId}, };`,
    );
    try {
      if (!delegateUserExternalId) {
        throw new UserNotFoundError('delegateUserExternalId is undefined');
      }

      const user = await this.usersRepository.findUserByExternalId(
        context,
        delegateUserExternalId,
      );

      const privateKey = getPrivateKey(this.configService);
      const pubkey = getPublicKey(this.configService);

      // トークンの検証
      const decodedToken = verify<RefreshToken>(refreshToken, pubkey);
      if (isVerifyError(decodedToken)) {
        throw new InvalidTokenFormatError(
          `Invalid token format. ${decodedToken.reason} | ${decodedToken.message}`,
        );
      }

      // トークンの生成には検証済みトークンの値を使用する
      const { userId, delegateUserId, tier, role } = decodedToken;

      if (delegateUserId === undefined) {
        throw new AdminUserNotFoundError('delegateUserId is undefined');
      }

      // 代行操作対象アカウントの管理者ユーザーが存在して、アカウントに対して代行操作権限があるか確認
      const delegationPermission =
        await this.usersRepository.isAllowDelegationPermission(
          context,
          user.account_id,
          userId,
        );

      if (!delegationPermission) {
        throw new DelegationNotAllowedError(
          `Delegation is not allowed. delegateUserId=${delegateUserId}, userId=${userId}`,
        );
      }

      const accessToken = sign<AccessToken>(
        {
          role: role,
          tier: tier,
          userId: userId,
          delegateUserId: delegateUserId,
        },
        this.accessTokenlifetime,
        privateKey,
      );

      return accessToken;
    } catch (e) {
      this.logger.error(`[${context.getTrackingId()}] error=${e}`);
      if (e instanceof HttpException) {
        throw e;
      }
      if (e instanceof Error) {
        switch (e.constructor) {
          case InvalidTokenFormatError:
            throw new HttpException(
              makeErrorResponse('E000101'),
              HttpStatus.UNAUTHORIZED,
            );
          case UserNotFoundError:
          case AccountNotFoundError:
          case AdminUserNotFoundError:
            throw new HttpException(
              makeErrorResponse('E010501'),
              HttpStatus.UNAUTHORIZED,
            );
          case DelegationNotAllowedError:
            throw new HttpException(
              makeErrorResponse('E010503'),
              HttpStatus.UNAUTHORIZED,
            );
          default:
            break;
        }
      }
      throw new HttpException(
        makeErrorResponse('E009999'),
        HttpStatus.INTERNAL_SERVER_ERROR,
      );
    } finally {
      this.logger.log(
        `[OUT] [${context.getTrackingId()}] ${
          this.updateDelegationAccessToken.name
        }`,
      );
    }
  }

  /**
   * Gets id token
   * @param token
   * @returns id token
   */
  async getVerifiedIdToken(context: Context, token: string): Promise<IDToken> {
    this.logger.log(
      `[IN] [${context.getTrackingId()}] ${this.getVerifiedIdToken.name}`,
    );

    let kid: string | undefined = '';
    try {
      // JWTトークンのヘッダを見るため一度デコードする
      const decodedToken = jwt.decode(token, { complete: true });
      kid = decodedToken?.header.kid;
      if (!kid) {
        throw new Error('kid not found');
      }
    } catch (e) {
      this.logger.error(`[${context.getTrackingId()}] error=${e}`);
      throw new HttpException(
        makeErrorResponse('E000101'),
        HttpStatus.UNAUTHORIZED,
      );
    }

    let issuer = '';
    try {
      const metadata = await this.adB2cService.getMetaData(context);
      const keySets = await this.adB2cService.getSignKeySets(context);

      issuer = metadata.issuer;

      const jwkKey = keySets.find((x) => x.kid === kid);

      if (!jwkKey) {
        throw new Error('Public Key Not Found.');
      }

      const publicKey = this.getPublicKeyFromJwk(context, jwkKey);

      const verifiedToken = jwt.verify(token, publicKey, {
        algorithms: ['RS256'],
        issuer: [issuer],
      });

      if (!isIDToken(verifiedToken)) {
        throw new Error('invalid format ID token');
      }
      return verifiedToken;
    } catch (e) {
      if (e instanceof Error) {
        const { name, message } = e;
        this.logger.error(
          `[${context.getTrackingId()}] error=${name}: ${message}`,
        );

        switch (e.constructor) {
          case jwt.TokenExpiredError:
            throw new HttpException(
              makeErrorResponse('E000102'),
              HttpStatus.UNAUTHORIZED,
            );
          case jwt.NotBeforeError:
            throw new HttpException(
              makeErrorResponse('E000103'),
              HttpStatus.UNAUTHORIZED,
            );
          case jwt.JsonWebTokenError:
            // メッセージごとにエラーを判定しHTTPエラーを生成
            throw this.makeHttpErrorFromJsonWebTokenErrorMessage(
              context,
              message,
              issuer,
            );
          default:
            break;
        }
      } else {
        this.logger.error(`[${context.getTrackingId()}] error=${e}`);
      }
      throw new HttpException(
        makeErrorResponse('E009999'),
        HttpStatus.INTERNAL_SERVER_ERROR,
      );
    } finally {
      this.logger.log(
        `[OUT] [${context.getTrackingId()}] ${this.getVerifiedIdToken.name}`,
      );
    }
  }

  getPublicKeyFromJwk(context: Context, jwkKey: JwkSignKey): string {
    this.logger.log(
      `[IN] [${context.getTrackingId()}] ${this.getPublicKeyFromJwk.name}`,
    );
    try {
      // JWK形式のJSONなのでJWTの公開鍵として使えるようにPEM形式に変換
      const publicKey = jwkToPem({
        kty: 'RSA',
        n: jwkKey.n,
        e: jwkKey.e,
      });

      return publicKey;
    } catch (e) {
      this.logger.error(`[${context.getTrackingId()}] error=${e}`);
      throw e;
    } finally {
      this.logger.log(
        `[OUT] [${context.getTrackingId()}] ${this.getPublicKeyFromJwk.name}`,
      );
    }
  }

  /**
   * JWT検証時のError、JsonWebTokenErrorをメッセージごとに仕分けてHTTPエラーを生成
   */
  makeHttpErrorFromJsonWebTokenErrorMessage = (
    context: Context,
    message: string,
    issuer: string,
  ): Error => {
    this.logger.log(
      `[IN] [${context.getTrackingId()}] ${
        this.makeHttpErrorFromJsonWebTokenErrorMessage.name
      }`,
    );
    try {
      // 署名が不正
      if (message === 'invalid signature') {
        return new HttpException(
          makeErrorResponse('E000104'),
          HttpStatus.UNAUTHORIZED,
        );
      }
      // 想定発行元と異なる
      if (message === `jwt issuer invalid. expected: ${issuer}`) {
        return new HttpException(
          makeErrorResponse('E000105'),
          HttpStatus.UNAUTHORIZED,
        );
      }
      // アルゴリズムが想定と異なる
      if (message === 'invalid algorithm') {
        return new HttpException(
          makeErrorResponse('E000106'),
          HttpStatus.UNAUTHORIZED,
        );
      }
      // トークンの形式が不正
      return new HttpException(
        makeErrorResponse('E000101'),
        HttpStatus.UNAUTHORIZED,
      );
    } catch (e) {
      this.logger.error(`[${context.getTrackingId()}] error=${e}`);
      throw e;
    } finally {
      this.logger.log(
        `[OUT] [${context.getTrackingId()}] ${
          this.makeHttpErrorFromJsonWebTokenErrorMessage.name
        }`,
      );
    }
  };

  /**
   * トークンに設定するユーザーのロールを取得
   */
  getUserRole = (context: Context, role: string): string => {
    this.logger.log(
      `[IN] [${context.getTrackingId()}] ${this.getUserRole.name}`,
    );
    try {
      // ユーザーのロールを設定
      // 万一不正なRoleが登録されていた場合、そのままDBの値を使用すると不正なロールのリフレッシュトークンが発行されるため、
      // ロールの設定値はDBに保存したRoleの値を直接トークンに入れないように定数で設定する
      // ※none/author/typist以外はロールに設定されない
      if (role === USER_ROLES.NONE) {
        return USER_ROLES.NONE;
      } else if (role === USER_ROLES.AUTHOR) {
        return USER_ROLES.AUTHOR;
      } else if (role === USER_ROLES.TYPIST) {
        return USER_ROLES.TYPIST;
      } else {
        throw new RoleUnexpectedError(
          `Role from DB is unexpected value. role=${role}`,
        );
      }
    } catch (e) {
      this.logger.error(`[${context.getTrackingId()}] error=${e}`);
      throw e;
    } finally {
      this.logger.log(
        `[OUT] [${context.getTrackingId()}] ${this.getUserRole.name}`,
      );
    }
  };

  /**
   * 同意済み利用規約バージョンが最新かチェック
   * @param idToken AzureAD B2Cにより発行されたIDトークン
   * @returns boolean
   */
  async isAcceptedLatestVersion(
    context: Context,
    idToken: IDToken,
  ): Promise<boolean> {
    this.logger.log(
      `[IN] [${context.getTrackingId()}] ${
        this.isAcceptedLatestVersion.name
      } | params: { ` + `idToken.sub: ${idToken.sub}, };`,
    );

    try {
      // DBからユーザーの同意済み利用規約バージョンと最新バージョンを取得
      const {
        acceptedEulaVersion,
        latestEulaVersion,
        acceptedPrivacyNoticeVersion,
        latestPrivacyNoticeVersion,
        acceptedDpaVersion,
        latestDpaVersion,
        tier,
      } = await this.usersRepository.getAcceptedAndLatestVersion(
        context,
        idToken.sub,
      );

      // 第五階層はEULAとPrivacyNoticeのみ判定
      if (tier === TIERS.TIER5) {
        if (!acceptedEulaVersion || !acceptedPrivacyNoticeVersion) {
          return false;
        }
        // 最新バージョンに同意済みか判定
        const eulaAccepted = acceptedEulaVersion === latestEulaVersion;
        const privacyNoticeAccepted =
          acceptedPrivacyNoticeVersion === latestPrivacyNoticeVersion;
        return eulaAccepted && privacyNoticeAccepted;
      } else {
        // 第一～第四階層はEULA、PrivacyNotice、DPAを判定
        if (
          !acceptedEulaVersion ||
          !acceptedPrivacyNoticeVersion ||
          !acceptedDpaVersion
        ) {
          return false;
        }
        // 最新バージョンに同意済みか判定
        const eulaAccepted = acceptedEulaVersion === latestEulaVersion;
        const privacyNoticeAccepted =
          acceptedPrivacyNoticeVersion === latestPrivacyNoticeVersion;
        const dpaAccepted = acceptedDpaVersion === latestDpaVersion;
        return eulaAccepted && privacyNoticeAccepted && dpaAccepted;
      }
    } catch (e) {
      this.logger.error(`[${context.getTrackingId()}] error=${e}`);
      throw new HttpException(
        makeErrorResponse('E009999'),
        HttpStatus.INTERNAL_SERVER_ERROR,
      );
    } finally {
      this.logger.log(
        `[OUT] [${context.getTrackingId()}] ${
          this.isAcceptedLatestVersion.name
        }`,
      );
    }
  }
}
