const { asArray, isObject, uniqBy, get, set } = require('../utils/objectUtils');
const { ref: createRef } = require('../queryBuilder/ReferenceBuilder');
const { raw: createRaw } = require('../queryBuilder/RawBuilder');
const { propToStr, PROP_KEY_PREFIX } = require('../model/modelValues');

class ModelNotFoundError extends Error {
  constructor(tableName) {
    super();
    this.name = this.constructor.name;
    this.tableName = tableName;
  }
}

class InvalidReferenceError extends Error {
  constructor() {
    super();
    this.name = this.constructor.name;
  }
}

// A pair of these define how two tables are related to each other.
// Both the owner and the related table have one of these.
//
// A relation property can be a single column, an array of columns
// (composite key) a json column reference, an array of json column
// references or any combination of the above.
class RelationProperty {
  // references must be a reference string like `Table.column:maybe.some.json[1].path`.
  // or an array of such references (composite key).
  //
  // modelClassResolver must be a function that takes a table name
  // and returns a model class.
  constructor(references, modelClassResolver) {
    const refs = createRefs(asArray(references));
    const paths = createPaths(refs, modelClassResolver);
    const modelClass = resolveModelClass(paths);

    this._refs = refs;
    this._modelClass = modelClass;
    this._props = paths.map(it => it.path[0]);
    this._cols = refs.map(it => it.column);
    this._propGetters = paths.map(it => createGetter(it.path));
    this._propSetters = paths.map(it => createSetter(it.path));
  }

  static get ModelNotFoundError() {
    return ModelNotFoundError;
  }

  static get InvalidReferenceError() {
    return InvalidReferenceError;
  }

  // The number of columns.
  get size() {
    return this._refs.length;
  }

  // The model class that owns the property.
  get modelClass() {
    return this._modelClass;
  }

  // An array of property names. Contains multiple values in case of composite key.
  // This may be different from `cols` if the model class has some kind of conversion
  // between database and "external" formats, for example a snake_case to camelCase
  // conversion.
  get props() {
    return this._props;
  }

  // An array of column names. Contains multiple values in case of composite key.
  // This may be different from `props` if the model class has some kind of conversion
  // between database and "external" formats, for example a snake_case to camelCase
  // conversion.
  get cols() {
    return this._cols;
  }

  // Creates a concatenated string from the property values of the given object.
  propKey(obj) {
    const size = this.size;
    var key = PROP_KEY_PREFIX;

    for (let i = 0; i < size; ++i) {
      key += propToStr(this.getProp(obj, i));

      if (i !== size - 1) {
        key += ',';
      }
    }

    return key;
  }

  // Returns the property values of the given object as an array.
  getProps(obj) {
    const size = this.size;
    const props = new Array(size);

    for (let i = 0; i < size; ++i) {
      props[i] = this.getProp(obj, i);
    }

    return props;
  }

  // Returns the index:th property value of the given object.
  getProp(obj, index) {
    return this._propGetters[index](obj);
  }

  // Sets the index:th property value of the given object.
  setProp(obj, index, value) {
    return this._propSetters[index](obj, value);
  }

  // Returns the index:th column name with table reference. Something like
  // 'Table.someColumn'.
  fullCol(builder, index) {
    const table = builder.tableRefFor(this.modelClass);

    return `${table}.${this.cols[index]}`;
  }

  // Returns an instance of ReferenceBuilder that points to the index:th
  // value of a row.
  ref(builder, index) {
    const table = builder.tableRefFor(this.modelClass);

    return this._refs[index].clone().table(table);
  }

  // Returns an array of reference builder. `ref(builder, i)` for each i.
  refs(builder) {
    const refs = new Array(this.size);

    for (let i = 0, l = refs.length; i < l; ++i) {
      refs[i] = this.ref(builder, i);
    }

    return refs;
  }

  // Appends an update operation for the index:th column into `patch` object.
  patch(patch, index, value) {
    const ref = this._refs[index];

    if (ref.isPlainColumnRef) {
      patch[this._cols[index]] = value;
    } else {
      // Objection `patch`, `update` etc. methods understand field expressions.
      patch[ref.expression] = value;
    }
  }

  // String representation of this property's index:th column for logging.
  propDescription(index) {
    return this._refs[index].expression;
  }
}

function createRefs(refs) {
  try {
    return refs.map(it => {
      if (!isObject(it) || !it.isObjectionReferenceBuilder) {
        return createRef(it);
      } else {
        return it;
      }
    });
  } catch (err) {
    throw new InvalidReferenceError();
  }
}

function createPaths(refs, modelClassResolver) {
  return refs.map(ref => {
    if (!ref.tableName) {
      throw new InvalidReferenceError();
    }

    const modelClass = modelClassResolver(ref.tableName);

    if (!modelClass) {
      throw new ModelNotFoundError(ref.tableName);
    }

    const prop = modelClass.columnNameToPropertyName(ref.column);
    const jsonPath = ref.reference.access.map(it => it.ref);

    return {
      path: [prop].concat(jsonPath),
      modelClass
    };
  });
}

function resolveModelClass(paths) {
  const modelClasses = paths.map(it => it.modelClass);
  const uniqueModelClasses = uniqBy(modelClasses);

  if (uniqueModelClasses.length !== 1) {
    throw new InvalidReferenceError();
  }

  return modelClasses[0];
}

function createGetter(path) {
  if (path.length === 1) {
    const prop = path[0];
    return obj => obj[prop];
  } else {
    return obj => get(obj, path);
  }
}

function createSetter(path) {
  if (path.length === 1) {
    const prop = path[0];
    return (obj, value) => (obj[prop] = value);
  } else {
    return (obj, value) => set(obj, path, value);
  }
}

module.exports = RelationProperty;
