const Promise = require('bluebird');
const DependencyGraph = require('./DependencyGraph');
const TableInsertion = require('./TableInsertion');
const { uniqBy } = require('../../utils/objectUtils');

class GraphInserter {
  constructor(args) {
    this.allowedRelations = args.allowedRelations || null;
    this.queryContext = args.queryContext;
    this.modelClass = args.modelClass;
    this.models = args.models;
    this.knex = args.knex;
    this.opt = args.opt;

    this.graph = this._buildDependencyGraph();
  }

  execute(inserter) {
    return Promise.try(() => this._executeNormalBatches(inserter))
      .then(() => this._executeJoinRowBatch(inserter))
      .then(() => this._finalize());
  }

  _buildDependencyGraph() {
    const graph = new DependencyGraph(this.opt, this.allowedRelations);
    graph.build(this.modelClass, this.models);
    return graph;
  }

  _executeNormalBatches(inserter) {
    return this._executeNextBatch(inserter);
  }

  _executeNextBatch(inserter) {
    const batch = this._nextBatch();

    if (!batch) {
      // No more normal batches to execute.
      return null;
    }

    // Since we are not performing the inserts using relation.insert()
    // we need to explicitly run the beforeInsert hooks here.
    return this._beforeInsertBatch(batch, 'executeBeforeInsert')
      .then(() => {
        // Insert the batch one table at a time.
        return Promise.map(
          Object.keys(batch),
          tableName => {
            const tableInsertion = batch[tableName];

            // We need to omit the uid properties so that they don't get inserted
            // into the database.
            this._omitUids(tableInsertion);

            return inserter(tableInsertion).then(() => {
              // Resolve dependencies to the inserted objects.
              return this._resolveDepsForInsertion(tableInsertion);
            });
          },
          { concurrency: this.modelClass.concurrency }
        );
      })
      .then(() => {
        return this._executeNextBatch(inserter);
      });
  }

  _nextBatch() {
    const batch = this._createBatch();

    if (batch) {
      // Mark the batch as inserted now even though the its is not yet inserted.
      // It is the very next thing we are going to do. We need to do this here
      // because the uid gets removed before insert.
      this._markBatchInserted(batch);
    }

    return batch;
  }

  _createBatch() {
    const batch = Object.create(null);
    const nodes = this.graph.nodes;
    let empty = true;

    for (let n = 0, ln = nodes.length; n < ln; ++n) {
      const node = nodes[n];

      if (!node.handled && !node.hasUnresolvedDependencies) {
        let tableInsertion = batch[node.modelClass.getTableName()];

        if (!tableInsertion) {
          tableInsertion = new TableInsertion(node.modelClass, false);
          batch[node.modelClass.getTableName()] = tableInsertion;
        }

        tableInsertion.items.push({
          model: node.model,
          relation: node.relation,
          node
        });

        empty = false;
      }
    }

    if (empty) {
      return null;
    } else {
      return batch;
    }
  }

  _beforeInsertBatch(batch, executorMethod) {
    const tableNames = Object.keys(batch);
    const modelsByRelation = new Map();

    for (let t = 0, lt = tableNames.length; t < lt; ++t) {
      const tableName = tableNames[t];
      const tableInsertion = batch[tableName];

      for (let i = 0, li = tableInsertion.items.length; i < li; ++i) {
        const item = tableInsertion.items[i];
        const model = item.model;
        const relation = item.relation;

        if (relation) {
          let relModels = modelsByRelation.get(relation);

          if (relModels === undefined) {
            relModels = [];
            modelsByRelation.set(relation, relModels);
          }

          relModels.push(model);
        }
      }
    }

    return Promise.map(
      Array.from(modelsByRelation.keys()),
      relation => {
        const models = modelsByRelation.get(relation);
        return relation[executorMethod](models, this.queryContext, null);
      },
      { concurrency: this.modelClass.concurrency }
    );
  }

  _markBatchInserted(batch) {
    const tableNames = Object.keys(batch);

    for (let t = 0, lt = tableNames.length; t < lt; ++t) {
      const tableInsertion = batch[tableNames[t]];
      const modelClass = tableInsertion.modelClass;
      const items = tableInsertion.items;

      for (let i = 0, li = items.length; i < li; ++i) {
        items[i].node.markAsInserted();
      }
    }
  }

  _executeJoinRowBatch(inserter) {
    const batch = this._createJoinRowBatch();

    // Since we are not performing the inserts using relation.insert()
    // we need to explicitly run the beforeInsert hooks here.
    return this._beforeInsertBatch(batch, 'executeJoinTableBeforeInsert').then(() => {
      // Insert the batch one table at a time.
      return Promise.map(Object.keys(batch), tableName => inserter(batch[tableName]), {
        concurrency: this.modelClass.concurrency
      });
    });
  }

  _createJoinRowBatch() {
    const batch = Object.create(null);

    for (let n = 0, ln = this.graph.nodes.length; n < ln; ++n) {
      const node = this.graph.nodes[n];

      for (let m = 0, lm = node.manyToManyConnections.length; m < lm; ++m) {
        const conn = node.manyToManyConnections[m];
        let tableInsertion = batch[conn.relation.joinTable];

        const ownerProp = conn.relation.ownerProp.getProps(node.model);
        const modelClass = conn.relation.getJoinModelClass(this.knex);
        let joinModel = conn.relation.createJoinModels(ownerProp, [conn.node.model])[0];

        if (conn.refNode) {
          // Also take extra properties from the referring model, it there was one.
          for (let k = 0, lk = conn.relation.joinTableExtras.length; k < lk; ++k) {
            const extra = conn.relation.joinTableExtras[k];

            if (conn.refNode.model[extra.aliasProp] !== undefined) {
              joinModel[extra.joinTableProp] = conn.refNode.model[extra.aliasProp];
            }
          }
        }

        joinModel = modelClass.fromJson(joinModel);

        if (!tableInsertion) {
          tableInsertion = new TableInsertion(modelClass, true);
          batch[modelClass.getTableName()] = tableInsertion;
        }

        tableInsertion.items.push({
          model: joinModel,
          relation: conn.relation,
          node: conn.node
        });
      }
    }

    return this._removeJoinRowDuplicatesFromBatch(batch);
  }

  _removeJoinRowDuplicatesFromBatch(batch) {
    const tableNames = Object.keys(batch);

    for (let t = 0, lt = tableNames.length; t < lt; ++t) {
      const tableName = tableNames[t];
      const tableInsertion = batch[tableName];

      if (tableInsertion.items.length) {
        const items = tableInsertion.items;
        const keySet = new Set();
        const keys = [];

        for (let i = 0, li = items.length; i < li; ++i) {
          const item = items[i];
          const model = item.model;
          const modelKeys = Object.keys(model);

          for (let k = 0, lk = modelKeys.length; k < lk; ++k) {
            const key = modelKeys[k];

            if (!keySet.has(key)) {
              keySet.add(modelKeys[k]);
              keys.push(key);
            }
          }
        }

        tableInsertion.items = uniqBy(items, item => item.model.$propKey(keys));
      }
    }

    return batch;
  }

  _omitUids(tableInsertion) {
    const modelClass = tableInsertion.modelClass;
    const uidProp = modelClass.uidProp;

    for (let i = 0, li = tableInsertion.items.length; i < li; ++i) {
      const item = tableInsertion.items[i];
      const model = item.model;

      modelClass.omitImpl(model, uidProp);
    }
  }

  _resolveDepsForInsertion(tableInsertion) {
    for (let i = 0, li = tableInsertion.items.length; i < li; ++i) {
      const node = tableInsertion.items[i].node;
      const model = tableInsertion.items[i].model;

      for (let d = 0, ld = node.isNeededBy.length; d < ld; ++d) {
        node.isNeededBy[d].resolve(model);
      }
    }
  }

  _finalize() {
    for (let n = 0, ln = this.graph.nodes.length; n < ln; ++n) {
      const refNode = this.graph.nodes[n];
      const modelClass = refNode.modelClass;
      const ref = refNode.model[modelClass.uidRefProp];

      if (ref) {
        // Copy all the properties to the reference nodes.
        const actualNode = this.graph.nodesById.get(ref);
        const relations = actualNode.modelClass.getRelations();
        const keys = Object.keys(actualNode.model);

        for (let i = 0, l = keys.length; i < l; ++i) {
          const key = keys[i];
          const value = actualNode.model[key];

          if (!relations[key] && typeof value !== 'function') {
            refNode.model[key] = value;
          }
        }

        modelClass.omitImpl(refNode.model, modelClass.uidProp);
        modelClass.omitImpl(refNode.model, modelClass.uidRefProp);
        modelClass.omitImpl(refNode.model, modelClass.dbRefProp);
      } else if (refNode.model[modelClass.uidProp]) {
        // Make sure the model no longer has an uid.
        modelClass.omitImpl(refNode.model, modelClass.uidProp);
        modelClass.omitImpl(refNode.model, modelClass.dbRefProp);
      }
    }

    return Promise.resolve(this.models);
  }
}

module.exports = GraphInserter;
