const assert = require('assert')

const deepeq = require('fast-deep-equal')
const jsonStringify = require('fast-json-stable-stringify')
const deepcopy = require('rfdc')()

const { InvalidFieldError, InvalidOptionsError, NotImplementedError, InvalidParameterError } = require('./errors')
const { validateValue } = require('./utils')

/**
 * Abstract class representing a field / property of a Model.
 *
 * @private
 * @memberof Internal
 */
class __FieldInterface {
  constructor () {
    if (this.constructor === __FieldInterface) {
      throw new Error('Can not instantiate abstract class')
    }
  }

  get __awsName () {
    throw new NotImplementedError()
  }

  get mutated () {
    throw new NotImplementedError()
  }

  get __mayHaveMutated () {
    throw new NotImplementedError()
  }

  get accessed () {
    throw new NotImplementedError()
  }

  get () {
    throw new NotImplementedError()
  }

  set (val) {
    throw new NotImplementedError()
  }

  __updateExpression (exprKey) {
    throw new NotImplementedError()
  }

  __conditionExpression (exprKey) {
    throw new NotImplementedError()
  }

  validate () {
    throw new NotImplementedError()
  }

  hasChangesToCommit (expectWrites = true) {
    throw new NotImplementedError()
  }
}

/**
 * @namespace Fields
 * @memberof Internal
 */

/**
 * @namespace Internal
 */

/**
 * Base class representing a field / property of a Model.
 *
 * @private
 * @memberof Internal
 */
class __BaseField extends __FieldInterface {
  /*
   * Indicates if any changes need to be committed in the field.
   * @param {Boolean} expectWrites whether the model will be updated,
   *  default is true.
   * @type {Boolean}
   */
  hasChangesToCommit (expectWrites = true) {
    // If a field is changed due to being initialized with the default
    // value within a read-only transaction, the change shouldn't be commited.
    // Otherwise, an error will occur when attempting to update in a
    // read-only transaction.
    const initDefault = !expectWrites && this.__useDefault && !this.__written
    return this.mutated && !initDefault
  }
}

/**
 * Internal object representing a field / property of a Model.
 *
 * @private
 * @memberof Internal
 */
class __Field extends __BaseField {
  static __validateFieldOptions (modelName, keyType, fieldName, schema) {
    if (fieldName.startsWith('_')) {
      throw new InvalidFieldError(
        fieldName, 'property names may not start with "_"')
    }

    assert(['PARTITION', 'SORT', undefined].includes(keyType),
      'keyType must be one of \'PARTITION\', \'SORT\' or undefined')
    assert(schema.isTodeaSchema, 'must be Todea schema')

    const compiledSchema = schema.getValidatorAndJSONSchema(
      `${modelName}.${fieldName}`)
    const jsonSchema = compiledSchema.jsonSchema
    const isKey = !!keyType
    const options = {
      keyType,
      schema: jsonSchema,
      optional: schema.required === false,
      immutable: isKey || jsonSchema.readOnly === true,
      default: jsonSchema.default,
      assertValid: compiledSchema.assertValid
    }
    const FieldCls = SCHEMA_TYPE_TO_FIELD_CLASS_MAP[options.schema.type]
    assert.ok(FieldCls, `unsupported field type ${options.schema.type}`)

    const hasDefault = Object.prototype.hasOwnProperty.call(jsonSchema, 'default')
    if (isKey) {
      if (hasDefault && keyType === 'PARTITION') {
        throw new InvalidOptionsError('default',
          'No defaults for partition keys.') // It just doesn\'t make sense.
      }
      if (jsonSchema.readOnly === false) {
        throw new InvalidOptionsError('immutable',
          'Keys must be immutable.')
      }
      if (options.optional) {
        throw new InvalidOptionsError('optional',
          'Keys must never be optional.')
      }
    }
    if (hasDefault) {
      validateValue(this.name, options, options.default)
    }
    return options
  }

  /**
   * @typedef {Object} FieldOptions
   * @property {'PARTITION'|'SORT'} [keyType=undefined] If specified, the field is
   *   a key. Use 'PARTITION' for a partition key. Use 'SORT' for a sort key.
   *   When keyType is specified, other options are forced to be
   *   { optional: false, immutable: true, default: undefined }. If user
   *   supplied values that conflicts with those values, InvalidOptionsError
   *   will be thrown.
   * @property {Boolean} [optional=false] If field can be left undefined.
   * @property {Boolean} [immutable=false] If field can be changed again after
   *   value is set to anything except undefined.
   * @property {*} [default=undefined] Default value to use. IMPORTANT: Value
   *   is deeply copied, so additional modifications to the parameter will
   *   not reflect in the field.
   * @property {schema} [schema=undefined] An optional JSON schema
   *   to validate Field's value.
   */

  /**
   * @param {String} name the field's name (also the name of the underlying
   *   attribute in the database where this field is stored [except key
   *   components which are not stored in the db in their own attribute])
   * @param {FieldOptions} opts
   * @param {*} val the initial value of the field
   * @param {boolean} valIsFromDB whether val is from (or is expected to be
   *   from) the database
   * @param {boolean} valSpecified whether val was specified (if this is
   *   true, then the field was present)
   * @param {boolean} isForUpdate whether this field is part of an update
   * @param {boolean} isForUpdate whether this field is part of an delete
   */
  constructor ({
    idx,
    name,
    opts,
    val,
    valIsFromDB,
    valSpecified,
    isForUpdate,
    isForDelete
  }) {
    super()
    for (const [key, value] of Object.entries(opts)) {
      Object.defineProperty(this, key, { value, writable: false })
    }

    // Setup states
    /**
     * @memberof Internal.__Field
     * @instance
     * @member {String} name The name of the owning property.
     */
    this.__idx = idx
    this.name = name
    this.__value = undefined
    this.__readInitialValue = false // If get is called
    this.__written = false // If set is called
    this.__default = opts.default // only used for new items!

    // determine whether to use the default value, or the given val
    let useDefault
    if (valSpecified) {
      // if val was specified, then use it
      useDefault = false
    } else {
      assert.ok(val === undefined,
        'valSpecified can only be false if val is undefined')
      if (this.__default === undefined) {
        // can't use the default if there is no default value
        useDefault = false
      } else if (valIsFromDB && this.optional) {
        // if the field is optional and the value is not in the db then we
        // can't use the default (we have to assume the field was omitted)
        useDefault = false
      } else if (isForUpdate) {
        // when creating an item as the base of an update, we don't implicitly
        // create defaults; our preconditions are ONLY what is explicitly given
        useDefault = false
      } else {
        useDefault = true
      }
    }

    this.__useDefault = useDefault
    this.__value = useDefault ? deepcopy(this.__default) : val

    if (valIsFromDB) {
      // The field's current value is the value stored in the database. Track
      // that value so that we can detect if it changes, and write that
      // change to the database.
      // Note: val is undefined whenever useDefault is true
      this.__initialValue = deepcopy(val)
    } else {
      this.__initialValue = undefined
    }

    // validate the value, if needed
    if (!this.keyType) { // keys are validated elsewhere; don't re-validate
      if (!useDefault) { // default was validated by __validateFieldOptions
        // validate everything except values omitted from an update() call
        if (valSpecified || !(isForUpdate || isForDelete)) {
          this.validate()
        }
      }
    }
  }

  /**
   * Name used in AWS expressions.
   *
   * This name is short for performance reasons, and is used with
   * ExpressionAttributeNames to avoid collisions with reserved AWS names.
   */
  get __awsName () {
    return `#${this.__idx}`
  }

  /**
   * Generates a [SET, AttributeValues, REMOVE] tuple.
   *
   * @access package
   * @param {String} exprKey A key to use to link values in ConditionExpression
   *   and ExpressionAttributeValues
   * @returns {Array} [ConditionExpression, ExpressionAttributeValues,
   *   ShouldRemove]
   */
  __updateExpression (exprKey) {
    if (!this.mutated) {
      return []
    }
    if (this.__value === undefined) {
      return [undefined, {}, true]
    }

    return [
      `${this.__awsName}=${exprKey}`,
      { [exprKey]: deepcopy(this.__value) },
      false
    ]
  }

  /**
   * Whether to condition the transaction on this field's initial value.
   */
  get canUpdateWithoutCondition () {
    return (
      // keys uniquely identify an item; all keys generate a condition check
      this.keyType === undefined &&
      // if an item's value is read before it is modified, then we must verify
      // that it's value doesn't change
      !this.__readInitialValue)
  }

  /**
   * Generates a [ConditionExpression, ExpressionAttributeValues] pair.
   *
   * @access package
   * @param {String} exprKey A key to use to link values in ConditionExpression
   *   and ExpressionAttributeValues
   * @returns {Array} [ConditionExpression, ExpressionAttributeValues]
   */
  __conditionExpression (exprKey) {
    if (this.canUpdateWithoutCondition) {
      return []
    }
    if (this.__initialValue === undefined) {
      return [
        `attribute_not_exists(${this.__awsName})`,
        {}
      ]
    }
    return [
      `${this.__awsName}=${exprKey}`,
      { [exprKey]: this.__initialValue }
    ]
  }

  /**
   * This method compares initialValue against the current value.
   *
   * @returns if value was changed.
   */
  get mutated () {
    return this.__value !== this.__initialValue
  }

  /**
   * A quick heuristic on if current value may differ from db. When this value
   * is true, use comparison / deep equality to confirm if the value has been
   * mutated. If this value is false, then the field must not have been
   * mutated, and there is no need to confirm mutation further.
   */
  get __mayHaveMutated () {
    // A field may be mutated when it's written or if the value is a complex
    // structure when it is read.
    if (this.accessed) {
      return true
    }
    // A field may still be mutated when it's never read or written, since the
    // field may be initialized with a mutated value (default value from schema
    // or default value upon creation).
    if (this.__initialValue === undefined && this.__value !== undefined) {
      return true
    }
    // Else the field must not be different from what's in database.
    return false
  }

  /**
   * This is primarily used for optimistic locking.
   * @returns {Boolean} if the field was accessed (read / write) by users of
   *   this library
   */
  get accessed () {
    return this.__readInitialValue || this.__written
  }

  /**
   * Gets the field's current value. Calling this method will mark the field as
   * "{@link accessed}".
   *
   * @see {@link __value} for accessing value within the library without
   *   "accessing" the field
   * @access public
   */
  get () {
    if (!this.__written) {
      this.__readInitialValue = true
    }
    return this.__value
  }

  /**
   * If the value passed in is valid, update field's current value, mark the
   * field as "{@link accessed}". If the value is not valid, throws
   * InvalidFieldError.
   *
   * @param {*} value New value for the field.
   * @affects {@link __Field#accessed}
   * @access public
   */
  set (val) {
    if (this.immutable) {
      throw new InvalidFieldError(
        this.name,
        'is immutable so value cannot be changed after first initialized.')
    }

    const prev = [this.__value, this.__written]
    this.__value = val
    this.__written = true

    try {
      this.validate()
    } catch (e) {
      [this.__value, this.__written] = prev
      throw e
    }
  }

  /**
   * Checks if the field's current value is valid. Throws InvalidFieldError if
   * check fails.
   */
  validate () {
    validateValue(this.name, this, this.__value)
  }
}

/**
 * @extends Internal.__Field
 * @memberof Internal.Fields
 * @private
 */
class NumberField extends __Field {
  constructor (options) {
    super(options)
    this.__diff = undefined
    this.__mustUseSet = false

    // figure out what value the diff will be added to
    if (this.__initialValue !== undefined) {
      // this occurs when the field is on an existing item AND the item already
      // had a value for the field
      this.__base = this.__initialValue
    } else if (this.__value !== undefined) {
      // this case occurs when the field is on a new item
      this.__base = this.__value
    } else {
      // this case occurs if the field is not currently present on the item;
      // in this case increment cannot be used to update the item
      this.__base = undefined
    }
  }

  set (val) {
    super.set(val)
    // don't change any state unless set() succeeds
    this.__diff = undefined // no longer computed as a diff
    this.__mustUseSet = true
  }

  /**
   * Updates the field's value by an unconditioned increment IF the field is
   * never read (reduces contention). If the field is ever read, there's no
   * reason to use this.
   * @param {Number} diff The diff amount.
   */
  incrementBy (diff) {
    // add the new diff to our current diff, if any
    // wait to set __diff until after super.set() succeeds to ensure no
    // changes are made if set() fails!
    const newDiff = (this.__diff === undefined) ? diff : this.__diff + diff

    // if we've already read the value, there's no point in generating an
    // increment update expression as we must lock on the original value anyway
    if (this.__readInitialValue || this.__mustUseSet) {
      this.set(this.__sumIfValid(false, newDiff))
      // this.__diff isn't set because we can't not using diff updates now
      return
    }

    // call directly on super to avoid clearing the diff value
    super.set(this.__sumIfValid(true, newDiff))
    this.__diff = newDiff
  }

  /**
   * Returns the sum of diff and some value. Throws if the latter is undefined.
   * @param {boolean} fromBase whether to add __base (else __value)
   * @param {number} diff how much to add
   * @returns {number} sum
   */
  __sumIfValid (fromBase, diff) {
    const base = fromBase ? this.__base : this.__value
    if (base === undefined) {
      throw new InvalidFieldError(
        this.name, 'cannot increment a field whose value is undefined')
    }
    return base + diff
  }

  /**
   * Whether this field can be updated with an increment expression.
   */
  get canUpdateWithIncrement () {
    return (
      // if there's no diff, we cannot use increment
      this.__diff !== undefined &&
      // if the field didn't have an old value, we can't increment it (DynamoDB
      // will throw an error if we try to do X=X+1 when X has no value)
      this.__initialValue !== undefined &&
      // if we're generating a condition on the initial value, there's no
      // benefit to do an increment so we can just do a standard set
      this.canUpdateWithoutCondition)
  }

  __updateExpression (exprKey) {
    // if we're locking, there's no point in doing an increment
    if (this.canUpdateWithIncrement) {
      return [
        `${this.__awsName}=${this.__awsName}+${exprKey}`,
        { [exprKey]: this.__diff },
        false
      ]
    }
    return super.__updateExpression(exprKey)
  }
}

/**
 * @extends Internal.__Field
 * @memberof Internal.Fields
 * @private
 */
class StringField extends __Field {
  get mutated () {
    return this.__mayHaveMutated && super.mutated
  }
}

/**
 * @extends Internal.__Field
 * @memberof Internal.Fields
 * @private
 */
class ObjectField extends __Field {
  /**
   * This method checks for equality deeply against the initial
   * value so use it as sparsely as possible. It is primarily meant to be
   * used internally for deciding whether a field needs to be transmitted to
   * the server.
   *
   * @returns if value was changed.
   */
  get mutated () {
    return this.__mayHaveMutated && !deepeq(this.__value, this.__initialValue)
  }
}

/**
 * @extends Internal.__Field
 * @memberof Internal.Fields
 * @private
 */
class BooleanField extends __Field {}

/**
 * @extends Internal.__Field
 * @memberof Internal.Fields
 * @private
 */
class ArrayField extends __Field {
  /**
   * This method checks for equality deeply against the initial
   * value so use it as sparsely as possible. It is primarily meant to be
   * used internally for deciding whether a field needs to be transmitted to
   * the server.
   *
   * @returns if value was changed.
   */
  get mutated () {
    return this.__mayHaveMutated && !deepeq(this.__value, this.__initialValue)
  }
}

/**
 * Internal object used to create a compound field containing one or more fields
 *
 * @private
 * @memberof Internal
 */
class __CompoundField extends __BaseField {
  constructor ({ idx, name, isNew, fields }) {
    super()
    if (fields.every(field => field instanceof __Field) === false) {
      throw new InvalidFieldError(name, 'Compound field can contain only Field objects')
    }
    this.name = name
    this.__fields = fields
    this.__idx = idx
    this.__isNew = isNew
    this.__initialValue = this.__value
    this.canUpdateWithoutCondition = true
  }

  get __awsName () {
    return `#${this.__idx}`
  }

  get mutated () {
    // in some cases, __initialValue and __value are equal, but we still want
    // write to the db, because the value cached here are used to populate
    // other fields. To detect changes correctly, we can't use __value, because
    // if multiple fields are undefined, the overall value is still undefined,
    // but in reality, a field might have been undefined in this transaction.
    return this.__isNew ||
      (this.__mayHaveMutated && this.__fields.some(field => field.mutated))
  }

  get __mayHaveMutated () {
    return this.__isNew || this.__fields.some(field => field.__mayHaveMutated)
  }

  get accessed () {
    return this.__fields.some(field => field.accessed)
  }

  /**
  * Generates the value for the compound property. If any of the underlying
  * field is undefined, compound value returns undefined.
  * Currently, it supports only generated fields used in Index.
  *
  * @returns encoded value for the compound field
  **/
  get __value () {
    const allVal = {}
    for (const field of this.__fields) {
      if (field.__value === undefined) {
        return undefined
      }
      allVal[field.name] = field.__value
    }
    return this.constructor.__encodeValues(Object.keys(allVal), allVal)
  }

  static __encodeName (fields) {
    return ['_c', ...fields.sort()].join('_')
  }

  static __encodeValues (fields, values) {
    const pieces = []
    fields = fields.sort()
    if (fields.length === 1 && typeof (values[fields[0]]) === 'number') {
      return values[fields[0]]
    }
    for (const field of fields) {
      const val = values[field]
      if (val === undefined) {
        throw new InvalidFieldError(field, 'must be provided')
      }
      if (typeof (val) === 'string') {
        if (val.indexOf('\0') !== -1) {
          throw new InvalidFieldError(field,
            'cannot put null bytes in strings in compound values')
        }
        pieces.push(val)
      } else {
        pieces.push(jsonStringify(val))
      }
    }
    return pieces.join('\0')
  }

  static __decodeValues (propName, propVal) {
    if (!propName.startsWith('_c')) {
      // the propName provided doesn't match the name format for compound field
      // We will simply return with no data
      return {}
    }
    const props = propName.substring(3).split('_')
    const data = {}
    if (typeof (propVal) === 'number') {
      data[props[0]] = propVal
      return data
    }
    const vals = propVal.split('\0')
    if (props.length !== vals.length) {
      throw new InvalidParameterError('Trying to decode compound field value with unequal amount of properties')
    }
    props.forEach((key, index) => {
      const val = vals[index]
      try {
        data[key] = JSON.parse(val)
      } catch (error) {
        // val was native int/string type
        data[key] = val
      }
    })
    return data
  }

  get () {
    return this.__value
  }

  set (val) {
    throw new InvalidFieldError(this.name, 'Compound fields are immutable.')
  }

  __updateExpression (exprKey) {
    const val = this.__value
    if (!this.mutated || (this.__isNew && val === undefined)) {
      return []
    }

    if (val === undefined) {
      return [undefined, {}, true]
    }

    return [
      `${this.__awsName}=${exprKey}`,
      { [exprKey]: val },
      false
    ]
  }

  __conditionExpression (exprKey) {
    return []
  }

  validate () {
    // All the underlying fields are validated in it's own section, no need to
    // re-validate them here
    return true
  }
}

const SCHEMA_TYPE_TO_FIELD_CLASS_MAP = {
  array: ArrayField,
  boolean: BooleanField,
  float: NumberField,
  integer: NumberField,
  number: NumberField,
  object: ObjectField,
  string: StringField
}

module.exports = {
  __FieldInterface,
  __Field,
  NumberField,
  ArrayField,
  BooleanField,
  StringField,
  ObjectField,
  __CompoundField,
  SCHEMA_TYPE_TO_FIELD_CLASS_MAP
}