/// <reference path="../fixtures/basic/.nuxt/nuxt.d.ts" />

import { describe, expect, it, vi } from 'vitest'
import { defineEventHandler } from 'h3'

import { mount } from '@vue/test-utils'
import { mountSuspended, registerEndpoint } from '@nuxt/test-utils/runtime'

import * as composables from '#app/composables'

import { clearNuxtData, refreshNuxtData, useAsyncData, useNuxtData } from '#app/composables/asyncData'
import { clearError, createError, isNuxtError, showError, useError } from '#app/composables/error'
import { onNuxtReady } from '#app/composables/ready'
import { setResponseStatus, useRequestEvent, useRequestFetch, useRequestHeaders } from '#app/composables/ssr'
import { clearNuxtState, useState } from '#app/composables/state'
import { useRequestURL } from '#app/composables/url'
import { getAppManifest, getRouteRules } from '#app/composables/manifest'
import { useId } from '#app/composables/id'
import { callOnce } from '#app/composables/once'
import { useLoadingIndicator } from '#app/composables/loading-indicator'
import { useRouteAnnouncer } from '#app/composables/route-announcer'

registerEndpoint('/api/test', defineEventHandler(event => ({
  method: event.method,
  headers: Object.fromEntries(event.headers.entries()),
})))

describe('app config', () => {
  it('can be updated', () => {
    const appConfig = useAppConfig()
    expect(appConfig).toMatchInlineSnapshot(`
      {
        "nuxt": {
          "buildId": "override",
        },
      }
    `)
    updateAppConfig({
      new: 'value',
      // @ts-expect-error property does not exist
      nuxt: { nested: 42 },
    })
    expect(appConfig).toMatchInlineSnapshot(`
      {
        "new": "value",
        "nuxt": {
          "buildId": "override",
          "nested": 42,
        },
      }
    `)
  })
})

describe('composables', () => {
  it('are all tested', () => {
    const testedComposables: string[] = [
      'clearNuxtData',
      'refreshNuxtData',
      'useAsyncData',
      'useNuxtData',
      'createError',
      'isNuxtError',
      'clearError',
      'showError',
      'useError',
      'getAppManifest',
      'useHydration',
      'getRouteRules',
      'onNuxtReady',
      'callOnce',
      'setResponseStatus',
      'prerenderRoutes',
      'useRequestEvent',
      'useRequestFetch',
      'isPrerendered',
      'useRequestHeaders',
      'useCookie',
      'clearNuxtState',
      'useState',
      'useRequestURL',
      'useId',
      'useRoute',
      'navigateTo',
      'abortNavigation',
      'setPageLayout',
      'defineNuxtComponent',
    ]
    const skippedComposables: string[] = [
      'addRouteMiddleware',
      'defineNuxtRouteMiddleware',
      'definePayloadReducer',
      'definePayloadReviver',
      'loadPayload',
      'onBeforeRouteLeave',
      'onBeforeRouteUpdate',
      'prefetchComponents',
      'preloadComponents',
      'preloadPayload',
      'preloadRouteComponents',
      'reloadNuxtApp',
      'refreshCookie',
      'useFetch',
      'useHead',
      'useLazyFetch',
      'useLazyAsyncData',
      'useRouter',
      'useSeoMeta',
      'useServerSeoMeta',
      'usePreviewMode',
    ]
    expect(Object.keys(composables).sort()).toEqual([...new Set([...testedComposables, ...skippedComposables])].sort())
  })
})

describe('useAsyncData', () => {
  it('should work at basic level', async () => {
    const res = useAsyncData(() => Promise.resolve('test'))
    expect(Object.keys(res)).toMatchInlineSnapshot(`
      [
        "data",
        "pending",
        "error",
        "status",
        "execute",
        "refresh",
        "clear",
      ]
    `)
    expect(res instanceof Promise).toBeTruthy()
    expect(res.data.value).toBe(null)
    await res
    expect(res.data.value).toBe('test')
  })

  it('should not execute with immediate: false', async () => {
    const immediate = await useAsyncData(() => Promise.resolve('test'))
    expect(immediate.data.value).toBe('test')
    expect(immediate.status.value).toBe('success')
    expect(immediate.pending.value).toBe(false)

    const nonimmediate = await useAsyncData(() => Promise.resolve('test'), { immediate: false })
    expect(nonimmediate.data.value).toBe(null)
    expect(nonimmediate.status.value).toBe('idle')
    expect(nonimmediate.pending.value).toBe(true)
  })

  it('should capture errors', async () => {
    const { data, error, status, pending } = await useAsyncData('error-test', () => Promise.reject(new Error('test')), { default: () => 'default' })
    expect(data.value).toMatchInlineSnapshot('"default"')
    expect(error.value).toMatchInlineSnapshot('[Error: test]')
    expect(status.value).toBe('error')
    expect(pending.value).toBe(false)
    expect(useNuxtApp().payload._errors['error-test']).toMatchInlineSnapshot('[Error: test]')

    // TODO: fix the below
    // const { data: syncedData, error: syncedError, status: syncedStatus, pending: syncedPending } = await useAsyncData('error-test', () => ({}), { immediate: false })

    // expect(syncedData.value).toEqual(null)
    // expect(syncedError.value).toEqual(error.value)
    // expect(syncedStatus.value).toEqual('idle')
    // expect(syncedPending.value).toEqual(true)
  })

  // https://github.com/nuxt/nuxt/issues/23411
  it('should initialize with error set to null when immediate: false', async () => {
    const { error, execute } = useAsyncData(() => ({}), { immediate: false })
    expect(error.value).toBe(null)
    await execute()
    expect(error.value).toBe(null)
  })

  it('should be accessible with useNuxtData', async () => {
    await useAsyncData('key', () => Promise.resolve('test'))
    const data = useNuxtData('key')
    expect(data.data.value).toMatchInlineSnapshot('"test"')
    clearNuxtData('key')
    expect(data.data.value).toBeUndefined()
    expect(useNuxtData('key').data.value).toBeUndefined()
  })

  it('should be usable _after_ a useNuxtData call', async () => {
    useNuxtApp().payload.data.call = null
    const { data: cachedData } = useNuxtData('call')
    expect(cachedData.value).toMatchInlineSnapshot('null')
    const { data } = await useAsyncData('call', () => Promise.resolve({ resolved: true }), { server: false })
    expect(cachedData.value).toMatchInlineSnapshot(`
      {
        "resolved": true,
      }
    `)
    expect(data.value).toEqual(cachedData.value)
    clearNuxtData('call')
  })

  it('should be refreshable', async () => {
    await useAsyncData('key', () => Promise.resolve('test'))
    clearNuxtData('key')
    const data = useNuxtData('key')
    expect(data.data.value).toBeUndefined()
    await refreshNuxtData('key')
    expect(data.data.value).toMatchInlineSnapshot('"test"')
  })

  it('should be clearable', async () => {
    const { data, error, pending, status, clear } = await useAsyncData(() => Promise.resolve('test'))
    expect(data.value).toBe('test')

    clear()

    expect(data.value).toBeUndefined()
    expect(error.value).toBeNull()
    expect(pending.value).toBe(false)
    expect(status.value).toBe('idle')
  })

  it('allows custom access to a cache', async () => {
    const { data } = await useAsyncData(() => ({ val: true }), { getCachedData: () => ({ val: false }) })
    expect(data.value).toMatchInlineSnapshot(`
      {
        "val": false,
      }
    `)
  })

  it('should use default while pending', async () => {
    const promise = useAsyncData(() => Promise.resolve('test'), { default: () => 'default' })
    const { data, pending } = promise

    expect(pending.value).toBe(true)
    expect(data.value).toMatchInlineSnapshot('"default"')

    await promise
    expect(data.value).toMatchInlineSnapshot('"test"')
  })

  it('should use default after reject', async () => {
    const { data } = await useAsyncData(() => Promise.reject(new Error('test')), { default: () => 'default' })
    expect(data.value).toMatchInlineSnapshot('"default"')
  })

  it('should execute the promise function once when dedupe option is "defer" for multiple calls', () => {
    const promiseFn = vi.fn(() => Promise.resolve('test'))
    useAsyncData('dedupedKey', promiseFn, { dedupe: 'defer' })
    useAsyncData('dedupedKey', promiseFn, { dedupe: 'defer' })
    useAsyncData('dedupedKey', promiseFn, { dedupe: 'defer' })

    expect(promiseFn).toHaveBeenCalledTimes(1)
  })

  it('should execute the promise function multiple times when dedupe option is not specified for multiple calls', () => {
    const promiseFn = vi.fn(() => Promise.resolve('test'))
    useAsyncData('dedupedKey', promiseFn)
    useAsyncData('dedupedKey', promiseFn)
    useAsyncData('dedupedKey', promiseFn)

    expect(promiseFn).toHaveBeenCalledTimes(3)
  })

  it('should execute the promise function as per dedupe option when different dedupe options are used for multiple calls', () => {
    const promiseFn = vi.fn(() => Promise.resolve('test'))
    useAsyncData('dedupedKey', promiseFn, { dedupe: 'defer' })
    useAsyncData('dedupedKey', promiseFn)
    useAsyncData('dedupedKey', promiseFn, { dedupe: 'defer' })

    expect(promiseFn).toHaveBeenCalledTimes(2)
  })

  it('should be synced with useNuxtData', async () => {
    const { data: nuxtData } = useNuxtData('nuxtdata-sync')
    const promise = useAsyncData('nuxtdata-sync', () => Promise.resolve('test'), { default: () => 'default' })
    const { data: fetchData } = promise

    expect(fetchData.value).toMatchInlineSnapshot('"default"')

    nuxtData.value = 'before-fetch'
    expect(fetchData.value).toMatchInlineSnapshot('"before-fetch"')

    await promise
    expect(fetchData.value).toMatchInlineSnapshot('"test"')
    expect(nuxtData.value).toMatchInlineSnapshot('"test"')

    nuxtData.value = 'new value'
    expect(fetchData.value).toMatchInlineSnapshot('"new value"')
    fetchData.value = 'another value'
    expect(nuxtData.value).toMatchInlineSnapshot('"another value"')
  })
})

describe('useFetch', () => {
  it('should match with/without computed values', async () => {
    const nuxtApp = useNuxtApp()
    const getPayloadEntries = () => Object.keys(nuxtApp.payload.data).length
    const baseCount = getPayloadEntries()

    await useFetch('/api/test')
    expect(getPayloadEntries()).toBe(baseCount + 1)

    /* @ts-expect-error Overriding auto-key */
    await useFetch('/api/test', { method: 'POST' }, '')
    /* @ts-expect-error Overriding auto-key */
    await useFetch('/api/test', { method: ref('POST') }, '')
    expect.soft(getPayloadEntries()).toBe(baseCount + 2)

    /* @ts-expect-error Overriding auto-key */
    await useFetch('/api/test', { query: { id: '3' } }, '')
    /* @ts-expect-error Overriding auto-key */
    await useFetch('/api/test', { query: { id: ref('3') } }, '')
    /* @ts-expect-error Overriding auto-key */
    await useFetch('/api/test', { params: { id: '3' } }, '')
    /* @ts-expect-error Overriding auto-key */
    await useFetch('/api/test', { params: { id: ref('3') } }, '')
    expect.soft(getPayloadEntries()).toBe(baseCount + 3)
  })

  it('should timeout', async () => {
    const { status, error } = await useFetch(
      () => new Promise(resolve => setTimeout(resolve, 5000)),
      { timeout: 1 },
    )
    await new Promise(resolve => setTimeout(resolve, 2))
    expect(status.value).toBe('error')
    expect(error.value).toMatchInlineSnapshot('[Error: [GET] "[object Promise]": <no response> The operation was aborted.]')
  })
})

describe('errors', () => {
  it('createError', () => {
    expect(createError({ statusCode: 404 }).toJSON()).toMatchInlineSnapshot(`
      {
        "message": "",
        "statusCode": 404,
      }
    `)
    expect(createError('Message').toJSON()).toMatchInlineSnapshot(`
      {
        "message": "Message",
        "statusCode": 500,
      }
    `)
  })

  it('isNuxtError', () => {
    const error = createError({ statusCode: 404 })
    expect(isNuxtError(error)).toBe(true)
    expect(isNuxtError(new Error('test'))).toBe(false)
  })

  it('global nuxt errors', () => {
    const err = useError()
    expect(err.value).toBeUndefined()
    showError('new error')
    expect(err.value).toMatchInlineSnapshot('[Error: new error]')
    clearError()
    // TODO: should this return to being undefined?
    expect(err.value).toBeNull()
  })
})

describe('onNuxtReady', () => {
  it('should call callback once nuxt is hydrated', async () => {
    const fn = vi.fn()
    onNuxtReady(fn)
    await new Promise(resolve => setTimeout(resolve, 1))
    expect(fn).toHaveBeenCalled()
  })
})

describe('ssr composables', () => {
  it('work on client', () => {
    // @ts-expect-error This should work for backward compatibility
    expect(setResponseStatus()).toBeUndefined()
    expect(useRequestEvent()).toBeUndefined()
    expect(useRequestFetch()).toEqual($fetch)
    expect(useRequestHeaders()).toEqual({})
    expect(prerenderRoutes('/')).toBeUndefined()
  })
})

describe('useHydration', () => {
  it('should hydrate value from payload', async () => {
    let val: any
    const nuxtApp = useNuxtApp()
    useHydration('key', () => {}, (fromPayload) => { val = fromPayload })
    await nuxtApp.hooks.callHook('app:created', nuxtApp.vueApp)
    expect(val).toMatchInlineSnapshot('undefined')

    nuxtApp.payload.key = 'from payload'
    await nuxtApp.hooks.callHook('app:created', nuxtApp.vueApp)
    expect(val).toMatchInlineSnapshot('"from payload"')
  })
})

describe('useState', () => {
  it('default', () => {
    expect(useState(() => 'default').value).toBe('default')
  })

  it('registers state in payload', () => {
    useState('key', () => 'value')
    expect(Object.entries(useNuxtApp().payload.state)).toContainEqual(['$skey', 'value'])
  })
})

describe('clearNuxtState', () => {
  it('clears state in payload for single key', () => {
    const key = 'clearNuxtState-test'
    const state = useState(key, () => 'test')
    expect(state.value).toBe('test')
    clearNuxtState(key)
    expect(state.value).toBeUndefined()
  })

  it('clears state in payload for array of keys', () => {
    const key1 = 'clearNuxtState-test'
    const key2 = 'clearNuxtState-test2'
    const state1 = useState(key1, () => 'test')
    const state2 = useState(key2, () => 'test')
    expect(state1.value).toBe('test')
    expect(state2.value).toBe('test')
    clearNuxtState([key1, 'other'])
    expect(state1.value).toBeUndefined()
    expect(state2.value).toBe('test')
    clearNuxtState([key1, key2])
    expect(state1.value).toBeUndefined()
    expect(state2.value).toBeUndefined()
  })

  it('clears state in payload for function', () => {
    const key = 'clearNuxtState-test'
    const state = useState(key, () => 'test')
    expect(state.value).toBe('test')
    clearNuxtState(() => false)
    expect(state.value).toBe('test')
    clearNuxtState(k => k === key)
    expect(state.value).toBeUndefined()
  })

  it('clears all state when no key is provided', () => {
    const state1 = useState('clearNuxtState-test', () => 'test')
    const state2 = useState('clearNuxtState-test2', () => 'test')
    expect(state1.value).toBe('test')
    expect(state2.value).toBe('test')
    clearNuxtState()
    expect(state1.value).toBeUndefined()
    expect(state2.value).toBeUndefined()
  })
})

describe('useId', () => {
  it('default', () => {
    const vals = new Set<string>()
    for (let index = 0; index < 100; index++) {
      mount(defineComponent({
        setup () {
          const id = useId()
          vals.add(id)
          return () => h('div', id)
        },
      }))
    }
    expect(vals.size).toBe(100)
  })

  it('generates unique ids per-component', () => {
    const component = defineComponent({
      setup () {
        const id = useId()
        return () => h('div', id)
      },
    })

    expect(mount(component).html()).not.toBe(mount(component).html())
  })
})

describe('url', () => {
  it('useRequestURL', () => {
    const url = useRequestURL()
    expect(url).toMatchInlineSnapshot('"http://localhost:3000/"')
    expect(url.hostname).toMatchInlineSnapshot('"localhost"')
    expect(url.port).toMatchInlineSnapshot('"3000"')
    expect(url.protocol).toMatchInlineSnapshot('"http:"')
  })
})

describe('loading state', () => {
  it('expect loading state to be changed by hooks', async () => {
    vi.stubGlobal('setTimeout', vi.fn((cb: Function) => cb()))
    const nuxtApp = useNuxtApp()
    const { isLoading } = useLoadingIndicator()
    expect(isLoading.value).toBeFalsy()
    await nuxtApp.callHook('page:loading:start')
    expect(isLoading.value).toBeTruthy()

    await nuxtApp.callHook('page:loading:end')
    expect(isLoading.value).toBeFalsy()
    vi.mocked(setTimeout).mockRestore()
  })
})

describe('loading state', () => {
  it('expect loading state to be changed by force starting/stoping', async () => {
    vi.stubGlobal('setTimeout', vi.fn((cb: Function) => cb()))
    const nuxtApp = useNuxtApp()
    const { isLoading, start, finish } = useLoadingIndicator()
    expect(isLoading.value).toBeFalsy()
    await nuxtApp.callHook('page:loading:start')
    expect(isLoading.value).toBeTruthy()
    start()
    expect(isLoading.value).toBeTruthy()
    finish()
    expect(isLoading.value).toBeFalsy()
  })
})

describe.skipIf(process.env.TEST_MANIFEST === 'manifest-off')('app manifests', () => {
  it('getAppManifest', async () => {
    const manifest = await getAppManifest()
    delete manifest.timestamp
    expect(manifest).toMatchInlineSnapshot(`
      {
        "id": "override",
        "matcher": {
          "dynamic": {},
          "static": {
            "/": null,
            "/pre": null,
            "/pre/test": {
              "redirect": true,
            },
          },
          "wildcard": {
            "/pre": {
              "prerender": true,
            },
          },
        },
        "prerendered": [
          "/specific-prerendered",
        ],
      }
    `)
  })
  it('getRouteRules', async () => {
    expect(await getRouteRules('/')).toMatchInlineSnapshot('{}')
    expect(await getRouteRules('/pre')).toMatchInlineSnapshot(`
      {
        "prerender": true,
      }
    `)
    expect(await getRouteRules('/pre/test')).toMatchInlineSnapshot(`
      {
        "prerender": true,
        "redirect": true,
      }
    `)
  })
  it('isPrerendered', async () => {
    expect(await isPrerendered('/specific-prerendered')).toBeTruthy()
    expect(await isPrerendered('/prerendered/test')).toBeFalsy()
    expect(await isPrerendered('/test')).toBeFalsy()
    expect(await isPrerendered('/pre/test')).toBeFalsy()
    expect(await isPrerendered('/pre/thing')).toBeTruthy()
  })
})

describe('routing utilities: `navigateTo`', () => {
  it('navigateTo should disallow navigation to external URLs by default', () => {
    expect(() => navigateTo('https://test.com')).toThrowErrorMatchingInlineSnapshot('[Error: Navigating to an external URL is not allowed by default. Use `navigateTo(url, { external: true })`.]')
    expect(() => navigateTo('https://test.com', { external: true })).not.toThrow()
  })
  it('navigateTo should disallow navigation to data/script URLs', () => {
    const urls = [
      ['data:alert("hi")', 'data'],
      ['\0data:alert("hi")', 'data'],
    ]
    for (const [url, protocol] of urls) {
      expect(() => navigateTo(url, { external: true })).toThrowError(`Cannot navigate to a URL with '${protocol}:' protocol.`)
    }
  })
})

describe('routing utilities: `useRoute`', () => {
  it('should show provide a mock route', () => {
    expect(useRoute()).toMatchObject({
      fullPath: '/',
      hash: '',
      href: '/',
      matched: [],
      meta: {},
      name: undefined,
      params: {},
      path: '/',
      query: {},
      redirectedFrom: undefined,
    })
  })
})

describe('routing utilities: `abortNavigation`', () => {
  it('should throw an error if one is provided', () => {
    const error = useError()
    expect(() => abortNavigation({ message: 'Page not found' })).toThrowErrorMatchingInlineSnapshot('[Error: Page not found]')
    expect(error.value).toBeFalsy()
  })
  it('should block navigation if no error is provided', () => {
    expect(abortNavigation()).toMatchInlineSnapshot('false')
  })
})

describe('routing utilities: `setPageLayout`', () => {
  it('should set layout on page metadata if run outside middleware', () => {
    const route = useRoute()
    expect(route.meta.layout).toBeUndefined()
    setPageLayout('custom')
    expect(route.meta.layout).toEqual('custom')
    route.meta.layout = undefined
  })

  it('should not set layout directly if run within middleware', () => {
    const route = useRoute()
    const nuxtApp = useNuxtApp()
    nuxtApp._processingMiddleware = true
    setPageLayout('custom')
    expect(route.meta.layout).toBeUndefined()
    nuxtApp._processingMiddleware = false
  })
})

describe('defineNuxtComponent', () => {
  it('should produce a Vue component', async () => {
    const wrapper = await mountSuspended(defineNuxtComponent({
      render: () => h('div', 'hi there'),
    }))
    expect(wrapper.html()).toMatchInlineSnapshot('"<div>hi there</div>"')
  })
  it.todo('should support Options API asyncData')
  it.todo('should support Options API head')
})

describe('useCookie', () => {
  it('should watch custom cookie refs', () => {
    const user = useCookie('userInfo', {
      default: () => ({ score: -1 }),
      maxAge: 60 * 60,
    })
    const computedVal = computed(() => user.value.score)
    expect(computedVal.value).toBe(-1)
    user.value.score++
    expect(computedVal.value).toBe(0)
  })

  it('should not watch custom cookie refs when shallow', () => {
    for (const value of ['shallow', false] as const) {
      const user = useCookie('shallowUserInfo', {
        default: () => ({ score: -1 }),
        maxAge: 60 * 60,
        watch: value,
      })
      const computedVal = computed(() => user.value.score)
      expect(computedVal.value).toBe(-1)
      user.value.score++
      expect(computedVal.value).toBe(-1)
    }
  })
})

describe('callOnce', () => {
  it('should only call composable once', async () => {
    const fn = vi.fn()
    const execute = () => callOnce(fn)
    await execute()
    await execute()
    expect(fn).toHaveBeenCalledTimes(1)
  })

  it('should only call composable once when called in parallel', async () => {
    const fn = vi.fn().mockImplementation(() => new Promise(resolve => setTimeout(resolve, 1)))
    const execute = () => callOnce(fn)
    await Promise.all([execute(), execute(), execute()])
    expect(fn).toHaveBeenCalledTimes(1)

    const fnSync = vi.fn().mockImplementation(() => {})
    const executeSync = () => callOnce(fnSync)
    await Promise.all([executeSync(), executeSync(), executeSync()])
    expect(fnSync).toHaveBeenCalledTimes(1)
  })

  it('should use key to dedupe', async () => {
    const fn = vi.fn()
    const execute = (key?: string) => callOnce(key, fn)
    await execute('first')
    await execute('first')
    await execute('second')
    expect(fn).toHaveBeenCalledTimes(2)
  })
})

describe('route announcer', () => {
  it('should create a route announcer with default politeness', () => {
    const announcer = useRouteAnnouncer()
    expect(announcer.politeness.value).toBe('polite')
  })

  it('should create a route announcer with provided politeness', () => {
    const announcer = useRouteAnnouncer({ politeness: 'assertive' })
    expect(announcer.politeness.value).toBe('assertive')
  })

  it('should set message and politeness', () => {
    const announcer = useRouteAnnouncer()
    announcer.set('Test message with politeness', 'assertive')
    expect(announcer.message.value).toBe('Test message with politeness')
    expect(announcer.politeness.value).toBe('assertive')
  })

  it('should set message with polite politeness', () => {
    const announcer = useRouteAnnouncer()
    announcer.polite('Test message polite')
    expect(announcer.message.value).toBe('Test message polite')
    expect(announcer.politeness.value).toBe('polite')
  })

  it('should set message with assertive politeness', () => {
    const announcer = useRouteAnnouncer()
    announcer.assertive('Test message assertive')
    expect(announcer.message.value).toBe('Test message assertive')
    expect(announcer.politeness.value).toBe('assertive')
  })
})