import { type Collection, type Db, ObjectId, type CreateIndexesOptions } from 'mongodb';
import {
  type ConnectionInformations,
  type CreateUserServicePassword,
  type DatabaseInterface,
  type User,
  type Session,
} from '@accounts/types';
import { MongoSessions } from '@accounts/mongo-sessions';
import { MongoServicePassword } from '@accounts/mongo-password';
import { MongoServiceMagicLink } from '@accounts/mongo-magic-link';
import { AccountsMongoConfigToken, type AccountsMongoOptions } from './types';
import { MongoConnectionToken } from './types/MongoConnection.symbol';
import { Inject, Injectable } from 'graphql-modules';

type PartialBy<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>>;

const toMongoID = (objectId: string | ObjectId) => {
  if (typeof objectId === 'string') {
    return new ObjectId(objectId);
  }
  return objectId;
};

const defaultOptions = {
  collectionName: 'users',
  timestamps: {
    createdAt: 'createdAt',
    updatedAt: 'updatedAt',
  },
  convertUserIdToMongoObjectId: true,
  convertSessionIdToMongoObjectId: true,
  caseSensitiveUserName: true,
  dateProvider: (date?: Date) => (date ? date.getTime() : Date.now()),
};

@Injectable({
  global: true,
})
export class Mongo implements DatabaseInterface {
  // Options of Mongo class
  private options: AccountsMongoOptions & typeof defaultOptions;
  // Database connection
  private dbConn: Db;
  // Account collection
  private collection: Collection<PartialBy<User & { _id?: string | object }, 'id' | 'deactivated'>>;
  // Session adaptor
  private sessions: MongoSessions;
  // Password service
  private servicePassword: MongoServicePassword;
  // Magic link service
  private serviceMagicLink: MongoServiceMagicLink;

  constructor(
    @Inject(MongoConnectionToken) dbConn: any,
    @Inject(AccountsMongoConfigToken) options: AccountsMongoOptions = {}
  ) {
    this.options = {
      ...defaultOptions,
      ...options,
      timestamps: { ...defaultOptions.timestamps, ...options.timestamps },
    };
    if (!dbConn) {
      throw new Error('A database connection is required');
    }
    this.dbConn = dbConn;
    this.collection = this.dbConn.collection(this.options.collectionName);
    this.sessions = new MongoSessions({ ...this.options, database: this.dbConn });
    this.servicePassword = new MongoServicePassword({
      ...this.options,
      userCollectionName: this.options.collectionName,
      database: this.dbConn,
    });
    this.serviceMagicLink = new MongoServiceMagicLink({
      ...this.options,
      userCollectionName: this.options.collectionName,
      database: this.dbConn,
    });
  }

  /**
   * Setup the mongo indexes needed.
   * @param options Options passed to the mongo native `createIndex` method.
   */
  public async setupIndexes(
    options: Omit<CreateIndexesOptions, 'unique' | 'sparse'> = {}
  ): Promise<void> {
    await this.sessions.setupIndexes(options);
    await this.servicePassword.setupIndexes(options);
    await this.serviceMagicLink.setupIndexes(options);
  }

  public async findUserById(userId: string): Promise<User | null> {
    return this.servicePassword.findUserById(userId);
  }

  public async createUser(user: CreateUserServicePassword): Promise<string> {
    return this.servicePassword.createUser(user);
  }

  public async findUserByEmail(email: string): Promise<User | null> {
    return this.servicePassword.findUserByEmail(email);
  }
  public async findUserByUsername(username: string): Promise<User | null> {
    return this.servicePassword.findUserByUsername(username);
  }

  public async findPasswordHash(userId: string): Promise<string | null> {
    return this.servicePassword.findPasswordHash(userId);
  }

  public async findUserByEmailVerificationToken(token: string): Promise<User | null> {
    return this.servicePassword.findUserByEmailVerificationToken(token);
  }

  public async findUserByResetPasswordToken(token: string): Promise<User | null> {
    return this.servicePassword.findUserByResetPasswordToken(token);
  }

  public async addEmail(userId: string, newEmail: string, verified: boolean): Promise<void> {
    return this.servicePassword.addEmail(userId, newEmail, verified);
  }

  public async removeEmail(userId: string, email: string): Promise<void> {
    return this.servicePassword.removeEmail(userId, email);
  }

  public async verifyEmail(userId: string, email: string): Promise<void> {
    return this.servicePassword.verifyEmail(userId, email);
  }

  public async setUsername(userId: string, newUsername: string): Promise<void> {
    return this.servicePassword.setUsername(userId, newUsername);
  }

  public async setPassword(userId: string, newPassword: string): Promise<void> {
    return this.servicePassword.setPassword(userId, newPassword);
  }

  public async removeAllResetPasswordTokens(userId: string): Promise<void> {
    return this.servicePassword.removeAllResetPasswordTokens(userId);
  }

  public async addEmailVerificationToken(
    userId: string,
    email: string,
    token: string
  ): Promise<void> {
    return this.servicePassword.addEmailVerificationToken(userId, email, token);
  }

  public async addResetPasswordToken(
    userId: string,
    email: string,
    token: string,
    reason: string
  ): Promise<void> {
    return this.servicePassword.addResetPasswordToken(userId, email, token, reason);
  }

  public async addLoginToken(userId: string, email: string, token: string): Promise<void> {
    return this.serviceMagicLink.addLoginToken(userId, email, token);
  }

  public async findUserByLoginToken(token: string): Promise<User | null> {
    return this.serviceMagicLink.findUserByLoginToken(token);
  }

  public async removeAllLoginTokens(userId: string): Promise<void> {
    return this.serviceMagicLink.removeAllLoginTokens(userId);
  }

  public async createSession(
    userId: string,
    token: string,
    connection: ConnectionInformations = {},
    extraData?: object
  ): Promise<string> {
    return this.sessions.createSession(userId, token, connection, extraData);
  }

  public async findSessionById(sessionId: string): Promise<Session | null> {
    return this.sessions.findSessionById(sessionId);
  }

  public async findSessionByToken(token: string): Promise<Session | null> {
    return this.sessions.findSessionByToken(token);
  }

  public async updateSession(
    sessionId: string,
    connection: ConnectionInformations,
    newToken?: string
  ): Promise<void> {
    return this.sessions.updateSession(sessionId, connection, newToken);
  }

  public async invalidateSession(sessionId: string): Promise<void> {
    return this.sessions.invalidateSession(sessionId);
  }

  public async invalidateAllSessions(userId: string, excludedSessionIds?: string[]): Promise<void> {
    return this.sessions.invalidateAllSessions(userId, excludedSessionIds);
  }

  public async findUserByServiceId(serviceName: string, serviceId: string): Promise<User | null> {
    const user = await this.collection.findOne({
      [`services.${serviceName}.id`]: serviceId,
    });
    if (user) {
      user.id = user._id.toString();
    }
    return user as User;
  }

  public async setService(userId: string, serviceName: string, service: object): Promise<void> {
    const id = this.options.convertUserIdToMongoObjectId ? toMongoID(userId) : userId;
    await this.collection.updateOne(
      { _id: id },
      {
        $set: {
          [`services.${serviceName}`]: service,
          [this.options.timestamps.updatedAt]: this.options.dateProvider(),
        },
      }
    );
  }

  public async unsetService(userId: string, serviceName: string): Promise<void> {
    const id = this.options.convertUserIdToMongoObjectId ? toMongoID(userId) : userId;
    await this.collection.updateOne(
      { _id: id },
      {
        $set: {
          [this.options.timestamps.updatedAt]: this.options.dateProvider(),
        },
        $unset: {
          [`services.${serviceName}`]: '',
        },
      }
    );
  }

  public async setUserDeactivated(userId: string, deactivated: boolean): Promise<void> {
    const id = this.options.convertUserIdToMongoObjectId ? toMongoID(userId) : userId;
    await this.collection.updateOne(
      { _id: id },
      {
        $set: {
          deactivated,
          [this.options.timestamps.updatedAt]: this.options.dateProvider(),
        },
      }
    );
  }
}
