import BackendEnvironmentClient
import ClerkClient
import Combine
import ComposableArchitecture
import Foundation
import GenAPI
import Get
import RageshakeClient
import StatsigClient
import SunoModelClient
import Utilities

public typealias SortBy = Paths.Playlist.WithPlaylistId.GetParameters.SortBy
public typealias SortOrder = Paths.Playlist.WithPlaylistId.GetParameters.SortOrder

// swiftlint:disable file_length

@DependencyClient
public struct APIClientV2 {
    public enum ClipsSortBy: String {
        case upvoteCount = "upvote_count"
        case createdAt = "created_at"
        case playCount = "play_count"
    }

    public enum IncrementableAction: String {
        case share
        case downloadAudio = "download_audio"
        case downloadVideo = "download_video"
    }

    // MARK: Util

    public var invalidToken: () -> AsyncStream<Void> = { XCTFail("\(Self.self).invalidToken"); return .init(unfolding: { nil }) }

    // MARK: Unauthenticated

    public var postAppVersionUpdate: (AppVersionUpdateRequest) async throws -> AppVersionUpdateResponse

    // MARK: Authenticated

    public var getFeed: @Sendable (_ page: Int) async throws -> [Clip]
    public var getFeedV2: @Sendable (
        _ page: Int,
        _ isPublic: Bool?,
        _ isLiked: Bool?,
        _ isVideoToSong: Bool?,
        _ isSunoShort: Bool?,
        _ isUploadedAudio: Bool?,
        _ fullSongsOnly: Bool?
    ) async throws -> ClipsFeed
    public var getFeedByIds: @Sendable ([String]) async throws -> [Clip]
    public var getRecommendedUsers: @Sendable (_ phoneNumbers: [String]) async throws -> [RecommendUser]
    public var getPlaylistById: @Sendable (_ page: Int, _ id: String, _ sortBy: SortBy?, _ sortOrder: SortOrder?) async throws -> Playlist
    public var getProfile: @Sendable (_ handle: String, _ page: Int, _ clipsSortBy: ClipsSortBy, _ isSunoShort: Bool?, _ includeHooks: Bool?) async throws -> Profile
    public var getPlaylists: @Sendable (_ page: Int) async throws -> PlaylistsResult
    public var getPlaylistsWithClipStatus: @Sendable (_ page: Int, _ clip: Clip, _ query: String?, _ showLiked: Bool?) async throws -> PlaylistsResult
    public var createPlaylist: @Sendable (_ name: String) async throws -> Playlist
    public var search: @Sendable (_ fromIndex: Int, _ type: SearchType, _ term: String, _ rankBy: SearchRank) async throws -> SearchResult
    public var searchUsers: @Sendable (_ boostedUserHandles: [String]?, _ excludedUserHandles: [String]?, _ term: String) async throws -> [SimpleProfile]
    public var getFollowingFeed: @Sendable (_ page: Int) async throws -> [Clip]
    public var findExistingUsersFromPhoneNumbers: @Sendable (_ phoneNumbers: [String]) async throws -> [SimpleProfileWithPhoneNumber]
    /// Remaster w/ v4. Merge with the endpoint below once we get backend model support here.
    public var upsampleClip: @Sendable (_ clipId: Clip.ID) async throws -> [Clip]
    /// Remasters with the v4.5 model.
    /// Turn this into a generalized remaster endpoint once we get backend support for models
    public var upsampleClip4_5: @Sendable (_ clipId: Clip.ID) async throws -> [Clip]
    /// Remasters with a specific model
    public var upsampleClipWithModel: @Sendable (_ clipId: Clip.ID, _ modelName: String) async throws -> [Clip]
    public var getListenHistory: @Sendable (_ handle: String, _ limit: Int) async throws -> ListenHistory
    public var getLikedPlaylists: @Sendable (_ page: Int) async throws -> PlaylistsResult
    public var getFullClip: @Sendable (_ clipId: Clip.ID) async throws -> Clip
    public var getFollowers: @Sendable (_ handle: String, _ page: Int) async throws -> [SimpleProfile]
    public var getFollowing: @Sendable (_ handle: String, _ page: Int) async throws -> [SimpleProfile]
    public var getClipEdits: @Sendable (_ clipId: Clip.ID) async throws -> [ChildClip]
    public var getPinnedClips: @Sendable () async throws -> PinnedClipsContainer
    public var togglePinClip: @Sendable (_ clipID: Clip.ID) async throws -> PinnedClipsContainer
    public var postLyricsPair: (GenLyricsSpec) async throws -> GenLyricsRequestIds
    public var postProfile: (ModifyArtistProfileSpec, _ previousUser: User) async throws -> User
    public var postCommentToClip: (ClipID, PostCommentRequest) async throws -> CommentSchema
    public var generateV2: @Sendable (Prompt) async throws -> [Clip]
    public var getClip: @Sendable (String) async throws -> Clip
    public var getDiscoverFeed: @Sendable (Int, Int) async throws -> DiscoverFeed
    public var getTrendingPlaylist: @Sendable (_ sectionContent: String?, _ secondarySectionContent: String?) async throws -> PlaylistSection
    public var postShareLink: @Sendable (_ contentId: String, _ contentType: String, _ platform: String?, _ source: String, _ recommendationMetadata: HooksRecommendationMetadata?) async throws -> ShareAttribution
    public var patchShareLink: @Sendable (UpdateShareLinkRequest) async throws -> Void
    public var postShareAttribute: @Sendable (ShareAttributionRequest) async throws -> Void
    public var updateClip: @Sendable (_ clipId: String, _ update: ClipMetadataSpec) async throws -> Void
    public var setReaction: @Sendable (_ clip: Clip, _ isLiked: Bool, _ isDisliked: Bool, _ recommendationMetadata: HooksRecommendationMetadata?) async throws -> Void
    public var setVisibility: @Sendable (_ clip: Clip, _ isPublished: Bool) async throws -> Void
    public var getVideoUploadParams: @Sendable () async throws -> UploadRequest
    public var markVideoUploadComplete: @Sendable (UploadRequest.ID, FinishUploadSpec) async throws -> Void
    public var getVideoUploadStatus: @Sendable (UploadRequest.ID) async throws -> UploadRequestStatus
    public var uploadGeneratedScene: @Sendable (UploadRequest.ID, VideoOutputSpec) async throws -> Clip
    public var setVideoCover: @Sendable (String, UploadRequest.ID) async throws -> Clip
    public var setStyleSummary: @Sendable (String, String) async throws -> Void
    public var removeVideoCover: @Sendable (ClipID) async throws -> Clip
    public var getShareShortlinkInfo: @Sendable (String) async throws -> ShareCodeResponse
    public var startShareAssetGeneration: @Sendable (ClipID, ShareAssetSpec) async throws -> ShareAssetSchema
    public var pollShareAssetGeneration: @Sendable (ClipID, String) async throws -> ShareAssetStatusSchema
    public var getDirectChildren: @Sendable (ClipID, _ page: Int) async throws -> [Clip]
    public var getUserConfig: @Sendable () async throws -> UserConfigSchema
    public var updateUserConfig: @Sendable (UserConfigSchema) async throws -> Void
    public var setAllRemixPermissions: @Sendable (Bool) async throws -> Void
    public var toggleRemixability: @Sendable (ClipID, _ enabled: Bool) async throws -> Void
    public var toggleShowRemixes: @Sendable (ClipID, _ enabled: Bool) async throws -> Void
    public var getBillingInfo: @Sendable () async throws -> SubscriptionInfoResponse
    public var getSubscriptionPage: @Sendable () async throws -> SubscriptionPageResponse
    public var getMe: @Sendable () async throws -> Me
    public var incrementPlayCounts: @Sendable ([ClipID]) async throws -> Void
    public var reorderPlaylistClip: @Sendable (_ playlistId: Playlist.ID, _ fromIndex: Int, _ toIndex: Int) async throws -> Void
    public var updatePlaylistClips: @Sendable (_ clipId: Clip.ID, _ playlistId: Playlist.ID, _ isAdded: Bool, _ recommendationMetadata: HooksRecommendationMetadata?) async throws -> Void
    public var followProfile: @Sendable (_ handle: String, _ unfollow: Bool?, _ recommendationMetadata: HooksRecommendationMetadata?) async throws -> Void
    public var getTimeSyncedComments: @Sendable (_ clipId: Clip.ID, _ searchTime: Int, _ searchRange: Int?, _ margin: Int?, _ numRequested: Int?, _ endTime: Int?) async throws -> [ClipComment]
    public var getHooksSuggestedClips: @Sendable (_ isLiked: Bool, _ isPublicOnly: Bool, _ startIndex: Int) async throws -> [ClipSnippet]
    public var createVideoHook: @Sendable (VideoHookCreationRequest) async throws -> VideoHookCreationResponse
    public var getCommentForClip: @Sendable (_ clipId: Clip.ID, _ commentId: String) async throws -> ClipComment
    public var getHookStatus: @Sendable (_ hookId: String) async throws -> VideoHookSchema
    public var getHookById: @Sendable (_ hookId: String) async throws -> Hook
    public var getHooks: @Sendable (_ prioritizeCache: Bool) async throws -> [Hook]
    public var getHooksForClip: @Sendable (_ clipId: String, _ startIndex: Int, _ pageSize: Int) async throws -> [Hook]
    public var reportHook: @Sendable (_ hookId: String, _ reportType: String, _ recommendationItemId: String?) async throws -> HookReportResponse
    public var deleteHook: @Sendable (_ hookId: String) async throws -> DeleteHookResponse
    public var checkUsernameAvailability: @Sendable (_ handle: String) async throws -> Bool
    public var incrementHookPlayCounts: @Sendable ([String: Int]) async throws -> Void
    public var setHookReaction: @Sendable (_ hookId: String, _ reaction: HookReaction, _ recommendationMetadata: HooksRecommendationMetadata?) async throws -> Void
    public var getUserCreatedHooks: @Sendable (_ startIndex: Int?, _ pageSize: Int?) async throws -> [Hook]
    public var getUserLikedHooks: @Sendable (_ startIndex: Int?, _ pageSize: Int?) async throws -> [Hook]
    public var toggleHideCreator: @Sendable (_ contentType: HideCreatorContentType, _ userHandle: String, _ recommendationMetadata: HooksRecommendationMetadata?, _ unhide: Bool) async throws -> Bool
    public var blockProfile: @Sendable (_ handle: String, _ unblock: Bool) async throws -> Void
    public var getHookDownloadURL: @Sendable (_ hookId: String) async throws -> HookDownloadResponse
    public var getSimilarClips: @Sendable (_ clipId: String, _ count: Int, _ exactMatch: Bool) async throws -> [Clip]
    public var getModals: @Sendable () async throws -> [Modal]
    public var markModalAsSeen: @Sendable (_ modalId: String) async throws -> Void
    public var getMyWorkspaces: @Sendable (_ page: Int, _ query: String?) async throws -> WorkspacesPage
    public var trashWorkspace: @Sendable (_ workspaceId: String, _ undoTrash: Bool?) async throws -> Void

    // MARK: Hook Comments

    public var deleteHookComment: @Sendable (_ commentId: String, _ entityType: CommentEntity.CommentEntityType) async throws -> Void
    public var setHookCommentReaction: @Sendable (_ commentId: String, _ entityType: CommentEntity.CommentEntityType, _ isLiked: Bool) async throws -> Void
    public var getHookCommentReplies: @Sendable (_ commentId: String, _ entityType: CommentEntity.CommentEntityType, _ cursor: String?, _ pageSize: Int?) async throws -> CommentSheetRepliesPage
    public var reportHookComment: @Sendable (_ commentId: String, _ entityType: CommentEntity.CommentEntityType, _ body: CommentReportingBody) async throws -> CommentReportingResponse
    public var postHookComment: @Sendable (_ hookId: String, _ entityType: CommentEntity.CommentEntityType, _ content: String, _ parentId: String?, _ trackTimestamp: Double?, _ userMentions: [UserMention]?, _ recommendationMetadata: HooksRecommendationMetadata?) async throws -> CommentEntity
    public var getHookComments: @Sendable (_ hookId: String, _ cursor: String?, _ pageSize: Int?, _ order: CommentsSheetPage.CommentsSortOrder?) async throws -> CommentsSheetPage
    public var getHookComment: @Sendable (_ hookId: String, _ commentId: String) async throws -> CommentEntity
    public var getHookCommentCount: @Sendable (_ hookId: String) async throws -> CommentCountResponse
    public var toggleHookComments: @Sendable (_ hookId: String, _ allowComments: Bool) async throws -> GenericToggleCommentsResponse
    public var getHookTimeSyncedComments: @Sendable (_ hookId: String, _ searchTime: Int, _ searchRange: Int?, _ margin: Int?, _ numRequested: Int?, _ endTime: Int?) async throws -> [CommentEntity]
    public var getHooksTabShortcuts: @Sendable () async throws -> [Shortcut]
    public var incrementShareCount: @Sendable (_ hookId: String, _ recommendationMetadata: HooksRecommendationMetadata?) async throws -> ShareHookResponse
    public var incrementClipActionCount: @Sendable (_ clipId: Clip.ID, _ action: IncrementableAction, _ sharePlatform: String?, _ shareId: String?) async throws -> Void

    // MARK: Hook Lyrics

    public var fetchHookLyrics: @Sendable (_ hookIds: [String]) async throws -> [String: AlignedLyrics]

    // MARK: Notifications

    public var clearAppBadgeCount: @Sendable () async throws -> Void

    // MARK: External API

    public var uploadVideo: @Sendable (URL, UploadRequest, Bool) -> VideoUploadSession = { _, _, _ in VideoUploadSession(stream: AsyncStream { _ in }, cancel: {}) } // (localVideoUrl, UploadRequest, shouldLimitUploadsOnLowNetwork)
}

/// Used as `APIClientV2.underlying`
/// This gives us direct access to the generic-supporting `.send(Paths.xxx)` function, that `Client`s can use directly instead of redefining the generated interface as a `var` in the `@DependencyClient APIClientV2`
public protocol GetAPIClientType {
    @discardableResult func send<T: Decodable>(_ request: Request<T>) async throws -> Response<T>
    @discardableResult func sendVoid(_ request: Request<Void>) async throws -> Response<Void>
}

extension Get.APIClient: GetAPIClientType {
    public func send<T>(_ request: Get.Request<T>) async throws -> Get.Response<T> where T: Decodable {
        try await sendWithAuthRetry {
            try await send(request, delegate: .none, configure: .none)
        }
    }

    public func sendVoid(_ request: Get.Request<Void>) async throws -> Get.Response<Void> {
        try await sendWithAuthRetry {
            try await send(request, delegate: .none, configure: .none)
        }
    }

    private func sendWithAuthRetry<T>(_ sendFunc: () async throws -> Get.Response<T>) async throws -> Get.Response<T> {
        @Dependency(ClerkClient.self) var clerk

        do {
            return try await sendFunc()
        } catch is InvalidTokenError {
            do {
                // We hit a 401 Unauthorized. Attempt to renew the token before logging the user out
                _ = try await clerk.jwt(skipCache: true)
                return try await sendFunc()

            } catch is InvalidTokenError {
                // If we're still failing, log the user out
                APIClientV2.underlyingInvalidTokenSubject.send()
                throw InvalidTokenError()
            }
        }
    }
}

public extension APIClientV2 {
    /// Identical to our segment `anonymous_id`. to make this easy for the data folk to connect back
    static var anonymousID: String?

    /// The backend returns a session ID when we send up the anonymous ID
    /// Persist to memory, and re-send with all future requests
    @Shared(.inMemory(.analyticsSessionId)) static var sessionID: String?

    fileprivate static let underlyingInvalidTokenSubject = PassthroughSubject<Void, Never>()

    static let underlying: GetAPIClientType = {
//        let endpoint = "studio-api.staging.suno.com"
        /// if you're testing in the demo app, there is no editable Info.plist, so define the string inline by uncommenting above
        @Dependency(BackendEnvironmentClient.self) var backendEnvironment
        let environmentConfiguration = backendEnvironment.configuration()
        let endpointHost = environmentConfiguration.apiEndpointHost
        guard let baseUrl = URL(string: "https://\(endpointHost)") else {
            fatalError("API_ENDPOINT is not defined for this configuration")
        }

        @Dependency(RageshakeClient.self) var rageshakeClient

        return Get.APIClient(baseURL: baseUrl) {
            $0.delegate = GetAPIClientDelegate(invalidToken: Self.underlyingInvalidTokenSubject)
            $0.decoder.keyDecodingStrategy = .convertFromSnakeCase
            $0.decoder.dateDecodingStrategy = .comprehensive
            $0.encoder.keyEncodingStrategy = .convertToSnakeCase
            $0.encoder.dateEncodingStrategy = .iso8601WithFractions

            rageshakeClient.registerNetworkingInterception($0.sessionConfiguration)
        }
    }()
}

extension APIClientV2: DependencyKey {
    public static let liveValue: APIClientV2 = {
        let api = Self.underlying

        @Dependency(SunoModelClient.self) var sunoModelClient

        return Self(
            invalidToken: {
                UncheckedSendable(Self.underlyingInvalidTokenSubject.values).eraseToStream()
            },
            postAppVersionUpdate: {
                try await api.send(Paths.appVersionUpdate.post($0)).value
            },
            getFeed: { page in
                let response = try await api.send(
                    Paths.feed.get(
                        parameters: .init(page: page, isPublic: true)
                    )
                )
                let remote = response.value
                return try remote.map(Clip.init)
            },
            getFeedV2: { page, isPublic, isLiked, isVideoToSong, isSunoShort, isUploadedAudio, onlyFullSongs in
                let response = try await api.send(
                    Paths.feed.v2.get(
                        parameters: .init(
                            page: page,
                            isLiked: isLiked,
                            isPublic: isPublic,
                            isVideoToSong: isVideoToSong,
                            isSunoShort: isSunoShort,
                            hideGenStems: true,
                            hideStudioClips: true,
                            isExtend: onlyFullSongs == nil ? nil : onlyFullSongs == true ? false : true,
                            isUploadedAudio: isUploadedAudio
                        )
                    )
                )
                let remote = response.value
                return try ClipsFeed(remote)
            },
            getFeedByIds: { clipIds in
                let ids = clipIds.joined(separator: ",")
                let response = try await api.send(
                    Paths.feed.get(
                        parameters: .init(page: 0, ids: ids)
                    )
                )
                let remote = response.value
                return try remote.map(Clip.init)
            },
            getRecommendedUsers: { phoneNumbers in
                let response = try await api.send(
                    Paths.social.recommendUsers.post(
                        RecommendUserReq(phones: phoneNumbers)
                    )
                )
                let remote = response.value
                return try remote.result.map(RecommendUser.init)
            },
            getPlaylistById: { page, id, sortBy, sortOrder in
                let response = try await api.send(
                    Paths.playlist.playlistId(id).get(
                        parameters: .init(page: page, sortBy: sortBy, sortOrder: sortOrder)
                    )
                )
                let remote = response.value
                return try Playlist(remote)
            },
            getProfile: { handle, page, clipsSortBy, isSunoShort, includeHooks in
                let response = try await api.send(
                    Paths.profiles.handle(handle).get(
                        parameters: .init(
                            playlistsSortBy: ClipsSortBy.createdAt.rawValue,
                            clipsSortBy: clipsSortBy.rawValue,
                            page: page,
                            isSunoShort: isSunoShort,
                            includeHooks: includeHooks ?? false
                        )
                    )
                )
                let remote = response.value
                return try Profile(remote)
            },
            getPlaylists: { page in
                let response = try await api.send(
                    Paths.playlist.me.get(
                        parameters: .init(page: page)
                    )
                )
                let remote = response.value
                return try PlaylistsResult(remote)
            },
            getPlaylistsWithClipStatus: { page, clip, query, showLiked in
                let response = try await api.send(
                    Paths.playlist.me.clipStatus
                        .get(parameters: .init(clipId: clip.id.remoteId, page: page, query: query, showLiked: showLiked))
                )
                let remote = response.value
                return try PlaylistsResult(remote, clip: clip)
            },
            createPlaylist: { name in
                let response = try await api.send(
                    Paths.playlist.create.post(
                        PlaylistCreateSpec(name: name)
                    )
                )
                let remote = response.value
                return try Playlist(remote)
            },
            search: { fromIndex, type, term, rankBy in
                let response = try await api.send(
                    Paths.search.post(
                        SearchRequest(searchQueries: [
                            SearchQuerySchema(
                                fromIndex: fromIndex,
                                rankBy: rankBy.enumValue,
                                searchType: type.enumValue,
                                term: term
                            ),
                        ])
                    )
                )
                let remote = response.value
                return try SearchResult(remote)
            },
            searchUsers: { boostedUserHandles, excludedUserHandles, term in
                let request = UserSearchRequest(
                    boostedUserHandles: boostedUserHandles,
                    excludedUserHandles: excludedUserHandles,
                    term: term
                )
                let response = try await api.send(Paths.search.users.post(request))
                return try response.value.map(SimpleProfile.init)
            },
            getFollowingFeed: { page in
                let response = try await api.send(
                    Paths.social.followingClipFeed.post(
                        FollowingFeedRequest(feedType: .default, pageSize: 20, startIndex: page)
                    )
                )
                let remote = response.value
                return try remote.items?
                    .compactMap(\.generatedClipSchema)
                    .map(Clip.init) ?? []
            },
            findExistingUsersFromPhoneNumbers: { phoneNumbers in
                let response = try await api.send(
                    Paths.user.findExistingUserByPhone.post(
                        FindExistingUserByPhoneReq(phones: phoneNumbers)
                    )
                )
                let remote = response.value
                return try remote.userProfiles?.compactMap(SimpleProfileWithPhoneNumber.init) ?? []
            },
            upsampleClip: { clipId in
                let response = try await api.send(
                    Paths.generate.upsample.post(
                        UpsampleParamsSpec(clipId: clipId.remoteId)
                    )
                )
                let remote = response.value
                return try remote.clips.map(Clip.init)
            },
            upsampleClip4_5: { clipId in
                // TODO: This is hardcoded until we get backend support for this.
                let modelName: String = "chirp-ahi"
                let response = try await api.send(
                    Paths.generate.upsample.post(
                        UpsampleParamsSpec(clipId: clipId.remoteId, modelName: modelName)
                    )
                )
                let remote = response.value
                return try remote.clips.map(Clip.init)
            },
            upsampleClipWithModel: { clipId, modelName in
                let response = try await api.send(
                    Paths.generate.upsample.post(
                        UpsampleParamsSpec(clipId: clipId.remoteId, modelName: modelName)
                    )
                )
                let remote = response.value
                return try remote.clips.map(Clip.init)
            },
            getListenHistory: { handle, limit in
                let response = try await api.send(
                    Paths.profiles.handle(handle).listenHistory.get(limit: limit)
                )
                let remote = response.value
                return try ListenHistory(remote)
            },
            getLikedPlaylists: { page in
                let response = try await api.send(
                    Paths.playlist.likedPlaylist.get(
                        parameters: .init(page: page)
                    )
                )
                let remote = response.value
                return try PlaylistsResult(remote)
            },
            getFullClip: { clipId in
                let response = try await api.send(
                    Paths.generate.concat.v2.post(
                        GenConcatSpec(clipId: clipId.remoteId)
                    )
                )
                let remote = response.value
                return try Clip(remote)
            },
            getFollowers: { handle, page in
                let response = try await api.send(
                    Paths.profiles.handle(handle).followers.get(
                        parameters: .init(page: page)
                    )
                )
                let remote = response.value
                return try remote.profiles.map(SimpleProfile.init)
            },
            getFollowing: { handle, page in
                let response = try await api.send(
                    Paths.profiles.handle(handle).following.get(
                        parameters: .init(page: page)
                    )
                )
                let remote = response.value
                return try remote.profiles.map(SimpleProfile.init)
            },
            getClipEdits: { clipId in
                let response = try await api.send(
                    Paths.clips.children.get(parameters: .init(clipId: clipId.remoteId))
                )
                let remote = response.value
                return try ClipLineage(remote).children
            },
            getPinnedClips: {
                let response = try await api.send(
                    Paths.profiles.pinnedClips.get
                )
                let remote = response.value
                return PinnedClipsContainer(remote: remote)
            },
            togglePinClip: { clipID in
                let response = try await api.send(
                    Paths.profiles.pinClip.clipId(clipID.remoteId).post()
                )
                let remote = response.value
                return PinnedClipsContainer(remote: remote)
            },
            postLyricsPair: {
                try await api.send(Paths.generate.lyricsPair.post($0)).value
            },
            postProfile: { request, previousUser in
                let response = try await api.send(Paths.profiles.patch(request)).value
                guard let parsed = User(response, previousUser: previousUser) else {
                    throw APIMappingError.missingField("userId")
                }
                return parsed
            },
            postCommentToClip: { clipId, postCommentRequest in
                try await api.send(Paths.gen.clipId(clipId.remoteId).comment.post(postCommentRequest)).value
            },
            generateV2: { prompt in
                let model = sunoModelClient.getCurrentModelExternalKey(isAudioUpload: prompt.continueClipId != nil)
                // Temp: Move this to SunoModelClient once we get official model support
                // Also, move to a permanent storage i.e. `AppStorage` as a followup, for now keep it in-memory
                @Shared(.fileStorage(FilePathKeys.ApplicationSupport.selectedLyricsModel.url())) var selectedLyricsModel: LyricsModelMetadata = .remi
                let response = try await api.send(
                    Paths.generate.v2.post(
                        prompt.asGenAPI(model: model, lyricsModel: selectedLyricsModel.externalKey)
                    )
                )
                return try response.value.clips.map(Clip.init)
            },
            getClip: { clipId in
                let response = try await api.send(
                    Paths.clip.clipId(clipId).get
                ).value
                return try Clip(response)
            },
            getDiscoverFeed: { sectionIndex, pageSize in
                let req = DiscoverReq(
                    language: Bundle.main.preferredLocalizations.first ?? "en",
                    pageSize: pageSize,
                    platform: DiscoverReq.Platform.mobile,
                    startIndex: sectionIndex
                )
                let response = try await api.send(Paths.discover.post(req)).value
                return DiscoverFeed(response)
            },
            getTrendingPlaylist: { sectionContent, secondarySectionContent in
                let req = DiscoverReq(
                    disableShuffle: true,
                    language: Bundle.main.preferredLocalizations.first ?? "en",
                    page: 1,
                    pageSize: 1,
                    platform: DiscoverReq.Platform.mobile,
                    secondarySectionContent: secondarySectionContent,
                    sectionContent: sectionContent,
                    sectionName: "trending_songs", // TODO: maybe dont hardcode? same value hardcoded in `TrendingScreen`
                    sectionSize: 50,
                    startIndex: 0
                )
                let response = try await api.send(Paths.discover.post(req)).value
                let feed = DiscoverFeed(response)

                guard let firstSection = feed.sections.first,
                      case .playlist(let playlistSection) = firstSection
                else {
                    throw APIError.errorMessage("No trending playlist section found")
                }

                return playlistSection
            },
            postShareLink: { contentId, contentType, platform, source, recommendationMetadata in
                let request = ShareLinkRequest(
                    contentId: contentId,
                    contentType: contentType,
                    platform: platform,
                    recommendationMetadata: recommendationMetadata?.toSchema(),
                    source: source
                )
                let response = try await api.send(Paths.share.link.post(request)).value
                if response.success,
                   let shareId = response.shareId,
                   let link = response.link,
                   let url = URL(string: link)
                {
                    return ShareAttribution(url: url, shareId: shareId)
                } else {
                    throw APIError.errorMessage(response.message ?? "nil")
                }
            },
            patchShareLink: { request in
                let response = try await api.send(Paths.share.link.patch(request)).value
                guard response.success else {
                    throw APIError.errorMessage(response.message ?? "nil")
                }
            },
            postShareAttribute: {
                let response = try await api.send(Paths.share.attribute.post($0)).value
                guard response.success else {
                    throw APIError.errorMessage(response.message ?? "nil")
                }
            },
            updateClip: { clipId, update in
                let response = try await api.send(
                    Paths.gen.genId(clipId).setMetadata.post(update)
                ).value

                guard response.metadataEditErrorSchema == nil else {
                    throw APIClipUpdateError.moderationError(response.metadataEditErrorSchema!)
                }
            },
            setReaction: { clip, isLiked, isDisliked, recommendationMetadata in
                let reaction: ReactionSpec.Reaction?
                if isLiked {
                    reaction = .like
                } else if isDisliked {
                    reaction = .dislike
                } else {
                    reaction = nil
                }

                try await api.sendVoid(Paths.gen.genId(clip.id.remoteId).updateReactionType.post(ReactionSpec(reaction: reaction, recommendationMetadata: recommendationMetadata?.toSchema())))
            },
            setVisibility: { clip, shouldPublish in
                let payload = ClipVisibilitySpec(isPublic: shouldPublish)
                let response = try await api.sendVoid(Paths.gen.genId(clip.id.remoteId).setVisibility.post(payload))
            },
            getVideoUploadParams: {
                let response = try await api.send(Paths.uploads.video.post(.init(extension: "mp4"))).value
                return UploadRequest(id: response.id, url: response.url, additionalFields: response.fields)
            },
            markVideoUploadComplete: { uploadId, finishUploadSpec in
                try await api.sendVoid(
                    Paths.uploads.video.uploadId(uploadId).uploadFinish.post(finishUploadSpec)
                )
            },
            getVideoUploadStatus: { uploadId in
                let response = try await api.send(Paths.uploads.video.uploadId(uploadId).get).value
                return UploadRequestStatus(
                    id: response.id,
                    status: UploadRequestStatus.Status(rawValue: response.status) ?? UploadRequestStatus.Status.unknown,
                    errorMessage: response.errorMessage,
                    s3Id: response.s3Id,
                    title: response.title,
                    imageUrl: response.imageUrl
                )
            },
            uploadGeneratedScene: { uploadId, videoOutputSpec in
                let response = try await api.send(Paths.uploads.videoOutput.uploadId(uploadId).post(videoOutputSpec)).value
                return try Clip(response)
            },
            setVideoCover: { genId, uploadId in
                let response = try await api.send(Paths.gen.genId(genId).setVideoCover.post(.init(videoCoverUploadId: uploadId))).value
                return try Clip(response.clip)
            },
            setStyleSummary: { genId, displayTags in
                try await api.send(Paths.gen.genId(genId).setDisplayTags.post(.init(displayTags: displayTags)))
            },
            removeVideoCover: { clipId in
                let response = try await api.send(Paths.gen.genId(clipId.remoteId).setVideoCover.post(.init(videoCoverUploadId: nil))).value
                return try Clip(response.clip)
            },
            getShareShortlinkInfo: { shortcode in
                try await api.send(Paths.share.code.shareId(shortcode).get).value
            },
            startShareAssetGeneration: { clipID, assetSpec in
                let response = try await api.send(Paths.gen.genId(clipID.remoteId).shareAsset.post(assetSpec))
                return response.value
            },
            pollShareAssetGeneration: { clipID, assetID in
                try await api.send(Paths.gen.genId(clipID.remoteId).shareAsset.assetId(assetID).get).value
            },
            getDirectChildren: { clipID, page in
                let response = try await api.send(Paths.clips.directChildren.get(parameters: .init(clipId: clipID.remoteId, page: page, pageSize: 20)))
                return try response.value.children.map(Clip.init)
            },
            getUserConfig: {
                try await api.send(Paths.user.userConfig.post([:])).value
            },
            updateUserConfig: { userConfig in
                try await api.sendVoid(Paths.user.updateUserConfig.post(userConfig))
            },
            setAllRemixPermissions: { optedIn in
                try await api.send(Paths.clips.setAllRemixPermissions.post(canRemix: optedIn))
            },
            toggleRemixability: { clipID, enabled in
                try await api.sendVoid(Paths.clips.clipId(clipID.remoteId).toggleRemixes.post(.init(canRemix: enabled)))
            },
            toggleShowRemixes: { clipID, enabled in
                try await api.sendVoid(Paths.clips.clipId(clipID.remoteId).toggleShowRemixes.post(.init(showRemix: enabled)))
            },
            getBillingInfo: {
                try await api.send(Paths.billing.info.get).value
            },
            getSubscriptionPage: {
                var request = Paths.cms.subscriptionPage.get(platform: "ios")

                let preferred = Locale.preferredLanguages.prefix(5)
                let appLanguage = Bundle.main.preferredLocalizations.first ?? "en"
                let languages = ([appLanguage] + preferred).joined(separator: ", ")

                request.headers = ["Accept-Language": languages]

                let response = try await api.send(request).value
                return SubscriptionPageResponse(response)
            },
            getMe: {
                let response = try await api.send(Paths.session.get).value
                if let loggedInSessionResponse = response.loggedInSessionResponse {
                    return try Me(loggedInSessionResponse)
                } else if let unauthenticatedSessionResponse = response.unauthenticatedSessionResponse {
                    return try Me(unauthenticatedSessionResponse)
                } else {
                    throw DecodingError.typeMismatch(Me.self, .init(codingPath: [], debugDescription: "Unable to decode Me from APIClientV2.getMe response: \(response)"))
                }
            },
            incrementPlayCounts: { clipIds in
                try await api.sendVoid(Paths.gen.bulkIncrementPlayCounts.v2.post(.init(genIds: clipIds.map(\.remoteId))))
            },
            reorderPlaylistClip: { playlistId, fromIndex, toIndex in
                do {
                    let fromIndex = try AnyJSON.from(fromIndex)
                    let toIndex = try AnyJSON.from(toIndex)
                    let metadata: [String: AnyJSON] = [
                        "from_index": fromIndex,
                        "to_index": toIndex,
                    ]
                    _ = try await api.send(Paths.playlist.updateClips.post(.init(metadata: metadata,
                                                                                 playlistId: playlistId,
                                                                                 updateType: .reorder)))
                } catch let error as DecodingError {
                    // Ignore JSON decoding errors for reorder operations since we don't need the response
                    return
                } catch {
                    throw error
                }
            },
            updatePlaylistClips: { clipId, playlistId, isAdded, recommendationMetadata in
                do {
                    let metadata: [String: AnyJSON]
                    if isAdded {
                        let ids = try AnyJSON.from([clipId.remoteId])
                        metadata = ["ids": ids]
                    } else {
                        let clipIds = try AnyJSON.from([clipId.remoteId])
                        metadata = ["clip_ids": clipIds]
                    }
                    _ = try await api.send(Paths.playlist.updateClips.post(.init(
                        metadata: metadata,
                        playlistId: playlistId,
                        recommendationMetadata: recommendationMetadata?.toSchema(),
                        updateType: isAdded ? .removeById : .add
                    )))
                } catch let error as DecodingError {
                    // Ignore JSON decoding errors
                    return
                } catch {
                    throw error
                }
            },
            followProfile: { handle, unfollow, recommendationMetadata in
                try await api.sendVoid(Paths.profiles.follow.post(.init(handle: handle, recommendationMetadata: recommendationMetadata?.toSchema(), unfollow: unfollow ?? false)))
            },
            getTimeSyncedComments: { clipId, searchTime, searchRange, margin, numRequested, endTime in
                let response = try await api.send(Paths.gen.clipId(clipId.remoteId).timeSyncComments.get(parameters: .init(
                    searchTime: searchTime,
                    searchRange: searchRange,
                    margin: margin,
                    numRequested: numRequested,
                    endTime: endTime
                )))
                return response.value.map(ClipComment.fromAPIV2)
            },
            getHooksSuggestedClips: { isLiked, isPublicOnly, startIndex in
                let pageSize: Int = 20
                let response = try await api.send(Paths.video.hooks.suggestedClips.get(parameters: .init(
                    pageSize: pageSize,
                    startIndex: startIndex,
                    isLiked: isLiked,
                    isPublicOnly: isPublicOnly
                )))
                return try response.value.clips.compactMap { clip in
                    guard let audioSnippet = clip.audioMetadata?.audioSnippet else {
                        return nil
                    }
                    let startTime = audioSnippet.startTimestamp
                    let endTime = audioSnippet.endTimestamp
                    return try ClipSnippet(clip: Clip(clip), startTime: startTime, endTime: endTime)
                }
            },
            createVideoHook: { request in
                let response = try await api.send(Paths.video.hooks.create.post(request))
                return response.value
            },
            getCommentForClip: { clip_id, comment_id in
                let response = try await api.send(Paths.gen.clipId(clip_id.remoteId).comments.get(parameters: .init(
                    cursor: nil,
                    pageSize: 1,
                    order: nil,
                    id: comment_id
                )))
                guard let comment = response.value.results.first else {
                    throw APIError.errorMessage("Comment not found with ID: \(comment_id)")
                }
                return ClipComment.fromAPIV2(comment)
            },
            getHookStatus: { hookId in
                let response = try await api.send(Paths.video.hooks.hookId(hookId).get())
                return response.value
            },
            getHookById: { hookId in
                let response = try await api.send(Paths.video.hooks.hookId(hookId).get())
                return try Hook(response.value)
            },
            getHooks: { prioritizeCache in
                let pageSize: Int = 10
                let startIndex = prioritizeCache ? 0 : nil // Only send this when necessary
                let response = try await api.send(Paths.video.hooks.feed.post(.init(pageSize: pageSize, startIndex: startIndex)))
                return try response.value.items.map(Hook.init)
            },
            getHooksForClip: { clipId, startIndex, pageSize in
                let response = try await api.send(Paths.video.hooks.fromClip.clipId(clipId).hooks.get(parameters: .init(startIndex: startIndex, pageSize: pageSize)))
                return try response.value.items.map(Hook.init)
            },
            reportHook: { hookId, reportType, recommendationItemId in
                let recommendationMetadata = RecommendationMetadata(recommendationItemId: recommendationItemId)
                let response = try await api.send(
                    Paths.video.hooks.hookId(hookId).report.post(.init(recommendationMetadata: recommendationMetadata, reportReason: reportType))
                )
                return response.value
            },
            deleteHook: { hookId in
                let response = try await api.send(Paths.video.hooks.delete.hookId(hookId).post)
                return response.value
            },
            checkUsernameAvailability: { handle in
                let response = try await api.send(
                    Paths.profiles.checkHandleAvailability.handle(handle).get
                )
                return response.value.available
            },
            incrementHookPlayCounts: { hookIdsToCountsMap in
                let response = try await api.send(Paths.video.hooks.watched.post(.init(hookIdsToTimesListened: hookIdsToCountsMap)))
                guard response.value.success else { throw APIError.errorMessage("test") }
            },
            setHookReaction: { hookId, reaction, recommendationMetadata in
                let reaction = ReactionAction(rawValue: reaction.rawValue)
                let response = try await api.send(Paths.video.hooks.hookId(hookId).reaction.post(
                    .init(action: reaction, recommendationMetadata: recommendationMetadata?.toMetadata())))
                guard response.value.success else {
                    throw APIError.errorMessage("Failed to set reaction for hook \(hookId): \(response.value.hookId)")
                }
            },
            getUserCreatedHooks: { startIndex, pageSize in
                let response = try await api.send(Paths.video.hooks.userHooks.get(parameters: .init(startIndex: startIndex, pageSize: pageSize)))
                let items = response.value.items
                return try items.map { try Hook($0) }
            },
            getUserLikedHooks: { startIndex, pageSize in
                let response = try await api.send(
                    Paths.video.hooks.me.liked.v2.get(parameters: .init(startIndex: startIndex, pageSize: pageSize))
                )
                let items = response.value.items
                return try items.map { try Hook($0) }
            },
            toggleHideCreator: { contentType, userHandle, recommendationMetadata, unhide in
                let recommendationMetadataSchema = recommendationMetadata?.toSchema()
                let response = try await api.send(Paths.recommend.hideCreator.post(.init(
                    contentType: contentType,
                    recommendationMetadata: recommendationMetadataSchema,
                    unhide: unhide,
                    userHandle: userHandle
                )))

                return response.value.success
            },
            blockProfile: { handle, unblock in
                try await api.sendVoid(Paths.profiles.block.post(.init(
                    handle: handle,
                    unblock: unblock
                )))
            },
            getHookDownloadURL: { hookId in
                let response = try await api.send(Paths.video.hooks.hookId(hookId).download.get())
                return response.value
            },
            getSimilarClips: { clipId, count, exactMatch in
                let response = try await api.send(
                    Paths.clips.getSimilar.get(parameters: .init(id: clipId, count: count, exactMatch: exactMatch))
                )
                return try response.value.similarClips.map(Clip.init)
            },
            getModals: {
                let response = try await api.send(Paths.modals.get)
                return try response.value.map { try Modal($0) }
            },
            markModalAsSeen: { modalId in
                let response = try await api.send(Paths.modals.modalId(modalId).seen.post)
                guard response.value.success else {
                    throw APIError.errorMessage("Failed to mark modal as seen: \(modalId)")
                }
            },
            getMyWorkspaces: { page, query in
                let response = try await api.send(
                    Paths.project.me.get(parameters: .init(page: page, query: query))
                )
                return try WorkspacesPage(response.value)
            },
            trashWorkspace: { workspaceId, undoTrash in
                try await api.sendVoid(
                    Paths.project.trash.post(
                        ProjectTrashSpec(
                            projectId: workspaceId,
                            undoTrash: undoTrash
                        )
                    )
                )
            },
            deleteHookComment: { commentId, entityType in
                let entityType = CommentEntityType(rawValue: entityType.rawValue)
                let response = try await api.send(Paths.video.hooks.comments.commentId(commentId).delete(
                    .init(entityType: entityType)
                ))
                guard response.value.status == .deleted else {
                    throw APIError.errorMessage("Failed to delete comment: \(commentId)")
                }
            },
            setHookCommentReaction: { commentId, entityType, isLiked in
                let entityType = CommentEntityType(rawValue: entityType.rawValue)
                try await api.sendVoid(Paths.video.hooks.comments.commentId(commentId).reaction.post(
                    .init(entityType: entityType, reaction: isLiked ? .like : .dislike)
                ))
            },
            getHookCommentReplies: { commentId, entityType, cursor, pageSize in
                let response = try await api.send(Paths.video.hooks.comments.commentId(commentId).replies.get(
                    parameters: .init(
                        cursor: cursor,
                        pageSize: pageSize,
                        entityType: .init(rawValue: entityType.rawValue)
                    )
                ))
                return CommentSheetRepliesPage(response.value)
            },
            reportHookComment: { commentId, entityType, reason in
                let entityType = CommentEntityType(rawValue: entityType.rawValue)
                let response = try await api.send(Paths.video.hooks.comments.commentId(commentId).report.post(
                    .init(entityType: entityType, reason: reason.reason)
                ))
                return CommentReportingResponse(response.value)
            },
            postHookComment: { hookId, entityType, content, parentId, trackTimestamp, userMentions, recommendationMetadata in
                let entityType = CommentEntityType(rawValue: entityType.rawValue)
                let mentions: [Mention] = userMentions?.map {
                    .init(displayName: $0.displayName, end: $0.end, handle: $0.handle, start: $0.start)
                } ?? []
                let response = try await api.send(Paths.video.hooks.comments.hookId(hookId).comment.post(
                    .init(
                        content: content,
                        entityType: entityType,
                        parentId: parentId,
                        recommendationMetadata: recommendationMetadata?.toSchema(),
                        trackTimestamp: trackTimestamp,
                        userMentions: mentions
                    )
                ))
                return try CommentEntity(response.value)
            },
            getHookComments: { hookId, cursor, pageSize, order in
                let response = try await api.send(Paths.video.hooks.comments.hookId(hookId).comments.get(
                    parameters: .init(
                        cursor: cursor,
                        pageSize: pageSize,
                        order: order.map { .init(rawValue: $0.propertyValue) }
                    )
                ))
                return try CommentsSheetPage(response.value)
            },
            getHookComment: { hookId, commentId in
                let response = try await api.send(Paths.video.hooks.comments.hookId(hookId).comments.get(
                    parameters: .init(
                        cursor: nil,
                        pageSize: 1,
                        order: nil,
                        id: commentId
                    )
                ))
                guard let comment = response.value.results.first else {
                    throw APIError.errorMessage("Comment (\(commentId)) not found for hook (\(hookId))")
                }
                return try CommentEntity(comment)
            },
            getHookCommentCount: { hookId in
                let response = try await api.send(Paths.video.hooks.comments.hookId(hookId).comments.count.get)
                return CommentCountResponse(response.value)
            },
            toggleHookComments: { hookId, allowComments in
                let response = try await api.send(Paths.video.hooks.comments.hookId(hookId).toggleComments.post(
                    .init(canComment: allowComments)
                ))
                return response.value
            },
            getHookTimeSyncedComments: { hookId, searchTime, searchRange, margin, numRequested, endTime in
                let response = try await api.send(Paths.video.hooks.comments.hookId(hookId).timeSyncComments.get(
                    parameters: .init(
                        searchTime: searchTime,
                        searchRange: searchRange,
                        margin: margin,
                        numRequested: numRequested,
                        endTime: endTime
                    )
                ))
                return try response.value.map { try CommentEntity($0) }
            },
            getHooksTabShortcuts: {
                let response = try await api.send(Paths.video.hooks.tabCarousel.post([:])).value
                return response.tabs.compactMap(Shortcut.init)
            },
            incrementShareCount: { hookId, recommendationMetadata in
                let request = ShareHookRequest(recommendationMetadata: recommendationMetadata?.toMetadata())
                let response = try await api.send(Paths.video.hooks.hookId(hookId).share.post(request))
                return response.value
            },
            incrementClipActionCount: { clipId, action, sharePlatform, shareId in
                let request = ActionSpec(action: action.rawValue, shareId: shareId, sharePlatform: sharePlatform)
                try await api.sendVoid(Paths.gen.genId(clipId.remoteId).incrementActionCount.post(request))
            },
            fetchHookLyrics: { hookIds in
                let response = try await api.send(Paths.video.hooks.fetchHookLyrics.post(
                    .init(hookIds: hookIds)
                ))
                return response.value.hookLyrics.mapValues { AlignedLyrics($0) }
            },
            clearAppBadgeCount: {
                try await api.sendVoid(Paths.notification.v2.clearBadge.post)
            },
            uploadVideo: { url, uploadRequest, limitUploadsOnLowNetwork in
                // Use an actor to safely manage the upload handle
                actor UploadHandleManager {
                    var handle: S3UploadHandle?

                    func setHandle(_ newHandle: S3UploadHandle) {
                        handle = newHandle
                    }

                    func cancel() {
                        handle?.cancel()
                    }
                }

                let handleManager = UploadHandleManager()

                let stream = AsyncStream<VideoUploadEvent> { continuation in
                    let uploadTask = Task {
                        do {
                            let uploadResult = try uploadToS3(
                                localFileUrl: url,
                                uploadRequest: uploadRequest,
                                limitUploadsOnLowNetwork: limitUploadsOnLowNetwork,
                                networkAccessIsLimited: {
                                    continuation.yield(VideoUploadEvent.postponed)
                                    continuation.finish()
                                }
                            )

                            await handleManager.setHandle(uploadResult.handle)

                            continuation.onTermination = { _ in
                                uploadResult.handle.cancel()
                            }

                            for try await progress in uploadResult.progressStream {
                                try Task.checkCancellation()
                                continuation.yield(VideoUploadEvent.updateProgress(progress))
                            }

                            try Task.checkCancellation()
                            continuation.yield(VideoUploadEvent.success)
                            continuation.finish()
                        } catch is CancellationError {
                            // Task was cancelled, finish the stream silently
                            continuation.finish()
                        } catch {
                            log.telemetry.error(error, message: "Failed to upload video.")
                            continuation.yield(VideoUploadEvent.failure(error.localizedDescription))
                            continuation.finish()
                        }
                    }

                    continuation.onTermination = { _ in
                        uploadTask.cancel()
                    }
                }

                // Return the session with a cancel function
                return VideoUploadSession(
                    stream: stream,
                    cancel: {
                        Task {
                            await handleManager.cancel()
                        }
                    }
                )
            }
        )
    }()
}

private extension APIClientV2 {
    class GetAPIClientDelegate: APIClientDelegate {
        @Dependency(ClerkClient.self) var clerk
        private let invalidTokenSubject: PassthroughSubject<Void, Never>

        init(invalidToken: PassthroughSubject<Void, Never>) {
            self.invalidTokenSubject = invalidToken
        }

        func client(_: Get.APIClient, willSendRequest request: inout URLRequest) async throws {
            do {
                let jwt = try await clerk.jwt(skipCache: false)
                request.setValue("Bearer \(jwt)", forHTTPHeaderField: "Authorization")
            } catch {
                log.telemetry.error(error, message: "Error from clerk while renewing JWT token.")
                throw error
            }

            if let info = Bundle.main.infoDictionary,
               let version = info["CFBundleShortVersionString"] as? String,
               let build = info["CFBundleVersion"] as? String
            {
                request.setValue("iOS \(version)-\(build)", forHTTPHeaderField: "X-Suno-Client")
            }

            /// `Locale.Region` is nullable, but should only be `nil` when using custom `Locale`s
            /// We expect `Locale.current` to have a value here
            if let region = Locale.current.region {
                /// BCP 47 region subtag
                request.setValue(region.identifier, forHTTPHeaderField: "X-Suno-Region")
            }

            if let anonymousID = APIClientV2.anonymousID {
                request.setValue(anonymousID, forHTTPHeaderField: "anonymous-id")
            }

            if let sessionID = APIClientV2.sessionID {
                request.setValue(sessionID, forHTTPHeaderField: "session-id")
            }
        }

        func client(_: Get.APIClient, validateResponse response: HTTPURLResponse, data: Data, task _: URLSessionTask) throws {
            if let sessionID = response.allHeaderFields["session-id"] as? String, sessionID != APIClientV2.sessionID {
                APIClientV2.$sessionID.withLock { $0 = sessionID }
            }

            // Attempt to decode an error either way
            let apiError = try? JSONDecoder().decode(APIError.StudioAPIErrorDetail.self, from: data)

            switch response.statusCode {
            case 200 ..< 300: // Success
                break

            case 401:
                throw InvalidTokenError()

            case 402:
                throw APIError.insufficientCredits(apiError?.detail)

            case 403:
                throw APIError.forbidden(apiError?.detail)

            case 422:
                throw APIError.invalidHCaptchaToken

            case 429:
                throw APIError.tooManyRunningJobs

            case 400 ... 499:
                throw APIError.clientError(response.statusCode, apiError?.detail)

            case 500 ... 599:
                throw APIError.serverError(response.statusCode, apiError?.detail)

            default:
                break
            }
        }
    }
}

public extension DependencyValues {
    var apiClientV2: APIClientV2 {
        get { self[APIClientV2.self] }
        set { self[APIClientV2.self] = newValue }
    }
}

extension APIClientV2: TestDependencyKey {
    public static let previewValue = Self.noop

    public static let testValue = Self()
}

public extension APIClientV2 {
    static let noop = Self()
}

public extension APIClientV2 {
    @CasePathable
    enum VideoUploadEvent: Equatable {
        case success
        case postponed
        case updateProgress(Double)
        case failure(String)

        public static func == (lhs: VideoUploadEvent, rhs: VideoUploadEvent) -> Bool {
            switch (lhs, rhs) {
            case (.success, .success):
                return true
            case (.postponed, .postponed):
                return true
            case let (.updateProgress(l), .updateProgress(r)):
                return l == r
            case let (.failure(l), .failure(r)):
                return l == r
            default:
                return false
            }
        }
    }

    struct VideoUploadSession {
        public let stream: AsyncStream<VideoUploadEvent>
        public let cancel: () -> Void

        public init(stream: AsyncStream<VideoUploadEvent>, cancel: @escaping () -> Void) {
            self.stream = stream
            self.cancel = cancel
        }
    }
}
