import {
  BaseOperation,
  EqualsOperation,
  Options,
  createTester,
  Tester,
  createQueryOperation,
  QueryOperation,
  Operation,
  Query,
  NamedGroupOperation,
  numericalOperation,
  containsOperation,
} from "./core";
import { Key, comparable, isFunction, isArray } from "./utils";

class $Ne extends BaseOperation<any> {
  readonly propop = true;
  private _test: Tester;
  init() {
    this._test = createTester(this.params, this.options.compare);
  }
  reset() {
    super.reset();
    this.keep = true;
  }
  next(item: any) {
    if (this._test(item)) {
      this.done = true;
      this.keep = false;
    }
  }
}
// https://docs.mongodb.com/manual/reference/operator/query/elemMatch/
class $ElemMatch extends BaseOperation<Query<any>> {
  readonly propop = true;
  private _queryOperation: QueryOperation<any>;
  init() {
    if (!this.params || typeof this.params !== "object") {
      throw new Error(`Malformed query. $elemMatch must by an object.`);
    }
    this._queryOperation = createQueryOperation(
      this.params,
      this.owneryQuery,
      this.options,
    );
  }
  reset() {
    super.reset();
    this._queryOperation.reset();
  }
  next(item: any) {
    if (isArray(item)) {
      for (let i = 0, { length } = item; i < length; i++) {
        // reset query operation since item being tested needs to pass _all_ query
        // operations for it to be a success
        this._queryOperation.reset();

        const child = item[i];
        this._queryOperation.next(child, i, item, false);
        this.keep = this.keep || this._queryOperation.keep;
      }
      this.done = true;
    } else {
      this.done = false;
      this.keep = false;
    }
  }
}

class $Not extends BaseOperation<Query<any>> {
  readonly propop = true;
  private _queryOperation: QueryOperation<any>;
  init() {
    this._queryOperation = createQueryOperation(
      this.params,
      this.owneryQuery,
      this.options,
    );
  }
  reset() {
    super.reset();
    this._queryOperation.reset();
  }
  next(item: any, key: Key, owner: any, root: boolean) {
    this._queryOperation.next(item, key, owner, root);
    this.done = this._queryOperation.done;
    this.keep = !this._queryOperation.keep;
  }
}

export class $Size extends BaseOperation<any> {
  readonly propop = true;
  init() {}
  next(item) {
    if (isArray(item) && item.length === this.params) {
      this.done = true;
      this.keep = true;
    }
    // if (parent && parent.length === this.params) {
    //   this.done = true;
    //   this.keep = true;
    // }
  }
}

const assertGroupNotEmpty = (values: any[]) => {
  if (values.length === 0) {
    throw new Error(`$and/$or/$nor must be a nonempty array`);
  }
};

class $Or extends BaseOperation<any> {
  readonly propop = false;
  private _ops: Operation<any>[];
  init() {
    assertGroupNotEmpty(this.params);
    this._ops = this.params.map((op) =>
      createQueryOperation(op, null, this.options),
    );
  }
  reset() {
    this.done = false;
    this.keep = false;
    for (let i = 0, { length } = this._ops; i < length; i++) {
      this._ops[i].reset();
    }
  }
  next(item: any, key: Key, owner: any) {
    let done = false;
    let success = false;
    for (let i = 0, { length } = this._ops; i < length; i++) {
      const op = this._ops[i];
      op.next(item, key, owner);
      if (op.keep) {
        done = true;
        success = op.keep;
        break;
      }
    }

    this.keep = success;
    this.done = done;
  }
}

class $Nor extends $Or {
  readonly propop = false;
  next(item: any, key: Key, owner: any) {
    super.next(item, key, owner);
    this.keep = !this.keep;
  }
}

class $In extends BaseOperation<any> {
  readonly propop = true;
  private _testers: Tester[];
  init() {
    const params = Array.isArray(this.params) ? this.params : [this.params];
    this._testers = params.map((value) => {
      if (containsOperation(value, this.options)) {
        throw new Error(`cannot nest $ under ${this.name.toLowerCase()}`);
      }
      return createTester(value, this.options.compare);
    });
  }
  next(item: any, key: Key, owner: any) {
    let done = false;
    let success = false;
    for (let i = 0, { length } = this._testers; i < length; i++) {
      const test = this._testers[i];
      if (test(item)) {
        done = true;
        success = true;
        break;
      }
    }

    this.keep = success;
    this.done = done;
  }
}

class $Nin extends BaseOperation<any> {
  readonly propop = true;
  private _in: $In;
  constructor(params: any, ownerQuery: any, options: Options, name: string) {
    super(params, ownerQuery, options, name);
    this._in = new $In(params, ownerQuery, options, name);
  }
  next(item: any, key: Key, owner: any, root: boolean) {
    this._in.next(item, key, owner);

    if (isArray(owner) && !root) {
      if (this._in.keep) {
        this.keep = false;
        this.done = true;
      } else if (key == owner.length - 1) {
        this.keep = true;
        this.done = true;
      }
    } else {
      this.keep = !this._in.keep;
      this.done = true;
    }
  }
  reset() {
    super.reset();
    this._in.reset();
  }
}

class $Exists extends BaseOperation<boolean> {
  readonly propop = true;
  next(item: any, key: Key, owner: any, root: boolean, leaf?: boolean) {
    if (!leaf) {
      this.done = true;
      this.keep = !this.params;
    } else if (owner.hasOwnProperty(key) === this.params) {
      this.done = true;
      this.keep = true;
    }
  }
}

class $And extends NamedGroupOperation {
  readonly propop = false;
  constructor(
    params: Query<any>[],
    owneryQuery: Query<any>,
    options: Options,
    name: string,
  ) {
    super(
      params,
      owneryQuery,
      options,
      params.map((query) => createQueryOperation(query, owneryQuery, options)),
      name,
    );

    assertGroupNotEmpty(params);
  }
  next(item: any, key: Key, owner: any, root: boolean) {
    this.childrenNext(item, key, owner, root);
  }
}

class $All extends NamedGroupOperation {
  readonly propop = true;
  constructor(
    params: Query<any>[],
    owneryQuery: Query<any>,
    options: Options,
    name: string,
  ) {
    super(
      params,
      owneryQuery,
      options,
      params.map((query) => createQueryOperation(query, owneryQuery, options)),
      name,
    );
  }
  next(item: any, key: Key, owner: any, root: boolean) {
    this.childrenNext(item, key, owner, root);
  }
}

export const $eq = (params: any, owneryQuery: Query<any>, options: Options) =>
  new EqualsOperation(params, owneryQuery, options);
export const $ne = (
  params: any,
  owneryQuery: Query<any>,
  options: Options,
  name: string,
) => new $Ne(params, owneryQuery, options, name);
export const $or = (
  params: Query<any>[],
  owneryQuery: Query<any>,
  options: Options,
  name: string,
) => new $Or(params, owneryQuery, options, name);
export const $nor = (
  params: Query<any>[],
  owneryQuery: Query<any>,
  options: Options,
  name: string,
) => new $Nor(params, owneryQuery, options, name);
export const $elemMatch = (
  params: any,
  owneryQuery: Query<any>,
  options: Options,
  name: string,
) => new $ElemMatch(params, owneryQuery, options, name);
export const $nin = (
  params: any,
  owneryQuery: Query<any>,
  options: Options,
  name: string,
) => new $Nin(params, owneryQuery, options, name);
export const $in = (
  params: any,
  owneryQuery: Query<any>,
  options: Options,
  name: string,
) => {
  return new $In(params, owneryQuery, options, name);
};

export const $lt = numericalOperation((params) => (b) => {
  return b != null && b < params;
});
export const $lte = numericalOperation((params) => (b) => {
  return b === params || b <= params;
});
export const $gt = numericalOperation((params) => (b) => {
  return b != null && b > params;
});
export const $gte = numericalOperation((params) => (b) => {
  return b === params || b >= params;
});
export const $mod = (
  [mod, equalsValue]: number[],
  owneryQuery: Query<any>,
  options: Options,
) =>
  new EqualsOperation(
    (b) => comparable(b) % mod === equalsValue,
    owneryQuery,
    options,
  );
export const $exists = (
  params: boolean,
  owneryQuery: Query<any>,
  options: Options,
  name: string,
) => new $Exists(params, owneryQuery, options, name);
export const $regex = (
  pattern: string,
  owneryQuery: Query<any>,
  options: Options,
) =>
  new EqualsOperation(
    new RegExp(pattern, owneryQuery.$options),
    owneryQuery,
    options,
  );
export const $not = (
  params: any,
  owneryQuery: Query<any>,
  options: Options,
  name: string,
) => new $Not(params, owneryQuery, options, name);

const typeAliases = {
  number: (v) => typeof v === "number",
  string: (v) => typeof v === "string",
  bool: (v) => typeof v === "boolean",
  array: (v) => Array.isArray(v),
  null: (v) => v === null,
  timestamp: (v) => v instanceof Date,
};

export const $type = (
  clazz: Function | string,
  owneryQuery: Query<any>,
  options: Options,
) =>
  new EqualsOperation(
    (b) => {
      if (typeof clazz === "string") {
        if (!typeAliases[clazz]) {
          throw new Error(`Type alias does not exist`);
        }

        return typeAliases[clazz](b);
      }

      return b != null ? b instanceof clazz || b.constructor === clazz : false;
    },
    owneryQuery,
    options,
  );
export const $and = (
  params: Query<any>[],
  ownerQuery: Query<any>,
  options: Options,
  name: string,
) => new $And(params, ownerQuery, options, name);

export const $all = (
  params: Query<any>[],
  ownerQuery: Query<any>,
  options: Options,
  name: string,
) => new $All(params, ownerQuery, options, name);
export const $size = (
  params: number,
  ownerQuery: Query<any>,
  options: Options,
) => new $Size(params, ownerQuery, options, "$size");
export const $options = () => null;
export const $where = (
  params: string | Function,
  ownerQuery: Query<any>,
  options: Options,
) => {
  let test;

  if (isFunction(params)) {
    test = params;
  } else if (!process.env.CSP_ENABLED) {
    test = new Function("obj", "return " + params);
  } else {
    throw new Error(
      `In CSP mode, sift does not support strings in "$where" condition`,
    );
  }

  return new EqualsOperation((b) => test.bind(b)(b), ownerQuery, options);
};
