import { markRaw, reactive, watch, UnwrapNestedRefs, toRaw } from "vue";
import { tryOnScopeDispose } from '@vueuse/core'

import { reaction, ObservableMap, ObservableSet, action, observable, computed } from 'mobx'
import { deepObserve } from "mobx-utils";

import get from 'lodash/get'
import cloneDeep from 'lodash/cloneDeep'
import clone from 'lodash/clone'

/**
 * Notify MobX reactivity system of changes to propagate Vue reactive changes, by reassigning values.
 *
 * @param obj
 * @param prop
 * @param key
 */
export function notify (obj) {
  return clone(obj)
}

/**
 * Get setters and getters of a class instance.
 * Also gets all magic methods (getters and setters) stored in 'all' param.
 *
 * @param instance
 * @return { get: [...], set: [...], all: [...] }
 */
function getMagicProps (instance) {

  let proto = Object.getPrototypeOf(instance)

  let props = []
  while (proto && proto.constructor.name !== 'Object') {
    props.push.apply(props, Object.entries(
      Object.getOwnPropertyDescriptors(proto)
    ))
    proto = Object.getPrototypeOf(proto.constructor.prototype)
  }

  const magicProps = { get: {}, set: {}, all: {} }

  for (let i = 0; i < props.length; i++) {
    if (props[i][0] !== '__proto__') {
      if (typeof props[i][1].get === 'function') {
        // getters
        magicProps.get[props[i][0]] = true
        magicProps.all[props[i][0]] = true
      }
      if (typeof props[i][1].set === 'function') {
        // setters
        magicProps.set[props[i][0]] = true
        magicProps.all[props[i][0]] = true
      }
    }
  }

  return magicProps
}

/**
 * Setup MobX observable and Vue Reactive state on an object.
 *
 * @param observers
 * @param raw
 * @param annotations
 */
export function reactiveObservable<TStore extends Record<string | number, any>> (
  observers: TStore,
  raw: Array<any> | Boolean = [],
): UnwrapNestedRefs<TStore> {

  const obj = Object.assign({}, observers)

  if (Array.isArray(raw)) {
    raw.forEach(prop => {
      if (typeof obj[prop] === 'object') {
        if (Array.isArray(obj[prop])) {
          obj[prop] = obj[prop].map(el => markRaw(el))
        }
        else {
          obj[prop] = markRaw(obj[prop])
        }
      }
    })
  }
  else if (raw === true) {
    for (const prop in observers) {
      if (typeof observers[prop] === 'object') {
        if (Array.isArray(obj[prop])) {
          obj[prop] = obj[prop].map(el => typeof el === 'object' ? markRaw(el) : el)
        }
        else {
          obj[prop] = markRaw(obj[prop])
        }
      }
    }
  }

  return reactive(obj)
}

function isObservableMap (obj) {
  return obj instanceof ObservableMap
}

function isObservableSet (obj) {
  return obj instanceof ObservableSet
}

function isObservableMapOrSet (obj) {
  return isObservableMap(obj) || isObservableSet(obj)
}

/**
 * Create shadow prop name.
 *
 * @param prop
 */
export function shadowProp (prop): string {
  return '__' + prop
}

/**
 * Create a Vue reactive object that is a shadow of MobX state.
 *
 * @param obj
 * @param observables
 * @param options
 */
export function useMobX<Store extends Record<string, any>> (obj, observables, options = {}): UnwrapNestedRefs<Store | any> {

  function shallowUpdateVue (state, observable, newValue, opts) {
    // Set property of shadow state.

    // If option opts.raw === true, Vues markRaw() will tag all objects as raw objects
    // preventing huge reactivity objects from being created (use opts.raw = true)
    // If option opts.raw is an array of properties, then markRaw()
    // will be applied only to those object properties
    if (opts.raw === true || typeof rawMap[observable] !== 'undefined') {
      // Handle raw
      if (typeof state[observable] === 'object') {
        if (Array.isArray(newValue)) {
          // Handle array
          state[observable] = newValue.map(el => typeof el === 'object' ? markRaw(cloneDeep(el)) : cloneDeep(el))
        }
        else {
          // Handle object
          state[observable] =
            // If it's an MobX's ObservbaleMap or ObservableSet, copy reference only!
            // Otherwise the reactivity gets lost
            isObservableMapOrSet(newValue)
              ? markRaw(newValue)
              : markRaw(cloneDeep(newValue))
        }
      }
      else {
        // Primitives values are copied by value, no need to markRaw() or clone them
        state[observable] = newValue
      }
    }
    else {
      state[observable] = isObservableMapOrSet(newValue)
        // If it's an MobX's ObservbaleMap or ObservableSet, copy reference only!
        // Otherwise the reactivity Observable values get lost
        ? newValue
        // Full clone the property without marking it raw
        : cloneDeep(newValue)
    }
  }

  function deepUpdateVue (state, observable, change, path) {

    path = path.replaceAll('/', '.')
    const ref = path === '' ? state[observable] : get(state[observable], path)

    switch (change.type) {
      case 'add':
      case 'update':
        if (change.observableKind === 'map') {
          ref.set(change.name, change.newValue)
        }
        else if (change.observableKind === 'set') {
          ref.add(change.newValue)
        }
        else {
          ref[change.name] = change.newValue
        }
        break;
      case 'remove':
        delete ref[change.name]
        break;
      case 'delete':
        ref.delete(change.name)
        break;
      case 'splice':
        if (change.added.length) {
          ref.splice(change.index, change.removedCount, ...change.added)
        }
        else {
          ref.splice(change.index, change.removedCount)
        }
        break;
    }
  }

  // Init options
  const opts = {
    raw: false, // true for all observables or [] of items that must be markRaw()
    attach: 'vm', // property name to attach to obj
    auto: false,
    ...options
  }

  const shadowAttach = shadowProp(opts.attach)
  // If singleton is already instantiated
  // return state property
  if (opts.attach && shadowAttach in obj) {
    return obj[shadowAttach]
  }

  // Get all the magic setters and getters from the object
  const magicProps = getMagicProps(obj)

  // Get all the props from the source object & its prototype
  let proto = Object.getPrototypeOf(obj)
  let allProps = Object.getOwnPropertyNames(obj)
  while (proto && proto.constructor.name !== 'Object') {
    allProps.push.apply(allProps, Object.getOwnPropertyNames(proto)
      .filter(el => !['caller', 'callee', 'arguments'].includes(el)))
    proto = Object.getPrototypeOf(proto.constructor.prototype)
  }

  if (observables === 'auto') {
    observables = {}
    for (let i = 0; i < allProps.length; i++) {
      const prop = allProps[i]
      if ((prop in opts.auto && opts.auto[prop] === false)
        || (prop === opts.attach && prop === shadowAttach)
      ) {
        continue;
      }
      observables[prop] = true
    }
  }

  // Collect all observable definitions
  const observers = {}
  for (const observable in observables) {
    observers[observable] = typeof obj[observable] === 'function'
      ? async function (...args) {
        await setTimeout(() => {});
        return obj[observable].bind(obj)(...args)
      }
      : (isObservableMapOrSet(obj[observable])
        ? (
          isObservableMap(obj[observable])
            ? new Map(cloneDeep(obj[observable]))
            : new Set(cloneDeep(obj[observable]))
        )
        : cloneDeep(obj[observable]))
  }

  // Setup the base combined MobX observable and Vue reactive state
  const state = reactiveObservable(observers, opts.raw)

  // Create a raw map for fast access
  const rawMap = {}
  if (Array.isArray(opts.raw)) {
    opts.raw.forEach(prop => rawMap[prop] = true)
  }

  // Determine if property should be watched by Vue & MobX
  function shouldWatch (obj, magicProps, observables, observable) {
    return typeof magicProps.get[observable] !== 'undefined' || typeof obj[observable] !== 'function'
  }


  // Create shadow of all the props that are not observables
  allProps.forEach(prop => {
    if (typeof observables[prop] === 'undefined' && prop !== opts.attach && prop !== shadowAttach) {

      let getSet = {
        get: typeof obj[prop] === 'function'
          ? function () {
            return async function (...args) {
              await setTimeout(() => {});
              return obj[prop].bind(obj)(...args)
            }
          } : function () {
            return obj[prop]
          },
        set: function (value) {
          obj[prop] = value;
        },
        enumerable: true
      }
      Object.defineProperty(state, prop, getSet)
    }
  })

  // MobX reactions
  const createMobXObserver = {}
  // MobX reaction disposers
  const mobxDisposer = {}

  // Vue watchers
  const createVueWatcher = {}
  // Vue watch disposers
  const vueDisposer = {}

  // Iterate over all the defined observables
  for (let observable in observables) {

    if (shouldWatch(obj, magicProps, observables, observable)) {

      // Create a Vue Watcher to set values on the original MobX observable object
      // Watches the values change in the Vue shadow state and sets
      // them back to the MobX observable
      createVueWatcher[observable] = () => {

        return watch(() => state[observable], function (newValue) {

          // Dispose the MobX reaction before setting value
          // to prevent infinite reactive recursion
          mobxDisposer[observable]()

          // Set the value of the original object property
          // Set to raw object instead of reactive() object
          // By calling toRaw() on the new value
          obj[observable] = toRaw(newValue)

          // Recreate MobX reaction and its disposer function after setting a new value
          mobxDisposer[observable] = createMobXObserver[observable]()
        }, { deep: true })
      }

      // Create Vue watcher and store the watch disposer
      vueDisposer[observable] = createVueWatcher[observable]()

      // Create MobX reactions that will change the Vue shadow state reactive() props
      createMobXObserver[observable] = () => {

        const isComputedGetter = typeof magicProps.get[observable] !== 'undefined'

        if (isComputedGetter) {
          // Handle computed getters
          // Computed getters should be updated shallowly
          const computedHandler = newValue => {

            // Dispose the Vue watcher before setting a value.
            // This is to prevent infinite recursion
            vueDisposer[observable]()

            // Update Vue reactive state by reassignment
            shallowUpdateVue(state, observable, newValue, opts)

            // Create Vue watcher and its disposer
            vueDisposer[observable] = createVueWatcher[observable]()
          }

          const computedObserveDisposer = reaction(() => obj[observable], computedHandler)

          return () => {
            computedObserveDisposer()
          }
        }
        else {
          // Handle regular primitives, objects & arrays that are not computed

          // Reaction handler will run only on primitives and when
          // the array/objects are reassigned via = operator, otherwise
          // the deep changes are handled by deepObserveHandler()
          const reactionHandler = (newValue, oldValue) => {

            if (oldValue !== newValue) {
              // Dispose the Vue watcher before setting a value.
              // This is to prevent infinite recursion
              vueDisposer[observable]()

              // Dispose MobX observers as well, because
              // They have to be recreated
              mobxDisposer[observable]()

              // Set property of shadow state.
              shallowUpdateVue(state, observable, newValue, opts)

              // Recreate MobX shallow and deep watchers
              mobxDisposer[observable] = createMobXObserver[observable]()
              // Recreate Vue watcher and its disposer
              vueDisposer[observable] = createVueWatcher[observable]()
            }
          }

          // Create shallow MobX reaction observer
          const shallowObserveDisposer = reaction(() => obj[observable], reactionHandler)

          // Handle deep object changes in MobX and mirror them into the Vue reactive state
          // based on those changes. More: https://github.com/mobxjs/mobx-utils#deepobserve
          // https://github.com/mobxjs/mobx/issues/214
          const deepObserveHandler = (change, path, root) => {
            // console.log('change', change, 'path', path, 'root', root)

            // Dispose the Vue watcher before setting a value.
            // This is to prevent infinite recursion
            vueDisposer[observable]()

            // Get updated values from MobX and change them in Vue object
            deepUpdateVue(state, observable, change, path)

            // Recreate Vue watcher and its disposer
            vueDisposer[observable] = createVueWatcher[observable]()
          }

          const deepObserveDisposer = deepObserve(obj[observable], deepObserveHandler)

          return () => {
            // MobX shallow and deep disposers
            shallowObserveDisposer()
            deepObserveDisposer()
          }
        }
      }

      // Create MobX reaction and store the reaction disposer
      mobxDisposer[observable] = createMobXObserver[observable]()
    }
  }

  // Dispose MobX reactions and Vue watchers on
  // scope destruction to prevent memory leaks
  tryOnScopeDispose(() => {
    for (let observable in mobxDisposer) mobxDisposer[observable]()
    for (let observable in vueDisposer) vueDisposer[observable]()
  })

  // Attach to the source object
  // if it is not attached already
  if (opts.attach && !(shadowAttach in obj)) {
    obj[shadowAttach] = state
  }

  return state
}