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

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

interface FolderT {
  id: number
  children: FolderT[]
}

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', 'Folder'],
  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: (result) => {
        if (result) {
          return [{ type: 'Post', id: result.id } as const]
        }
        return []
      },
      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,
    }),
    getFolder: build.query<FolderT, number>({
      queryFn: async (args) => {
        return {
          data: {
            id: args,
            // Folder contains children that are as well folders
            children: [{ id: 2, children: [] }],
          },
        }
      },
      providesTags: (result, err, args) => [{ type: 'Folder', id: args }],
      onQueryStarted: async (args, queryApi) => {
        const { data } = await queryApi.queryFulfilled

        // Upsert getFolder endpoint with children from response data
        const upsertData = data.children.map((child) => ({
          arg: child.id,
          endpointName: 'getFolder' as const,
          value: child,
        }))

        queryApi.dispatch(api.util.upsertQueryEntries(upsertData))
      },
    }),
  }),
})

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)

    for (const post of posts) {
      expect(api.endpoints.postWithSideEffect.select(post.id)(state).data).toBe(
        post,
      )

      // Should have added tags
      expect(state.api.provided.tags.Post[post.id]).toEqual([
        `postWithSideEffect("${post.id}")`,
      ])
    }
  })

  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,
    )
  })

  test('Handles repeated upserts and async lifecycles', async () => {
    const StateForUpsertFolder = ({ folderId }: { folderId: number }) => {
      const { status } = api.useGetFolderQuery(folderId)

      return (
        <>
          <div>
            Status getFolder with ID (
            {folderId === 1 ? 'original request' : 'upserted'}) {folderId}:{' '}
            <span data-testid={`status-${folderId}`}>{status}</span>
          </div>
        </>
      )
    }

    const Folder = () => {
      const { data, isLoading, isError } = api.useGetFolderQuery(1)

      return (
        <div>
          <h1>Folders</h1>

          {isLoading && <div>Loading...</div>}

          {isError && <div>Error...</div>}

          <StateForUpsertFolder key={`state-${1}`} folderId={1} />
          <StateForUpsertFolder key={`state-${2}`} folderId={2} />
        </div>
      )
    }

    render(<Folder />, { wrapper: storeRef.wrapper })

    await waitFor(() => {
      const { actions } = storeRef.store.getState()
      // Inspection:
      // - 2 inits
      // - 2 pendings, 2 fulfilleds for the hook queries
      // - 2 upserts
      expect(actions.length).toBe(8)
      expect(
        actions.filter((a) => api.util.upsertQueryEntries.match(a)).length,
      ).toBe(2)
    })
    expect(screen.getByTestId('status-2').textContent).toBe('fulfilled')
  })
})

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 },
      )
    })
  })
})
