import { createApi } from '@reduxjs/toolkit/query/react'
import { createAction } from '@reduxjs/toolkit'
import {
  actionsReducer,
  hookWaitFor,
  setupApiStore,
} from '../../tests/utils/helpers'
import { renderHook, act, waitFor } from '@testing-library/react'
import { delay } from 'msw'

interface Post {
  id: string
  title: string
  contents: string
}

const baseQuery = vi.fn()
beforeEach(() => baseQuery.mockReset())

const postAddedAction = createAction<string>('postAdded')

const api = createApi({
  baseQuery: (...args: any[]) => {
    const result = baseQuery(...args)
    if (typeof result === 'object' && 'then' in result)
      return result
        .then((data: any) => ({ data, meta: 'meta' }))
        .catch((e: any) => ({ error: e }))
    return { data: result, meta: 'meta' }
  },
  tagTypes: ['Post'],
  endpoints: (build) => ({
    getPosts: build.query<Post[], void>({
      query: () => '/posts',
    }),
    post: build.query<Post, string>({
      query: (id) => `post/${id}`,
      providesTags: ['Post'],
    }),
    updatePost: build.mutation<void, Pick<Post, 'id'> & Partial<Post>>({
      query: ({ id, ...patch }) => ({
        url: `post/${id}`,
        method: 'PATCH',
        body: patch,
      }),
      async onQueryStarted(arg, { dispatch, queryFulfilled, getState }) {
        const currentItem = api.endpoints.post.select(arg.id)(getState())
        if (currentItem?.data) {
          dispatch(
            api.util.upsertQueryData('post', arg.id, {
              ...currentItem.data,
              ...arg,
            }),
          )
        }
      },
      invalidatesTags: (result) => (result ? ['Post'] : []),
    }),
    post2: build.query<Post, string>({
      queryFn: async (id) => {
        await delay(20)
        return {
          data: {
            id,
            title: 'All about cheese.',
            contents: 'TODO',
          },
        }
      },
    }),
    postWithSideEffect: build.query<Post, string>({
      query: (id) => `post/${id}`,
      providesTags: ['Post'],
      async onCacheEntryAdded(arg, api) {
        // Verify that lifecycle promise resolution works
        const res = await api.cacheDataLoaded

        // and leave a side effect we can check in the test
        api.dispatch(postAddedAction(res.data.id))
      },
      keepUnusedDataFor: 0.01,
    }),
  }),
})

const storeRef = setupApiStore(api, {
  ...actionsReducer,
})

describe('basic lifecycle', () => {
  let onStart = vi.fn(),
    onError = vi.fn(),
    onSuccess = vi.fn()

  const extendedApi = api.injectEndpoints({
    endpoints: (build) => ({
      test: build.mutation({
        query: (x) => x,
        async onQueryStarted(arg, api) {
          onStart(arg)
          try {
            const result = await api.queryFulfilled
            onSuccess(result)
          } catch (e) {
            onError(e)
          }
        },
      }),
    }),
    overrideExisting: true,
  })

  beforeEach(() => {
    onStart.mockReset()
    onError.mockReset()
    onSuccess.mockReset()
  })

  test('Does basic inserts and upserts', async () => {
    const newPost: Post = {
      id: '3',
      contents: 'Inserted content',
      title: 'Inserted title',
    }
    const insertPromise = storeRef.store.dispatch(
      api.util.upsertQueryData('post', newPost.id, newPost),
    )

    await insertPromise

    const selectPost3 = api.endpoints.post.select(newPost.id)
    const insertedPostEntry = selectPost3(storeRef.store.getState())
    expect(insertedPostEntry.isSuccess).toBe(true)
    expect(insertedPostEntry.data).toEqual(newPost)

    const updatedPost: Post = {
      id: '3',
      contents: 'Updated content',
      title: 'Updated title',
    }

    const updatePromise = storeRef.store.dispatch(
      api.util.upsertQueryData('post', updatedPost.id, updatedPost),
    )

    await updatePromise

    const updatedPostEntry = selectPost3(storeRef.store.getState())

    expect(updatedPostEntry.isSuccess).toBe(true)
    expect(updatedPostEntry.data).toEqual(updatedPost)
  })

  test('success', async () => {
    const { result } = renderHook(
      () => extendedApi.endpoints.test.useMutation(),
      {
        wrapper: storeRef.wrapper,
      },
    )

    baseQuery.mockResolvedValue('success')

    expect(onStart).not.toHaveBeenCalled()
    expect(baseQuery).not.toHaveBeenCalled()
    act(() => void result.current[0]('arg'))
    expect(onStart).toHaveBeenCalledWith('arg')
    expect(baseQuery).toHaveBeenCalledWith('arg', expect.any(Object), undefined)

    expect(onError).not.toHaveBeenCalled()
    expect(onSuccess).not.toHaveBeenCalled()
    await act(() => delay(5))
    expect(onError).not.toHaveBeenCalled()
    expect(onSuccess).toHaveBeenCalledWith({ data: 'success', meta: 'meta' })
  })

  test('error', async () => {
    const { result } = renderHook(
      () => extendedApi.endpoints.test.useMutation(),
      {
        wrapper: storeRef.wrapper,
      },
    )

    baseQuery.mockRejectedValueOnce('error')

    expect(onStart).not.toHaveBeenCalled()
    expect(baseQuery).not.toHaveBeenCalled()
    act(() => void result.current[0]('arg'))
    expect(onStart).toHaveBeenCalledWith('arg')
    expect(baseQuery).toHaveBeenCalledWith('arg', expect.any(Object), undefined)

    expect(onError).not.toHaveBeenCalled()
    expect(onSuccess).not.toHaveBeenCalled()
    await act(() => delay(5))
    expect(onError).toHaveBeenCalledWith({
      error: 'error',
      isUnhandledError: false,
      meta: undefined,
    })
    expect(onSuccess).not.toHaveBeenCalled()
  })
})

describe('upsertQueryData', () => {
  test('inserts cache entry', async () => {
    baseQuery
      .mockResolvedValueOnce({
        id: '3',
        title: 'All about cheese.',
        contents: 'TODO',
      })
      // TODO I have no idea why the query is getting called multiple times,
      // but passing an additional mocked value (_any_ value)
      // seems to silence some annoying "got an undefined result" logging
      .mockResolvedValueOnce(42)
    const { result } = renderHook(() => api.endpoints.post.useQuery('3'), {
      wrapper: storeRef.wrapper,
    })
    await hookWaitFor(() => expect(result.current.isSuccess).toBeTruthy())

    const dataBefore = result.current.data
    expect(dataBefore).toEqual({
      id: '3',
      title: 'All about cheese.',
      contents: 'TODO',
    })

    await act(async () => {
      storeRef.store.dispatch(
        api.util.upsertQueryData('post', '3', {
          id: '3',
          title: 'All about cheese.',
          contents: 'I love cheese!',
        }),
      )
    })

    expect(result.current.data).not.toBe(dataBefore)
    expect(result.current.data).toEqual({
      id: '3',
      title: 'All about cheese.',
      contents: 'I love cheese!',
    })
  })

  test('does update non-existing values', async () => {
    baseQuery
      // throw an error to make sure there is no cached data
      .mockImplementationOnce(async () => {
        throw new Error('failed to load')
      })
      .mockResolvedValueOnce(42)

    // a subscriber is needed to have the data stay in the cache
    // Not sure if this is the wanted behavior, I would have liked
    // it to stay in the cache for the x amount of time the cache
    // is preserved normally after the last subscriber was unmounted
    const { result, rerender } = renderHook(
      () => api.endpoints.post.useQuery('4'),
      {
        wrapper: storeRef.wrapper,
      },
    )
    await hookWaitFor(() => expect(result.current.isError).toBeTruthy())

    // upsert the data
    act(() => {
      storeRef.store.dispatch(
        api.util.upsertQueryData('post', '4', {
          id: '4',
          title: 'All about cheese',
          contents: 'I love cheese!',
        }),
      )
    })

    // rerender the hook
    rerender()
    // wait until everything has settled
    await hookWaitFor(() => expect(result.current.isSuccess).toBeTruthy())

    // the cached data is returned as the result
    expect(result.current.data).toStrictEqual({
      id: '4',
      title: 'All about cheese',
      contents: 'I love cheese!',
    })
  })

  test('upsert while a normal query is running (success)', async () => {
    const fetchedData = {
      id: '3',
      title: 'All about cheese.',
      contents: 'Yummy',
    }
    baseQuery.mockImplementation(() => delay(20).then(() => fetchedData))
    const upsertedData = {
      id: '3',
      title: 'Data from a SSR Render',
      contents: 'This is just some random data',
    }

    const selector = api.endpoints.post.select('3')
    const fetchRes = storeRef.store.dispatch(api.endpoints.post.initiate('3'))
    const upsertRes = storeRef.store.dispatch(
      api.util.upsertQueryData('post', '3', upsertedData),
    )

    await upsertRes
    let state = selector(storeRef.store.getState())
    expect(state.data).toEqual(upsertedData)

    await fetchRes
    state = selector(storeRef.store.getState())
    expect(state.data).toEqual(fetchedData)
  })
  test('upsert while a normal query is running (rejected)', async () => {
    baseQuery.mockImplementationOnce(async () => {
      await delay(20)
      // eslint-disable-next-line no-throw-literal
      throw 'Error!'
    })
    const upsertedData = {
      id: '3',
      title: 'Data from a SSR Render',
      contents: 'This is just some random data',
    }

    const selector = api.endpoints.post.select('3')
    const fetchRes = storeRef.store.dispatch(api.endpoints.post.initiate('3'))
    const upsertRes = storeRef.store.dispatch(
      api.util.upsertQueryData('post', '3', upsertedData),
    )

    await upsertRes
    let state = selector(storeRef.store.getState())
    expect(state.data).toEqual(upsertedData)
    expect(state.isSuccess).toBeTruthy()

    await fetchRes
    state = selector(storeRef.store.getState())
    expect(state.data).toEqual(upsertedData)
    expect(state.isError).toBeTruthy()
  })
})

describe('upsertQueryEntries', () => {
  const posts: Post[] = [
    {
      id: '1',
      contents: 'A',
      title: 'A',
    },
    {
      id: '2',
      contents: 'B',
      title: 'B',
    },
    {
      id: '3',
      contents: 'C',
      title: 'C',
    },
  ]

  const entriesAction = api.util.upsertQueryEntries([
    {
      endpointName: 'getPosts',
      arg: undefined,
      value: posts,
    },
    ...posts.map((post) => ({
      endpointName: 'postWithSideEffect' as const,
      arg: post.id,
      value: post,
    })),
  ])

  test('Upserts many entries at once', async () => {
    storeRef.store.dispatch(entriesAction)

    const state = storeRef.store.getState()

    expect(api.endpoints.getPosts.select()(state).data).toBe(posts)

    expect(api.endpoints.postWithSideEffect.select('1')(state).data).toBe(
      posts[0],
    )
    expect(api.endpoints.postWithSideEffect.select('2')(state).data).toBe(
      posts[1],
    )
    expect(api.endpoints.postWithSideEffect.select('3')(state).data).toBe(
      posts[2],
    )
  })

  test('Triggers cache lifecycles and side effects', async () => {
    storeRef.store.dispatch(entriesAction)

    // Tricky timing. The cache data promises will be resolved
    // in microtasks. We need to wait for them. Best to do this
    // in a loop just to avoid a hardcoded delay, but also this
    // needs to complete before `keepUnusedDataFor` expires them.
    await waitFor(
      () => {
        const state = storeRef.store.getState()

        // onCacheEntryAdded should have run for each post,
        // including cache data being resolved
        for (const post of posts) {
          const matchingSideEffectAction = state.actions.find(
            (action) =>
              postAddedAction.match(action) && action.payload === post.id,
          )
          expect(matchingSideEffectAction).toBeTruthy()
        }

        const selectedData =
          api.endpoints.postWithSideEffect.select('1')(state).data

        expect(selectedData).toBe(posts[0])
      },
      { timeout: 50, interval: 5 },
    )

    // The cache data should be removed after the keepUnusedDataFor time,
    // so wait longer than that
    await delay(100)

    const stateAfter = storeRef.store.getState()

    expect(api.endpoints.postWithSideEffect.select('1')(stateAfter).data).toBe(
      undefined,
    )
  })
})

describe('full integration', () => {
  test('success case', async () => {
    baseQuery
      .mockResolvedValueOnce({
        id: '3',
        title: 'All about cheese.',
        contents: 'TODO',
      })
      .mockResolvedValueOnce({
        id: '3',
        title: 'Meanwhile, this changed server-side.',
        contents: 'Delicious cheese!',
      })
      .mockResolvedValueOnce({
        id: '3',
        title: 'Meanwhile, this changed server-side.',
        contents: 'Delicious cheese!',
      })
      .mockResolvedValueOnce(42)
    const { result } = renderHook(
      () => ({
        query: api.endpoints.post.useQuery('3'),
        mutation: api.endpoints.updatePost.useMutation(),
      }),
      {
        wrapper: storeRef.wrapper,
      },
    )
    await hookWaitFor(() => expect(result.current.query.isSuccess).toBeTruthy())

    expect(result.current.query.data).toEqual({
      id: '3',
      title: 'All about cheese.',
      contents: 'TODO',
    })

    await act(async () => {
      await result.current.mutation[0]({
        id: '3',
        contents: 'Delicious cheese!',
      })
    })

    expect(result.current.query.data).toEqual({
      id: '3',
      title: 'Meanwhile, this changed server-side.',
      contents: 'Delicious cheese!',
    })

    await hookWaitFor(() =>
      expect(result.current.query.data).toEqual({
        id: '3',
        title: 'Meanwhile, this changed server-side.',
        contents: 'Delicious cheese!',
      }),
    )
  })

  test('error case', async () => {
    baseQuery
      .mockResolvedValueOnce({
        id: '3',
        title: 'All about cheese.',
        contents: 'TODO',
      })
      .mockRejectedValueOnce('some error!')
      .mockResolvedValueOnce({
        id: '3',
        title: 'Meanwhile, this changed server-side.',
        contents: 'TODO',
      })
      .mockResolvedValueOnce(42)

    const { result } = renderHook(
      () => ({
        query: api.endpoints.post.useQuery('3'),
        mutation: api.endpoints.updatePost.useMutation(),
      }),
      {
        wrapper: storeRef.wrapper,
      },
    )
    await hookWaitFor(() => expect(result.current.query.isSuccess).toBeTruthy())

    expect(result.current.query.data).toEqual({
      id: '3',
      title: 'All about cheese.',
      contents: 'TODO',
    })

    await act(async () => {
      await result.current.mutation[0]({
        id: '3',
        contents: 'Delicious cheese!',
      })
    })

    // optimistic update
    expect(result.current.query.data).toEqual({
      id: '3',
      title: 'All about cheese.',
      contents: 'Delicious cheese!',
    })

    // mutation failed - will not invalidate query and not refetch data from the server
    await expect(() =>
      hookWaitFor(
        () =>
          expect(result.current.query.data).toEqual({
            id: '3',
            title: 'Meanwhile, this changed server-side.',
            contents: 'TODO',
          }),
        50,
      ),
    ).rejects.toBeTruthy()

    act(() => void result.current.query.refetch())

    // manually refetching gives up-to-date data
    await hookWaitFor(
      () =>
        expect(result.current.query.data).toEqual({
          id: '3',
          title: 'Meanwhile, this changed server-side.',
          contents: 'TODO',
        }),
      50,
    )
  })

  test('Interop with in-flight requests', async () => {
    await act(async () => {
      const fetchRes = storeRef.store.dispatch(
        api.endpoints.post2.initiate('3'),
      )

      const upsertRes = storeRef.store.dispatch(
        api.util.upsertQueryData('post2', '3', {
          id: '3',
          title: 'Upserted title',
          contents: 'Upserted contents',
        }),
      )

      const selectEntry = api.endpoints.post2.select('3')
      await waitFor(
        () => {
          const entry1 = selectEntry(storeRef.store.getState())
          expect(entry1.data).toEqual({
            id: '3',
            title: 'Upserted title',
            contents: 'Upserted contents',
          })
        },
        { interval: 1, timeout: 15 },
      )
      await waitFor(
        () => {
          const entry2 = selectEntry(storeRef.store.getState())
          expect(entry2.data).toEqual({
            id: '3',
            title: 'All about cheese.',
            contents: 'TODO',
          })
        },
        { interval: 1 },
      )
    })
  })
})
