import { ClientSecretCredential } from '@azure/identity';
import { Client } from '@microsoft/microsoft-graph-client';
import { TokenCredentialAuthenticationProvider } from '@microsoft/microsoft-graph-client/authProviders/azureTokenCredentials';
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import axios from 'axios';
import { B2cMetadata, JwkSignKey } from '../../common/token';
import { AdB2cResponse, AdB2cUser } from './types/types';
import { isPromiseRejectedResult } from './utils/utils';
import { Context } from '../../common/log';
import { ADB2C_SIGN_IN_TYPE, MANUAL_RECOVERY_REQUIRED } from '../../constants';
import { makeADB2CKey, restoreAdB2cID } from '../../common/cache';
import { RedisService } from '../redis/redis.service';

export type ConflictError = {
  reason: 'email';
  message: string;
};

export class Adb2cTooManyRequestsError extends Error {}

export const isConflictError = (arg: unknown): arg is ConflictError => {
  const value = arg as ConflictError;
  if (value.message === undefined) {
    return false;
  }
  if (value.reason === 'email') {
    return true;
  }
  return false;
};

@Injectable()
export class AdB2cService {
  private readonly logger = new Logger(AdB2cService.name);
  private readonly tenantName =
    this.configService.getOrThrow<string>('TENANT_NAME');
  private readonly flowName =
    this.configService.getOrThrow<string>('SIGNIN_FLOW_NAME');
  private readonly ttl =
    this.configService.getOrThrow<number>('ADB2C_CACHE_TTL');
  private graphClient: Client;

  constructor(
    private readonly configService: ConfigService,
    private readonly redisService: RedisService,
  ) {
    // ADB2Cへの認証情報
    const credential = new ClientSecretCredential(
      this.configService.getOrThrow<string>('ADB2C_TENANT_ID'),
      this.configService.getOrThrow<string>('ADB2C_CLIENT_ID'),
      this.configService.getOrThrow<string>('ADB2C_CLIENT_SECRET'),
    );
    const authProvider = new TokenCredentialAuthenticationProvider(credential, {
      scopes: ['https://graph.microsoft.com/.default'],
    });

    this.graphClient = Client.initWithMiddleware({ authProvider });
  }

  /**
   * Creates user AzureADB2Cにユーザーを追加する
   * @param email 管理ユーザーのメールアドレス
   * @param password 管理ユーザーのパスワード
   * @param username 管理ユーザーの名前
   * @returns user
   */
  async createUser(
    context: Context,
    email: string,
    password: string,
    username: string,
  ): Promise<{ sub: string } | ConflictError> {
    this.logger.log(
      `[IN] [${context.getTrackingId()}] ${this.createUser.name}`,
    );
    try {
      // ユーザをADB2Cに登録
      const newUser = await this.graphClient.api('users/').post({
        accountEnabled: true,
        displayName: username,
        passwordPolicies: 'DisableStrongPassword',
        passwordProfile: {
          forceChangePasswordNextSignIn: false,
          password: password,
        },
        identities: [
          {
            signinType: ADB2C_SIGN_IN_TYPE.EMAILADDRESS,
            issuer: `${this.tenantName}.onmicrosoft.com`,
            issuerAssignedId: email,
          },
        ],
      });
      return { sub: newUser.id };
    } catch (e) {
      this.logger.error(`[${context.getTrackingId()}] error=${e}`);
      if (e?.statusCode === 400 && e?.body) {
        const error = JSON.parse(e.body);

        // エラーが競合エラーである場合は、メールアドレス重複としてエラーを返す
        if (error?.details?.find((x) => x.code === 'ObjectConflict')) {
          return { reason: 'email', message: 'ObjectConflict' };
        }
      }

      throw e;
    } finally {
      this.logger.log(
        `[OUT] [${context.getTrackingId()}] ${this.createUser.name}`,
      );
    }
  }

  /**
   * ADB2Cのメタデータを取得する
   * @returns meta data
   */
  async getMetaData(context: Context): Promise<B2cMetadata> {
    this.logger.log(
      `[IN] [${context.getTrackingId()}] ${this.getMetaData.name}`,
    );
    try {
      // Azure AD B2Cのメタデータを取得する。 以下のURLから取得できる。
      // https://<テナント名>.b2clogin.com/<テナント名>.onmicrosoft.com/<ユーザーフロー名>/v2.0/.well-known/openid-configuration
      const metaData = await axios
        .get(
          `https://${this.tenantName}.b2clogin.com/${this.tenantName}.onmicrosoft.com/${this.flowName}/v2.0/.well-known/openid-configuration`,
        )
        .then((res) => res.data);

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

  /**
   * IDトークンの署名キーセットを取得する
   * @returns sign key sets
   */
  async getSignKeySets(context: Context): Promise<JwkSignKey[]> {
    this.logger.log(
      `[IN] [${context.getTrackingId()}] ${this.getSignKeySets.name}`,
    );
    try {
      // 署名キーのキーセット配列を取得する。 以下のURLから取得できる。
      const keySets = await axios
        .get(
          `https://${this.tenantName}.b2clogin.com/${this.tenantName}.onmicrosoft.com/${this.flowName}/discovery/v2.0/keys`,
        )
        .then((res) => res.data.keys);

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

  /**
   * change Password 特定のADB2Cのユーザのパスワードを変更する
   * @param externalId ユーザ情報
   * @param password パスワード
   */
  async changePassword(
    context: Context,
    externalId: string,
    password: string,
  ): Promise<void> {
    this.logger.log(
      `[IN] [${context.getTrackingId()}] ${
        this.changePassword.name
      } | params: { ` + `externalId: ${externalId}, };`,
    );
    try {
      // ADB2Cのユーザのパスワードを変更する
      await this.graphClient.api(`/users/${externalId}`).patch({
        passwordProfile: {
          forceChangePasswordNextSignIn: false,
          password: password,
        },
      });
    } catch (e) {
      this.logger.error(`[${context.getTrackingId()}] error=${e}`);
      throw e;
    } finally {
      this.logger.log(
        `[OUT] [${context.getTrackingId()}] ${this.changePassword.name}`,
      );
    }
  }

  /**
   * Azure AD B2Cからユーザ情報を取得する
   * @param externalId 外部ユーザーID
   * @returns ユーザ情報
   */
  async getUser(context: Context, externalId: string): Promise<AdB2cUser> {
    this.logger.log(
      `[IN] [${context.getTrackingId()}] ${this.getUser.name} | params: { ` +
        `externalId: ${externalId} };`,
    );

    try {
      const key = makeADB2CKey(externalId);

      // キャッシュ上に存在していれば、キャッシュから取得する
      const cachedUser = await this.redisService.get<AdB2cUser>(context, key);
      if (cachedUser) {
        this.logger.log(
          `[${context.getTrackingId()}] [CACHE HIT] id: ${externalId}`,
        );
        return cachedUser;
      }

      // キャッシュ上に存在していなければ、ADB2Cから取得してキャッシュに保存する
      const user = await this.graphClient
        .api(`users/${externalId}`)
        .select(['id', 'displayName', 'identities'])
        .get();
      await this.redisService.set(context, key, user, this.ttl);
      this.logger.log(
        `[${context.getTrackingId()}] [ADB2C GET] externalId: ${externalId}`,
      );

      return user;
    } catch (e) {
      this.logger.error(`[${context.getTrackingId()}] error=${e}`);
      throw e;
    } finally {
      this.logger.log(
        `[OUT] [${context.getTrackingId()}] ${this.getUser.name}`,
      );
    }
  }
  /**
   * Gets users
   * @param externalIds
   * @returns users
   */
  async getUsers(
    context: Context,
    externalIds: string[],
  ): Promise<AdB2cUser[]> {
    this.logger.log(
      `[IN] [${context.getTrackingId()}] ${
        this.getUsers.name
      } | params: { externalIds:[${externalIds.join(',')}] };`,
    );

    const keys = externalIds.map((externalId) => makeADB2CKey(externalId));
    const cache = await this.redisService.mget<AdB2cUser>(context, keys);

    // キャッシュ上に存在していれば、キャッシュから取得する
    const cachedUsers = cache.flatMap((x) => (x.value ? [x.value] : []));
    if (cachedUsers.length > 0) {
      this.logger.log(
        `[${context.getTrackingId()}] [CACHE HIT] ids: ${cachedUsers
          .map((x) => x.id)
          .join(',')}`,
      );
    }

    // キャッシュ上に存在していなければ、ADB2Cから取得する
    const queryExternalIds = cache
      .filter((x) => x.value === null)
      .map((x) => restoreAdB2cID(x.key));

    const chunkExternalIds = splitArrayInChunksOfFifteen(queryExternalIds);

    try {
      const b2cUsers: AdB2cUser[] = [];
      for (let index = 0; index < chunkExternalIds.length; index++) {
        const element = chunkExternalIds[index];
        const res: AdB2cResponse = await this.graphClient
          .api(`users/`)
          .select(['id', 'displayName', 'identities'])
          .filter(`id in (${element.map((y) => `'${y}'`).join(',')})`)
          .get();

        b2cUsers.push(...res.value);

        // 取得したユーザーをキャッシュに保存する
        const users = res.value.map((user) => {
          const key = makeADB2CKey(user.id);
          return {
            key: key,
            value: user,
          };
        });
        await this.redisService.mset(context, users, this.ttl);
        this.logger.log(
          `[${context.getTrackingId()}] [ADB2C GET] externalIds: ${res.value
            ?.map((x) => x.id)
            .join(',')}`,
        );
      }

      return [...cachedUsers, ...b2cUsers];
    } catch (e) {
      this.logger.error(`[${context.getTrackingId()}] error=${e}`);
      const { statusCode } = e;
      if (statusCode === 429) {
        throw new Adb2cTooManyRequestsError();
      }

      throw e;
    } finally {
      this.logger.log(
        `[OUT] [${context.getTrackingId()}] ${this.getUsers.name}`,
      );
    }
  }
  /**
   * Azure AD B2Cからユーザ情報を削除する
   * @param externalId 外部ユーザーID
   * @param context コンテキスト
   */
  async deleteUser(externalId: string, context: Context): Promise<void> {
    this.logger.log(
      `[IN] [${context.getTrackingId()}] ${
        this.deleteUser.name
      } | params: { externalId: ${externalId} };`,
    );

    try {
      // https://learn.microsoft.com/en-us/graph/api/user-delete?view=graph-rest-1.0&tabs=javascript#example
      await this.graphClient.api(`users/${externalId}`).delete();
      this.logger.log(
        `[${context.getTrackingId()}] [ADB2C DELETE] externalId: ${externalId}`,
      );

      // キャッシュからも削除する
      try {
        await this.redisService.del(context, makeADB2CKey(externalId));
      } catch (e) {
        // キャッシュからの削除に失敗しても、ADB2Cからの削除は成功しているため例外はスローしない
        this.logger.error(`[${context.getTrackingId()}] error=${e}`);
      }
    } catch (e) {
      this.logger.error(`[${context.getTrackingId()}] error=${e}`);
      throw e;
    } finally {
      this.logger.log(
        `[OUT] [${context.getTrackingId()}] ${this.deleteUser.name}`,
      );
    }
  }

  /**
   * Azure AD B2Cからユーザ情報を削除する（複数）
   * @param externalIds 外部ユーザーID
   * @param context コンテキスト
   */
  async deleteUsers(externalIds: string[], context: Context): Promise<void> {
    this.logger.log(
      `[IN] [${context.getTrackingId()}] ${
        this.deleteUsers.name
      } | params: { externalIds: ${externalIds} };`,
    );

    try {
      // 複数ユーザーを一括削除する方法がないため、1人ずつで削除を行う（rate limitに大きな影響がないこと確認済）
      const results = await Promise.allSettled(
        externalIds.map(async (externalId) => {
          await this.graphClient.api(`users/${externalId}`).delete();
          this.logger.log(
            `[${context.getTrackingId()}] [ADB2C DELETE] externalId: ${externalId}`,
          );

          // キャッシュからも削除する
          try {
            await this.redisService.del(context, makeADB2CKey(externalId));
          } catch (e) {
            // キャッシュからの削除に失敗しても、ADB2Cからの削除は成功しているため例外はスローしない
            this.logger.error(`[${context.getTrackingId()}] error=${e}`);
          }
        }),
      );

      // 失敗したプロミスのエラーをログに記録
      results.forEach((result, index) => {
        // statusがrejectedでない場合は、エラーが発生していないためログに記録しない
        if (result.status !== 'rejected') {
          return;
        }

        const failedId = externalIds[index];
        if (isPromiseRejectedResult(result)) {
          const error = result.reason.toString();

          this.logger.error(
            `${MANUAL_RECOVERY_REQUIRED} [${context.getTrackingId()}] Failed to delete user ${failedId}: ${error}`,
          );
        } else {
          this.logger.error(
            `${MANUAL_RECOVERY_REQUIRED} [${context.getTrackingId()}] Failed to delete user ${failedId}`,
          );
        }
      });
    } catch (e) {
      this.logger.error(`[${context.getTrackingId()}] error=${e}`);
      throw e;
    } finally {
      this.logger.log(
        `[OUT] [${context.getTrackingId()}] ${this.deleteUsers.name}`,
      );
    }
  }
}

// ID指定で検索できる最大数は15のため、文字列の配列を１５要素ずつ区切る。
// PJ状況的にAzure AD B2C側のユーザーパラメータを増やして一括Queryできるようにする事が難しいので個別にQueryする。[2023/10/29]
const splitArrayInChunksOfFifteen = (arr: string[]): string[][] => {
  const result: string[][] = [];
  const chunkSize = 15; // SDKの制限数
  for (let i = 0; i < arr.length; i += chunkSize) {
    result.push(arr.slice(i, i + chunkSize));
  }
  return result;
};
