const Promise = require('bluebird');

const Selection = require('../select/Selection');
const { uniqBy, values } = require('../../../utils/objectUtils');
const { Type: ValidationErrorType } = require('../../../model/ValidationError');

const ID_LENGTH_LIMIT = 63;
const RELATION_RECURSION_LIMIT = 64;

class RelationJoinBuilder {
  constructor({ modelClass, expression, filters = Object.create(null) }) {
    this.rootModelClass = modelClass;
    this.expression = expression;
    this.filters = filters;
    this.allRelations = null;

    this.pathInfo = new Map();
    this.encodings = new Map();
    this.decodings = new Map();
    this.encIdx = 0;
    this.opt = {
      minimize: false,
      separator: ':',
      aliases: {}
    };
  }

  setOptions(opt) {
    this.opt = Object.assign(this.opt, opt);
  }

  /**
   * Fetches the column information needed for building the select clauses.
   * This must be called before calling `build`. `buildJoinOnly` can be called
   * without this since it doesn't build selects.
   */
  fetchColumnInfo(builder) {
    const allModelClasses = findAllModels(this.expression, this.rootModelClass);

    return Promise.map(
      allModelClasses,
      ModelClass => ModelClass.fetchTableMetadata({ parentBuilder: builder }),
      {
        concurrency: this.rootModelClass.concurrency
      }
    );
  }

  buildJoinOnly(builder) {
    this.doBuild({
      expr: this.expression,
      builder,
      modelClass: builder.modelClass(),
      joinOperation: this.opt.joinOperation || 'leftJoin',
      parentInfo: null,
      relation: null,
      noSelects: true,
      path: ''
    });
  }

  build(builder) {
    const tableName = builder.tableNameFor(this.rootModelClass);
    const tableAlias = builder.tableRefFor(this.rootModelClass);

    if (tableName === tableAlias) {
      builder.table(tableName);
    } else {
      builder.table(`${tableName} as ${tableAlias}`);
    }

    this.doBuild({
      expr: this.expression,
      builder,
      modelClass: builder.modelClass(),
      joinOperation: this.opt.joinOperation || 'leftJoin',
      selectFilterQuery: builder.clone(),
      parentInfo: null,
      relation: null,
      path: ''
    });
  }

  rowsToTree(rows) {
    if (!Array.isArray(rows) || rows.length === 0) {
      return rows;
    }

    const keyInfoByPath = this.createKeyInfo(rows);
    const pathInfo = Array.from(this.pathInfo.values());

    const tree = Object.create(null);
    const stack = new Map();

    for (let i = 0, lr = rows.length; i < lr; ++i) {
      const row = rows[i];
      let curBranch = tree;

      for (let j = 0, lp = pathInfo.length; j < lp; ++j) {
        const pInfo = pathInfo[j];
        const id = pInfo.idGetter(row);
        let model;

        if (id === null) {
          continue;
        }

        if (pInfo.relation) {
          const parentModel = stack.get(pInfo.encParentPath);

          curBranch = pInfo.getBranch(parentModel);

          if (!curBranch) {
            curBranch = pInfo.createBranch(parentModel);
          }
        }

        model = pInfo.getModelFromBranch(curBranch, id);

        if (!model) {
          model = createModel(row, pInfo, keyInfoByPath);
          pInfo.setModelToBranch(curBranch, id, model);
        }

        stack.set(pInfo.encPath, model);
      }
    }

    return this.finalize(pathInfo[0], values(tree));
  }

  createKeyInfo(rows) {
    const keys = Object.keys(rows[0]);
    const keyInfo = new Map();

    for (let i = 0, l = keys.length; i < l; ++i) {
      const key = keys[i];
      const sepIdx = key.lastIndexOf(this.sep);
      let col, pInfo;

      if (sepIdx === -1) {
        pInfo = this.pathInfo.get('');
        col = key;
      } else {
        const encPath = key.substr(0, sepIdx);
        const path = this.decode(encPath);

        col = key.substr(sepIdx + this.sep.length);
        pInfo = this.pathInfo.get(path);
      }

      if (!pInfo.omitCols.has(col)) {
        let infoArr = keyInfo.get(pInfo.encPath);

        if (!infoArr) {
          infoArr = [];
          keyInfo.set(pInfo.encPath, infoArr);
        }

        infoArr.push({ pInfo, key, col });
      }
    }

    return keyInfo;
  }

  finalize(pInfo, models) {
    const relNames = Array.from(pInfo.children.keys());

    if (Array.isArray(models)) {
      for (let m = 0, lm = models.length; m < lm; ++m) {
        this.finalizeOne(pInfo, relNames, models[m]);
      }
    } else if (models) {
      this.finalizeOne(pInfo, relNames, models);
    }

    return models;
  }

  finalizeOne(pInfo, relNames, model) {
    for (let r = 0, lr = relNames.length; r < lr; ++r) {
      const relName = relNames[r];
      const branch = model[relName];
      const childPathInfo = pInfo.children.get(relName);

      const finalized = childPathInfo.finalizeBranch(branch, model);
      this.finalize(childPathInfo, finalized);
    }
  }

  doBuild({
    expr,
    builder,
    relation,
    modelClass,
    selectFilterQuery,
    joinOperation,
    parentInfo,
    noSelects,
    path
  }) {
    if (!this.allRelations) {
      this.allRelations = findAllRelations(this.expression, this.rootModelClass);
    }

    const info = this.createPathInfo({
      modelClass,
      path,
      expr,
      relation,
      parentInfo
    });

    this.pathInfo.set(path, info);

    if (!noSelects) {
      this.buildSelects({
        builder,
        selectFilterQuery,
        modelClass,
        relation,
        info
      });
    }

    forEachExpr(expr, modelClass, (childExpr, relation) => {
      const nextPath = this.joinPath(path, childExpr.$name);
      const encNextPath = this.encode(nextPath);
      const encJoinTablePath = relation.joinTable ? this.encode(joinTableForPath(nextPath)) : null;
      const ownerTable = info.encPath || undefined;

      const filterQuery = createFilterQuery({
        builder,
        modelClass,
        relation,
        expr: childExpr,
        filters: this.filters
      });

      const relatedJoinSelectQuery = createRelatedJoinFromQuery({
        filterQuery,
        relation,
        allRelations: this.allRelations
      });

      relation.join(builder, {
        ownerTable,
        joinOperation,
        relatedTableAlias: encNextPath,
        joinTableAlias: encJoinTablePath,
        relatedJoinSelectQuery
      });

      // Apply relation.modify since it may also contains selections. Don't move this
      // to the createFilterQuery function because relatedJoinSelectQuery is cloned
      // from the return value of that function and we don't want relation.modify
      // to be called twice for it.
      filterQuery.modify(relation.modify);

      this.doBuild({
        expr: childExpr,
        builder,
        modelClass: relation.relatedModelClass,
        joinOperation,
        relation,
        parentInfo: info,
        noSelects,
        path: nextPath,
        selectFilterQuery: filterQuery
      });
    });
  }

  createPathInfo({ modelClass, path, expr, relation, parentInfo }) {
    const encPath = this.encode(path);
    let info;

    if (relation && relation.isOneToOne()) {
      info = new OneToOnePathInfo();
    } else {
      info = new PathInfo();
    }

    info.path = path;
    info.encPath = encPath;
    info.parentPath = parentInfo && parentInfo.path;
    info.encParentPath = parentInfo && parentInfo.encPath;
    info.modelClass = modelClass;
    info.relation = relation;
    info.idGetter = this.createIdGetter(modelClass, encPath);
    info.relationAlias = expr.$name;

    if (parentInfo) {
      parentInfo.children.set(expr.$name, info);
    }

    return info;
  }

  buildSelects({ builder, selectFilterQuery, modelClass, relation, info }) {
    const selects = [];
    const idCols = modelClass.getIdColumnArray();
    const rootTable = builder.tableRefFor(this.rootModelClass);
    const isSelectFilterQuerySubQuery = !!info.encPath;

    let selections = selectFilterQuery.findAllSelections();
    const selectAllIndex = selections.findIndex(isSelectAll);

    // If there are no explicit selects, or there is a `select *` item,
    // we need to select all columns using the schema information
    // in `modelClass.tableMetadata()`.
    if (selections.length === 0 || selectAllIndex !== -1) {
      const table = builder.tableNameFor(modelClass);

      selections.splice(selectAllIndex, 1);
      selections = modelClass
        .tableMetadata({ table })
        .columns.map(it => new Selection(null, it))
        .concat(selections);
    }

    // Id columns always need to be selected so that we are able to construct
    // the tree structure from the flat columns.
    for (let i = 0, l = idCols.length; i < l; ++i) {
      const idCol = idCols[i];

      if (!selections.some(it => it.name === idCol)) {
        info.omitCols.add(idCol);
        selections.unshift(new Selection(null, idCol));
      }
    }

    for (let i = 0, l = selections.length; i < l; ++i) {
      const selection = selections[i];

      // If `selections` come from a subquery, we need to use the possible alias instead
      // of the column name because that's what the root query sees instead of the real
      // column name.
      const col = isSelectFilterQuerySubQuery ? selection.name : selection.column;
      const name = selection.name;

      const fullCol = `${info.encPath || rootTable}.${col}`;
      const alias = this.joinPath(info.encPath, name);

      if (!builder.hasSelectionAs(fullCol, alias, true)) {
        checkAliasLength(modelClass, alias);
        selects.push(`${fullCol} as ${alias}`);
      }
    }

    if (relation && relation.joinTableExtras) {
      const joinTable = this.encode(joinTableForPath(info.path));

      for (let i = 0, l = relation.joinTableExtras.length; i < l; ++i) {
        const extra = relation.joinTableExtras[i];
        const filterPassed = selectFilterQuery.hasSelection(extra.joinTableCol);

        if (filterPassed) {
          const fullCol = `${joinTable}.${extra.joinTableCol}`;

          if (!builder.hasSelection(fullCol, true)) {
            const alias = this.joinPath(info.encPath, extra.aliasCol);
            checkAliasLength(modelClass, alias);
            selects.push(`${fullCol} as ${alias}`);
          }
        }
      }
    }

    builder.select(selects);
  }

  encode(path) {
    if (!this.opt.minimize) {
      let encPath = this.encodings.get(path);

      if (!encPath) {
        const parts = path.split(this.sep);

        // Don't encode the root.
        if (!path) {
          encPath = path;
        } else {
          encPath = parts.map(part => this.opt.aliases[part] || part).join(this.sep);
        }

        this.encodings.set(path, encPath);
        this.decodings.set(encPath, path);
      }

      return encPath;
    } else {
      let encPath = this.encodings.get(path);

      if (!encPath) {
        // Don't encode the root.
        if (!path) {
          encPath = path;
        } else {
          encPath = this.nextEncodedPath();
        }

        this.encodings.set(path, encPath);
        this.decodings.set(encPath, path);
      }

      return encPath;
    }
  }

  decode(path) {
    return this.decodings.get(path);
  }

  nextEncodedPath() {
    return `_t${++this.encIdx}`;
  }

  createIdGetter(modelClass, path) {
    const idCols = modelClass.getIdColumnArray().map(col => this.joinPath(path, col));

    if (idCols.length === 1) {
      return createSingleIdGetter(idCols);
    } else if (idCols.length === 2) {
      return createTwoIdGetter(idCols);
    } else if (idCols.length === 3) {
      return createThreeIdGetter(idCols);
    } else {
      return createNIdGetter(idCols);
    }
  }

  get sep() {
    return this.opt.separator;
  }

  joinPath(path, nextPart) {
    if (path) {
      return `${path}${this.sep}${nextPart}`;
    } else {
      return nextPart;
    }
  }
}

function findAllModels(expr, modelClass) {
  const modelClasses = [];

  findAllModelsImpl(expr, modelClass, modelClasses);

  return uniqBy(modelClasses, getTableName);
}

function getTableName(modelClass) {
  return modelClass.getTableName();
}

function findAllModelsImpl(expr, modelClass, models) {
  models.push(modelClass);

  forEachExpr(expr, modelClass, (childExpr, relation) => {
    findAllModelsImpl(childExpr, relation.relatedModelClass, models);
  });
}

function findAllRelations(expr, modelClass) {
  const relations = [];

  findAllRelationsImpl(expr, modelClass, relations);

  return uniqBy(relations);
}

function strictEqual(lhs, rhs) {
  return lhs === rhs;
}

function findAllRelationsImpl(expr, modelClass, relations) {
  forEachExpr(expr, modelClass, (childExpr, relation) => {
    relations.push(relation);

    findAllRelationsImpl(childExpr, relation.relatedModelClass, relations);
  });
}

function forEachExpr(expr, modelClass, callback) {
  const relations = modelClass.getRelations();

  if (expr.isAllRecursive || expr.maxRecursionDepth > RELATION_RECURSION_LIMIT) {
    throw modelClass.createValidationError({
      type: ValidationErrorType.RelationExpression,
      message: `recursion depth of eager expression ${expr.toString()} too big for JoinEagerAlgorithm`
    });
  }

  expr.forEachChildExpression(relations, callback);
}

function createSingleIdGetter(idCols) {
  const idCol = idCols[0];

  return row => {
    const val = row[idCol];

    if (isNullOrUndefined(val)) {
      return null;
    } else {
      return `id:${val}`;
    }
  };
}

function createTwoIdGetter(idCols) {
  const idCol1 = idCols[0];
  const idCol2 = idCols[1];

  return row => {
    const val1 = row[idCol1];
    const val2 = row[idCol2];

    if (isNullOrUndefined(val1) || isNullOrUndefined(val2)) {
      return null;
    } else {
      return `id:${val1},${val2}`;
    }
  };
}

function createThreeIdGetter(idCols) {
  const idCol1 = idCols[0];
  const idCol2 = idCols[1];
  const idCol3 = idCols[2];

  return row => {
    const val1 = row[idCol1];
    const val2 = row[idCol2];
    const val3 = row[idCol3];

    if (isNullOrUndefined(val1) || isNullOrUndefined(val2) || isNullOrUndefined(val3)) {
      return null;
    } else {
      return `id:${val1},${val2},${val3}`;
    }
  };
}

function createNIdGetter(idCols) {
  return row => {
    let id = 'id:';

    for (let i = 0, l = idCols.length; i < l; ++i) {
      const val = row[idCols[i]];

      if (isNullOrUndefined(val)) {
        return null;
      }

      id += (i > 0 ? ',' : '') + val;
    }

    return id;
  };
}

function isNullOrUndefined(val) {
  return val === null || val === undefined;
}

function createFilterQuery({ builder, modelClass, expr, filters, relation }) {
  const modelNamedFilters = relation.relatedModelClass.namedFilters || {};
  const filterQuery = relation.relatedModelClass.query().childQueryOf(builder);

  for (let i = 0, l = expr.$modify.length; i < l; ++i) {
    const filterName = expr.$modify[i];
    const filter = filters[filterName] || modelNamedFilters[filterName];

    if (typeof filter !== 'function') {
      throw modelClass.createValidationError({
        type: ValidationErrorType.RelationExpression,
        message: `could not find filter "${filterName}" for relation "${relation.name}"`
      });
    }

    filter(filterQuery);
  }

  return filterQuery;
}

function createRelatedJoinFromQuery({ filterQuery, relation, allRelations }) {
  const relatedJoinFromQuery = filterQuery.clone();
  const tableRef = filterQuery.tableRefFor(relation.relatedModelClass);

  const allForeignKeys = findAllForeignKeysForModel({
    modelClass: relation.relatedModelClass,
    allRelations
  });

  return relatedJoinFromQuery.select(
    allForeignKeys
      .filter(col => {
        return !relatedJoinFromQuery.hasSelectionAs(col, col);
      })
      .map(col => {
        return `${tableRef}.${col}`;
      })
  );
}

function findAllForeignKeysForModel({ modelClass, allRelations }) {
  const foreignKeys = modelClass.getIdColumnArray().slice();

  allRelations.forEach(rel => {
    if (rel.relatedModelClass === modelClass) {
      rel.relatedProp.cols.forEach(col => foreignKeys.push(col));
    }

    if (rel.ownerModelClass === modelClass) {
      rel.ownerProp.cols.forEach(col => foreignKeys.push(col));
    }
  });

  return uniqBy(foreignKeys);
}

function createModel(row, pInfo, keyInfoByPath) {
  const keyInfo = keyInfoByPath.get(pInfo.encPath);
  const json = {};

  for (let k = 0, lk = keyInfo.length; k < lk; ++k) {
    const kInfo = keyInfo[k];
    json[kInfo.col] = row[kInfo.key];
  }

  return pInfo.modelClass.fromDatabaseJson(json);
}

function joinTableForPath(path) {
  return path + '_join';
}

function checkAliasLength(modelClass, alias) {
  if (alias.length > ID_LENGTH_LIMIT) {
    throw modelClass.createValidationError({
      type: ValidationErrorType.RelationExpression,
      message: `identifier ${alias} is over ${ID_LENGTH_LIMIT} characters long and would be truncated by the database engine.`
    });
  }
}

function isSelectAll(selection) {
  return selection.column === '*';
}

class PathInfo {
  constructor() {
    this.path = null;
    this.encPath = null;
    this.encParentPath = null;
    this.modelClass = null;
    this.relation = null;
    this.omitCols = new Set();
    this.children = new Map();
    this.idGetter = null;
    this.relationAlias = null;
  }

  createBranch(parentModel) {
    const branch = Object.create(null);
    parentModel[this.relationAlias] = branch;
    return branch;
  }

  getBranch(parentModel) {
    return parentModel[this.relationAlias];
  }

  getModelFromBranch(branch, id) {
    return branch[id];
  }

  setModelToBranch(branch, id, model) {
    branch[id] = model;
  }

  finalizeBranch(branch, parentModel) {
    const relModels = values(branch);
    parentModel[this.relationAlias] = relModels;
    return relModels;
  }
}

class OneToOnePathInfo extends PathInfo {
  createBranch(parentModel) {
    return parentModel;
  }

  getBranch(parentModel) {
    return parentModel;
  }

  getModelFromBranch(branch, id) {
    return branch[this.relationAlias];
  }

  setModelToBranch(branch, id, model) {
    branch[this.relationAlias] = model;
  }

  finalizeBranch(branch, parentModel) {
    parentModel[this.relationAlias] = branch || null;
    return branch || null;
  }
}

module.exports = RelationJoinBuilder;
