'use client';

import * as Ably from 'ably';
import { pick, pickBy } from 'lodash-es';
import { AppRouterInstance } from 'next/dist/shared/lib/app-router-context.shared-runtime';
import React from 'react';
import { deepCamelKeys, deepSnakeKeys } from 'string-ts';

import { workspaceCollaborationService } from '@/app/(root)/create/WorkspaceCollaborationInitializer';
import { CreateModes } from '@/app/(root)/create/v2/types';
import { invalidateWorkspaceQueries } from '@/components/clipBrowser/clipBrowserQueryClient';
import { ModalTypes } from '@/components/modal/constants/ModalTypes';
import { toast } from '@/components/toast/Toast';
import { ToastV2 } from '@/components/toast/ToastV2';
import { clipsKeys, updateClipData } from '@/hooks/useClipById';
import { getMsSinceServerReportedTime } from '@/lib/apiClient';
import { components } from '@/lib/gen';
import { ClientEntity } from '@/lib/typeUtils';
import logWebUserEvent, { TransactionLogger } from '@/logging/logWebUserEvent';
import {
  CREATE_VERSION,
  DEFAULT_PROJECT_ID,
  LEGACY_CREATE_VERSION,
  MAX_CUSTOM_PROMPT_CHARS,
  MAX_CUSTOM_PROMPT_CHARS_LONG,
  MAX_CUSTOM_PROMPT_CHARS_LONGEST,
  MAX_PINNED_SONGS_ARTIST_PROFILE,
  MAX_PINNED_SONGS_PROFILE,
  SONG_ROW_SPARKLE_ANIMATE_SECONDS,
} from '@/utils/constants';
import {
  createShareAsset,
  downloadClipWav,
  downloadMedia,
  getShareAssetStatus,
} from '@/utils/download';
import { genEndpoint } from '@/utils/dynamicConfigs';
import { isDevOrStaging } from '@/utils/environment';
import { ActionName } from '@/utils/event-names';
import {
  SubscriptionLevel,
  isBlendedCreateExperimentEnabled,
  isHybridModelAvailable,
  isPersonaFeatureEnabled,
  isProjectsFeatureEnabled,
} from '@/utils/session';
import {
  getFirstNSongsFromPlaylist,
  getRootClipIdsFromPersonas,
} from '@/utils/utils';

import {
  PLAYLIST_CLIPS_PAGE_SIZE,
  getDefaultModel,
  modelSupportsFeature,
} from '../utils/utils';
import { ControlSliderKey } from './createV2Store';
import { MenusStore } from './menusStore';
import { ProjectMetadataSchema } from './projectStore';
import { RootStore, Substore } from './rootStore';
import { CaptchaConsumer, PlanKey, SessionStore } from './sessionStore';
import { makeAutoObservableSubstore } from './utils';

export type SetMetadataPayload = {
  clipId: string;
  title: string;
  imageUrl?: string;
  lyrics?: string;
  caption?: string;
  caption_mentions?: {
    user_mentions?: {
      handle: string;
      start: number;
      end: number;
    }[];
  };
  isAudioUploadTOSAccepted?: boolean;
  videoCoverUploadId?: string | null;
  removeImageCover?: boolean;
  removeVideoCover?: boolean;
  showSuccessToast?: boolean;
  s3Id?: string | null;
  optOutVideoCoverHook?: boolean | null;
};

export type PatchSchema<A extends object, B extends object> = A &
  Partial<Omit<B, keyof A>>;

/**
 * We currently have several clip schemas on the backend. This takes the "best"
 * one and then patches it with optional versions of some fields that are in
 * a few of the others. Then it adds on a client-only field for good measure.
 */
export type Clip = components['schemas']['GeneratedClipSchema'] & {
  // is_not_interested is toggled when the user clicks the "Not Interested" button from recommendations
  // it is saved on the client and upon refresh the song should be hidden from the recommendations
  is_not_interested?: boolean;
  // TODO: adding this back for now, but this is not being populated from the backend yet. we will investigate how the moderation hiding is being handled and then
  // modify this
  is_hidden?: boolean;
};

export type ParentClip = components['schemas']['ParentClipSchema'];

export type GenerationRequest =
  components['schemas']['GenerationRequestSimpleSchema'];
type UpsampleParams = components['schemas']['UpsampleParamsSpec'];
export type Playlist = components['schemas']['PlaylistSchema'] & {
  clipIds: string[];
};

/**
 * Entity types are the client-modified versions of the backend schemas to
 * convert the keys to camelCase and append any client-only fields.
 */
export type ClipEntity = ClientEntity<
  components['schemas']['GeneratedClipSchema']
>;
export type CommentEntity = ClientEntity<
  components['schemas']['CommentSchema']
> & {
  isDeleted?: boolean;
  isReported?: boolean;
  entityType: CommentEntityType; // @TODO: remove after backend schema update
  replies?: CommentReplyEntity[]; // @TODO: remove after backend schema update
};
export type CommentReplyEntity = ClientEntity<
  components['schemas']['ReplySchema']
> & {
  isDeleted?: boolean;
  isReported?: boolean;
  entityType: CommentEntityType; // @TODO: remove after backend schema update
};
export type CommentEntityType = components['schemas']['CommentEntityType'];
export type PlaylistMetadataEntity = ClientEntity<
  components['schemas']['PlaylistMetadataSchema']
>;
export type PlaylistEntity = ClientEntity<
  components['schemas']['PlaylistSchema']
>;

export type GenerationType =
  components['schemas']['GenParamsSpec']['generation_type'];

export type AlignedLyrics = NonNullable<
  ClientEntity<components['schemas']['AlignedLyricsV2Schema']>['alignedLyrics']
>;
export interface Profile {
  handle: string;
  display_name?: string;
}

export enum CommentSortBy {
  Newest = 'newest',
  Oldest = 'oldest',
  MostLiked = 'most_liked',
}

// The API endpoints do not consistently return all fields, so this defines a
// few that we want to keep from the old data when they're missing.
const PRESERVED_CLIP_FIELDS = [
  'comment_count',
  'is_pinned',
  'play_count',
  'upvote_count',
  'reaction',
] satisfies Array<keyof Clip>;
function getPreservedClipFields(clip: Clip) {
  const fields = pick(clip, PRESERVED_CLIP_FIELDS);
  return pickBy<typeof fields>(fields, (value) => value != null);
}

export function formatErrorResponse(error: unknown, response: Response) {
  const message =
    typeof error === 'string'
      ? error
      : error &&
          typeof error === 'object' &&
          'detail' in error &&
          typeof error.detail === 'string'
        ? error.detail
        : null;
  return new Error(message || response.statusText, {
    cause: response.status,
  });
}
const SUBMITTED_TIMEOUT_MS = 60 * 2 * 1000;
const QUEUED_TIMEOUT_MS = 60 * 15 * 1000;

const msSinceTimeout = (clip: Clip) => {
  if (!clip?.created_at) {
    return -1;
  }

  const elapsedTime = getMsSinceServerReportedTime(clip.created_at);
  if (clip?.status === 'submitted') {
    return elapsedTime - SUBMITTED_TIMEOUT_MS;
  }
  if (clip?.status === 'queued') {
    return elapsedTime - QUEUED_TIMEOUT_MS;
  }
  return -1;
};

export const resolveModelName = (
  genForm: GenParams,
  clips: ClipsStore,
  enableOverride: boolean = false
) => {
  const defaultOverrideModelName = 'chirp-v3-5-tau';
  const latestOverrideModelName = 'chirp-v4-tau';
  const uploadExtendModelName = 'chirp-v3-5-upload';
  // won't show up in session.models, manually setting
  // 3.5 2h and v4 should be able to extend uploads and infill

  const selectedModelIsOverrideTest =
    genForm.mv.includes('auk') ||
    genForm.mv.includes('bluejay') ||
    genForm.mv.includes('crow');

  const selectedModelCanInfill =
    genForm.mv.includes('engine-t') ||
    genForm.mv.includes('engine-ft') ||
    genForm.mv.includes('v3-5-tau') ||
    genForm.mv.includes('v4-h-t-6') ||
    selectedModelIsOverrideTest;

  const selectedModelCanExtend = genForm.mv.includes('engine-t');
  const defaultArtistCoverModelNamePattern = /^chirp-v3p5-engine-t/;

  // auk can be used for all tasks except for infill (for now)
  if (
    selectedModelIsOverrideTest &&
    !(
      (genForm.infillFromSeconds !== null &&
        genForm.infillToSeconds !== null) ||
      clips.root.edit.activeEditTool === 'infill'
    )
  ) {
    return genForm.mv;
  }

  if (
    (genForm.infillFromSeconds !== null && genForm.infillToSeconds !== null) ||
    clips.root.edit.activeEditTool === 'infill'
  ) {
    if (process.env.NEXT_PUBLIC_NODE_ENV === 'production') {
      if (isHybridModelAvailable(clips.root.session)) {
        return (
          {
            'chirp-v3-5': defaultOverrideModelName,
            'chirp-v4': latestOverrideModelName,
          }[genForm.mv] || latestOverrideModelName
        );
      }
      return defaultOverrideModelName;
    }
    return selectedModelCanInfill ? genForm.mv : latestOverrideModelName;
  } else if (
    genForm.continueClipId &&
    clips.clipById[genForm.continueClipId].metadata.type === 'upload'
  ) {
    if (isHybridModelAvailable(clips.root.session)) {
      return (
        {
          'chirp-v3-5': uploadExtendModelName,
        }[genForm.mv] || 'chirp-v4'
      );
    }
    return selectedModelCanExtend ? genForm.mv : uploadExtendModelName;
  } else if (
    genForm.artistClipId !== null ||
    genForm.coverClipId !== null ||
    genForm.personaClipId !== null
  ) {
    if (enableOverride) {
      if (isHybridModelAvailable(clips.root.session)) {
        return selectedModelIsOverrideTest
          ? genForm.mv
          : {
              'chirp-v3-5': defaultOverrideModelName,
              'chirp-v4': latestOverrideModelName,
            }[genForm.mv] || latestOverrideModelName;
      }
      return defaultOverrideModelName;
    }
    return defaultArtistCoverModelNamePattern.test(genForm.mv) ||
      process.env.NEXT_PUBLIC_NODE_ENV !== 'production'
      ? genForm.mv
      : 'chirp-v3p5-engine-t-5'; // Default if no match
  }
  return genForm.mv;
};

export const isModelSelectable = (genForm: GenParams, clips: ClipsStore) => {
  return (
    !genForm.continueClipId ||
    clips.clipById[genForm.continueClipId].metadata.type !== 'upload'
  );
};

// Generation metadata that gets passed in gen metadata for additional
// user controllability - i.e. weirdness, tag_strength, vocal_gender
type ControlParams = {
  audio_weight?: number;
  style_weight?: number;
  weirdness_constraint?: number;
};

type ControlSlidersParam = {
  control_sliders?: ControlParams;
  can_control_sliders?: ControlSliderKey[];
};

export interface GenParams {
  lyrics: string;
  style: string;
  mv: string;
  description: string;
  title: string;
  continueClipId?: string | null;
  continueAtSeconds?: number | null;
  placeholder: string;
  isSimple: boolean;
  instrumental?: boolean;
  infillFromSeconds?: number | null;
  infillToSeconds?: number | null;
  artistClipId?: string | null;
  coverClipId?: string | null;
  personaClipId?: string | null;
}

export class ClipsStore implements Substore {
  // TanStack vs. MobX
  writeClipToQuery = false;
  readClipFromQuery = false;
  // registry of all loaded clips by ID
  clipById: { [key: string]: Clip } = {};
  // registry of all loaded playlists by ID
  // TODO move to its own state wrapper
  playlistById: { [key: string]: Playlist } = {};
  clipIds: string[] = [];
  // playlist IDs for the current page (?)
  playlistIds: string[] = [];
  // all playlist IDs that have been loaded
  allPlaylistIds: string[] = [];
  loadingPlaylistIds: string[] = [];
  runningRequests: Set<string> = new Set();
  requestById: { [key: string]: GenerationRequest } = {};
  clipRequestId: { [key: string]: string } = {};
  videoPendingById: { [key: string]: boolean } = {};
  notInterestedById: { [key: string]: boolean } = {};
  notifiedErrors = new Set<string>();
  trashedClipIndices = new Map();
  loadingRadio: boolean = false;
  pendingWavDownloadClip: Clip | null = null;
  pendingWavDownloadAtTime: number | null = null;
  wavDownloadClip: Clip | null = null;
  wavDownloadUrl: string | null = null;
  pendingAlignedLyricsClipIds: Set<string> = new Set();
  recentCompleteUpsampleClipIds: { [key: string]: number | undefined } = {};
  feedbackGivenByClipId: { [clipId: string]: boolean } = {};
  alignedLyricsByClipId: { [clipId: string]: object[] } = {};
  pregenWaveformByClipId: {
    [clipId: string]: {
      waveformData: number[];
      hootErrorRate: number | null | undefined;
    };
  } = {};
  isUpdatingClipReaction: boolean = false;
  isLoadingAlignedLyrics: boolean = false;
  replyChannel: Ably.RealtimeChannel | null = null;
  generatingClipIds: Set<string> = new Set();
  ablyClient: Ably.RealtimeClient | null = null;
  wavIntervalId: NodeJS.Timeout | null = null;
  readonly root: RootStore;
  get apiClient() {
    return this.root.apiClient;
  }
  get queryClient() {
    return this.root.queryClient;
  }
  get logger() {
    return this.root.logger;
  }
  directChildrenByClipId: Record<
    string,
    { clips: Clip[]; currentPage: number }
  > = {};
  displayableRemixesByClipId: Record<
    string,
    { clips: Clip[]; currentPage: number }
  > = {};
  loadingDirectChildren: Set<string> = new Set();
  loadingDisplayableRemixes: Set<string> = new Set();
  // Add this property to store pinned clip IDs in their correct order
  pinnedClipsInitialized: boolean = false;
  pinnedClipsLoaded: boolean = false;
  pinnedClipIds: string[] = [];
  pinnedClipsModalOptions: {
    title: string;
    message: string;
  } = {
    title: 'Pin limit reached !',
    message:
      'You can only pin up to 5 songs to your Profile. Pinning this song will replace your oldest pinned song',
  };

  // Add a new map to store parent clips
  parentClipByClipId: Record<
    string,
    {
      id: string;
      title: string;
      image_url: string;
      user_display_name: string;
      user_handle: string;
    } | null
  > = {};

  // Add these properties to the class
  selectedShareAssetClip: Clip | null = null;
  pendingShareAssetClip: Clip | null = null;
  pendingShareAssetAtTime: number | null = null;
  shareAssetId: string | null = null;
  shareAssetUrl: string | null = null;
  shareAssetStatus: string | null = null;
  shareAssetIntervalId: NodeJS.Timer | null = null;

  // Keeps track of how many remixes a profile has inspired
  remixesInspiredCountByHandle: Record<string, number> = {};

  // Preview polling state
  previewPollingQueue: Set<string> = new Set();
  previewPollingInterval?: NodeJS.Timeout;
  previewPollingTimeouts: Map<string, NodeJS.Timeout> = new Map();

  videoGenerationToastId: { [clipId: string]: string | number } = {};

  // DO NOT ADD INIT LOGIC IN constructor. this will run on every page! considering adding any it in the component init logic instead
  constructor(root: RootStore) {
    this.root = root;
    makeAutoObservableSubstore(this);
  }

  setPendingWavPolling() {
    if (this.pendingWavDownloadClip) {
      this.wavIntervalId = setInterval(async () => {
        if (this.pendingWavDownloadClip) {
          const response = await downloadClipWav(
            this.apiClient,
            this.pendingWavDownloadClip.id
          );
          logWebUserEvent({
            actionName: 'DownloadWAVUrlRetrieved',
            context: {
              pendingWavDownloadClipId: this.pendingWavDownloadClip.id,
              isMobile: false,
            },
          });
          if ((response as any)?.data?.wav_file_url) {
            const pendingWavClip = this.pendingWavDownloadClip;
            this.pendingWavDownloadClip = null;
            this.pendingWavDownloadAtTime = null;
            this.wavDownloadClip = pendingWavClip;
            this.wavDownloadUrl = (response as any)?.data?.wav_file_url;
          } else {
            // Timeout the pending wav download request
            if (
              this.pendingWavDownloadAtTime &&
              Date.now() - this.pendingWavDownloadAtTime > 120000
            ) {
              logWebUserEvent({
                actionName: 'DownloadWAVTimeout',
                context: {
                  pendingWavDownloadClipId: this.pendingWavDownloadClip.id,
                  isMobile: false,
                },
              });
              toast({
                title: 'WAV file download timed out',
                description: 'Please try again.',
                status: 'error',
                duration: 4000,
                isClosable: true,
              });
              this.clearWavDownload();
            }
          }
        }
      }, 5000);
    }
  }
  clearWavDownload = () => {
    if (this.wavIntervalId) {
      clearInterval(this.wavIntervalId);
      this.wavIntervalId = null;
    }
    this.pendingWavDownloadAtTime = null;
    this.pendingWavDownloadClip = null;
    this.wavDownloadUrl = null;
  };

  processErrorResponse = async (
    response: Response,
    error: any,
    transactionLogger: TransactionLogger,
    silentError?: boolean
  ) => {
    if (response.status === 424) {
      // moderation dependency
      const reason = await response.text();
      const { menus } = this.root;
      switch (reason) {
        case 'copyright_infringment':
          menus.openModal(ModalTypes.COPYRIGHT_WARNING);
          break;
        default:
          toast({
            title: 'An error occurred.',
            description: error?.detail,
            status: 'error',
            duration: 4000,
            isClosable: true,
          });
      }
    } else if (response.status === 429) {
      if ((await response.text()).includes('Too many running jobs')) {
        // check if user is pro here and upsell if not
        if (
          this.root.session.subscriptionLevel === undefined ||
          this.root.session.subscriptionLevel === SubscriptionLevel.Basic
        ) {
          this.root.session.notifyOutOfConcurrency = true;
        } else {
          toast({
            title: 'Please wait.',
            description: 'Please wait for your other generations to finish.',
            status: 'error',
            duration: 4000,
            isClosable: true,
          });
        }
      } else {
        toast({
          title: 'Too many requests.',
          description: 'Please try again in a few moments.',
          status: 'error',
          duration: 4000,
          isClosable: true,
        });
      }
      transactionLogger.logWebUserEvent({
        actionName: 'GenerationStartError',
        principalObjectType: 'error_type',
        principalObjectValue: 'Rate Limited',
        context: {
          createVersion: isBlendedCreateExperimentEnabled(this.root.session)
            ? CREATE_VERSION
            : LEGACY_CREATE_VERSION,
          isRemix: this.root.genForm.isRemixCreate,
        },
      });
    } else if (response.status === 402) {
      console.log('error', error);
      if (error?.detail === 'Subscription past due.') {
        toast({
          title: 'Please wait.',
          description: 'Please wait for your other generations to finish.',
          status: 'error',
          duration: 4000,
          isClosable: true,
          render: () => {
            return React.createElement(ToastV2, {
              title: 'Your subscription is past due.',
              description: 'Please update your payment method.',
              linkText: 'Manage',
              href: '/account',
            });
          },
        });
        transactionLogger.logWebUserEvent({
          actionName: 'GenerationStartError',
          principalObjectType: 'error_type',
          principalObjectValue: 'Subscription past due',
          context: {
            createVersion: isBlendedCreateExperimentEnabled(this.root.session)
              ? CREATE_VERSION
              : LEGACY_CREATE_VERSION,
            isRemix: this.root.genForm.isRemixCreate,
          },
        });
      } else {
        toast({
          title: 'Need at least 10 credits to create.',
          description: 'Upgrade your account to get more credits!',
          status: 'error',
          duration: 4000,
          isClosable: true,
          render: () => {
            return React.createElement(ToastV2, {
              title: "You've used all your credits",
              description: 'Upgrade to keep creating',
              linkText: 'Upgrade',
              href: '/account',
            });
          },
        });
        transactionLogger.logWebUserEvent({
          actionName: 'GenerationStartError',
          principalObjectType: 'error_type',
          principalObjectValue: 'Insufficient Credits',
          context: {
            createVersion: isBlendedCreateExperimentEnabled(this.root.session)
              ? CREATE_VERSION
              : LEGACY_CREATE_VERSION,
            isRemix: this.root.genForm.isRemixCreate,
          },
        });
      }
    } else if (response.status === 423) {
      toast({
        title: 'Your account requires support assistance',
        description: 'Please contact support@suno.com.',
        status: 'error',
        duration: 6000,
        isClosable: true,
      });
      transactionLogger.logWebUserEvent({
        actionName: 'GenerationStartError',
        principalObjectType: 'error_type',
        principalObjectValue: 'Support Assistance Required',
        context: {
          createVersion: isBlendedCreateExperimentEnabled(this.root.session)
            ? CREATE_VERSION
            : LEGACY_CREATE_VERSION,
          isRemix: this.root.genForm.isRemixCreate,
        },
      });
    } else if (response.status === 451) {
      toast({
        title: 'The lyrics do not meet our content guidelines',
        description: 'Please try again with different lyrics.',
        status: 'error',
        duration: 6000,
        isClosable: true,
      });
      transactionLogger.logWebUserEvent({
        actionName: 'GenerationStartError',
        principalObjectType: 'error_type',
        principalObjectValue: 'Does Not Meet Guidelines',
        context: {
          createVersion: isBlendedCreateExperimentEnabled(this.root.session)
            ? CREATE_VERSION
            : LEGACY_CREATE_VERSION,
          isRemix: this.root.genForm.isRemixCreate,
        },
      });
    } else if (response.status === 503) {
      toast({
        title: 'We are seeing elevated usage.',
        description:
          'Generations are currently only available for Pro and Premier subscribers.',
        status: 'error',
        duration: 4000,
        isClosable: true,
      });
      transactionLogger.logWebUserEvent({
        actionName: 'GenerationStartError',
        principalObjectType: 'error_type',
        principalObjectValue: 'Elevated Usage',
        context: {
          createVersion: isBlendedCreateExperimentEnabled(this.root.session)
            ? CREATE_VERSION
            : LEGACY_CREATE_VERSION,
          isRemix: this.root.genForm.isRemixCreate,
        },
      });
    } else if (error?.detail) {
      toast({
        title: 'An error occurred.',
        description: error?.detail,
        status: 'error',
        duration: 4000,
        isClosable: true,
      });
    } else if (error && !silentError) {
      toast({
        title: 'An error occurred.',
        description: 'A system error occurred.',
        status: 'error',
        duration: 4000,
        isClosable: true,
      });

      transactionLogger.logWebUserEvent({
        actionName: 'GenerationStartError',
        principalObjectType: 'error_type',
        principalObjectValue: 'Unknown Error',
        context: {
          message:
            (error as any)?.message || (error as any)?.error_message || error,
          createVersion: isBlendedCreateExperimentEnabled(this.root.session)
            ? CREATE_VERSION
            : LEGACY_CREATE_VERSION,
          isRemix: this.root.genForm.isRemixCreate,
        },
      });
    }
  };

  preemptedCreditDeductions: Set<string> = new Set();

  hasPreemptedCreditDeduction = (clipId: string) => {
    return localStorage?.getItem(`preempted-credit-deduction-${clipId}`);
  };

  addPreemptedCreditDeduction = (clipId: string) => {
    this.preemptedCreditDeductions.add(clipId);
    localStorage?.setItem(`preempted-credit-deduction-${clipId}`, 'true');
  };

  removePreemptedCreditDeduction = (clipId: string) => {
    this.preemptedCreditDeductions.delete(clipId);
    localStorage?.removeItem(`preempted-credit-deduction-${clipId}`);
  };

  expectCreditDeductionForClip = (clip: Clip) => {
    if (!this.hasPreemptedCreditDeduction(clip.id)) {
      this.addPreemptedCreditDeduction(clip.id);
      this.deductCreditsOrFreeClips(clip);
    }
  };

  fulfillCreditDeductionForClip = (clip: Clip) => {
    if (!this.hasPreemptedCreditDeduction(clip.id)) {
      this.deductCreditsOrFreeClips(clip);
    } else {
      this.removePreemptedCreditDeduction(clip.id);
    }
  };

  revertCreditDeductionForClip = (clip: Clip) => {
    if (this.hasPreemptedCreditDeduction(clip.id)) {
      this.removePreemptedCreditDeduction(clip.id);
      this.refundCreditsOrFreeClips(clip);
    }
  };

  runStream = async ({
    transactionLogger,
    session,
    ably,
    params,
    silentError,
  }: {
    transactionLogger: TransactionLogger;
    session: SessionStore;
    ably?: Ably.RealtimeClient | undefined;
    params?: any;
    silentError?: boolean;
  }) => {
    const user_uploaded_images_b64: string[] = [];
    if (!!ably) {
      this.ablyClient = ably;
    }
    Object.values(this.root.genForm.imageData).forEach((data) => {
      if (data) {
        user_uploaded_images_b64.push(data);
      }
    });

    const isInfill =
      this.root.genForm.infillFromSeconds !== null &&
      this.root.genForm.infillToSeconds !== null;
    const isMatrixModeEnabled =
      this.root.session?.flags?.['configs'] && isDevOrStaging;

    const isWebVideoToSongEnabled =
      this.root.session?.flags?.['video-to-song-web-gen'];

    const isCoverEnabled = await this.root.contest.isCoverEnabledOrIsContest(
      this.root.genForm.coverClipId ?? '',
      this.root.session.userId ?? ''
    );
    const isPersonaEnabled = isPersonaFeatureEnabled(this.root.session);
    const isProjectsEnabled = isProjectsFeatureEnabled(this.root.session);
    const isEditModeEnabled = this.root.session?.flags?.['edit-mode-ui'];
    const isMultiRootPersonaEnabled =
      this.root.session?.flags?.['multi-root-persona'];
    const isPlaylistConditionEnabled =
      this.root.session?.flags?.['playlist-condition'];
    const isEnablePersonalizationEnabled =
      this.root.session?.flags?.['use-personalization'];
    const isUnderOverpaintingEnabled =
      this.root.session?.flags?.['under-over-painting'];

    const validSessionModels = this.root.session
      .getViewableModels()
      .map((model: any) => model.external_key);
    this.root.genForm.mv = validSessionModels.includes(this.root.genForm.mv)
      ? this.root.genForm.mv
      : getDefaultModel(this.root.session.getViewableModels());

    const controlParams: ControlParams = {
      style_weight: params?.style_weight,
      audio_weight: params?.audio_weight,
      weirdness_constraint: params?.weirdness_constraint,
    };

    const controlSlidersParam: ControlSlidersParam =
      params?.createMode !== CreateModes.CUSTOM
        ? {}
        : Object.keys(controlParams).filter(
              (key) => controlParams[key as ControlSliderKey] !== undefined
            ).length > 0
          ? { control_sliders: { ...controlParams } }
          : {};

    controlSlidersParam.can_control_sliders =
      params?.createMode !== CreateModes.CUSTOM
        ? []
        : [
            ...(modelSupportsFeature(
              this.root.genForm.mv,
              session.billingModels,
              'create_control_sliders'
            )
              ? (['weirdness_constraint', 'style_weight'] as ControlSliderKey[])
              : []),
            ...(modelSupportsFeature(
              this.root.genForm.mv,
              session.billingModels,
              'create_control_sliders'
            ) && this.root.createV2.hasAudioCondition
              ? (['audio_weight'] as ControlSliderKey[])
              : []),
          ];

    const userTier =
      this.root.session.sub?.plan?.id ||
      (this.root.session.sub?.plans || []).find(
        (plan: any) => plan.plan_key === PlanKey.Free
      )?.id;

    const prompt =
      this.root.genForm.isSimple || params?.isSimple
        ? {
            token: await this.root.session.getCaptchaTokenIfRequired(
              CaptchaConsumer.Generation
            ),
            gpt_description_prompt:
              this.root.genForm.description || this.root.genForm.placeholder,
            mv: this.root.genForm.mv,
            prompt: '',
            metadata: {
              create_mode: params?.createMode,
              user_tier: userTier,
              lyrics_model:
                params?.lyricsModel ||
                this.root.genForm.lyricsModel ||
                'default',
              ...controlSlidersParam,
              ...(!!params?.vocalGender
                ? { vocal_gender: params.vocalGender }
                : {}),
            },
            ...(!!params?.title ? { title: params.title } : {}),
            make_instrumental: this.root.genForm.instrumental,
            user_uploaded_images_b64: user_uploaded_images_b64,
            generation_type: this.root.genForm.generationType,
            ...(isWebVideoToSongEnabled && {
              user_uploaded_video_id: this.root.genForm.videoUploadId,
            }),
          }
        : {
            ...(this.root.session.flags?.['create-v1.5']
              ? {
                  gpt_description_prompt:
                    this.root.genForm.description || undefined,
                }
              : {}),
            token: await this.root.session.getCaptchaTokenIfRequired(
              CaptchaConsumer.Generation
            ),
            prompt: this.root.genForm.instrumental
              ? ''
              : this.root.genForm.lyrics,
            generation_type: this.root.genForm.generationType,
            tags: this.root.genForm.style,
            negative_tags: this.root.genForm.enableExcludeStyle
              ? this.root.genForm.negativeTags
              : '',
            mv: resolveModelName(
              this.root.genForm,
              this,
              isCoverEnabled || isPersonaEnabled || isEditModeEnabled
            ),
            title: params?.title || this.root.genForm.title,
            continue_clip_id: this.root.genForm.continueClipId,
            continue_at: this.root.genForm.continueAtSeconds,
            continued_aligned_prompt: this.root.genForm.continuedAlignedPrompt,
            infill_start_s: this.root.genForm.infillFromSeconds,
            infill_end_s: this.root.genForm.infillToSeconds,
            task: this.root.genForm.task,
            override_fields: params?.overrideFields || [],
            ...(isInfill && this.root.genForm.fullLyrics
              ? { continued_aligned_prompt: this.root.genForm.fullLyrics }
              : {}),
            ...(isInfill
              ? {
                  infill_dur_s: this.root.genForm.infillFixDuration
                    ? this.root.genForm.task === 'infill_outro' ||
                      this.root.genForm.task === 'infill_intro'
                      ? [5, 10, 15, 20][Math.floor(Math.random() * 4)]
                      : (this.root.genForm.infillToSeconds || 0) -
                        (this.root.genForm.infillFromSeconds || 0)
                    : null,
                }
              : {}),
            ...(isInfill &&
            this.root.genForm.infillContextStartSeconds !== null &&
            this.root.genForm.infillContextEndSeconds !== null
              ? {
                  infill_context_start_s:
                    this.root.genForm.infillContextStartSeconds,
                  infill_context_end_s:
                    this.root.genForm.infillContextEndSeconds,
                }
              : {}),
            ...(isPersonaEnabled && {
              persona_id: this.root.genForm.personaId,
            }),
            ...(isMultiRootPersonaEnabled && {
              persona_ids: this.root.createV2.activePersonas.map(
                (persona) => persona.id
              ),
            }),
            ...(isMultiRootPersonaEnabled && {
              artist_clip_ids: getRootClipIdsFromPersonas(
                this.root.createV2.activePersonas
              ),
            }),
            ...(isPlaylistConditionEnabled && {
              playlist_id: this.root.createV2.conditioningPlaylist?.id,
              playlist_clip_ids: getFirstNSongsFromPlaylist(
                this.root.createV2.conditioningPlaylist
              ),
            }),
            ...(isEnablePersonalizationEnabled && {
              use_personalization: this.root.createV2.enablePersonalization,
            }),
            ...(isUnderOverpaintingEnabled && {
              underpainting_clip_id: this.root.createV2.underpaintingClip?.id,
              underpainting_start_s:
                this.root.createV2.underpaintingStartSeconds,
              underpainting_end_s: this.root.createV2.underpaintingEndSeconds,
              overpainting_clip_id: this.root.createV2.overpaintingClip?.id,
              overpainting_start_s: this.root.createV2.overpaintingStartSeconds,
              overpainting_end_s: this.root.createV2.overpaintingEndSeconds,
            }),
            ...{
              artist_clip_id: !this.root.genForm.artistClipId
                ? this.root.genForm.personaClipId
                : this.root.genForm.artistClipId,
              artist_start_s: this.root.genForm.artistStartSeconds,
              artist_end_s: this.root.genForm.artistEndSeconds,
            },
            ...(isCoverEnabled && {
              cover_clip_id: this.root.genForm.coverClipId,
            }),
            ...(this.root.genForm.stylesLyricsClipId
              ? { styles_lyrics_clip_id: this.root.genForm.stylesLyricsClipId }
              : {}),
            metadata: {
              create_mode: params?.createMode,
              user_tier: userTier,
              lyrics_model: params?.lyricsModel || 'default',
              create_session_token: this.root.genForm.createSessionToken,
              ...(this.root.genForm.isMumbleMode && {
                is_mumble: true,
              }),
              ...(this.root.genForm.lastLyricsGeneration && {
                last_lyrics_generation: {
                  lyrics_model:
                    this.root.genForm.lastLyricsGeneration.lyricsModel,
                  prompt: this.root.genForm.lastLyricsGeneration.prompt,
                  lyrics: this.root.genForm.lastLyricsGeneration.lyrics,
                  title: this.root.genForm.lastLyricsGeneration.title,
                },
              }),
              ...(this.root.genForm.lastTagsGeneration && {
                last_tags_generation: {
                  tags: this.root.genForm.lastTagsGeneration.generatedTags,
                  request_id: this.root.genForm.lastTagsGeneration.requestId,
                },
              }),
              ...(isMatrixModeEnabled && {
                forced_infer_config: {
                  ...Object.entries(
                    this.root.genForm.getAdvancedParams() || {}
                  ).reduce<Record<string, any>>((acc, [key, val]) => {
                    switch (val.type) {
                      case 'float':
                        acc[key] =
                          this.root.genForm.configurations[key] !== undefined
                            ? parseFloat(this.root.genForm.configurations[key])
                            : null;
                        break;
                      case 'int':
                        acc[key] =
                          this.root.genForm.configurations[key] !== undefined
                            ? parseInt(this.root.genForm.configurations[key])
                            : null;
                        break;
                      default:
                        break;
                    }
                    return acc;
                  }, {}),
                },
              }),
              ...controlSlidersParam,
              ...(this.root.genForm.isRemixCreate && { is_remix: true }),
              ...(!!params?.vocalGender
                ? { vocal_gender: params.vocalGender }
                : {}),
            },
            ...(params?.editSessionId
              ? { edit_session_id: params?.editSessionId }
              : {}),
          };

    const endpoint = genEndpoint(this.root.session);
    const { data, error, response } = await this.apiClient.POST(endpoint, {
      body: prompt,
    });

    if (data) {
      transactionLogger.logWebUserEvent({
        actionName: 'GenerationStartSuccess',
        context: {
          createVersion: isBlendedCreateExperimentEnabled(this.root.session)
            ? CREATE_VERSION
            : LEGACY_CREATE_VERSION,
          isRemix: this.root.genForm.isRemixCreate,
        },
      });
      const initialResponse = this.processClipInitialResponse(
        data,
        params?.projectId,
        isProjectsEnabled,
        isMatrixModeEnabled,
        isPersonaEnabled,
        !!ably,
        transactionLogger
      );
      this.root.genForm.resetCreateToken();
      if (!!ably && !this.replyChannel) {
        this.ablyClient = ably;
        this.replyChannel = ably.channels.get(this.getReplyChannelName());
        this.replyChannel.subscribe((message: Ably.InboundMessage) => {
          const clipUpdate = JSON.parse(message.data);
          clipUpdate?.clips?.forEach((clip: Clip) => {
            this.processClipUpdate(clip);
          });
        });
      }
      for (const clip of data.clips) {
        this.generatingClipIds.add(clip.id);
      }
      return initialResponse;
    } else {
      this.processErrorResponse(
        response,
        error,
        transactionLogger,
        silentError
      );
    }
  };

  dispatchGenerateRequest = async (
    transactionLogger: TransactionLogger,
    payload: any,
    projectId?: string,
    silentError?: boolean
  ) => {
    const isMatrixModeEnabled =
      this.root.session?.flags?.['configs'] && isDevOrStaging;
    const endpoint = genEndpoint(this.root.session);
    const { data, error, response } = await this.apiClient.POST(endpoint, {
      body: payload,
    });

    if (data) {
      transactionLogger.logWebUserEvent({
        actionName: 'GenerationStartSuccess',
        context: {
          createVersion: isBlendedCreateExperimentEnabled(this.root.session)
            ? CREATE_VERSION
            : LEGACY_CREATE_VERSION,
          isRemix: this.root.genForm.isRemixCreate,
        },
      });
      const initialResponse = this.processClipInitialResponse(
        data,
        projectId ?? this.root.project.userSelectedProjectId ?? undefined,
        true,
        isMatrixModeEnabled,
        true,
        undefined,
        transactionLogger
      );
      this.root.genForm.resetCreateToken();
      for (const clip of data.clips) {
        this.generatingClipIds.add(clip.id);
      }
      return initialResponse;
    } else {
      this.processErrorResponse(
        response,
        error,
        transactionLogger,
        silentError
      );
    }
  };

  getReplyChannelName = () => {
    return `user:${this.root.session.userId}:generate_songs`;
  };

  checkTimedOutGens = () => {
    this.clipIds.map((clipId: string) => {
      const clip = this.clipById[clipId];
      const timeout = msSinceTimeout(clip);
      // fetch the time since timeout so the error can be shown only once
      if (timeout > 0 && timeout < 5000) {
        toast({
          title: 'A clip got stuck while generating.',
          description: 'No credits were used and you can retry.',
          status: 'error',
          duration: 4000,
          isClosable: true,
        });
      }
    });
  };

  runConcat = async (
    clipId: string,
    isInfill: boolean = false,
    params?: any
  ) => {
    const { data } = await this.apiClient.POST('/api/generate/concat/v2/', {
      body: {
        clip_id: clipId,
        is_infill: isInfill,
        ...(params?.editSessionId
          ? { edit_session_id: params?.editSessionId }
          : {}),
      },
    });

    if (data) {
      const isProjectsEnabled = isProjectsFeatureEnabled(this.root.session);

      this.addClip(
        data,
        !!(isProjectsEnabled && this.root.project.currentProjectId)
      );
      this.runningRequests.add(data.id);

      if (isProjectsEnabled) {
        this.root.project.addClip(data);
      }

      if (isProjectsEnabled && this.root.project.currentProjectId) {
        this.root.project.addClipsToProject(
          [data],
          this.root.project.currentProjectId
        );
      }

      return data as Clip;
    }
  };

  hasRunningRequests = (clips: Clip[]) => {
    return clips.some((clip) => this.runningRequests.has(clip.id));
  };

  clipIsRunning = (clip: Clip) => {
    return this.runningRequests.has(clip.id);
  };

  queueClipToPoll = (clip: Clip) => {
    if (clip.status && clip.status != 'complete' && clip.status != 'error') {
      if (!this.runningRequests.has(clip.id)) {
        this.runningRequests.add(clip.id);
      }
      this.expectCreditDeductionForClip(clip);
    }
  };

  subscribeToClipUpdates = (clip: Clip, ably: Ably.RealtimeClient) => {
    if (clip.status && clip.status != 'complete' && clip.status != 'error') {
      this.expectCreditDeductionForClip(clip);

      if (!this.replyChannel) {
        this.ablyClient = ably;
        this.replyChannel = ably.channels.get(this.getReplyChannelName());
        this.replyChannel.subscribe((message: Ably.InboundMessage) => {
          const clipUpdate = JSON.parse(message.data);
          clipUpdate?.clips?.forEach((clip: Clip) => {
            this.processClipUpdate(clip);
          });
        });
      }
      this.generatingClipIds.add(clip.id);
    }
  };

  runMerge = async (clipIds: string[]) => {
    const { data } = await this.apiClient.POST('/api/generate/merge/', {
      body: { clip_ids: clipIds },
    });

    if (data) {
      const isProjectsEnabled = isProjectsFeatureEnabled(this.root.session);
      this.addClip(
        data,
        !!(isProjectsEnabled && this.root.project.currentProjectId)
      );
      if (isProjectsEnabled) {
        this.root.project.addClip(data);
      }
      this.runningRequests.add(data.id);

      if (isProjectsEnabled && this.root.project.currentProjectId) {
        this.root.project.addClipsToProject(
          [data],
          this.root.project.currentProjectId
        );
      }
    }
  };

  fetchRecentClips = async (ably?: Ably.RealtimeClient) => {
    const { data } = await this.apiClient.GET('/api/feed/v2', {
      params: { query: { add_preset_clips: true } },
    });

    if (!data) {
      return;
    }
    data?.clips?.reverse();

    data?.clips?.forEach((clip) => {
      if (!!ably) {
        this.subscribeToClipUpdates(clip, ably);
      } else {
        this.queueClipToPoll(clip);
      }

      this.clipById[clip.id] = clip;
      this.feedbackGivenByClipId[clip.id] =
        getFeedbackReasons(clip).length !== 0;
    });
    this.clipIds = data?.clips?.map((clip) => clip.id) || [];
  };

  addClip = (clip: Clip, addToFront: boolean = false) => {
    this.clipById[clip.id] = clip;
    if (!this.clipIds.includes(clip.id)) {
      if (addToFront) {
        this.clipIds.unshift(clip.id);
      } else {
        this.clipIds.push(clip.id);
      }
    }
  };

  /**
   * DO NOT CALL DIRECTLY!
   *
   * This is a sync function for `useClipById()` for backwards-compatibility
   */
  updateClipById(clip: Clip) {
    if (clip?.id) {
      this.clipById[clip.id] = clip;
      this.feedbackGivenByClipId[clip.id] =
        getFeedbackReasons(clip).length !== 0;
    }
  }

  /**
   * All-purpose "we have new data for clip X" update function.
   *
   * By default, this tries to preserve special fields from the previous clip
   * data and update the `useQuery` cache. Wherever possible, it is preferable
   * to use `useClipById` directly.
   */
  updateClips = (clips: Clip[], merge = true) => {
    clips.forEach((clip) => {
      if (clip?.id) {
        const updatedClip = merge
          ? {
              // Start with new clip
              ...clip,
              // Keep old special fields
              ...getPreservedClipFields(this.clipById[clip?.id]),
              // Unless they're non-null/undefined on new clip
              ...getPreservedClipFields(clip),
            }
          : clip;

        // workaround for error that could occur when playing a clip that is streaming/polling
        updatedClip.play_count = Math.max(
          this.clipById[clip?.id]?.play_count || 0,
          updatedClip.play_count || 0
        );
        updatedClip.upvote_count = isLiked(updatedClip)
          ? Math.max(
              this.clipById[clip?.id]?.upvote_count || 0,
              updatedClip.upvote_count || 0
            )
          : updatedClip.upvote_count || 0;
        // TanStack query update
        if (this.writeClipToQuery) {
          updateClipData(
            this.queryClient,
            clipsKeys.clip({ clipId: clip.id }),
            () => deepCamelKeys(updatedClip)
          );
        }
        // MobX update
        this.updateClipById(updatedClip);
      }
    });
  };

  /**
   * Updates clips from ClipEntity format (camelCase) by converting to Clip format (snake_case)
   */
  updateClipsFromEntities = (clipEntities: ClipEntity[], merge = true) => {
    const clips = clipEntities.map((clipEntity) => deepSnakeKeys(clipEntity));
    this.updateClips(clips, merge);
  };

  /**
   * Gets a clip by ID
   *
   * This is meant to be a replacement for direct access clipById access so
   * that we can transparently swap out the data source under the hood
   *
   * If camelCase is enabled, it will only read from the query
   */
  getClipById(clipId: string, camelCase = false) {
    if (camelCase && !this.writeClipToQuery) {
      console.warn(
        `Weird configuration detected! Clip ${clipId} may not be populated in cache`
      );
    }
    if (!this.readClipFromQuery && !camelCase) {
      return this.clipById[clipId];
    }
    const clip = this.queryClient.getQueryData<ClipEntity>(
      clipsKeys.clip({ clipId })
    );
    return camelCase ? clip : deepSnakeKeys(clip);
  }

  updatePlaylists = (playlists: Playlist[], pageToLoad: number) => {
    this.allPlaylistIds = [
      ...this.allPlaylistIds.slice(0, (pageToLoad - 1) * 12), // TODO
      ...playlists.map((playlist: Playlist) => playlist.id),
    ];
    this.playlistIds = playlists.map((playlist: Playlist) => playlist.id);
    playlists.forEach((playlist) => {
      // we only want to update playlists that have not been loaded yet, since the new data we're adding is likely lightweight
      // if we already have the playlist stored, we don't want to overwrite critical info such as clips in the playlist
      if (!this.playlistById[playlist.id]) {
        this.playlistById[playlist.id] = playlist;
      }
    });
  };

  removeFromCreatePage = (clipId: string) => {
    const index = this.clipIds.indexOf(clipId);
    if (index !== -1) {
      this.trashedClipIndices.set(clipId, index);
      this.clipIds.splice(index, 1);
    }
  };

  addToCreatePage = (clipId: string) => {
    if (!this.clipIds.includes(clipId)) {
      const index = this.trashedClipIndices.get(clipId);
      if (typeof index === 'number') {
        this.clipIds.splice(index, 0, clipId);
        this.trashedClipIndices.delete(clipId);
      } else {
        this.clipIds.push(clipId);
      }
    }
  };

  fetchRunningRequests = async () => {
    const { data } = await this.apiClient.GET('/api/generate/requests/', {
      params: {
        query: { status: 'running' },
      },
    });

    if (!data) {
      return;
    }

    data?.forEach((request) => {
      request.clips.forEach((clip) => this.addClip(clip));
      this.runningRequests.add(request.id);
      this.requestById[request.id] = request;

      this.updateClips(request.clips);
    });
  };

  async loadClipById(clipId: string) {
    const { data, error } = await this.apiClient.GET('/api/clip/{clip_id}', {
      params: {
        path: {
          clip_id: clipId,
        },
      },
    });
    if (error) {
      throw new Error((error as any).detail);
    }
    return data;
  }

  async getProjectForClip(
    clipId: string
  ): Promise<ProjectMetadataSchema | undefined> {
    try {
      // Use the studio endpoint to find or create a project for this clip
      const response = await this.apiClient.GET(
        '/api/clips/{clip_id}/project',
        {
          params: {
            path: { clip_id: clipId },
          },
        }
      );

      if (response.data) {
        return response.data;
      }
    } catch (error) {
      console.error('Error getting project for clip:', error);
    }
    return undefined;
  }
  async unlockPreview(clipId: string) {
    const { data, error } = await this.apiClient.POST(
      '/api/gen/{gen_id}/unlock-preview',
      {
        params: {
          path: {
            gen_id: clipId,
          },
        },
      }
    );

    if (error) {
      throw new Error((error as any).detail || 'Failed to unlock preview');
    }

    return data;
  }

  addToPreviewPollingQueue(clipId: string) {
    if (!this.previewPollingQueue.has(clipId)) {
      this.previewPollingQueue.add(clipId);

      const timeout = setTimeout(() => {
        this.previewPollingQueue.delete(clipId);
        this.previewPollingTimeouts.delete(clipId);
      }, 60000);

      this.previewPollingTimeouts.set(clipId, timeout);

      if (!this.previewPollingInterval) {
        this.startPreviewPolling();
      }
    }
  }

  removeFromPreviewPollingQueue(clipId: string) {
    this.previewPollingQueue.delete(clipId);
    const timeout = this.previewPollingTimeouts.get(clipId);
    if (timeout) {
      clearTimeout(timeout);
      this.previewPollingTimeouts.delete(clipId);
    }

    if (this.previewPollingQueue.size === 0 && this.previewPollingInterval) {
      clearInterval(this.previewPollingInterval);
      this.previewPollingInterval = undefined;
    }
  }

  private startPreviewPolling() {
    if (this.previewPollingInterval) return;
    this.previewPollingInterval = setInterval(async () => {
      if (this.previewPollingQueue.size === 0) {
        // stop polling if no clips to poll
        clearInterval(this.previewPollingInterval);
        this.previewPollingInterval = undefined;
        return;
      }

      try {
        // batch fetch all preview clips
        const clipIds = Array.from(this.previewPollingQueue);
        const idsToPoll = clipIds.join(',');
        const { data, error } = await this.apiClient.GET('/api/feed/v2', {
          params: {
            query: {
              ids: idsToPoll,
            },
          },
        });

        if (error) {
          console.error('Error polling preview clips:', error);
          return;
        }

        if (data?.clips) {
          // update clips in store and check if any are no longer preview
          const clipsToRemove: string[] = [];

          data.clips.forEach((clipData: any) => {
            const clipId = clipData.id;
            this.clipById[clipId] = clipData;

            // if clip is no longer preview, remove from polling
            if (clipData.metadata?.type === 'gen') {
              clipsToRemove.push(clipId);
            }
          });

          // remove clips that are no longer preview
          clipsToRemove.forEach((clipId) => {
            this.removeFromPreviewPollingQueue(clipId);
          });
        }
      } catch (error) {
        console.error('Error in batched preview polling:', error);
      }
    }, 15000); // poll every 15 seconds
  }

  shouldShowManualUnlock(clipId: string): boolean {
    const clip = this.clipById[clipId];
    return (
      clip?.metadata?.type === 'preview' &&
      !this.previewPollingQueue.has(clipId) && // check if already polling
      !this.previewPollingTimeouts.has(clipId) // check if timeout has expired
    );
  }

  clearPreviewPolling() {
    if (this.previewPollingInterval) {
      clearInterval(this.previewPollingInterval);
      this.previewPollingInterval = undefined;
    }

    this.previewPollingTimeouts.forEach((timeout) => clearTimeout(timeout));
    this.previewPollingTimeouts.clear();
    this.previewPollingQueue.clear();
  }

  async fetchClipCommentsCount(clipId: string) {
    const { data, error } = await this.apiClient.GET(
      '/api/gen/{clip_id}/comments/count',
      {
        params: {
          path: {
            clip_id: clipId,
          },
        },
      }
    );
    if (error) {
      throw new Error((error as any).detail);
    }
    return data;
  }

  async fetchClipComments(
    clipId: string,
    order?: CommentSortBy | null,
    cursor?: string | null,
    deeplinkedCommentId?: string
  ) {
    const { data, error } = await this.apiClient.GET(
      '/api/gen/{clip_id}/comments',
      {
        params: {
          query: {
            cursor,
            // page_size: 20, // using default for ease of caching
            order,
            id: deeplinkedCommentId,
            deeplink: deeplinkedCommentId ? true : undefined,
          },
          path: {
            clip_id: clipId,
          },
        },
      }
    );
    if (error) {
      throw new Error((error as any).detail);
    }
    return data;
  }

  async fetchCommentReplies(
    commentId: string,
    cursor?: string | null,
    deeplinkedCommentId?: string
  ) {
    const { data, error } = await this.apiClient.GET(
      '/api/comment/{comment_id}/replies',
      {
        params: {
          query: {
            cursor,
            page_size: 10,
            deeplinked_comment_id: deeplinkedCommentId,
          },
          path: {
            comment_id: commentId,
          },
        },
      }
    );
    if (error) {
      throw new Error((error as any).detail);
    }
    return data;
  }

  async postComment(
    clipId: string,
    content:
      | string
      | {
          content: string;
          userMentions?: Array<{
            handle: string;
            displayName?: string;
            start: number;
            end: number;
          }>;
        },
    commentId?: string | null,
    trackTimestamp?: number | null
  ) {
    const { data, response, error } = await this.apiClient.POST(
      '/api/gen/{clip_id}/comment',
      {
        body:
          typeof content === 'string'
            ? {
                content,
                parent_id: commentId,
                track_timestamp: trackTimestamp || null,
              }
            : {
                content: content.content,
                parent_id: commentId,
                user_mentions: deepSnakeKeys(content.userMentions),
                track_timestamp: trackTimestamp || null,
              },
        params: {
          path: {
            clip_id: clipId,
          },
        },
      }
    );
    if (error) {
      throw formatErrorResponse(error, response);
    }
    return data;
  }

  async postCommentReaction(commentId: string, isLike = true) {
    const { data, response, error } = await this.apiClient.POST(
      '/api/comment/{comment_id}/reaction',
      {
        body: {
          reaction: isLike ? 'LIKE' : 'DISLIKE',
        },
        params: {
          path: {
            comment_id: commentId,
          },
        },
      }
    );
    if (error) {
      throw formatErrorResponse(error, response);
    }
    return data;
  }

  async deleteComment(commentId: string) {
    const { data, response, error } = await this.apiClient.DELETE(
      '/api/comment/{comment_id}',
      {
        params: {
          path: {
            comment_id: commentId,
          },
        },
      }
    );
    if (error) {
      throw formatErrorResponse(error, response);
    }
    return data;
  }

  async reportComment(commentId: string, reason?: string) {
    const { data, response, error } = await this.apiClient.POST(
      '/api/comment/{comment_id}/report',
      {
        body: {
          reason,
        },
        params: {
          path: {
            comment_id: commentId,
          },
        },
      }
    );
    if (error) {
      throw formatErrorResponse(error, response);
    }
    return data;
  }

  async blockUser(handle: string, reason?: string) {
    const { data, response, error } = await this.apiClient.POST(
      '/api/comment/block-user',
      {
        body: {
          handle,
          reason,
        },
      }
    );
    if (error) {
      throw formatErrorResponse(error, response);
    }
    return data;
  }

  async unblockUser(handle: string, reason?: string) {
    const { data, response, error } = await this.apiClient.POST(
      '/api/comment/unblock-user',
      {
        body: {
          handle,
          reason,
        },
      }
    );
    if (error) {
      throw formatErrorResponse(error, response);
    }
    return data;
  }

  get clips() {
    return this.clipIds
      .map((id) => this.clipById[id])
      .filter((clip) => !isDisliked(clip))
      .filter((clip) => clip?.status !== 'error');
  }

  // the same as the clips getter, but without filtering out dislikes (for radio)
  get allClips() {
    return this.clipIds
      .map((id) => this.clipById[id])
      .filter((clip) => clip.status !== 'error');
  }

  get playlists() {
    return this.playlistIds
      .map((playlistId: string) => this.playlistById[playlistId])
      .filter((playlist: any) => !!playlist);
  }

  get allPlaylists() {
    return this.allPlaylistIds
      .map((playlistId: string) => this.playlistById[playlistId])
      .filter((playlist: any) => !!playlist);
  }

  get loadingRequests() {
    return Array.from(this.runningRequests).map((id) => this.requestById[id]);
  }

  getPlaylistClips = (id: string) => {
    return this.playlistById[id]?.playlist_clips;
  };

  deductCreditsOrFreeClips = (clip: Clip) => {
    if (clip.preview_seconds != null) return;

    const isEligibleForFreeRemasteredClip =
      !!clip.metadata.upsample_clip_id &&
      this.root.session.freeRemastersLeft > 0;

    const isEligibleForFreeV4Clip =
      clip.major_model_version == 'v4' && this.root.session.freeV4GensLeft > 0;

    if (isEligibleForFreeRemasteredClip) {
      this.root.session.deductFreeRemaster();
    } else if (isEligibleForFreeV4Clip) {
      this.root.session.deductFreeV4Gen();
    }
  };

  refundCreditsOrFreeClips = (clip: Clip) => {
    if (clip.preview_seconds != null) return;

    const usedFreeRemasteredClip =
      clip.metadata.free_quota_category === 'remaster';

    const usedFreeV4Clip = clip.metadata.free_quota_category === 'web_v4_gen';

    if (usedFreeRemasteredClip) {
      this.root.session.refundFreeRemaster();
    } else if (usedFreeV4Clip) {
      this.root.session.refundFreeV4Gen();
    }
  };

  processClipInitialResponse = (
    data: any,
    projectId: string | undefined,
    isProjectsEnabled: boolean | undefined,
    isMatrixModeEnabled: boolean | undefined,
    isPersonaEnabled: boolean | undefined,
    isWebsocketUpdate: boolean = false,
    transactionLogger: TransactionLogger
  ) => {
    this.requestById[data.id] = data;
    const newIds: string[] = [];
    const newClips: Clip[] = [];
    data.clips.forEach((clip: Clip) => {
      this.addClip(
        {
          ...clip,
        },
        !!(
          isProjectsEnabled &&
          (projectId || this.root.project.currentProjectId)
        )
      );

      if (isProjectsEnabled) {
        this.root.project.addClip(clip);
      }

      if (isMatrixModeEnabled) {
        this.setClipConfigurations(clip.id, this.root.genForm.configurations);
      }

      if (!isWebsocketUpdate) {
        this.runningRequests.add(clip.id);
      }

      this.expectCreditDeductionForClip(clip);

      this.clipRequestId[clip.id] = data.id;
      newIds.push(clip.id);
      newClips.push(clip);
      transactionLogger.logWebUserEvent({
        actionName: 'GenerationClipReceived',
        principalObjectType: 'song',
        principalObjectValue: clip.id,
        context: {
          projectId: projectId || this.root.project.currentProjectId,
        },
      });
    });

    if (isProjectsEnabled && this.root.project.currentProjectId) {
      this.root.project.addClipsToProject(
        newClips,
        projectId || this.root.project.currentProjectId
      );
      this.root.project.checkAndUpdateProjectState(
        projectId || this.root.project.currentProjectId
      );
    }

    const continuedFromClip =
      this.clipById[this.root.genForm.continueClipId || ''];

    this.logger.logAudioCreationEvent(
      this.root.genForm.isMobile,
      ActionName.createSong,
      this.root.genForm,
      this.root.session,
      {
        newClipIds: newIds,
        isProjectsEnabled: isProjectsEnabled,
        projectId:
          isProjectsEnabled &&
          (projectId || this.root.project.currentProjectId) !==
            DEFAULT_PROJECT_ID
            ? projectId || this.root.project.currentProjectId
            : null,
        // indicates that the user was shown a warning about mixing v4/non-v4 models in an extend
        isDownsampledExtend:
          (continuedFromClip?.major_model_version === 'v4' &&
            this.root.genForm.mv.indexOf('v4') === -1) ||
          (!!continuedFromClip &&
            continuedFromClip?.major_model_version !== 'v4' &&
            this.root.genForm.mv.indexOf('v4') !== -1),
      }
    );

    const result = data.clips.map((clip: any) => this.clipById[clip.id]);
    return result;
  };

  processClipUpdate = (clip: Clip) => {
    this.updateClips([clip]);
    if (clip.status === 'queued') {
      if (this.root.createV2.pendingMetadataInMode) {
        this.root.createV2.setLastGenMetadataInMode(
          !!clip.metadata.gpt_description_prompt
            ? {
                gpt_description_prompt: clip.metadata.gpt_description_prompt,
                tags: clip.metadata.tags,
                prompt: clip.metadata.prompt,
                clip: clip,
              }
            : null
        );
        this.root.createV2.setPendingMetadataInMode(null);
      }
    }
    if (clip.status === 'streaming') {
      this.expectCreditDeductionForClip(clip);
    }
    if (clip.status === 'complete' || clip.status === 'error') {
      this.runningRequests.delete(clip.id);
      if (clip.metadata.task !== 'gen_stem') {
        this.getVideoGenStatus({ clip: clip }); // TODO: don't ever poll for video gen status, unless user clicks generate video
      }
      if (clip.status !== 'error' && !clip.metadata.refund_credits) {
        this.fulfillCreditDeductionForClip(clip);
      } else if (clip.metadata.refund_credits || clip.status === 'error') {
        this.revertCreditDeductionForClip(clip);
      }

      this.root.session.loadSubscriptionInfo();
      this.generatingClipIds.delete(clip.id);
      if (clip.status === 'complete' && clip.metadata.upsample_clip_id) {
        this.recentCompleteUpsampleClipIds[clip.id] = Date.now();
        setTimeout(() => {
          this.recentCompleteUpsampleClipIds[clip.id] = undefined;
        }, SONG_ROW_SPARKLE_ANIMATE_SECONDS);
      }
      if (clip.status === 'error') {
        const requestId = this.clipRequestId[clip.id];
        if (requestId && this.notifiedErrors.has(requestId)) {
          return;
        }
        if (requestId) {
          this.notifiedErrors.add(requestId);
        }
        if (clip.metadata.error_type === 'moderation_failure') {
          toast({
            title: "Couldn't generate that.",
            description: clip.metadata.error_message,
            status: 'error',
            duration: 6000,
            isClosable: true,
          });
        } else {
          toast({
            title: 'An error occurred generating a clip.',
            description: 'An error occurred.',
            status: 'error',
            duration: 4000,
            isClosable: true,
          });
        }
      }
    } else {
      const timeout = msSinceTimeout(clip);
      // fetch the time since timeout so the error can be shown only once
      if (timeout > 0 && timeout < 5000) {
        this.runningRequests.delete(clip.id);
        toast({
          title: 'A clip got stuck while generating.',
          description: 'No credits were used and you can retry.',
          status: 'error',
          duration: 4000,
          isClosable: true,
        });
      }
    }
  };

  pollRunningGens = async () => {
    const { data } = await this.apiClient.GET('/api/feed/v2', {
      params: {
        query: {
          ids: Array.from(this.runningRequests).join(','),
        },
      },
    });

    if (!data) {
      return;
    }

    data?.clips?.forEach((clip) => {
      this.processClipUpdate(clip);
    });
  };

  likeClip = async (
    clipId: string,
    like: boolean,
    options?: {
      requireClipById?: boolean;
      contextType?: string;
      recommendationItemId?: string;
      hookId?: string;
    }
  ) => {
    const {
      requireClipById = true,
      contextType,
      recommendationItemId,
      hookId,
    } = options || {};
    // Bail if we don't have the clip data in the store already
    if (!this.clipById[clipId] && requireClipById) {
      return;
    }

    // In case the update fails and we need to undo the optimistic update
    const prevClipReaction = this.clipById[clipId]?.reaction;
    const prevClipUpvoteCount = this.clipById[clipId]?.upvote_count;

    // Try an optimistic update
    if (this.clipById[clipId]) {
      this.clipById[clipId].reaction = {
        updated_at: new Date().toISOString(),
        ...(this.clipById[clipId].reaction || {}),
        reaction_type: like ? 'L' : null,
      };
      if (typeof this.clipById[clipId].upvote_count === 'number') {
        this.clipById[clipId].upvote_count += like ? 1 : -1;
      } else {
        this.clipById[clipId].upvote_count = like ? 1 : 0;
      }
    }

    const { error } = await this.apiClient.POST(
      '/api/gen/{gen_id}/update_reaction_type/',
      {
        params: { path: { gen_id: clipId } },
        body: {
          reaction: like ? 'LIKE' : null,
          recommendation_metadata: {
            context_type: contextType,
            recommendation_item_id: recommendationItemId,
            hook_id: hookId,
          },
        },
      }
    );

    if (this.clipById[clipId]) {
      if (error) {
        this.clipById[clipId].reaction = prevClipReaction;
        this.clipById[clipId].upvote_count = prevClipUpvoteCount;
      } else if (
        like &&
        this.root.session.createOnboardingContext?.step === 'like_tooltip'
      ) {
        this.root.session.setCreateOnboardingContext({
          ...this.root.session.createOnboardingContext,
          step: 'share_tooltip',
          isDismissed: false,
        });
      }
    }
  };

  dislikeClip = async (clipId: string, dislike: boolean) => {
    const updateDislikeClip = async () => {
      const isNegatingLike =
        this.clipById[clipId].reaction?.reaction_type === 'L';
      this.clipById[clipId].reaction = {
        ...(this.clipById[clipId].reaction as any),
        reaction_type: dislike ? 'D' : null,
      };
      if (
        typeof this.clipById[clipId].upvote_count !== 'undefined' &&
        isNegatingLike
      ) {
        this.clipById[clipId].upvote_count! -= 1;
      }
      data = await this.apiClient.POST(
        '/api/gen/{gen_id}/update_reaction_type/',
        {
          params: { path: { gen_id: clipId } },
          body: {
            reaction: dislike ? 'DISLIKE' : null,
          },
        }
      );
      return data;
    };

    let data = {};
    data = updateDislikeClip();

    if (data) {
      // TODO: Error handle
    }
  };

  updateSongNotInterested = async (
    clipId: string,
    isNotInterested: boolean
  ) => {
    if (!this.clipById[clipId]) return;

    const FEEDBACK_URL = '/api/recommend/feedback/song/{clip_id}';
    const requestParams = {
      body: {
        feedback_type: 'not_interested' as const,
      },
      params: {
        path: {
          clip_id: clipId,
        },
      },
    };

    if (isNotInterested) {
      const { response, error } = await this.apiClient.POST(
        FEEDBACK_URL,
        requestParams
      );
      if (error) {
        throw formatErrorResponse(error, response);
      }
    } else {
      const { response, error } = await this.apiClient.DELETE(
        FEEDBACK_URL,
        requestParams
      );
      if (error) {
        throw formatErrorResponse(error, response);
      }
    }
    this.notInterestedById[clipId] = isNotInterested;
  };

  isNotInterested(clipId: string) {
    return this.notInterestedById[clipId];
  }

  async setClipConfigurations(
    clipId: string,
    configurations: Record<string, any>
  ) {
    if (!this.clipById[clipId]) {
      console.error('Clip not found:', clipId);
      return;
    }

    if (!this.clipById[clipId].metadata) {
      this.clipById[clipId].metadata = {};
    }

    this.clipById[clipId].metadata.configurations = configurations;

    const { data } = await this.apiClient.POST(
      '/api/gen/{gen_id}/set_configurations/',
      {
        params: { path: { gen_id: clipId } },
        body: { configurations },
      }
    );

    if (data) {
    }
  }

  setMetadataAndDisplayTags = async (
    setMetadataPayload: SetMetadataPayload,
    displayTags?: string
  ) => {
    const existingDisplayTags =
      this.clipById[setMetadataPayload.clipId].display_tags;
    if (
      displayTags !== undefined &&
      (displayTags || existingDisplayTags) &&
      displayTags !== this.clipById[setMetadataPayload.clipId].display_tags
    ) {
      const { success } = await this.setDisplayTags(
        setMetadataPayload.clipId,
        displayTags
      );
      if (!success) {
        return { success: false };
      }
    }
    return await this.setMetadata(setMetadataPayload);
  };

  setDisplayTags = async (clipId: string, displayTags: string) => {
    const { data } = await this.apiClient.POST(
      '/api/gen/{gen_id}/set_display_tags',
      {
        params: { path: { gen_id: clipId } },
        body: { display_tags: displayTags },
      }
    );
    let error: string | undefined = (data as any)?.error_type;
    if (!data) error = 'unknown_error';
    if (error) {
      if (error === 'display_tags_moderation_error') {
        toast({
          title: 'Error',
          description:
            "That style summary didn't pass moderation. Please try again.",
          status: 'error',
          duration: 6000,
          isClosable: true,
        });
        return { success: false };
      } else {
        toast({
          title: 'Error',
          description: 'An unknown error occurred.',
          status: 'error',
          duration: 6000,
          isClosable: true,
        });
        return { success: false };
      }
    } else {
      this.clipById[clipId].display_tags = displayTags;
      return { success: true };
    }
  };

  setMetadata = async ({
    clipId,
    title,
    imageUrl,
    lyrics,
    caption,
    caption_mentions,
    isAudioUploadTOSAccepted,
    videoCoverUploadId,
    removeImageCover,
    removeVideoCover,
    showSuccessToast = true,
    s3Id,
    optOutVideoCoverHook,
  }: SetMetadataPayload) => {
    try {
      const response = await this.apiClient.POST(
        `/api/gen/{gen_id}/set_metadata/`,
        {
          params: { path: { gen_id: clipId } },
          body: {
            title: title,
            image_url: imageUrl,
            lyrics: lyrics,
            caption: caption,
            caption_mentions: caption_mentions,
            is_audio_upload_tos_accepted: isAudioUploadTOSAccepted,
            video_cover_upload_id: videoCoverUploadId,
            remove_image_cover: removeImageCover,
            remove_video_cover: removeVideoCover,
            image_s3_id: s3Id,
            opt_out_video_cover_hook: optOutVideoCoverHook,
          },
        }
      );

      const data = response.data as any;

      if (!data || (data && data.error_type)) {
        let description = 'An error occurred updating your song details.';
        if (data?.error_type === 'title_too_long') {
          description =
            'That title is too long. Please try a shorter title (max 100 characters).';
        } else if (data?.error_type === 'text_moderation_error') {
          description = "That title didn't pass moderation. Please try again.";
        } else if (data?.error_type === 'caption_moderation_error') {
          description =
            "That caption didn't pass moderation. Please try again.";
        } else if (data?.error_type === 'image_moderation_error') {
          description = "That image didn't pass moderation. Please try again.";
        } else if (data?.error_type === 'image_upload_error') {
          description =
            'Image upload failed. The image you uploaded may be invalid.';
        } else if (data?.error_type === 'video_cover_error') {
          description =
            'Video upload failed. The video you uploaded may be invalid.';
        } else {
          description = 'An unknown error occurred.';
        }

        toast({
          title: 'Error',
          description: description,
          status: 'error',
          duration: 6000,
          isClosable: true,
        });
        return { success: false, data };
      } else {
        if (showSuccessToast) {
          toast({
            title: 'Success',
            description: 'Song details updated successfully.',
            status: 'info',
            duration: 5000,
            isClosable: true,
          });
        }

        if (lyrics !== this.clipById[clipId].metadata.prompt) {
          // if lyrics changes, regen the lyric alignment
          this.regenerateAlignedLyrics(this.clipById[clipId]);
        }

        if (title) {
          this.clipById[clipId].title =
            data.title !== undefined && data.title !== null
              ? data.title
              : title;
        }
        if (lyrics) {
          this.clipById[clipId].metadata.prompt = lyrics;
        }
        if (caption !== undefined) {
          this.clipById[clipId].caption = caption;
        }
        if (caption_mentions) {
          this.clipById[clipId].caption_mentions = caption_mentions;
        }
        this.clipById[clipId].image_url = data.image_url;
        this.clipById[clipId].video_cover_url = data.video_cover_url;
        this.clipById[clipId].preview_url = data.preview_url;
        // If the backend returns an updated video_is_stale flag, sync it
        if (!this.clipById[clipId].metadata) {
          this.clipById[clipId].metadata = {};
        }
        this.clipById[clipId].metadata.video_is_stale =
          data.video_is_stale ?? true;
        if (data.opt_out_video_cover_hook !== undefined) {
          this.clipById[clipId].metadata.opt_out_video_cover_hook =
            data.opt_out_video_cover_hook;
        }

        return { success: true, data };
      }
    } catch (error) {
      toast({
        title: 'Network Error',
        description: 'A network error occurred. Please try again.',
        status: 'error',
        duration: 6000,
        isClosable: true,
      });
      return { success: false, error };
    }
  };

  setClipPrompt = async (
    clipId: string,
    newPrompt: string,
    model_name: string
  ) => {
    try {
      const response = await this.apiClient.POST(
        `/api/gen/{gen_id}/set_clip_prompt/`,
        {
          params: { path: { gen_id: clipId } },
          body: {
            prompt: newPrompt,
            model_name: model_name,
          },
        }
      );

      const data = response.data as any;

      if (!data || (data && data.error_type)) {
        let description = 'An error occurred updating your lyrics.';
        if (data?.error_type.includes('prompt_too_long')) {
          description = `Those lyrics are too long. Please try shorter lyrics (max ${
            model_name.includes('v3.5') ||
            model_name.includes('v3-5') ||
            model_name.includes('v4') ||
            model_name.includes('auk')
              ? MAX_CUSTOM_PROMPT_CHARS_LONG
              : model_name.includes('bluejay') || model_name.includes('crow')
                ? MAX_CUSTOM_PROMPT_CHARS_LONGEST
                : MAX_CUSTOM_PROMPT_CHARS
          } characters).`;
        } else if (data?.error_type === 'text_moderation_error') {
          description =
            "Those lyrics didn't pass moderation. Please try again.";
        } else {
          description = `An unknown error occurred.`;
        }

        toast({
          title: 'Error',
          description: description,
          status: 'error',
          duration: 6000,
          isClosable: true,
        });
        return { success: false, data };
      } else {
        toast({
          title: 'Success',
          description: 'Song lyrics updated successfully.',
          status: 'info',
          duration: 5000,
          isClosable: true,
        });

        this.clipById[clipId].metadata.prompt = newPrompt;
        // Lyrics/title changes generally make the existing video stale.
        if (!this.clipById[clipId].metadata) {
          this.clipById[clipId].metadata = {};
        }
        this.clipById[clipId].metadata.video_is_stale =
          data?.video_is_stale ?? true;
        // need to manually call regenerateAlignedLyrics bc alignment
        // doesn't happen when updating set_clip_prompt
        this.regenerateAlignedLyrics(this.clipById[clipId]);

        return { success: true, data };
      }
    } catch (error) {
      toast({
        title: 'Network Error',
        description: 'A network error occurred. Please try again.',
        status: 'error',
        duration: 6000,
        isClosable: true,
      });
      return { success: false, error };
    }
  };

  loadPlaylist = async (
    id: string,
    page: number = 0,
    callback?: () => void,
    loadInBackground: boolean = false
  ) => {
    if (
      this.loadingPlaylistIds.includes(id) ||
      (this.playlistById[id] && page === 0)
    ) {
      return {};
    }
    this.loadingPlaylistIds = [...this.loadingPlaylistIds, id];
    const { data } = await this.apiClient.GET('/api/playlist/{playlist_id}/', {
      params: {
        path: {
          playlist_id: id,
        },
        query: {
          page,
        },
      },
    });

    if (!data) return {};
    if (
      page > 1 &&
      (this.playlistById[id]?.clipIds?.length || 0) <
        (page - 1) * PLAYLIST_CLIPS_PAGE_SIZE
    ) {
      return {};
    }

    const newClipIds = [
      ...(page > 1
        ? this.playlistById[id]?.clipIds?.slice(
            0,
            (page - 1) * PLAYLIST_CLIPS_PAGE_SIZE
          )
        : []),
      ...data.playlist_clips.map((c) => c.clip.id),
    ];

    this.playlistById[id] = {
      ...data,
      clipIds: newClipIds,
    };
    this.loadingPlaylistIds = this.loadingPlaylistIds.filter(
      (plId: string) => plId !== id
    );

    if (!loadInBackground) {
      this.clipIds = newClipIds;
    }

    this.updateClips(data.playlist_clips.map((c) => c.clip));

    if (callback) {
      callback();
    }

    return {
      loadedClips: Object.fromEntries(
        data.playlist_clips
          .filter((playlist_clip: any) => !isTimedOut(playlist_clip.clip))
          .map((clip: any, index: number) => [
            index + (page - 1) * PLAYLIST_CLIPS_PAGE_SIZE,
            clip,
          ])
      ),
      shouldLoadMoreClips:
        data.playlist_clips.length === PLAYLIST_CLIPS_PAGE_SIZE,
    };
  };

  loadTrendingPlaylist = async (
    id: string,
    page: number = 0,
    callback?: () => void
  ) => {
    const { data } = await this.apiClient.GET('/api/playlist/{playlist_id}/', {
      params: {
        path: {
          playlist_id: id,
        },
        query: {
          page,
        },
      },
    });

    if (!data) return false;

    if (callback) {
      callback();
    }

    return data;
  };

  loadTrendingMetaplaylist = async (): Promise<string[]> => {
    const { data } = await this.apiClient.GET('/api/trending/metaplaylist/', {
      params: {},
    });

    if (!data || !data.playlists) return [];

    const playlistIds: string[] = [];

    data.playlists.forEach((playlistData: any) => {
      const { id, playlist_clips } = playlistData;

      if (playlist_clips) {
        this.playlistById[id] = {
          ...playlistData,
          clipIds: playlist_clips.map((pc: any) => pc.clip.id),
        };

        playlistIds.push(id);

        this.updateClips(playlist_clips.map((pc: any) => pc.clip));
      }
    });

    return playlistIds;
  };

  createPlaylist = async (body?: any) => {
    const { data } = await this.apiClient.POST('/api/playlist/create/', {
      body: body || {
        name: 'Untitled',
      },
    });
    return data;
  };

  loadRadio = async ({
    tag,
    extend = false,
    excludeClipIds = [],
  }: {
    tag: string;
    extend?: boolean;
    excludeClipIds?: string[];
  }) => {
    this.loadingRadio = true;
    const { data } = await this.apiClient.GET('/api/radio/{tag}/', {
      params: {
        path: {
          tag,
        },
        query: {
          exclude_clip_ids: excludeClipIds,
        },
      },
    });
    this.loadingRadio = false;
    this.updateClips(data?.clips || []);
    this.clipIds = [
      ...(extend ? this.clipIds : []),
      ...(data?.clips.map((clip: Clip) => clip.id) || []),
    ];
    this.root.queue.setClips(this.allClips);
    return this.clips;
  };

  setClipVisibility = async ({
    clipId,
    isPublic,
    submitToContest = false,
  }: {
    clipId: string;
    isPublic: boolean;
    submitToContest?: boolean;
  }) => {
    if (!this.clipById[clipId]) return;

    this.clipById[clipId].is_public = isPublic;

    const { data } = await this.apiClient.POST(
      '/api/gen/{gen_id}/set_visibility/',
      {
        params: { path: { gen_id: clipId } },
        body: { is_public: isPublic, submit_to_contest: submitToContest },
      }
    );
    this.handleContestSubmission({ clipId, submitToContest });

    if (data) {
    }
  };

  handleContestSubmission = ({
    clipId,
    submitToContest,
  }: {
    clipId: string;
    submitToContest: boolean;
  }) => {
    // use the contest store for client-side state to optimistically update the UI
    this.root.contest.handleEnterContest({
      isEntering: submitToContest,
      clipId,
    });
    if (!submitToContest) {
      // if not submited to contest, clear the contest ids for the clip
      if (
        this.clipById[clipId].metadata?.contest_ids &&
        this.clipById[clipId].metadata?.contest_ids?.length > 0
      ) {
        this.clipById[clipId].metadata.contest_ids = [];
      }
    }
  };

  async setCommentsEnabled(clipId: string, isEnabled: boolean) {
    if (!this.clipById[clipId]) return;

    this.clipById[clipId].allow_comments = isEnabled;

    const { data, error } = await this.apiClient.POST(
      '/api/gen/{clip_id}/toggle_comments/',
      {
        params: { path: { clip_id: clipId } },
        body: { can_comment: isEnabled },
      }
    );

    if (error) {
      throw new Error((error as any).detail);
    }
    return data;
  }

  flagClip = async (
    clipId: string,
    reason: string = '',
    flagged_reason_details?: Record<string, any>
  ) => {
    const { data } = await this.apiClient.POST(
      '/api/gen/{gen_id}/update_flag_state/',
      {
        params: { path: { gen_id: clipId } },
        body: {
          flagged: true,
          flagged_reason: reason,
          flagged_reason_details,
        },
      }
    );

    if (data) {
      toast({
        title: 'Song flagged.',
        description: 'This song has been flagged for review.',
        status: 'warning',
        duration: 4000,
        isClosable: true,
      });
    }

    this.logger.logAudioCreationEvent(
      this.root.genForm.isMobile,
      ActionName.reportInappropriate,
      this.root.genForm,
      this.root.session
    );
  };

  feedbackClip = async (clipId: string, feedbackReason: string = '') => {
    const { data } = await this.apiClient.POST(
      '/api/gen/{gen_id}/update_feedback_state/',
      {
        params: { path: { gen_id: clipId } },
        body: {
          feedback_reason: feedbackReason,
        },
      }
    );

    if (data) {
      if (feedbackReason === '') {
        toast({
          title: 'Your feedback has been removed.',
          description: 'Your feedback has been removed for this clip.',
          status: 'error',
          duration: 4000,
          isClosable: true,
        });
      } else {
        toast({
          title: 'Thank you for your feedback!',
          description: 'Your feedback has been updated for this clip.',
          status: 'success',
          duration: 4000,
          isClosable: true,
        });
      }
    }
  };

  trashClips = async (clipIds: string[], trashed: boolean) => {
    if (clipIds.some((clipId) => !this.clipById[clipId])) return;

    clipIds.forEach((clipId) => {
      this.clipById[clipId].is_trashed = trashed;
    });

    const projects = new Map<string, ProjectMetadataSchema>();

    if (this.root.session.flags?.['collab-workspaces']) {
      const allProjects = await Promise.all(
        clipIds.map((clipId) => {
          return this.getProjectForClip(clipId);
        })
      );

      // Deduplicate by project ID
      allProjects.forEach((project) => {
        if (project?.id) {
          projects.set(project.id, project);
        }
      });
    }

    const { response } = await this.apiClient.POST('/api/gen/trash', {
      body: { trash: trashed, clip_ids: clipIds },
    });

    if (response.ok && this.root.session.flags?.['collab-workspaces']) {
      const action = trashed ? 'remove_clips' : 'add_clips';
      const published =
        workspaceCollaborationService.makeWorkspaceChange(action);
      if (!published) {
        for (const project of projects.values()) {
          const shared = project?.shared;
          const projectId = project?.id;
          if (projectId && projectId !== DEFAULT_PROJECT_ID && shared) {
            this.apiClient.POST('/api/project/{project_id}/ably-update', {
              params: {
                path: {
                  project_id: projectId,
                },
              },
              body: {
                update_type: action,
              },
            });
          }
        }
      }
    }
  };

  deleteClips = async (clipIds: string[]) => {
    if (!clipIds.length) return false;

    try {
      const { response } = await this.apiClient.POST('/api/clips/delete/', {
        body: { ids: clipIds },
      });
      if (!response.ok) {
        const hasPersonaFeature = isPersonaFeatureEnabled(this.root.session);
        const hasPersonaClips =
          hasPersonaFeature &&
          clipIds.some((id) => {
            const clip = this.clipById[id];
            return clip?.metadata?.persona_id;
          });

        toast({
          title: hasPersonaClips
            ? 'Clip could not be deleted. Please delete any associated Personas.'
            : 'Clip could not be deleted.',
          status: 'error',
          duration: 5000,
          isClosable: true,
        });
        return false;
      }
      return true;
    } catch (error) {
      console.error('Error deleting clips:', error);
    }
  };

  loadAlignedLyrics = async (clip: Clip) => {
    if (!this.alignedLyricsByClipId[clip.id]) {
      this.isLoadingAlignedLyrics = true;
      const response = await this.apiClient.GET(
        '/api/gen/{clip_id}/aligned_lyrics/v2/',
        {
          params: { path: { clip_id: clip.id } },
        }
      );
      const alignedLyrics = (response as any)?.data?.aligned_words || undefined;
      const pregenWaveform =
        (response as any)?.data?.waveform_data || undefined;
      const hootErrorRate = (response as any)?.data?.hoot_cer || undefined;
      if (alignedLyrics) {
        this.alignedLyricsByClipId[clip.id] = alignedLyrics;
      }
      if (pregenWaveform) {
        this.pregenWaveformByClipId[clip.id] = {
          waveformData: pregenWaveform,
          hootErrorRate,
        };
      }
      this.isLoadingAlignedLyrics = false;
    }
  };

  regenerateVideo = async (
    clip: Clip,
    router?: any,
    statsigClient?: any,
    openDownloadConfirmModalCallback?:
      | ((
          clip: Clip,
          menus: MenusStore,
          session: SessionStore,
          clips: ClipsStore,
          router: AppRouterInstance,
          statsigClient: any,
          apiClient: any,
          confirmFn: () => void
        ) => Promise<void>)
      | null
  ) => {
    if (this.clipById[clip?.id]) {
      this.videoPendingById[clip?.id] = true;
    }
    const toastId = toast({
      title: !!clip.video_url
        ? 'Generating updated video...'
        : 'Generating video...',
      description: 'Video will be downloaded automatically after generation',
      status: 'info',
      duration: null,
      isClosable: true,
    });
    this.videoGenerationToastId[clip?.id] = toastId;
    const {
      data: _,
      error,
      response,
    } = await this.apiClient.POST('/api/video/generate/{clip_id}/', {
      params: { path: { clip_id: clip?.id } },
    });
    if (error || !response?.ok) {
      toast({
        title: 'Error generating video',
        description: 'An error occurred. Please try again.',
        status: 'error',
        duration: 4000,
        isClosable: true,
      });
      this.videoPendingById[clip?.id] = false;
      return;
    }
    this.getVideoGenStatus({
      clip: clip,
      retries: 60,
      download: true,
      generateCalled: true,
      router: router,
      statsigClient: statsigClient,
      openDownloadConfirmModalCallback: openDownloadConfirmModalCallback,
    });
  };

  regenerateAlignedLyrics = async (clip: Clip) => {
    this.pendingAlignedLyricsClipIds.add(clip.id);
    const response = await this.apiClient.POST(
      '/api/gen/{clip_id}/aligned_lyrics/v2/',
      {
        params: { path: { clip_id: clip.id } },
      }
    );
    const alignedLyrics = (response as any)?.data?.aligned_words || undefined;
    const pregenWaveform = (response as any)?.data?.waveform_data || undefined;
    const hootErrorRate = (response as any)?.data?.hoot_cer || undefined;
    if (alignedLyrics) {
      this.alignedLyricsByClipId[clip.id] = alignedLyrics;
    }
    if (pregenWaveform) {
      this.pregenWaveformByClipId[clip.id] = {
        waveformData: pregenWaveform,
        hootErrorRate,
      };
    }
    this.pendingAlignedLyricsClipIds.delete(clip.id);
  };

  makeStems = async (clip: Clip) => {
    const { data } = await this.apiClient.POST('/api/edit/stems/{clip_id}/', {
      params: { path: { clip_id: clip?.id } },
    });

    if (data) {
      const isProjectsEnabled = isProjectsFeatureEnabled(this.root.session);

      data.clips?.forEach((clip) => {
        this.addClip(
          clip,
          !!(isProjectsEnabled && this.root.project.currentProjectId)
        );
        if (isProjectsEnabled) {
          this.root.project.addClip(clip);
        }
        this.runningRequests.add(clip.id);
      });

      if (
        isProjectsEnabled &&
        this.root.project.currentProjectId &&
        data.clips?.length
      ) {
        this.root.project.addClipsToProject(
          data.clips,
          this.root.project.currentProjectId
        );
      }
    }
  };

  upsampleClip = async (
    clip: Clip,
    modelName?: string,
    sliders?: { [sliderKey: string]: number },
    stylesParam?: string,
    variationCategory?: 'subtle' | 'normal' | 'high'
  ) => {
    const params: UpsampleParams = { clip_id: clip?.id };
    await this.root.project.setCurrentProjectToClipProject(clip);
    const validatedFreedomParam =
      sliders?.freedom !== undefined
        ? Math.max(0.0, Math.min(1.0, sliders.freedom))
        : null;
    const validatedTone =
      sliders?.tone !== undefined
        ? Math.max(0.0, Math.min(1.0, sliders.tone))
        : null;
    const validatedClarity =
      sliders?.clarity !== undefined
        ? Math.max(0.0, Math.min(1.0, sliders.clarity))
        : null;
    const validatedStrength =
      sliders?.strength !== undefined
        ? Math.max(0.0, Math.min(1.0, sliders.strength))
        : null;
    const validatedStereoWidth =
      sliders?.stereoWidth !== undefined
        ? Math.max(0.0, Math.min(1.0, sliders.stereoWidth))
        : null;
    if (modelName) {
      params.model_name = modelName;
    }
    if (!!stylesParam && stylesParam.trim().length > 0) {
      params.tags = stylesParam;
    }
    if (validatedFreedomParam && validatedFreedomParam !== 0) {
      params.freedom = validatedFreedomParam;
    }
    if (validatedTone && validatedTone !== 0.5) {
      params.tone = validatedTone;
    }
    if (validatedStrength && validatedStrength !== 0.5) {
      params.strength = validatedStrength;
    }
    if (validatedStereoWidth && validatedStereoWidth !== 0.5) {
      params.stereo_width = validatedStereoWidth;
    }
    if (validatedClarity && validatedClarity !== 0.5) {
      params.clarity = validatedClarity;
    }
    if (variationCategory) {
      params.variation_category = variationCategory;
    }

    const { data, response, error } = await this.apiClient.POST(
      '/api/generate/upsample',
      {
        body: params,
      }
    );

    if (data) {
      const isProjectsEnabled = isProjectsFeatureEnabled(this.root.session);
      data.clips.forEach((clip: Clip) => {
        this.addClip(
          clip,
          !!(isProjectsEnabled && this.root.project.currentProjectId)
        );
        if (isProjectsEnabled) {
          this.root.project.addClip(clip);
        }
        this.runningRequests.add(clip.id);
        this.expectCreditDeductionForClip(clip);
      });

      if (
        isProjectsEnabled &&
        this.root.project.currentProjectId &&
        data.clips?.length
      ) {
        this.root.project.addClipsToProject(
          data.clips,
          this.root.project.currentProjectId
        );
      }
      invalidateWorkspaceQueries(
        this.root.project.currentProjectId ?? 'default'
      );
      return data.clips;
    } else if (response.status === 400) {
      toast({
        title: 'Remaster not allowed.',
        description: 'Subscribe to access Remaster.',
        status: 'error',
        duration: 4000,
        isClosable: true,
      });
    } else if (
      response.status === 422 &&
      (error as any).detail === 'Title too long.'
    ) {
      toast({
        title: 'Remaster failed.',
        description:
          'Please use "Edit > Song Details" to set a shorter title (max 80 characters).',
        status: 'error',
        duration: 4000,
        isClosable: true,
      });
    } else {
      toast({
        title: 'Remaster failed.',
        description: 'An error occurred.',
        status: 'error',
        duration: 4000,
        isClosable: true,
      });
    }
    return null;
  };

  getVideoGenStatus = async ({
    clip,
    retries = 60,
    download = false,
    generateCalled = false,
    router = null,
    statsigClient = null,
    openDownloadConfirmModalCallback = null,
  }: {
    clip: Clip;
    retries?: number;
    download?: boolean;
    generateCalled?: boolean;
    router?: any;
    statsigClient?: any;
    openDownloadConfirmModalCallback?:
      | ((
          clip: any,
          menus: any,
          session: any,
          clips: any,
          router: any,
          statsigClient: any,
          apiClient: any,
          confirmFn: () => void
        ) => Promise<void>)
      | null;
  }) => {
    const { data } = await this.apiClient.GET(
      '/api/video/generate/{clip_id}/status/',
      {
        params: { path: { clip_id: clip?.id } },
      }
    );
    if ((data as any)?.status === 'complete') {
      if (this.clipById[clip?.id]) {
        this.videoPendingById[clip?.id] = false;
        this.clipById[clip?.id].video_url = (data as any)?.video_url;
        // Only clear/update video_is_stale if backend confirms, otherwise reload latest clip state
        if (!this.clipById[clip?.id].metadata) {
          this.clipById[clip?.id].metadata = {};
        }
        this.clipById[clip?.id].metadata.video_is_stale = (
          data as any
        )?.video_is_stale;
        if (generateCalled) {
          if (this.videoGenerationToastId[clip?.id]) {
            toast.close(this.videoGenerationToastId[clip?.id]);
            delete this.videoGenerationToastId[clip?.id];
          }
          toast({
            title: 'Video generated and downloaded automatically',
            status: 'success',
            duration: 4000,
            isClosable: true,
          });
        }
      }
      if (download && router && openDownloadConfirmModalCallback) {
        openDownloadConfirmModalCallback(
          clip,
          this.root.menus,
          this.root.session,
          this.root.clips,
          router,
          statsigClient,
          this.apiClient,
          () => {
            downloadMedia(
              this.apiClient,
              clip,
              'video',
              this.root.session,
              (data as any)?.video_url,
              true
            );
          }
        );
      }
    } else {
      if (retries === 0) {
        if (this.clipById[clip?.id]) {
          this.videoPendingById[clip?.id] = false;
        }
        toast({
          title: 'Video generation timed out',
          description: 'Please try again.',
          status: 'error',
          duration: 4000,
          isClosable: true,
        });
        if (this.videoGenerationToastId[clip?.id]) {
          toast.close(this.videoGenerationToastId[clip?.id]);
          delete this.videoGenerationToastId[clip?.id];
        }
        return;
      }
      if (this.clipById[clip?.id]) {
        this.videoPendingById[clip?.id] = true;
      }
      setTimeout(
        () =>
          this.getVideoGenStatus({
            clip: clip,
            retries: retries - 1,
            download: download,
            generateCalled: generateCalled,
            router: router,
            statsigClient: statsigClient,
            openDownloadConfirmModalCallback: openDownloadConfirmModalCallback,
          }),
        4000
      );
    }
  };

  setFeedbackGivenForClip(
    clipId: string | undefined,
    isFeedbackGiven: boolean
  ) {
    if (clipId) {
      this.feedbackGivenByClipId[clipId] = isFeedbackGiven;
    }
  }

  setDirectChildren = (
    clipId: string,
    children: Clip[],
    currentPage: number
  ) => {
    this.directChildrenByClipId[clipId] = {
      clips:
        currentPage === 1
          ? children
          : [
              ...(this.directChildrenByClipId[clipId]?.clips || []),
              ...children,
            ],
      currentPage,
    };
  };

  getDirectChildren = async (clipId: string, page: number = 1) => {
    if (this.loadingDirectChildren.has(clipId)) return;

    this.loadingDirectChildren.add(clipId);
    try {
      const { data } = await this.apiClient.GET('/api/clips/direct_children', {
        params: {
          query: {
            clip_id: clipId,
            page,
            page_size: 20,
          },
        },
      });

      if (data) {
        this.setDirectChildren(clipId, data.children || [], data.current_page);
      }
    } catch (error) {
      console.error('Error fetching direct children:', error);
    } finally {
      this.loadingDirectChildren.delete(clipId);
    }
  };

  getDirectChildrenCount = async (clipId: string) => {
    try {
      if (!this.root.session.user?.id) {
        return 0;
      }
      const { data } = await this.apiClient.GET(
        '/api/clips/direct_children_count',
        {
          params: {
            query: {
              clip_id: clipId,
            },
          },
        }
      );
      return data?.count || 0;
    } catch (error) {
      console.error('Error fetching direct children count:', error);
      return 0;
    }
  };

  setDisplayableRemixes = (
    clipId: string,
    children: Clip[],
    currentPage: number
  ) => {
    this.displayableRemixesByClipId[clipId] = {
      clips: children,
      currentPage,
    };
  };

  getDisplayableRemixes = async (clipId: string, page: number = 1) => {
    if (this.loadingDisplayableRemixes.has(clipId)) return;

    this.loadingDisplayableRemixes.add(clipId);
    try {
      const { data } = await this.apiClient.GET(
        '/api/clips/displayable_remixes',
        {
          params: {
            query: {
              clip_id: clipId,
              page,
              page_size: 20,
            },
          },
        }
      );

      if (data) {
        this.setDisplayableRemixes(
          clipId,
          data.children || [],
          data.current_page
        );
      }
    } catch (error) {
      console.error('Error fetching displayable remixes:', error);
    } finally {
      this.loadingDisplayableRemixes.delete(clipId);
    }
  };

  getDisplayableRemixesCount = async (clipId: string) => {
    try {
      if (!this.root.session.user?.id) {
        return 0;
      }
      const { data } = await this.apiClient.GET(
        '/api/clips/displayable_remixes_count',
        {
          params: {
            query: {
              clip_id: clipId,
            },
          },
        }
      );
      return data?.count || 0;
    } catch (error) {
      console.error('Error fetching displayable remixes count:', error);
      return 0;
    }
  };

  getGeneratedVideos = async (clipId: string) => {
    const { data } = await this.apiClient.GET('/api/video_gen/videos', {
      params: {
        query: {
          clip_id: clipId,
          page_size: 1,
        },
      },
    });
    return data?.video_urls || [];
  };

  toggleCanRemix = async (clipId: string, canRemix: boolean) => {
    if (!this.clipById[clipId]) return;

    // Optimistically update the local state
    if (!this.clipById[clipId].metadata) {
      this.clipById[clipId].metadata = {};
    }
    this.clipById[clipId].metadata.can_remix = canRemix;

    try {
      const { data, error } = await this.apiClient.POST(
        '/api/clips/{clip_id}/toggle_remixes/',
        {
          params: { path: { clip_id: clipId } },
          body: { can_remix: canRemix } as any,
        }
      );

      if (error) {
        // Revert the local state if the API call fails
        this.clipById[clipId].metadata.can_remix = !canRemix;
        throw new Error((error as any).detail);
      }

      // Update the clip with the response data to ensure consistency
      if (data) {
        this.updateClips([
          {
            ...this.clipById[clipId],
            metadata: {
              ...this.clipById[clipId].metadata,
              can_remix: (data as any).can_remix,
            },
          },
        ]);
      }

      return data;
    } catch (e) {
      console.error('Error toggling remix permission:', e);
      throw e;
    }
  };

  toggleVideoHookFeedVisibility = async (
    clipId: string,
    showInFeed: boolean
  ) => {
    if (!this.clipById[clipId]) return;

    // Optimistically update the local state
    if (!this.clipById[clipId].metadata) {
      this.clipById[clipId].metadata = {};
    }
    this.clipById[clipId].metadata.opt_out_video_cover_hook = !showInFeed;

    try {
      const result = await this.setMetadata({
        clipId,
        title: this.clipById[clipId].title || '',
        optOutVideoCoverHook: !showInFeed,
        showSuccessToast: false,
      });

      if (!result.success) {
        // Revert the local state if the API call fails
        this.clipById[clipId].metadata.opt_out_video_cover_hook = showInFeed;
        throw new Error('Failed to update video hook feed visibility');
      }

      return result.data;
    } catch (e) {
      console.error('Error toggling video hook feed visibility:', e);
      throw e;
    }
  };

  setAllRemixPermissions = async (canRemix: boolean) => {
    try {
      const { data, error } = await this.apiClient.POST(
        '/api/clips/set_all_remix_permissions/',
        {
          params: {
            query: { can_remix: canRemix },
          },
        } as any
      );

      if (error) {
        console.log('ERROR', error);
      }

      // Update user config in session store if needed
      if (this.root.session) {
        this.root.session.setHasSetRemixPerm(true);
      }

      toast({
        title: 'Success',
        description: 'Remix permissions updated successfully.',
        status: 'success',
        duration: 5000,
        isClosable: true,
      });

      return { success: true, data };
    } catch (e) {
      console.error('Error setting all remix permissions:', e);
      toast({
        title: 'Error',
        description: 'Failed to update remix permissions.',
        status: 'error',
        duration: 5000,
        isClosable: true,
      });
      return { success: false, error: e };
    }
  };

  async setRemixType(clipId: string, remixType: string) {
    const { data, response, error } = await this.apiClient.POST(
      '/api/clips/{clip_id}/set_remix_type',
      {
        body: {
          remix: remixType,
        } as any,
        params: {
          path: {
            clip_id: clipId,
          },
          query: { data: {} },
        },
      }
    );

    if (error) {
      throw formatErrorResponse(error, response);
    }

    if (this.clipById[clipId]) {
      this.clipById[clipId].metadata = {
        ...this.clipById[clipId].metadata,
        remix: remixType,
      };
    }

    return data;
  }

  toggleShowRemixes = async (clipId: string, showRemix: boolean) => {
    if (!this.clipById[clipId]) return;

    // Optimistically update the local state
    if (!this.clipById[clipId].metadata) {
      this.clipById[clipId].metadata = {};
    }
    this.clipById[clipId].metadata.show_remix = showRemix;

    try {
      const { data, error } = await this.apiClient.POST(
        '/api/clips/{clip_id}/toggle_show_remixes',
        {
          params: { path: { clip_id: clipId } },
          body: { show_remix: showRemix } as any,
        }
      );

      if (error) {
        // Revert the local state if the API call fails
        this.clipById[clipId].metadata.show_remix = !showRemix;
        throw new Error((error as any).detail);
      }

      if (data) {
        this.updateClips([
          {
            ...this.clipById[clipId],
            metadata: {
              ...this.clipById[clipId].metadata,
              show_remix: (data as any).show_remix,
            },
          },
        ]);
      }

      return data;
    } catch (e) {
      console.error('Error toggling show remixes setting:', e);
      throw e;
    }
  };

  // Function to fetch pinned clips
  fetchPinnedClips = async () => {
    try {
      const { data, error } = await this.apiClient.GET(
        '/api/profiles/pinned-clips'
      );

      if (error) {
        console.error('Error fetching pinned clips:', error);
        this.pinnedClipsLoaded = true;
        return [];
      }

      if (data?.pinned_clips) {
        // Update the clips in the store
        //this.updateClips(data.pinned_clips);

        // Store the pinned clip IDs in their correct order
        this.pinnedClipIds = data.pinned_clips.map((clip: Clip) => clip.id);

        this.pinnedClipsLoaded = true;

        return data.pinned_clips;
      }

      return [];
    } catch (error) {
      console.error('Error fetching pinned clips:', error);
      this.pinnedClipsLoaded = true;
      return [];
    }
  };

  // Helper function to check if a clip is pinned
  isClipPinned = (clipId: string) => {
    return this.pinnedClipIds.includes(clipId);
  };

  getPinnedCount = () => {
    return this.pinnedClipIds.length;
  };

  // Enhanced version of pinClipToProfile that updates the pinnedClipIds
  pinClipToProfile = async ({
    clipId,
    submitToContest = false,
  }: {
    clipId: string;
    submitToContest?: boolean;
  }) => {
    try {
      const { data, response } = await this.apiClient.POST(
        '/api/profiles/pin-clip/{clip_id}',
        {
          params: {
            path: { clip_id: clipId },
          },
          body: {
            submit_to_contest: submitToContest,
            max_pins: this.root.session.flags?.['artist-profiles']
              ? MAX_PINNED_SONGS_ARTIST_PROFILE
              : MAX_PINNED_SONGS_PROFILE,
          },
        }
      );

      if (response.ok) {
        if (submitToContest) {
          this.handleContestSubmission({
            clipId,
            submitToContest,
          });
        }
        // Update the clip's pinned status in the store
        if (this.clipById[clipId]) {
          // this.clipById[clipId].is_pinned = true;
          // this.clipById[clipId].is_public = true;
          //this.updateClips([this.clipById[clipId]], false);
        }

        // If the API returns the updated list of pinned clips, update our list
        if (data?.pinned_clips) {
          // Update the clips in the store
          //this.updateClips(data.pinned_clips);

          // Update the pinnedClipIds to maintain the correct order
          this.pinnedClipIds = data.pinned_clips.map((clip: Clip) => {
            if (this.clipById[clip.id]) {
              this.clipById[clip.id].is_public = true;
              this.clipById[clip.id].is_pinned = true;
            }
            return clip.id;
          });
        } else {
          // If the API doesn't return the updated list, add the clip to the beginning
          this.pinnedClipIds = [
            clipId,
            ...this.pinnedClipIds.filter((id) => id !== clipId),
          ];
        }

        return { success: true, pinnedClips: data?.pinned_clips || [] };
      } else {
        return { success: false, error: 'Failed to pin clip to profile' };
      }
    } catch (error) {
      console.error('Error pinning clip to profile:', error);
      return { success: false, error };
    }
  };

  // Helper function to sort clips with pinned clips first and preserving original order for unpinned
  sortClipsWithPinnedFirst = (clips: Clip[], sortKey?: string) => {
    // Create a map of original positions
    const originalPositions = new Map();
    clips.forEach((clip, index) => {
      originalPositions.set(clip.id, index);
    });

    return [...clips].sort((a, b) => {
      const aIsPinned = this.pinnedClipIds.includes(a.id);
      const bIsPinned = this.pinnedClipIds.includes(b.id);

      // First check if both are pinned
      if (aIsPinned && bIsPinned) {
        // If both are pinned, sort by the order in pinnedClipIds
        const aIndex = this.pinnedClipIds.indexOf(a.id);
        const bIndex = this.pinnedClipIds.indexOf(b.id);

        // If both are in pinnedClipIds, sort by their position
        if (aIndex !== -1 && bIndex !== -1) {
          return aIndex - bIndex;
        }

        // If only one is in pinnedClipIds, prioritize it
        if (aIndex !== -1) return -1;
        if (bIndex !== -1) return 1;
      }

      // If only one is pinned, prioritize it
      if (aIsPinned && !bIsPinned) return -1;
      if (!aIsPinned && bIsPinned) return 1;

      // If neither is pinned, sort by the specified sort key
      if (!aIsPinned && !bIsPinned && sortKey) {
        // Handle different sort keys
        if (sortKey === 'upvote_count') {
          return (b.upvote_count || 0) - (a.upvote_count || 0); // Sort by likes (descending)
        } else if (sortKey === 'created_at') {
          return (
            new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
          ); // Sort by date (newest first)
        }
      }

      // Fall back to original order if no sort key or unknown sort key
      return originalPositions.get(a.id) - originalPositions.get(b.id);
    });
  };

  getParentClip = async (clipId: string) => {
    try {
      if (!this.root.session.user?.id) {
        return null;
      }
      const { data } = await this.apiClient.GET('/api/clips/parent', {
        params: {
          query: {
            clip_id: clipId,
          },
        },
      });

      // Return the data if it has an id OR if it has user information (link only parent clip)
      return data?.id || data?.user_handle ? data : null;
    } catch (error) {
      console.error('Error fetching parent clip:', error);
      return null;
    }
  };

  setPendingShareAssetPolling() {
    if (this.pendingShareAssetClip && this.shareAssetId) {
      this.shareAssetIntervalId = setInterval(async () => {
        if (this.pendingShareAssetClip && this.shareAssetId) {
          try {
            const response = await getShareAssetStatus(
              this.apiClient,
              this.pendingShareAssetClip,
              this.shareAssetId
            );

            const data = response.data;
            if (data) {
              this.shareAssetStatus = data.render_status;
              if (data.render_status === 'complete' && data.asset_url) {
                this.shareAssetUrl = data.asset_url;
                this.clearShareAssetPolling();
              } else if (data.render_status === 'error') {
                toast({
                  title: 'Share asset generation failed',
                  description: 'Please try again.',
                  status: 'error',
                  duration: 4000,
                  isClosable: true,
                });
                this.clearShareAsset();
              } else {
                // Timeout the pending share asset request
                if (
                  this.pendingShareAssetAtTime &&
                  Date.now() - this.pendingShareAssetAtTime > 300000 // 5 minutes timeout
                ) {
                  logWebUserEvent({
                    actionName: 'DownloadShareAssetTimeout',
                    context: {
                      pendingShareAssetClipId: this.pendingShareAssetClip.id,
                      isMobile: false,
                    },
                  });
                  toast({
                    title: 'Share asset generation timed out',
                    description: 'Please try again.',
                    status: 'error',
                    duration: 4000,
                    isClosable: true,
                  });
                  this.clearShareAsset();
                }
              }
            }
          } catch (error) {
            console.error('Error polling share asset status:', error);
          }
        }
      }, 2000);
    }
  }

  clearShareAssetPolling = () => {
    if (this.shareAssetIntervalId) {
      clearInterval(this.shareAssetIntervalId as NodeJS.Timeout);
      this.shareAssetIntervalId = null;
    }
  };

  clearShareAsset = () => {
    this.clearShareAssetPolling();
    this.pendingShareAssetAtTime = null;
    this.pendingShareAssetClip = null;
    this.shareAssetId = null;
    this.shareAssetUrl = null;
    this.shareAssetStatus = null;
  };

  createShareAsset = async (
    clip: Clip,
    config: {
      asset_config: {
        preset_id: string;
        preset_style: string;
        sticker_style: string;
        lyrics_style: string;
      };
      clip_start_time: number;
      clip_end_time: number;
    }
  ) => {
    if (!clip) return null;

    this.pendingShareAssetClip = clip;
    this.pendingShareAssetAtTime = Date.now();
    this.shareAssetStatus = 'rendering';

    try {
      const response = await createShareAsset(this.apiClient, clip, config);

      const data = response.data;
      if (data) {
        this.shareAssetId = data.asset_id;
        this.setPendingShareAssetPolling();
        return data.asset_id;
      }
    } catch (error) {
      console.error('Error creating share asset:', error);
      toast({
        title: 'Failed to create share asset',
        description: 'Please try again.',
        status: 'error',
        duration: 4000,
        isClosable: true,
      });
      this.clearShareAsset();
      return null;
    }
  };

  getRemixesInspired = async (
    handle: string,
    page: number = 1,
    pageSize: number = 20
  ) => {
    try {
      const { data, error } = (await this.apiClient.GET(
        '/api/profiles/{handle}/remixes-inspired/',
        {
          params: {
            path: {
              handle,
            },
            query: {
              page,
              page_size: pageSize,
            },
          },
        }
      )) as {
        data: {
          clips: Clip[];
          current_page: number;
          total_pages: number;
          total_count: number;
        };
        error: any;
      };

      if (error) {
        console.error('Error fetching remixes inspired:', error);
        return { clips: [], current_page: 1, total_pages: 0, total_count: 0 };
      }

      if (data) {
        this.updateClips(data.clips);
      }

      return data;
    } catch (error) {
      console.error('Error fetching remixes inspired:', error);
      return { clips: [], current_page: 1, total_pages: 0, total_count: 0 };
    }
  };

  getRemixesInspiredCount = async (handle: string) => {
    // Return cached count if available
    if (this.remixesInspiredCountByHandle[handle] !== undefined) {
      return this.remixesInspiredCountByHandle[handle];
    }

    try {
      const { data, error } = (await this.apiClient.GET(
        '/api/profiles/{handle}/remixes-inspired-count/',
        {
          params: {
            path: {
              handle,
            },
          },
        }
      )) as { data: { count: number }; error: any };

      if (error) {
        console.error('Error fetching remixes inspired count:', error);
        return 0;
      }

      // Cache the count
      this.remixesInspiredCountByHandle[handle] = data?.count || 0;
      return data?.count || 0;
    } catch (error) {
      console.error('Error fetching remixes inspired count:', error);
      return 0;
    }
  };

  getDirectChildrenByUser = async (
    clipId: string,
    page: number = 1,
    pageSize: number = 20
  ) => {
    try {
      const { data, error } = await this.apiClient.GET(
        '/api/clips/direct_children_by_user/',
        {
          params: {
            query: {
              clip_id: clipId,
              page,
              page_size: pageSize,
            },
          },
        }
      );

      if (error) {
        console.error('Error fetching direct children by user:', error);
        return null;
      }

      if (data && data.user_groups) {
        // Update clips in the store
        (data.user_groups as unknown as Array<{ first_remix: Clip }>).forEach(
          (group) => {
            if (group.first_remix) {
              this.updateClips([group.first_remix]);
            }
          }
        );
      }

      return data;
    } catch (error) {
      console.error('Error fetching direct children by user:', error);
      return null;
    }
  };

  getUserRemixesForClip = async (
    clipId: string,
    userId: string,
    page: number = 1,
    pageSize: number = 20
  ) => {
    try {
      const { data, error } = await this.apiClient.GET(
        '/api/clips/user_remixes_for_clip/',
        {
          params: {
            query: {
              clip_id: clipId,
              user_id: userId,
              page,
              page_size: pageSize,
            },
          },
        }
      );

      if (error) {
        console.error('Error fetching user remixes for clip:', error);
        return null;
      }

      if (data && data.remixes) {
        this.updateClips(data.remixes as Clip[]);
      }

      return data;
    } catch (error) {
      console.error('Error fetching user remixes for clip:', error);
      return null;
    }
  };

  getDisplayableRemixesByUser = async (
    clipId: string,
    page: number = 1,
    pageSize: number = 20
  ) => {
    try {
      const { data, error } = await this.apiClient.GET(
        '/api/clips/displayable_remixes_by_user/',
        {
          params: {
            query: {
              clip_id: clipId,
              page,
              page_size: pageSize,
            },
          },
        }
      );

      if (error) {
        console.error('Error fetching displayable remixes by user:', error);
        return null;
      }

      if (data && data.user_groups) {
        // Update clips in the store
        (data.user_groups as unknown as Array<{ first_remix: Clip }>).forEach(
          (group) => {
            if (group.first_remix) {
              this.updateClips([group.first_remix]);
            }
          }
        );
      }

      return data;
    } catch (error) {
      console.error('Error fetching displayable remixes by user:', error);
      return null;
    }
  };

  getDisplayableUserRemixesForClip = async (
    clipId: string,
    userId: string,
    page: number = 1,
    pageSize: number = 20
  ) => {
    try {
      const { data, error } = await this.apiClient.GET(
        '/api/clips/displayable_user_remixes_for_clip/',
        {
          params: {
            query: {
              clip_id: clipId,
              user_id: userId,
              page,
              page_size: pageSize,
            },
          },
        }
      );

      if (error) {
        console.error(
          'Error fetching displayable user remixes for clip:',
          error
        );
        return null;
      }

      if (data && data.remixes) {
        this.updateClips(data.remixes as Clip[]);
      }

      return data;
    } catch (error) {
      console.error('Error fetching displayable user remixes for clip:', error);
      return null;
    }
  };

  getVideoIsStale = async (clipId: string) => {
    try {
      const data = await this.loadClipById(clipId);
      if (data) {
        // Sync the latest clip data into the store
        this.updateClips([data]);
        return !!data?.metadata?.video_is_stale;
      }
      return !!this.clipById[clipId]?.metadata?.video_is_stale;
    } catch (e) {
      console.error('Error fetching latest clip for video_is_stale:', e);
      return !!this.clipById[clipId]?.metadata?.video_is_stale;
    }
  };
}

export const isTimedOut = (clip: Clip) => {
  return msSinceTimeout(clip) > 0;
};

export const isLiked = (clip: Clip) => {
  return clip?.reaction?.reaction_type === 'L';
};

export const isDisliked = (clip: Clip) => {
  return clip?.reaction?.reaction_type === 'D';
};

export const isFullSong = (clip: Clip) => {
  return (
    !clip.metadata?.task ||
    clip.metadata?.type === 'concat' ||
    clip.metadata?.type === 'concat_infilling'
  );
};

export const isUpload = (clip: Clip) => {
  return clip.metadata?.type === 'upload';
};

export const isStem = (clip: Clip) => {
  return !!clip.model_name?.includes('-stem');
};

export const usedPersona = (clip: Clip) => {
  return !!clip.metadata?.persona_id;
};

export const isCover = (clip: Clip) => {
  return !!clip.metadata?.cover_clip_id;
};

export const isExtend = (clip: Clip) => {
  // TODO: check for the presence of an extended id or similar, to catch clips which are extend + something else
  return (
    clip.metadata?.task === 'extend' || clip.metadata?.task === 'upload_extend'
  );
};

export const isRemaster = (clip: Clip) => {
  return clip.metadata?.task === 'upsample';
};

export const isConcat = (clip: Clip) => {
  return (
    clip.metadata?.type === 'concat' ||
    clip.metadata?.type === 'concat_infilling'
  );
};

export const canAddVocal = (clip: Clip) => {
  const prompt = clip.metadata?.prompt || '';
  const isPromptEmptyOrBracketsOnly = !prompt || /^\[.*\]$/.test(prompt.trim());

  return (
    clip.metadata?.type === 'upload' ||
    clip.metadata?.stem_type_group_name === 'Instrumental' ||
    isPromptEmptyOrBracketsOnly
  );
};

export const canAddInstrumental = (clip: Clip) => {
  return (
    clip.metadata?.type === 'upload' ||
    ['Vocals', 'Backing_Vocals'].includes(
      clip.metadata?.stem_type_group_name as any
    )
  );
};

export const canRemix = (clip: Clip) => {
  return clip.metadata?.can_remix && !clip.is_trashed;
};

export const getFeedbackReasons = (clip: Clip) => {
  if (!clip || !clip.reaction) return [];
  const reasons = clip.reaction.feedback_reason
    ? clip.reaction.feedback_reason.split(',')
    : [];
  return reasons;
};
