import APIClient
import CameraClient
import ComponentLibrary
import ComposableArchitecture
import Errors
import FeatureToasts
import FeatureVideoTrimmer
import GenAPI
import Localization
import PhotoLibraryClient
import PhotosUI
import StatsigClient
import SwiftUI
import Utilities

// swiftlint:disable file_length

@Reducer
public struct ReplaceSongArt {
    @Reducer(state: .equatable)
    public enum Destination {
        case camera
        case alert(AlertState<Alert>)
        case preview(VideoSongArtFullscreenPreview)
        case videoTrimmer(VideoTrimmer)

        public enum Alert {
            case openSettings
            case discardChanges
        }
    }

    enum UploadState: Equatable {
        case idle
        case uploading(progress: Double)
        case uploaded
        case checkingStatus(UploadRequestStatus)
        case completed(UploadRequestStatus)
        case error(AnyError)

        var isComplete: Bool {
            if case .completed(let status) = self, status.status == .complete {
                return true
            }
            return false
        }
    }

    enum AssetLoadingState<Asset: Equatable>: Equatable {
        case loading(PhotosPickerItem)
        case preparing(Asset)
        case done(Result<Asset, AnyError>)

        var pickerItem: PhotosPickerItem? {
            switch self {
            case .loading(let item):
                return item
            case .preparing, .done:
                return nil
            }
        }

        var asset: Asset? {
            switch self {
            case .done(.success(let asset)):
                return asset
            case .loading, .done(.failure), .preparing:
                return nil
            }
        }

        var preparingAsset: Asset? {
            if case .preparing(let asset) = self {
                return asset
            }
            return nil
        }
    }

    @ObservableState
    public struct State: Equatable {
        @Presents public var destination: Destination.State?
        @ObservationStateIgnored @ObservedBox var toastState = ToastReducer.State()

        let clip: Clip
        let showSheetNavigationBar: Bool

        @Shared(.appStorage(.hasUsedVideoCoversFeature)) var hasUsedVideoCoversFeature = false

        public init(clip: Clip, showSheetNavigationBar: Bool = false) {
            self.clip = clip
            self.videoAspectMode = clip.isVideoLandscape ? .resizeAspect : .resizeAspectFill
            self.showSheetNavigationBar = showSheetNavigationBar
        }

        // PhotosPicker items
        var photoPickerItem: PhotosPickerItem? {
            get { photoLoadingState?.pickerItem }
            set {
                if let newValue {
                    photoLoadingState = .loading(newValue)
                } else {
                    photoLoadingState = nil
                }
            }
        }

        var videoPickerItem: PhotosPickerItem? {
            get { videoLoadingState?.pickerItem }
            set {
                if let newValue {
                    videoLoadingState = .loading(newValue)
                } else {
                    videoLoadingState = nil
                }
            }
        }

        var shouldShowVideoCoverInHooksFeedText: Bool {
            FeatureFlag.hooks.isFeedEnabled
        }

        // Core asset states
        var videoLoadingState: AssetLoadingState<AVAsset>?
        var photoLoadingState: AssetLoadingState<UIImage>?

        // Playback state
        var videoAspectMode: AVLayerVideoGravity

        // Camera access state
        var isRequestingCameraAccess = false

        // Upload state
        var uploadId: String?
        var uploadState: UploadState = .idle

        // Save button state
        var showSaveButtonProgressSpinner: Bool = false
        var didTapSaveWhileUploading: Bool {
            !uploadState.isComplete && photoLoadingState?.asset == nil && videoLoadingState?.asset == nil
        }

        // Constants
        let cardHeight: CGFloat = UIScreen.isCompact ? 230.0 : 372.0
        let aspectRatio: CGFloat = 9.0 / 16.0

        // MARK: - Updated computed properties

        var clipHasVideoCover: Bool {
            clip.videoCoverUrl != nil
        }

        var hasUnsavedChanges: Bool {
            videoLoadingState != nil || photoLoadingState != nil
        }

        var isUploadingNewCoverArt: Bool {
            showSaveButtonProgressSpinner && hasUnsavedChanges
        }

        var showPlaybackControls: Bool {
            guard photoLoadingState == nil else { return false }
            return videoLoadingState?.asset != nil || (clipHasVideoCover && !isImportingFromLibrary)
        }

        var isImportingPhotoFromLibrary: Bool {
            if case .loading = photoLoadingState { return true }
            return false
        }

        var isImportingVideoFromLibrary: Bool {
            if case .loading = videoLoadingState {
                return true
            } else if case .preparing = videoLoadingState {
                return true
            } else {
                return false
            }
        }

        var isImportingFromLibrary: Bool {
            isImportingPhotoFromLibrary || isImportingVideoFromLibrary
        }
    }

    public enum Action: BindableAction {
        case destination(PresentationAction<Destination.Action>)
        case toastAction(ToastReducer.Action)

        case dismiss
        case dismissTapped
        case dismissPicker
        case saveTapped
        case cameraRowTapped
        case setPhotoPickerItem(PhotosPickerItem)
        case setVideoPickerItem(PhotosPickerItem)
        case selectFromPhotosFailed
        case selectImage(Result<UIImage, Error>)
        case selectImageFromPhotos(UIImage)
        case selectVideo(Result<URL, Error>)
        case selectVideoFromPhotos(Movie)
        case presentVideoTrimmer(URL)
        case dismissVideoTrimmer
        case playTapped
        case dismissPreview
        case binding(BindingAction<State>)
        case `internal`(Internal)
        case delegate(Delegate)

        public enum Internal {
            case cameraPermissionResponse(Result<(granted: Bool, wasNotDetermined: Bool), Error>)
            case presentCamera

            // Clip update actions
            case updateClipResponse(Result<Void, Error>)
            case setVideoCoverClipResponse(Result<Clip, Error>)
            case getClipResponse(Result<Clip, Error>)

            // Video asset load actions
            case loadAsset
            case loadAssetResponse(Result<AVAsset, Error>)
            case cancelVideoUpload

            // Video upload actions
            case createVideoFileResponse(Result<UploadRequest, Error>)
            case uploadVideoResponse(UploadRequest.ID, Result<APIClientV2.VideoUploadEvent, Error>)
            case finishVideoUploadResponse(UploadRequest.ID, Result<Void, Error>)
            case checkVideoStatus(UploadRequest.ID)
            case videoStatusResponse(Result<UploadRequestStatus, Error>)

            // Aspect ratio actions
            case checkAspectRatio
            case checkAspectRatioResponse(Bool)
        }

        public enum Delegate {
            case songArtUpdateSuccess(Clip)
        }
    }

    public init() {}

    @Dependency(PhotoLibraryClient.self) var photoLibraryClient
    @Dependency(CameraClient.self) var cameraClient
    @Dependency(\.dismiss) var dismiss
    @Dependency(APIClientV2.self) var apiClientV2
    @Dependency(\.continuousClock) var clock

    public var body: some ReducerOf<Self> {
        Scope(state: \.toastState, action: \.toastAction) {
            ToastReducer()
        }
        BindingReducer()
            .onChange(of: \.videoLoadingState) { _, newValue in
                Reduce { _, _ in
                    guard case .loading(let item) = newValue else { return .none }
                    return .send(.setVideoPickerItem(item))
                }
            }
            .onChange(of: \.photoLoadingState) { _, newValue in
                Reduce { _, _ in
                    guard case .loading(let item) = newValue else { return .none }
                    return .send(.setPhotoPickerItem(item))
                }
            }
        Reduce { state, action in
            struct VideoUploadCancellableId: Hashable {}
            struct VideoProcessingCancellableId: Hashable {}

            switch action {
            case .dismissTapped:
                guard state.hasUnsavedChanges else { return .send(.dismiss) }

                // "Your upload is still ongoing" if we have one ongoing,
                // and the user tapped "Save" (so we don't show that
                // we were actually already uploading),
                // otherwise show "You have unsaved changes"
                let unsavedChangesOrOngoingUploadMessage: String
                if state.videoLoadingState != nil, !state.uploadState.isComplete, state.showSaveButtonProgressSpinner {
                    unsavedChangesOrOngoingUploadMessage = L10n.FeatureClipDetail.discardCoverUploadMessage
                } else {
                    unsavedChangesOrOngoingUploadMessage = L10n.FeatureClipDetail.discardCoverUnsavedMessage
                }

                state.destination = .alert(.init(
                    title: { TextState(L10n.FeatureClipDetail.discardCoverTitle) },
                    actions: {
                        ButtonState(role: .destructive, action: .send(.discardChanges)) { TextState(L10n.FeatureClipDetail.discardCoverConfirm) }
                        ButtonState(role: .cancel) { TextState(L10n.FeatureClipDetail.discardCoverCancel) }
                    },
                    message: { TextState(unsavedChangesOrOngoingUploadMessage) }
                ))
                return .none

            case .destination(.presented(.alert(.discardChanges))):
                return .merge(
                    .send(.dismiss),
                    .send(.internal(.cancelVideoUpload))
                )

            case .destination(.presented(.alert(.openSettings))):
                return .run { _ in
                    guard let url = URL(string: UIApplication.openSettingsURLString),
                          await UIApplication.shared.canOpenURL(url)
                    else {
                        assertionFailure(L10n.FeaturePhotoPicker.settingsFailure)
                        return
                    }

                    await UIApplication.shared.open(url, options: [:], completionHandler: nil)
                }

            case .dismiss:
                return .merge(
                    .run { _ in await dismiss() },
                    .send(.internal(.cancelVideoUpload))
                )

            case .cameraRowTapped:
                state.isRequestingCameraAccess = true
                return .run { [statusBefore = cameraClient.authorizationStatus()] send in
                    await send(.internal(.cameraPermissionResponse(Result(catching: {
                        let granted = await cameraClient.requestAuthorization()
                        return (granted: granted, wasNotDetermined: statusBefore == .notDetermined)
                    }))))
                }

            case .internal(.cameraPermissionResponse(.success(let response))):
                // TODO: Handle granted == false
                state.isRequestingCameraAccess = false
                let status = cameraClient.authorizationStatus()
                switch status {
                case .notDetermined:
                    return .run { send in
                        try await Task.sleep(for: .seconds(1))
                        await send(.internal(.cameraPermissionResponse(.success(response))))
                    }

                case .restricted,
                     .denied:
                    state.destination = .alert(.init(
                        title: { TextState(L10n.FeaturePhotoPicker.cameraAccessTitle) },
                        actions: {
                            ButtonState(role: .destructive, action: .openSettings) { TextState(L10n.FeaturePhotoPicker.cameraAccessButtonSettings) }
                            ButtonState(role: .cancel) { TextState(L10n.FeaturePhotoPicker.cameraAccessButtonCancel) }
                        },
                        message: { TextState(L10n.FeaturePhotoPicker.cameraAccessMessage) }
                    ))
                    return .none

                case .authorized:
                    if response.wasNotDetermined {
                        return .run { send in
                            try await Task.sleep(for: .milliseconds(300))
                            await send(.internal(.presentCamera))
                        }
                    } else {
                        state.destination = .camera
                        return .none
                    }

                @unknown default:
                    return .none
                }

            case .internal(.presentCamera):
                state.destination = .camera
                return .none

            case .internal(.cameraPermissionResponse(.failure(let error))):
                state.isRequestingCameraAccess = false
                log.telemetry.error(error)
                return .none

            case .saveTapped:
                state.showSaveButtonProgressSpinner = true
                // Check if we have any errors since upload technically starts right after the
                // user selects and trims their video
                if case .error = state.uploadState {
                    if state.showSaveButtonProgressSpinner {
                        state.showSaveButtonProgressSpinner = false
                        state.uploadState = .idle
                        state.uploadId = nil
                        return .send(.toastAction(.show(.warning(L10n.FeatureClipDetail.videoUploadFailed, .string(L10n.FeatureClipDetail.videoUploadFailedMessage), position: .top))))
                    } else {
                        // Restart the upload process
                        state.showSaveButtonProgressSpinner = true
                        state.uploadState = .idle
                        return .run { send in
                            await send(.internal(.createVideoFileResponse(Result(catching: { try await apiClientV2.getVideoUploadParams() }))))
                        }
                    }
                } else if !state.uploadState.isComplete, state.photoLoadingState?.asset == nil {
                    return .none
                } else if case .completed = state.uploadState {
                    // Fetch new uploadId if we don't have one, just in case
                    guard let uploadId = state.uploadId else {
                        state.uploadState = .error(AnyError(ReplaceSongArtError.videoUploadIdMissing))
                        return .none
                    }
                    return .run { [clipId = state.clip.id.remoteId] send in
                        await send(.internal(.setVideoCoverClipResponse(Result(catching: {
                            try await apiClientV2.setVideoCover(clipId, uploadId)
                        }))))
                    }
                } else if let image = state.photoLoadingState?.asset {
                    guard let base64Image = image.jpegData(compressionQuality: 0.7)?.base64EncodedString() else {
                        let error = ReplaceSongArtError.imageFileCompressionFailed
                        log.telemetry.error(error)
                        state.uploadState = .error(AnyError(error))
                        return .send(.toastAction(.show(.warning(L10n.FeatureClipDetail.imageUploadFailed, .string(L10n.FeatureClipDetail.uploadFailedMessage), position: .top))))
                    }

                    return .run { [clip = state.clip, base64Image = base64Image] send in
                        let update = ClipMetadataSpec(imageUrl: "data:image/png;base64,\(base64Image)")
                        // Clear the video cover if it exists
                        _ = try? await apiClientV2.removeVideoCover(clip.id)
                        await send(.internal(.updateClipResponse(Result(catching: { try await apiClientV2.updateClip(clip.id.remoteId, update) }))))
                    }
                }

                return .none

            case .setPhotoPickerItem(let item):
                return .run { send in
                    if let data = try? await item.loadTransferable(type: Data.self),
                       let image = UIImage(data: data)
                    {
                        await send(.selectImageFromPhotos(image))
                    } else {
                        await send(.selectFromPhotosFailed)
                    }
                }

            case .setVideoPickerItem(let item):
                return .run { send in
                    if let loaded = try? await item.loadTransferable(type: Movie.self) {
                        await send(.selectVideoFromPhotos(loaded))
                    } else {
                        await send(.selectFromPhotosFailed)
                    }
                }

            case .selectFromPhotosFailed:
                state.showSaveButtonProgressSpinner = false
                return .send(.toastAction(.show(.warning(L10n.FeatureClipDetail.errorTitle, .string(L10n.FeatureClipDetail.uploadFailedMessage), position: .top))))

            case .selectImageFromPhotos(let image):
                state.photoLoadingState = .done(.success(image))
                return .send(.dismissPicker)

            case .selectImage(let result):
                guard case .success(let image) = result else {
                    // Show error toast
                    return .send(.toastAction(.show(.warning(L10n.FeatureClipDetail.errorTitle, .string(L10n.FeatureClipDetail.uploadFailedMessage), position: .top))))
                }

                state.photoLoadingState = .done(.success(image))
                state.videoLoadingState = nil
                return .send(.dismissPicker)

            case .selectVideo(let result):
                switch result {
                case .success(let url):
                    return .send(.presentVideoTrimmer(url))
                case .failure(let error):
                    log.telemetry.error(error)
                    return .send(.dismissPicker)
                }

            case .selectVideoFromPhotos(let video):
                state.$hasUsedVideoCoversFeature.withLock { $0 = true }
                return .merge(
                    .send(.internal(.checkAspectRatio)),
                    .send(.presentVideoTrimmer(video.url))
                )

            case .presentVideoTrimmer(let videoURL):
                state.destination = .videoTrimmer(.init(videoURL: videoURL, contentType: .videoCover))
                return .none

            case .destination(.presented(.videoTrimmer(.delegate(.saveResponse(.success(let trimmedURL)))))):
                state.videoLoadingState = .preparing(AVAsset(url: trimmedURL))
                return .run { send in
                    await send(.dismissVideoTrimmer)
                    await send(.internal(.loadAsset))
                }

            case .destination(.presented(.videoTrimmer(.delegate(.saveResponse(.failure(let error)))))):
                log.telemetry.error(error)
                return .send(.dismissVideoTrimmer)

            case .destination(.presented(.videoTrimmer(.delegate(.showDurationError)))):
                state.videoLoadingState = nil
                return .concatenate(
                    .send(.dismissVideoTrimmer),
                    .send(.toastAction(.show(.warning(L10n.FeatureClipDetail.videoSongArtTooShortTitle, .string(L10n.FeatureClipDetail.videoSongArtTooShortMessage), position: .top))))
                )

            case .destination(.presented(.videoTrimmer(.dismiss))):
                state.destination = nil
                state.videoLoadingState = nil
                return .none

            case .dismissPicker:
                state.destination = nil
                return .none

            case .dismissVideoTrimmer:
                state.destination = nil
                return .none

            case .playTapped:
                var videoAsset: AVAsset?
                if let asset = state.videoLoadingState?.asset {
                    videoAsset = asset
                } else if let coverUrl = state.clip.videoCoverUrl,
                          let url = URL(string: coverUrl)
                {
                    videoAsset = AVAsset(url: url)
                }
                guard let videoAsset else { return .none }
                state.destination = .preview(.init(clip: state.clip, videoAsset: videoAsset, videoGravity: state.videoAspectMode))
                return .none

            case .dismissPreview:
                state.destination = nil
                return .none

            case .internal(.updateClipResponse(.success)):
                state.showSaveButtonProgressSpinner = false
                state.uploadState = .uploaded
                return .run { [clipId = state.clip.id.remoteId] send in
                    await send(.internal(.getClipResponse(Result(catching: { try await apiClientV2.getClip(clipId) }))))
                }

            case .internal(.updateClipResponse(.failure(let error))):
                state.showSaveButtonProgressSpinner = false
                state.uploadState = .error(AnyError(error))
                log.telemetry.error(error)
                return .send(.toastAction(.show(.warning(L10n.FeatureClipDetail.imageUploadFailed, .string(L10n.FeatureClipDetail.uploadFailedMessage), position: .top))))

            case .internal(.loadAsset):
                guard let videoAsset = state.videoLoadingState?.preparingAsset else { return .none }

                return .run { [videoAsset] send in
                    do {
                        _ = try await videoAsset.load(.isPlayable)
                        await send(.internal(.checkAspectRatio))
                    } catch {
                        await send(.internal(.loadAssetResponse(.failure(error))))
                    }
                }

            case .internal(.loadAssetResponse(.failure(let error))):
                log.telemetry.error(error)
                return .none

            case .internal(.checkAspectRatio):
                guard let videoAsset = state.videoLoadingState?.preparingAsset else { return .none }

                return .run { [aspectRatio = state.aspectRatio] send in
                    guard aspectRatio > 0 else { return }
                    let needsAdjustment = try? await videoAsset.isLandscape()
                    await send(.internal(.checkAspectRatioResponse(needsAdjustment ?? false)))
                }

            case .internal(.checkAspectRatioResponse(let needsAdjustment)):
                state.videoAspectMode = needsAdjustment ? .resizeAspect : .resizeAspectFill
                guard let videoAsset = state.videoLoadingState?.preparingAsset else { return .none }
                state.videoLoadingState = .done(.success(videoAsset))
                return .run { send in
                    await send(.internal(.createVideoFileResponse(Result(catching: { try await apiClientV2.getVideoUploadParams() }))))
                }

            // Video Upload Actions
            case .internal(.createVideoFileResponse(.success(let uploadRequest))):
                guard let videoAsset = state.videoLoadingState?.asset,
                      let videoURL = (videoAsset as? AVURLAsset)?.url
                else {
                    state.showSaveButtonProgressSpinner = false
                    return .send(.toastAction(.show(.warning(L10n.FeatureClipDetail.videoUploadFailed, .string(L10n.FeatureClipDetail.uploadFailedMessage), position: .top))))
                }

                return .run { send in
                    await withTaskCancellation(id: VideoUploadCancellableId(), cancelInFlight: true) {
                        do {
                            let fileName = "video_upload_\(uploadRequest.id)"
                            let exportURL = try await exportAndStoreInputVideo(inputURL: videoURL, fileName: fileName)
                            try Task.checkCancellation()
                            let uploadSession = apiClientV2.uploadVideo(exportURL, uploadRequest, false)
                            for try await event in uploadSession.stream {
                                try Task.checkCancellation()
                                await send(.internal(.uploadVideoResponse(uploadRequest.id, .success(event))))
                            }
                        } catch is CancellationError {
                            await send(.internal(.cancelVideoUpload))
                        } catch {
                            await send(.internal(.uploadVideoResponse(uploadRequest.id, .failure(error))))
                        }
                    }
                }

            case .internal(.createVideoFileResponse(.failure(let error))):
                state.uploadState = .error(AnyError(error))
                log.telemetry.error(error)
                if state.showSaveButtonProgressSpinner {
                    state.showSaveButtonProgressSpinner = false
                    return .send(.toastAction(.show(.warning(L10n.FeatureClipDetail.videoUploadFailed, .string(L10n.FeatureClipDetail.uploadFailedMessage), position: .top))))
                } else {
                    return .none
                }

            case .internal(.uploadVideoResponse(_, .success(.updateProgress(let progress)))):
                state.uploadState = .uploading(progress: progress)
                return .none

            case .internal(.uploadVideoResponse(let requestId, .success(.success))):
                state.uploadState = .uploaded
                let fileName = "\(requestId).mp4"
                return .run { send in
                    await send(.internal(.finishVideoUploadResponse(requestId, Result(catching: {
                        let finishUploadSpec = FinishUploadSpec(isVideoCover: true, uploadFilename: fileName, uploadType: "file_upload")
                        try await apiClientV2.markVideoUploadComplete(requestId, finishUploadSpec)
                    }))))
                }

            case .internal(.uploadVideoResponse(_, .failure(let error))):
                state.uploadState = .error(AnyError(error))
                let errorWithUploadId = ReplaceSongArtError.uploadVideo(uploadId: state.uploadId ?? "nil", error)
                log.telemetry.error(errorWithUploadId)
                if state.showSaveButtonProgressSpinner {
                    state.showSaveButtonProgressSpinner = false
                    return .send(.toastAction(.show(.warning(L10n.FeatureClipDetail.videoUploadFailed, .string(L10n.FeatureClipDetail.uploadFailedMessage), position: .top))))
                } else {
                    return .none
                }

            case .internal(.uploadVideoResponse(_, .success(.postponed))):
                return .none

            case .internal(.finishVideoUploadResponse(let requestId, .success)):
                state.uploadId = requestId
                return .run { send in
                    await withTaskCancellation(id: VideoProcessingCancellableId(), cancelInFlight: true) {
                        do {
                            for await _ in clock.timer(interval: .seconds(3)) {
                                try Task.checkCancellation()
                                await send(.internal(.checkVideoStatus(requestId)))
                            }

                            // Get the updated clip
                        } catch is CancellationError {
                            await send(.internal(.cancelVideoUpload))
                        } catch {
                            await send(.internal(.videoStatusResponse(.failure(error))))
                        }
                    }
                }

            case .internal(.finishVideoUploadResponse(_, .failure(let error))):
                state.uploadState = .error(AnyError(error))
                let errorWithUploadId = ReplaceSongArtError.finishVideoUpload(uploadId: state.uploadId ?? "nil", error)
                log.telemetry.error(errorWithUploadId)
                if state.showSaveButtonProgressSpinner {
                    state.showSaveButtonProgressSpinner = false
                    // Show "Something went wrong. Please try again" if we get an unexpected error
                    // but log the underlying error
                    return .send(.toastAction(.show(.warning(L10n.FeatureClipDetail.videoUploadFailed, .string(L10n.FeatureClipDetail.uploadFailedMessage), position: .top))))
                } else {
                    return .none
                }

            case .internal(.checkVideoStatus(let uploadId)):
                return .run { send in
                    await send(.internal(.videoStatusResponse(Result(catching: {
                        try await apiClientV2.getVideoUploadStatus(uploadId)
                    }))))
                }

            case .internal(.videoStatusResponse(let result)):
                switch result {
                case .success(let status):
                    state.uploadState = .checkingStatus(status)
                    if let errorMessage = status.errorMessage {
                        var effects: [Effect<Action>] = [
                            .cancel(id: VideoProcessingCancellableId()),
                        ]
                        let error = ReplaceSongArtError.videoUploadStatusCheckFailed(errorMessage)
                        state.uploadState = .error(AnyError(error))
                        log.telemetry.error(error)
                        guard state.didTapSaveWhileUploading else { return .merge(effects) }
                        state.showSaveButtonProgressSpinner = false
                        effects.append(.send(.toastAction(.show(.warning(L10n.FeatureClipDetail.videoUploadFailed, .string(errorMessage), position: .top)))))
                        return .merge(effects)
                    } else if status.status == .complete {
                        var effects: [Effect<Action>] = [
                            .cancel(id: VideoProcessingCancellableId()),
                        ]
                        guard let uploadId = state.uploadId else {
                            state.uploadState = .error(AnyError(ReplaceSongArtError.videoUploadIdMissing))
                            return .merge(effects)
                        }
                        state.uploadState = .completed(.complete(id: uploadId, imageUrl: nil))
                        guard state.uploadState.isComplete,
                              state.photoLoadingState?.asset == nil,
                              state.showSaveButtonProgressSpinner
                        else { return .merge(effects) }
                        effects.append(.run { [clipId = state.clip.id.remoteId] send in
                            await send(.internal(.setVideoCoverClipResponse(Result(catching: {
                                try await apiClientV2.setVideoCover(clipId, uploadId)
                            }))))
                        })

                        return .merge(effects)
                    } else {
                        // Don't do anything if the upload is still processing
                        return .none
                    }

                case .failure(let error):
                    var effects: [Effect<Action>] = [
                        .cancel(id: VideoProcessingCancellableId()),
                    ]
                    state.uploadState = .error(AnyError(error))
                    let errorWithUploadId = ReplaceSongArtError.videoStatus(uploadId: state.uploadId ?? "nil", error)
                    log.telemetry.error(errorWithUploadId)
                    guard state.showSaveButtonProgressSpinner else { return .merge(effects) }
                    state.showSaveButtonProgressSpinner = false
                    effects.append(.send(.toastAction(.show(.warning(L10n.FeatureClipDetail.videoUploadFailed, .string(L10n.FeatureClipDetail.uploadFailedMessage), position: .top)))))
                    return .merge(effects)
                }

            case .internal(.getClipResponse(let result)),
                 .internal(.setVideoCoverClipResponse(let result)):
                var effects: [Effect<Action>] = [
                    .cancel(id: VideoProcessingCancellableId()),
                ]

                state.videoLoadingState = nil
                state.photoLoadingState = nil

                // Only send an updated clip if we have it
                switch result {
                case .success(let clip):
                    effects.append(.send(.delegate(.songArtUpdateSuccess(clip))))
                case .failure(let error):
                    log.telemetry.error(error)
                    // Send the clip without the updated video url
                    // This just means video upload succeeded, but we couldn't fetch the latest clip
                    // The user will not see the updated preview in SongActionsMenu when this sheet dismisses,
                    // but maybe after the next clip fetch
                    effects.append(.send(.delegate(.songArtUpdateSuccess(state.clip))))
                }

                // Show success toast and dismiss
                return .concatenate(effects)

            case .internal(.cancelVideoUpload):
                state.showSaveButtonProgressSpinner = false
                state.uploadState = .idle
                return .merge(
                    .cancel(id: VideoUploadCancellableId()),
                    .cancel(id: VideoProcessingCancellableId())
                    // TODO: Add upcoming API to cancel any clips that are still uploading
                )

            case .destination, .internal, .binding, .toastAction, .delegate:
                // Catch-all
                return .none
            }
        }
        .ifLet(\.$destination, action: \.destination)

        Analytics()
    }
}

public struct ReplaceSongArtScreen: View {
    @Bindable var store: StoreOf<ReplaceSongArt>

    public init(store: StoreOf<ReplaceSongArt>) {
        self.store = store
    }

    public var body: some View {
        if store.showSheetNavigationBar {
            root
                .sheetNavigationBar(
                    leading: { EmptyView() },
                    center: { ToolbarTitle(L10n.FeatureClipDetail.uploadSongArt) },
                    trailing: {
                        ToolbarButton(.close, background: Color.SemanticV1.backgroundQuaternary) {
                            store.send(.dismissTapped)
                        }
                    }
                )
                .background(Color.SemanticV1.backgroundPrimary)
        } else {
            root
                .toolbar {
                    ToolbarItem(placement: .topBarLeading) {
                        dismissButton
                    }
                    ToolbarItem(placement: .principal) {
                        ToolbarTitle(L10n.FeatureClipDetail.uploadSongArt)
                    }
                }
                .background(Color.SemanticV1.backgroundPrimary)
        }
    }

    public var root: some View {
        content
            .padding(.horizontal, 16)
            .padding(.bottom, 8)
            .presentationDetents([.large])
            .navigationBarTitleDisplayMode(.inline)
            .presentationCornerRadius(40, conditional: true)
            .fullScreenCover(item: $store.scope(state: \.destination?.camera, action: \.destination.camera)) { _ in
                cameraScreen
            }
            .alert($store.scope(state: \.destination?.alert, action: \.destination.alert))
            .overlay(alignment: .top) {
                ToastView(store: store.scope(state: \.toastState, action: \.toastAction))
                    .padding(.top, 18)
            }
            .fullScreenCover(item: $store.scope(state: \.destination?.preview, action: \.destination.preview)) { previewStore in
                VideoSongArtFullscreenPreviewScreen(store: previewStore)
                    .environment(\.colorScheme, .dark)
            }
            .sheet(item: $store.scope(state: \.destination?.videoTrimmer, action: \.destination.videoTrimmer)) { store in
                VideoTrimmerView(store: store)
            }
            .background(Color.SemanticV1.backgroundPrimary)
    }

    @ViewBuilder
    private var content: some View {
        VStack(spacing: 0) {
            Spacer()
            placeholder
                .overlay {
                    Group {
                        videoPreview
                        playbackControls
                        if store.isImportingFromLibrary {
                            GradientSpinner(size: .extraLarge,
                                            startColor: Color.SemanticV1.auraPink,
                                            endColor: Color.SemanticV1.v4Blue)
                        }
                    }
                }
            titleAndControls
        }
    }

    @ViewBuilder
    private var dismissButton: some View {
        ToolbarButton(.chevronLeft, color: Color.SemanticV2.foregroundPrimary, background: Color.SemanticV1.backgroundQuaternary) {
            store.send(.dismissTapped)
        }
    }

    @ViewBuilder
    private var cameraScreen: some View {
        CameraView(
            showsCameraControls: true,
            photoCompletion: { result in
                store.send(.selectImage(result))
            },
            videoCompletion: { result in
                store.send(.selectVideo(result))
            },
            cancelCompletion: {
                store.send(.dismissPicker)
            }
        )
        .overlay(alignment: .topTrailing) {
            ToolbarButton(.close, background: Material.ultraThin) {
                store.send(.dismissPicker)
            }
            .padding(.trailing, 20)
            .colorScheme(.dark)
        }
        .padding(.bottom, 16)
        .background(Color.black)
    }

    @ViewBuilder
    private var titleAndControls: some View {
        clipTitle
            .padding(.bottom, 18)

        replacePhotoButton

        Divider()

        addVideoButton

        Divider()

        takeVideoOrPhotoButton

        saveButton
    }

    @ViewBuilder
    private var placeholder: some View {
        ZStack {
            RemoteImage(url: store.clip.imageUrl, fallbackId: store.clip.id.remoteId)
                .opacity(store.clipHasVideoCover || store.videoLoadingState != nil ? 0 : 1)
            Color.SemanticV1.backgroundSecondary
                .opacity(store.clipHasVideoCover || store.videoLoadingState != nil ? 1 : 0)
        }
        .aspectRatio(store.aspectRatio, contentMode: .fit)
        .frame(height: store.cardHeight)
        .clipShape(.rect(cornerRadius: 20))
    }

    @ViewBuilder
    private var videoPreview: some View {
        ZStack {
            if let videoAsset = store.videoLoadingState?.asset {
                RemoteAverageColorView(imageUrl: store.clip.largeImageUrl)
                LoopingVideoPlayer(
                    asset: videoAsset,
                    autoPlay: false,
                    isPlaying: .constant(false),
                    videoGravity: store.videoAspectMode,
                    fallbackView: { EmptyView() }
                )
                .id("video-\(store.videoAspectMode)")
            } else if let image = store.photoLoadingState?.asset {
                previewImage(image)
            } else if let videoCoverUrl = store.clip.videoCoverUrl,
                      let playableUrl = URL(string: videoCoverUrl)
            {
                RemoteAverageColorView(imageUrl: store.clip.largeImageUrl)
                LoopingVideoPlayer(
                    playableUrl: playableUrl,
                    autoPlay: false,
                    isPlaying: .constant(false),
                    videoGravity: store.videoAspectMode,
                    fallbackView: { EmptyView() }
                )
                .id("video-\(store.videoAspectMode)")
            } else {
                EmptyView()
            }
        }
        .frame(maxHeight: store.cardHeight)
        .aspectRatio(store.aspectRatio, contentMode: .fit)
        .clipShape(.rect(cornerRadius: 20))
        .opacity(store.isImportingFromLibrary ? 0.5 : 1)
    }

    @ViewBuilder
    private func previewImage(_ image: UIImage) -> some View {
        Color.SemanticV1.backgroundSecondary.overlay {
            Image(uiImage: image)
                .resizable()
                .aspectRatio(contentMode: .fill)
        }
    }

    @ViewBuilder
    private var clipTitle: some View {
        Text(store.clip.title)
            .foregroundStyle(Color.SemanticV1.textPrimary)
            .typographyV1(.headline3.editorialNewLight().size { _ in 18.0 })
            .padding(.top, 16)
    }

    @ViewBuilder
    private var playbackControls: some View {
        ZStack {
            playButton
                .frame(maxWidth: .infinity, alignment: .center)
        }
        .padding(10)
        .frame(maxWidth: .infinity, maxHeight: .infinity)
        .opacity(store.showPlaybackControls ? 1 : 0)
        .disabled(store.showSaveButtonProgressSpinner)
        .environment(\.colorScheme, .dark)
    }

    @ViewBuilder
    private var playButton: some View {
        Button {
            store.send(.playTapped)
        } label: {
            ZStack {
                Image.Icon.playFilled
                    .resizable()
                    .foregroundStyle(Color.SemanticV1.iconPrimary)
                    .frame(width: 24, height: 24)
            }
            .frame(width: 46, height: 46)
            .shadow(color: Color.SemanticV1.backgroundSecondary.opacity(0.5), radius: 4, x: 0, y: 0)
        }
    }

    @ViewBuilder
    private var addVideoButton: some View {
        PhotosPicker(
            selection: $store.videoPickerItem,
            matching: .videos
        ) {
            HStack(spacing: 0) {
                Image.Icon.videos
                    .foregroundColor(Color.SemanticV1.iconPrimary)
                    .padding(.trailing, 24)

                let addVideoText = store.clip.videoCoverUrl == nil ? L10n.FeatureClipDetail.addVideo : L10n.FeatureClipDetail.replaceVideo
                VStack(alignment: .leading, spacing: 0) {
                    HStack {
                        Text(addVideoText)
                            .typographyV1(.body1)
                            .multilineTextAlignment(.leading)
                            .foregroundColor(Color.SemanticV1.textPrimary)
                            .padding(.trailing, 8)

                        if !store.hasUsedVideoCoversFeature {
                            BadgeLabel(L10n.FeatureClipDetail.new)
                        }
                    }

                    if store.shouldShowVideoCoverInHooksFeedText {
                        Text(L10n.FeatureClipDetail.videoCoverCanAppearInHooksFeed)
                            .typographyV1(.caption3)
                            .multilineTextAlignment(.leading)
                            .foregroundColor(Color.SemanticV1.textPrimary)
                            .opacity(0.5)
                            .padding(.trailing, 8)
                    }
                }

                Spacer()
            }
            .padding(.vertical, 16)
        }
        .disabled(store.showSaveButtonProgressSpinner)
    }

    @ViewBuilder
    private var replacePhotoButton: some View {
        PhotosPicker(
            selection: $store.photoPickerItem,
            matching: .images
        ) {
            HStack(spacing: 0) {
                Image.Icon.images
                    .foregroundColor(Color.SemanticV1.iconPrimary)
                    .padding(.trailing, 24)

                // If the clip has a video cover, show "replace photo"
                // otherwise, show "add cover photo"
                let replaceSongArtText = store.clip.videoCoverUrl == nil ? L10n.FeatureClipDetail.replaceCoverImage : L10n.FeatureClipDetail.addCoverPhoto
                Text(replaceSongArtText)
                    .typographyV1(.body1)
                    .foregroundColor(Color.SemanticV1.textPrimary)

                Spacer()
            }
            .padding(.vertical, 16)
        }
        .disabled(store.showSaveButtonProgressSpinner)
    }

    @ViewBuilder
    private var takeVideoOrPhotoButton: some View {
        Button {
            store.send(.cameraRowTapped)
        } label: {
            HStack(spacing: 0) {
                Image.Icon.camera
                    .foregroundColor(Color.SemanticV1.iconPrimary)
                    .padding(.trailing, 24)

                Text(L10n.FeaturePhotoPicker.takeVideoOrPhoto)
                    .typographyV1(.body1)
                    .foregroundColor(Color.SemanticV1.textPrimary)

                Spacer()
            }
            .padding(.vertical, 16)
            .contentShape(.rect)
        }
        .disabled(store.showSaveButtonProgressSpinner)
    }

    @ViewBuilder
    private var saveButton: some View {
        Button {
            store.send(.saveTapped)
        } label: {
            Text(L10n.FeatureClipDetail.save)
                .foregroundColor(Color.SemanticV1.textInvert)
                .typographyV1(.body3.size { _ in 17.0 })
                .frame(maxWidth: .infinity)
                .opacity(store.showSaveButtonProgressSpinner ? 0 : 1)
                .padding(16)
                .background(RoundedRectangle(cornerRadius: 10).foregroundColor(Color.SemanticV1.backgroundInvert))
                .overlay {
                    ProgressView()
                        .progressViewStyle(.circular)
                        .tint(Color.SemanticV1.iconInvert)
                        .opacity(store.showSaveButtonProgressSpinner ? 1 : 0)
                        .animation(.easeInOut(duration: 0.5), value: store.showSaveButtonProgressSpinner)
                }
        }
        .disabled(store.showSaveButtonProgressSpinner && store.hasUnsavedChanges)
        .opacity(store.hasUnsavedChanges ? 1 : 0.5)
        .padding(.top, 10)
    }
}

private extension ReplaceSongArt {
    func exportAndStoreInputVideo(
        inputURL: URL,
        fileName: String
    ) async throws -> URL {
        let asset = AVAsset(url: inputURL)

        let exportOutputURL = try FileManager.default.url(for: .cachesDirectory, in: .userDomainMask, appropriateFor: nil, create: false)
            .appendingPathComponent(fileName)
            .appendingPathExtension("mp4")

        try? FileManager.default.removeItem(at: exportOutputURL)

        let duration = try await asset.load(.duration)
        let startTime = CMTime.zero
        let endTime = duration

        // Used to convert the video from Original Quality
        // to a lower upload-friendly Medium Quality
        let exportURL = try await asset.trimmedForExport(
            to: exportOutputURL,
            startTime: startTime,
            endTime: endTime
        )

        return exportURL
    }
}

public enum ReplaceSongArtError: Error {
    case videoUploadStatusCheckFailed(String)
    case imageFileCompressionFailed
    case missingAssetURL
    case videoUploadIdMissing
    case importedVideoTooShort

    case uploadVideo(uploadId: String, Error)
    case videoUploadStatus(uploadId: String, Error)
    case finishVideoUpload(uploadId: String, Error)
    case videoStatus(uploadId: String, Error)
}
