import merge from 'lodash.merge';
import * as jwt from 'jsonwebtoken';
import Emittery from 'emittery';
import {
  User,
  LoginResult,
  Tokens,
  Session,
  ImpersonationUserIdentity,
  ImpersonationResult,
  HookListener,
  DatabaseInterface,
  AuthenticationService,
  ConnectionInformations,
} from '@accounts/types';

import { generateAccessToken, generateRefreshToken, generateRandomToken } from './utils/tokens';

import { emailTemplates, sendMail } from './utils/email';
import { ServerHooks } from './utils/server-hooks';

import { AccountsServerOptions } from './types/accounts-server-options';
import { JwtData } from './types/jwt-data';
import { EmailTemplateType } from './types/email-template-type';
import { JwtPayload } from './types/jwt-payload';
import { AccountsJsError } from './utils/accounts-error';
import {
  AuthenticateWithServiceErrors,
  LoginWithServiceErrors,
  ImpersonateErrors,
  FindSessionByAccessTokenErrors,
  RefreshTokensErrors,
  LogoutErrors,
  ResumeSessionErrors,
} from './errors';
import { isString } from './utils/validation';

const defaultOptions = {
  ambiguousErrorMessages: true,
  tokenSecret: 'secret' as
    | string
    | {
        publicKey: jwt.Secret;
        privateKey: jwt.Secret;
      },
  tokenConfigs: {
    accessToken: {
      expiresIn: '90m',
    },
    refreshToken: {
      expiresIn: '7d',
    },
  },
  emailTemplates,
  sendMail,
  siteUrl: 'http://localhost:3000',
  userObjectSanitizer: (user: User) => user,
  createNewSessionTokenOnRefresh: false,
  useInternalUserObjectSanitizer: true,
  useStatelessSession: false,
};

export class AccountsServer<CustomUser extends User = User> {
  public options: AccountsServerOptions<CustomUser> & typeof defaultOptions;
  private services: { [key: string]: AuthenticationService<CustomUser> };
  private db: DatabaseInterface<CustomUser>;
  private hooks: Emittery;

  constructor(
    options: AccountsServerOptions<CustomUser>,
    services: { [key: string]: AuthenticationService<CustomUser> }
  ) {
    this.options = merge({ ...defaultOptions }, options);
    if (!this.options.db) {
      throw new Error('A database driver is required');
    }
    if (this.options.tokenSecret === defaultOptions.tokenSecret) {
      console.log(`
You are using the default secret "${this.options.tokenSecret}" which is not secure.
Please change it with a strong random token.`);
    }
    if (this.options.ambiguousErrorMessages && this.options.enableAutologin) {
      throw new Error(
        `Can't enable autologin when ambiguous error messages are enabled (https://www.accountsjs.com/docs/api/server/globals#ambiguouserrormessages).
Please set ambiguousErrorMessages to false to be able to use autologin.`
      );
    }

    this.services = services || {};
    this.db = this.options.db;

    // Set the db to all services
    for (const service in this.services) {
      this.services[service].setStore(this.db);
      this.services[service].server = this;
    }

    // Initialize hooks
    this.hooks = new Emittery();
  }

  public getServices(): { [key: string]: AuthenticationService } {
    return this.services;
  }

  public getOptions(): AccountsServerOptions<CustomUser> {
    return this.options;
  }

  public getHooks(): Emittery {
    return this.hooks;
  }

  /**
   * Subscribe to an accounts-js event.
   * ```javascript
   * accountsServer.on(ServerHooks.ValidateLogin, ({ user }) => {
   *   // This hook is called every time a user try to login
   *   // You can use it to only allow users with verified email to login
   * });
   * ```
   */
  public on(eventName: string, callback: HookListener): () => void {
    this.hooks.on(eventName, callback);

    return () => this.hooks.off(eventName, callback);
  }

  /**
   * @description Try to authenticate the user for a given service
   * @throws {@link AuthenticateWithServiceErrors}
   */
  public async authenticateWithService(
    serviceName: string,
    params: any,
    infos: ConnectionInformations
  ): Promise<boolean> {
    const hooksInfo: any = {
      // The service name, such as “password” or “twitter”.
      service: serviceName,
      // The connection informations <ConnectionInformations>
      connection: infos,
      // Params received
      params,
    };
    try {
      if (!this.services[serviceName]) {
        throw new AccountsJsError(
          `No service with the name ${serviceName} was registered.`,
          AuthenticateWithServiceErrors.ServiceNotFound
        );
      }

      const user: CustomUser | null = await this.services[serviceName].authenticate(params);
      hooksInfo.user = user;
      if (!user) {
        throw new AccountsJsError(
          `Service ${serviceName} was not able to authenticate user`,
          AuthenticateWithServiceErrors.AuthenticationFailed
        );
      }
      if (user.deactivated) {
        throw new AccountsJsError(
          'Your account has been deactivated',
          AuthenticateWithServiceErrors.UserDeactivated
        );
      }

      await this.hooks.emit(ServerHooks.AuthenticateSuccess, hooksInfo);
      return true;
    } catch (err) {
      await this.hooks.emit(ServerHooks.AuthenticateError, { ...hooksInfo, error: err });
      throw err;
    }
  }

  /**
   * @throws {@link LoginWithServiceErrors}
   */
  public async loginWithService(
    serviceName: string,
    params: any,
    infos: ConnectionInformations
  ): Promise<LoginResult> {
    const hooksInfo: any = {
      // The service name, such as “password” or “twitter”.
      service: serviceName,
      // The connection informations <ConnectionInformations>
      connection: infos,
      // Params received
      params,
    };
    try {
      if (!this.services[serviceName]) {
        throw new AccountsJsError(
          `No service with the name ${serviceName} was registered.`,
          LoginWithServiceErrors.ServiceNotFound
        );
      }

      const user: CustomUser | null = await this.services[serviceName].authenticate(params);
      hooksInfo.user = user;
      if (!user) {
        throw new AccountsJsError(
          `Service ${serviceName} was not able to authenticate user`,
          LoginWithServiceErrors.AuthenticationFailed
        );
      }
      if (user.deactivated) {
        throw new AccountsJsError(
          'Your account has been deactivated',
          LoginWithServiceErrors.UserDeactivated
        );
      }

      // Let the user validate the login attempt
      await this.hooks.emitSerial(ServerHooks.ValidateLogin, hooksInfo);
      const loginResult = await this.loginWithUser(user, infos);
      await this.hooks.emit(ServerHooks.LoginSuccess, hooksInfo);
      return loginResult;
    } catch (err) {
      await this.hooks.emit(ServerHooks.LoginError, { ...hooksInfo, error: err });
      throw err;
    }
  }

  /**
   * @description Server use only.
   * This method creates a session without authenticating any user identity.
   * Any authentication should happen before calling this function.
   * @param {User} user - The user object.
   * @param {ConnectionInformations} infos - User's connection informations.
   * @returns {Promise<LoginResult>} - Session tokens and user object.
   */
  public async loginWithUser(
    user: CustomUser,
    infos: ConnectionInformations
  ): Promise<LoginResult> {
    const token = await this.createSessionToken(user);
    const sessionId = await this.db.createSession(user.id, token, infos);

    const { accessToken, refreshToken } = await this.createTokens({
      token,
      user,
    });

    return {
      sessionId,
      tokens: {
        refreshToken,
        accessToken,
      },
      user,
    };
  }

  /**
   * @description Impersonate to another user.
   * For security reasons, even if `useStatelessSession` is set to true the token will be checked against the database.
   * @param {string} accessToken - User access token.
   * @param {object} impersonated - impersonated user.
   * @param {ConnectionInformations} infos - User connection informations.
   * @returns {Promise<Object>} - ImpersonationResult
   * @throws {@link LoginWithServiceErrors}
   */
  public async impersonate(
    accessToken: string,
    impersonated: ImpersonationUserIdentity,
    infos: ConnectionInformations
  ): Promise<ImpersonationResult> {
    try {
      const session = await this.findSessionByAccessToken(accessToken);
      if (!session.valid) {
        throw new AccountsJsError(
          'Session is not valid for user',
          ImpersonateErrors.InvalidSession
        );
      }

      const user = await this.db.findUserById(session.userId);
      if (!user) {
        throw new AccountsJsError('User not found', ImpersonateErrors.UserNotFound);
      }

      let impersonatedUser;
      if (impersonated.userId) {
        impersonatedUser = await this.db.findUserById(impersonated.userId);
      } else if (impersonated.username) {
        impersonatedUser = await this.db.findUserByUsername(impersonated.username);
      } else if (impersonated.email) {
        impersonatedUser = await this.db.findUserByEmail(impersonated.email);
      }

      if (!impersonatedUser) {
        if (this.options.ambiguousErrorMessages) {
          return { authorized: false };
        }
        throw new AccountsJsError(
          `Impersonated user not found`,
          ImpersonateErrors.ImpersonatedUserNotFound
        );
      }

      if (!this.options.impersonationAuthorize) {
        return { authorized: false };
      }

      const isAuthorized = await this.options.impersonationAuthorize(user, impersonatedUser);
      if (!isAuthorized) {
        return { authorized: false };
      }

      const token = await this.createSessionToken(impersonatedUser);
      const newSessionId = await this.db.createSession(impersonatedUser.id, token, infos, {
        impersonatorUserId: user.id,
      });

      const impersonationTokens = await this.createTokens({
        token,
        isImpersonated: true,
        user,
      });
      const impersonationResult = {
        authorized: true,
        tokens: impersonationTokens,
        user: this.sanitizeUser(impersonatedUser),
      };

      await this.hooks.emit(ServerHooks.ImpersonationSuccess, {
        user,
        impersonationResult,
        sessionId: newSessionId,
      });

      return impersonationResult;
    } catch (e) {
      await this.hooks.emit(ServerHooks.ImpersonationError, e);

      throw e;
    }
  }

  /**
   * @description Refresh a user token.
   * @param {string} accessToken - User access token.
   * @param {string} refreshToken - User refresh token.
   * @param {ConnectionInformations} infos - User connection informations.
   * @returns {Promise<Object>} - LoginResult.
   * @throws {@link RefreshTokensErrors}
   */
  public async refreshTokens(
    accessToken: string,
    refreshToken: string,
    infos: ConnectionInformations
  ): Promise<LoginResult> {
    try {
      if (!isString(accessToken) || !isString(refreshToken)) {
        throw new AccountsJsError(
          'An accessToken and refreshToken are required',
          RefreshTokensErrors.InvalidTokens
        );
      }

      let sessionToken: string;
      try {
        jwt.verify(refreshToken, this.getSecretOrPublicKey());
        const decodedAccessToken = jwt.verify(accessToken, this.getSecretOrPublicKey(), {
          ignoreExpiration: true,
        }) as { data: JwtData };
        sessionToken = decodedAccessToken.data.token;
      } catch (err) {
        throw new AccountsJsError(
          'Tokens are not valid',
          RefreshTokensErrors.TokenVerificationFailed
        );
      }

      const session: Session | null = await this.db.findSessionByToken(sessionToken);
      if (!session) {
        throw new AccountsJsError('Session not found', RefreshTokensErrors.SessionNotFound);
      }

      if (session.valid) {
        const user = await this.db.findUserById(session.userId);
        if (!user) {
          throw new AccountsJsError('User not found', RefreshTokensErrors.UserNotFound);
        }

        let newToken;
        if (this.options.createNewSessionTokenOnRefresh) {
          newToken = await this.createSessionToken(user);
        }

        const tokens = await this.createTokens({ token: newToken || sessionToken, user });
        await this.db.updateSession(session.id, infos, newToken);

        const result = {
          sessionId: session.id,
          tokens,
          user,
          infos,
        };
        await this.hooks.emit(ServerHooks.RefreshTokensSuccess, result);

        return result;
      } else {
        throw new AccountsJsError('Session is no longer valid', RefreshTokensErrors.InvalidSession);
      }
    } catch (err) {
      await this.hooks.emit(ServerHooks.RefreshTokensError, err);

      throw err;
    }
  }

  /**
   * @description Refresh a user token.
   * @param {string} token - User session token.
   * @param {boolean} isImpersonated - Should be true if impersonating another user.
   * @param {User} user - The user object.
   * @returns {Promise<Tokens>} - Return a new accessToken and refreshToken.
   */
  public async createTokens({
    token,
    isImpersonated = false,
    user,
  }: {
    token: string;
    isImpersonated?: boolean;
    user: CustomUser;
  }): Promise<Tokens> {
    const { tokenConfigs } = this.options;
    const jwtData: JwtData = {
      token,
      isImpersonated,
      userId: user.id,
    };

    const accessToken = generateAccessToken({
      payload: await this.createJwtPayload(jwtData, user),
      secret: this.getSecretOrPrivateKey(),
      config: tokenConfigs.accessToken,
    });
    const refreshToken = generateRefreshToken({
      secret: this.getSecretOrPrivateKey(),
      config: tokenConfigs.refreshToken,
    });

    return { accessToken, refreshToken };
  }

  /**
   * @description Logout a user and invalidate his session.
   * @param {string} accessToken - User access token.
   * @returns {Promise<void>} - Return a promise.
   * @throws {@link LogoutErrors}
   */
  public async logout(accessToken: string): Promise<void> {
    try {
      const session: Session = await this.findSessionByAccessToken(accessToken);

      if (session.valid) {
        await this.db.invalidateSession(session.id);
        await this.hooks.emit(ServerHooks.LogoutSuccess, {
          session,
          accessToken,
        });
      } else {
        throw new AccountsJsError('Session is no longer valid', LogoutErrors.InvalidSession);
      }
    } catch (error) {
      await this.hooks.emit(ServerHooks.LogoutError, error);

      throw error;
    }
  }

  /**
   * @description Resume the current session associated to the access token. Will throw if the token
   * or the session is invalid.
   * If `useStatelessSession` is false the session validity will be checked against the database.
   * @param accessToken - User JWT access token.
   * @returns Return the user associated to the session.
   * @throws {@link ResumeSessionErrors}
   */
  public async resumeSession(accessToken: string): Promise<CustomUser> {
    try {
      if (!isString(accessToken)) {
        throw new AccountsJsError('An accessToken is required', ResumeSessionErrors.InvalidToken);
      }

      let sessionToken: string;
      let userId: string;
      try {
        const decodedAccessToken = jwt.verify(accessToken, this.getSecretOrPublicKey()) as {
          data: JwtData;
        };
        sessionToken = decodedAccessToken.data.token;
        userId = decodedAccessToken.data.userId;
      } catch (err) {
        throw new AccountsJsError(
          'Tokens are not valid',
          ResumeSessionErrors.TokenVerificationFailed
        );
      }

      // If the session is stateful we check the validity of the token against the db
      let session: Session | null = null;
      if (!this.options.useStatelessSession) {
        session = await this.db.findSessionByToken(sessionToken);
        if (!session) {
          throw new AccountsJsError('Session not found', ResumeSessionErrors.SessionNotFound);
        }
        if (!session.valid) {
          throw new AccountsJsError('Invalid Session', ResumeSessionErrors.InvalidSession);
        }
      }

      const user = await this.db.findUserById(userId);
      if (!user) {
        throw new AccountsJsError('User not found', ResumeSessionErrors.UserNotFound);
      }

      await this.options.resumeSessionValidator?.(user, session!);

      await this.hooks.emit(ServerHooks.ResumeSessionSuccess, { user, accessToken, session });

      return this.sanitizeUser(user);
    } catch (error) {
      await this.hooks.emit(ServerHooks.ResumeSessionError, error);
      throw error;
    }
  }

  /**
   * @description Find a session by his token.
   * @param {string} accessToken
   * @returns {Promise<Session>} - Return a session.
   * @throws {@link FindSessionByAccessTokenErrors}
   */
  public async findSessionByAccessToken(accessToken: string): Promise<Session> {
    if (!isString(accessToken)) {
      throw new AccountsJsError(
        'An accessToken is required',
        FindSessionByAccessTokenErrors.InvalidToken
      );
    }

    let sessionToken: string;
    try {
      const decodedAccessToken = jwt.verify(accessToken, this.getSecretOrPublicKey()) as {
        data: JwtData;
      };
      sessionToken = decodedAccessToken.data.token;
    } catch (err) {
      throw new AccountsJsError(
        'Tokens are not valid',
        FindSessionByAccessTokenErrors.TokenVerificationFailed
      );
    }

    const session: Session | null = await this.db.findSessionByToken(sessionToken);
    if (!session) {
      throw new AccountsJsError(
        'Session not found',
        FindSessionByAccessTokenErrors.SessionNotFound
      );
    }

    return session;
  }

  /**
   * @description Find a user by his id.
   * @param {string} userId - User id.
   * @returns {Promise<Object>} - Return a user or null if not found.
   */
  public findUserById(userId: string): Promise<CustomUser | null> {
    return this.db.findUserById(userId);
  }

  /**
   * @description Deactivate a user, the user will not be able to login until his account is reactivated.
   * @param {string} userId - User id.
   * @returns {Promise<void>} - Return a Promise.
   */
  public async deactivateUser(userId: string): Promise<void> {
    return this.db.setUserDeactivated(userId, true);
  }

  /**
   * @description Activate a user.
   * @param {string} userId - User id.
   * @returns {Promise<void>} - Return a Promise.
   */
  public async activateUser(userId: string): Promise<void> {
    return this.db.setUserDeactivated(userId, false);
  }

  public prepareMail(
    to: string,
    token: string,
    user: CustomUser,
    pathFragment: string,
    emailTemplate: EmailTemplateType,
    from: string
  ): any {
    if (this.options.prepareMail) {
      return this.options.prepareMail(to, token, user, pathFragment, emailTemplate, from);
    }
    return this.defaultPrepareEmail(to, token, user, pathFragment, emailTemplate, from);
  }

  public sanitizeUser(user: CustomUser): CustomUser {
    const { userObjectSanitizer } = this.options;
    const baseUser = this.options.useInternalUserObjectSanitizer
      ? this.internalUserSanitizer(user)
      : user;

    return userObjectSanitizer(baseUser) as CustomUser;
  }

  private internalUserSanitizer(user: CustomUser): CustomUser {
    // Remove services from the user object
    const {
      // eslint-disable-next-line @typescript-eslint/no-unused-vars
      services,
      ...sanitizedUser
    } = user;
    return sanitizedUser as any;
  }

  private defaultPrepareEmail(
    to: string,
    token: string,
    user: CustomUser,
    pathFragment: string,
    emailTemplate: EmailTemplateType,
    from: string
  ): object {
    const tokenizedUrl = this.defaultCreateTokenizedUrl(pathFragment, token);
    return {
      from: emailTemplate.from || from,
      to,
      subject: emailTemplate.subject(user),
      text: emailTemplate.text(user, tokenizedUrl),
      html: emailTemplate.html && emailTemplate.html(user, tokenizedUrl),
    };
  }

  private defaultCreateTokenizedUrl(pathFragment: string, token: string): string {
    const siteUrl = this.options.siteUrl;
    return `${siteUrl}/${pathFragment}/${token}`;
  }

  private async createSessionToken(user: CustomUser): Promise<string> {
    return this.options.tokenCreator
      ? this.options.tokenCreator.createToken(user)
      : generateRandomToken();
  }

  private async createJwtPayload(data: JwtData, user: CustomUser): Promise<JwtPayload> {
    return this.options.createJwtPayload
      ? {
          ...(await this.options.createJwtPayload(data, user)),
          data,
        }
      : { data };
  }

  private getSecretOrPublicKey(): jwt.Secret {
    return typeof this.options.tokenSecret === 'string'
      ? this.options.tokenSecret
      : this.options.tokenSecret.publicKey;
  }

  private getSecretOrPrivateKey(): jwt.Secret {
    return typeof this.options.tokenSecret === 'string'
      ? this.options.tokenSecret
      : this.options.tokenSecret.privateKey;
  }
}

export default AccountsServer;
