/**
 * Possible states of the promise
 */
const STATE = Object.freeze({
  PENDING: Object.freeze({}),
  RESOLVED: Object.freeze({}),
  REJECTED: Object.freeze({})
})

/**
 * Private field symbols.
 */
const FIELDS = Object.freeze({
  state: Symbol("state"),
  value: Symbol("value"),
  fulfillListeners: Symbol("fulfillListeners"),
  errorListeners: Symbol("errorListeners")
})

/**
 * The {@linkcode PromiseSync} is a synchronous alternative to the Promise/A+
 * API, meaning that all callbacks are executed synchronously.
 *
 * The synchronous promise is used for in-transaction operations to ensure the
 * following:
 * - It allows safe wrapping of IndexedDB request objects, allowing the
 *   callbacks to request more operations on the transaction without the risk
 *   of the transaction becoming inactive. Promise/A+ callbacks are executed
 *   asynchronously, so the transaction will be marked as inactive before the
 *   {@code then} callback of an Promise/A+-wrapped request would be executed.
 * - Firefox (37, and probably later versions too) does not allow the
 *   Promise/A+ callbacks to request new operations on the transaction even if
 *   the transaction is still alive (it throws an error claiming the
 *   transaction is inactive).
 *
 * Since safe wrapping of IndexedDB requests is the main use of the
 * {@linkcode PromiseSync} promises, the {@linkcode PromiseSync.resolve()}
 * method can handle {@linkcode IDBRequest} instances as an argument.
 */
export default class PromiseSync {
  /**
   * Initializes the synchronous promise.
   *
   * @param {function(function(*), function(Error))} callback The promise
   *        initialization callback.
   */
  constructor(callback) {
    /**
     * One of the {@code STATE.*} constants representing the current state of
     * this promise.
     *
     * @type {Object}
     */
    this[FIELDS.state] = STATE.PENDING

    /**
     * The result value of this promise.
     *
     * @type {(undefined|*|Error)}
     */
    this[FIELDS.value] = undefined

    /**
     * Internal listeners used to notify the descending promises when this
     * promise is asynchronously fulfilled.
     *
     * @type {function()[]}
     */
    this[FIELDS.fulfillListeners] = []

    /**
     * Internal listeners used to notify the descending promises when this
     * promise is asynchronously rejected.
     *
     * @type {function()[]}
     */
    this[FIELDS.errorListeners] = [
      () => {
        if (this[FIELDS.errorListeners] === 1) {
          console.error("Uncaught (in sync promise)", this[FIELDS.value])
        }
      }
    ]

    try {
      callback(
        value => resolve(this, STATE.RESOLVED, value),
        error => resolve(this, STATE.REJECTED, error)
      )
    } catch (error) {
      resolve(this, STATE.REJECTED, error)
    }
  }

  /**
   * Registers the provided success and error callbacks on this promise.
   *
   * If this promise resolves, the success callback will be triggered. The
   * value returned by the success callback will be the value of the promise
   * returned by this method. However, if the value returned by the callback is
   * a {@linkcode PromiseSync} instance, the promise returned by this method
   * will resolve to the result of the promise returned by the callback.
   * Finally, if the callback throws an error, the promise returned by this
   * method will be rejected by the error thrown by the callback.
   *
   * If this promise rejects, the error callback will be triggered. The error
   * callback affects the resolution/rejection of the promise returned by this
   * method in the same way the success callback does.
   *
   * @param {function(*): *} onFulfill The success callback.
   * @param {function(Error): *} onError The error callback.
   * @return {PromiseSync} A promise resolved when this promise is resolved and
   *         the return value of the invoked callback is resolved.
   */
  then(onFulfill, onError = undefined) {
    let thisPromise = this

    return new PromiseSync((resolve, reject) => {
      switch (this[FIELDS.state]) {
        case STATE.PENDING:
          this[FIELDS.fulfillListeners].push(() => handleResolution(onFulfill))
          this[FIELDS.errorListeners].push(() => handleResolution(onError))
          break
        case STATE.RESOLVED:
          handleResolution(onFulfill)
          break
        case STATE.REJECTED:
          handleResolution(onError)
          break
      }

      function handleResolution(callback) {
        if (!(callback instanceof Function)) {
          if (thisPromise[FIELDS.state] === STATE.RESOLVED) {
            resolve(thisPromise[FIELDS.value])
          } else {
            reject(thisPromise[FIELDS.value])
          }
        }

        try {
          let newValue = callback(thisPromise[FIELDS.value])
          if (newValue instanceof PromiseSync) {
            newValue.then(
              resultingValue => resolve(resultingValue),
              error => reject(error)
            )
          } else {
            resolve(newValue)
          }
        } catch (error) {
          reject(error)
        }
      }
    })
  }

  /**
   * Returns a new promise that executes the provided callback if this promise
   * gets rejected, passing the error as the argument to the callback.
   *
   * The returned promise will resolve if this promise resolves.
   *
   * This method is essentially a shorthand for
   * {@code promise.then(undefined, onError)}.
   *
   * @param {function(Error): *} onError The callback to execute if this
   *        promise is rejected. The callback may either return a value to
   *        resolve the returned promise, or throw an error to reject it.
   * @return {PromiseSync<*>} A promise resolved when this promise is resolved
   *         or the provided callback returns a value, and rejected if this
   *         promise is rejected and the callback throws an error.
   */
  catch(onError) {
    return this.then(undefined, onError)
  }

  /**
   * Converts this promise to a regular Promise/A+ native promise.
   *
   * @return {Promise<*>} Asynchronous representation of this promise.
   */
  toPromise() {
    return new Promise((resolve, reject) => {
      this.then(resolve, reject)
    })
  }

  /**
   * Creates a new promise that resolves to the provided value.
   *
   * If the value is a {@linkcode Promise} or a {@linkcode PromiseSync}
   * instance, the returned promise will be resolved when the provided promise
   * is resolved, and rejected if the provided promise is rejected.
   *
   * If the value is a {@linkcode IDBRequest} instance, the returned promise
   * will resolve when the request's {@code onsuccess} method is triggered, and
   * rejects when the request's {@code onerror} method is triggered.
   *
   * Note that the method returns the provided value if the value is a
   * {@linkcode PromiseSync} instance.
   *
   * Also, if an already completed Indexed DB request is provided, the returned
   * promise will never resolve. The method replaces any value previously set
   * to the request's {@code onsuccess} and {@code onerror} methods.
   *
   * @param {(Promise<*>|PromiseSync<*>|IDBRequest<*>|*)} value The value to
   *        which the returned promise should be resolved.
   * @return {PromiseSync<*>} A promise that resolves to the provided value, or
   *         the value to which the provided promise or Indexed DB request
   *         resolves.
   */
  static resolve(value) {
    if (value instanceof PromiseSync) {
      return value
    }

    if (value instanceof Promise) {
      return new PromiseSync((resolve, reject) => {
        value.then(resolve, reject)
      })
    }

    if (value instanceof IDBRequest) {
      return new PromiseSync((resolve, reject) => {
        value.onsuccess = () => resolve(value.result)
        value.onerror = () => reject(value.error)
      })
    }

    return new PromiseSync((resolve) => {
      resolve(value)
    })
  }

  /**
   * Creates a new promise that is rejected with the provided error.
   *
   * @param {Error} error The error.
   * @return {PromiseSync} A new promise rejected with the provided error.
   */
  static reject(error) {
    return new PromiseSync(() => {
      throw error
    })
  }

  /**
   * Returns a new promise that resolves once all the provided promises
   * resolve, or rejects if any of the promises rejects.
   *
   * The returned promise resolves to an array of values provided by the
   * resolved promises.
   *
   * @param {(PromiseSync<*>|Promise<*>)[]} promises The promises to resolve.
   * @return {PromiseSync} A promise that resolves when all of the provided
   *         promises resolve.
   */
  static all(promises) {
    return new PromiseSync((resolve, reject) => {
      let state = []
      for (let promise of promises) {
        let promiseState = {
          resolved: false,
          result: undefined
        }
        state.push(promiseState)

        promise.then((result) => {
          promiseState.result = result
          promiseState.resolved = true

          checkState()
        })

        promise.catch((error) => {
          reject(error)
        })
      }

      function checkState() {
        if (state.every(promiseState => promiseState.resolved)) {
          resolve(state.map(promiseState => promiseState.result))
        }
      }
    })
  }

  /**
   * Returns a new promise that resolves to the value or error provided by the
   * promise that resolved as first from the provided promises.
   *
   * @param {(PromiseSync<*>|Promise<*>)[]} promises The promises that should
   *        race among each other.
   * @return {PromiseSync<*>} A promise resolved when one of the promises
   *         resolves. The promise will resolve to the result of the promise
   *         that resolved as first.
   */
  static race(promises) {
    return new PromiseSync((resolve, reject) => {
      for (let promise of promises) {
        promise.then(resolve, reject)
      }
    })
  }
}

/**
 * Resolves the provided promise to the specified state and result value. This
 * function has no effect if the provided promise has already been resolved.
 *
 * @param {PromiseSync} instance The promise to resolve.
 * @param {Object} newState One of the {@code STATE.*} constants representing
 *        the new state of the promise. Must not be {@code STATE.PENDING}.
 * @param {(*|Error)} value The new result value of the promise.
 */
function resolve(instance, newState, value) {
  if (instance[FIELDS.state] !== STATE.PENDING) {
    return
  }

  instance[FIELDS.state] = newState
  instance[FIELDS.value] = value

  let listeners
  if (newState === STATE.RESOLVED) {
    listeners = instance[FIELDS.fulfillListeners]
  } else {
    listeners = instance[FIELDS.errorListeners]
  }

  for (let listener of listeners) {
    listener()
  }
}


import {idbProvider} from "../NativeDBAccessor"
import CursorDirection from "./CursorDirection"
import {normalizeFilter, compileOrderingFieldPaths, partiallyOptimizeFilter}
    from "./utils"

/**
 * Allowed cursor direction values.
 */
const CURSOR_DIRECTIONS = Object.freeze([
  CursorDirection.NEXT,
  CursorDirection.PREVIOUS,
  "NEXT",
  "PREVIOUS",
  "PREV"
])

/**
 * Executes the specified high-level query on this object store. The method
 * will attempt to do this as efficiently as possible, however, note the
 * following situations that may impact the performance heavily:
 *
 * - using a function as filter
 * - using an object-map of fields to values or key ranges as filter that
 *   cannot be transformed to a single key range even partially. This happens
 *   when the storage (object store or index) chosen by this method has a key
 *   path that contains a field path not present in the filter object, or a
 *   field path that resolves to a key range within the filter object.
 * - using a comparator function to specify the expected order of records.
 * - using field paths that do not have the same direction to specify the
 *   expected order of records.
 * - using field paths that do not match the key path of this object store
 *   nor the key paths of any of its indexes.
 *
 * The sorting and filtering can be optimized by this method if the field
 * paths match the key path of the object store or the index - so the method
 * can utilize the key ranges and / or the order (implicit or reversed) of
 * the records provided by the object store or its index.
 *
 * The methods first checks whether it can run the query on this object store
 * and optimize the record sorting and filtering. If it cannot, it checks
 * whether it can optimize the record sorting and filtering by using any of
 * the object store's indexes.
 *
 * If both the filtering and sorting cannot be optimized, the method attempts
 * to optimize either the sorting or filtering (preferring optimizing the
 * sorting over filtering), and preferably using this object store instead
 * of its indexes.
 *
 * The method executes the query on this object store if neither the filtering
 * nor sorting can be optimized.
 *
 * Note that if the sorting cannot be optimized, the method can execute the
 * callback on the provided records only after it traverses all records
 * matching the filter.
 *
 * In such case, the method keeps the semi-constructed result in a sorted array
 * of up to {@code offset + limit} elements, inserts the current record using
 * the insert sort algorithm optimized using the binary search algorithm and
 * trims the end of the array if its length exceeds the {@code offset + limit}
 * limit. This results in {@code O(n log c)} complexity, for {@code c} being
 * the {@code offset + limit} and {@code n} being the total number records.
 *
 * @param {ReadOnlyObjectStore} objectStore The object store on which the query
 *        should be executed
 * @param {?(undefined|number|string|Date|Array|IDBKeyRange|Object<string, (number|string|Date|Array|IDBKeyRange)>|function(*, (number|string|Date|Array)): boolean)=} filter
 *        The filter, restricting the records returned by this method. If a
 *        function is provided, the first argument will be set to the record
 *        and the second argument will be set to the primary key of the record.
 * @param {?(CursorDirection|string|string[]|function(*, *): number)} order How
 *        the resulting records should be sorted. This can be one of the
 *        following:
 *        - a {@code CursorDirection} constant, either {@code NEXT} or
 *          {@code PREVIOUS} for ascending or descending order respectively
 *        - {@code null} as alias for {@code CursorDirection.NEXT}
 *        - one of the {@code "NEXT"} (alias for {@code CursorDirection.NEXT}),
 *          {@code "PREVIOUS"} or {@code "PREV"} (aliases for
 *          {@code CursorDirection.PREVIOUS})
 *        - a string containing a field path, meaning the records should be
 *          sorted by the values of the denoted field (note that the field must
 *          exist in all records and its value must be a valid IndexedDB key
 *          value).
 *          The order is ascending by default, use the {@code "!" prefix} for
 *          descending order.
 *          To sort by a field named {@code NEXT}, {@code PREVIOUS} or
 *          {@code PREV} wrap the field path into an array containing the
 *          field path.
 *        - an array of field paths, as described above. The records will be
 *          sorted by the values of the specified fields lexicographically.
 *        - a comparator function compatible with the
 *          {@codelink Array.prototype.sort} method.
 * @param {number} offset The index of the first record to include in the
 *        result. The records are numbered from {@code 0}, the offset must be a
 *        non-negative integer.
 * @param {?number} limit The maximum number of records to return as a result.
 *        The limit must be a positive integer, or {@code null} if no limit
 *        should be imposed.
 * @param {function(*, (number|string|Date|Array))} callback Callback to
 *        execute on every iterated record that matches the query.
 * @return {PromiseSync<undefined>} A promise that resolves when the query has
 *         been executed.
 */
export default function executeQuery(objectStore, filter, order, offset, limit,
    callback) {
  if ((offset < 0) || (Math.floor(offset) !== offset)) {
    throw new Error("The offset must be a non-negative integer, " +
        `${offset} provided`)
  }
  if ((limit !== null) && ((limit <= 0) || (Math.floor(limit) !== limit))) {
    throw new Error("The limit must be a positive integer or null, " +
        `${limit} provided`)
  }

  let keyRange = undefined
  let direction
  let comparator = null
  let storage = objectStore

  order = prepareOrderingSpecificationForQuery(order, objectStore.keyPath)
  if (order instanceof Function) {
    direction = CursorDirection.NEXT
    comparator = order

    filter = normalizeFilter(filter, storage.keyPath)
    if (!(filter instanceof Function)) {
      keyRange = filter
      filter = null
    }
  } else {
    let preparedQuery = prepareQuery(storage, filter, order)
    ;({ storage, direction, comparator, keyRange, filter } = preparedQuery)
  }

  return runQuery(
    storage.createCursorFactory(keyRange, direction),
    filter,
    comparator,
    offset,
    limit,
    callback
  )
}

/**
 * Executes the specified query using the provided cursor factory.
 *
 * Note that the callback will be executed on the records only after all
 * records have been populated if the comparator if a function so the records
 * are passed to the callback in the right order.
 *
 * @param {function(function(ReadyOnlyCursor)): number} cursorFactory The
 *        cursor factory to use to create a cursor for executing the query.
 * @param {?function(*, (number|string|Date|Array)): boolean} filter Optional
 *        custom filter callback.
 * @param {?function(*, *): number} comparator Optional record comparator to
 *        use to sort the records in the result.
 * @param {number} offset The index of the first record to include in the
 *        result. The records are numbered from {@code 0}, the offset must be
 *        a non-negative integer.
 * @param {?number} limit The maximum number of records to return as a result.
 *        The limit must be a positive integer, or {@code null} if no limit
 *        should be imposed.
 * @param {function(*, (number|string|Date|Array))} callback Callback to
 *        execute on every iterated record that matches the query.
 * @return {PromiseSync<undefined>} A promise that resolves when the query has
 *         been executed.
 */
function runQuery(cursorFactory, filter, comparator, offset, limit, callback) {
  let records = []
  let recordIndex = -1

  return cursorFactory((cursor) => {
    if (!filter && offset && ((recordIndex + 1) < offset)) {
      recordIndex = offset - 1
      cursor.advance(offset)
      return
    }

    let primaryKey = cursor.primaryKey
    if (filter && !filter(cursor.record, primaryKey)) {
      cursor.continue()
      return
    }

    if (comparator) {
      insertSorted(records, cursor.record, primaryKey, comparator)
      if (offset || limit) {
        if (records.length > (offset + limit)) {
          records.pop()
        }
      }

      cursor.continue()
      return
    }

    recordIndex++
    if (recordIndex < offset) {
      cursor.continue()
      return
    }

    callback(cursor.record, primaryKey)

    if (!limit || ((recordIndex + 1) < (offset + limit))) {
      cursor.continue()
    }
  }).then(() => {
    if (!comparator) {
      return
    }

    records = records.slice(offset)
    for (let { record, primaryKey } of records) {
      callback(record, primaryKey)
    }
  })
}

/**
 * Inserts the provided record into the sorted array of records and their
 * primary keys, keeping it sorted.
 *
 * @param {{record: *, primaryKey: (number|string|Date|Array)}[]} records The
 *        array of records into which the provided record should be inserted.
 * @param {*} record The record to insert into the records array.
 * @param {(number|string|Date|Array)} primaryKey The primary key of the
 *        record.
 * @param {function(*, *): number} comparator Record comparator by which the
 *        array is sorted. The comparator is a standard comparator function
 *        compatible with the {@linkcode Array.prototype.sort} method.
 */
function insertSorted(records, record, primaryKey, comparator) {
  let index = findInsertIndex(records, record, comparator)
  records.splice(index, 0, {
    record,
    primaryKey
  })
}

/**
 * Uses the binary search algorithm to find the index at which the specified
 * record should be inserted into the specified array of records to keep the
 * array sorted according to the provided comparator.
 *
 * @param {{record: *, primaryKey: (number|string|Date|Array)}[]} records The
 *        array of records into which the provided record should be inserted.
 * @param {*} record The record to insert into the records array.
 * @param {function(*, *): number} comparator Record comparator by which the
 *        array is sorted. The comparator is a standard comparator function
 *        compatible with the {@linkcode Array.prototype.sort} method.
 * @return {number} The index at which the record should be inserted to keep
 *         the array of records sorted.
 */
function findInsertIndex(records, record, comparator) {
  if (!records.length) {
    return 0
  }

  if (records.length === 1) {
    let comparison = comparator(records[0].record, record)
    return (comparison > 0) ? 0 : 1
  }

  let comparison = comparator(records[0].record, record)
  if (comparison > 0) {
    return 0
  }

  let bottom = 1
  let top = records.length - 1

  while (bottom <= top) {
    let pivotIndex = Math.floor((bottom + top) / 2)

    let comparison = comparator(records[pivotIndex].record, record)
    if (comparison > 0) {
      let previousElement = records[pivotIndex - 1].record
      if (comparator(previousElement, record) <= 0) {
        return pivotIndex
      }

      top = pivotIndex - 1
    } else {
      bottom = pivotIndex + 1
    }
  }

  return records.length
}

/**
 * Prepares the query that uses the specified filter and record order for
 * execution on this storage.
 *
 * The method attempts uses a heuristic to determine whether the query should
 * be run directly on this object store or on one of its indexes for maximum
 * performance.
 *
 * If the method cannot optimize sorting and filtering, it prefers optimizing
 * sorting to optimizing filtering, as optimizing sorting allows the query
 * executor to skip the requested amount of records and the records following
 * the last record that fills the required amount of records.
 *
 * If the sorting cannot be optimized, the method attempts to optimize
 * filtering so that the matching records can be selected by providing a key
 * range to cursor, so that only the matching records will be traversed.
 *
 * The method constructs the filtering predicate and sorting comparator if
 * neither the sorting nor filtering can be optimized, and prepares the query
 * to be executed directly on the object store.
 *
 * @param {ReadOnlyObjectStore} thisStorage This object store.
 * @param {?(undefined|number|string|Date|Array|IDBKeyRange|Object<string, (number|string|Date|Array|IDBKeyRange)>|function(*, (number|string|Date|Array)): boolean)=} filter
 *        The filter, restricting the records returned by this method. If a
 *        function is provided, the first argument will be set to the record
 *        and the second argument will be set to the primary key of the record.
 * @param {(string|string[])} order Field paths by which the records should be
 *        sorted. A field path may be prefixed by an exclamation mark
 *        ({@code "!"}) for descending order.
 * @return {{storage: AbstractReadOnlyStorage, direction: CursorDirection, comparator: ?function(*, *): number, keyRange: (undefined|IDBKeyRange), filter: (undefined|function(*, (number|string|Date|Array)): boolean)}}
 *         The storage on which the query should be executed, the direction in
 *         which the cursor should be opened and the record comparator to use
 *         to additionally sort the fetched records matching the filter.
 *         Finally, the returned object has the {@code keyRange} and
 *         {@code filter} fields set to the key range and custom filter to use
 *         with the storage to run the query.
 */
function prepareQuery(thisStorage, filter, order) {
  order = normalizeKeyPath(order)

  let expectedSortingDirection = order[0].charAt(0) === "!"
  let canSortingBeOptimized
  canSortingBeOptimized = canOptimizeSorting(expectedSortingDirection, order)

  let storages = new Map()
  storages.set(normalizeKeyPath(thisStorage.keyPath), {
    storage: thisStorage,
    score: 1 // traversing storage is faster than fetching records by index
  })

  for (let indexName of thisStorage.indexNames) {
    let index = thisStorage.getIndex(indexName)
    if (!index.multiEntry) {
      storages.set(normalizeKeyPath(index.keyPath), {
        storage: index,
        score: 0
      })
    }
  }

  let simplifiedOrderFieldPaths = simplifyOrderingFieldPaths(order)

  if (canSortingBeOptimized) {
    prepareSortingOptimization(storages, simplifiedOrderFieldPaths)
  }

  prepareFilteringOptimization(storages, filter)

  return chooseStorageForQuery(
    storages,
    order,
    simplifiedOrderFieldPaths,
    canSortingBeOptimized,
    expectedSortingDirection
  )
}

/**
 * Calculates the best possible sorting optimization for the provided storages,
 * updating the provided map with sorting optimization scores for each storage.
 *
 * @param {Map<string[], {storage: AbstractReadOnlyStorage, score: number, keyRange: (undefined|IDBKeyRange), filter: ?function(*, (number|string|Date|Array)): boolean}>} storages
 *        Map of storage key paths to storages and information related to how
 *        the query would be executed on each of them, including the
 *        performance optimization score (the higher is better).
 * @param {string[]} simplifiedOrderFieldPaths Ordering field paths with the
 *        exclamation mark prefix stripped from them.
 */
function prepareSortingOptimization(storages, simplifiedOrderFieldPaths) {
  let idb = idbProvider()
  for (let [keyPath, storageAndScore] of storages) {
    let keyPathSlice = keyPath.slice(0, simplifiedOrderFieldPaths.length)
    if (idb.cmp(keyPathSlice, simplifiedOrderFieldPaths) === 0) {
      storageAndScore.score += 4 // optimizing the sorting is more important
    }
  }
}

/**
 * Calculates the best possible filtering optimizations for the provided
 * storages, updating the provided map with optimized filtering info and
 * optimization score for each storage.
 *
 * @param {Map<string[], {storage: AbstractReadOnlyStorage, score: number, keyRange: (undefined|IDBKeyRange), filter: ?function(*, (number|string|Date|Array)): boolean}>} storages
 *        Map of storage key paths to storages and information related to how
 *        the query would be executed on each of them, including the
 *        performance optimization score (the higher is better).
 * @param {?(undefined|number|string|Date|Array|IDBKeyRange|Object<string, (number|string|Date|Array|IDBKeyRange)>|function(*, (number|string|Date|Array)): boolean)=} filter
 *        The filter, restricting the records returned by this method. If a
 *        function is provided, the first argument will be set to the record
 *        and the second argument will be set to the primary key of the record.
 */
function prepareFilteringOptimization(storages, filter) {
  if (filter instanceof Function) {
    for (let [keyPath, storageAndScore] of storages) {
      storageAndScore.filter = filter
    }

    return
  }

  for (let [keyPath, storageAndScore] of storages) {
    let normalizedFilter = normalizeFilter(filter, keyPath)
    if (normalizedFilter instanceof Function) {
      let isOptimizableFilter =
          (filter instanceof Object) &&
          !(filter instanceof Date) &&
          !(filter instanceof Array) &&
          !(filter instanceof IDBKeyRange)
      if (isOptimizableFilter) {
        let partialOptimization = partiallyOptimizeFilter(filter, keyPath)
        storageAndScore.keyRange = partialOptimization.keyRange
        storageAndScore.filter = partialOptimization.filter
        if (partialOptimization.score) {
          storageAndScore.score += 1 + partialOptimization.score
        }
      } else {
        storageAndScore.filter = normalizedFilter
      }
    } else {
      storageAndScore.keyRange = normalizedFilter
      storageAndScore.score += 2
    }
  }
}

/**
 * Selects the storage on which the execute a query that should lead to the
 * best possible performance. The method returns all the data necessary to
 * execute the query.
 *
 * @param {Map<string[], {storage: AbstractReadOnlyStorage, score: number, keyRange: (undefined|IDBKeyRange), filter: ?function(*, (number|string|Date|Array)): boolean}>} storages
 *        Map of storage key paths to storages and information related to how
 *        the query would be executed on each of them, including the
 *        performance optimization score (the higher is better).
 * @param {string[]} order Field paths by which the records should be sorted. A
 *        field path may be prefixed by an exclamation mark ({@code "!"}) for
 *        descending order.
 * @param {string[]} simplifiedOrderFieldPaths Ordering field paths with the
 *        exclamation mark prefix stripped from them.
 * @param {boolean} canSortingBeOptimized Set to {@code true} if sorting
 *        optimization is possible.
 * @param {boolean} expectedSortingDirection Set to {@code true} for descending
 *        order, set to {@code false} for ascending order.
 * @return {{storage: AbstractReadOnlyStorage, direction: CursorDirection, comparator: ?function(*, *): number, keyRange: (undefined|IDBKeyRange), filter: (undefined|function(*, (number|string|Date|Array)): boolean)}}
 *         The storage on which the query should be executed, the direction in
 *         which the cursor should be opened and the record comparator to use
 *         to additionally sort the fetched records matching the filter.
 *         Finally, the returned object has the {@code keyRange} and
 *         {@code filter} fields set to the key range and custom filter to use
 *         with the storage to run the query.
 */
function chooseStorageForQuery(storages, order, simplifiedOrderFieldPaths,
    canSortingBeOptimized, expectedSortingDirection) {
  let sortedStorages = Array.from(storages.values())
  sortedStorages.sort((storage1, storage2) => {
    return storage2.score - storage1.score
  })

  let chosenStorageDetails = sortedStorages[0]
  let chosenStorage = chosenStorageDetails.storage
  let chosenStorageKeyPath = normalizeKeyPath(chosenStorage.keyPath)
  let storageKeyPathSlice = chosenStorageKeyPath.slice(
    0,
    simplifiedOrderFieldPaths.length
  )
  let optimizeSorting = canSortingBeOptimized &&
      (idbProvider().cmp(storageKeyPathSlice, simplifiedOrderFieldPaths) === 0)

  return {
    storage: chosenStorage,
    direction: optimizeSorting ? (
    CursorDirection[expectedSortingDirection ? "PREVIOUS" : "NEXT"]
    ) : CursorDirection.NEXT,
    comparator: optimizeSorting ? null : compileOrderingFieldPaths(order),
    keyRange: chosenStorageDetails.keyRange,
    filter: chosenStorageDetails.filter
  }
}

/**
 * Simplifies the provided ordering field paths by stripping their direction
 * prefix (if present). This allows for comparing with storage key paths.
 *
 * @param {string[]} order The ordering field paths specifying how the records
 *        should be sorted.
 * @return {string[]} Raw field paths compatible with storage key paths.
 */
function simplifyOrderingFieldPaths(order) {
  return order.map((fieldPath) => {
    return fieldPath.replace(/^!/, "")
  })
}

/**
 * Determines whether the sorting of the query result can be done through an
 * index (provided such index exists) or the natural order of the records in
 * the object store.
 *
 * @param {boolean} expectedSortingDirection The sorting direction that is
 *        expected from all sort-by fields so that the sorting can be
 *        optimized. {@code true} represents descending order, {@code false}
 *        represents ascending order.
 * @param {string[]} order Ordering field paths specifying how the records
 *        should be sorted.
 * @return {boolean} {@code true} if the sorting can be optimized, if an
 *         appropriate index exists or the natural order of the records in the
 *         object store matches the order denoted by the field paths.
 */
function canOptimizeSorting(expectedSortingDirection, order) {
  for (let orderingFieldPath of order) {
    if ((orderingFieldPath.charAt(0) === "!") !== expectedSortingDirection) {
      return false
    }
  }

  return true
}

/**
 * Preprocess the raw ordering specification into form that can be used in
 * query optimization.
 *
 * @param {?(CursorDirection|string|string[]|function(*, *): number)} order How
 *        the resulting records should be sorted. This can be one of the
 *        following:
 *        - a {@code CursorDirection} constant, either {@code NEXT} or
 *          {@code PREVIOUS} for ascending or descending order respectively
 *        - {@code null} as alias for {@code CursorDirection.NEXT}
 *        - one of the {@code "NEXT"} (alias for {@code CursorDirection.NEXT}),
 *          {@code "PREVIOUS"} or {@code "PREV"} (aliases for
 *          {@code CursorDirection.PREVIOUS})
 *        - a string containing a field path, meaning the records should be
 *          sorted by the values of the denoted field (note that the field
 *          must exist in all records and its value must be a valid IndexedDB
 *          key value).
 *          The order is ascending by default, use the {@code "!" prefix} for
 *          descending order.
 *          To sort by a field named {@code NEXT}, {@code PREVIOUS} or
 *          {@code PREV} wrap the field path into an array containing the field
 *          path.
 *        - an array of field paths, as described above. The records will be
 *          sorted by the values of the specified fields lexicographically.
 *        - a comparator function compatible with the
 *          {@linkcode Array.prototype.sort} method.
 * @param {(string|string[])} keyPath The key path of this object store.
 * @return {(string[]|function(*, *): number)} Prepared ordering specification
 *         ready to be used in query optimization.
 */
function prepareOrderingSpecificationForQuery(order, keyPath) {
  if (order === null) {
    order = CursorDirection.NEXT
  }

  let isCursorDirection = ((typeof order === "string") &&
      (CURSOR_DIRECTIONS.indexOf(order.toUpperCase()) > -1)) ||
      (CURSOR_DIRECTIONS.indexOf(order) > -1)
  if (isCursorDirection && (typeof order === "string")) {
    order = CursorDirection[order.toUpperCase()] || CursorDirection.PREVIOUS
  }

  if (order instanceof CursorDirection) {
    keyPath = normalizeKeyPath(keyPath)

    if (order === CursorDirection.NEXT) {
      return keyPath
    } else {
      return keyPath.map(fieldPath => `!${fieldPath}`)
    }
  }

  return order
}

/**
 * Normalized the provided key path into an array of field paths.
 *
 * @param {(string|string[])} keyPath The key path to normalize.
 * @return {string[]} The normalized key path.
 */
function normalizeKeyPath(keyPath) {
  if (typeof keyPath === "string") {
    return [keyPath]
  }

  return keyPath
}


import Database from "./Database"
import NativeDBAccessor from "./NativeDBAccessor"
import PromiseSync from "./PromiseSync"
import DatabaseMigrator from "./migration/DatabaseMigrator"

/**
 * Registered listeners to be executed when a database schema migration is
 * started. The listeners will be executed with the following arguments:
 *
 * - database name
 * - the version number from which the schema is being migrated
 * - the version number to which the schema is being migrated
 * - a promise that resolves when the migration is finished
 *
 * @type {Set<function(string, number, number, Promise<undefined>)>}
 */
const migrationListeners = new Set()

/**
 * Provider of connections to the database, manager of database versioning and
 * utility for deleting databases.
 *
 * The DB Provider provides a high-level {@linkcode Promise}-enabled API
 * wrapper of IndexedDB, so there are a few limitations to consider:
 *
 * A record can be any value that can be converted to JSON or structure-cloned.
 * For details about structured cloning, see
 * https://developer.mozilla.org/en-US/docs/Web/Guide/API/DOM/The_structured_clone_algorithm
 * or
 * http://www.w3.org/html/wg/drafts/html/master/infrastructure.html#safe-passing-of-structured-data
 *
 * While it is possible to use compound keys on object stores and indexes, some
 * browsers do not implement support for this, so creating or using such an
 * object store or index will probably result in an error.
 *
 * Record keys can be {@code number}s (except for {@linkcode NaN}),
 * {@code string}s, {@linkcode Date} instances (unless the internal value is a
 * {@linkcode NaN}) or {@linkcode Array} objects. An {@linkcode Array} is only
 * a valid key if every item in the array is defined and is a valid key (i.e.
 * sparse arrays can not be valid keys) and if the {@linkcode Array} doesn't
 * directly or indirectly contain itself. For more details, see
 * http://www.w3.org/TR/IndexedDB/#dfn-valid-key
 *
 * While {@linkcode Array}s containing {@linkcode Array}s are considered to be
 * valid keys, some browsers do not process such keys properly when getting a
 * value from an object store, so such an operation may result in
 * {@code undefined} value even if the record exists.
 */
export default class DBFactory {
  /**
   * Opens a new connection to the database.
   *
   * When the database is being opened, its version is being tested against the
   * greatest version described in the provided schema descriptors. If the
   * current database version is lower than the greatest version defined, or
   * the database does not exist (the version is considered to be {@code 0}
   * then), the method will upgrade the database.
   *
   * The database upgrade consists of processing the schema descriptors for all
   * versions greater than the current version in the ascending order.
   *
   * The database version is always a positive integer. The version numbers
   * specified by the descriptors do not have to start at {@code 1} and may
   * contain gaps of any size.
   *
   * Note that this method handles opening the connection slightly differently
   * when the database version is to be upgraded and the request is blocked:
   *
   * This method will reject the returned promise immediately if the attempt is
   * blocked. The native Indexed DB connection open request would behave
   * slightly differently, that is, if all pending connections to the database
   * are closed, the Indexed DB will fire the {@code upgradeneeded} and
   * {@code success} events, even though the {@code blocked} event has been
   * previously fired on the open request.
   *
   * Since this behavior of the native Indexed DB is considered
   * counter-intuitive this method simplifies this special case by ignoring the
   * native events received after the {@code blocked} event and rejecting the
   * returned promise.
   *
   * @param {string} databaseName The name of the database.
   * @param {...(DatabaseSchema|UpgradedDatabaseSchema|Object)} schemaDescriptors
   *        The descriptors of the database schema across all its versions. The
   *        descriptor of the lowest version must be a
   *        {@linkcode DatabaseSchema} instance, all other must be
   *        {@linkcode UpgradedDatabaseSchema} instances.
   *        Alternatively, plain object that follow the structure of the
   *        {@linkcode DatabaseSchema} and {@linkcode UpgradedDatabaseSchema}
   *        instances may be used instead, if preferred. Plain objects must not
   *        be mixed with the {@linkcode DatabaseSchema} and
   *        {@linkcode UpgradedDatabaseSchema} instances.
   *        The order of the descriptors does not matter. At least one schema
   *        descriptor must be provided.
   * @return {Promise<Database>} A promise that resolves to the database
   *         connection.
   */
  static open(databaseName, ...schemaDescriptors) {
    if (!schemaDescriptors.length) {
      throw new Error("The list of schema descriptors must not be empty")
    }

    let sortedSchemaDescriptors = schemaDescriptors.slice().sort((d1, d2) => {
      return d1.version - d2.version
    })

    return openConnection(
      databaseName,
      sortedSchemaDescriptors
    )
  }

  /**
   * Attempts to delete the specified database.
   *
   * The method will trigger the database deletion handlers on all open
   * database connections. The attempt will fail if any of the handlers will
   * not close the database connection, thus blocking the deletion request.
   *
   * The method will resolve to the version number of the deleted database on
   * success, or {@code null} if the database did not exist.
   *
   * Deleting a non-existing database is always successful.
   *
   * In case the request fails, the returned promise will reject with the error
   * that that occurred.
   *
   * @param {string} databaseName The name of the database to delete.
   * @return {Promise<?number>} The promise that resolves to the version number
   *         of the deleted database, or {@code null} if the database did not
   *         exist.
   */
  static deleteDatabase(databaseName) {
    let request = NativeDBAccessor.indexedDB.deleteDatabase(databaseName)
    return new Promise((resolve, reject) => {
      request.onsuccess = (event) => resolve(event.oldVersion)
      request.onerror = (event) => reject(event)
    })
  }

  /**
   * Registers the specified listener to be executed whenever a database schema
   * migration is started. The listeners will be executed with the following
   * arguments:
   *
   * - database name
   * - the version number from which the schema is being migrated, set to 0 if
   *   the database is being created
   * - the version number to which the schema is being migrated
   * - a promise that resolves when the migration is finished
   *
   * Any error throw by the listener will be logged as a warning, but will not
   * disrupt the execution of the remaining listeners.
   *
   * @param {function(string, number, number, Promise<undefined>)} listener The
   *        listener to register.
   */
  static addMigrationListener(listener) {
    migrationListeners.add(listener)
  }

  /**
   * Unregisters the specified database schema migration listener. This method
   * has no effect if the listener is not registered.
   *
   * @param {function(string, number, number, Promise<undefined>)} listener The
   *        listener to unregister.
   */
  static removeMigrationListener(listener) {
    migrationListeners.delete(listener)
  }

  /**
   * Sets the IndexedDB implementation to use by indexed-db.es6.
   *
   * @param {IDBFactory} indexedDBImplementation The new IndexedDB
   *        implementation to use.
   */
  static set nativeIndexedDB(indexedDBImplementation) {
    NativeDBAccessor.indexedDB = indexedDBImplementation
  }

  /**
   * Returns the current IndexedDB implementation used by indexed-db.es6.
   *
   * @return {IDBFactory} The native IndexedDB implementation to use.
   */
  static get nativeIndexedDB() {
    return NativeDBAccessor.indexedDB
  }
}

/**
 * Handles opening the connection to the database and wraps the whole process
 * in a promise.
 *
 * @param {string} databaseName The name of the Indexed DB database to connect
 *        to.
 * @param {(DatabaseSchema|UpgradedDatabaseSchema|Object)[]} sortedSchemaDescriptors
 *        The database schema descriptors, sorted in ascending order by the
 *        schema version number.
 * @return {Promise<Database>} A promise that resolves to the database
 *         connection.
 */
function openConnection(databaseName, sortedSchemaDescriptors) {
  let version = sortedSchemaDescriptors.slice().pop().version
  let request = NativeDBAccessor.indexedDB.open(databaseName, version)

  return new Promise((resolve, reject) => {
    let wasBlocked = false
    let upgradeTriggered = false

    let migrationPromiseResolver, migrationPromiseRejector
    let migrationPromise = new Promise((resolve, reject) => {
      migrationPromiseResolver = resolve
      migrationPromiseRejector = reject
    })
    // prevent leaking the same error to the console twice
    migrationPromise.catch(() => {})

    request.onsuccess = () => {
      let database = new Database(request.result)
      resolve(database)
      migrationPromiseResolver()
    }

    request.onupgradeneeded = (event) => {
      if (!wasBlocked) {
        upgradeTriggered = true
      }

      let database = request.result
      let transaction = request.transaction

      if (wasBlocked) {
        transaction.abort()
        return
      }

      upgradeDatabaseSchema(databaseName, event, migrationPromise, database,
          transaction, sortedSchemaDescriptors, migrationPromiseResolver,
          migrationPromiseRejector).catch((error) => {
        transaction.abort()
      })
    }

    request.onerror = (event) => {
      handleConnectionError(event, request.error, wasBlocked, upgradeTriggered,
          reject, migrationPromiseRejector)
    }

    request.onblocked = () => {
      wasBlocked = true

      let error = new Error("A database upgrade was needed, but could not " +
          "be performed, because the attempt was blocked by a connection " +
          "that remained opened after receiving the notification")
      reject(error)
      migrationPromiseRejector(error)
    }
  })
}

/**
 * Handles a database error encountered during connection establishing.
 *
 * @param {Event} event The error event.
 * @param {Error} error The encountered error.
 * @param {boolean} wasBlocked Flag signalling whether the connection has been
 *        blocked.
 * @param {boolean} upgradeTriggered Flag signalling whether the database schema
 *        upgrade process was triggered.
 * @param {function(Error)} reject The connection promise rejection callback.
 * @param {function(Error)} migrationPromiseRejector The schema migration
 *        promise rejection callback.
 */
function handleConnectionError(event, error, wasBlocked, upgradeTriggered,
    reject, migrationPromiseRejector) {
  if (wasBlocked || upgradeTriggered) {
    event.preventDefault()
    return
  }

  reject(request.error)
  migrationPromiseRejector(request.error)
}

/**
 * Handles the provided {@code upgradeneeded} event that occurred during
 * opening a database that was not blocked. The function handles database
 * schema upgrade.
 *
 * @param {string} databaseName The name of the database to upgrade.
 * @param {Event} event The {@code upgradeneeded} event.
 * @param {Promise<undefined>} migrationPromise The database schema migration
 *        promise to pass to the migration listeners.
 * @param {IDBDatabase} database The native Indexed DB database to upgrade.
 * @param {IDBTransaction} transaction The native {@code versionchange}
 *        transaction to use to manipulate the data.
 * @param {(DatabaseSchema|UpgradedDatabaseSchema|Object)[]} sortedSchemaDescriptors
 *        The database schema descriptors, sorted in ascending order by the
 *        schema version number.
 * @param {function()} migrationPromiseResolver Schema migration promise
 *        resolver.
 * @param {function(Error)} migrationPromiseRejector Schema migration promise
 *        rejection callback.
 * @return {PromiseSync<undefined>} A promise resolved when the schema has been
 *         upgraded.
 */
function upgradeDatabaseSchema(databaseName, event, migrationPromise,
    database, transaction, sortedSchemaDescriptors, migrationPromiseResolver,
    migrationPromiseRejector) {
  executeMigrationListeners(databaseName, event.oldVersion, event.newVersion,
      migrationPromise)

  let migrator = new DatabaseMigrator(
    database,
    transaction,
    sortedSchemaDescriptors,
    event.oldVersion
  )

  return PromiseSync.resolve().then(() => {
    return migrator.executeMigration()
  }).then(() => {
    migrationPromiseResolver()
  }).catch((error) => {
    migrationPromiseRejector(error)
    throw error
  })
}

/**
 * Executes the currently registered database schema migration listeners with
 * the provided arguments.
 *
 * @param {string} databaseName The name of the database being migrated.
 * @param {number} oldVersion The version number from which the schema is being
 *        migrated.
 * @param {number} newVersion The version number to which the schema is being
 *        migrated.
 * @param {Promise<undefined>} completionPromise A promise that resolves when
 *        the migration is finished.
 */
function executeMigrationListeners(databaseName, oldVersion, newVersion,
    completionPromise) {
  for (let listener of migrationListeners) {
    try {
      listener(databaseName, oldVersion, newVersion, completionPromise)
    } catch (e) {
      console.warn("A schema migration event listener threw an error", e);
    }
  }
}


import {idbProvider} from "../NativeDBAccessor"
import KeyRange from "./KeyRange"

/**
 * Converts the provided key range object into a field range map object.
 *
 * @param {IDBKeyRange} keyRange The key range object to convert.
 * @param {(string|string[])} keyPath The key path of this storage.
 * @return {Object<string, IDBKeyRange>} A field range map object.
 */
export function keyRangeToFieldRangeObject(keyRange, keyPath) {
  let fieldRangeObject = {}

  if (!(keyPath instanceof Array)) {
    setFieldValue(fieldRangeObject, keyPath, keyRange)
    return fieldRangeObject
  }

  let lowerBound = keyRange.lower
  let upperBound = keyRange.upper
  let lowerBoundOpen = keyRange.lowerOpen
  let upperBoundOpen = keyRange.upperOpen

  keyPath.forEach((fieldPath, index) => {
    let fieldLowerBound = lowerBound ? lowerBound[index] : undefined
    let fieldUpperBound = upperBound ? upperBound[index] : undefined
    let fieldRange = KeyRange.bound(
      fieldLowerBound,
      fieldUpperBound,
      lowerBoundOpen,
      upperBoundOpen
    )
    setFieldValue(fieldRangeObject, fieldPath, fieldRange)
  })

  return fieldRangeObject
}

/**
 * Normalizes the provided filter to an {@code undefined}, an
 * {@codelink IDBKeyRange} instance or a filter predicate function.
 *
 * @param {?(undefined|number|string|Date|Array|IDBKeyRange|Object<string, (number|string|Date|Array|IDBKeyRange)>|function(*, (number|string|Date|Array), (number|string|Date|Array)): boolean)} filter
 *        A filter to use, as provided by the client code.
 * @param {?(string|string[])} keyPath The key path of the storage. This
 *        parameter is used to optimize field map filters that specify a the
 *        field constraints matching the field paths - such field map filters
 *        will be optimized to an IDBKeyRange.
 * @return {(undefined|IDBKeyRange|function(*, (number|string|Date|Array), (number|string|Date|Array)): boolean)}
 *         Normalized filter.
 */
export function normalizeFilter(filter, keyPath) {
  if (keyPath) {
    let normalizedFilter = convertFieldMapToKeyRange(filter, keyPath)
    if (normalizedFilter) {
      filter = normalizedFilter
    }
  }

  if ((filter === null) || (filter === undefined)) {
    return undefined
  }

  let isFieldMap = (filter instanceof Object) &&
      !(filter instanceof Function) &&
      !(filter instanceof IDBKeyRange) &&
      !(filter instanceof Array) &&
      !(filter instanceof Date)
  if (isFieldMap) {
    filter = compileFieldRangeFilter(filter)
  }

  if (!(filter instanceof Function) && !(filter instanceof IDBKeyRange)) {
    filter = KeyRange.only(filter)
  }

  return filter
}

/**
 * Attempts to convert the part of the filter object that matches the specified
 * storage key path to a key range, while compiling the remaining fields to a
 * filter predicate function.
 *
 * The method returns information on the ratio of storage key path fields
 * converted to the key range to the total number of field paths in the filter.
 *
 * @param {Object<string, (number|string|Date|Array|IDBKeyRange)>} filter A map
 *        of field names to expected values or key ranges representing the
 *        expected values for those fields.
 * @param {string[]} keyPath The key path of the storage for which the filter
 *        is to be optimized.
 * @return {{keyRange: IDBKeyRange, filter: ?function(*, (number|string|Date|Array)): boolean, score: number}}
 *         The IndexedDB key range to use with the native API, additional
 *         filtering predicate, and optimization score as a floating point
 *         number within the range [0, 1]. The score is set to 1 if all filter
 *         fields were converted to the key range, and the score is set to 0 if
 *         none of the filter fields could have been converted to the key
 *         range.
 */
export function partiallyOptimizeFilter(filter, keyPath) {
  let fieldPaths = getFieldPaths(filter, false)

  let canOptimize = keyPath.every(path => fieldPaths.indexOf(path) > -1)
  if (!canOptimize) {
    return {
      keyRange: undefined,
      filter: compileFieldRangeFilter(filter),
      score: 0
    }
  }

  if (keyPath.length === fieldPaths.length) {
   return partiallyOptimizeKeyPathMatchingFilter(filter, keyPath)
  }

  let keyPathContainsKeyRange = keyPath.some((fieldPath) => {
    return getFieldValue(filter, fieldPath) instanceof IDBKeyRange
  })
  if (keyPathContainsKeyRange) {
    return {
      keyRange: undefined,
      filter: compileFieldRangeFilter(filter),
      score: 0
    }
  }

  let {fieldsToOptimize, fieldsToCompile} = splitFilteringObject(
    filter,
    fieldPaths,
    keyPath
  )

  return {
    keyRange: convertFieldMapToKeyRange(fieldsToOptimize, keyPath),
    filter: compileFieldRangeFilter(fieldsToCompile),
    score: keyPath.length / fieldPaths.length
  }
}

/**
 * A sub-routine of the {@linkcode partiallyOptimizeFilter} used to generate
 * optimization result if the storage key path matches the field paths in the
 * filter object.
 *
 * @param {Object<string, (number|string|Date|Array|IDBKeyRange)>} filter A map
 *        of field names to expected values or key ranges representing the
 *        expected values for those fields.
 * @param {string[]} keyPath The key path of the storage for which the filter
 *        is to be optimized.
 * @return {{keyRange: IDBKeyRange, filter: ?function(*, (number|string|Date|Array)): boolean, score: number}}
 *         The IndexedDB key range to use with the native API, additional
 *         filtering predicate, and optimization score as a floating point
 *         number within the range [0, 1]. The score is set to 1 if all filter
 *         fields were converted to the key range, and the score is set to 0 if
 *         none of the filter fields could have been converted to the key
 *         range.
 */
function partiallyOptimizeKeyPathMatchingFilter(filter, keyPath) {
  let keyRange = convertFieldMapToKeyRange(filter, keyPath)
  if (!keyRange) { // at least one of the values is a IDBKeyRange instance
    return {
      keyRange: undefined,
      filter: compileFieldRangeFilter(filter),
      score: 0
    }
  }

  return {
    keyRange,
    filter: null,
    score: 1
  }
}

/**
 * Splits the provided filter object to two objects according the the provided
 * storage key path. This is used to separate a complex filter object into an
 * object optimizable into a key range and a simpler object that will be
 * compiled into a filter predicate function.
 *
 * @param {Object<string, *>} filter Filtering object to split.
 * @param {string[]} filterFieldPaths All field paths in the filtering object.
 * @param {string[]} storageKeyPath The key path(s) of the storage for which
 *        the filtering object is being split.
 * @return {{fieldsToOptimize: Object<string, *>, fieldsToCompile: Object<string, *>}}
 *          The {@code fieldsToOptimize} filter object will contain only the
 *          field paths specified in the {@code storageKeyPath}, while the
 *          {@code fieldsToCompile} will contain the remaining field paths of
 *          the source filter object.
 */
function splitFilteringObject(filter, filterFieldPaths, storageKeyPath) {
  let fieldsToOptimize = {}
  let fieldsToCompile = {}

  filterFieldPaths.forEach((fieldPath) => {
    let value = getFieldValue(filter, fieldPath)
    if (storageKeyPath.indexOf(fieldPath) > -1) {
      setFieldValue(fieldsToOptimize, fieldPath, value)
    } else {
      setFieldValue(fieldsToCompile, fieldPath, value)
    }
  })

  return {
    fieldsToOptimize,
    fieldsToCompile
  }
}

/**
 * Compiles the provided map of field names to expected values or value ranges
 * to a predicate function that takes a record and returns {@code true} if the
 * record satisfies the provided field values restrictions.
 *
 * @param {Object<string, (number|string|Date|Array|IDBKeyRange)>} filter A map
 *        of field names to expected values or key ranges representing the
 *        expected values for those fields.
 * @return {function(*): boolean} The compiled filter.
 */
export function compileFieldRangeFilter(filter) {
  let fieldPaths = getFieldPaths(filter, false)
  let idb = idbProvider()

  let fieldFilters = fieldPaths.map((fieldPath) => {
    let fieldRange = getFieldValue(filter, fieldPath)
    if (!(fieldRange instanceof IDBKeyRange)) {
      fieldRange = KeyRange.only(fieldRange)
    }

    return (record) => {
      let fieldValue;
      try {
        fieldValue = getFieldValue(record, fieldPath)
      } catch (error) {
        return false // the field does not exist in the record
      }

      if (fieldRange.lower !== undefined) {
        let lowerComparison
        lowerComparison = idb.cmp(fieldRange.lower, fieldValue)

        let failedTest = (lowerComparison > 0) ||
            (fieldRange.lowerOpen && (lowerComparison === 0))
        if (failedTest) {
          return false
        }
      }

      if (fieldRange.upper !== undefined) {
        let upperComparison
        upperComparison = idb.cmp(fieldRange.upper, fieldValue)

        let failedTest = (upperComparison < 0) ||
            (fieldRange.upperOpen && (upperComparison === 0))
        if (failedTest) {
          return false
        }
      }

      return true
    }
  })

  return (record) => {
    if (!(record instanceof Object)) {
      return false
    }

    return fieldFilters.every(fieldFilter => fieldFilter(record))
  }
}

/**
 * Compiles the provided sequence of ordering field paths to a comparator
 * function that relies on the native {@code indexedDB.cmp} method.
 *
 * The created comparator evaluates the field paths on the provided values in
 * the same order they were specified. The resulting values are then compared
 * using the native {@code indexedDB.cmp} method. The comparator then returns
 * the comparison, unless the values are equal - in such case the comparator
 * moves to the next field path unless there are no more field paths left.
 *
 * @param {(string|string[])} orderingFieldPaths The field path(s) to compile
 *        into a comparator function. A field path may be prefixed by an
 *        exclamation mark ({@code "!"}) for the reverse order.
 * @return {function(*, *): number} The compiled comparator function.
 */
export function compileOrderingFieldPaths(orderingFieldPaths) {
  if (typeof orderingFieldPaths === "string") {
    orderingFieldPaths = [orderingFieldPaths]
  }

  let inverted = []
  let getters = []

  for (let fieldPath of orderingFieldPaths) {
    if (fieldPath.charAt(0) === "!") {
      inverted.push(true)
      getters.push(compileFieldGetter(fieldPath.substring(1)))
    } else {
      inverted.push(false)
      getters.push(compileFieldGetter(fieldPath))
    }
  }

  let idb = idbProvider()
  let gettersCount = getters.length

  return (record1, record2) => {
    for (let i = 0; i < gettersCount; i++) {
      let getter = getters[i]
      let value1 = getter(record1)
      let value2 = getter(record2)

      let comparison
      if (inverted[i]) {
        comparison = idb.cmp(value2, value1)
      } else {
        comparison = idb.cmp(value1, value2)
      }

      if (comparison !== 0) {
        return comparison
      }
    }

    return 0
  }
}

/**
 * Compiles the provided field path to a function that retrieves the value of
 * the field denoted by the field path from the object passed to the function,
 * or {@code undefined} if the field does not exist in the object.
 *
 * @param {string} fieldPath The field path to compile.
 * @return {function(*): *} The compiled field getter.
 */
function compileFieldGetter(fieldPath) {
  let fields = fieldPath.split(".")

  return (record) => {
    let value = record

    for (let field of fields) {
      if (!(value instanceof Object) || !value.hasOwnProperty(field)) {
        return undefined
      }

      value = value[field]
    }

    return value
  }
}

/**
 * Attempts to convert the provided filter to a single {@codelink IDBKeyRange}
 * instance.
 *
 * The conversion is only possible if the filter is a field map filter, field
 * paths in the object match the provided key paths exactly, and the filter
 * does not have a field set to an {@codelink IDBKeyRange} instance.
 *
 * @param {?(undefined|number|string|Date|Array|IDBKeyRange|Object<string, (number|string|Date|Array|IDBKeyRange)>|function(*, (number|string|Date|Array), (number|string|Date|Array)): boolean)} filter
 *        The filter to convert.
 * @param {(string|string[])} keyPaths The field paths representing the key in
 *        this storage.
 * @return {?IDBKeyRange} The filter represented as a single
 *         {@linkcode IDBKeyRange} instance, or {@code null} if the conversion
 *         is not possible.
 */
function convertFieldMapToKeyRange(filter, keyPaths) {
  let isOtherFormOfFilter =
      !(filter instanceof Object) ||
      (filter instanceof Function) ||
      (filter instanceof Array) ||
      (filter instanceof Date) ||
      (filter instanceof IDBKeyRange)
  if (isOtherFormOfFilter) {
    return null
  }

  if (!(keyPaths instanceof Array)) {
    keyPaths = [keyPaths]
  }

  let fieldPaths = getFieldPaths(filter)
  if (!fieldPaths) {
    return null
  }

  let isKeyFilter =
      (fieldPaths.length === keyPaths.length) &&
      fieldPaths.every(path => keyPaths.indexOf(path) > -1)
  if (!isKeyFilter) {
    return null
  }

  if (keyPaths.length === 1) {
    return IDBKeyRange.only(getFieldValue(filter, keyPaths[0]))
  }

  return new IDBKeyRange.only(keyPaths.map((keyPath) => {
    getFieldValue(filter, keyPath)
  }))
}

/**
 * Generates an array containing all field paths in the provided object.
 *
 * The method also tests whether any of the leaf field values is an
 * {@linkcode IDBKeyRange} instance, in such case the method returns
 * {@code null}.
 *
 * The method is used to determine whether the provided object can be turned
 * into and {@linkcode IDBKeyRange} instance for a compound record key, and if
 * so, retrieve all field paths in the object so that its structure can be
 * validated against the field paths of the storage.
 *
 * @param {Object} object The object from which the field paths are to be
 *        extracted.
 * @param {boolean=} stopOnKeyRange When {@code true}, the method returns
 *        {@code null} if it encounters a key range.
 * @return {?string[]} All field paths in the provided objects, unless the
 *         object contains a field set to an {@linkcode IDBKeyRange} instance.
 */
function getFieldPaths(object, stopOnKeyRange = true) {
  let fieldPaths = []
  fieldPaths.containsKeyRange = false
  generateFieldPaths(object, [])

  return fieldPaths

  function generateFieldPaths(object, parts) {
    Object.keys(object).some((fieldName) => {
      let value = object[fieldName]
      if (stopOnKeyRange && (value instanceof IDBKeyRange)) {
        fieldPaths = null
        return true
      }

      let isTerminalValue =
          !(value instanceof Object) ||
          (value instanceof Date) ||
          (value instanceof Array) ||
          (value instanceof IDBKeyRange)
      let fieldPath = parts.slice()
      fieldPath.push(fieldName)

      if (isTerminalValue) {
        fieldPaths.push(fieldPath.join("."))
      } else {
        generateFieldPaths(value, fieldPath)
      }
    })
  }
}

/**
 * Sets the specified field, denoted by the specified field path, on the
 * provided object to the provided value.
 *
 * If the specified field path does not exist in the object, it is
 * automatically created by inserting new empty objects.
 *
 * @param {Object} object An object on which the field should be set.
 * @param {string} fieldPath A field path on which the value should be set. A
 *        field path is a sequence of field names joined by dots ({@code "."}).
 * @param {*} value The value to set at the specified field path.
 * @throws {Error} Thrown if the field path is already present or in a conflict
 *         with another already present field path.
 */
function setFieldValue(object, fieldPath, value) {
  let parts = fieldPath.split(".")
  let done = []
  let currentObject = object

  while (parts.length) {
    let field = parts.shift()

    if (!parts.length) {
      if (currentObject.hasOwnProperty(field)) {
        throw new Error(`The ${fieldPath} field seems to be already present `)
      }
      currentObject[field] = value
      break
    }

    if (!currentObject.hasOwnProperty(field)) {
      currentObject[field] = {}
    }

    if (!(currentObject[field] instanceof Object)) {
      throw new Error(`The ${fieldPath} field is in a conflict with the ` +
          `${done.join(".")} field`)
    }

    currentObject = currentObject[field]

    done.push(field)
  }
}

/**
 * Retrieves the value from the provided object at the specified field path.
 *
 * @param {Object} object The object from which the field value is to be
 *        extracted.
 * @param {string} fieldPath The path to the field to retrieve, specified as a
 *        sequence of field names joined by dots ({@code "."}).
 * @return {*} The value of the specified field.
 * @throw {Error} Thrown if the field path does not exist in the provided
 *        object.
 */
function getFieldValue(object, fieldPath) {
  if (!fieldPath) {
    return object
  }

  let currentObject = object
  fieldPath.split(".").forEach((fieldName) => {
    if (!currentObject.hasOwnProperty(fieldName)) {
      throw new Error(`The field path ${fieldPath} does not exist in the ` +
          "provided object")
    }

    currentObject = currentObject[fieldName]
  })

  return currentObject
}


import PromiseSync from "../PromiseSync"
import Transaction from "../transaction/Transaction"
import ObjectStoreMigrator from "./ObjectStoreMigrator"

/**
 * Private field symbols.
 */
const FIELDS = Object.freeze({
  database: Symbol("database"),
  transaction: Symbol("transaction"),
  objectStores: Symbol("objectStores")
})

/**
 * Utility for migrating the database schema by a single version.
 */
export default class DatabaseVersionMigrator {
  /**
   * Initializes the database version migrator.
   *
   * @param {IDBDatabase} database The database to upgrade.
   * @param {IDBTransaction} transaction The {@code versionchange} transaction.
   * @param {(ObjectStoreSchema[]|Object[])} objectStores Descriptors of object
   *        stores representing the schema the database should have after the
   *        migration. Use either {@linkcode ObjectStoreSchema} instances or
   *        plain object with compatible structure.
   */
  constructor(database, transaction, objectStores) {
    /**
     * The database to upgrade.
     *
     * @type {IDBDatabase}
     */
    this[FIELDS.database] = database

    /**
     * The {@code versionchange} transaction.
     *
     * @type {IDBTransaction}
     */
    this[FIELDS.transaction] = transaction

    /**
     * Descriptors of object stores representing the schema the database should
     * have after the migration.
     *
     * @type {(ObjectStoreSchema[]|Object[])}
     */
    this[FIELDS.objectStores] = objectStores

    Object.freeze(this)
  }

  /**
   * Upgrades the database schema and executes the provided callback within the
   * transaction provided in the constructor.
   *
   * @param {function(Transaction, Object<string, {key: (number|string|Date|Array), record: *}[]>): ?PromiseSync<undefined>} onComplete
   *        Callback to execute when the schema has been successfully migrated.
   *        If the callback performs database operations, it must execute the
   *        first operation synchronously, the subsequent operations may be
   *        executed from the operation promise callbacks.
   * @param {Object<string, {key: (number|string|Date|Array), record: *}[]>} callbackData
   *        The data to pass as the second argument of the callback.
   * @return {PromiseSync<undefined>} A promise that resolves when the database
   *         schema has been upgraded and the promise returned by the callback
   *         resolves.
   */
  executeMigration(onComplete, callbackData) {
    let nativeDatabase = this[FIELDS.database]
    let nativeTransaction = this[FIELDS.transaction]
    let objectStores = this[FIELDS.objectStores]
    upgradeSchema(nativeDatabase, nativeTransaction, objectStores)

    return PromiseSync.resolve().then(() => {
      let transaction = new Transaction(nativeTransaction, () => transaction)
      transaction.completionPromise.catch(() => {})

      let promise = PromiseSync.resolve(onComplete(transaction, callbackData))
      return promise.then(() => undefined)
    })
  }
}

/**
 * Updates the schema of the provided Indexed DB database to the schema
 * specified by the provided schema descriptors.
 *
 * @param {IDBDatabase} nativeDatabase The native Indexed DB database being
 *        migrated.
 * @param {IDBTransaction} nativeTransaction The native {@code versionchange}
 *        transaction.
 * @param {((DatabaseSchema|UpgradedDatabaseSchema))[]} descriptors Schema
 *        descriptor of the version to which the database is to be upgraded.
 */
function upgradeSchema(nativeDatabase, nativeTransaction, descriptors) {
  let objectStoreNames = Array.from(nativeDatabase.objectStoreNames)
  let newObjectStoreNames = descriptors.map((objectStore) => {
    return objectStore.name
  })
  objectStoreNames.forEach((objectStoreName) => {
    if (newObjectStoreNames.indexOf(objectStoreName) === -1) {
      nativeDatabase.deleteObjectStore(objectStoreName)
    }
  })

  descriptors.forEach((objectStoreDescriptor) => {
    let objectStoreName = objectStoreDescriptor.name
    let nativeObjectStore = objectStoreNames.indexOf(objectStoreName) > -1 ?
        nativeTransaction.objectStore(objectStoreName) : null

    let objectStoreMigrator = new ObjectStoreMigrator(nativeDatabase,
        nativeObjectStore, objectStoreDescriptor)
    objectStoreMigrator.executeMigration()
  })
}


var document = require("global/document")
var window = require("global/window")

var signal = require("../signal/signal")

function Point(x, y) {
    this.x = x
    this.y = y
}

Point.prototype.toString = function _Point_toString() {
    return "[x=" + this.x + ", y=" + this.y + "]"
}

module.exports = MousePosition

// Signal { x: Int, y: Int }
function MousePosition() {
    return signal(function (next) {
        document.addEventListener("mousemove", function (event) {
            next(getXY(event))
        })
    }, new Point(0, 0))
}

function getXY(event) {
    var posx = 0
    var posy = 0

    if (!event) {
        event = window.event
    }

    if (event.pageX || event.pageY) {
        posx = event.pageX;
        posy = event.pageY;
    } else if (event.clientX || event.clientY)  {
        posx = event.clientX + document.body.scrollLeft +
            document.documentElement.scrollLeft;
        posy = event.clientY + document.body.scrollTop +
            document.documentElement.scrollTop;
    }

    return new Point(posx, posy)
}


module.exports = rect

// Int -> Int -> { x: Number, y: Number} -> Shape
function rect(width, height, pos) {
    return new Shape([{
        x: 0 - width / 2
        , y: 0 - height / 2
    }, {
        x: 0 - width / 2
        , y: height / 2
    }, {
        x: width / 2
        , y: height / 2
    }, {
        x: width / 2
        , y: 0 - height / 2
    }], pos)
}

function Shape(points, position) {
    this.points = points
    this.position = position
}


var document = require("global/document")

var Element = require("./element")
var setPosition = require("./set-position")

function ContainerElement(position, elem) {
    this.position = position
    this.elem = elem
}

ContainerElement.prototype.type = "ContainerElement"

ContainerElement.prototype.create = function _ContainerElement_create() {
    var child = this.elem.create()
    setPosition(this.position, child)
    var div = document.createElement("div")
    div.style.position = "relative"
    div.style.overflow = "hidden"
    div.appendChild(child)
    return div
}

ContainerElement.prototype.update =
    function _ContainerElement_update(elem, previous) {
        this.elem.update(elem.firstChild, previous.elem)
        setPosition(this.position, elem.firstChild)
    }

module.exports = container

// Int -> Int -> Position -> Element -> Element
function container(width, height, position, elem) {
    return new Element(new ContainerElement(position, elem)
        , width, height)
}


var uuid = require("uuid")
var extend = require("xtend")
var localStorage = require("global/window").localStorage

var inspect = require("../../signal/inspect")
var merge = require("../../signal/merge")
var foldp = require("../../signal/foldp")
var transform = require("../../signal/transform")
var EventPool = require("../../input/event-pool")
var Router = require("../../input/router")
var h = require("../../element/html-element")
var render = require("../../render")

// Application Model
var TodoModel = { id: "", title: "", completed: false, editing: false }
var TodosModel = { todos: [], route: "all" }

// Inputs
var addTodoPool = EventPool("add-todo")
var toggleAllPool = EventPool("toggle-all")
var modifyTodoPool = EventPool("modify-todo")
var clearCompletedPool = EventPool("clear-completed")
var routes = Router()

// State operations
var addTodo = transform(addTodoPool.signal, function (todo) {
    return function (state) {
        return todo.title === "" ? state :
            extend(state, { todos: state.todos.concat(todo) })
    }
})
var modifyTodo = transform(modifyTodoPool.signal, function (change) {
    return function (state) {
        return extend(state, {
            todos: state.todos.reduce(function (todos, todo) {
                return change.id !== todo.id ? todos.concat(todo) :
                    change.title === "" ? todos :
                    todos.concat(extend(todo, change))
            }, [])
        })
    }
})
var toggleAll = transform(toggleAllPool.signal, function (toggled) {
    return function (state) {
        return extend(state, { todos: state.todos.map(function (todo) {
            return extend(todo, { completed: toggled })
        }) })
    }
})
var clearCompleted = transform(clearCompletedPool.signal, function () {
    return function (state) {
        return extend(state, { todos: state.todos.filter(function (todo) {
            return !todo.completed
        }) })
    }
})
var handleRoutes = transform(routes, function (routeEvent) {
    return function (state) {
        return extend(state, { route: routeEvent.hash.slice(2) || "all" })
    }
})

var input = merge([addTodo, modifyTodo, toggleAll,
    clearCompleted, handleRoutes])

inspect(merge([addTodoPool.signal, modifyTodoPool.signal, toggleAllPool.signal,
    clearCompletedPool.signal, routes]))
// =>

var storedState = localStorage.getItem("todos-graphics")
var initialState = storedState ? JSON.parse(storedState) : TodosModel

// Updating the current state
var todosState = foldp(input, function update(state, modification) {
    return modification(state)
}, initialState)
inspect(todosState) // =>

todosState(function (value) {
    localStorage.setItem("todos-graphics", JSON.stringify(value))
})

// Various template functions to render subsets of the UI
function mainSection(state) {
    var todos = state.todos
    var route = state.route

    return h("section.main", { hidden: todos.length === 0 }, [
        toggleAllPool.change(h("input#toggle-all.toggle-all", {
            type: "checkbox",
            checked: todos.every(function (todo) {
                return todo.completed
            })
        })),
        h("label", { htmlFor: "toggle-all" }, "Mark all as complete"),
        h("ul.todo-list", todos.filter(function (todo) {
            return route === "completed" && todo.completed ||
                route === "active" && !todo.completed ||
                route === "all"
        }).map(todoItem))
    ])
}

function todoItem(todo) {
    var editBox = h("input.edit", {
        value: todo.title,
        focus: todo.editing
    })
    editBox = modifyTodoPool.submit(editBox, edit)
    editBox = modifyTodoPool.on(editBox, "blur", edit)

    var classes = (todo.completed ? ".completed" : "") +
        (todo.editing ? ".editing" : "")

    return h("li" + classes, [
        h("div.view", [
            modifyTodoPool.change(h("input.toggle", {
                type: "checkbox", checked: todo.completed
            }), completed),
            modifyTodoPool.on(h("label", todo.title), "dblclick", editing),
            modifyTodoPool.submit(h("button.destroy"), destroy)
        ]),
        editBox
    ])

    function edit(value) {
        return { id: todo.id, title: value, editing: false }
    }
    function editing() { return { id: todo.id, editing: true } }
    function completed(checked) { return { id: todo.id, completed: checked } }
    function destroy() { return { id: todo.id, title: "" } }
}

function statsSection(state) {
    var todos = state.todos
    var todosLeft = todos.filter(function (todo) {
        return todo.completed === false
    }).length
    var todosCompleted = todos.length - todosLeft

    return h("footer.footer", {
        hidden: todos.length === 0
    }, [
        h("span.todo-count", [
            h("strong", String(todosLeft)),
            " item" + (todos.length !== 1 ? "s" : "") + " left"
        ]),
        h("ul.filters", [
            link("#/", "All", state.route === "all"),
            link("#/active", "Active", state.route === "active"),
            link("#/completed", "Completed", state.route === "completed")
        ]),
        clearCompletedPool.submit(h("button.clear-completed", {
            hidden: todosCompleted === 0
        }, "Clear completed (" + String(todosCompleted) + ")"))
    ])
}

function link(uri, text, selected) {
    return h("li", [
        h("a" + (selected ? ".selected" : ""), { href: uri }, text)
    ])
}

function Header() {
    return h("header.header", [
        h("h1", "todos"),
        addTodoPool.submit(h("input.new-todo", {
            placeholder: "What needs to be done?"
            , autofocus: true
        }), function (value, target) {
            // OOPS. I touched the DOM.
            target.value = ""

            return { id: uuid(), title: value.trim(),
                completed: false, editing: false }
        })
    ])
}

function InfoFooter() {
    return h("footer.info", [
        h("p", "Double-click to edit a todo"),
        h("p", [
            "Written by ",
            h("a", { href: "https://github.com/Raynos" }, "Raynos")
        ]),
        h("p", [
            "Part of ",
            h("a", { href: "http://todomvc.com" }, "TodoMVC")
        ])
    ])
}

var headerSection = Header()
var infoFooter = InfoFooter()

var app = transform(todosState, function display(state) {
    return h("div.todomvc-wrapper", [
        h("section.todoapp", [
            headerSection,
            mainSection(state),
            statsSection(state)
        ]),
        infoFooter
    ])
})

// Render scene
render(app, false)
// =>


'use strict';

var util = require('util');
var dgram = require('dgram');
var EventEmitter = require('eventemitter3');
var _ = require('lodash');

//var Packet = require('../dreamscreen').message;
var Light = require('../dreamscreen').Light;
var utils = require('../dreamscreen').utils;
var constants = require('../dreamscreen').constants;

/**
 * Creates a dreamscreen client
 * @extends EventEmitter
 */
function Client() {
    EventEmitter.call(this);

    this.debug = true;  //false
    this.socket = dgram.createSocket('udp4');
    this.isSocketBound = false;
    this.devices = {};
    this.port = constants.DREAMSCREEN_PORT;
    this.discoveryTimer = null;
    this.messageHandlers = [];
    this.messageHandlerTimeout = 5000; // 45000 45 sec
    this.broadcastIp = constants.DEFAULT_BROADCAST_IP;
}
util.inherits(Client, EventEmitter);

/**
 * Creates a new socket and starts discovery
 * @example
 * init({debug: true}, function() {
 *   console.log('Client started');
 * })
 * @param {Object} [options] Configuration to use
 * @param {String} [options.address] The IPv4 address to bind to
 * @param {Number} [options.port] The port to bind to
 * @param {Boolean} [options.debug] Show debug output
 * @param {Array} [options.lights] Pre set list of ip addresses of known addressable lights
 * @param {String} [options.broadcast] The broadcast address to use for light discovery
 * @param {Number} [options.sendPort] The port to send messages to
 * @param {Function} [callback] Called after initialation
 */
Client.prototype.init = function (options, callback) {
    var defaults = {
        address: '0.0.0.0',
        port: constants.DREAMSCREEN_PORT,
        debug: false,
        // lightOfflineTolerance: 3,
        // messageHandlerTimeout: 45000,
        // source: '',
        startDiscovery: true,
        lights: [],
        broadcastIp: constants.DEFAULT_BROADCAST_IP,
        sendPort: constants.DREAMSCREEN_PORT
        // resendPacketDelay: 150,
        // resendMaxTimes: 3
    };

    options = options || {};
    var opts = _.defaults(options, defaults);

    // if (typeof opts.port !== 'number') {
    //     throw new TypeError('DreamScreen Client port option must be a number');
    // } else if (opts.port > 65535 || opts.port < 0) {
    //     throw new RangeError('DreamScreen Client port option must be between 0 and 65535');
    // }

    if (typeof opts.debug !== 'boolean') {
        throw new TypeError('DreamScreen Client debug option must be a boolean');
    }
    this.debug = opts.debug;

    // if (typeof opts.lightOfflineTolerance !== 'number') {
    //     throw new TypeError('DreamScreen Client lightOfflineTolerance option must be a number');
    // }
    // this.lightOfflineTolerance = opts.lightOfflineTolerance;

    // if (typeof opts.messageHandlerTimeout !== 'number') {
    //     throw new TypeError('DreamScreen Client messageHandlerTimeout option must be a number');
    // }
    // this.messageHandlerTimeout = opts.messageHandlerTimeout;

    if (typeof opts.broadcastIp !== 'string') {
        throw new TypeError('DreamScreen Client broadcast option must be a string');
    } else if (!utils.isIpv4Format(opts.broadcastIp)) {
        throw new TypeError('DreamScreen Client broadcast option does only allow IPv4 address format');
    }
    this.broadcastIp = opts.broadcastIp;

    // if (typeof opts.sendPort !== 'number') {
    //     throw new TypeError('DreamScreen Client sendPort option must be a number');
    // } else if (opts.sendPort > 65535 || opts.sendPort < 1) {
    //     throw new RangeError('DreamScreen Client sendPort option must be between 1 and 65535');
    // }
    // this.sendPort = opts.sendPort;

    if (!_.isArray(opts.lights)) {
        throw new TypeError('DreamScreen Client lights option must be an array');
    } else {
        opts.lights.forEach(function (light) {
            if (!utils.isIpv4Format(light)) {
                throw new TypeError('DreamScreen Client lights option array element \'' + light + '\' is not expected IPv4 format');
            }
        });
    }

    // if (opts.source !== '') {
    //     if (typeof opts.source === 'string') {
    //         if (/^[0-9A-F]{8}$/.test(opts.source)) {
    //             this.source = opts.source;
    //         } else {
    //             throw new RangeError('DreamScreen Client source option must be 8 hex chars');
    //         }
    //     } else {
    //         throw new TypeError('DreamScreen Client source option must be given as string');
    //     }
    // }

    this.socket.on('error', function (err) {
        this.isSocketBound = false;
        console.error('DreamScreen Client UDP error');
        console.trace(err);
        this.socket.close();
        this.emit('error', err);
    }.bind(this));

    this.socket.on('message', function (msg, rinfo) {

        if (utils.getHostIPs().indexOf(rinfo.address) >= 0 || !Buffer.isBuffer(msg)) {  // Ignore own messages and false formats
            return;
        }

        this.handleReceivedPacket(msg, rinfo);

        this.emit('message', msg, rinfo);

    }.bind(this));

    this.socket.bind(opts.port, opts.address, function () {
        this.isSocketBound = true;
        this.socket.setBroadcast(true);
        this.emit('listening');
        this.port = opts.port;

        // Start scanning
        if (opts.startDiscovery) {
            this.startDiscovery(opts.lights);
        }
        if (typeof callback === 'function') {
            return callback();
        }
    }.bind(this));
};

/**
 * Destroy an instance
 */
Client.prototype.destroy = function () {
    this.stopDiscovery();
    //this.stopSendingProcess();
    if (this.isSocketBound) {
        this.socket.close();
    }
};

Client.prototype.handleReceivedPacket = function (received, rinfo) {
    if (received.length < 7) return;    //automatically ignore any messages that do not have any payload

    let senderIp = rinfo.address;
    if (this.debug) {
        console.log(`handleReceivedPacket ${senderIp} - ${received[4]} ${received[5]}`);
    }

    let length = received[1];
    if (length != (received.length - 2)) {
        if (this.debug) {
            console.log("bad length, ignoring");
        }
        return;
    }
    let crc = received[length + 1];    //should always be last byte in packet
    let calculatedCRC = utils.uartComm_calculate_crc8(received);
    if (crc != calculatedCRC) { //crc is incorrect
        if (this.debug) {
            console.log("bad crc in received packet");
        }
        return;
    }

    if (received[0] != 0xFC || length < 6) {    // must have atleast 1 byte of payload  //validates length
        if (this.debug) {
            console.log("bad packet, ignoring");
        }
        return;
    }

    let commandReceived = (received[4] << 8) + received[5];
    let payload = received.slice(6, received.length - 1);

    var light = this.devices[senderIp];
    if (!light) {   //new device
        if (this.debug) {
            console.log('received from unknown device');
        }
        let isResponse = ((received[3] >> 6) & 1) == 1; //parsing flags
        let isResponseRequested = ((received[3] >> 4) & 1) == 1;

        if ((commandReceived == 0x010A) && (!isResponseRequested && isResponse)) {  //0x010A, received Get State payload
            let productId = payload[payload.length - 1];

            if (productId == 1 || productId == 2) { //Dreamscreen HD, DreamScreen 4K

                light = new Light({
                    client: this,

                    ipAddress: senderIp,
                    productId: productId,
                    lastSeen: 0,   //?
                    isReachable: true,

                    name: utils.subBufferString(0, 16, payload),
                    groupName: utils.subBufferString(16, 16, payload),
                    groupNumber: payload[32],

                    mode: payload[33],
                    brightness: payload[34],
                    ambientColor: payload.slice(40, 43),
                    ambientShow: payload[62],
                    hdmiInput: payload[73],
                    hdmiInputName1: utils.subBufferString(75, 16, payload),
                    hdmiInputName2: utils.subBufferString(91, 16, payload),
                    hdmiInputName3: utils.subBufferString(107, 16, payload)
                });

            } else if (productId == 3) {

                light = new Light({
                    client: this,

                    ipAddress: senderIp,
                    productId: productId,
                    lastSeen: 0,   //?
                    isReachable: true,

                    name: utils.subBufferString(0, 16, payload),
                    groupName: utils.subBufferString(16, 16, payload),
                    groupNumber: payload[32],

                    mode: payload[33],
                    brightness: payload[34],
                    ambientColor: payload.slice(35, 38),
                    ambientShow: payload[60]
                });

            } else {
                if (this.debug) {
                    console.log(`received unsupported productId ${productId}`);
                }
                return;
            }

            this.devices[senderIp] = light;
            this.emit('light-new', light);

            if (this.debug) {
                console.log(`Added new light: ${light.name}`);
                console.log(`sending Get Serial Number packet to new light ${senderIp}`);
            }

            let getSerialBuffer = new Buffer(constants.GET_SERIAL_PACKET);
            this.send(getSerialBuffer, senderIp);

        }

    } else {    //update state of light

        if (commandReceived == 0x0103) {
            let serialNumber = utils.subBufferString(0, 4, payload);
            serialNumber = Buffer.from(serialNumber, 'utf8').toString('hex');
            if (this.debug) {
                console.log(`0x0103 serial number received`);
            }

            light.serialNumber = serialNumber;

            light.lastSeen = Date.now();
            light.isReachable = true;

        } else if (commandReceived == 0x010A) {  //0x010A, received Get State payload
            let productId = payload[payload.length - 1];
            let lightUpdated = false;
            let temp;

            light.lastSeen = Date.now();
            light.isReachable = true;

            temp = utils.subBufferString(0, 16, payload);
            if (light.name !== temp) {
                light.name = temp;
                lightUpdated = true;
            }

            temp = utils.subBufferString(16, 16, payload);
            if (light.groupName !== temp) {
                light.groupName = temp;
                lightUpdated = true;
            }

            temp = payload[32];
            if (light.groupNumber !== temp) {
                light.groupNumber = temp;
                lightUpdated = true;
            }

            temp = payload[33];
            if (light.mode !== temp) {
                light.mode = temp;
                lightUpdated = true;
            }

            temp = payload[34];
            if (light.brightness !== temp) {
                light.brightness = temp;
                lightUpdated = true;
            }

            if (productId == 1 || productId == 2) { //Dreamscreen HD, DreamScreen 4K specific payload indexing
                temp = payload.slice(40, 43);
                if (!(light.ambientColor[0] === temp[0] && light.ambientColor[1] === temp[1] && light.ambientColor[2] === temp[2])) {
                    light.ambientColor = temp.slice();
                    lightUpdated = true;
                }

                temp = payload[62];
                if (light.ambientShow !== temp) {
                    light.ambientShow = temp;
                    lightUpdated = true;
                }

                temp = payload[73];
                if (light.hdmiInput !== temp) {
                    light.hdmiInput = temp;
                    lightUpdated = true;
                }

                temp = utils.subBufferString(75, 16, payload);
                if (light.hdmiInputName1 !== temp) {
                    light.hdmiInputName1 = temp;
                    lightUpdated = true;
                }

                temp = utils.subBufferString(91, 16, payload);
                if (light.hdmiInputName2 !== temp) {
                    light.hdmiInputName2 = temp;
                    lightUpdated = true;
                }

                temp = utils.subBufferString(107, 16, payload);
                if (light.hdmiInputName3 !== temp) {
                    light.hdmiInputName3 = temp;
                    lightUpdated = true;
                }

            } else if (productId == 3) {    //SideKick specific payload indexing
                temp = payload.slice(35, 38);
                if (!(light.ambientColor[0] === temp[0] && light.ambientColor[1] === temp[1] && light.ambientColor[2] === temp[2])) {
                    light.ambientColor = temp.slice();
                    lightUpdated = true;
                }

                temp = payload[60];
                if (light.ambientShow !== temp) {
                    light.ambientShow = temp;
                    lightUpdated = true;
                }
            }

            if (lightUpdated) {
                if (this.debug) {
                    console.log(`${light.name} updated attribute(s)`);
                }
                this.emit('light-updated', light);
            }

        }
    }

    this.processMessageHandlers(received[4], received[5], payload, rinfo)

}

/**
 * Start discovery of lights
 * This will keep the list of lights updated, finds new lights and sets lights
 * offline if no longer found
 * @param {Array} [lights] Pre set list of ip addresses of known addressable lights to request directly
 */
Client.prototype.startDiscovery = function (lights) {
    lights = lights || [];

    var sendDiscoveryPacket = function () {
        if (this.debug) {
            console.log("sending discovery packet");
        }
        let discoveryBuffer = new Buffer(constants.DISCOVERY_PACKET);
        this.send(discoveryBuffer, this.broadcastIp);
    }.bind(this);

    this.discoveryTimer = setInterval(
        sendDiscoveryPacket,
        constants.DISCOVERY_INTERVAL
    );

    sendDiscoveryPacket();
};

/**
 * This stops the discovery process
 * The client will be no longer updating the state of lights or find lights
 */
Client.prototype.stopDiscovery = function () {
    clearInterval(this.discoveryTimer);
    this.discoveryTimer = null;
};

/**
 * Checks all registered message handlers if they request the given message
 * @param  {Number} commandUpper the upper command that was received
 * @param  {Number} commandLower the lower command that was received
 * @param  {Object} payload the payload that was received
 * @param  {Object} rinfo rinfo address info to check handler for
 */
Client.prototype.processMessageHandlers = function (commandUpper, commandLower, payload, rinfo) {

    // Check each message handler to see if the received command is requested
    this.messageHandlers.forEach(function (handler, hdlrIndex) {

        if (commandUpper === handler.commandUpper && commandLower === handler.commandLower) {
            this.messageHandlers.splice(hdlrIndex, 1);

            return handler.callback(null, payload, rinfo);  // Call the function requesting the packet
        }

        // Check for expired request handlers for specific packages after the
        // messageHandlerTimeout set in options, to specify an error
        if (Date.now() > (handler.timestamp + this.messageHandlerTimeout)) {
            this.messageHandlers.splice(hdlrIndex, 1);

            var err = new Error('No DreamScreen response in time');
            return handler.callback(err, null, null);
        }
    }, this);
};

Client.prototype.send = function (message, ipAddress, callback) {
    this.socket.send(message, 0, message.length, constants.DREAMSCREEN_PORT, ipAddress);

    if (typeof callback === 'function') {
        this.addMessageHandler(message[4], message[5], callback);
    }
};

/**
 * Get network address data from connection
 * @return {Object} Network address data
 */
Client.prototype.address = function () {
    var address = null;
    try {
        address = this.socket.address();
    } catch (e) { }
    return address;
};

/**
 * Sets debug on or off at runtime
 * @param  {boolean} debug debug messages on
 */
Client.prototype.setDebug = function (debug) {
    if (typeof debug !== 'boolean') {
        throw new TypeError('DreamScreen Client setDebug expects boolean as parameter');
    }
    this.debug = debug;
};

/**
 * Adds a message handler that calls a function when the requested
 * info was received
 * @param {Number} commandUpper The upper command to listen for
 * @param {Number} commandLower The lower command to listen for
 * @param {Function} callback the function to call if the packet was received,
 *                   this will be called with parameters msg and rinfo
 */
Client.prototype.addMessageHandler = function (commandUpper, commandLower, callback) {
    if (typeof commandUpper !== 'number') {
        throw new TypeError('DreamScreen Client addMessageHandler expects commandUpper parameter to be number');
    }
    if (typeof commandLower !== 'number') {
        throw new TypeError('DreamScreen Client addMessageHandler expects commandLower parameter to be number');
    }
    if (typeof callback !== 'function') {
        throw new TypeError('DreamScreen Client addMessageHandler expects callback parameter to be a function');
    }

    var handler = {
        commandUpper: commandUpper,
        commandLower: commandLower,
        callback: callback.bind(this),
        timestamp: Date.now()
    };

    this.messageHandlers.push(handler);
};

/**
 * Returns the list of all known lights
 * @return {Array} Lights
 */
Client.prototype.lights = function () {
    var result = [];
    _.forEach(this.devices, function (light) {
        result.push(light);
    });
    return result;
};

/**
 * Find a light by label, id or ip
 * @param {String} identifier label, id or ip to search for
 * @return {Object|Boolean} the light object or false if not found
 */
Client.prototype.light = function (identifier) {
    var result;
    if (typeof identifier !== 'string') {
        throw new TypeError('DreamScreen Client light expects identifier for DreamScreen light to be a string');
    }

    // There is no ip or id longer than 45 chars
    if (identifier.length > 45) {   //ipv4 address is max 15 characters? eg 111.111.111.111
        return false;
    }

    // Dots or colons is high likely an ip
    if (identifier.indexOf('.') >= 0 || identifier.indexOf(':') >= 0) {
        result = _.find(this.devices, { ipAddress: identifier }) || false;
        if (result !== false) {
            return result;
        }
    }

    // Search serial number
    result = _.find(this.devices, { serialNumber: identifier }) || false;
    if (result !== false) {
        return result;
    }

    // Search name
    result = _.find(this.devices, { name: identifier }) || false;

    return result;
};

exports.Client = Client;