import APIClient
import ComponentLibrary
import ComposableArchitecture
import EventBusClient
import FeatureAnnouncements
import FeatureBrandedAlert
import FeatureCreateClip
import FeaturePaywall
import FeatureToasts
import HCaptchaClient
import Localization
import OpenAPIRuntime
import StatsigClient
import SunoModelClient
import SwiftUI
import Utilities

// swiftlint:disable file_length

@Reducer
public struct EditClipCoordinator {
    // Edit modes that are used to present ExtendClipScreen or upcoming edit flows (infill, replace section, etc.)
    public enum Mode {
        case extend
    }

    @Reducer(state: .equatable)
    public enum Destination {
        case subscriptions(Paywall)
        case alert(AlertState<Alert>)
        case bluejayAnnouncement(BluejayAnnouncement)
        case v5Announcement(V5Announcement)

        public enum Alert {
            case upgrade
        }
    }

    @ObservableState
    public struct State: Equatable {
        @Presents public var destination: Destination.State?
        @Presents public var settings: CreateClipSettings.State?
        @Shared(.inMemory(.selectedSunoModel)) var selectedSunoModel: SunoModelMetaData = .modelDefault
        @ObservationStateIgnored @ObservedBox public var extend: ExtendClip.State?
        @ObservationStateIgnored @ObservedBox public var toast = ToastReducer.State()
        @ObservationStateIgnored @ObservedBox public var clipEdits: ClipEdits.State
        @ObservationStateIgnored @ObservedBox public var player: EditClipPlayer.State?
        @ObservationStateIgnored @ObservedBox public var brandedAlert: BrandedAlert.State

        @Shared(.fileStorage(.savedPrompts)) var savedPrompts: [Clip.ID: Prompt] = [:]
        @Shared(.inMemory(.playingClipKey)) var playingClip: Clip?
        @Shared(.inMemory(.isPlayingKey)) var isPlaying: Bool = false
        @Shared(.appStorage(.hasSeenTapToMakeMoreExtensionsTooltip)) var hasSeenTapToMakeMoreExtensionsTooltip: Bool = false

        public var isCollapsed = false

        @Shared var me: Me
        var clip: Clip // Base clip
        var hook: Hook?
        @Shared(.inMemory(.billingInfo)) var billingInfo: SubscriptionInfoResponse?
        var mode: Mode = .extend

        var hCaptchaRetryCount = 0
        var tokenValidationFailureCount = 0
        let maxTokenValidationFailures = 3

        public var requiresTokenToGenerate: Bool {
            FeatureFlag.legacy.requiresTokenToGenerate
        }

        var generatedClips: [Clip]?
        var currentlyPlayingClip: Clip?
        var showClipEdits: Bool = true

        // Use this to show the previously playing clip
        // when exiting Edit mode
        var previouslyPlayingClip: Clip?

        // Tells us whether or not we've shown the "Tap to make more extensions"
        // tooltip in the current session
        var didShowTapToMakeMoreExtensionsTooltipInSession: Bool = false

        let formatter: DateComponentsFormatter = {
            var formatter = DateComponentsFormatter()
            formatter.allowedUnits = [.minute, .second]
            formatter.unitsStyle = .positional
            formatter.zeroFormattingBehavior = .pad
            return formatter
        }()

        // Only enable the CTA if the waveform loaded successfully
        public var disableCta: Bool {
            return extend?.waveformData == nil
        }

        public var forceDarkMode: Bool {
            @Shared(.inMemory(.isOnHooksFeed)) var isOnHooksFeed: Bool = false
            return isOnHooksFeed
        }

        public init(clip: Clip, me: Shared<Me>, prompt: Prompt, hook: Hook? = nil) {
            self.clip = clip
            self._me = me
            self.extend = .init(clip: clip, me: me, prompt: prompt)
            self.clipEdits = .init(clip: clip, me: me)
            self.player = .init(rootClip: clip)
            self.brandedAlert = .init(style: .noAlert)
            self.previouslyPlayingClip = playingClip
            self.hook = hook
        }
    }

    public enum Action {
        case dismiss
        case dismissEditor
        case destination(PresentationAction<Destination.Action>)
        case settings(PresentationAction<CreateClipSettings.Action>)
        case extend(ExtendClip.Action)
        case toast(ToastReducer.Action)
        case clipEdits(ClipEdits.Action)
        case player(EditClipPlayer.Action)
        case brandedAlert(BrandedAlert.Action)
        case task
        case getBillingInfo
        case showSubscriptions
        case loadClipIntoEditor(Clip)
        case `internal`(Internal)
        case hCaptcha(HCaptcha)
        case collapseTapped
        case setCollapsed(Bool)
        case extendTapped
        case settingsTapped

        public enum HCaptcha {
            case configure
            case fetchToken
            case setTokenIfNeeded(String?)
            case tokenGenerationFailed
        }

        public enum Internal {
            case billingInfoResponse(Result<SubscriptionInfoResponse, Error>)
            case generationComplete([Clip])
        }
    }

    @Dependency(\.dismiss) var dismiss
    @Dependency(\.eventBus.sendHookEvent) var sendHookEvent
    @Dependency(\.telemetryClient) var telemetry
    @Dependency(SunoModelClient.self) var sunoModelClient
    @Dependency(APIClient.self) var apiClient
    @Dependency(APIClientV2.self) var api
    @Dependency(HCaptchaClient.self) private var hCaptchaClient
    @Dependency(\.continuousClock) var clock
    @Dependency(\.eventBus.getCreateChannel) var getCreateChannel

    public init() {}

    public var body: some ReducerOf<Self> {
        Scope(state: \.toast, action: \.toast) {
            ToastReducer()
        }
        Scope(state: \.clipEdits, action: \.clipEdits) {
            ClipEdits()
        }
        Scope(state: \.brandedAlert, action: \.brandedAlert) {
            BrandedAlert()
        }
        Reduce { state, action in
            switch action {
            case .task:
                return .merge(
                    .send(.getBillingInfo),
                    .send(.hCaptcha(.configure))
                )

            case .getBillingInfo:
                return .run { send in
                    await send(.internal(.billingInfoResponse(Result(catching: { try await api.getBillingInfo() }))))
                }

            case .internal(.billingInfoResponse(let result)):
                switch result {
                case .success(let billingInfo):
                    state.$billingInfo.withLock { $0 = billingInfo }
                    sunoModelClient.setUserAccess(billingInfo.sunoModelUserAccess)
                    return .run { [userId = state.me.user.id] _ in
                        // Sets available models
                        await sunoModelClient.configureWithSubscriptionInfoResponse(userId: userId, response: billingInfo)
                    }

                case .failure:
                    break
                }
                return .none

            case .dismiss:
                state.extend = nil
                if state.hook != nil {
                    sendHookEvent(.playCurrentHook(cause: .resume(.extend)))
                }
                return .run { _ in await dismiss() }

            case let .extend(.delegate(.generationResponse(prompt, .success(clips)))):
                // Reset hCaptcha retry count if successful
                state.hCaptchaRetryCount = 0
                for clip in clips {
                    state.$savedPrompts.withLock { $0[clip.id] = prompt }
                }
                state.isCollapsed = true
                return .send(.clipEdits(.addClips(clips)))

            case .internal(.generationComplete(let clips)):
                state.generatedClips = clips
                return .none

            case .extend(.delegate(.generationResponse(_, .failure(let error)))):
                if let error = error as? OpenAPIRuntime.ClientError, let apiError = error.underlyingError as? APIError {
                    switch apiError {
                    case .insufficientCredits:
                        let message = apiError.insufficientCreditsMessage(billingInfo: state.billingInfo)
                        return .send(.toast(.show(.warning(nil, .string(message), destination: .paywall))))

                    case .tooManyRunningJobs:
                        state.destination = .alert(.init(
                            title: { TextState(L10n.FeatureCreateClip.capacityTitle) },
                            actions: {
                                ButtonState(action: .upgrade) { TextState(L10n.FeatureCreateClip.upgrade) }
                                ButtonState(role: .cancel) { TextState(L10n.FeatureCreateClip.cancel) }
                            },
                            message: { TextState(L10n.FeatureCreateClip.capacityMessage) }
                        ))
                        return .none

                    case .clientError, .serverError, .forbidden, .errorMessage:
                        log.telemetry.error(apiError)
                        return .send(.toast(.show(.warning(L10n.FeatureCreateClip.error, .string(apiError.underlyingError)))))

                    case .invalidHCaptchaToken:
                        let isRetry = state.hCaptchaRetryCount != 0
                        let tokenValidationFailureCount = state.tokenValidationFailureCount
                        let userId = state.me.user.id
                        let error = HCaptchaError.invalidToken("isRetry: \(isRetry), errorWithRetryCount: \(tokenValidationFailureCount), userId: \(userId)")
                        log.telemetry.error(error)
                        if state.hCaptchaRetryCount < 1 {
                            state.hCaptchaRetryCount += 1
                            return .send(.hCaptcha(.fetchToken))
                        } else {
                            state.hCaptchaRetryCount = 0
                            state.tokenValidationFailureCount += 1
                            if state.tokenValidationFailureCount >= state.maxTokenValidationFailures {
                                return .merge(
                                    .send(.hCaptcha(.tokenGenerationFailed)),
                                    .send(.toast(.show(.warning(L10n.FeatureCreateClip.errorTitle, .string(L10n.FeatureCreateClip.contactUsMessage)))))
                                )
                            } else {
                                return .merge(
                                    .send(.hCaptcha(.tokenGenerationFailed)),
                                    .send(.toast(.show(.warning(L10n.FeatureCreateClip.errorTitle, .string(L10n.FeatureCreateClip.tryAgain)))))
                                )
                            }
                        }
                    }

                } else {
                    log.telemetry.error(error)
                    return .send(.toast(.show(.warning(L10n.FeatureCreateClip.error, .string(error.underlyingError)))))
                }

            case .extendTapped:
                state.extend?.isSubmitting = true
                return .send(.hCaptcha(.fetchToken))

            case .hCaptcha(.configure):
                guard state.requiresTokenToGenerate else { return .none }
                hCaptchaClient.prepareToken()
                return .none

            case .hCaptcha(.fetchToken):
                return .run { [requiresTokenToGenerate = state.requiresTokenToGenerate] send in
                    do {
                        if requiresTokenToGenerate {
                            let token = try await hCaptchaClient.getToken()
                            await send(.hCaptcha(.setTokenIfNeeded(token)))
                        } else {
                            await send(.hCaptcha(.setTokenIfNeeded(nil)))
                        }
                    } catch {
                        log.telemetry.error(error)
                        await send(.hCaptcha(.tokenGenerationFailed))
                        await send(.toast(.show(.warning(L10n.FeatureCreateClip.errorTitle, .string(L10n.FeatureCreateClip.tryAgain)))))
                    }
                }

            case .hCaptcha(.tokenGenerationFailed):
                return .send(.extend(.hCaptchaTokenGenerationFailed))

            case .hCaptcha(.setTokenIfNeeded(let hCaptchaToken)):
                return .send(.extend(.generate(hCaptchaToken)))

            case .destination(.presented(.alert(.upgrade))):
                return .send(.showSubscriptions)

            case .showSubscriptions:
                state.destination = .subscriptions(.init())
                return .none

            case .setCollapsed(let collapsed):
                state.isCollapsed = collapsed

                guard collapsed else {
                    // Reset the tooltip if we're re-opening the editor
                    if state.didShowTapToMakeMoreExtensionsTooltipInSession {
                        state.didShowTapToMakeMoreExtensionsTooltipInSession = false
                        return .send(.clipEdits(.resetTapToSeeMoreExtensionsTooltip))
                    }
                    return .none
                }

                var effects: [Effect<Action>] = []
                // Prepare the EditClipPlayer since we attached to the PlayerClient
                // when loading the WaveformEditor and
                // WaveformEditorPlayer in the expanded state
                effects.append(.send(.player(.prepareForPlayback)))

                // Show the "Tap to make more extensions" tooltip
                // once we collapse the editor for the first time
                if !state.hasSeenTapToMakeMoreExtensionsTooltip {
                    state.didShowTapToMakeMoreExtensionsTooltipInSession = true
                    effects.append(.send(.clipEdits(.showTapToMakeMoreExtensionsTooltip)))
                }

                return .merge(effects)

            case .clipEdits(.delegate(.didGetFullClip(let clip))):
                getCreateChannel().queue(.getFullClip(clip))
                state.showClipEdits = false
                return .send(.player(.teardown))

            case .clipEdits(.delegate(.closeTapped)):
                state.showClipEdits = false
                // Set the previously playing clip after closing everything
                if let previouslyPlayingClip = state.previouslyPlayingClip {
                    state.$playingClip.withLock { $0 = previouslyPlayingClip }
                }
                // Pause the player and seek to 00:00
                return .send(.player(.teardown))

            case .clipEdits(.delegate(.setupPlayer(let clips))):
                state.showClipEdits = true
                return .send(.player(.loadClips(clips)))

            case .clipEdits(.delegate(.addClipToPlayer(let clip))):
                return .send(.player(.addNewClips([clip])))

            case .clipEdits(.delegate(.reloadEditorWithClips(let clipItems))):
                guard let clip = clipItems.first?.clip else { return .none }
                let prompt = reusablePrompt(clip: clip, savedPrompt: state.savedPrompts[clip.id])
                state.clip = clip
                state.extend = ExtendClip.State(clip: clip, me: state.$me, prompt: prompt)

                return .merge(
                    .send(.clipEdits(.updateCurrentlyEditingClip(clip))),
                    .send(.player(.replaceAllClips(clipItems))),
                    .send(.extend(.task))
                )

            case .clipEdits(.delegate(.togglePlayPause(let clip))):
                // Show the editor if it's been dismissed
                guard state.extend != nil else {
                    return .send(.loadClipIntoEditor(clip))
                }
                return .send(.player(.togglePlayPause(clip)))

            case .player(.delegate(.didPlayClip(let clip))):
                state.currentlyPlayingClip = clip
                return .send(.clipEdits(.markClipAsPlayed(clip)))

            case .player(.delegate(.updatePlayerElapsedTime(let time))):
                guard let currentlyPlayingClip = state.currentlyPlayingClip else { return .none }
                return .send(.clipEdits(.clipEdits(.element(id: currentlyPlayingClip.id, action: .updateProgress(time)))))

            case .clipEdits(.delegate(.removeClipFromPlayer(let clipItem))):
                return .send(.player(.removeClip(clipItem)))

            case .dismissEditor:
                state.extend = nil
                return .send(.clipEdits(.resetCurrentlyEditingClip))

            case .collapseTapped:
                // Makes sure we have a smooth animation with the keyboard
                return .merge(
                    .send(.setCollapsed(true)),
                    .send(.extend(.dismissKeyboard))
                )

            case .settingsTapped:
                state.settings = .init()
                return .none

            case .settings(.presented(.delegate(.showUpgradeAlert(let marketingLevelUnderstanding)))):
                // Dismiss settings
                state.settings = nil
                switch marketingLevelUnderstanding {
                case .previousToV4, .v3Dot5:
                    assertionFailure("Should not `showUpgradeAlert` for free models.")
                case .v4:
                    return .send(.brandedAlert(.setStyle(.versioningAlert(.preset(.v4FreeUserUpgradeInfoPush)))))
                case .auk:
                    return .send(.brandedAlert(.setStyle(.versioningAlert(.preset(.v4_5FreeUserUpgradeInfoPush)))))
                case .bluejay:
                    state.destination = .bluejayAnnouncement(.init(style: .free))
                case .v5:
                    state.destination = .v5Announcement(.init(style: .free))
                }
                return .none

            case .destination(.presented(.bluejayAnnouncement(.delegate(.openSubscriptions)))):
                state.destination = .subscriptions(.init())
                return .none

            case .brandedAlert(.delegate(.didTriggerWithIntentionToUpgrade)):
                UIImpactFeedbackGenerator(style: .medium).impactOccurred()
                return .send(.showSubscriptions)
                    // Dismiss the branded alert
                        .merge(with: .send(.brandedAlert(.setStyle(.noAlert))))

            case .extend(.delegate(.showBrandedAlertOverlay(let style))):
                return .send(.brandedAlert(.setStyle(style)))

            case .loadClipIntoEditor(let clip):
                let prompt = reusablePrompt(clip: clip, savedPrompt: state.savedPrompts[clip.id])
                state.clip = clip
                state.extend = ExtendClip.State(clip: clip, me: state.$me, prompt: prompt)
                return .merge(
                    .send(.setCollapsed(false)),
                    .send(.clipEdits(.updateCurrentlyEditingClip(clip))),
                    .send(.player(.prepareClip(clip)))
                )

            case .extend, .destination, .settings, .toast, .clipEdits, .player, .brandedAlert:
                // Catch-all
                return .none
            }
        }
        .ifLet(\.$destination, action: \.destination)
        .ifLet(\.extend, action: \.extend) {
            ExtendClip()
        }
        .ifLet(\.player, action: \.player) {
            EditClipPlayer()
        }
        .ifLet(\.$settings, action: \.settings) {
            CreateClipSettings()
        }

        Analytics()
    }

    private func reusablePrompt(clip: Clip, savedPrompt: Prompt?) -> Prompt {
        if var prompt = savedPrompt {
            prompt.lyrics = clip.prompt
            prompt.styles = clip.tags
            return prompt
        } else {
            // audio not supported without a saved prompt
            let generationType: GenerationType = .text
            return Prompt(
                title: clip.title,
                lyrics: clip.prompt,
                styles: clip.tags,
                text: clip.gptDescriptionPrompt,
                generationType: generationType
            )
        }
    }
}

public struct EditClipCoordinatorScreen: View {
    @Bindable var store: StoreOf<EditClipCoordinator>
    @Namespace private var animation
    public init(store: StoreOf<EditClipCoordinator>) {
        self.store = store
    }

    public var body: some View {
        ZStack(alignment: .top) {
            if !store.isCollapsed {
                expandedView
            } else {
                collapsedView
            }
        }
        .task {
            store.send(.task)
        }
    }

    @ViewBuilder
    private var collapsedView: some View {
        VStack(spacing: 2) {
            if let playerStore = store.scope(state: \.player, action: \.player) {
                EditClipPlayerView(store: playerStore)
            }
            extendButton
        }
        .padding(.top, 14)
        .background(Color.SemanticV1.backgroundSecondary)
        .overlay(alignment: .top) {
            // Drag indicator
            RoundedRectangle(cornerRadius: 64)
                .fill(Color.SemanticV1.iconTertiary)
                .frame(width: 22, height: 4)
                .padding(.top, 10)
        }
    }

    @ViewBuilder
    private var expandedView: some View {
        ZStack(alignment: .bottom) {
            VStack(spacing: 16) {
                clipDetails
                editor
            }
            extendButton
                .padding(.bottom, 32)
        }
        .edgesIgnoringSafeArea(.bottom)
        .navigationBarTitleDisplayMode(.inline)
        .sheetNavigationBar(
            leading: { navigationLeading },
            center: { navigationCenter },
            trailing: { navigationTrailing }
        )
        .background(Color.SemanticV1.backgroundPrimary)
        .sheet(item: $store.scope(state: \.settings, action: \.settings)) { store in
            CreateClipSettingsSheet(store: store)
        }
        .overlay(alignment: .top) {
            ToastView(store: store.scope(state: \.toast, action: \.toast)) { destination in
                if destination == .paywall, store.me.flags[FlagKey.iosSubscriptions] == true {
                    store.send(.showSubscriptions)
                }
            }
        }
        .fullScreenCover(item: $store.scope(state: \.destination?.subscriptions, action: \.destination.subscriptions)) { paywallStore in
            NavigationStack {
                PaywallScreen(store: paywallStore)
                    .toolbar {
                        ToolbarItem(placement: .navigationBarLeading) {
                            ToolbarButton(.close, background: Material.ultraThin) {
                                paywallStore.send(.dismiss)
                            }
                        }
                    }
            }
        }
        .alert($store.scope(state: \.destination?.alert, action: \.destination.alert))
        .overlay {
            if let _ = store.brandedAlert.destination {
                Color.SemanticV1.backgroundQuaternary.opacity(0.4)
                BrandedAlertView(store.scope(state: \.brandedAlert, action: \.brandedAlert))
            }
        }
        .overlay {
            if let store = store.scope(state: \.destination?.bluejayAnnouncement, action: \.destination.bluejayAnnouncement) {
                BluejayAnnouncementView(store: store)
            }
        }
        .overlay {
            if let store = store.scope(state: \.destination?.v5Announcement, action: \.destination.v5Announcement) {
                V5AnnouncementView(store: store)
            }
        }
    }

    @ViewBuilder
    private var navigationLeading: some View {
        ToolbarButton(.settings, background: Color.SemanticV1.backgroundQuaternary) {
            store.send(.settingsTapped)
        }
    }

    @ViewBuilder
    private var navigationTrailing: some View {
        ToolbarButton(.chevronDown, background: Color.SemanticV1.backgroundQuaternary) {
            store.send(.collapseTapped, animation: .easeOut(duration: 0.3))
        }
    }

    @ViewBuilder
    private var navigationCenter: some View {
        HStack(spacing: 10) {
            Image.Icon.extend
                .foregroundColor(Color.SemanticV1.iconPrimary)

            Text(L10n.FeatureEditClip.extend)
                .typographyV1(.headline6)
                .foregroundStyle(Color.SemanticV1.textPrimary)
        }
    }

    @ViewBuilder
    var clipDetails: some View {
        HStack(alignment: .top, spacing: 10) {
            RemoteImage(url: store.clip.imageUrl, fallbackId: store.clip.id.remoteId)
                .frame(width: 54, height: 72)
                .cornerRadius(4)
                .clipped()

            VStack(alignment: .leading, spacing: 8) {
                Text(store.clip.title)
                    .typographyV1(.body2)
                    .foregroundStyle(Color.SemanticV1.textPrimary)

                HStack(spacing: 8) {
                    RemoteImage(url: store.clip.avatarImageUrl, fallbackId: store.clip.userId)
                        .frame(width: 19, height: 19)
                        .clipShape(Circle())

                    Text(store.clip.displayName)
                        .typographyV1(.body2)
                        .foregroundStyle(Color.SemanticV1.textSecondary)
                }
            }

            Spacer()
            Text(store.formatter.string(from: store.clip.duration) ?? "--:--")
                .typographyV1(.body2)
                .foregroundStyle(Color.SemanticV1.textSecondary)
                .padding(.trailing, 5)
        }
        .padding(5)
        .background(Color.SemanticV1.backgroundSecondary.cornerRadius(8))
        .padding(.horizontal, 12)
    }

    @ViewBuilder
    private var editor: some View {
        switch store.mode {
        case .extend:
            if let extendClipStore = store.scope(state: \.extend, action: \.extend) {
                // WaveformEditor, LyricsSelection, and Style inputs
                ExtendClipScreen(store: extendClipStore)
                    .overlay(alignment: .bottom) {
                        LinearGradient(
                            colors: [
                                .SemanticV1.backgroundPrimary.opacity(0),
                                .SemanticV1.backgroundPrimary.opacity(0.75),
                                .SemanticV1.backgroundPrimary,
                            ],
                            startPoint: .top,
                            endPoint: .bottom
                        )
                        .frame(height: 172)
                        .allowsHitTesting(false)
                    }
            } else {
                EmptyView()
            }
        }
    }

    @ViewBuilder
    var extendButton: some View {
        if let extendClipStore = store.scope(state: \.extend, action: \.extend) {
            AuraButton(
                title: L10n.FeatureEditClip.extend,
                isLoading: extendClipStore.isSubmitting,
                auraStyle: .orangeAura,
                leadingView: {
                    Image.Icon.extend
                        .resizable()
                        .frame(width: 30, height: 30)
                        .foregroundStyle(Color.SemanticV1.iconPrimary)
                },
                action: { store.send(.extendTapped) }
            )
            .pillButtonSizeV1(store.isCollapsed ? .extendAdaptiveButtonSize : .extendButtonSize)
            .disabled(store.disableCta)
            .opacity(store.disableCta ? 0.8 : 1)
            .padding(.horizontal, 12)
            .padding(.vertical, 8)
        }
    }

    private func navigationButton(image: Image, action: @escaping () -> Void) -> some View {
        Button {
            UIImpactFeedbackGenerator(style: .light).impactOccurred()
            action()
        } label: {
            ZStack(alignment: .center) {
                Circle()
                    .fill(Color.SemanticV1.backgroundQuaternary)
                image
                    .foregroundColor(Color.SemanticV1.iconPrimary)
            }
            .frame(width: 44, height: 44)
            .clipShape(Circle())
        }
        .buttonStyle(.plain)
    }
}
