import BackendEnvironmentClient
import ClerkClient
import Combine
import ComposableArchitecture
import Foundation
import Localization
import OpenAPIRuntime
import OpenAPIURLSession
import RageshakeClient
import SunoModelClient

// swiftlint:disable file_length

@DependencyClient
public struct APIClient {
    public enum IncrementableAction: String {
        case share
        case downloadAudio = "download_audio"
        case downloadVideo = "download_video"
    }

    @CasePathable
    public enum DownloadEvent: Equatable {
        case response(Data)
        case updateProgress(Double)
    }

    @CasePathable
    public enum UploadEvent: Equatable, Sendable {
        case success
        case updateProgress(Double)
        case failure(Error)

        public static func == (lhs: UploadEvent, rhs: UploadEvent) -> Bool {
            switch (lhs, rhs) {
            case (.success, .success):
                return true
            case let (.updateProgress(lhsProgress), .updateProgress(rhsProgress)):
                return lhsProgress == rhsProgress
            case let (.failure(lhsError), .failure(rhsError)):
                return lhsError.localizedDescription == rhsError.localizedDescription
            default:
                return false
            }
        }
    }

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

    public var updateMe: @Sendable (MeUpdate) async throws -> Void
    public var deleteClip: @Sendable (Clip) async throws -> Void
    public var trashClip: @Sendable (_ clip: Clip, _ isTrashed: Bool) async throws -> Void
    public var deletePlaylist: @Sendable (Playlist.ID) async throws -> Void
    public var deleteAccount: @Sendable () async throws -> Void
    public var generateLyrics: @Sendable (_ lyricsPrompt: String) async throws -> String
    public var getGeneratedLyrics: @Sendable (_ requestId: String) async throws -> GeneratedLyrics
    public var getCommentsForClip: @Sendable (
        _ clipId: Clip.ID,
        _ cursor: String?,
        _ page_size: Int?,
        _ order: CommentsPage.CommentsSortOrder
    ) async throws -> CommentsPage
    public var getRepliesForComment: @Sendable (
        _ commentID: String,
        _ cursor: String?,
        _ page_size: Int?
    ) async throws -> CommentRepliesPage
    public var postCommentToClip: @Sendable (_ clipID: Clip.ID, _ body: CommentPostBody) async throws -> ClipComment
    public var updateCommentReaction: @Sendable (_ commentID: String, _ body: CommentReactionBody) async throws -> CommentReactionResponse
    public var reportComment: @Sendable (_ commentID: String, _ body: CommentReportingBody) async throws -> CommentReportingResponse
    public var deleteComment: @Sendable (_ commentID: String) async throws -> CommentDeleteResponse
    public var toggleCommentAbilityOnClip: @Sendable (_ clipID: Clip.ID, _ body: CommentsToggle) async throws -> CommentsToggle
    public var totalCommentCountOnClip: @Sendable (_ clipID: Clip.ID) async throws -> CommentCountResponse
    public var updateFlag: @Sendable (Clip, Bool, String) async throws -> Void
    public var setReaction: @Sendable (Clip, Bool, Bool) async throws -> Void
    public var incrementPlayCount: @Sendable (Clip) async throws -> Void
    public var incrementAction: @Sendable (Clip, IncrementableAction) async throws -> Void
    public var getServiceStatus: @Sendable () async throws -> ServiceStatus?
    public var profileFollow: @Sendable (_ handle: String, _ unfollow: Bool) async throws -> Void
    public var registerDevice: @Sendable (_ token: String) async throws -> Void
    public var updatePlaylistClips: @Sendable (_ clipId: Clip.ID, _ playlistId: Playlist.ID, _ isAdded: Bool) async throws -> Void
    public var updatePlaylist: @Sendable (_ playlistId: Playlist.ID, _ title: String) async throws -> Void
    public var downloadStream: @Sendable (_ url: URL) -> AsyncThrowingStream<DownloadEvent, Error> = { _ in .finished() }
    public var uploadStream: @Sendable (_ input: URL, _ uploadRequest: UploadRequest) -> AsyncStream<UploadEvent> = { _, _ in XCTFail("\(Self.self).uploadStream"); return .init(unfolding: { nil }) }
    public var uploadData: @Sendable (_ input: Data, _ uploadRequest: UploadRequest) -> AsyncStream<UploadEvent> = { _, _ in XCTFail("\(Self.self).uploadImage"); return .init(unfolding: { nil }) }
    public var downloadData: @Sendable (_ url: URL) async throws -> Data
    public var setPlaylistVisibility: @Sendable (_ playlistId: Playlist.ID, _ isPublic: Bool) async throws -> Void
    public var createImageFile: @Sendable () async throws -> UploadRequest
    public var createAudioFile: @Sendable () async throws -> UploadRequest
    public var finishAudioUpload: @Sendable (_ uploadId: UploadRequest.ID, _ filename: String) async throws -> Void
    public var uploadAudioStatus: @Sendable (_ uploadId: UploadRequest.ID) async throws -> UploadRequestStatus
    public var initializeClip: @Sendable (_ uploadId: UploadRequest.ID) async throws -> String?
    public var getRecommendedStyles: @Sendable (_ excludedStyles: [String]) async throws -> Styles
    public var manifest: @Sendable () async throws -> Manifest
    public var updatePhoneNumber: @Sendable (_ phoneNumber: String) async throws -> Void
    public var deletePhoneNumber: @Sendable (_ phoneNumber: String) async throws -> Void
    public var getNotifications: @Sendable (_ after: Date?) async throws -> UserNotification
    public var setNotificationsRead: @Sendable (_ ids: [String]?, _ all: Bool) async throws -> Void
    public var getNotificationsV2: @Sendable (_ before: Date?, _ after: Date?) async throws -> UserNotificationV2
    public var setNotificationsReadV2: @Sendable (_ ids: [String]?, _ all: Bool) async throws -> Void
    public var getAlignedLyrics: @Sendable (_ clip: Clip) async throws -> AlignedLyrics
    public var createUserSessionId: @Sendable (_ deviceId: String) async throws -> AnalyticsSession
    public var userSessionEnded: @Sendable (_ sessionId: String, _ sessionLength: Int, _ sessionCreationTime: Int?, _ sessionExpirationTime: Int?) async throws -> Bool
    public var redeemPromoCode: @Sendable (_ promoCode: String) async throws -> UsePromoCodeResponse
    public var getUserAnalyticsData: @Sendable () async throws -> UserAnalyticsData
    public var getGeneratedLyricsV2: @Sendable (_ prompt: String) async throws -> LyricsResponse
}

public struct InvalidTokenError: Error, Equatable, LocalizedError {}

public extension APIError {
    struct StudioAPIErrorDetail: Codable {
        public let detail: String
    }
}

public enum APIError: Equatable, LocalizedError, CustomStringConvertible {
    /// StatusCode: 402
    case insufficientCredits(String? = nil)

    /// StatusCode: 403
    case forbidden(String? = nil)

    /// StatusCode: 422
    case invalidHCaptchaToken

    /// StatusCode: 429
    case tooManyRunningJobs

    /// StatusCode: 400...499
    case clientError(Int, String? = nil)

    /// StatusCode: 500...599
    case serverError(Int, String? = nil)

    case errorMessage(String)

    public var description: String {
        switch self {
        case .insufficientCredits: L10n.APIClient.insufficientCredits
        case .forbidden: L10n.APIClient.forbidden
        case .invalidHCaptchaToken: L10n.APIClient.invalidHcaptcha
        case .tooManyRunningJobs: L10n.APIClient.tooManRequests
        case let .clientError(code, message): L10n.APIClient.clientError(code, message ?? "")
        case let .serverError(code, message): L10n.APIClient.serverError(code, message ?? "")
        case let .errorMessage(message): L10n.APIClient.apiError(message)
        }
    }

    public var localizedDescription: String {
        errorDetail?.ifNotEmpty ?? description
    }

    public var errorDescription: String? {
        errorDetail?.ifNotEmpty ?? description
    }

    public var errorDetail: String? {
        switch self {
        case let .insufficientCredits(detail):
            return detail
        case let .forbidden(detail):
            return detail
        case .invalidHCaptchaToken:
            return nil
        case .tooManyRunningJobs:
            return nil
        case let .clientError(_, detail):
            return detail
        case let .serverError(_, detail):
            return detail
        case let .errorMessage(detail):
            return detail
        }
    }
}

public enum UserError: LocalizedError {
    case usernameAlreadyTaken

    public var errorDescription: String? {
        switch self {
        case .usernameAlreadyTaken: L10n.APIClient.usernameTaken
        }
    }
}

public enum ForbiddenError: LocalizedError {
    case publicContentRestrictions

    public var errorDescription: String? {
        switch self {
        case .publicContentRestrictions: L10n.APIClient.contentRestricted
        }
    }
}

extension APIClient: DependencyKey {
    public static let liveValue: Self = {
        @Dependency(BackendEnvironmentClient.self) var backendEnvironment
        @Dependency(RageshakeClient.self) var rageshakeClient
        let environmentConfiguration = backendEnvironment.configuration()
        let endpointHost = environmentConfiguration.apiEndpointHost
        guard let url = URL(string: "https://\(endpointHost)") else {
            fatalError("Invalid API_ENDPOINT configuration")
        }

        let invalidToken = PassthroughSubject<Void, Never>()

        let urlSessionTransportSession = URLSession.shared
        rageshakeClient.registerNetworkingInterception(urlSessionTransportSession.configuration)

        let sunoClient = SunoClientTransport(
            inner: URLSessionTransport(configuration: URLSessionTransport.Configuration(session: urlSessionTransportSession)),
            invalidToken: { invalidToken.send() }
        )

        let retryingMiddleware = RetryingMiddleware(
            // 429: Too many requests
            // 500..<600: These are the server error codes. Often they indicate problems with overwhelmed servers.
            signals: [.code(429), .range(500 ..< 600)],
            policy: .upToAttempts(count: 3),
            delay: .constant(seconds: 1)
        )

        let client = Client(
            serverURL: url,
            configuration: .init(
                dateTranscoder: ISO8601DateTranscoder(options: [.withFullDate, .withFullTime, .withFractionalSeconds])
            ),
            transport: sunoClient,
            middlewares: [
                AuthenticationMiddleware(),
                CustomHeadersMiddleware(),
                retryingMiddleware,
            ]
        )

        let unauthenticatedClient = Client(
            serverURL: url,
            configuration: .init(
                dateTranscoder: ISO8601DateTranscoder(options: [.withFullDate, .withFullTime, .withFractionalSeconds])
            ),
            transport: URLSessionTransport(),
            middlewares: [
                CustomHeadersMiddleware(),
                retryingMiddleware,
            ]
        )

        @discardableResult
        @Sendable
        func unboxingError<T>(_ function: @autoclosure () async throws -> T) async throws -> T {
            do {
                return try await function()
            } catch let error as OpenAPIRuntime.ClientError {
                throw error.underlyingError
            } catch {
                throw error
            }
        }

        @Dependency(ClerkClient.self) var clerkClient
        @Dependency(SunoModelClient.self) var sunoModelClient
        @Shared(.inMemory(.selectedSunoModel)) var selectedSunoModel: SunoModelMetaData = .modelDefault

        return Self(
            invalidToken: { UncheckedSendable(invalidToken.values).eraseToStream() },
            updateMe: { update in
                do {
                    try await unboxingError(await client.studio_api_bots_profiles_api_update_artist_profile(body: .json(.init(source: update))))
                } catch APIError.clientError(400, _) {
                    throw UserError.usernameAlreadyTaken
                } catch {
                    throw error
                }
            },
            deleteClip: { clip in
                _ = try await client.studio_api_bots_api_delete_clips(.init(body: .json(.init(ids: [.init(stringLiteral: clip.id.remoteId)]))))
            },
            trashClip: { clip, isTrashed in
                _ = try await client.studio_api_bots_api_trash_gen(body: .json(.init(clip_ids: [.init(stringLiteral: clip.id.remoteId)], trash: isTrashed)))
            },
            deletePlaylist: { playlistId in
                _ = try await client.studio_api_bots_api_trash_playlist(.init(body: .json(.init(playlist_id: playlistId))))
            },
            deleteAccount: {
                _ = try await client.studio_api_bots_api_delete_user_account(
                    .init(body: .json(.init(confirm_delete: .init(stringLiteral: "confirm_delete"))))
                )
            },
            // This endpoint will return request ID that will be used in `getGeneratedLyrics` below
            generateLyrics: { lyricsPrompt in
                let response = try await client.studio_api_bots_api_run_lyrics_generation(.init(body: .json(.init(prompt: lyricsPrompt))))
                return try response.ok.body.json.id
            },
            getGeneratedLyrics: { requestId in
                let response = try await client.studio_api_bots_api_get_lyrics_generation(path: .init(request_id: requestId))
                let remote = try response.ok.body.json
                return try GeneratedLyrics(source: remote)
            },
            getCommentsForClip: { clip_id, cursor, page_size, order in
                let response = try await client.studio_api_bots_api_clip_comments(
                    path: .init(clip_id: .init(stringLiteral: clip_id.remoteId)),
                    query: .init(cursor: cursor, page_size: page_size, order: order.propertyValue)
                )
                let remote = try response.ok.body.json
                return try CommentsPage(remote)
            },
            getRepliesForComment: { commentID, cursor, page_size in
                let response = try await client.studio_api_bots_api_get_comment_replies(
                    path: .init(comment_id: commentID),
                    query: .init(cursor: cursor, page_size: page_size)
                )
                let remote = try response.ok.body.json
                return try CommentRepliesPage(remote)
            },
            postCommentToClip: { clip_id, post_body in
                let response = try await client.studio_api_bots_api_post_comment(
                    path: .init(gen_id: .init(stringLiteral: clip_id.remoteId)),
                    body: .json(
                        .init(
                            content: post_body.content,
                            parent_id: post_body.parentID,
                            track_timestamp: post_body.trackTimestamp
                        )
                    )
                )

                let remote = try response.ok.body.json
                return try ClipComment(remote)
            },
            updateCommentReaction: { comment_id, reaction_body in
                let response = try await client.studio_api_bots_api_update_comment_reaction(
                    path: .init(comment_id: comment_id),
                    body: .json(.init(reaction: reaction_body.reaction))
                )

                let remote = try response.ok.body.json
                return try CommentReactionResponse(remote)
            },
            reportComment: { comment_id, reporting_body in
                let response = try await client.studio_api_bots_api_report_comment(
                    path: .init(comment_id: comment_id),
                    body: .json(.init(reason: reporting_body.reason))
                )

                let remote = try response.ok.body.json
                return CommentReportingResponse(remote)
            },
            deleteComment: { comment_id in
                let response = try await client.studio_api_bots_api_delete_comment(
                    path: .init(comment_id: comment_id)
                )

                let remote = try response.ok.body.json
                return try CommentDeleteResponse(remote)
            },
            toggleCommentAbilityOnClip: { clipID, body in
                let genID: String = .init(stringLiteral: clipID.remoteId)
                let response = try await client.studio_api_bots_api_toggle_comment_ability(
                    path: .init(gen_id: genID),
                    body: .json(.init(clip_id: genID, can_comment: body.canComment))
                )

                let remote = try response.ok.body.json
                return try CommentsToggle(remote)
            },
            totalCommentCountOnClip: { clipID in
                let genID: String = .init(stringLiteral: clipID.remoteId)
                let response = try await client.studio_api_bots_api_get_comment_count_for_clip(
                    path: .init(gen_id: genID)
                )
                let remote = try response.ok.body.json
                return try CommentCountResponse(remote)
            },
            updateFlag: { clip, flagged, reason in
                _ = try await client.studio_api_bots_api_update_flag_state(
                    path: .init(gen_id: .init(stringLiteral: clip.id.remoteId)),
                    body: .json(.init(flagged: flagged, flagged_reason: reason))
                )
            },
            setReaction: { clip, isLiked, isDisliked in
                _ = try await client.studio_api_bots_api_update_reaction_type(.init(
                    path: .init(gen_id: clip.id.remoteId),
                    body: .json(.init(reaction: isLiked ? .LIKE : isDisliked ? .DISLIKE : nil))
                ))
            },
            incrementPlayCount: { clip in
                _ = try await client.studio_api_bots_api_increment_play_count_with_spec(
                    path: .init(gen_id: .init(stringLiteral: clip.id.remoteId)),
                    body: .json(.init(sample_factor: .init(integerLiteral: 1)))
                )
            },
            incrementAction: { clip, action in
                _ = try await client.studio_api_bots_api_increment_action_count(
                    path: .init(gen_id: .init(stringLiteral: clip.id.remoteId)),
                    body: .json(.init(action: .init(stringLiteral: action.rawValue)))
                )
            },
            getServiceStatus: {
                let statusEndpoint = environmentConfiguration.statusEndpointPath
                guard let statusURL = URL(string: "https://\(statusEndpoint)") else {
                    fatalError("STATUS_ENDPOINT is not defined for this configuration")
                }

                let (data, response) = try await URLSession.shared.data(for: .init(url: statusURL))
                guard let httpResponse = response as? HTTPURLResponse, 200 ... 299 ~= httpResponse.statusCode else {
                    throw APIError.serverError((response as? HTTPURLResponse)?.statusCode ?? 0)
                }
                return try? JSONDecoder().decode(ServiceStatus.self, from: data)
            },
            profileFollow: { handle, unfollow in
                _ = try await client.studio_api_bots_profiles_api_follow_artist_profile(
                    .init(body: .json(.init(handle: handle, unfollow: unfollow)))
                )
            },
            registerDevice: { token in
                _ = try await client.studio_api_bots_mobile_api_register_push(body: .json(.init(token: token)))
            },
            updatePlaylistClips: { clipId, playlistId, isAdded in
                let metadata: OpenAPIValueContainer
                if isAdded {
                    metadata = try .init(unvalidatedValue: ["ids": [clipId.remoteId]])
                } else {
                    metadata = try .init(unvalidatedValue: ["clip_ids": [clipId.remoteId]])
                }

                _ = try await client.studio_api_bots_api_update_playlist_clips(.init(
                    body: .json(.init(
                        playlist_id: playlistId,
                        update_type: isAdded ? "remove_by_id" : "add",
                        metadata: metadata
                    ))
                ))
            },
            updatePlaylist: { playlistId, name in
                _ = try await client.studio_api_bots_api_set_playlist_metadata(
                    .init(body: .json(.init(playlist_id: playlistId, name: name, description: "")))
                )
            },
            downloadStream: { url in
                AsyncThrowingStream { continuation in
                    Task {
                        do {
                            let (bytes, response) = try await URLSession.shared.bytes(from: url)
                            var data = Data()
                            var progress = 0
                            for try await byte in bytes {
                                data.append(byte)
                                let newProgress = Int(Double(data.count) / Double(response.expectedContentLength) * 100)
                                if newProgress != progress {
                                    progress = newProgress
                                    continuation.yield(.updateProgress(Double(progress) / 100))
                                }
                            }
                            continuation.yield(.response(data))
                            continuation.finish()
                        } catch {
                            continuation.finish(throwing: error)
                        }
                    }
                }
            },
            uploadStream: { url, uploadRequest in
                AsyncStream { continuation in
                    Task {
                        do {
                            let uploadResult = try uploadToS3(
                                localFileUrl: url,
                                uploadRequest: uploadRequest,
                                limitUploadsOnLowNetwork: false,
                                networkAccessIsLimited: nil
                            )
                            for try await progress in uploadResult.progressStream {
                                continuation.yield(UploadEvent.updateProgress(progress))
                            }
                            continuation.yield(UploadEvent.success)
                            continuation.finish()
                        } catch {
                            log.telemetry.error(error, message: "Upload stream failed")
                            continuation.yield(UploadEvent.failure(error))
                            continuation.finish()
                        }
                    }
                }
            },
            uploadData: { data, uploadRequest in
                AsyncStream { continuation in
                    Task {
                        do {
                            let uploadResult = uploadDataToS3(
                                fileData: data,
                                filename: uploadRequest.id,
                                uploadRequest: uploadRequest,
                                limitUploadsOnLowNetwork: false,
                                networkAccessIsLimited: nil
                            )
                            for try await progress in uploadResult.progressStream {
                                continuation.yield(UploadEvent.updateProgress(progress))
                            }
                            continuation.yield(UploadEvent.success)
                            continuation.finish()
                        } catch {
                            log.telemetry.error(error, message: "Upload data failed")
                            continuation.yield(UploadEvent.failure(error))
                            continuation.finish()
                        }
                    }
                }
            },
            downloadData: { url in
                let (data, response) = try await URLSession.shared.data(from: url)
                guard let httpResponse = response as? HTTPURLResponse
                else { throw URLError(.badServerResponse) }
                guard 200 ... 299 ~= httpResponse.statusCode
                else {
                    throw httpResponse.statusCode >= 500
                        ? APIError.serverError(httpResponse.statusCode)
                        : APIError.clientError(httpResponse.statusCode)
                }
                return data
            },
            setPlaylistVisibility: { playlistId, isPublic in
                _ = try await client.studio_api_bots_playlist_reaction_api_set_visibility(
                    path: .init(playlist_id: playlistId),
                    body: .json(.init(is_public: isPublic))
                )
            },
            createImageFile: {
                let response = try await client.studio_api_bots_uploads_api_start_image_upload(.init(body: .json(.init(_extension: "jpeg"))))
                let remote = try response.ok.body.json
                return try UploadRequest(remote)
            },
            createAudioFile: {
                let response = try await client.studio_api_bots_uploads_api_start_audio_upload(.init(body: .json(.init(_extension: "mp3"))))
                let remote = try response.ok.body.json
                return try UploadRequest(remote)
            },
            finishAudioUpload: { uploadId, filename in
                _ = try await client.studio_api_bots_uploads_api_finish_processing_audio_upload(
                    path: .init(upload_id: uploadId),
                    body: .json(.init(upload_type: "audio_recording", upload_filename: filename))
                )
            },
            uploadAudioStatus: { uploadId in
                let response = try await client.studio_api_bots_uploads_api_get_audio_upload_status(path: .init(upload_id: uploadId))
                let remote = try response.ok.body.json
                return UploadRequestStatus(remote)
            },
            initializeClip: { uploadId in
                let response = try await client.studio_api_bots_uploads_api_initialize_upload_clip(path: .init(upload_id: uploadId))
                let remote = try response.ok.body.json
                return remote.clip_id
            },
            getRecommendedStyles: { excludedStyles in
                let response = try await client.studio_api_bots_api_refresh_recommend_styles(body: .json(.init(excluded_styles: excludedStyles)))
                let remote = try response.ok.body.json
                return try Styles(source: remote)
            },
            manifest: {
                let response = try await unauthenticatedClient.studio_api_bots_app_version_update_api_get_version_updates(.init(body: .json(.init(app_name: "Suno", platform: "ios"))))
                let remote = try response.ok.body.json
                return Manifest(remote)
            },
            updatePhoneNumber: { phoneNumber in
                _ = try await client.studio_api_bots_user_api_update_phone_number(.init(body: .json(.init(phone: phoneNumber))))
            },
            deletePhoneNumber: { phoneNumber in
                try await clerkClient.deletePhoneNumber(phoneNumber)
                _ = try await client.studio_api_bots_user_api_delete_phone_number(.init(body: .json(.init(phone: phoneNumber))))
            },
            getNotifications: { after in
                let response = try await client.studio_api_bots_notification_api_get_notifications(query: .init(after_datetime_utc: after))
                let remote = try response.ok.body.json
                return try UserNotification(remote)
            },
            setNotificationsRead: { ids, all in
                _ = try await client.studio_api_bots_notification_api_mark_notifications_as_read(body: .json(.init(ids: ids, all: all)))
            },
            getNotificationsV2: { before, after in
                let response = try await client
                    .studio_api_bots_notification_api_get_notifications_v2(query: .init(
                        before_datetime_utc: before,
                        after_datetime_utc: after
                    ))
                let remote = try response.ok.body.json
                return try UserNotificationV2(remote)
            },
            setNotificationsReadV2: { ids, all in
                _ = try await client
                    .studio_api_bots_notification_api_mark_notifications_as_read_v2(
                        body: .json(.init(ids: ids, all: all))
                    )
            },
            getAlignedLyrics: { clip in
                let response = try await client.studio_api_bots_gen_api_get_aligned_lyrics_v2(path: .init(clip_id: clip.id.remoteId))
                let remote = try response.ok.body.json
                return AlignedLyrics(remote)
            },
            createUserSessionId: { properties in
                let response = try await client.studio_api_bots_user_api_create_session_id(body: .json(.init(session_properties: properties, session_type: ._1)))
                let remote = try response.ok.body.json
                return AnalyticsSession(remote)
            },
            userSessionEnded: { _, _, _, _ in
                false
            },
            redeemPromoCode: { promoCode in
                let response = try await client.studio_api_bots_invite_ios_api_redeem_promo_code(body: .json(.init(promo_code: promoCode)))
                let remote = try response.ok.body.json
                return UsePromoCodeResponse(remote)
            },
            getUserAnalyticsData: {
                let response = try await client.studio_api_bots_analytics_api_get_user_analytics_data()
                let remote = try response.ok.body.json
                return try UserAnalyticsData(remote)
            },
            getGeneratedLyricsV2: { prompt in
                let response = try await client.studio_api_bots_api_run_lyrics_generation(.init(body: .json(.init(prompt: prompt))))
                let remote = try response.ok.body.json

                var lyricsResponse: LyricsResponse = .initialState
                let interval = 2
                var currentAttempt = 0
                let retryLimit = 10

                while lyricsResponse.status != "complete", currentAttempt < retryLimit {
                    currentAttempt += 1
                    let response = try await client.studio_api_bots_api_get_lyrics_generation(path: .init(request_id: remote.id))
                    let remote = try response.ok.body.json
                    lyricsResponse = LyricsResponse(remote)

                    if let errorMessage = lyricsResponse.errorMessage,
                       !errorMessage.isEmpty
                    {
                        throw APIError.errorMessage(errorMessage)
                    }

                    try await Task.sleep(for: .seconds(interval))
                }

                if currentAttempt >= retryLimit {
                    throw APIError.errorMessage("Lyrics generation timed out")
                }

                return lyricsResponse
            }
        )
    }()
}

extension Prompt.TaskType {
    var asPayload: Components.Schemas.GenParamsSpec.taskPayload? {
        .init(rawValue: self.rawValue)
    }
}

public extension DependencyValues {
    var apiClient: APIClient {
        get { self[APIClient.self] }
        set { self[APIClient.self] = newValue }
    }
}

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

    public static let testValue = Self()
}

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