'use client';

import {
  InfiniteData,
  QueryClient,
  QueryKey,
  QueryObserverOptions,
  useInfiniteQuery,
  useMutation,
  useQuery,
  useQueryClient,
} from '@tanstack/react-query';
import { WritableDraft, produce } from 'immer';
import { useCallback, useEffect, useMemo, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { DeepCamelKeys, deepCamelKeys } from 'string-ts';

import { useDialogModal } from '@/components/modal/DialogModal';
import { toast } from '@/components/toast/Toast';
import usePageUnload from '@/hooks/usePageUnload';
import { ApiResponse, useApiClient } from '@/lib/apiClient';
import { components } from '@/lib/gen';
import { ClientEntity } from '@/lib/typeUtils';

import {
  DEFAULT_FEED_PAGE_SIZE,
  HookStaffReviewStatus,
  HooksFeedType,
  VIEW_TRACKER_BATCH_SIZE,
  VIEW_TRACKER_FLUSH_DEBOUNCE_MS,
} from './constants';

export type HookItemResponse = DeepCamelKeys<
  ApiResponse<'GET', '/api/video/hooks/{hook_id}'>
>;
export type HooksRecommendationsResponse = DeepCamelKeys<
  ApiResponse<'POST', '/api/video/hooks/feed'>
>;
export type HooksProfileResponse = DeepCamelKeys<
  ApiResponse<'GET', '/api/video/hooks/user_hooks'>
>;
export type HooksLikedResponse = DeepCamelKeys<
  ApiResponse<'GET', '/api/video/hooks/me/liked/v2'>
>;
export type HooksModerationResponse = DeepCamelKeys<
  ApiResponse<'POST', '/api/video/hooks/feed/admin'>
>;

export type HooksFeedResponseMap = {
  [HooksFeedType.Recommendations]: HooksRecommendationsResponse;
  [HooksFeedType.Profile]: HooksProfileResponse;
  [HooksFeedType.Liked]: HooksLikedResponse;
  [HooksFeedType.Moderation]: HooksModerationResponse;
};

export type HooksFeedResponse<T extends HooksFeedType> =
  HooksFeedResponseMap[T];

/**
 * This is the backend response schema for a video hook.
 *
 * If you are trying to type client code, make sure to processed the response
 * to convert to camelCase and use `VideoHookEntity` instead.
 */
export type VideoHook = components['schemas']['VideoHookSchema'];

export type VideoHookEntity = ClientEntity<VideoHook>;

export type VideoHookQueryKey = {
  scope: 'hooks';
  entity?: 'hook' | 'feed';
  hookId?: string;
  feedId?: HooksFeedType;
  userHandle?: string;
  pageSize?: number;
};

export const videoHooksKeys = {
  all: [{ scope: 'hooks' }] as const,
  hook: ({ hookId }: { hookId: string }) =>
    [{ ...videoHooksKeys.all[0], entity: 'hook', hookId }] as const,
  feed: ({
    feedId,
    hookId,
    userHandle,
    pageSize,
  }: {
    feedId?: HooksFeedType;
    hookId?: string;
    userHandle?: string;
    pageSize?: number;
  }) =>
    [
      {
        ...videoHooksKeys.all[0],
        entity: 'feed',
        hookId,
        feedId,
        userHandle,
        pageSize,
      },
    ] as const,
  likedLibrary: ({ pageSize }: { pageSize?: number }) =>
    [{ ...videoHooksKeys.all[0], entity: 'likedLibrary', pageSize }] as const,
};

export type UseVideoHookActionsOptions<
  T extends HooksFeedType = HooksFeedType.Recommendations,
> = {
  enabled?: boolean;
  feedId?: T;
  hookId?: string;
  userHandle?: string;
  pageSize?: number;
};

/**
 * Search the cache data for a specific hook and update it according to the
 * given mutation function
 */
function updateHooksFeedData<
  T extends HooksFeedType = HooksFeedType.Recommendations,
>(options: {
  queryClient: QueryClient;
  queryKey: QueryKey;
  hookId?: string | null;
  mutationFnHook?: (
    hook: WritableDraft<VideoHookEntity>
  ) => boolean | null | void;
  mutationFn?: (
    data: WritableDraft<InfiniteData<HooksFeedResponse<T>>>,
    hasEdit?: boolean | null
  ) => void;
}) {
  const { queryClient, queryKey, hookId, mutationFnHook, mutationFn } = options;

  const prevData =
    queryClient.getQueryData<InfiniteData<HooksFeedResponse<T>>>(queryKey);
  queryClient.setQueryData<InfiniteData<HooksFeedResponse<T>>>(
    queryKey,
    (prevData) => {
      if (!prevData?.pages) return prevData;
      return produce(prevData, (nextData) => {
        let hasEdit: boolean | null = null;
        // If we have a mutation function for an individual hook, run through everything
        if (mutationFnHook) {
          hasEdit = false;
          for (const page of nextData.pages) {
            for (const hook of page.items) {
              // If we have a specific hook ID, only check that hook
              if (hookId == null || hook.id === hookId) {
                const hookWasEdited = mutationFnHook?.(hook);
                // Assume hook was edited unless the mutation function returns false
                if (hookWasEdited ?? true) {
                  hasEdit = true;
                }
              }
            }
          }
        }
        // Run the mutation function for the entire feed
        mutationFn?.(nextData, hasEdit);
      });
    }
  );

  return prevData;
}

export function useVideoHookActions<
  T extends HooksFeedType = HooksFeedType.Recommendations,
>(options?: UseVideoHookActionsOptions<T>) {
  const {
    feedId = HooksFeedType.Recommendations as T,
    hookId,
    userHandle = '',
    pageSize = DEFAULT_FEED_PAGE_SIZE,
  } = options || {};

  const { t } = useTranslation();

  const apiClient = useApiClient();
  const queryClient = useQueryClient();
  const queryKey = videoHooksKeys.feed({
    feedId,
    hookId,
    userHandle,
    pageSize,
  });

  const hookReactionMutation = useMutation({
    mutationFn: async ({
      hookId,
      isLike = true,
      isNotInterested,
      recommendationItemId,
    }: {
      hookId: string;
      isLike?: boolean;
      isNotInterested?: boolean;
      recommendationItemId?: string;
    }) => {
      const response = await apiClient.POST(
        '/api/video/hooks/{hook_id}/reaction',
        {
          params: {
            path: {
              hook_id: hookId,
            },
          },
          body: {
            action:
              typeof isNotInterested === 'boolean'
                ? isNotInterested
                  ? 'dislike'
                  : 'undislike'
                : isLike
                  ? 'like'
                  : 'unlike',
            recommendation_metadata: {
              recommendation_item_id: recommendationItemId,
            },
          },
        }
      );
      return deepCamelKeys(response.data);
    },
    onMutate: async ({ hookId, isLike, isNotInterested }) => {
      const updateHookData = (hook: WritableDraft<VideoHookEntity>) => {
        const wasLiked = hook.currentUserLiked;
        hook.currentUserLiked = isLike === true;
        hook.currentUserDisliked = isNotInterested === true;
        // If the current user like state changed, adjust the count
        if (wasLiked !== hook.currentUserLiked) {
          hook.likeCount += isLike ? 1 : -1;
        }
      };

      // Update matching hook in the feed
      const prevDataFeed = updateHooksFeedData({
        queryClient,
        queryKey,
        hookId,
        mutationFnHook: updateHookData,
      });

      // Update the individual hook
      const queryKeyItem = videoHooksKeys.hook({ hookId });
      const prevDataItem =
        queryClient.getQueryData<HookItemResponse>(queryKeyItem);
      queryClient.setQueryData<HookItemResponse>(
        queryKeyItem,
        (prevData) => prevData && produce(prevData, updateHookData)
      );

      return { prevDataItem, prevDataFeed };
    },
    onSuccess: async (response) => {
      if (response?.success === false)
        throw new Error('Hook reaction reaponse was not successful');

      if (response?.hookId) {
        // Update with the information from the server
        const updateHookData = (hook: WritableDraft<VideoHookEntity>) => {
          hook.currentUserLiked = response.currentUserLiked;
          hook.currentUserDisliked = response.currentUserDisliked;
          hook.likeCount = response.likeCount;
        };

        // Update matching hook in the feed
        updateHooksFeedData({
          queryClient,
          queryKey,
          hookId: response.hookId,
          mutationFnHook: updateHookData,
        });

        // Update the individual hook
        const queryKeyItem = videoHooksKeys.hook({ hookId: response.hookId });
        queryClient.setQueryData<HookItemResponse>(
          queryKeyItem,
          (prevData) => prevData && produce(prevData, updateHookData)
        );
      }
    },
    onError: (_err, { hookId }, context) => {
      if (context?.prevDataItem) {
        queryClient.setQueryData(
          videoHooksKeys.hook({ hookId }),
          context.prevDataItem
        );
      }
      if (context?.prevDataFeed) {
        queryClient.setQueryData(queryKey, context.prevDataFeed);
      }
      toast({
        title: t('hooks.reactionError'),
        duration: 2000,
        isClosable: true,
      });
    },
  });

  const hookShareMutation = useMutation({
    mutationFn: async ({
      hookId,
      recommendationItemId,
    }: {
      hookId: string;
      recommendationItemId?: string;
    }) => {
      const response = await apiClient.POST(
        '/api/video/hooks/{hook_id}/share',
        {
          params: {
            path: {
              hook_id: hookId,
            },
          },
          body: {
            recommendation_metadata: {
              recommendation_item_id: recommendationItemId,
            },
          },
        }
      );
      return deepCamelKeys(response.data);
    },
  });

  const hookHideCreatorMutation = useMutation({
    mutationFn: async ({
      handle,
      unhide,
      recommendationItemId,
    }: {
      handle: string;
      unhide?: boolean;
      recommendationItemId?: string;
    }) => {
      const response = await apiClient.POST('/api/recommend/hide-creator', {
        body: {
          content_type: 'HOOK',
          user_handle: handle,
          unhide,
          recommendation_metadata: {
            recommendation_item_id: recommendationItemId,
          },
        },
      });
      return deepCamelKeys(response.data);
    },
    onSuccess: async (response, { handle, unhide }) => {
      if (response?.success === false)
        throw new Error('Hide creator response was not successful');
      toast({
        title:
          unhide !== true
            ? t('hooks.hideCreatorSuccess', {
                name: handle,
                fallback:
                  "Okay, we'll stop recommending content from this creator",
              })
            : t('hooks.unhideCreatorSuccess', {
                name: handle,
                fallback: "We'll allow content from this creator",
              }),
        status: 'info',
        duration: 2000,
        isClosable: true,
      });
    },
    onError: (_err) => {
      toast({
        title: t('hooks.hideCreatorError', 'Failed to hide creator'),
        status: 'error',
        duration: 2000,
        isClosable: true,
      });
    },
  });

  const hookReportMutation = useMutation({
    mutationFn: async ({
      hookId,
      recommendationItemId,
    }: {
      hookId: string;
      recommendationItemId?: string;
    }) => {
      const response = await apiClient.POST(
        '/api/video/hooks/{hook_id}/report',
        {
          params: {
            path: {
              hook_id: hookId,
            },
          },
          body: {
            recommendation_metadata: {
              recommendation_item_id: recommendationItemId,
            },
          },
        }
      );
      return deepCamelKeys(response.data);
    },
    onSuccess: async (response) => {
      if (response?.success === false)
        throw new Error('Hook report response was not successful');
      toast({
        title: t('hooks.reportSuccess', 'Hook reported successfully'),
        status: 'info',
        duration: 2000,
        isClosable: true,
      });
    },
    onError: (_err) => {
      toast({
        title: t('hooks.reportError', 'Failed to report hook'),
        status: 'error',
        duration: 2000,
        isClosable: true,
      });
    },
  });

  const hookDownloadMutation = useMutation({
    mutationFn: async ({
      hookId,
      forceUpdate,
    }: {
      hookId: string;
      forceUpdate?: boolean;
    }) => {
      const response = await apiClient.GET(
        '/api/video/hooks/{hook_id}/download',
        {
          params: {
            path: {
              hook_id: hookId,
            },
            query: forceUpdate ? { force_update: forceUpdate } : undefined,
          },
        }
      );
      return deepCamelKeys(response.data);
    },
    onSuccess: async (response) => {
      if (response?.status === 'error')
        throw new Error('Hook download failed on server');

      if (response?.status === 'processing') {
        toast({
          title: t(
            'hooks.downloadProcessing',
            'Video is being processed. Please try again later.'
          ),
          status: 'info',
          duration: 3000,
          isClosable: true,
        });
        return;
      }

      if (response?.status === 'ready' && response?.downloadUrl) {
        // download file
        const link = document.createElement('a');
        link.href = response.downloadUrl;
        link.download = ''; // use filename from server
        document.body.appendChild(link);
        link.click();
        document.body.removeChild(link);

        toast({
          title: t('hooks.downloadSuccess', 'Download started successfully'),
          status: 'success',
          duration: 2000,
          isClosable: true,
        });
      }
    },
    onError: (_err) => {
      toast({
        title: t('hooks.downloadError', 'Failed to download hook'),
        status: 'error',
        duration: 2000,
        isClosable: true,
      });
    },
  });

  const hookFlagMutation = useMutation({
    mutationFn: async ({
      hookId,
      staffReviewStatus,
    }: {
      hookId: string;
      staffReviewStatus?: HookStaffReviewStatus;
    }) => {
      const response = await apiClient.POST('/api/video/hooks/{hook_id}/flag', {
        params: {
          path: {
            hook_id: hookId,
          },
        },
        body: {
          staff_review_status:
            staffReviewStatus ?? HookStaffReviewStatus.Flagged,
        },
      });
      return deepCamelKeys(response.data);
    },
    onSuccess: async (response, { staffReviewStatus, hookId }) => {
      if (response?.success === false)
        throw new Error('Hook flag response was not successful');
      toast({
        title:
          staffReviewStatus === HookStaffReviewStatus.Flagged
            ? `Hook flagged: ${hookId}`
            : staffReviewStatus === HookStaffReviewStatus.Approved
              ? `Hook approved: ${hookId}`
              : `Previous hook unflagged: ${hookId} `,
        status: 'info',
        duration: 2000,
        isClosable: true,
      });
    },
    onError: (_err) => {
      toast({
        title: 'Failed to flag hook',
        status: 'error',
        duration: 2000,
        isClosable: true,
      });
    },
  });

  const hookDeleteMutation = useMutation({
    mutationFn: async ({ hookId }: { hookId: string }) => {
      const response = await apiClient.POST(
        '/api/video/hooks/delete/{hook_id}',
        {
          params: {
            path: {
              hook_id: hookId,
            },
          },
        }
      );
      return deepCamelKeys(response.data);
    },
    onMutate: async ({ hookId }) => {
      const prevData =
        queryClient.getQueryData<InfiniteData<HooksFeedResponse<T>>>(queryKey);
      if (hookId) {
        queryClient.setQueryData<InfiniteData<HooksFeedResponse<T>>>(
          queryKey,
          (prevData) => {
            if (!prevData?.pages) return prevData;
            return produce(prevData, (nextData) => {
              for (const page of nextData.pages) {
                for (let i = page.items.length - 1; i >= 0; i--) {
                  const hook = page.items[i];
                  if (hook.id === hookId) {
                    page.items.splice(i, 1);
                  }
                }
              }
            });
          }
        );
      }
      return { prevData };
    },
    onSuccess: async (response, { hookId }) => {
      if (response?.success === false)
        throw new Error('Delete response was not successful');
      toast({
        title: 'Hook deleted',
        status: 'info',
        duration: 5000,
        isClosable: true,
      });
      await queryClient.invalidateQueries({
        predicate: (query) => {
          const [queryKeyParams] =
            (query.queryKey as [VideoHookQueryKey]) ?? videoHooksKeys.all;
          return queryKeyParams.hookId === hookId;
        },
        queryKey,
        type: 'active',
      });
    },
    onError: (_err, _variables, context) => {
      if (context?.prevData) {
        queryClient.setQueryData(queryKey, context.prevData);
      }
      toast({
        title: t('hooks.deleteError'),
        status: 'error',
        duration: 5000,
        isClosable: true,
      });
    },
  });

  const hookToggleTestMutation = useMutation({
    mutationFn: async ({
      hookId,
      isTest,
    }: {
      hookId: string;
      isTest: boolean;
    }) => {
      const response = await apiClient.POST(
        '/api/video/hooks/{hook_id}/toggle_test',
        {
          params: {
            path: {
              hook_id: hookId,
            },
          },
          body: {
            is_test: isTest,
          },
        }
      );
      return deepCamelKeys(response.data);
    },
    onMutate: async ({ hookId, isTest }) => {
      // Update the individual hook
      const queryKeyItem = videoHooksKeys.hook({ hookId });
      const prevDataItem =
        queryClient.getQueryData<HookItemResponse>(queryKeyItem);
      const prevDataFeed =
        queryClient.getQueryData<InfiniteData<HooksFeedResponse<T>>>(queryKey);
      if (hookId) {
        queryClient.setQueryData<HookItemResponse>(queryKeyItem, (prevData) => {
          return (
            prevData &&
            produce(prevData, (nextData) => {
              nextData.isTest = isTest;
            })
          );
        });
        queryClient.setQueryData<InfiniteData<HooksFeedResponse<T>>>(
          queryKey,
          (prevData) => {
            if (!prevData?.pages) return prevData;
            return produce(prevData, (nextData) => {
              for (const page of nextData.pages) {
                for (let i = page.items.length - 1; i >= 0; i--) {
                  const hook = page.items[i];
                  if (hook.id === hookId) {
                    hook.isTest = isTest;
                  }
                }
              }
            });
          }
        );
      }
      return { prevDataItem, prevDataFeed };
    },
    onSuccess: async (response, { isTest }) => {
      if (response?.success === false)
        throw new Error('Hook toggle test response was not successful');
      toast({
        title: isTest ? 'Hook marked as test' : 'Hook unmarked as test',
        status: 'info',
        duration: 2000,
        isClosable: true,
      });
    },
    onError: (_err, { hookId, isTest }, context) => {
      if (context?.prevDataItem) {
        queryClient.setQueryData(
          videoHooksKeys.hook({ hookId }),
          context.prevDataItem
        );
      }
      if (context?.prevDataFeed) {
        queryClient.setQueryData(queryKey, context.prevDataFeed);
      }
      toast({
        title: `Failed to ${isTest ? 'mark' : 'unmark'} hook as test`,
        status: 'error',
        duration: 2000,
        isClosable: true,
      });
    },
  });

  const hookQualityLabelMutation = useMutation({
    mutationFn: async ({
      hookId,
      label,
      hasLabel,
    }: {
      hookId: string;
      label: string;
      hasLabel: boolean;
    }) => {
      const endpoint = hasLabel
        ? '/api/video/hooks/admin/{hook_id}/remove_quality_label'
        : '/api/video/hooks/admin/{hook_id}/add_quality_label';

      const response = await apiClient.POST(endpoint, {
        params: {
          path: {
            hook_id: hookId,
          },
        },
        body: {
          label,
        },
      });
      return deepCamelKeys(response.data);
    },
    onMutate: async ({ hookId, label, hasLabel }) => {
      // Update the individual hook
      const queryKeyItem = videoHooksKeys.hook({ hookId });
      const prevDataItem =
        queryClient.getQueryData<HookItemResponse>(queryKeyItem);
      const prevDataFeed =
        queryClient.getQueryData<InfiniteData<HooksFeedResponse<T>>>(queryKey);
      if (hookId) {
        queryClient.setQueryData<HookItemResponse>(queryKeyItem, (prevData) => {
          return (
            prevData &&
            produce(prevData, (nextData) => {
              nextData.humanRating = hasLabel ? null : label;
            })
          );
        });
        queryClient.setQueryData<InfiniteData<HooksFeedResponse<T>>>(
          queryKey,
          (prevData) => {
            if (!prevData?.pages) return prevData;
            return produce(prevData, (nextData) => {
              for (const page of nextData.pages) {
                for (let i = page.items.length - 1; i >= 0; i--) {
                  const hook = page.items[i];
                  if (hook.id === hookId) {
                    hook.humanRating = hasLabel ? null : label;
                  }
                }
              }
            });
          }
        );
      }
      return { prevDataItem, prevDataFeed };
    },
    onSuccess: async (response, { label, hasLabel }) => {
      if (response?.success === false)
        throw new Error('Hook quality label response was not successful');
      toast({
        title: hasLabel
          ? `Hook removed from ${label}`
          : `Hook marked as ${label}`,
        status: 'info',
        duration: 2000,
        isClosable: true,
      });
    },
    onError: (_err, { hookId, label, hasLabel }, context) => {
      if (context?.prevDataItem) {
        queryClient.setQueryData(
          videoHooksKeys.hook({ hookId }),
          context.prevDataItem
        );
      }
      if (context?.prevDataFeed) {
        queryClient.setQueryData(queryKey, context.prevDataFeed);
      }
      toast({
        title: `Failed to ${hasLabel ? 'remove' : 'mark'} hook as ${label}`,
        status: 'error',
        duration: 2000,
        isClosable: true,
      });
    },
  });

  const hookResetModerationMutation = useMutation({
    mutationFn: async ({ hookId }: { hookId: string }) => {
      const response = await apiClient.POST(
        '/api/video/hooks/admin/reset_moderation',
        {
          body: {
            hook_id: hookId,
          },
        }
      );
      return deepCamelKeys(response.data);
    },
    onSuccess: async (response) => {
      if (response?.success === false)
        throw new Error('Hook reset moderation response was not successful');
      toast({
        title: 'Hook moderation reset successfully',
        status: 'info',
        duration: 2000,
        isClosable: true,
      });
    },
    onError: (_err) => {
      toast({
        title: 'Failed to reset hook moderation',
        status: 'error',
        duration: 2000,
        isClosable: true,
      });
    },
  });

  const hookReprocessMutation = useMutation({
    mutationFn: async ({ hookId }: { hookId: string }) => {
      const response = await apiClient.POST(
        '/api/video/hooks/admin/reprocess-hook',
        {
          body: {
            hook_id: hookId,
          },
        }
      );
      return deepCamelKeys(response.data);
    },
    onSuccess: async (response) => {
      if (response?.success === false)
        throw new Error('Hook reprocess response was not successful');
      toast({
        title: 'Hook reprocess started successfully',
        status: 'info',
        duration: 2000,
        isClosable: true,
      });
    },
    onError: (_err) => {
      toast({
        title: 'Failed to reprocess hook',
        status: 'error',
        duration: 2000,
        isClosable: true,
      });
    },
  });

  return useMemo(
    () => ({
      reactionMutation: hookReactionMutation,
      shareMutation: hookShareMutation,
      hideCreatorMutation: hookHideCreatorMutation,
      reportMutation: hookReportMutation,
      downloadMutation: hookDownloadMutation,
      flagMutation: hookFlagMutation,
      deleteMutation: hookDeleteMutation,
      toggleTestMutation: hookToggleTestMutation,
      qualityLabelMutation: hookQualityLabelMutation,
      resetModerationMutation: hookResetModerationMutation,
      reprocessMutation: hookReprocessMutation,
    }),
    [
      hookReactionMutation,
      hookShareMutation,
      hookHideCreatorMutation,
      hookReportMutation,
      hookDownloadMutation,
      hookFlagMutation,
      hookDeleteMutation,
      hookToggleTestMutation,
      hookQualityLabelMutation,
      hookResetModerationMutation,
      hookReprocessMutation,
    ]
  );
}

export type UseVideoHookOptions = Pick<
  QueryObserverOptions<any>,
  'enabled' | 'refetchOnMount'
>;

/**
 * Fetches a single hook by its ID
 */
export function useVideoHook(hookId: string, options?: UseVideoHookOptions) {
  const { enabled, refetchOnMount } = options || {};

  const apiClient = useApiClient();
  const queryKey = videoHooksKeys.hook({ hookId });

  const hookQuery = useQuery({
    enabled,
    refetchOnMount,
    queryKey,
    queryFn: async () => {
      const response = await apiClient.GET('/api/video/hooks/{hook_id}', {
        params: {
          path: {
            hook_id: hookId,
          },
        },
      });
      return deepCamelKeys(response.data);
    },
  });

  return useMemo(
    () => ({
      hook: hookQuery.data,
      updatedTime: new Date(hookQuery.dataUpdatedAt).toUTCString(),

      query: hookQuery,
    }),
    [hookQuery]
  );
}

export type UseVideoHooksContextualFeedOptions<
  T extends HooksFeedType = HooksFeedType.Recommendations,
> = {
  staleTime?: number;
  feedId?: T;
  hookId?: string;
  userHandle?: string;
  pageSize?: number;
} & Pick<QueryObserverOptions<any>, 'enabled' | 'refetchOnMount'>;

/**
 * Fetches a hooks from a paginated feed with some context
 */
export function useVideoHooksContextualFeed<
  T extends HooksFeedType = HooksFeedType.Recommendations,
>(options?: UseVideoHooksContextualFeedOptions<T>) {
  const {
    enabled,
    refetchOnMount,
    feedId = HooksFeedType.Recommendations as T,
    hookId,
    userHandle = '',
    pageSize = DEFAULT_FEED_PAGE_SIZE,
    staleTime = 15 * 60000,
  } = options || {};

  const { t } = useTranslation();
  const { launchDialog } = useDialogModal();

  const apiClient = useApiClient();
  const queryClient = useQueryClient();

  const keyOptions = {
    feedId,
    hookId,
    userHandle,
    pageSize,
  };
  const queryKey = videoHooksKeys.feed(keyOptions);

  const hooksFeedQuery = useInfiniteQuery({
    enabled,
    refetchOnMount,
    queryKey,
    queryFn: async ({ pageParam }) => {
      switch (feedId) {
        case HooksFeedType.Moderation: {
          const response = await apiClient.POST('/api/video/hooks/feed/admin', {
            body: {
              start_index: pageParam,
              page_size: pageSize,
            },
          });
          return deepCamelKeys(response.data);
        }
        case HooksFeedType.Profile: {
          const response = await apiClient.GET('/api/video/hooks/user_hooks', {
            params: {
              query: {
                start_index: pageParam,
                page_size: pageSize,
                user_handle: userHandle,
              },
            },
          });
          return deepCamelKeys(response.data);
        }
        case HooksFeedType.Liked: {
          const response = await apiClient.GET('/api/video/hooks/me/liked/v2', {
            params: {
              query: {
                start_index: pageParam,
                page_size: pageSize,
                user_handle: userHandle,
              },
            },
          });
          return deepCamelKeys(response.data);
        }
        default:
        case HooksFeedType.Recommendations: {
          const response = await apiClient.POST('/api/video/hooks/feed', {
            body: {
              start_index: pageParam,
              page_size: pageSize,
            },
          });
          const data = deepCamelKeys(response.data);
          /**
           * Filter hooks to dedupe any were on the most recent page
           *
           * We do this because the backend does not know which hooks are already
           * queued up but have not been viewed yet if we fetch before the user
           * hits the very bottom of the feed.
           */
          const prevData =
            queryClient.getQueryData<InfiniteData<HooksFeedResponse<T>>>(
              queryKey
            );
          const lastPage = prevData?.pages[prevData.pages.length - 1];
          if (data?.items && lastPage?.items) {
            const hookIds = new Set(lastPage.items.map((item) => item.id));
            data.items = data.items.filter((item) => !hookIds.has(item.id));
          }
          return data;
        }
      }
    },
    initialPageParam: 0,
    getNextPageParam: (srcLastPage, _pages, lastPageParam) => {
      // Assume we're at the end of the last page was empty
      if (!srcLastPage?.items?.length) return undefined;
      // Assert the response type
      const lastPage =
        feedId === HooksFeedType.Profile
          ? (srcLastPage as HooksProfileResponse)
          : (srcLastPage as HooksFeedResponse<T>);
      // Assume no next page if the last page was not full
      // if (lastPage.items.length < pageSize) return undefined;
      // If the response tells use `startIndex` and `pageSize`, use them.
      // Otherwise, use `lastPageParam` to keep track.
      return 'startIndex' in lastPage
        ? lastPage.startIndex + lastPage.pageSize
        : lastPageParam + pageSize;
    },
    select(data) {
      return data.pages.flatMap((data) => data?.items).filter((v) => v != null);
    },
    staleTime,
  });

  const hookActions = useVideoHookActions(keyOptions);

  const { mutateAsync: hookDeleteMutateAsync } = hookActions.deleteMutation;
  const handleDeleteAction = useCallback(
    async (payload: { id: string }, confirmAction = true) => {
      const action = confirmAction
        ? await launchDialog<boolean>(t('hooks.confirmDelete'), [
            { label: t('cta.confirm'), action: true },
            { label: t('cta.cancel'), action: false },
          ])
        : true;
      if (action === true) {
        return await hookDeleteMutateAsync({
          hookId: payload.id,
        });
      }
    },
    [t, launchDialog, hookDeleteMutateAsync]
  );

  const { mutateAsync: hookDownloadMutateAsync } = hookActions.downloadMutation;
  const handleDownloadAction = useCallback(
    async (payload: { id: string }) =>
      hookDownloadMutateAsync({
        hookId: payload.id,
      }),
    [hookDownloadMutateAsync]
  );

  return useMemo(
    () => ({
      hooks: hooksFeedQuery.data || [],
      updatedTime: new Date(hooksFeedQuery.dataUpdatedAt).toUTCString(),

      query: hooksFeedQuery,
      ...hookActions,
      onDeleteAction: handleDeleteAction,
      onDownloadAction: handleDownloadAction,
    }),
    [hooksFeedQuery, hookActions, handleDeleteAction, handleDownloadAction]
  );
}

export type UseVideoHookViewTrackerOptions = {
  /**
   * Flush the view counts when we hit a certain number of pending views
   */
  countBatchSize?: number;
  /**
   * Flush the view counts on a rolling basis
   */
  flushDebounceMs?: number;
};

export function useVideoHookViewTracker(
  options?: UseVideoHookViewTrackerOptions
) {
  const {
    countBatchSize = VIEW_TRACKER_BATCH_SIZE,
    flushDebounceMs = VIEW_TRACKER_FLUSH_DEBOUNCE_MS,
  } = options || {};

  const apiClient = useApiClient();

  const stateRef = useRef<{
    lastViewId?: string;
    viewCountsById: Record<string, number>;
    pendingViews: number;
    flushTimeout: NodeJS.Timeout | undefined;
  }>({
    lastViewId: undefined,
    viewCountsById: {},
    pendingViews: 0,
    flushTimeout: undefined,
  });

  const hookViewMutation = useMutation({
    mutationFn: async ({
      viewCountsById,
    }: {
      viewCountsById: Record<string, number>;
    }) => {
      const { data } = await apiClient.POST('/api/video/hooks/watched', {
        body: {
          hook_ids_to_times_listened: viewCountsById,
        },
      });
      if (!data?.success) {
        throw new Error();
      }
      return deepCamelKeys(data);
    },
    onMutate: () => {
      // Optimistically reset the view counts...
      const { viewCountsById, pendingViews } = stateRef.current;
      stateRef.current.viewCountsById = {};
      stateRef.current.pendingViews = 0;
      // ...but hang onto previous values in case we need to revert
      return {
        viewCountsById,
        pendingViews,
      };
    },
    onError: (_err, _variables, context) => {
      // If the request failed, add the previous view counts back to the state
      // so that we can try to send them again later
      if (context) {
        Object.entries(context.viewCountsById).forEach(([key, value]) => {
          if (key in stateRef.current.viewCountsById) {
            stateRef.current.viewCountsById[key] += value;
          } else {
            stateRef.current.viewCountsById[key] = value;
          }
        });
        stateRef.current.pendingViews += context.pendingViews;
      }
    },
  });

  /**
   * Flush the view counts to the server if we have any pending
   */
  const { mutateAsync: hookViewMutateAsync } = hookViewMutation;
  const flushViewCounts = useCallback(async () => {
    clearTimeout(stateRef.current.flushTimeout);
    if (!stateRef.current.pendingViews) return;
    return await hookViewMutateAsync({
      viewCountsById: stateRef.current.viewCountsById,
    });
  }, [hookViewMutateAsync]);

  /**
   * Increment the view count on the given Hook ID
   */
  const incrementViewCount = useCallback(
    (hookId: string) => {
      // Avoid double-counting on the same Hook ID
      if (stateRef.current.lastViewId === hookId) return;
      // Increment count on Hook and overall pending count
      if (hookId in stateRef.current.viewCountsById) {
        stateRef.current.viewCountsById[hookId]++;
      } else {
        stateRef.current.viewCountsById[hookId] = 1;
      }
      stateRef.current.pendingViews++;
      stateRef.current.lastViewId = hookId;
      /**
       * When the number of pending views changes, either flush it immediately if
       * we have hit the max counr threshold, or wait for the flush interval
       */
      if (stateRef.current.pendingViews >= countBatchSize) {
        flushViewCounts();
      } else {
        clearTimeout(stateRef.current.flushTimeout);
        if (flushDebounceMs > 0) {
          stateRef.current.flushTimeout = setTimeout(() => {
            flushViewCounts();
          }, flushDebounceMs);
        }
      }
    },
    [flushViewCounts, countBatchSize, flushDebounceMs]
  );

  /**
   * Flush the view counts when the component unmounts
   */
  useEffect(() => {
    return () => {
      flushViewCounts();
    };
  }, [flushViewCounts]);

  /**
   * Flush the view counts when the page is unloaded
   */
  usePageUnload(() => {
    flushViewCounts();
  });

  return {
    incrementViewCount,
    flushViewCounts,
    hookViewMutation,
  };
}
