import {
  AccountProfile,
  AnonymousIdentity,
  CitizenIdentity,
  EmployeeIdentity,
  ExternalUserIdentity,
  GenericUserIdentity,
  GuestUserIdentity,
  Identity,
  ServiceAccountIdentity,
  UnknownIdentity,
  UnknownUserIdentity,
  UserServiceAccountIdentity,
} from '../models/identities';

const usernameClaimName = 'userName';

/**
 * creates a specific type of Identity from the submitted JWT.
 * @param jwt specifies the JWT issued by TokenAPI
 * @returns a specific type of Identity
 * @description The purpose of this function is to classify the type of user behind the access token and select the right claim
 * for the unique ID of the account, as we need the most stable ID as possible.
 *
 * There is a displayName attribute when you need to display the identity in a GUI.
 *
 * Also, we provide a toString() function to format a verbose representation of the identity, used to audit as much information as possible.
 * @example
 * const identity = createIdentityFromJwt(req.jwt);
 * product.name = newName;
 * product.modifiedBy = identity.id; // udoejo3
 * @example
 * const identity = createIdentityFromJwt(req.jwt);
 * console.log(`Order created by "${identity}"`); // Order created by "user:employee:udoejo3:John DOE:john.doe@montreal.ca:100674051:421408000000:vdm"
 * @example
 * const identity = createIdentityFromJwt(req.jwt);
 * res.send({ message: `Welcome back ${identity.displayName}`}); // { message: "Welcome back John DOE" }
 * @example
 * const identity = createIdentityFromJwt(req.jwt);
 * if (identity.type === 'user' && identity.attributes.type === 'employee') {
 *    const employeeRecord = await employeeAPI.findEmployee(identity.attributes.registrationNumber);
 * } else {
 *    throw new Error(`Expected an employee but received "${identity}"`);
 * }
 * @example
 * const identity = createIdentityFromJwt(req.jwt);
 * if (identity.type === 'user') {
 *   if (identity.attributes.email) {
 *      emailService.send(identity.attributes.email, 'Welcome', 'Bla bla...');
 *   } else {
 *      throw new Error(`User "${identity}" has no email`);
 *   }
 * }
 */
export function createIdentityFromJwt(jwt: any): Identity {
  if (jwt === null || jwt === undefined) {
    throw new Error('"jwt" parameter is required');
  }
  const issuer = getStringClaim(jwt, 'iss');
  const realm = getStringClaim(jwt, 'realm');
  const aud = getStringClaim(jwt, 'aud');
  const sub = getStringClaim(jwt, 'sub');
  const audDisplayName = getOptionalStringClaim(jwt, 'displayName');
  const oid = getOptionalStringClaim(jwt, 'oid');
  const env = getOptionalStringClaim(jwt, 'env');
  const userType = getOptionalStringClaim(jwt, 'userType') ?? 'citizen';
  const accessTokenIssuer = getOptionalStringClaim(jwt, 'accessTokenIssuer');
  const isGenericUser = jwt.isGenericAccount === true;

  //----------< Anonymous user >-----------------------------------------
  if (userType === 'anonymous') {
    const type = 'anonymous';
    if (realm !== 'anonymous') {
      throw new Error(`${type}: expected token to belong to the "anonymous" realm`);
    }
    const username = getStringClaim(jwt, usernameClaimName, type);
    const result: AnonymousIdentity = {
      type,
      id: username,
      displayName: getStringClaim(jwt, 'name', type),
      attributes: {
        type: 'anonymous',
        username,
      },
      source: {
        aud,
        audDisplayName,
        issuer,
        accessTokenIssuer,
        env,
        realm,
        claim: usernameClaimName,
        internalId: sub,
      },
      toString(this: AnonymousIdentity) {
        return encodeComponents(this.type, this.id, this.displayName);
      },
    };
    return result;
  }
  //----------< Client Service account >-----------------------------------------
  if (userType === 'client') {
    const type = 'service-account';
    const subType = 'client';
    const result: ServiceAccountIdentity = {
      type,
      id: aud, // Gluu clientID or Azure appId
      displayName: getStringClaim(jwt, 'displayName', type, subType),
      attributes: {
        type: subType,
      },
      source: {
        aud,
        audDisplayName,
        issuer,
        accessTokenIssuer,
        env,
        realm,
        claim: 'aud',
        internalId: oid ?? sub,
      },
      toString(this: ServiceAccountIdentity) {
        return encodeComponents(this.type, this.attributes.type, this.id, this.displayName);
      },
    };
    return result;
  }
  //----------< User Service account >-----------------------------------------
  if (userType === 'serviceAccount') {
    const type = 'service-account';
    const subType = 'user';
    const username = getStringClaim(jwt, usernameClaimName, type, subType);
    const result: UserServiceAccountIdentity = {
      type,
      id: username,
      displayName: getStringClaim(jwt, 'name', type, subType),
      attributes: {
        type: subType,
        username,
      },
      source: {
        aud,
        audDisplayName,
        issuer,
        accessTokenIssuer,
        env,
        realm,
        claim: usernameClaimName,
        internalId: sub,
      },
      toString(this: UserServiceAccountIdentity) {
        return encodeComponents(this.type, this.attributes.type, this.id, this.displayName);
      },
    };
    return result;
  }
  //----------< Citizen >-----------------------------------------
  if (userType === 'citizen') {
    const type = 'user';
    const subType = 'citizen';
    if (realm !== 'citizens') {
      throw new Error(`${type}:${subType}: expected token to belong to the "citizens" realm`);
    }
    const result: CitizenIdentity = {
      type,
      id: getStringClaim(jwt, 'mtlIdentityId', type, subType),
      displayName: getStringClaim(jwt, 'name', type, subType),
      attributes: {
        type: subType,
        username: getStringClaim(jwt, usernameClaimName, type, subType),
        email: getStringClaim(jwt, 'email', type, subType),
        firstName: getStringClaim(jwt, 'givenName', type, subType),
        lastName: getStringClaim(jwt, 'familyName', type, subType),
      },
      source: {
        aud,
        audDisplayName,
        issuer,
        accessTokenIssuer,
        env,
        realm,
        claim: 'mtlIdentityId',
        internalId: oid ?? sub,
      },
      toString(this: CitizenIdentity) {
        return encodeComponents(
          this.type,
          this.attributes.type,
          this.id,
          this.displayName,
          this.attributes.email,
        );
      },
    };
    return result;
  }
  //----------< Employee >-----------------------------------------
  if (userType === 'employee' && isEmployee(jwt)) {
    const type = 'user';
    const subType = 'employee';
    if (realm !== 'employees') {
      throw new Error(`${type}:${subType}: expected token to belong to the "employees" realm`);
    }
    const username = getStringClaim(jwt, usernameClaimName, type, subType);
    const result: EmployeeIdentity = {
      type,
      id: username,
      displayName: getStringClaim(jwt, 'name', type, subType),
      attributes: {
        type: subType,
        email: getStringClaim(jwt, 'email', type, subType),
        username,
        registrationNumber: getStringClaim(jwt, 'employeeNumber', type, subType),
        department: getStringClaim(jwt, 'department', type, subType),
        firstName: getStringClaim(jwt, 'givenName', type, subType),
        lastName: getStringClaim(jwt, 'familyName', type, subType),
        accountProfile: getAccountProfile(jwt),
        isGeneric: isGenericUser,
      },
      source: {
        aud,
        audDisplayName,
        issuer,
        accessTokenIssuer,
        env,
        realm,
        claim: usernameClaimName,
        internalId: oid ?? sub,
      },
      toString(this: EmployeeIdentity) {
        return encodeComponents(
          this.type,
          this.attributes.type,
          this.id,
          this.displayName,
          this.attributes.email,
          this.attributes.registrationNumber,
          this.attributes.department,
          this.attributes.accountProfile,
        );
      },
    };
    return result;
  }
  //----------< External user >-----------------------------------------
  if (userType === 'employee' && isExternalUser(jwt)) {
    const type = 'user';
    const subType = 'external';
    if (realm !== 'employees') {
      throw new Error(`${type}:${subType}: expected token to belong to the "employees" realm`);
    }
    const username = getStringClaim(jwt, usernameClaimName, type, subType);
    const result: ExternalUserIdentity = {
      type,
      id: username,
      displayName: getStringClaim(jwt, 'name', type, subType),
      attributes: {
        type: subType,
        email: getOptionalStringClaim(jwt, 'email'),
        username,
        department: getOptionalStringClaim(jwt, 'department'),
        firstName: getStringClaim(jwt, 'givenName', type, subType),
        lastName: getStringClaim(jwt, 'familyName', type, subType),
        accountProfile: getAccountProfile(jwt),
        isGeneric: isGenericUser,
      },
      source: {
        aud,
        audDisplayName,
        issuer,
        accessTokenIssuer,
        env,
        realm,
        claim: usernameClaimName,
        internalId: oid ?? sub,
      },
      toString(this: ExternalUserIdentity) {
        return encodeComponents(
          this.type,
          this.attributes.type,
          this.id,
          this.displayName,
          this.attributes.email,
          this.attributes.department,
          this.attributes.accountProfile,
        );
      },
    };
    return result;
  }
  //----------< Guest user >-----------------------------------------
  if (isGuestUser(jwt)) {
    const type = 'user';
    const subType = 'guest-user';
    const username = getStringClaim(jwt, usernameClaimName, type, subType);
    const result: GuestUserIdentity = {
      type,
      id: username,
      displayName: getStringClaim(jwt, 'name', type, subType),
      attributes: {
        type: 'guest',
        email: getStringClaim(jwt, 'email', type, subType),
        username,
        department: getOptionalStringClaim(jwt, 'department'),
        firstName: getOptionalStringClaim(jwt, 'givenName'),
        lastName: getOptionalStringClaim(jwt, 'familyName'),
        accountProfile: getAccountProfile(jwt),
      },
      source: {
        aud,
        audDisplayName,
        issuer,
        accessTokenIssuer,
        env,
        realm,
        claim: usernameClaimName,
        internalId: oid ?? sub,
      },
      toString(this: GuestUserIdentity) {
        return encodeComponents(
          this.type,
          this.attributes.type,
          realm,
          this.id,
          this.displayName,
          this.attributes.email,
        );
      },
    };
    return result;
  }
  //----------< Generic user >-----------------------------------------
  if (isGenericUser) {
    const type = 'user';
    const subType = 'generic-user';
    const username = getStringClaim(jwt, usernameClaimName, type, subType);
    const result: GenericUserIdentity = {
      type,
      id: username,
      displayName: getStringClaim(jwt, 'name', type, subType),
      attributes: {
        type: 'generic',
        username,
        email: getOptionalStringClaim(jwt, 'email'),
        department: getOptionalStringClaim(jwt, 'department'),
        firstName: getStringClaim(jwt, 'givenName', type, subType),
        lastName: getStringClaim(jwt, 'familyName', type, subType),
        accountProfile: getAccountProfile(jwt),
      },
      source: {
        aud,
        audDisplayName,
        issuer,
        accessTokenIssuer,
        env,
        realm,
        claim: usernameClaimName,
        internalId: oid ?? sub,
      },
      toString(this: GenericUserIdentity) {
        return encodeComponents(
          this.type,
          this.attributes.type,
          this.id,
          this.displayName,
          this.attributes.email,
          this.attributes.department,
          this.attributes.accountProfile,
        );
      },
    };
    return result;
  }
  //----------< Unknown user type >-----------------------------------------
  const username = getOptionalStringClaim(jwt, usernameClaimName);
  const email = getOptionalStringClaim(jwt, 'email');
  if (username || email) {
    const type = 'user';
    const subType = 'unknown';
    const claim = username ? usernameClaimName : 'email';
    const result: UnknownUserIdentity = {
      type,
      id: getStringClaim(jwt, claim, type, subType),
      displayName: getOptionalStringClaim(jwt, 'name') ?? email ?? username,
      attributes: {
        type: subType,
        email: getOptionalStringClaim(jwt, 'email'),
        username,
        registrationNumber: getOptionalStringClaim(jwt, 'employeeNumber'),
        department: getOptionalStringClaim(jwt, 'department'),
        firstName: getOptionalStringClaim(jwt, 'givenName'),
        lastName: getOptionalStringClaim(jwt, 'familyName'),
        accountProfile: getAccountProfile(jwt),
      },
      source: {
        aud,
        audDisplayName,
        issuer,
        accessTokenIssuer,
        env,
        realm,
        claim: claim,
        internalId: oid ?? sub,
      },
      toString(this: UnknownUserIdentity) {
        return encodeComponents(
          this.type,
          this.attributes.type,
          this.id,
          this.displayName,
          this.attributes.email,
          this.attributes.registrationNumber,
          this.attributes.department,
          this.attributes.accountProfile,
        );
      },
    };
    return result;
  }
  //----------< Unknown identity type >-----------------------------------------
  const result: UnknownIdentity = {
    type: 'unknown',
    id: sub,
    displayName: getOptionalStringClaim(jwt, 'name') ?? 'unknown',
    attributes: {
      type: 'unknown',
    },
    source: {
      aud,
      audDisplayName,
      issuer,
      accessTokenIssuer,
      env,
      realm,
      claim: 'sub',
      internalId: oid ?? sub,
    },
    toString(this: UnknownIdentity) {
      return encodeComponents(this.type, this.id, this.displayName);
    },
  };
  return result;
}

function getOptionalStringClaim(jwt: any, name: string): string | undefined {
  const result = jwt[name];
  if (result === undefined || result === null) {
    return undefined;
  }
  if (typeof result !== 'string') {
    throw new Error(`Expected claim '${name}' to contain a string but received: ${result}`);
  }
  return result;
}

function getStringClaim(
  jwt: any,
  name: string,
  identityType?: string,
  identitySubType?: string,
): string | undefined {
  const result = getOptionalStringClaim(jwt, name);
  if (!result) {
    const subType = identitySubType ? `${identitySubType}: ` : '';
    const prefix = (identityType ? `${identityType}: ` : '') + subType;
    throw new Error(`${prefix}expected to find the "${name}" claim in the JWT`);
  }
  return result;
}

function encodeComponents(...components: string[]): string {
  return components.map((x) => (x ?? '').replace(':', '_')).join(':');
}

function getAccountProfile(jwt: any): AccountProfile {
  const email = getOptionalStringClaim(jwt, 'email');
  if (email) {
    if (email.endsWith('@spvm.qc.ca')) {
      return 'spvm';
    }
    if (email.endsWith('.adm@lavilledemontreal.omnicrosoft.com')) {
      return 'vdm-admin';
    }
    if (email.toLocaleLowerCase().endsWith('.adm@montrealville.omnicrosoft.com')) {
      return 'vdm-admin';
    }
  }
  return 'vdm';
}

function isValidCodeU(code: string): boolean {
  const re = /^u[a-z0-9]{4,22}$/gi;
  return re.test(code);
}

function isEmployee(jwt: any): boolean {
  const username = getOptionalStringClaim(jwt, usernameClaimName);
  if (!username) {
    return false;
  }
  const employeeNumber = getOptionalStringClaim(jwt, 'employeeNumber');
  const department = getOptionalStringClaim(jwt, 'department');
  const name = getOptionalStringClaim(jwt, 'name');
  const firstName = getOptionalStringClaim(jwt, 'givenName');
  const lastName = getOptionalStringClaim(jwt, 'familyName');
  const isCodeU = isValidCodeU(username);
  const hasEmployeeNumber = !!employeeNumber;
  const hasDepartment = !!department;
  const hasNames = !!name && !!firstName && !!lastName;
  return isCodeU && hasEmployeeNumber && hasDepartment && hasNames;
}

function isValidCodeX(code: string): boolean {
  const re = /^x[a-z0-9]{4,22}$/gi;
  return re.test(code);
}

function isExternalUser(jwt: any): boolean {
  const username = getOptionalStringClaim(jwt, usernameClaimName);
  if (!username) {
    return false;
  }
  const name = getOptionalStringClaim(jwt, 'name');
  const firstName = getOptionalStringClaim(jwt, 'givenName');
  const lastName = getOptionalStringClaim(jwt, 'familyName');
  const hasNames = !!name && !!firstName && !!lastName;
  if (!hasNames) {
    return false;
  }
  const email = getOptionalStringClaim(jwt, 'email');
  const isCodeX = isValidCodeX(username);
  const isExtEmail = email && email.toLocaleLowerCase().includes('.ext@');
  return isCodeX || isExtEmail;
}

function isGuestUser(jwt: any): boolean {
  const username = getOptionalStringClaim(jwt, usernameClaimName);
  if (!username) {
    return false;
  }
  const email = getOptionalStringClaim(jwt, 'email');
  if (!email) {
    return false;
  }
  const name = getOptionalStringClaim(jwt, 'name');
  if (!name) {
    return false;
  }
  return (
    username.endsWith('#EXT#@lavilledemontreal.omnicrosoft.com') ||
    username.endsWith('#EXT#@MontrealVille.onmicrosoft.com')
  );
}
