import AnalyticsClient
import APIClient
import ClipPollingClient
import ComponentLibrary
import ComposableArchitecture
import EventBusClient
import FeatureToasts
import Foundation
import Localization
import StatsigClient
import SunoModelClient
import SwiftUI
import Utilities

// swiftlint:disable file_length

@Reducer
public struct ClipList {
    @ObservableState
    public struct State: Equatable {
        public struct RemovedClip: Identifiable, Equatable {
            public var id: Clip.ID { state.id }
            public let index: Int
            public let state: ClipListItem.State
        }

        public var clips: IdentifiedArrayOf<ClipListItem.State> = []
        public var recentlyRemovedClips: IdentifiedArrayOf<RemovedClip> = []
        public var loadState: LoadState = .loading
        public var pages: PageState
        public var totalResults: Int?
        public var isLibraryScreen: Bool
        public var tooltipToShow: Tooltip?
        public var activeTooltipClipId: Clip.ID?
        public var context: SessionContext

        @Shared var me: Me
        var playlist: Playlist?
        var firstLoad = true
        var allowResortingOnPin = false

        @Shared(.inMemory(.hasSeenOmniPlayerTooltipThisSession)) var hasSeenTooltipThisSession: Bool = false
        @Shared(.inMemory(.billingInfo)) var billingInfo: SubscriptionInfoResponse?
        @Shared(.appStorage(.hasSeenRemasterTooltip)) var hasSeenRemasterTooltip: Bool = false

        public init(
            me: Shared<Me>,
            playlist: Playlist? = nil,
            firstPageIndex: Int = 0,
            isLibraryScreen: Bool = false,
            tooltipToShow: Tooltip? = nil,
            allowResortingOnPin: Bool = false,
            context: SessionContext
        ) {
            self._me = me
            self.playlist = playlist
            self.pages = .init(firstPageIndex: firstPageIndex)
            self.isLibraryScreen = isLibraryScreen
            self.tooltipToShow = tooltipToShow
            self.allowResortingOnPin = FeatureFlag.clips.iosProfilePinning && allowResortingOnPin
            self.context = context
        }

        public func clips(startingAt clip: ClipListItem.State) -> [ClipListItem.State] {
            guard let startIndex = clips.index(id: clip.id) else { return [] }
            return Array(clips[startIndex...])
        }

        public func clips(startingAt clip: ClipListItem.State) -> [Clip] {
            clips(startingAt: clip).map(\.clip)
        }

        public mutating func resortClipsBasedOnPinning() {
            clips.sort {
                let pinningNumberFirst = $0.clip.isPinned ? 1 : 0
                let pinningNumberSecond = $1.clip.isPinned ? 1 : 0
                return pinningNumberFirst > pinningNumberSecond
            }
        }

        public mutating func hidePinningSymbolIfNeeded() {
            for idForClip in clips.ids {
                guard let index = clips.index(id: idForClip) else { continue }
                var mutableClip = clips[index]
                mutableClip.setPinGating(false)
                clips[index] = mutableClip
            }
        }

        public mutating func applyNewPinning(_ pinnedClips: IdentifiedArray<ClipID, Clip>) {
            guard FeatureFlag.clips.iosProfilePinning else { return }

            for idForClip in clips.ids {
                if let pinnedClipIndex = pinnedClips.index(id: idForClip) {
                    let pinnedClip = pinnedClips[pinnedClipIndex]
                    if let index = clips.index(id: idForClip) {
                        var mutableClip = clips[index]
                        mutableClip.clip.isPinned = true
                        clips[index] = mutableClip
                    } else {
                        clips.append(.init(clip: pinnedClip, me: $me, position: clips.endIndex))
                    }
                } else {
                    guard let index = clips.index(id: idForClip) else { continue }
                    var mutableClip = clips[index]
                    mutableClip.clip.isPinned = false
                    clips[index] = mutableClip
                }
            }
        }

        var shouldShowRemasterTooltip: Bool {
            // If we're coming from the Remaster promo alert on Library,
            // or if we haven't seen this tooltip elsewhere (ie. OmniPlayer),
            // show the tooltip
            return tooltipToShow == .preset(.remasterLaunch) && isLibraryScreen
        }

        var shouldShowVideoSongArtTooltip: Bool {
            return tooltipToShow == .preset(.videoSongArtWalkthroughRedirect) && isLibraryScreen
        }
    }

    public enum Action {
        public enum Internal {
            case removeClip(Clip.ID)
            case restoreClip(Clip.ID)
            case clipsLoadResult(page: Int, result: Result<[Clip], Error>)
            case clipsLoadResultV2(page: Int, result: Result<ClipsFeed, Error>)
            case deleteClipResponse(Result<Void, Error>, Clip)
            case removeClipFromPlaylistResponse(Result<Void, Error>, Clip, Playlist)
            case likeClipResponse(Result<Void, Error>, Clip, Playlist)
            case reorderClipsFailed(from: IndexSet, to: Int)
        }

        public enum Delegate {
            case clipDeleted(Clip, succeed: Bool)
            case clipRemoved(Clip, Playlist, succeed: Bool)
            case clipUpdated(Clip)
            case toastAfter(ToastReducer.State.ToastType)
        }

        case clips(IdentifiedActionOf<ClipListItem>)
        case `internal`(Internal)
        case delegate(Delegate)

        case undoDeleteClip(Clip)
        case loadClips
        case getNextPage
        case updateClip(Clip)
        case deleteClip(Clip)

        case showTooltipIfNeeded
        case showTooltip(Tooltip)
        case didDismissTooltip
        case hideTooltip

        case playClipTapped(Clip)
        case playAllClips
        case reorderClips(playlistId: Playlist.ID, from: IndexSet, to: Int)

        case clipEvents(EventBusClient.ClipEvent)
        case task

        case updatePreviewPolling
        case billingChannel(EventBusClient.BillingEvent)
    }

    @Dependency(ClipListClient.self) private var clipListClient
    @Dependency(AnalyticsClient.self) private var analytics
    @Dependency(APIClient.self) var apiClient
    @Dependency(APIClientV2.self) var apiClientV2
    @Dependency(\.telemetryClient) var telemetry
    @Dependency(ClipPollingClient.self) var clipPollingClient
    @Dependency(\.continuousClock) private var clock
    @Dependency(\.eventBus.getOmniplayerChannel) private var getOmniplayerChannel
    @Dependency(\.eventBus.getClipPublisher) private var getClipPublisher
    @Dependency(\.eventBus.sendClipEvent) private var sendClipEvent
    @Dependency(\.eventBus.getBillingChannel) private var getBillingChannel

    public init() {}

    public var body: some ReducerOf<Self> {
        Reduce<State, Action> { state, action in
            struct BillingChannelCancellableId: Hashable {}

            switch action {
            case .task:
                return .merge(
                    .subscribe(getClipPublisher(), send: Action.clipEvents),
                    .channel(
                        getBillingChannel(),
                        send: Action.billingChannel,
                        cancellableId: BillingChannelCancellableId()
                    )
                )

            case .loadClips:
                if state.firstLoad { state.loadState = .loading }
                state.firstLoad = false
                state.pages.reset()

                return .send(.getNextPage)

            case .getNextPage:
                guard let nextPage = state.pages.nextIndex() else { return .none }

                if clipListClient.isV2 {
                    return .run { send in
                        await send(.internal(.clipsLoadResultV2(
                            page: nextPage,
                            result: Result(catching: { try await clipListClient.getClipsV2(nextPage) })
                        )))
                    }
                } else {
                    return .run { send in
                        await send(.internal(.clipsLoadResult(
                            page: nextPage,
                            result: Result(catching: { try await clipListClient.getClips(nextPage) })
                        )))
                    }
                }

            case .reorderClips(let playlistId, let from, let to):
                state.clips.move(fromOffsets: from, toOffset: to)
                return .run { send in
                    do {
                        guard let fromIndex = from.first else { return }
                        try await apiClientV2.reorderPlaylistClip(playlistId, fromIndex, to)
                    } catch {
                        log.telemetry.error(error, message: "Failed to reorder clips.")
                        await send(.internal(.reorderClipsFailed(from: from, to: to)))
                    }
                }

            case .internal(.reorderClipsFailed(let from, let to)):
                state.clips.move(fromOffsets: IndexSet(integer: to), toOffset: from.first ?? 0)
                return .send(.delegate(.toastAfter(.warning(L10n.FeatureClipList.actionFailed, position: .bottom))))

            case .clips(.element(_, action: .delegate(.shouldResortPlaylist))):
                if state.allowResortingOnPin {
                    state.resortClipsBasedOnPinning()
                } else {
                    state.hidePinningSymbolIfNeeded()
                }
                return .none

            case .clips(.element(_, action: .delegate(.shouldReloadPinnedList(let pinnedList)))):
                if state.allowResortingOnPin {
                    state.applyNewPinning(pinnedList)
                }
                return .none

            case let .internal(.clipsLoadResultV2(page, .success(clipsFeed))):
                state.totalResults = clipsFeed.totalResults
                return .send(.internal(.clipsLoadResult(page: page, result: .success(clipsFeed.clips))))

            case let .internal(.clipsLoadResultV2(page, .failure(error))):
                if state.clips.isEmpty {
                    state.loadState = .failed(L10n.FeatureClipList.actionFailed)
                }
                state.pages.update(error)
                log.telemetry.error(error, message: "Failed to load clip list v2")
                return .none

            case let .internal(.clipsLoadResult(page, .success(clips))):
                if page == state.pages.firstPageIndex {
                    state.clips = []
                }

                let startIndex = state.clips.count
                state.clips.append(contentsOf: clips.enumerated().map { index, clip in
                    .init(
                        clip: clip,
                        me: state.$me,
                        position: (startIndex + index) + 1,
                        playlist: state.playlist,
                        isLibraryScreen: state.isLibraryScreen
                    )
                })
                state.loadState = .loaded
                state.pages.update(clips)

                let previewClipsBeingUnlocked = clips.compactMap { clip in
                    clip.isUnlockingPreview(isPaidUser: state.billingInfo?.plan != nil) ? clip.id : nil
                }

                var effects: [Effect<Action>] = []
                effects.append(.send(.updatePreviewPolling))

                if state.shouldShowRemasterTooltip || state.shouldShowVideoSongArtTooltip {
                    effects.append(.run { send in
                        try? await Task.sleep(for: .seconds(0.5))
                        await send(.showTooltipIfNeeded)
                    })
                }

                return .merge(effects)

            case .internal(.clipsLoadResult(_, .failure(let error))):
                if state.clips.isEmpty {
                    state.loadState = .failed(L10n.FeatureClipList.actionFailed)
                }
                state.pages.update(error)
                log.telemetry.error(error, message: "Failed to load clip list")
                return .none

            // MARK: Remove clip from playlist

            case let .clips(.element(_, action: .delegate(.removeFromPlaylist(clip, playlist)))):
                if playlist == .liked {
                    analytics.track(.audioAction(.songUndoLikeTapped, clip: clip))
                }
                return .merge(
                    .run { send in
                        await send(.internal(.removeClip(clip.id)), animation: .default)
                    },
                    .run { send in
                        if playlist == .liked {
                            await send(.internal(.likeClipResponse(Result(catching: {
                                try await apiClientV2.setReaction(clip, false, false, nil)
                            }), clip, playlist)))
                        } else {
                            await send(.internal(.removeClipFromPlaylistResponse(Result(catching: {
                                try await apiClientV2.updatePlaylistClips(clip.id, playlist.id, true, nil)
                            }), clip, playlist)))
                        }
                    }
                )

            case let .internal(.removeClipFromPlaylistResponse(.success, clip, playlist)):
                return .merge(
                    .send(.delegate(.clipRemoved(clip, playlist, succeed: true))),
                    .send(.delegate(.toastAfter(.success(L10n.FeatureClipList.removedFromPlaylist(playlist.name), position: .bottom))))
                )

            case let .internal(.removeClipFromPlaylistResponse(.failure(error), clip, playlist)):
                log.telemetry.error(error)
                return .run { send in
                    await send(.internal(.restoreClip(clip.id)), animation: .default)
                }
                .merge(with: .send(.delegate(.clipRemoved(clip, playlist, succeed: false))))
                .merge(with: .send(.delegate(.toastAfter(.warning(L10n.FeatureClipList.actionFailed, position: .bottom)))))

            // MARK: Like/Dislike clip (remove from Liked playlist)

            case let .internal(.likeClipResponse(.success, clip, playlist)):
                return .merge(
                    .send(.delegate(.clipRemoved(clip, playlist, succeed: true))),
                    .send(.delegate(.toastAfter(.success(L10n.FeatureClipList.removedFromPlaylist(playlist.name), position: .bottom))))
                )

            case let .internal(.likeClipResponse(.failure(error), clip, playlist)):
                log.telemetry.error(error)
                return .run { send in
                    await send(.internal(.restoreClip(clip.id)), animation: .default)
                }
                .merge(with: .send(.delegate(.clipRemoved(clip, playlist, succeed: false))))
                .merge(with: .send(.delegate(.toastAfter(.warning(L10n.FeatureClipList.actionFailed, position: .bottom)))))

            // MARK: Delete clip

            case .clips(.element(_, action: .delegate(.deleteClip(let clip)))):
                return .merge(
                    .run { send in
                        await send(.internal(.deleteClipResponse(Result(catching: { try await apiClient.trashClip(clip, true) }), clip)))
                    },
                    .send(.internal(.removeClip(clip.id)), animation: .default)
                )

            case .internal(.deleteClipResponse(.success, let clip)):
                return .send(.delegate(.clipDeleted(clip, succeed: true)))

            case let .internal(.deleteClipResponse(.failure(error), clip)):
                log.telemetry.error(error)
                return .run { send in
                    await send(.internal(.restoreClip(clip.id)), animation: .default)
                }
                .merge(with: .send(.delegate(.clipDeleted(clip, succeed: false))))

            // MARK: Internal remove or restore clip

            case .internal(.removeClip(let clipId)):
                guard let index = state.clips.index(clipId: clipId),
                      let clip = state.clips.removeClip(id: clipId) else { return .none }
                state.recentlyRemovedClips.append(.init(index: index, state: clip))
                return .send(.updatePreviewPolling)

            case .internal(.restoreClip(let clipId)):
                guard let clip = state.recentlyRemovedClips.removeClip(id: clipId) else { return .none }
                state.clips.insert(clip.state, at: clip.index)
                return .none

            case .updateClip(let clip):
                if var clipState = state.clips[id: clip.id] {
                    clipState.clip = clip
                    state.clips[id: clip.id] = clipState
                }
                updatedPinnedListIfNeeded(oldClip: state.clips[id: clip.id]?.clip, newClip: clip)

                return .none

            case .deleteClip(let clip):
                return .send(.internal(.removeClip(clip.id)))

            case .undoDeleteClip(let clip):
                return .merge(
                    .run { _ in try await apiClient.trashClip(clip, false) },
                    .run { send in
                        await send(.internal(.restoreClip(clip.id)), animation: .default)
                    }
                )

            case .showTooltipIfNeeded:
                // Check if the user has any clips
                guard let firstClipId = state.clips.first?.id else { return .none }
                guard let tooltipToShow = state.tooltipToShow else { return .none }

                // Set `activeTooltipClipId` right away to allow for the animation to appear in the maximum z-index
                state.activeTooltipClipId = firstClipId
                return .send(.clips(.element(id: firstClipId, action: .showTooltip(tooltipToShow))))

            case .showTooltip(let tooltip):
                state.tooltipToShow = tooltip
                return .send(.showTooltipIfNeeded)

            case .clips(.element(_, action: .moreTapped)):
                // If the user toggles Song Actions for any clip, remove the tooltip
                return .send(.didDismissTooltip)

            case .didDismissTooltip:
                guard let clip = state.clips.first(where: { $0.tooltipToShow != nil }) else { return .none }
                if case .preset(.remasterLaunch) = state.tooltipToShow {
                    state.$hasSeenRemasterTooltip.withLock { $0 = true }
                }
                state.$hasSeenTooltipThisSession.withLock { $0 = true }
                state.tooltipToShow = nil
                UIImpactFeedbackGenerator(style: .light).impactOccurred()
                return .merge(
                    .send(.clips(.element(id: clip.id, action: .didDismissTooltip))),
                    .run { send in
                        try? await Task.sleep(for: .seconds(0.3))
                        // Add some delay for the animation to complete
                        await send(.hideTooltip)
                    }
                )

            case .hideTooltip:
                state.activeTooltipClipId = nil
                return .none

            case .playClipTapped(let clip):
                getOmniplayerChannel()
                    .queue(
                        .playClip(
                            clip,
                            queue: state.clips.map(\.clip),
                            context: state.context
                        )
                    )
                return .none

            case .playAllClips:
                var queue = state.clips.map(\.clip)

                guard let first = queue.first else { return .none }
                getOmniplayerChannel().queue(.playClip(first, queue: queue, context: state.context))
                return .none

            // MARK: Preview Polling

            case .updatePreviewPolling:
                let clips = Set(state.clips.compactMap { clip in
                    let isPaidUser = state.billingInfo?.plan != nil
                    return clip.clip.isUnlockingPreview(isPaidUser: isPaidUser) ? clip.clip : nil
                })

                clipPollingClient.pollClipsToUnlock(clips: clips)
                return .none

            case .billingChannel(.billingInfoUpdated(let billingInfo)):
                state.$billingInfo.withLock { $0 = billingInfo }
                return .send(.updatePreviewPolling)

            // MARK: Clip Channel Subscription

            // !!! Watch out for infinite recursion !!!
            case .clipEvents(.updateClip(let clip)):
                return self.reduce(into: &state, action: .updateClip(clip))

            case .clipEvents(.removeClip(let clip)):
                return self.reduce(into: &state, action: .internal(.removeClip(clip.id)))

            case .clipEvents(.removeClipFromPlaylist(let clip, let playlist)):
                guard state.playlist?.id == playlist.id else { return .none }
                return self.reduce(into: &state, action: .internal(.removeClip(clip.id)))

            case .clipEvents(.toggledLike(let clip)):
                if state.playlist == .liked && !clip.isLiked {
                    return .send(.internal(.removeClip(clip.id)), animation: .default)
                }
                if clip.isLiked, state.recentlyRemovedClips[id: clip.id] != nil {
                    return .send(.internal(.restoreClip(clip.id)), animation: .default)
                }
                return self.reduce(into: &state, action: .updateClip(clip))

            case .clipEvents(.undoDeleteClip(let clip)):
                return self.reduce(into: &state, action: .undoDeleteClip(clip))

            // MARK: Clip Channel Publishing

            // !!! Watch out for infinite recursion !!!
            // Currently will cause a duplicate call
            case .delegate(.clipUpdated(let clip)):
                sendClipEvent(.updateClip(clip))
                return .none

            case .delegate(.clipDeleted(let clip, succeed: let succeed)):
                guard succeed else { return .none }
                sendClipEvent(.removeClip(clip))
                return .none

            case .delegate(.clipRemoved(let clip, let playlist, succeed: let succeed)):
                guard succeed else { return .none }
                sendClipEvent(.removeClipFromPlaylist(clip, playlist: playlist))
                return .none

            case .internal, .clips, .delegate, .clipEvents:
                return .none
            }

            func updatedPinnedListIfNeeded(oldClip _: Clip?, newClip: Clip) {
                let oldClip = state.clips[id: newClip.id]?.clip

                if oldClip?.isPinned != newClip.isPinned {
                    if state.allowResortingOnPin {
                        state.resortClipsBasedOnPinning()
                    } else {
                        state.hidePinningSymbolIfNeeded()
                    }
                }
            }
        }
        .forEach(\.clips, action: \.clips) {
            ClipListItem()
        }
    }
}

public struct ClipListContent<ItemView: View>: View {
    @Bindable private var store: StoreOf<ClipList>
    private var itemView: (StoreOf<ClipListItem>, ClipListItemView) -> ItemView
    private var isReorderable: Bool

    public init(store: StoreOf<ClipList>) where ItemView == ClipListItemView {
        self.init(store: store, itemView: { $1 })
    }

    public init(store: StoreOf<ClipList>, isReorderable: Bool = false, @ViewBuilder itemView: @escaping (StoreOf<ClipListItem>, ClipListItemView) -> ItemView) {
        self.store = store
        self.itemView = itemView
        self.isReorderable = isReorderable
    }

    public var body: some View {
        clipList
            .task {
                store.send(.task)
            }

        Color.clear
            .frame(height: 80)
            .listRowSeparator(.hidden)
            .overlay {
                if store.pages.hasMore, !store.clips.isEmpty {
                    ProgressView()
                        .progressViewStyle(.circular)
                        .padding()
                        .frame(maxWidth: .infinity)
                        .listRowSeparator(.hidden)
                        .background(Color.clear)
                        .listRowBackground(Color.clear)
                        .id(UUID())
                        .onAppear {
                            guard !store.pages.loadingNext else { return }
                            store.send(.getNextPage)
                        }
                }
            }
            .listRowBackground(Color.clear)
            .listSectionSeparator(.hidden)
            .listRowSeparator(.hidden)
    }

    private var clipList: some View {
        ForEach(store.scope(state: \.clips, action: \.clips)) { clipStore in
            itemView(clipStore, ClipListItemView(store: clipStore))
                .id(clipStore.id)
                .zIndex(store.activeTooltipClipId == clipStore.id ? 5 : 0)
                .onTapGesture {
                    store.send(.playClipTapped(clipStore.clip))
                }
                .transition(.blurReplace)
        }
        .conditionalModifier(isReorderable) { view in
            view.onMove { from, to in
                guard let playlist = store.playlist else { return }
                store.send(.reorderClips(playlistId: playlist.id, from: from, to: to))
            }
        }
        .listRowSpacing(8.0)
        .listRowBackground(Color.clear)
        .listSectionSeparator(.hidden)
        .listRowSeparator(.hidden)
    }
}
