import {
  asJson,
  asNumber,
  asString,
  choose,
  Condition,
  Database,
  entries,
  Exception,
  FetchOptions,
  Field,
  Id,
  ifDefined,
  ifTrue,
  isArray,
  isDefined,
  isField,
  isSortCondition,
  json,
  Json,
  JsonValue,
  LogicalCondition,
  OneOrMore,
  PageList,
  Sort,
  toArray,
  toPageList,
  tuple2,
  tuple3,
  use,
  when,
} from '@thisisagile/easy';
import {
  AggregationCursor,
  Collection as MongoCollection,
  CreateIndexesOptions,
  Document,
  FindCursor,
  FindOptions as MongoFindOptions,
  IndexSpecification,
  MongoClient,
  StrictFilter as MongoFilter,
} from 'mongodb';
import { Collection } from './Collection';
import { toMongoType } from './Utils';

const omitId = (j: Json): Json => json.delete(j, '_id');

export type Projection = Record<string, 0 | 1>;
export type FindOptions = FetchOptions & { projection?: Projection };
export type Filter<T = unknown> = MongoFilter<T>;
export type Query = Condition | LogicalCondition | Filter<any>;

export type IndexOptions = {
  unique?: boolean;
  filter?: Query;
  languageOverride?: string;
  languageDefault?: string;
};

export type Indexes = OneOrMore<string | Field | Sort | Record<string, 1 | -1>>;

export type Options = { maxTimeMS?: number };

export class MongoProvider {
  protected static readonly clients: { [key: string]: Promise<MongoClient> } = {};

  constructor(readonly coll: Collection) {}

  static destroyAll(): Promise<void> {
    return Promise.all(entries(MongoProvider.clients).map(([u, c]) => c.then(c => c.close()).then(() => delete MongoProvider.clients[u]))).then(
      () => undefined
    );
  }

  private static connect(u: string, db: Database) {
    return MongoClient.connect(u, {
      auth: {
        username: asString(db.options?.user),
        password: asString(db.options?.password),
      },
      ...(db.options?.maxPoolSize && { maxPoolSize: db.options?.maxPoolSize }),
      ...(db.options?.minPoolSize && { minPoolSize: db.options?.minPoolSize }),
      ...(db.options?.maxIdleTimeMS && { maxIdleTimeMS: db.options?.maxIdleTimeMS }),
    })
      .then(c => {
        c.on('error', () => delete MongoProvider.clients[u]);
        c.on('close', () => delete MongoProvider.clients[u]);
        return c;
      })
      .catch(err => {
        delete MongoProvider.clients[u];
        return Promise.reject(err);
      });
  }

  cluster(): Promise<MongoClient> {
    return use(this.coll.db, db =>
      when(db.options?.cluster)
        .not.isDefined.reject(Exception.IsNotValid.because('Missing cluster in database options.'))
        .then(c => MongoProvider.clients[c] ?? (MongoProvider.clients[c] = MongoProvider.connect(c, db)))
    );
  }

  collection<T extends Document = Document>(): Promise<MongoCollection<T>> {
    return this.cluster()
      .then(c => c.db(this.coll.db.name))
      .then(db => db.collection<T>(asString(this.coll)));
  }

  toMongoJson(query: Query): Json {
    return toMongoType(asJson(query));
  }

  find(query: Query, options?: FindOptions & Options): Promise<PageList<Json>> {
    const { maxTimeMS, ...opts } = options ?? {};
    return tuple3(this.collection(), this.toMongoJson(query), this.toFindOptions(opts))
      .then(([c, q, o]) =>
        tuple2(
          c.find(q, { ...o, ...ifDefined(maxTimeMS, { maxTimeMS }, {}) }),
          ifTrue(o.total, () =>
            ifDefined(
              maxTimeMS,
              maxTimeMS => c.countDocuments(q, { maxTimeMS }),
              () => c.countDocuments(q)
            )
          )
        )
      )
      .then(([res, total]) => this.toArray(res, { ...opts, total }));
  }

  all(options?: FindOptions): Promise<PageList<Json>> {
    return this.find({}, options);
  }

  byId(id: Id, options?: FindOptions): Promise<Json> {
    return this.collection().then(c => c.findOne(this.toMongoJson({ id: id }), this.toFindOptions(options)) as Promise<Json>);
  }

  by(key: string, value: JsonValue, options?: FindOptions): Promise<PageList<Json>> {
    return this.find({ [key]: value }, options);
  }

  group(qs: Filter<any>[], options?: Options): Promise<PageList<Json>> {
    return this.aggregate(qs, options);
  }

  aggregate(qs: Filter<any>[], options?: Options): Promise<PageList<Json>> {
    return this.collection()
      .then(c =>
        c.aggregate(
          qs.map(q => this.toMongoJson(q)),
          options
        )
      )
      .then(res => this.toArray(res));
  }

  add(item: Json): Promise<Json> {
    return this.collection()
      .then(c => c.insertOne(omitId(item)))
      .then(() => omitId(item));
  }

  update(item: Json): Promise<Json> {
    return this.collection()
      .then(c => c.updateOne(this.toMongoJson({ id: item.id }), { $set: omitId(item) }))
      .then(() => this.byId(item.id as Id));
  }

  remove(id: Id): Promise<boolean> {
    return this.collection()
      .then(c => c.deleteOne(this.toMongoJson({ id })))
      .then(d => d.acknowledged);
  }

  count(query?: Query, options?: Options): Promise<number> {
    return this.collection().then(c => c.countDocuments(this.toMongoJson(query ?? {}), options));
  }

  createIndex(indexes: Indexes, options?: IndexOptions): Promise<string> {
    return this.collection().then(c => c.createIndex(this.toIndexSpecification(indexes), this.toCreateIndexesOptions(options)));
  }

  createPartialIndex(indexes: Indexes, filter: Query, options?: Omit<IndexOptions, 'filter'>): Promise<string> {
    return this.createIndex(indexes, { ...options, filter });
  }

  createTextIndex(indexes: OneOrMore<Field | string>, options?: IndexOptions): Promise<string> {
    const ii = toArray(indexes).reduce((i, f) => ({ ...i, [asString(f)]: 'text' }), {});
    return this.createIndex(ii, { unique: false, ...options });
  }

  protected toFindOptions(options?: FindOptions): MongoFindOptions & { total: boolean } {
    return {
      limit: asNumber(options?.take ?? 250),
      ...(options?.skip && { skip: asNumber(options?.skip) }),
      ...((options?.sorts && { sort: options?.sorts }) || (options?.sort && { sort: this.coll.sort(...(options?.sort ?? [])) })),
      total: isDefined(options?.skip) || isDefined(options?.take),
      projection: options?.projection ?? { _id: 0 },
    };
  }

  protected toIndexSpecification(index: Indexes): IndexSpecification {
    return choose(index)
      .type(isField, f => f.property as IndexSpecification)
      .type(isSortCondition, s => s.toJSON() as IndexSpecification)
      .type(isArray, aa => aa.map(a => this.toIndexSpecification(a)) as IndexSpecification)
      .else(i => i as IndexSpecification);
  }

  protected toCreateIndexesOptions(options?: IndexOptions): CreateIndexesOptions {
    return {
      unique: options?.unique ?? true,
      ...(options?.languageOverride && { language_override: options.languageOverride }),
      ...(options?.languageDefault && { default_language: options.languageDefault }),
      ...(options?.filter && { partialFilterExpression: toMongoType(asJson(options.filter)) }),
    };
  }

  protected toArray(
    cursor: FindCursor<Document> | AggregationCursor<Document>,
    options?: { take?: number; skip?: number; total?: number }
  ): Promise<PageList<Json>> {
    return cursor.toArray().then(r => toPageList<Json>(r, options));
  }
}
