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

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

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

import { type AccountsServerOptions } from './types/accounts-server-options';
import { type JwtData } from './types/jwt-data';
import { type EmailTemplateType } from './types/email-template-type';
import { type 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';
import { ExecutionContext, Inject, Injectable } from 'graphql-modules';
import { AccountsCoreConfigToken } from './types/AccountsCoreConfig.symbol';
import { AuthenticationServicesToken } from './types/AuthenticationServices.symbol';
import { type AuthenticationServices } from './types/authentication-services';
import { DatabaseInterfaceUserToken } from './types/DatabaseInterfaceUser.symbol';
import { DatabaseInterfaceSessionsToken } from './types/DatabaseInterfaceSessions.symbol';

const defaultOptions = {
  enableAutologin: false,
  micro: false,
  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: <CustomUser extends User = User>(user: CustomUser) => user,
  createNewSessionTokenOnRefresh: false,
  useInternalUserObjectSanitizer: true,
  useStatelessSession: false,
};

@Injectable({
  global: true,
})
export class AccountsServer<CustomUser extends User = User> {
  @ExecutionContext() public context!: ExecutionContext;
  public options: AccountsServerOptions<CustomUser> & typeof defaultOptions;
  private hooks: Emittery;
  private micro: boolean;
  private dbSessions: DatabaseInterfaceSessions;

  constructor(
    @Inject(AccountsCoreConfigToken) options: AccountsServerOptions<CustomUser>,
    @Inject(AuthenticationServicesToken)
    public services:
      | AuthenticationServices<CustomUser>
      | Record<string, new (args: any) => AuthenticationService<CustomUser>>,
    @Inject(DatabaseInterfaceUserToken)
    private db: DatabaseInterface<CustomUser> | DatabaseInterfaceUser<CustomUser>,
    @Inject(DatabaseInterfaceSessionsToken) dbSessions?: DatabaseInterfaceSessions
  ) {
    this.options = merge({ ...defaultOptions }, options);
    this.micro = this.options.micro;
    this.dbSessions = dbSessions ?? (db as DatabaseInterfaceSessions);
    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.`);
    }

    this.services = services || {};

    for (const serviceName in this.services) {
      const service = this.services[serviceName];
      // We have an instance, thus no Dependency Injection is being used (REST)
      // and we must manually set the db and server in all services
      if (typeof service === 'object') {
        service.setUserStore(this.db);
        service.setSessionsStore(this.dbSessions);
        service.server = this;
      }
    }

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

  private getService(serviceName: string) {
    const instanceOrCtor = this.services[serviceName];
    // If it's a constructor we use dependency injection (GraphQL), otherwise we already have an instance (REST)
    const service =
      typeof instanceOrCtor === 'function'
        ? this.context.injector.get(instanceOrCtor)
        : instanceOrCtor;
    if (!service) {
      throw new AccountsJsError(
        `No service with the name ${serviceName} was registered.`,
        AuthenticateWithServiceErrors.ServiceNotFound
      );
    }
    return service;
  }

  public getServices(): AuthenticationServices<CustomUser> {
    return Object.entries(this.services).reduce<AuthenticationServices<CustomUser>>(
      (acc, [name, instanceOrCtor]) => {
        acc[name] =
          typeof instanceOrCtor === 'function'
            ? this.context.injector.get(instanceOrCtor)
            : instanceOrCtor;
        return acc;
      },
      {}
    );
  }

  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 {
      const user: CustomUser | null = await this.getService(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 {
      const user: CustomUser | null = await this.getService(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.dbSessions.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.dbSessions.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.dbSessions.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.dbSessions.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.dbSessions.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 (this.micro) {
        // We need to only verify the access tokens without any additional session logic
        return { id: userId } as CustomUser;
      }

      // 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.dbSessions.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.dbSessions.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;
