object.js

import { getStringHash } from './string.js'
import { isArray, isObject, isFile, isDate, isFunction, inArray, isSymbol, inObject, isUndefined, hasOwnKey } from './is.js'
import { decideby } from './syntax.js'

/**
 * @template T
 * @param {T} obj
 * @returns {T}
 */
export function clone(obj) {
  const parents = []
  const clone = function(origin) {
    if (!isObject(origin) && !isArray(origin)) {
      return origin
    }

    const result = isArray(origin) ? [] : {}
    const keys = Object.keys(origin)

    parents.push({ origin, result })

    for (let i = 0, len = keys.length; i < len; i ++) {
      const key = keys[i]
      const value = origin[key]
      const referer = parents.find(item => item.origin === value)

      if (referer) {
        result[key] = referer.result
      }
      else {
        result[key] = clone(value)
      }
    }

    return result
  }

  const result = clone(obj)
  return result
}

/**
 * Deep extend an object
 * @param {object} obj1
 * @param {object} obj2
 * @param {0|1|2} [mixArr] 0: extend array as object, 1: push into array, 2: replace all items
 * @returns {object} obj1
 */
export function extend(obj1, obj2, mixArr = 0) {
  const exists = []
  const extend = (obj1, obj2) => {
    each(obj2, (value, key) => {
      const originalValue = obj1[key]

      // check whether extended
      const exist = exists.find(item => item.e === value)
      if (exist) {
        if (originalValue === exist.o) {
          return
        }
        if (!originalValue || typeof originalValue !== 'object') {
          obj1[key] = exist.o
          return
        }
      }

      if (isObject(originalValue)) {
        if (isObject(value) || isArray(value)) {
          extend(originalValue, value, mixArr)
        }
        else {
          obj1[key] = value
        }
      }
      else if (isArray(originalValue)) {
        if (isObject(value)) {
          if (mixArr === 0 || mixArr === 1) {
            extend(originalValue, value, mixArr)
          }
          else if (mixArr === 2) {
            originalValue.length = 0
            extend(originalValue, value, mixArr)
          }
          else {
            obj1[key] = value
          }
        }
        else if (isArray(value)) {
          if (mixArr === 0) {
            extend(originalValue, value, mixArr)
          }
          else if (mixArr === 1) {
            originalValue.push(...value)
          }
          else if (mixArr === 2) {
            originalValue.length = 0
            originalValue.push(...value)
          }
          else {
            obj1[key] = value
          }
        }
        else {
          obj1[key] = value
        }
      }
      else {
        obj1[key] = value
      }
    })

    // record this pair
    exists.push({
      o: obj1, // original
      e: obj2, // extend by this
    })

    return obj1
  }
  return extend(obj1, obj2)
}

/**
 * @param {object} obj1
 * @param {object} obj2
 * @param {0|1|2} [mixArr] 0: extend array as object, 1: push into array, 2: replace all items
 * @returns {object} a new object
 */
export function merge(obj1, obj2, mixArr = 0) {
  obj1 = clone(obj1)

  if (!isArray(obj2) && !isObject(obj2)) {
    return isArray(obj1) && isObject(obj1) ? obj1 : null
  }

  obj2 = clone(obj2)

  if (!isArray(obj1) && !isObject(obj1)) {
    return isArray(obj2) && isObject(obj2) ? obj2 : null
  }

  const exists = []
  const merge = (obj1, obj2) => {
    if (isArray(obj1)) {
      if (isArray(obj2) && mixArr) {
        return mixArr === 1 ?  [...obj1, ...obj2] : obj2
      }
    }

    const result = obj1
    const keys = Object.keys(obj2)
    keys.forEach((key) => {
      const oldValue = obj1[key]
      const newValue = obj2[key]

      if (isObject(newValue) || isArray(newValue)) {
        const index = exists.indexOf(newValue)
        if (index === -1) {
          exists.push(newValue)
        }
        else if (!isArray(oldValue) && !isObject(oldValue)) {
          result[key] = newValue
          return
        }
      }

      if (isObject(newValue) || isArray(newValue)) {
        if (isObject(oldValue) || isArray(oldValue)) {
          result[key] = merge(oldValue, newValue)
        }
        else {
          result[key] = newValue
        }
      }
      else {
        result[key] = newValue
      }
    })
    return result
  }

  return merge(obj1, obj2)
}

/**
 * @param {object} obj
 * @returns {string}
 */
export function stringify(obj) {
  const exists = [obj]
  const used = []
  const stringifyObjectByKeys = (obj) => {
    if (isArray(obj)) {
      let items = obj.map((item) => {
        if (item && typeof item === 'object') {
          return stringifyObjectByKeys(item)
        }
        else {
          return JSON.stringify(item)
        }
      })
      let str = '[' + items.join(',') + ']'
      return str
    }

    let str = '{'
    let keys = Object.keys(obj)
    let total = keys.length
    keys.sort()
    keys.forEach((key, i) => {
      let value = obj[key]
      str += key + ':'

      if (value && typeof value === 'object') {
        let index = exists.indexOf(value)
        if (index > -1) {
          str += '#' + index
          used.push(index)
        }
        else {
          exists.push(value)
          const num = exists.length - 1
          str += '#' + num + stringifyObjectByKeys(value)
        }
      }
      else {
        str += JSON.stringify(value)
      }

      if (i < total - 1) {
        str += ','
      }
    })
    str += '}'
    return str
  }
  let str = stringifyObjectByKeys(obj)

  exists.forEach((item, i) => {
    if (!used.includes(i)) {
      str = str.replace(new RegExp(`:#${i}`, 'g'), ':')
    }
  })

  if (used.includes(0)) {
    str = '#0' + str
  }

  return str
}

/**
 * @param {object} obj
 * @returns {number}
 */
export function getObjectHash(obj) {
  if (typeof obj !== 'object') {
    return
  }

  let str = stringify(obj)
  let hash = getStringHash(str)
  return hash
}

/**
 * @param {object} obj
 * @param {string} key
 * @param {object|function} descriptor
 * @returns {object}
 */
export function define(obj, key, descriptor) {
  if (isFunction(descriptor)) {
    return Object.defineProperty(obj, key, { get: descriptor })
  }
  else if (isObject(descriptor)) {
    if (hasOwnKey(descriptor, 'enumerable') || hasOwnKey(descriptor, 'configurable')) {
      return Object.defineProperty(obj, key, descriptor)
    }
    else if ((isFunction(descriptor.set) && isFunction(descriptor.get))) {
      return Object.defineProperty(obj, key, descriptor)
    }
    else if (hasOwnKey(descriptor, 'value')) {
      return Object.defineProperty(obj, key, descriptor)
    }
    else {
      return Object.defineProperty(obj, key, { value: descriptor })
    }
  }
  else {
    return Object.defineProperty(obj, key, { value: descriptor })
  }
}

/**
 * @param {object|array} obj
 * @param {function} [determine]
 * @returns {object}
 */
export function flat(obj, determine) {
  const flat = (input, path = '', result = {}) => {
    if (isArray(input)) {
      input.forEach((item, i) => flat(item, `${path}[${i}]`, result))
      return result
    }
    else if (input && typeof input === 'object' && !isFile(input) && !isDate(input)) {
      if (isFunction(determine) && !determine(input)) {
        result[path] = input
        return result
      }

      each(input, (value, key) => {
        flat(value, !path ? key : `${path}[${key}]`, result)
      })
      return result
    }
    else {
      result[path] = input
      return result
    }
  }
  return flat(obj)
}

/**
 * @param {object|array} obj
 * @param {function} fn
 * @param {boolean} [descriptor]
 * @returns {object|array}
 */
export function each(obj, fn, descriptor) {
  const withDescriptor = () => {
    const descriptors = Object.getOwnPropertyDescriptors(obj)
    const keys = Object.keys(descriptors)
    keys.forEach((key) => {
      const descriptor = descriptors[key]
      const { get, set, enumerable, configurable, writable } = descriptor
      if (enumerable || (get || set) || (configurable && writable)) {
        fn(descriptor, key, obj)
      }
    })
  }

  const withIterator = () => {
    if (isArray(obj)) {
      obj.forEach(fn)
    }
    else {
      const keys = Object.keys(obj)
      keys.forEach((key) => {
        const value = obj[key]
        fn(value, key, obj)
      })
    }
  }

  return descriptor ? withDescriptor() : withIterator()
}

/**
 * @param {object|array} obj
 * @param {function} fn
 * @returns {object}
 */
export function map(obj, fn) {
  if (isArray(obj)) {
    return obj.map(fn)
  }
  else {
    const result = {}
    each(obj, (value, key) => {
      result[key] = fn(value, key, obj)
    })
    return result
  }
}

/**
 * @param {object|array} obj
 * @param {function} fn
 * @returns {object}
 */
export function filter(obj, fn) {
  if (isArray(obj)) {
    return obj.filter(fn)
  }
  else {
    const result = {}
    each(obj, (value, key) => {
      const bool = fn(value, key, obj)
      if (!bool) {
        return
      }
      result[key] = value
    })
    return result
  }
}

/**
 * @param {object|array} obj
 * @param {function} fn
 */
export function iterate(obj, fn) {
  if (isArray(obj)) {
    let i = 0
    const next = () => {
      if (i >= obj.length) {
        return
      }
      const item = obj[i]
      fn(item, i, next)
    }
    next()
  }
  else {
    const keys = Object.keys(obj)
    let i = 0
    const next = () => {
      if (i >= keys.length) {
        return
      }
      const key = keys[i]
      const value = obj[key]
      fn(value, key, next)
    }
    next()
  }
}

/**
 * @param {object|array} obj
 * @param {Function} fn
 * @returns {any}
 */
export function search(obj, fn) {
  if (isArray(obj)) {
    for (let i = 0, len = obj.length; i < len; i ++) {
      const item = obj[i]
      const res = fn(item, i)
      if (!isUndefined(res)) {
        return res
      }
    }
  }
  else {
    const keys = Object.keys(obj)
    for (let i = 0, len = keys.length; i < len; i ++) {
      const key = keys[i]
      const value = obj[key]
      const res = fn(value, key, obj)
      if (!isUndefined(res)) {
        return res
      }
    }
  }
}

/**
 * 在对象中查找,fn返回true表示找到,返回false表示没有找到继续找,找到后返回该属性的key,通过key就可以方便的获取value
 * @param {object|array} obj
 * @param {function} fn
 * @returns {string} 返回key,找不到返回undefined
 */
export function find(obj, fn) {
  return search(obj, (value, key) => {
    const res = fn(value, key)
    if (res) {
      return key
    }
  })
}

/**
 * @param {object} obj
 * @param {string[]} keys
 * @returns {object}
 * @deprecated use pick instead
 */
export function extract(obj, keys) {
  return pick(obj, keys)
}

/**
 * @param {object} obj
 * @param {string[]} keys
 * @returns {object}
 */
export function pick(obj, keys) {
  const results = {}
  keys.forEach((key) => {
    if (hasOwnKey(obj, key)) {
      results[key] = obj[key]
    }
  })
  return results
}

/**
 * deep freeze
 * @param {object} o
 * @returns {object}
 */
export function freeze(o) {
	if (!Object.freeze) {
		return o
	}

	Object.freeze(o)

	Object.getOwnPropertyNames(o).forEach((prop) => {
		const v = o[prop]
		if (
			Object.prototype.hasOwnProperty.call(o, prop)
			&& v !== null
			&& (typeof v === 'object' || typeof v === 'function')
			&& !Object.isFrozen(v)
		) {
			freeze(v)
		}
	})

	return o
}

/**
 * create a reactive object.
 * it will change your original data
 * @param {object|array} origin
 * @param {object} options
 * @param {function} options.get to modify output value of each node, receive (keyPath, reactiveValue), reactiveValue is a reactive object/array as if, keyPath is an array which catains keys in path
 * @param {function} options.set to modify input value of each node, receive (keyPath, nextValue), nextValue is the given passed value, the return value will be transformed to be reactive object/array as if
 * @param {function} options.dispatch to notify change with keyPath, receive (keypath, next, prev), it will be called after value is set into
 * @param {function} options.writable whether be able to change value, return false to disable writable, default is true
 * @param {function} options.disable return true to disable create nest reactive on this node
 * @returns {object|array}
 * @example
 * const some = {
 *   body: {
 *     hand: true,
 *     foot: true,
 *   },
 * }
 * const a = createReactive(some, {
 *   get(keyPath, value) {
 *     if (keyPath.join('.') === 'body.hand') {
 *       return value.toString()
 *     }
 *     else {
 *       return value
 *     }
 *   },
 *   set(keyPath, value) {},
 *   dispatch({
 *     keyPath,
 *     value, // receive value
 *     input, // getter output
 *     next, // created reactive
 *     prev, // current reactive
 *   }, force) {},
 * })
 *
 * a !== some // reactive object !== object
 * a.body !== some.body // reactive object !== object
 * a.body.hand !== some.body.hand // true !== 'true'
 * a.body.foot == some.body.foot // true == true
 *
 * a.body.hand = false // now a.body.hand is 'false', a string
 * some.body.hand === false // original data changed
 */
export function createReactive(origin, options = {}) {
  const { get, set, del, dispatch, writable, disable, receive } = options

  const create = (origin, parents = []) => {
    if (!isObject(origin) && !isArray(origin)) {
      return origin
    }

    if (isFunction(disable) && disable(parents, origin)) {
      return origin
    }

    let output = null
    if (isObject(origin)) {
      output = createObject(origin, parents)
    }
    else {
      output = createArray(origin, parents)
    }

    return output
  }

  const createObject = (origin, parents = []) => {
    const media = {}
    const reactive = {}

    const setValue = (key, value, trigger) => {
      const keyPath = [...parents, key]

      if (Object.isFrozen(origin)) {
        const active = create(value, keyPath)
        return active
      }

      const prev = origin[key]
      const invalid = media[key]
      const input = isFunction(set) ? set(keyPath, value) : value

      let active
      let next
      if (inObject(key, media) && (
        value === prev
        || value === invalid
        || input === prev
        || input === invalid
      )) {
        // origin property is changed any where else
        if ((typeof prev !== 'object' || prev === null) && prev !== invalid) {
          next = prev
          active = prev
        }
        else if (invalid && typeof invalid === 'object' && invalid.$$_ORIGIN !== prev) {
          next = prev
          active = create(prev, keyPath)
        }
        else {
          next = prev
          active = invalid
        }
      }
      else {
        next = input
        active = create(next, keyPath)
      }

      origin[key] = next
      media[key] = active

      if (trigger && isFunction(dispatch)) {
        dispatch({
          keyPath,
          value,
          next, active,
          prev, invalid,
        })
      }

      return active
    }

    const delValue = (key, trigger) => {
      const keyPath = [...parents, key]
      const prev = origin[key]
      const invalid = media[key]

      if (isFunction(del)) {
        del(keyPath)
      }

      delete reactive[key]
      delete media[key]
      delete origin[key]

      if (trigger && isFunction(dispatch)) {
        const none = void 0
        dispatch({
          keyPath,
          value: none,
          next: none,
          active: none,
          prev, invalid,
        }, isUndefined(prev))
      }
    }

    const put = (key, value, trigger) => {
      const keyPath = [...parents, key]

      Object.defineProperty(reactive, key, {
        get: () => {
          const active = media[key]
          const output = isFunction(get) ? get(keyPath, active) : active
          return output
        },
        set: (value) => {
          if (isFunction(receive)) {
            receive(keyPath, value)
          }

          if (Object.isFrozen(origin)) {
            return media[key]
          }

          if (isFunction(writable) && !writable(keyPath, value)) {
            return media[key]
          }

          const descriptor = Object.getOwnPropertyDescriptor(media, key)
          if (descriptor) {
            if (!('value' in descriptor)) {
              if ('set' in descriptor) {
                origin[key] = value
              }
              return value
            }
            if (!descriptor.writable) {
              return descriptor.value
            }
          }

          const active = setValue(key, value, true)
          return active
        },
        enumerable: true,
        configurable: true,
      })

      // initialize the current value at the first time
      const active = setValue(key, value, trigger)
      return active
    }

    each(origin, (descriptor, key) => {
      if ('value' in descriptor) {
        const value = descriptor.value
        put(key, value)
      }
      else {
        Object.defineProperty(media, key, descriptor)
      }
    }, true)

    Object.defineProperties(reactive, {
      $get: {
        value: key => reactive[key],
      },
      $set: {
        value: (key, value) => {
          const keyPath = [...parents, key]

          if (isFunction(receive)) {
            receive(keyPath, value)
          }

          if (Object.isFrozen(origin)) {
            return media[key]
          }

          if (isFunction(writable) && !writable(keyPath)) {
            return media[key]
          }

          const descriptor = Object.getOwnPropertyDescriptor(media, key)
          if (descriptor) {
            if (!('value' in descriptor)) {
              if ('set' in descriptor) {
                origin[key] = value
              }
              return value
            }
            if (!descriptor.writable) {
              return descriptor.value
            }
          }

          const active = inObject(key, reactive) ? setValue(key, value, true) : put(key, value, true)
          return active
        },
      },
      $del: {
        value: (key) => {
          const keyPath = [...parents, key]

          if (isFunction(receive)) {
            receive(keyPath)
          }

          if (Object.isFrozen(origin)) {
            return false
          }

          if (isFunction(writable) && !writable(keyPath)) {
            return false
          }

          const descriptor = Object.getOwnPropertyDescriptor(media, key)
          if (!descriptor) {
            return false
          }
          if (!descriptor.configurable) {
            return false
          }

          delValue(key, true)
          return true
        },
      },
      $$_ORIGIN: {
        get: () => origin,
      },
    })

    return reactive
  }

  const createArray = (origin, parents = []) => {
    const media = []
    const reactive = []

    const setValue = (i, value, trigger) => {
      const keyPath = [...parents, i]

      if (Object.isFrozen(origin)) {
        const active = create(value, keyPath)
        return active
      }

      const prev = origin[i]
      const invalid = media[i]
      const input = isFunction(set) ? set(keyPath, value) : value

      let active
      let next
      if (inObject(i, media) && (
        value === prev
        || value === invalid
        || input === prev
        || input === invalid
      )) {
        // origin property is changed any where else
        if ((typeof prev !== 'object' || prev === null) && prev !== invalid) {
          next = prev
          active = prev
        }
        else if (invalid && typeof invalid === 'object' && invalid.$$_ORIGIN !== prev) {
          next = prev
          active = create(prev, keyPath)
        }
        else {
          next = prev
          active = invalid
        }
      }
      else {
        next = input
        active = create(next, keyPath)
      }

      origin[i] = next
      media[i] = active

      if (trigger && isFunction(dispatch)) {
        dispatch({
          keyPath,
          value,
          next, active,
          prev, invalid,
        })
      }

      return active
    }

    // fill items into output array
    // start and end, where to start and end
    // items, original data to use
    const shuffle = (start, end) => {
      for (let i = start; i <= end; i ++) {
        const keyPath = [...parents, i]

        Object.defineProperty(reactive, i, {
          get: () => {
            const active = media[i]
            const output = isFunction(get) ? get(keyPath, active) : active
            return output
          },
          set: (value) => {
            if (isFunction(writable) && !writable(keyPath, value)) {
              return media[i]
            }

            const descriptor = Object.getOwnPropertyDescriptor(media, i)
            if (descriptor) {
              if (!('value' in descriptor)) {
                if ('set' in descriptor) {
                  origin[i] = value
                }
                return value
              }
              if (!descriptor.writable) {
                return descriptor.value
              }
            }

            const active = setValue(i, value, true)
            return active
          },
          enumerable: true,
          configurable: true,
        })

        // initialize
        setValue(i, origin[i])
      }

      // make sure the no use items are removed
      if (media.length > origin.length) {
        media.length = origin.length
      }
      if (reactive.length > media.length) {
        reactive.length = media.length
      }
    }

    // change array prototype methods
    const modify = (fn) => ({
      value: function(...args) {
        const nonAs = () => {
          if (fn === 'push' || fn === 'unshift') {
            return media.length
          }
          else if (fn === 'splice') {
            return []
          }
          else if (fn === 'shift') {
            return media[0]
          }
          else if (fn === 'pop') {
            return media[media.length - 1]
          }
          else if (fn === 'insert' || fn === 'remove') {
            return -1
          }
          else {
            return media
          }
        }

        if (isFunction(receive)) {
          receive(parents, origin, fn, args)
        }

        if (Object.isFrozen(origin)) {
          return nonAs()
        }

        if (isFunction(writable) && !writable(parents, origin)) {
          return nonAs()
        }

        // a hook to modify args for array push, shift inputs
        if (inObject(fn, options) && isFunction(options[fn])) {
          const res = options[fn](parents, args)
          // when return false, it means don't change the value
          if (res === false) {
            return nonAs()
          }
          // when return array, use it as new args
          if (isArray(res)) {
            args = res
          }
          // when return object, switch to another method
          else if (isObject(res)) {
            const { to, args: newArgs } = res
            fn = to
            args = newArgs
          }
        }

        let output = null

        // deal with original data
        const operate = () => {
          const before = origin.length
          output = Array.prototype[fn].apply(origin, args)
          const after = origin.length
          return [after, before]
        }

        if (fn === 'push') {
          const [after, before] = operate()
          output = after
          media.length = after
          reactive.length = after
          shuffle(before - 1, after - 1)
        }
        else if (fn === 'unshift') {
          const [after] = operate()
          output = after
          media.length = after
          reactive.length = after
          shuffle(0, after - 1)
        }
        else if (fn === 'splice') {
          const [after] = operate()

          const [start, len, ...items] = args
          output = media.slice(start, start + len)

          media.length = after
          reactive.length = after

          if (!items.length) {
            shuffle(start, after - 1)
          }
          else if (len === items.length) {
            shuffle(start, start + len - 1)
          }
          else {
            shuffle(start, after - 1)
          }
        }
        else if (fn === 'shift') {
          const [after] = operate()
          output = media[0]
          media.length = after
          reactive.length = after
          shuffle(0, after - 1)
        }
        else if (fn === 'pop') {
          const [after] = operate()
          output = media[media.length - 1]
          media.length = after
          reactive.length = after
        }
        else if (fn === 'fill') {
          const [, before] = operate()
          const [, start = 0, end = before] = args
          output = media
          shuffle(start, end - 1)
        }
        else if (fn === 'insert') {
          if (args.length < 1) {
            return -1
          }
          else if (args.length < 2) {
            const [item] = args
            output = origin.length
            Array.prototype.push.call(origin, item)
            shuffle(output, output)
          }
          else {
            const [item, before] = args
            const beforeIndex = decideby(() => {
              const mediaIndex = media.indexOf(before)
              if (mediaIndex > -1) {
                return mediaIndex
              }

              const originIndex = origin.indexOf(before)
              return originIndex
            })

            if (beforeIndex < 0) {
              return -1
            }

            Array.prototype.splice.call(origin, beforeIndex, 0, item)
            shuffle(beforeIndex, origin.length - 1)
            output = beforeIndex
          }
        }
        else if (fn === 'remove') {
          if (args.length < 1) {
            return -1
          }
          else {
            const [item] = args
            const index = decideby(() => {
              const mediaIndex = media.indexOf(item)
              if (mediaIndex > -1) {
                return mediaIndex
              }

              const originIndex = origin.indexOf(item)
              return originIndex
            })

            if (index < 0) {
              return index
            }

            Array.prototype.splice.call(origin, index, 1)
            Array.prototype.splice.call(media, index, 1)
            shuffle(index, origin.length - 1)
            output = index
          }
        }
        else {
          operate()
          output = media
        }

        if (isFunction(dispatch)) {
          dispatch({
            keyPath: parents,
            value: origin,
            next: origin,
            active: reactive,
            prev: origin,
            invalid: reactive,
            fn,
            result: output,
          }, true)
        }

        return output
      },
    })

    Object.defineProperties(reactive, {
      push: modify('push'),
      unshift: modify('unshift'),
      splice: modify('splice'),
      pop: modify('pop'),
      shift: modify('shift'),
      sort: modify('sort'),
      reverse: modify('reverse'),
      fill: modify('fill'),
      insert: modify('insert'),
      remove: modify('remove'),
      $$_ORIGIN: {
        get: () => origin,
      },
    })

    shuffle(0, origin.length - 1)

    return reactive
  }

  const output = create(origin)
  return output
}

const ProxySymbol = Symbol('Proxy')
/**
 * create a proxy object.
 * it will change your original data
 * @param {object|array} origin
 * @param {object} options
 * @param {function} options.get to modify output value of each node, receive (keyPath, proxiedValue), proxiedValue is a reactive object/array as if, keyPath is an array which catains keys in path
 * @param {function} options.set to modify input value of each node, receive (keyPath, nextValue), nextValue is the given passed value, the return value will be transformed to be reactive object/array as if
 * @param {function} options.dispatch to notify change with keyPath, receive (keypath, next, prev), it will be called after value is set into
 * @param {function} options.writable whether be able to change value, return false to disable writable, default is true
 * @returns {any}
 * @example
 * const some = {
 *   body: {
 *     hand: true,
 *     foot: true,
 *   },
 * }
 * const a = createProxy(some, {
 *   get(keyPath, value) {
 *     if (keyPath.join('.') === 'body.hand') {
 *       return value.toString()
 *     }
 *     else {
 *       return value
 *     }
 *   },
 *   set(keyPath, value) {},
 *   dispatch(keyPath, next, current) {},
 * })
 *
 * a !== some // proxy object !== object
 * a.body !== some.body // proxy object !== object
 * a.body.hand !== some.body.hand // true !== 'true'
 * a.body.foot == some.body.foot // true == true
 *
 * a.body.hand = false // now a.body.hand is 'false', a string
 * some.body.hand === false // some.body.hand changes to false
 */
export function createProxy(origin, options = {}) {
  const { get, set, del, dispatch, writable, disable, receive, extensible, enumerable } = options

  const create = (origin, parents = []) => {
    if (!isObject(origin) && !isArray(origin)) {
      return origin
    }

    if (isFunction(disable) && disable(parents, origin)) {
      return origin
    }

    let output = null
    if (isObject(origin)) {
      output = createObject(origin, parents)
    }
    else {
      output = createArray(origin, parents)
    }

    return output
  }

  const createObject = (origin, parents = []) => {
    const media = {}
    const proxy = new Proxy(media, {
      get: (target, key, receiver) => {
        // get original property value
        if (isSymbol(key) && key === ProxySymbol) {
          return origin
        }

        // primitive property
        // such as 'a' + obj, and obj[Symbol.toPrimitive](hint) defined
        if (isSymbol(key) && getSymbolContent(key).indexOf('Symbol.') === 0) {
          return Reflect.get(target, key, receiver)
        }

        const active = Reflect.get(target, key, receiver)

        // here should be noticed
        // a Symbol key will not to into `get` option function
        if (isFunction(get) && !isSymbol(key)) {
          const keyPath = [...parents, key]
          const output = get(keyPath, active)
          return output
        }
        else {
          return active
        }
      },
      set: (target, key, value, receiver) => {
        const keyPath = [...parents, key]

        if (isFunction(receive)) {
          receive(keyPath, value)
        }

        if (Object.isFrozen(origin)) {
          return true
        }

        if (isFunction(writable) && !writable(keyPath, value)) {
          return true
        }

        const descriptor = Object.getOwnPropertyDescriptor(media, key)
        if (descriptor) {
          if (!('value' in descriptor)) {
            if ('set' in descriptor) {
              origin[key] = value
            }
            return true
          }
          if (!descriptor.writable) {
            return true
          }
        }

        const prev = origin[key]
        const invalid = media[key]
        const input = isFunction(set) ? set(keyPath, value) : value

        let active
        let next
        if (inObject(key, media) && (
          value === prev
          || value === invalid
          || input === prev
          || input === invalid
        )) {
          next = prev
          active = invalid
        }
        else {
          next = input
          active = create(next, keyPath)
        }

        origin[key] = next
        Reflect.set(target, key, active, receiver)

        if (isFunction(dispatch)) {
          dispatch({
            keyPath,
            value,
            next, active,
            prev, invalid,
          })
        }

        return true
      },
      deleteProperty: (target, key) => {
        const keyPath = [...parents, key]

        if (isFunction(receive)) {
          receive(keyPath)
        }

        if (Object.isFrozen(origin)) {
          return true
        }

        if (isFunction(writable) && !writable(keyPath)) {
          return true
        }

        const descriptor = Object.getOwnPropertyDescriptor(media, key)
        if (!descriptor) {
          return true
        }
        if (!descriptor.configurable) {
          return true
        }

        const prev = origin[key]
        const invalid = media[key]

        if (isFunction(del) && !isSymbol(key)) {
          del(keyPath)
        }

        delete origin[key]
        Reflect.deleteProperty(target, key)

        if (isFunction(dispatch)) {
          const none = undefined
          dispatch({
            keyPath,
            value: none,
            next: none,
            active: none,
            prev, invalid,
          }, !isUndefined(prev))
        }

        return true
      },
      has(target, key) {
        if (isFunction(enumerable)) {
          const keyPath = [...parents, key]
          return enumerable(keyPath)
        }
        return key in target
      },
      isExtensible() {
        const keyPath = [...parents]
        if (isFunction(extensible)) {
          return extensible(keyPath)
        }
        return true
      },
    })

    each(origin, (descriptor, key) => {
      if ('value' in descriptor) {
        const value = descriptor.value
        const keyPath = [...parents, key]

        if (Object.isFrozen(origin)) {
          media[key] = create(value, keyPath)
        }
        else {
          const needRewrite = isFunction(set) && !isSymbol(key)
          const next = needRewrite ? set(keyPath, value) : value

          if (needRewrite) {
            origin[key] = next
          }

          media[key] = create(next, keyPath)
        }
      }
      else {
        Object.defineProperty(media, key, descriptor)
      }
    }, true)

    return proxy
  }

  const createArray = (origin, parents = []) => {
    const media = []
    const proxy = new Proxy(media, {
      get: (target, key, receiver) => {
        // get original property value
        if (isSymbol(key) && key === ProxySymbol) {
          return origin
        }

        // primitive property
        // such as 'a' + obj, and obj[Symbol.toPrimitive](hint) defined
        if (isSymbol(key) && getSymbolContent(key).indexOf('Symbol.') === 0) {
          return Reflect.get(target, key, receiver)
        }

        // array primitive operation
        const methods = [
          // the following 3 lines will change the array's length
          // the following 1 line will return the new length
          'push', 'unshift',
          // the following 1 line will return the spliced items array
          'splice',
          // the following 1 line will return the removed item value
          'shift', 'pop',
          // the following 1 line will return the changed original array
          'sort', 'reverse', 'fill',
          // provided method
          'insert', 'remove',
        ]
        if (inArray(key, methods)) {
          return (...args) => {
            const nonAs = () => {
              if (key === 'push' || key === 'unshift') {
                return origin.length
              }
              else if (key === 'splice') {
                return []
              }
              else if (key === 'shift') {
                return media[0]
              }
              else if (key === 'pop') {
                return media[origin.length - 1]
              }
              else if (key === 'insert') {
                return -1
              }
              else {
                return media
              }
            }

            if (isFunction(receive)) {
              receive(parents, origin, key, args)
            }

            if (Object.isFrozen(origin)) {
              return nonAs()
            }

            if (isFunction(writable) && !writable(parents, origin)) {
              return nonAs()
            }

            // a hook to modify args for array push, shift inputs
            if (inObject(key, options) && isFunction(options[key])) {
              const res = options[key](parents, args)
              // when return false, it means don't change the value
              if (res === false) {
                return nonAs()
              }
              // when return array, use it as new args
              if (isArray(res)) {
                args = res
              }
              // when return object, switch to another method
              else if (isObject(res)) {
                const { to, args: newArgs } = res
                key = to
                args = newArgs
              }
            }

            const max = origin.length
            let output = null

            // create sub children
            if (key === 'push') {
              // change original data
              Array.prototype[key].apply(origin, args)

              const medias = args.map((item, i) => {
                const index = max + i
                return create(item, [...parents, index])
              })
              output = Array.prototype.push.apply(media, medias)
            }
            else if (key === 'splice') {
              // change original data
              Array.prototype[key].apply(origin, args)

              const [start, len, ...items] = args
              if (!items.length) {
                output = Array.prototype.splice.call(media, start, len)
              }
              else if (len === items.length) {
                const medias = items.map((item, i) => {
                  const index = start + i
                  return create(item, [...parents, index])
                })
                const params = [start, len, ...medias]
                output = Array.prototype.splice.apply(media, params)
              }
              // the ones which are right in media will be changed
              else {
                output = media.slice(start, start + len)

                const items = origin.slice(start)
                const medias = items.map((item, i) => {
                  const index = start + i
                  return create(item, [...parents, index])
                })
                const params = [start, origin.length, ...medias]
                Array.prototype.splice.apply(media, params)
              }
            }
            else if (key === 'fill') {
              // change original data
              Array.prototype[key].apply(origin, args)

              const [item, start = 0, end = max] = args
              const items = []
              for (let i = start; i < end; i ++) {
                items.push(create(item, [...parents, i]))
              }
              const params = [start, end - start, items]
              Array.prototype.splice.apply(media, params)
              output = media
            }
            else if (key === 'insert') {
              if (args.length < 1) {
                return -1
              }
              else if (args.length < 2) {
                const [item] = args
                output = origin.length
                Array.prototype.push.call(origin, item)
                Array.prototype.push.call(media, item)
              }
              else {
                const [item, before] = args
                const beforeIndex = decideby(() => {
                  const mediaIndex = media.indexOf(before)
                  if (mediaIndex > -1) {
                    return mediaIndex
                  }

                  const originIndex = origin.indexOf(before)
                  return originIndex
                })

                if (beforeIndex < 0) {
                  return -1
                }

                Array.prototype.splice.call(origin, beforeIndex, 0, item)
                Array.prototype.splice.call(media, beforeIndex, 0, item)
                output = beforeIndex
              }
            }
            else if (key === 'remove') {
              const [item] = args
              const index = decideby(() => {
                const mediaIndex = media.indexOf(item)
                if (mediaIndex > -1) {
                  return mediaIndex
                }

                const originIndex = origin.indexOf(item)
                return originIndex
              })

              if (index < 0) {
                return index
              }

              Array.prototype.splice.call(origin, index, 1)
              Array.prototype.splice.call(media, index, 1)
              output = index
            }
            else {
              // change original data
              Array.prototype[key].apply(origin, args)
              output = Array.prototype[key].apply(media, args)
            }

            if (isFunction(dispatch)) {
              dispatch({
                keyPath: parents,
                value: origin,
                next: origin,
                active: proxy,
                prev: origin,
                invalid: proxy,
                fn: key,
                result: output,
              }, true)
            }

            return output
          }
        }

        const keyPath = [...parents, key]
        const active = Reflect.get(target, key, receiver)

        // here should be noticed
        // a Symbol key will not to into `get` option function
        if (isFunction(get) && !isSymbol(key)) {
          const output = get(keyPath, active)
          return output
        }
        else {
          return active
        }
      },
      set: (target, key, value, receiver) => {
        const keyPath = [...parents, key]

        if (isFunction(receive)) {
          receive(keyPath, value)
        }

        if (Object.isFrozen(origin)) {
          return true
        }

        if (isFunction(writable) && !writable(keyPath, value)) {
          return true
        }

        const descriptor = Object.getOwnPropertyDescriptor(media, key)
        if (descriptor) {
          if (!('value' in descriptor)) {
            if ('set' in descriptor) {
              origin[key] = value
            }
            return true
          }
          if (!descriptor.writable) {
            return true
          }
        }

        // operate like media.length = 0
        if (key === 'length') {
          if (isFunction(writable) && !writable(parents, origin)) {
            return true
          }

          origin.length = value
          media.length = value

          if (isFunction(dispatch)) {
            dispatch({
              keyPath: parents,
              value: origin,
              next: origin,
              prev: origin,
              active: proxy,
            }, true)
          }

          return true
        }

        const prev = origin[key]
        const invalid = media[key]
        const input = isFunction(set) ? set(keyPath, value) : value

        let active
        let next
        if (inObject(key, media) && (
          value === prev
          || value === invalid
          || input === prev
          || input === invalid
        )) {
          next = prev
          active = invalid
        }
        else {
          next = input
          active = create(next, keyPath)
        }

        origin[key] = next
        Reflect.set(target, key, active, receiver)

        if (isFunction(dispatch)) {
          dispatch({
            keyPath,
            value,
            next, active,
            prev, invalid,
          })
        }

        return true
      },
      deleteProperty: (target, key) => {
        const keyPath = [...parents, key]

        if (isFunction(receive)) {
          receive(keyPath)
        }

        if (Object.isFrozen(origin)) {
          return true
        }

        if (isFunction(writable) && !writable(keyPath)) {
          return true
        }

        const descriptor = Object.getOwnPropertyDescriptor(media, key)
        if (!descriptor) {
          return true
        }
        if (!descriptor.configurable) {
          return true
        }

        const prev = origin[key]
        const invalid = media[key]

        if (isFunction(del) && !isSymbol(key)) {
          del(keyPath)
        }

        delete origin[key]
        Reflect.deleteProperty(target, key)

        if (isFunction(dispatch)) {
          const none = undefined
          dispatch({
            keyPath,
            value: none,
            next: none,
            active: none,
            prev, invalid,
          }, !isUndefined(prev))
        }

        return true
      },
      has(target, key) {
        if (inArray(key, ['remove', 'insert'])) {
          return true
        }
        if (isFunction(enumerable)) {
          const keyPath = [...parents, key]
          return enumerable(keyPath)
        }
        return key in target
      },
      isExtensible() {
        if (isFunction(extensible)) {
          const keyPath = [...parents]
          return extensible(keyPath)
        }
        return true
      },
    })

    each(origin, (descriptor, i) => {
      if ('value' in descriptor) {
        const value = descriptor.value
        const keyPath = [...parents, i]

        if (Object.isFrozen(origin)) {
          media[i] = create(value, keyPath)
        }
        else {
          const needRewrite = isFunction(set) && !isSymbol(i)
          const next = needRewrite ? set(keyPath, value) : value

          if (needRewrite) {
            origin[i] = next
          }

          media[i] = create(next, keyPath)
        }
      }
      else {
        Object.defineProperty(media, key, descriptor)
      }
    }, true)

    return proxy
  }

  const output = create(origin)
  return output
}

/**
 * determine whether an object is a Proxy
 * @param {any} value
 * @returns {boolean}
 */
export function isProxy(value) {
  return !!(value && value[ProxySymbol])
}

/**
 * refine the original value from a Proxy
 * @param {object} obj
 * @returns {any}
 */
export function refineProxy(obj) {
  return obj ? obj[ProxySymbol] : void 0
}

/**
 * get the string of a symbol
 * @param {symbol} symb
 * @returns {string}
 */
export function getSymbolContent(symb) {
  if (symb.description) {
    return symb.description
  }
  const str = symb.toString()
  return str.substring(7, str.length - 1)
}

/**
 * convert an object to an entry array
 * @param {object} obj
 * @returns {array[]}
 */
export function toEntries(obj) {
  const keys = Object.keys(obj)
  return keys.map(key => [key, obj[key]])
}

/**
 * conver an entry/key-value array to an object
 * @param {array[] | object[]} entries
 * @param {boolean} kv
 * @returns {object}
 */
export function fromEntries(entries, kv = false) {
  const obj = {}
  entries.forEach((item) => {
    if (kv) {
      const { key, value } = item
      obj[key] = value
    }
    else {
      const [key, value] = item
      obj[key] = value
    }
  })
  return obj
}