import defu from 'defu'
import { computed, getCurrentInstance as getVM, isReactive, isRef, onBeforeMount, onServerPrefetch, reactive, ref, set, shallowRef, toRaw, toRefs, watch } from '@vue/composition-api'
import { useNuxtApp } from './app'
import { useRouter as _useRouter, useState } from './composables'

// Vue composition API export
export {
  computed,
  createApp,
  createRef,
  customRef,
  defineAsyncComponent,
  del,
  effectScope,
  getCurrentInstance,
  getCurrentScope,
  h,
  inject,
  isRaw,
  isReactive,
  isReadonly,
  isRef,
  markRaw,
  nextTick,
  onActivated,
  onBeforeMount,
  onBeforeUnmount,
  onBeforeUpdate,
  onDeactivated,
  onErrorCaptured,
  onMounted,
  onScopeDispose,
  onServerPrefetch,
  onUnmounted,
  onUpdated,
  provide,
  proxyRefs,
  reactive,
  readonly,
  set,
  shallowReactive,
  shallowReadonly,
  shallowRef,
  toRaw,
  toRef,
  toRefs,
  triggerRef,
  unref,
  useAttrs,
  useCssModule,
  useCSSModule,
  useSlots,
  version,
  warn,
  watch,
  watchEffect,
  watchPostEffect,
  watchSyncEffect
} from '@vue/composition-api'

export { ref }

// Common deprecation utils
// TODO: Add migration guide docs to @nuxtjs/composition-api
const checkDocsMsg = 'Please see https://go.nuxtjs.dev/bridge-capi for more information.'
const msgPrefix = '[bridge] [legacy capi]'
const unsupported = message => () => { throw new Error(`${msgPrefix} ${message} ${checkDocsMsg}`) }
const _warned = {}
const warnOnce = (id, message) => {
  if (!_warned[id]) {
    console.warn(msgPrefix, message)
    _warned[id] = true
  }
}

// TODO: Add warning back once we had replacement for useAsync and useMeta
// Warn in case of having any imports from `@nuxtjs/composition-api`
// warnOnce('import', `\`@nuxtjs/composition-api\` is deprecated. ${checkDocsMsg}`)

// Stub functions that provided type support
export const defineNuxtMiddleware = unsupported('You are using `defineNuxtMiddleware`, which is not supported. You can remove wrapper function to keep using Nuxt 2 middleware.')
export const defineNuxtPlugin = unsupported('You are using `defineNuxtPlugin`, which can be replaced with `defineNuxtPlugin` (import from `#app`).')

// Internal exports
export const setMetaPlugin = unsupported('`setMetaPlugin` is an internal function that is no longer used.')
export const setSSRContext = unsupported('`setSSRContext` is an internal function that is no longer used.')
export const globalPlugin = unsupported('`globalPlugin` is an internal function that is no longer used.')

// Deprecated functions
export const withContext = unsupported('`withContext` is a deprecated method that is no longer provided and can be replaced with `useNuxtApp` (import from `#app`).')
export const useStatic = unsupported('`useStatic` is a deprecated method that is no longer provided.')
export const reqRef = unsupported('`reqRef` is a deprecated method that is no longer provided and can be replaced with `ref` (import from `@vue/composition-api`).')
export const reqSsrRef = unsupported('`reqSsrRef` is no longer provided and can be replaced with `useState` (import from `#app`).')

// ssrRef helpers
const sanitise = val => (val && JSON.parse(JSON.stringify(val))) || val
const getValue = val => val instanceof Function ? val() : val

export const ssrRef = (value, key) => {
  const vm = getVM()
  if (!vm) { throw new Error('ssrRef no longer supports global/ambient context and must be called within a setup() function') }

  warnOnce('ssrRef', '`ssrRef` is deprecated and can be replaced with `useState` (import from `#app`).')

  return useState(key, value instanceof Function ? value : () => value)
}

export const shallowSsrRef = (value, key) => {
  warnOnce('shallowSsrRef', '`shallowSsrRef` is deprecated and can be replaced with `useState` (import from `#app`).')

  const ref = ssrRef(value, key)

  if (process.client) {
    return shallowRef(ref.value)
  }

  return ref
}

export const ssrPromise = (value, key) => {
  warnOnce('ssrPromise', 'ssrPromise is deprecated. Please use an alternative implementation.')

  const ssrRefs = useSSRRefs()
  const promise = Promise.resolve(isHMR() ? getValue(value) : ssrRefs[key] ?? getValue(value))

  onServerPrefetch(async () => { ssrRefs[key] = sanitise(await promise) })
  return promise
}

// Composition API functions
export const onGlobalSetup = (fn) => {
  warnOnce('onGlobalSetup', '`onGlobalSetup` is deprecated and can be replaced with `defineNuxtPlugin(nuxtApp => nuxtApp.provide)` (import from `#app`).')
  const app = useNuxtApp()
  app._setupFns.push(fn)
}

export const useAsync = (cb, key) => {
  warnOnce('useAsync', 'You are using `useAsync`, which is deprecated.')

  const _ref = isRef(key) ? key : ssrRef(null, key)

  if (!_ref.value || isHMR()) {
    const p = Promise.resolve(cb()).then(res => (_ref.value = res))
    onServerPrefetch(() => p)
  }

  return _ref
}

export const useContext = () => {
  warnOnce('useContext', 'You are using `useContext`, which can be replaced with `useNuxtApp` (import from `#app`).')

  const route = useRoute()
  const nuxt = useNuxtApp()

  return {
    ...nuxt.nuxt2App.context,
    route: computed(() => route),
    query: computed(() => route.value.query),
    from: computed(() => nuxt.nuxt2App.context.from),
    params: computed(() => route.value.params)
  }
}

function createEmptyMeta () {
  return {
    titleTemplate: null,

    __dangerouslyDisableSanitizers: [],
    __dangerouslyDisableSanitizersByTagID: {},

    title: undefined,
    htmlAttrs: {},
    headAttrs: {},
    bodyAttrs: {},

    base: undefined,

    meta: [],
    link: [],
    style: [],
    script: [],
    noscript: [],

    changed: undefined,
    afterNavigation: undefined
  }
}

const getHeadOptions = (options) => {
  const head = function () {
    const optionHead =
      options.head instanceof Function ? options.head.call(this) : options.head

    if (!this._computedHead) { return optionHead }

    const computedHead = this._computedHead.map((h) => {
      if (isReactive(h)) { return toRaw(h) }
      if (isRef(h)) { return h.value }
      return h
    })
    return defu({}, ...computedHead.reverse(), optionHead)
  }

  return { head }
}

export const defineComponent = (options) => {
  if (!('head' in options)) { return options }

  return {
    ...options,
    ...getHeadOptions(options)
  }
}

export const useMeta = (init) => {
  const vm = getCurrentInstance()
  const refreshMeta = () => vm.$meta().refresh()

  if (!vm._computedHead) {
    const metaRefs = reactive(createEmptyMeta())
    vm._computedHead = [metaRefs]
    vm._metaRefs = toRefs(metaRefs)

    if (process.client) {
      watch(Object.values(vm._metaRefs), refreshMeta, { immediate: true })
    }
  }

  if (init) {
    const initRef = init instanceof Function ? computed(init) : ref(init)
    vm._computedHead.push(initRef)

    if (process.client) {
      watch(initRef, refreshMeta, { immediate: true })
    }
  }

  return vm._metaRefs
}

// Wrapped properties
export const wrapProperty = (property, makeComputed = true) => () => {
  const vm = getCurrentInstance()
  return makeComputed ? computed(() => vm[property]) : vm[property]
}

export const useRouter = () => {
  warnOnce('useRouter', 'You are using `useRouter`, which can be replaced with `useRouter` (import from `#app`).')
  return _useRouter()
}

export const useRoute = () => {
  warnOnce('useRoute', 'You are using `useRoute`, which can be replaced with `useRoute` (import from `#app`).')
  const vm = getCurrentInstance()
  return computed(() => vm.$route)
}

export const useStore = () => {
  warnOnce('useRoute', 'You are using `useStore`, which can be replaced with `useNuxtApp` (import from `#app`).')
  return getCurrentInstance().$store
}

// useFetch and helper functions

const fetches = new WeakMap()
const fetchPromises = new Map()

const mergeDataOnMount = (data) => {
  const vm = getCurrentInstance()
  if (!vm) { throw new Error('This must be called within a setup function.') }

  onBeforeMount(() => {
    // Merge data
    for (const key in data) {
      try {
        // Assign missing properties
        if (key in vm) {
          // Skip functions (not stringifiable)
          if (typeof vm[key] === 'function') { continue }
          // Preserve reactive objects
          if (isReactive(vm[key])) {
            // Unset keys that do not exist in incoming data
            for (const k in vm[key]) {
              if (!(k in data[key])) {
                delete vm[key][k]
              }
            }
            Object.assign(vm[key], data[key])
            continue
          }
        }
        set(vm, key, data[key])
      } catch (e) {
        if (process.env.NODE_ENV === 'development')
          // eslint-disable-next-line
          console.warn(`Could not hydrate ${key}.`)
      }
    }
  })
}

function createGetCounter (counterObject, defaultKey = '') {
  return function getCounter (id = defaultKey) {
    if (counterObject[id] === undefined) {
      counterObject[id] = 0
    }
    return counterObject[id]++
  }
}

const setFetchState = (vm) => {
  vm.$fetchState =
    vm.$fetchState ||
    reactive({
      error: null,
      pending: false,
      timestamp: 0
    })
}

function getKey (vm) {
  const nuxt = useNuxtApp()
  const nuxtState = nuxt.payload
  if (process.server && 'push' in vm.$ssrContext.nuxt.fetch) {
    return undefined
  } else if (process.client && '_payloadFetchIndex' in nuxtState) {
    nuxtState._payloadFetchIndex = nuxtState._payloadFetchIndex || 0
    return nuxtState._payloadFetchIndex++
  }
  const defaultKey = vm.$options._scopeId || vm.$options.name || ''
  const getCounter = createGetCounter(
    process.server
      ? vm.$ssrContext.fetchCounters
      : nuxt.vue2App._fetchCounters,
    defaultKey
  )

  if (typeof vm.$options.fetchKey === 'function') {
    return vm.$options.fetchKey.call(vm, getCounter)
  } else {
    const key = typeof vm.$options.fetchKey === 'string' ? vm.$options.fetchKey : defaultKey
    return key ? key + ':' + getCounter(key) : String(getCounter(key))
  }
}

function normalizeError (err) {
  let message
  if (!(err.message || typeof err === 'string')) {
    try {
      message = JSON.stringify(err, null, 2)
    } catch (e) {
      message = `[${err.constructor.name}]`
    }
  } else {
    message = err.message || err
  }
  return {
    ...err,
    message,
    statusCode:
      err.statusCode ||
      err.status ||
      (err.response && err.response.status) ||
      500
  }
}

const loadFullStatic = (vm) => {
  vm._fetchKey = getKey(vm)
  // Check if component has been fetched on server
  const { fetchOnServer } = vm.$options
  const fetchedOnServer =
    typeof fetchOnServer === 'function'
      ? fetchOnServer.call(vm) !== false
      : fetchOnServer !== false

  if (!fetchedOnServer || vm.$nuxt?.isPreview || !vm.$nuxt?._pagePayload) {
    return
  }
  vm._hydrated = true
  const data = vm.$nuxt._pagePayload.fetch[vm._fetchKey]

  // If fetch error
  if (data && data._error) {
    vm.$fetchState.error = data._error
    return
  }

  mergeDataOnMount(data)
}

async function serverPrefetch (vm) {
  if (!vm._fetchOnServer) { return }

  // Call and await on $fetch
  setFetchState(vm)

  try {
    await callFetches.call(vm)
  } catch (err) {
    if (process.dev) {
      console.error('Error in fetch():', err)
    }
    vm.$fetchState.error = normalizeError(err)
  }
  vm.$fetchState.pending = false

  // Define an ssrKey for hydration
  vm._fetchKey =
    // Nuxt 2.15+ uses a different format - an object rather than an array
    'push' in vm.$ssrContext.nuxt.fetch
      ? vm.$ssrContext.nuxt.fetch.length
      : vm._fetchKey || vm.$ssrContext.fetchCounters['']++

  // Add data-fetch-key on parent element of Component
  if (!vm.$vnode.data) { vm.$vnode.data = {} }
  const attrs = (vm.$vnode.data.attrs = vm.$vnode.data.attrs || {})
  attrs['data-fetch-key'] = vm._fetchKey

  const data = { ...vm._data }
  Object.entries(vm.__composition_api_state__.rawBindings).forEach(
    ([key, val]) => {
      if (val instanceof Function || val instanceof Promise) { return }

      data[key] = isRef(val) ? val.value : val
    }
  )

  // Add to ssrContext for window.__NUXT__.fetch
  const content = vm.$fetchState.error
    ? { _error: vm.$fetchState.error }
    : JSON.parse(JSON.stringify(data))
  if ('push' in vm.$ssrContext.nuxt.fetch) {
    vm.$ssrContext.nuxt.fetch.push(content)
  } else {
    vm.$ssrContext.nuxt.fetch[vm._fetchKey] = content
  }
}

async function callFetches () {
  const fetchesToCall = fetches.get(this)
  if (!fetchesToCall) { return }
  this.$nuxt.nbFetching++

  this.$fetchState.pending = true
  this.$fetchState.error = null
  this._hydrated = false

  let error = null
  const startTime = Date.now()

  try {
    await Promise.all(
      fetchesToCall.map((fetch) => {
        if (fetchPromises.has(fetch)) { return fetchPromises.get(fetch) }
        const promise = Promise.resolve(fetch(this)).finally(() =>
          fetchPromises.delete(fetch)
        )
        fetchPromises.set(fetch, promise)
        return promise
      })
    )
  } catch (err) {
    if (process.dev) {
      console.error('Error in fetch():', err)
    }
    error = normalizeError(err)
  }

  const delayLeft = (this._fetchDelay || 0) - (Date.now() - startTime)
  if (delayLeft > 0) {
    await new Promise(resolve => setTimeout(resolve, delayLeft))
  }

  this.$fetchState.error = error
  this.$fetchState.pending = false
  this.$fetchState.timestamp = Date.now()

  this.$nextTick(() => (this.$nuxt).nbFetching--)
}

const isSsrHydration = vm => vm.$vnode?.elm?.dataset?.fetchKey

export const useFetch = (callback) => {
  const vm = getCurrentInstance()
  const nuxt = useNuxtApp()

  const nuxtState = nuxt.payload

  const callbacks = fetches.get(vm) || []
  fetches.set(vm, [...callbacks, callback])

  if (typeof vm.$options.fetchOnServer === 'function') {
    vm._fetchOnServer = vm.$options.fetchOnServer.call(vm) !== false
  } else {
    vm._fetchOnServer = vm.$options.fetchOnServer !== false
  }

  if (process.server) {
    vm._fetchKey = getKey(vm)
  }

  setFetchState(vm)

  onServerPrefetch(() => serverPrefetch(vm))

  function result () {
    return {
      fetch: vm.$fetch,
      fetchState: vm.$fetchState
    }
  }

  vm._fetchDelay =
    typeof vm.$options.fetchDelay === 'number' ? vm.$options.fetchDelay : 0

  vm.$fetch = callFetches.bind(vm)

  onBeforeMount(() => !vm._hydrated && callFetches.call(vm))

  if (process.server || !isSsrHydration(vm)) {
    if (process.client && !process.dev && process.static) { loadFullStatic(vm) }
    return result()
  }

  // Hydrate component
  vm._hydrated = true
  vm._fetchKey = vm.$vnode.elm?.dataset.fetchKey || getKey(vm)
  const data = nuxtState.fetch[vm._fetchKey]

  // If fetch error
  if (data && data._error) {
    vm.$fetchState.error = data._error
    return result()
  }

  mergeDataOnMount(data)

  return result()
}

// -- Private shared utils (across composables) --

function getCurrentInstance () {
  const vm = getVM()
  if (!vm) { throw new Error('This must be called within a setup function.') }
  return vm.proxy
}

const useSSRRefs = () => {
  const { payload } = useNuxtApp()
  payload.ssrRefs = payload.ssrRefs || {}
  return payload.ssrRefs
}

const isHMR = () => process.env.NODE_ENV === 'development' && process.client && window.$nuxt?.context.isHMR
