import Vue from 'vue'

const components = createErrorTestComponents()

describe('Error handling', () => {
  // hooks that prevents the component from rendering, but should not
  // break parent component
  ;[
    ['data', 'data()'],
    ['render', 'render'],
    ['beforeCreate', 'beforeCreate hook'],
    ['created', 'created hook'],
    ['beforeMount', 'beforeMount hook'],
    ['directive bind', 'directive foo bind hook'],
    ['event', 'event handler for "e"']
  ].forEach(([type, description]) => {
    it(`should recover from errors in ${type}`, done => {
      const vm = createTestInstance(components[type])
      expect(`Error in ${description}`).toHaveBeenWarned()
      expect(`Error: ${type}`).toHaveBeenWarned()
      assertRootInstanceActive(vm).then(done)
    })
  })

  // hooks that can return rejected promise
  ;[
    ['beforeCreate', 'beforeCreate hook'],
    ['created', 'created hook'],
    ['beforeMount', 'beforeMount hook'],
    ['mounted', 'mounted hook'],
    ['event', 'event handler for "e"']
  ].forEach(([type, description]) => {
    it(`should recover from promise errors in ${type}`, done => {
      createTestInstance(components[`${type}Async`])
      waitForUpdate(() => {
        expect(`Error in ${description} (Promise/async)`).toHaveBeenWarned()
        expect(`Error: ${type}`).toHaveBeenWarned()
      }).then(done)
    })
  })

  // error in mounted hook should affect neither child nor parent
  it('should recover from errors in mounted hook', done => {
    const vm = createTestInstance(components.mounted)
    expect(`Error in mounted hook`).toHaveBeenWarned()
    expect(`Error: mounted`).toHaveBeenWarned()
    assertBothInstancesActive(vm).then(done)
  })

  // error in beforeUpdate/updated should affect neither child nor parent
  ;[
    ['beforeUpdate', 'beforeUpdate hook'],
    ['updated', 'updated hook'],
    ['directive update', 'directive foo update hook']
  ].forEach(([type, description]) => {
    it(`should recover from errors in ${type} hook`, done => {
      const vm = createTestInstance(components[type])
      assertBothInstancesActive(vm)
        .then(() => {
          expect(`Error in ${description}`).toHaveBeenWarned()
          expect(`Error: ${type}`).toHaveBeenWarned()
        })
        .then(done)
    })
  })

  // hooks that can return rejected promise
  ;[
    ['beforeUpdate', 'beforeUpdate hook'],
    ['updated', 'updated hook']
  ].forEach(([type, description]) => {
    it(`should recover from promise errors in ${type} hook`, done => {
      const vm = createTestInstance(components[`${type}Async`])
      assertBothInstancesActive(vm)
        .then(() => {
          expect(`Error in ${description} (Promise/async)`).toHaveBeenWarned()
          expect(`Error: ${type}`).toHaveBeenWarned()
        })
        .then(done)
    })
  })
  ;[
    ['beforeDestroy', 'beforeDestroy hook'],
    ['destroyed', 'destroyed hook'],
    ['directive unbind', 'directive foo unbind hook']
  ].forEach(([type, description]) => {
    it(`should recover from errors in ${type} hook`, done => {
      const vm = createTestInstance(components[type])
      vm.ok = false
      waitForUpdate(() => {
        expect(`Error in ${description}`).toHaveBeenWarned()
        expect(`Error: ${type}`).toHaveBeenWarned()
      })
        .thenWaitFor(next => {
          assertRootInstanceActive(vm).end(next)
        })
        .then(done)
    })
  })
  ;[
    ['beforeDestroy', 'beforeDestroy hook'],
    ['destroyed', 'destroyed hook']
  ].forEach(([type, description]) => {
    it(`should recover from promise errors in ${type} hook`, done => {
      const vm = createTestInstance(components[`${type}Async`])
      vm.ok = false
      setTimeout(() => {
        expect(`Error in ${description} (Promise/async)`).toHaveBeenWarned()
        expect(`Error: ${type}`).toHaveBeenWarned()
        assertRootInstanceActive(vm).then(done)
      })
    })
  })

  it('should recover from errors in user watcher getter', done => {
    const vm = createTestInstance(components.userWatcherGetter)
    vm.n++
    waitForUpdate(() => {
      expect(`Error in getter for watcher`).toHaveBeenWarned()
      function getErrorMsg() {
        try {
          this.a.b.c
        } catch (e: any) {
          return e.toString()
        }
      }
      const msg = getErrorMsg.call(vm)
      expect(msg).toHaveBeenWarned()
    })
      .thenWaitFor(next => {
        assertBothInstancesActive(vm).end(next)
      })
      .then(done)
  })
  ;[
    ['userWatcherCallback', 'watcher'],
    ['userImmediateWatcherCallback', 'immediate watcher']
  ].forEach(([type, description]) => {
    it(`should recover from errors in user ${description} callback`, done => {
      const vm = createTestInstance(components[type])
      assertBothInstancesActive(vm)
        .then(() => {
          expect(`Error in callback for ${description} "n"`).toHaveBeenWarned()
          expect(`Error: ${type} error`).toHaveBeenWarned()
        })
        .then(done)
    })

    it(`should recover from promise errors in user ${description} callback`, done => {
      const vm = createTestInstance(components[`${type}Async`])
      assertBothInstancesActive(vm)
        .then(() => {
          expect(
            `Error in callback for ${description} "n" (Promise/async)`
          ).toHaveBeenWarned()
          expect(`Error: ${type} error`).toHaveBeenWarned()
        })
        .then(done)
    })
  })

  it('config.errorHandler should capture render errors', done => {
    const spy = (Vue.config.errorHandler = vi.fn())
    const vm = createTestInstance(components.render)

    const args = spy.mock.calls[0]
    expect(args[0].toString()).toContain('Error: render') // error
    expect(args[1]).toBe(vm.$refs.child) // vm
    expect(args[2]).toContain('render') // description

    assertRootInstanceActive(vm)
      .then(() => {
        Vue.config.errorHandler = undefined
      })
      .then(done)
  })

  it('should capture and recover from nextTick errors', done => {
    const err1 = new Error('nextTick')
    const err2 = new Error('nextTick2')
    const spy = (Vue.config.errorHandler = vi.fn())
    Vue.nextTick(() => {
      throw err1
    })
    Vue.nextTick(() => {
      expect(spy).toHaveBeenCalledWith(err1, undefined, 'nextTick')

      const vm = new Vue()
      vm.$nextTick(() => {
        throw err2
      })
      Vue.nextTick(() => {
        // should be called with correct instance info
        expect(spy).toHaveBeenCalledWith(err2, vm, 'nextTick')
        Vue.config.errorHandler = undefined
        done()
      })
    })
  })

  it('should recover from errors thrown in errorHandler itself', () => {
    Vue.config.errorHandler = () => {
      throw new Error('error in errorHandler ¯\\_(ツ)_/¯')
    }
    const vm = new Vue({
      render(h) {
        throw new Error('error in render')
      },
      renderError(h, err) {
        return h('div', err.toString())
      }
    }).$mount()
    expect('error in errorHandler').toHaveBeenWarned()
    expect('error in render').toHaveBeenWarned()
    expect(vm.$el.textContent).toContain('error in render')
    Vue.config.errorHandler = undefined
  })

  // event handlers that can throw errors or return rejected promise
  ;[
    ['single handler', '<div v-on:click="bork"></div>'],
    [
      'multiple handlers',
      '<div v-on="{ click: [bork, function test() {}] }"></div>'
    ]
  ].forEach(([type, template]) => {
    it(`should recover from v-on errors for ${type} registered`, () => {
      const vm = new Vue({
        template,
        methods: {
          bork() {
            throw new Error('v-on')
          }
        }
      }).$mount()
      document.body.appendChild(vm.$el)
      global.triggerEvent(vm.$el, 'click')
      expect('Error in v-on handler').toHaveBeenWarned()
      expect('Error: v-on').toHaveBeenWarned()
      document.body.removeChild(vm.$el)
    })

    it(`should recover from v-on async errors for ${type} registered`, done => {
      const vm = new Vue({
        template,
        methods: {
          bork() {
            return new Promise((resolve, reject) =>
              reject(new Error('v-on async'))
            )
          }
        }
      }).$mount()
      document.body.appendChild(vm.$el)
      global.triggerEvent(vm.$el, 'click')
      waitForUpdate(() => {
        expect('Error in v-on handler (Promise/async)').toHaveBeenWarned()
        expect('Error: v-on').toHaveBeenWarned()
        document.body.removeChild(vm.$el)
      }).then(done)
    })
  })
})

function createErrorTestComponents() {
  const components: any = {}

  // data
  components.data = {
    data() {
      throw new Error('data')
    },
    render(h) {
      return h('div')
    }
  }

  // render error
  components.render = {
    render(h) {
      throw new Error('render')
    }
  }

  // lifecycle errors
  ;['create', 'mount', 'update', 'destroy'].forEach(hook => {
    // before
    const before = 'before' + hook.charAt(0).toUpperCase() + hook.slice(1)
    const beforeComp = (components[before] = {
      props: ['n'],
      render(h) {
        return h('div', this.n)
      }
    })
    beforeComp[before] = function () {
      throw new Error(before)
    }

    const beforeCompAsync = (components[`${before}Async`] = {
      props: ['n'],
      render(h) {
        return h('div', this.n)
      }
    })
    beforeCompAsync[before] = function () {
      return new Promise((resolve, reject) => reject(new Error(before)))
    }

    // after
    const after = hook.replace(/e?$/, 'ed')
    const afterComp = (components[after] = {
      props: ['n'],
      render(h) {
        return h('div', this.n)
      }
    })
    afterComp[after] = function () {
      throw new Error(after)
    }

    const afterCompAsync = (components[`${after}Async`] = {
      props: ['n'],
      render(h) {
        return h('div', this.n)
      }
    })
    afterCompAsync[after] = function () {
      return new Promise((resolve, reject) => reject(new Error(after)))
    }
  })

  // directive hooks errors
  ;['bind', 'update', 'unbind'].forEach(hook => {
    const key = 'directive ' + hook
    const dirComp: any = (components[key] = {
      props: ['n'],
      template: `<div v-foo="n">{{ n }}</div>`
    })
    const dirFoo = {}
    dirFoo[hook] = function () {
      throw new Error(key)
    }
    dirComp.directives = {
      foo: dirFoo
    }
  })

  // user watcher
  components.userWatcherGetter = {
    props: ['n'],
    created() {
      this.$watch(
        function () {
          return this.n + this.a.b.c
        },
        val => {
          console.log('user watcher fired: ' + val)
        }
      )
    },
    render(h) {
      return h('div', this.n)
    }
  }

  components.userWatcherCallback = {
    props: ['n'],
    watch: {
      n() {
        throw new Error('userWatcherCallback error')
      }
    },
    render(h) {
      return h('div', this.n)
    }
  }

  components.userImmediateWatcherCallback = {
    props: ['n'],
    watch: {
      n: {
        immediate: true,
        handler() {
          throw new Error('userImmediateWatcherCallback error')
        }
      }
    },
    render(h) {
      return h('div', this.n)
    }
  }

  components.userWatcherCallbackAsync = {
    props: ['n'],
    watch: {
      n() {
        return Promise.reject(new Error('userWatcherCallback error'))
      }
    },
    render(h) {
      return h('div', this.n)
    }
  }

  components.userImmediateWatcherCallbackAsync = {
    props: ['n'],
    watch: {
      n: {
        immediate: true,
        handler() {
          return Promise.reject(new Error('userImmediateWatcherCallback error'))
        }
      }
    },
    render(h) {
      return h('div', this.n)
    }
  }

  // event errors
  components.event = {
    beforeCreate() {
      this.$on('e', () => {
        throw new Error('event')
      })
    },
    mounted() {
      this.$emit('e')
    },
    render(h) {
      return h('div')
    }
  }

  components.eventAsync = {
    beforeCreate() {
      this.$on(
        'e',
        () => new Promise((resolve, reject) => reject(new Error('event')))
      )
    },
    mounted() {
      this.$emit('e')
    },
    render(h) {
      return h('div')
    }
  }

  return components
}

function createTestInstance(Comp) {
  return new Vue({
    data: {
      n: 0,
      ok: true
    },
    render(h) {
      return h('div', [
        'n:' + this.n + '\n',
        this.ok ? h(Comp, { ref: 'child', props: { n: this.n } }) : null
      ])
    }
  }).$mount()
}

function assertRootInstanceActive(vm) {
  expect(vm.$el.innerHTML).toContain('n:0\n')
  vm.n++
  return waitForUpdate(() => {
    expect(vm.$el.innerHTML).toContain('n:1\n')
  })
}

function assertBothInstancesActive(vm) {
  vm.n = 0
  return waitForUpdate(() => {
    expect(vm.$refs.child.$el.innerHTML).toContain('0')
  }).thenWaitFor(next => {
    assertRootInstanceActive(vm)
      .then(() => {
        expect(vm.$refs.child.$el.innerHTML).toContain('1')
      })
      .end(next)
  })
}