const { appendDataPath } = require('../../utils/dataPath');
const { Type: ValidationErrorType } = require('../../model/ValidationError');
const { isObject, last } = require('../../utils/objectUtils');

const HasManyRelation = require('../../relations/hasMany/HasManyRelation');
const RelationExpression = require('../RelationExpression');
const ManyToManyRelation = require('../../relations/manyToMany/ManyToManyRelation');
const BelongsToOneRelation = require('../../relations/belongsToOne/BelongsToOneRelation');

const DependencyNode = require('./DependencyNode');
const HasManyDependency = require('./HasManyDependency');
const ManyToManyConnection = require('./ManyToManyConnection');
const ReplaceValueDependency = require('./ReplaceValueDependency');
const BelongsToOneDependency = require('./BelongsToOneDependency');
const InterpolateValueDependency = require('./InterpolateValueDependency');

class DependencyGraph {
  constructor(opt, allowedRelations) {
    this.allowedRelations = allowedRelations;
    this.nodesById = new Map();
    this.nodes = [];
    this.uid = 0;
    this.opt = opt || Object.create(null);
  }

  build(modelClass, models) {
    this.nodesById = new Map();
    this.nodes = [];

    if (Array.isArray(models)) {
      for (let i = 0, l = models.length; i < l; ++i) {
        this.buildForModel({
          modelClass,
          model: models[i],
          parentModel: null,
          allowedRelations: this.allowedRelations,
          dataPath: null,
          rel: null
        });
      }
    } else {
      this.buildForModel({
        modelClass,
        model: models,
        parentModel: null,
        allowedRelations: this.allowedRelations,
        dataPath: null,
        rel: null
      });
    }

    this.solveReferences();
    this.createNonRelationDeps();

    if (this.isCyclic(this.nodes)) {
      throw modelClass.createValidationError({
        type: ValidationErrorType.InvalidGraph,
        message: 'the object graph contains cyclic references'
      });
    }

    return this.nodes;
  }

  buildForModel({ modelClass, model, parentNode, rel, allowedRelations, dataPath }) {
    if (!model || !model.$isObjectionModel) {
      throw modelClass.createValidationError({
        type: ValidationErrorType.InvalidGraph,
        message: 'not a model'
      });
    }

    if (!model[modelClass.uidProp]) {
      model[modelClass.uidProp] = this.createUid();
    }

    const node = new DependencyNode({ parentNode, model, modelClass, relation: rel, dataPath });
    const isRelate = this.isRelate({ modelClass, model, parentNode, rel });
    const dbRef = model[modelClass.dbRefProp];

    this.nodesById.set(node.id, node);
    this.nodes.push(node);

    if (isRelate && dbRef) {
      const isComposite = Array.isArray(dbRef);

      for (let i = 0; i < rel.relatedProp.size; ++i) {
        rel.relatedProp.setProp(model, i, isComposite ? dbRef[i] : dbRef);
      }
    }

    if (rel) {
      if (rel instanceof HasManyRelation) {
        node.needs.push(new HasManyDependency(parentNode, rel));
        parentNode.isNeededBy.push(new HasManyDependency(node, rel));

        if (isRelate) {
          throw new Error(
            `You cannot relate HasManyRelation or HasOneRelation using insertGraph, because those require update operations. Consider using upsertGraph instead.`
          );
        }
      } else if (rel instanceof BelongsToOneRelation) {
        node.isNeededBy.push(new BelongsToOneDependency(parentNode, rel));
        parentNode.needs.push(new BelongsToOneDependency(node, rel));

        if (isRelate) {
          // We can resolve the node immediately if we are relating since
          // `model` already has the foreign key.
          last(node.isNeededBy).resolve(model);
        }
      } else if (rel instanceof ManyToManyRelation) {
        // ManyToManyRelations create no dependencies since we can create the
        // join table rows after everything else has been inserted.
        parentNode.manyToManyConnections.push(new ManyToManyConnection(node, rel));
      }
    }

    if (isRelate) {
      // If the node is a relate node, it already exists in the database.
      // Mark it as inserted.
      node.markAsInserted();
    }

    this.buildForRelations({ modelClass, node, allowedRelations, dataPath });
  }

  buildForRelations({ modelClass, node, allowedRelations, dataPath }) {
    const model = node.model;
    const relations = modelClass.getRelationArray();

    for (let i = 0, l = relations.length; i < l; ++i) {
      const rel = relations[i];
      const relModels = model[rel.name];

      let nextAllowed = null;

      if (relModels) {
        if (isObject(allowedRelations) && allowedRelations.isObjectionRelationExpression) {
          nextAllowed = allowedRelations.childExpression(rel.name);

          if (!nextAllowed) {
            throw modelClass.createValidationError({
              type: ValidationErrorType.UnallowedRelation,
              message: 'trying to insert an unallowed relation'
            });
          }
        }

        const relPath = appendDataPath(dataPath, rel);

        if (Array.isArray(relModels)) {
          for (let i = 0, l = relModels.length; i < l; ++i) {
            this.buildForModel({
              modelClass: rel.relatedModelClass,
              model: relModels[i],
              parentNode: node,
              rel,
              allowedRelations: nextAllowed,
              dataPath: appendDataPath(relPath, i)
            });
          }
        } else {
          this.buildForModel({
            model: relModels,
            modelClass: rel.relatedModelClass,
            parentNode: node,
            allowedRelations: nextAllowed,
            dataPath: relPath,
            rel
          });
        }
      }
    }
  }

  isRelate({ modelClass, model, parentNode, rel }) {
    if (!rel) {
      return false;
    }

    if (model[modelClass.dbRefProp]) {
      return true;
    }

    return rel.hasRelateProp(model) && this.hasOption('relate', relationPath(parentNode, rel));
  }

  hasOption(option, relationPath) {
    const opt = this.opt[option];

    if (Array.isArray(opt)) {
      return opt.indexOf(relationPath) !== -1;
    } else {
      return !!opt;
    }
  }

  solveReferences() {
    const refMap = new Map();

    // First merge all reference nodes into the actual node.
    this.mergeReferences(refMap);

    // Replace all reference nodes with the actual nodes.
    this.replaceReferenceNodes(refMap);
  }

  mergeReferences(refMap) {
    for (let n = 0, ln = this.nodes.length; n < ln; ++n) {
      const refNode = this.nodes[n];
      let ref;

      if (refNode.handled) {
        continue;
      }

      ref = refNode.model[refNode.modelClass.uidRefProp];

      if (ref) {
        const actualNode = this.nodesById.get(ref);

        if (!actualNode) {
          throw refNode.modelClass.createValidationError({
            type: ValidationErrorType.InvalidGraph,
            message: `could not resolve reference "${ref}"`
          });
        }

        for (let d = 0, ld = refNode.needs.length; d < ld; ++d) {
          actualNode.needs.push(refNode.needs[d]);
        }

        for (let d = 0, ld = refNode.isNeededBy.length; d < ld; ++d) {
          actualNode.isNeededBy.push(refNode.isNeededBy[d]);
        }

        for (let m = 0, lm = refNode.manyToManyConnections.length; m < lm; ++m) {
          actualNode.manyToManyConnections.push(refNode.manyToManyConnections[m]);
        }

        refMap.set(refNode.id, actualNode);
        refNode.handled = true;
      }
    }
  }

  replaceReferenceNodes(refMap) {
    for (let n = 0, ln = this.nodes.length; n < ln; ++n) {
      const node = this.nodes[n];
      let d, ld, dep, actualNode;

      for (d = 0, ld = node.needs.length; d < ld; ++d) {
        dep = node.needs[d];
        actualNode = refMap.get(dep.node.id);

        if (actualNode) {
          dep.node = actualNode;
        }
      }

      for (d = 0, ld = node.isNeededBy.length; d < ld; ++d) {
        dep = node.isNeededBy[d];
        actualNode = refMap.get(dep.node.id);

        if (actualNode) {
          dep.node = actualNode;
        }
      }

      for (let m = 0, lm = node.manyToManyConnections.length; m < lm; ++m) {
        const conn = node.manyToManyConnections[m];
        actualNode = refMap.get(conn.node.id);

        if (actualNode) {
          conn.refNode = conn.node;
          conn.node = actualNode;
        }
      }
    }
  }

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

      if (!node.handled) {
        this.createNonRelationDepsForObject(node.model, node, []);
      }
    }
  }

  createNonRelationDepsForObject(obj, node, path) {
    const propRefRegex = node.modelClass.propRefRegex;
    const relations = node.modelClass.getRelations();
    const isModel = obj && obj.$isObjectionModel;
    const keys = Object.keys(obj);

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

      if (isModel && relations[key]) {
        // Don't traverse the relations of model instances.
        return;
      }

      path.push(key);

      if (typeof value === 'string') {
        allMatches(propRefRegex, value, matchResult => {
          const [match, refId, refProp] = matchResult;
          const refNode = this.nodesById.get(refId);

          if (!refNode) {
            throw node.modelClass.createValidationError({
              type: ValidationErrorType.InvalidGraph,
              message: `could not resolve reference "${value}"`
            });
          }

          if (value === match) {
            // If the match is the whole string, replace the value with the resolved value.
            // This means that the value will have the same type as the resolved value
            // (date, number, etc).
            node.needs.push(new ReplaceValueDependency(refNode, path, refProp, false));
            refNode.isNeededBy.push(new ReplaceValueDependency(node, path, refProp, true));
          } else {
            // If the match is inside a string, replace the reference inside the string with
            // the resolved value.
            node.needs.push(new InterpolateValueDependency(refNode, path, refProp, match, false));
            refNode.isNeededBy.push(
              new InterpolateValueDependency(node, path, refProp, match, true)
            );
          }
        });
      } else if (isObject(value)) {
        this.createNonRelationDepsForObject(value, node, path);
      }

      path.pop();
    }
  }

  isCyclic(nodes) {
    let isCyclic = false;

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

      if (node.handled) {
        continue;
      }

      if (this.isCyclicNode(node)) {
        isCyclic = true;
        break;
      }
    }

    this.clearFlags(this.nodes);
    return isCyclic;
  }

  isCyclicNode(node) {
    if (!node.visited) {
      node.visited = true;
      node.recursion = true;

      for (let d = 0, ld = node.needs.length; d < ld; ++d) {
        let dep = node.needs[d];

        if (!dep.node.visited && this.isCyclicNode(dep.node)) {
          return true;
        } else if (dep.node.recursion) {
          return true;
        }
      }
    }

    node.recursion = false;
    return false;
  }

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

      node.visited = false;
      node.recursion = false;
    }
  }

  createUid() {
    return `__objection_uid(${++this.uid})__`;
  }
}

function allMatches(regex, str, cb) {
  let matchResult = regex.exec(str);

  while (matchResult) {
    cb(matchResult);
    matchResult = regex.exec(str);
  }
}

function relationPath(parentNode, rel) {
  let path = '';

  while (parentNode !== null && parentNode.relation !== null) {
    path = parentNode.relation.name + (path ? '.' : '') + path;
    parentNode = parentNode.parentNode;
  }

  return path + (path ? '.' : '') + (rel ? rel.name : '');
}

module.exports = DependencyGraph;
