const getModel = () => require('../../model/Model');
const Relation = require('../Relation');
const RelationProperty = require('../RelationProperty');

const { ref } = require('../../queryBuilder/ReferenceBuilder');
const { isSqlite, isMySql } = require('../../utils/knexUtils');
const { inheritModel } = require('../../model/inheritModel');
const { resolveModel } = require('../../utils/resolveModel');
const { mapAfterAllReturn } = require('../../utils/promiseUtils');

const ManyToManyFindOperation = require('./find/ManyToManyFindOperation');
const ManyToManyInsertOperation = require('./insert/ManyToManyInsertOperation');
const ManyToManyRelateOperation = require('./relate/ManyToManyRelateOperation');

const ManyToManyUnrelateOperation = require('./unrelate/ManyToManyUnrelateOperation');
const ManyToManyUnrelateMySqlOperation = require('./unrelate/ManyToManyUnrelateMySqlOperation');
const ManyToManyUnrelateSqliteOperation = require('./unrelate/ManyToManyUnrelateSqliteOperation');

const ManyToManyUpdateOperation = require('./update/ManyToManyUpdateOperation');
const ManyToManyUpdateMySqlOperation = require('./update/ManyToManyUpdateMySqlOperation');
const ManyToManyUpdateSqliteOperation = require('./update/ManyToManyUpdateSqliteOperation');

const ManyToManyDeleteOperation = require('./delete/ManyToManyDeleteOperation');
const ManyToManyDeleteMySqlOperation = require('./delete/ManyToManyDeleteMySqlOperation');
const ManyToManyDeleteSqliteOperation = require('./delete/ManyToManyDeleteSqliteOperation');

class ManyToManyRelation extends Relation {
  setMapping(mapping) {
    const retVal = super.setMapping(mapping);

    let ctx = {
      mapping,
      ownerModelClass: this.ownerModelClass,
      relatedModelClass: this.relatedModelClass,
      ownerProp: this.ownerProp,
      relatedProp: this.relatedProp,

      joinTableModelClass: null,
      joinTableOwnerProp: null,
      joinTableRelatedProp: null,
      joinTableBeforeInsert: null,
      joinTableExtras: [],

      createError: msg => this.createError(msg)
    };

    ctx = checkThroughObject(ctx);
    ctx = resolveJoinModelClassIfDefined(ctx);
    ctx = createJoinProperties(ctx);
    ctx = parseExtras(ctx);
    ctx = parseBeforeInsert(ctx);
    ctx = finalizeJoinModelClass(ctx);

    this.joinTableExtras = ctx.joinTableExtras;
    this.joinTableModelClass = ctx.joinTableModelClass;
    this.joinTableOwnerProp = ctx.joinTableOwnerProp;
    this.joinTableRelatedProp = ctx.joinTableRelatedProp;
    this.joinTableBeforeInsert = ctx.joinTableBeforeInsert;

    return retVal;
  }

  get forbiddenMappingProperties() {
    return [];
  }

  joinTableAlias(builder) {
    const table = builder.tableRefFor(this.joinTableModelClass);
    return `${table}_rel_${this.name}`;
  }

  findQuery(builder, opt) {
    const joinTableOwnerRefs = this.joinTableOwnerProp.refs(builder);

    builder.join(this.joinTable, join => {
      for (let i = 0, l = this.relatedProp.size; i < l; ++i) {
        const relatedRef = this.relatedProp.ref(builder, i);
        const joinTableRelatedRef = this.joinTableRelatedProp.ref(builder, i);

        join.on(relatedRef, joinTableRelatedRef);
      }
    });

    if (opt.isColumnRef) {
      for (let i = 0, l = joinTableOwnerRefs.length; i < l; ++i) {
        builder.where(joinTableOwnerRefs[i], ref(opt.ownerIds[i]));
      }
    } else if (containsNonNull(opt.ownerIds)) {
      builder.whereInComposite(joinTableOwnerRefs, opt.ownerIds);
    } else {
      builder.resolve([]);
    }

    return builder.modify(this.modify);
  }

  join(
    builder,
    {
      joinOperation = 'join',
      relatedTableAlias = this.relatedTableAlias(builder),
      relatedJoinSelectQuery = this.relatedModelClass.query().childQueryOf(builder),
      relatedTable = builder.tableNameFor(this.relatedModelClass),
      ownerTable = builder.tableRefFor(this.ownerModelClass),
      joinTableAlias = `${relatedTableAlias}_join`
    } = {}
  ) {
    const joinTableAsAlias = `${this.joinTable} as ${joinTableAlias}`;
    let relatedJoinSelect = relatedJoinSelectQuery.modify(this.modify).as(relatedTableAlias);

    if (relatedJoinSelect.isSelectAll()) {
      // No need to join a subquery if the query is `select * from "RelatedTable"`.
      relatedJoinSelect = `${relatedTable} as ${relatedTableAlias}`;
    }

    return builder[joinOperation](joinTableAsAlias, join => {
      const ownerProp = this.ownerProp;
      const joinTableOwnerProp = this.joinTableOwnerProp;

      for (let i = 0, l = ownerProp.size; i < l; ++i) {
        const joinTableOwnerRef = joinTableOwnerProp.ref(builder, i).table(joinTableAlias);
        const ownerRef = ownerProp.ref(builder, i).table(ownerTable);

        join.on(joinTableOwnerRef, ownerRef);
      }
    })[joinOperation](relatedJoinSelect, join => {
      const relatedProp = this.relatedProp;
      const joinTableRelatedProp = this.joinTableRelatedProp;

      for (let i = 0, l = relatedProp.size; i < l; ++i) {
        const joinTableRelatedRef = joinTableRelatedProp.ref(builder, i).table(joinTableAlias);
        const relatedRef = relatedProp.ref(builder, i).table(relatedTableAlias);

        join.on(joinTableRelatedRef, relatedRef);
      }
    });
  }

  find(builder, owners) {
    return new ManyToManyFindOperation('find', {
      relation: this,
      owners: owners
    });
  }

  insert(builder, owner) {
    return new ManyToManyInsertOperation('insert', {
      relation: this,
      owner: owner
    });
  }

  update(builder, owner) {
    if (isSqlite(builder.knex())) {
      return new ManyToManyUpdateSqliteOperation('update', {
        relation: this,
        owner: owner
      });
    } else if (isMySql(builder.knex())) {
      return new ManyToManyUpdateMySqlOperation('update', {
        relation: this,
        owner: owner
      });
    } else {
      return new ManyToManyUpdateOperation('update', {
        relation: this,
        owner: owner
      });
    }
  }

  patch(builder, owner) {
    if (isSqlite(builder.knex())) {
      return new ManyToManyUpdateSqliteOperation('patch', {
        modelOptions: { patch: true },
        relation: this,
        owner: owner
      });
    } else if (isMySql(builder.knex())) {
      return new ManyToManyUpdateMySqlOperation('patch', {
        modelOptions: { patch: true },
        relation: this,
        owner: owner
      });
    } else {
      return new ManyToManyUpdateOperation('patch', {
        modelOptions: { patch: true },
        relation: this,
        owner: owner
      });
    }
  }

  delete(builder, owner) {
    if (isSqlite(builder.knex())) {
      return new ManyToManyDeleteSqliteOperation('delete', {
        relation: this,
        owner: owner
      });
    } else if (isMySql(builder.knex())) {
      return new ManyToManyDeleteMySqlOperation('delete', {
        relation: this,
        owner: owner
      });
    } else {
      return new ManyToManyDeleteOperation('delete', {
        relation: this,
        owner: owner
      });
    }
  }

  relate(builder, owner) {
    return new ManyToManyRelateOperation('relate', {
      relation: this,
      owner: owner
    });
  }

  unrelate(builder, owner) {
    if (isSqlite(builder.knex())) {
      return new ManyToManyUnrelateSqliteOperation('unrelate', {
        relation: this,
        owner: owner
      });
    } else if (isMySql(builder.knex())) {
      return new ManyToManyUnrelateMySqlOperation('unrelate', {
        relation: this,
        owner: owner
      });
    } else {
      return new ManyToManyUnrelateOperation('unrelate', {
        relation: this,
        owner: owner
      });
    }
  }

  createJoinModels(ownerId, related) {
    const joinModels = new Array(related.length);

    for (let i = 0, lr = related.length; i < lr; ++i) {
      const rel = related[i];
      let joinModel = {};

      for (let j = 0, lp = this.joinTableOwnerProp.size; j < lp; ++j) {
        this.joinTableOwnerProp.setProp(joinModel, j, ownerId[j]);
      }

      for (let j = 0, lp = this.joinTableRelatedProp.size; j < lp; ++j) {
        this.joinTableRelatedProp.setProp(joinModel, j, this.relatedProp.getProp(rel, j));
      }

      for (let j = 0, lp = this.joinTableExtras.length; j < lp; ++j) {
        const extra = this.joinTableExtras[j];
        const extraValue = rel[extra.aliasProp];

        if (extraValue !== undefined) {
          joinModel[extra.joinTableProp] = extraValue;
        }
      }

      joinModels[i] = joinModel;
    }

    return joinModels;
  }

  omitExtraProps(models) {
    if (this.joinTableExtras && this.joinTableExtras.length) {
      const props = this.joinTableExtras.map(extra => extra.aliasProp);

      for (let i = 0, l = models.length; i < l; ++i) {
        const queryProps = models[i].$$queryProps;

        // Omit extra properties instead of deleting them from the models so that they can
        // be used in the `$before` and `$after` hooks.
        models[i].$omitFromDatabaseJson(props);

        if (queryProps) {
          // We can delete the query properties since they shouldn't be used by anything
          // other than `$toDatabaseJson()`.
          for (let j = 0; j < props.length; ++j) {
            const prop = props[j];

            if (prop in queryProps) {
              delete queryProps[prop];
            }
          }
        }
      }
    }
  }

  executeJoinTableBeforeInsert(models, queryContext, result) {
    return mapAfterAllReturn(
      models,
      model => this.joinTableBeforeInsert(model, queryContext),
      result
    );
  }
}

function checkThroughObject(ctx) {
  const mapping = ctx.mapping;

  if (!mapping.join.through || typeof mapping.join.through !== 'object') {
    throw ctx.createError('join must have a `through` object that describes the join table.');
  }

  if (!mapping.join.through.from || !mapping.join.through.to) {
    throw ctx.createError(
      'join.through must be an object that describes the join table. For example: {from: "JoinTable.someId", to: "JoinTable.someOtherId"}'
    );
  }

  return ctx;
}

function resolveJoinModelClassIfDefined(ctx) {
  let joinTableModelClass = null;

  if (ctx.mapping.join.through.modelClass) {
    try {
      joinTableModelClass = resolveModel(
        ctx.mapping.join.through.modelClass,
        ctx.ownerModelClass.modelPaths,
        'join.through.modelClass'
      );
    } catch (err) {
      throw ctx.createError(err.message);
    }
  }

  return Object.assign(ctx, { joinTableModelClass });
}

function createJoinProperties(ctx) {
  let ret;

  let fromProp;
  let toProp;

  let relatedProp;
  let ownerProp;

  ret = createRelationProperty(ctx, ctx.mapping.join.through.from, 'join.through.from');
  fromProp = ret.prop;
  ctx = ret.ctx;

  ret = createRelationProperty(ctx, ctx.mapping.join.through.to, 'join.through.to');
  toProp = ret.prop;
  ctx = ret.ctx;

  if (fromProp.modelClass.getTableName() !== toProp.modelClass.getTableName()) {
    throw ctx.createError('join.through `from` and `to` must point to the same join table.');
  }

  if (ctx.relatedProp.modelClass.getTableName() === fromProp.modelClass.getTableName()) {
    relatedProp = fromProp;
    ownerProp = toProp;
  } else {
    relatedProp = toProp;
    ownerProp = fromProp;
  }

  return Object.assign(ctx, {
    joinTableOwnerProp: ownerProp,
    joinTableRelatedProp: relatedProp
  });
}

function createRelationProperty(ctx, refString, messagePrefix) {
  let prop = null;
  let joinTableModelClass = ctx.joinTableModelClass;

  const resolveModelClass = table => {
    if (joinTableModelClass === null) {
      joinTableModelClass = inheritModel(getModel());
      joinTableModelClass.tableName = table;
      joinTableModelClass.idColumn = null;
      joinTableModelClass.concurrency = 1;
    }

    if (joinTableModelClass.getTableName() === table) {
      return joinTableModelClass;
    } else {
      return null;
    }
  };

  try {
    prop = new RelationProperty(refString, resolveModelClass);
  } catch (err) {
    if (err instanceof RelationProperty.ModelNotFoundError) {
      throw ctx.createError('join.through `from` and `to` must point to the same join table.');
    } else {
      throw ctx.createError(
        `${messagePrefix} must have format JoinTable.columnName. For example "JoinTable.someId" or in case of composite key ["JoinTable.a", "JoinTable.b"].`
      );
    }
  }

  return {
    ctx: Object.assign(ctx, { joinTableModelClass }),
    prop
  };
}

function parseExtras(ctx) {
  let extraDef = ctx.mapping.join.through.extra;

  if (!extraDef) {
    return ctx;
  }

  if (Array.isArray(extraDef)) {
    extraDef = extraDef.reduce((extraDef, col) => {
      extraDef[col] = col;
      return extraDef;
    }, {});
  }

  const joinTableExtras = Object.keys(extraDef).map(key => {
    const val = extraDef[key];

    return {
      joinTableCol: val,
      joinTableProp: ctx.joinTableModelClass.columnNameToPropertyName(val),
      aliasCol: key,
      aliasProp: ctx.joinTableModelClass.columnNameToPropertyName(key)
    };
  });

  return Object.assign(ctx, { joinTableExtras });
}

function parseBeforeInsert(ctx) {
  let joinTableBeforeInsert;

  if (typeof ctx.mapping.join.through.beforeInsert === 'function') {
    joinTableBeforeInsert = ctx.mapping.join.through.beforeInsert;
  } else {
    joinTableBeforeInsert = model => model;
  }

  return Object.assign(ctx, { joinTableBeforeInsert });
}

function finalizeJoinModelClass(ctx) {
  if (ctx.joinTableModelClass.getIdColumn() === null) {
    // We cannot know if the join table has a primary key. Therefore we set some
    // known column as the idColumn so that inserts will work.
    ctx.joinTableModelClass.idColumn = ctx.joinTableRelatedProp.cols;
  }

  return ctx;
}

function containsNonNull(arr) {
  for (let i = 0, l = arr.length; i < l; ++i) {
    const val = arr[i];

    if (Array.isArray(val) && containsNonNull(val)) {
      return true;
    } else if (val !== null && val !== undefined) {
      return true;
    }
  }

  return false;
}

module.exports = ManyToManyRelation;
