const QueryBuilderContextBase = require('./QueryBuilderContextBase');
const QueryBuilderUserContext = require('./QueryBuilderUserContext');

class QueryBuilderOperationSupport {
  constructor(knex) {
    this._knex = knex;
    this._operations = [];
    this._context = new this.constructor.QueryBuilderContext(this);
    this._parentQuery = null;
  }

  static get QueryBuilderContext() {
    return QueryBuilderContextBase;
  }

  static get QueryBuilderUserContext() {
    return QueryBuilderUserContext;
  }

  context(obj) {
    const ctx = this._context;

    if (arguments.length === 0) {
      return ctx.userContext;
    } else {
      ctx.userContext = ctx.userContext.newFromObject(this, obj);
      return this;
    }
  }

  mergeContext(obj) {
    const ctx = this._context;
    ctx.userContext = ctx.userContext.newMerge(this, obj);
    return this;
  }

  internalContext(ctx) {
    if (arguments.length === 0) {
      return this._context;
    } else {
      this._context = ctx;
      return this;
    }
  }

  internalOptions(opt) {
    if (arguments.length === 0) {
      return this._context.options;
    } else {
      const oldOpt = this._context.options;
      this._context.options = Object.assign(oldOpt, opt);
      return this;
    }
  }

  childQueryOf(query, fork) {
    if (query) {
      let ctx = query.internalContext();

      if (fork) {
        ctx = ctx.clone();
      }

      this._parentQuery = query;
      this.internalContext(ctx);

      if (this.unsafeKnex() === null) {
        this.knex(query.unsafeKnex());
      }
    }

    return this;
  }

  subqueryOf(query) {
    if (query) {
      this._parentQuery = query;
      this.knex(query.knex());
    }

    return this;
  }

  parentQuery() {
    return this._parentQuery;
  }

  knex() {
    if (arguments.length === 0) {
      const knex = this.unsafeKnex();

      if (!knex) {
        throw new Error(
          `no database connection available for a query. You need to bind the model class or the query to a knex instance.`
        );
      }

      return knex;
    } else {
      this._knex = arguments[0];
      return this;
    }
  }

  unsafeKnex() {
    return this._context.knex || this._knex || null;
  }

  clear(operationSelector) {
    const operations = [];

    this.forEachOperation(
      operationSelector,
      op => {
        operations.push(op);
      },
      false
    );

    this._operations = operations;
    return this;
  }

  copyFrom(queryBuilder, operationSelector) {
    const operations = this._operations;

    queryBuilder.forEachOperation(operationSelector, op => {
      operations.push(op);
    });

    return this;
  }

  has(operationSelector) {
    return this.indexOfOperation(operationSelector) !== -1;
  }

  indexOfOperation(operationSelector) {
    let idx = -1;

    this.forEachOperation(operationSelector, (op, i) => {
      idx = i;
      return false;
    });

    return idx;
  }

  indexOfLastOperation(operationSelector) {
    let idx = -1;

    this.forEachOperation(operationSelector, (op, i) => {
      idx = i;
    });

    return idx;
  }

  findLastOperation(operationSelector) {
    const idx = this.indexOfLastOperation(operationSelector);

    if (idx !== -1) {
      return this._operations[idx];
    } else {
      return null;
    }
  }

  forEachOperation(operationSelector, callback, match) {
    match = match == null ? true : match;

    let check;
    if (operationSelector instanceof RegExp) {
      check = op => operationSelector.test(op.name);
    } else if (typeof operationSelector === 'string') {
      check = op => op.name === operationSelector;
    } else {
      check = op => op.is(operationSelector);
    }

    for (let i = 0, l = this._operations.length; i < l; ++i) {
      const op = this._operations[i];

      if (check(op) === match) {
        if (callback(op, i) === false) {
          break;
        }
      }
    }

    return this;
  }

  addOperation(operation, args) {
    const shouldAdd = operation.onAdd(this, args);

    if (shouldAdd) {
      this._operations.push(operation);
    }

    return this;
  }

  addOperationToFront(operation, args) {
    const shouldAdd = operation.onAdd(this, args);

    if (shouldAdd) {
      this._operations.unshift(operation);
    }

    return this;
  }

  clone() {
    return this.baseCloneInto(new this.constructor(this.unsafeKnex()));
  }

  baseCloneInto(builder) {
    builder._knex = this._knex;
    builder._operations = this._operations.slice();
    builder._context = this._context.clone();
    builder._parentQuery = this._parentQuery;

    return builder;
  }

  build() {
    return this.buildInto(this.knex().queryBuilder());
  }

  buildInto(knexBuilder) {
    this.executeOnBuild();

    // onBuildKnex operations should never add new operations. They should only call
    // methods on the knex query builder.
    for (let i = 0, l = this._operations.length; i < l; ++i) {
      this._operations[i].onBuildKnex(knexBuilder, this);

      if (this._operations.length !== l) {
        throw new Error('onBuildKnex should only call query building methods on the knex builder');
      }
    }

    return knexBuilder;
  }

  executeOnBuild() {
    const operationSet = new Set();

    for (let i = 0; i < this._operations.length; ++i) {
      operationSet.add(this._operations[i]);
    }

    let i = 0;
    while (i < this._operations.length) {
      const iOp = this._operations[i];
      const newOps = [];

      iOp.onBuild(this);

      for (let j = 0; j < this._operations.length; ++j) {
        const jOp = this._operations[j];

        // onBuild may have removed operations before `op`.
        // We need to update the index.
        if (iOp === jOp) {
          i = j;
        }

        if (!operationSet.has(jOp)) {
          // New operation was added.
          newOps.push(jOp);
          operationSet.add(jOp);
        }
      }

      if (newOps.length !== 0) {
        // Make room for the new operations after the current operation.
        for (let j = this._operations.length - 1; j > i + newOps.length; --j) {
          this._operations[j] = this._operations[j - newOps.length];
        }

        // Move the new operations after the current operation.
        for (let j = 0; j < newOps.length; ++j) {
          this._operations[i + j + 1] = newOps[j];
        }
      }

      ++i;
    }
  }

  toString() {
    return this.build().toString();
  }

  toSql() {
    return this.toString();
  }

  skipUndefined() {
    this._context.options.skipUndefined = true;
    return this;
  }
}

module.exports = QueryBuilderOperationSupport;
