import Algorithms
import AnalyticsClient
import APIClient
import AVFoundation
import CommentsClient
import ComponentLibrary
import ComposableArchitecture
import EventBusClient
import FeatureShare
import LyricsClient
import MediaPlayer
import PlayerClient
import StatsigClient
import Utilities

// swiftlint:disable file_length

@Reducer
public struct Player {
    @Reducer(state: .equatable)
    public enum Destination {
        case songActions(SongActions)
        case share(Share)
    }

    @ObservableState
    public struct State: Equatable, Identifiable {
        public enum ScrubState: Equatable {
            case scrubbing(CMTime)
            case complete(CMTime)

            public var time: CMTime {
                switch self {
                case .scrubbing(let time), .complete(let time): return time
                }
            }
        }

        @Presents public var destination: Destination.State?
        @ObservationStateIgnored @ObservedBox public var lyricsState: LyricsReducer.State

        public var id: Clip.ID { clip.id }

        public var clip: Clip
        public var queue: IdentifiedArrayOf<Clip>
        @Shared public var me: Me
        @Shared(.inMemory(.playingClipKey)) var playingClip: Clip?
        @Shared(.inMemory(.isPlayingKey)) public var isPlaying: Bool = false
        @Shared(.inMemory(.commentsActivationState)) var commentsActivationState: CommentsActivationState = .notActive

        var isSelected = false
        var hasFetchedTotalTime = false
        public var elapsedTime: CMTime = .zero
        public var totalTime: CMTime = .zero
        public var scrub: ScrubState?
        var isShowingLyrics = false
        var isReplacingCurrentItem = false
        var isDeleting = false
        public var isScrubbing: Bool { scrub != nil }
        public var displayTime: CMTime { scrub?.time ?? elapsedTime }

        public var isPlaybackLikelyToKeepUp: Bool {
            @Dependency(PlayerClient.self) var playerClient
            return playerClient.isPlaybackLikelyToKeepUp()
        }

        public var areCommentsEnabled: Bool {
            FeatureFlag.legacy.clipComments
        }

        public var timeControlStatus: AVPlayer.TimeControlStatus = .waitingToPlayAtSpecifiedRate {
            didSet {
                $isPlaying.withLock { $0 = timeControlStatus == .playing }
            }
        }

        public var isReplacingClip: Bool {
            return isReplacingCurrentItem
        }

        public var isAtEndOfQueue: Bool {
            return clip.id == queue.last?.id
        }

        public var fallbackTotalTimeSeconds: Double { clip.isScene ? 30 : 120 }

        public var stopPlayingAtEndTime: Bool = false
        public let restartQueueAfterLastSong: Bool

        public init(clip: Clip, queue: [Clip], me: Shared<Me>, restartQueueAfterLastSong: Bool = true) {
            self.clip = clip
            // Make sure we don't open the player with duplicated clips
            let uniqueQueue = queue.uniqued(on: { $0.id })
            self.queue = .init(uniqueElements: uniqueQueue)

            self._me = me
            self.lyricsState = .init(clip: clip)
            self.restartQueueAfterLastSong = restartQueueAfterLastSong
        }

        func timeString(seconds: TimeInterval) -> String {
            seconds.positionalTime
        }
    }

    public enum Action: BindableAction {
        case destination(PresentationAction<Destination.Action>)
        case lyricsAction(LyricsReducer.Action)

        case setup
        case replaceCurrentItem(Clip, autoPlay: Bool)
        case replaceCurrentItemResponse(Clip, CMTime)
        case updateClip(Clip)
        case deleteClip(Clip)
        case playTapped
        case pauseTapped
        case prevTapped
        case nextTapped
        case toggleLike(Bool)
        case toggleDislike(Bool)
        case likeTapped
        case unlikeTapped
        case shareTapped
        case restartTapped
        case binding(BindingAction<State>)
        case lyricsTapped
        case moreTapped
        case authorTapped(String)
        case deleteTapped
        case shareDismissed
        case `internal`(Internal)
        case delegate(Delegate)

        public enum Delegate {
            case setupComplete
            case toggledLike(Clip)
            case deleted(Clip)
            case loadClipInOmniPlayer(Clip)
        }

        public enum Internal {
            case setupCompletion(Result<Void, Error>)
            case timeControlStatusResponse(Result<AVPlayer.TimeControlStatus, Error>)
            case periodicTimeResponse(CMTime)
            case likeResponse((Bool, Bool), Result<Void, Error>)
            case currentItemDidPlayToEndTime(Clip.ID)
            case incrementActionResponse(Result<Void, Error>)
            case deleteResponse(Result<Void, Error>)
            case incrementPlayCountMetrics(Clip)
        }
    }

    @Dependency(PlayerClient.self) var playerClient
    @Dependency(APIClient.self) var apiClient
    @Dependency(APIClientV2.self) var apiClientV2
    @Dependency(\.mainQueue) var mainQueue
    @Dependency(\.telemetryClient) var telemetry
    @Dependency(\.continuousClock) private var clock
    @Dependency(AnalyticsClient.self) var analyticsClient
    @Dependency(PlayerDelegateClient.self) private var delegate
    @Dependency(\.commentsClient) var commentsClient
    @Dependency(\.eventBus.sendClipEvent) private var sendClipEvent

    public init() {}

    public var body: some ReducerOf<Self> {
        BindingReducer()
        Scope(state: \.lyricsState, action: \.lyricsAction) {
            LyricsReducer()
        }
        Reduce { state, action in
            struct CheckCompletionCancellableId: Hashable {}
            struct DidPlayToEndTimeNotificationCancellable: Hashable {}
            struct TimeControlStatusPublisherCancellable: Hashable {}
            struct ElapsedTimePublisherCancellable: Hashable {}
            struct ReplaceCurrentItemCancellableId: Hashable {}

            // FIXME: `PlayerDelegate` can probably be removed now
            func sendDelegate(_ action: Player.Action.Delegate) -> Effect<Action> {
                if case let .toggledLike(clip) = action {
                    // Make sure to retain this logic if we remove this block
                    sendClipEvent(.toggledLike(clip))
                } else {
                    delegate.send(action)
                }
                return .send(.delegate(action))
            }

            switch action {
            case .setup:
                return .merge(
                    .run { send in
                        await send(.internal(.setupCompletion(Result(catching: { try playerClient.setup() }))))
                    },
                    .publisher {
                        playerClient.timeControlStatus().map { .internal(.timeControlStatusResponse(.success($0))) }
                    }
                    .cancellable(id: TimeControlStatusPublisherCancellable(), cancelInFlight: true)
                )

            case .internal(.timeControlStatusResponse(.success(let status))):
                guard !state.isReplacingCurrentItem else { return .none }
                state.timeControlStatus = status
                return .none

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

            case .internal(.setupCompletion(.success)):
                return .concatenate(
                    .send(.lyricsAction(.setup)),
                    sendDelegate(.setupComplete)
                )

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

            case .replaceCurrentItem(let clip, let autoPlay):
                guard !clip.audioUrl.isEmpty else { return .none }

                state.totalTime = CMTime(seconds: state.clip.duration, preferredTimescale: 1000)

                var effects: [Effect<Action>] = [
                    .cancel(id: TimeControlStatusPublisherCancellable()),
                ]

                // If we're already replacing a clip, just cancel ongoing operations
                // but continue with the new replacement
                if state.isReplacingCurrentItem {
                    effects.append(.cancel(id: ReplaceCurrentItemCancellableId()))
                }

                let url = clip.playableSceneUrl?.absoluteString ?? clip.audioUrl

                // Add the main effect to replace the current item
                // and play right away
                effects.append(.run { send in
                    var duration: CMTime = .zero
                    if autoPlay {
                        duration = await playerClient.replaceCurrentItemAndPlay(url)
                    } else {
                        duration = await playerClient.replaceCurrentItem(url)
                    }
                    await send(.replaceCurrentItemResponse(clip, duration))
                }
                .cancellable(id: ReplaceCurrentItemCancellableId(), cancelInFlight: true))

                if clip.id != state.clip.id {
                    state.clip = clip
                    state.lyricsState = .init(clip: clip)
                }

                if state.playingClip?.id != clip.id {
                    state.$playingClip.withLock { $0 = clip }
                }

                // Mark that we're replacing the current item
                state.isReplacingCurrentItem = true

                // Cancel any ongoing end-of-track notifications
                effects.append(.cancel(id: DidPlayToEndTimeNotificationCancellable()))

                // Add the lyrics effects
                effects.append(clip.status == .complete ? .none : .send(.lyricsAction(.startPollingForLyricsStatus)))
                effects.append(.send(.lyricsAction(.getAlignedLyrics)))

                return .merge(effects)

            case .updateClip(let clip):
                if state.clip.id == clip.id {
                    state.clip = clip
                }
                if state.queue.contains(where: { $0.id == clip.id }) {
                    state.queue[id: clip.id] = clip
                }
                return .none

            case .deleteClip(let clip):
                state.queue.remove(clip)
                return .none

            case let .replaceCurrentItemResponse(clip, time):
                // Always reset the replacing state, even if the clip doesn't match
                state.isReplacingCurrentItem = false

                // If this clip is no longer the current clip, ignore the rest of the response
                guard clip.id == state.clip.id else { return .none }

                state.hasFetchedTotalTime = true
                state.totalTime = time

                playerClient.setupNowPlayingInfo(
                    state.clip.title,
                    state.clip.displayName,
                    state.clip.largeImageUrl,
                    state.elapsedTime,
                    state.totalTime
                )

                return .merge(
                    .publisher {
                        NotificationCenter.default.publisher(for: AVPlayerItem.didPlayToEndTimeNotification, object: playerClient.getCurrentItem())
                            .receive(on: mainQueue)
                            .map { _ in .internal(.currentItemDidPlayToEndTime(clip.id)) }
                    }
                    .cancellable(id: DidPlayToEndTimeNotificationCancellable(), cancelInFlight: true),
                    .send(.internal(.incrementPlayCountMetrics(clip)))
                        // https://github.com/suno-ai/app-ios/issues/546
                        // Our audio playback logic plays new songs twice in some cases,
                        // so until we get that fixed it's important to fire metrics only once.
                            .debounce(id: clip.id, for: 1.0, scheduler: DispatchQueue.main),
                    .publisher { playerClient.periodicTime().map { .internal(.periodicTimeResponse($0)) } }
                        .cancellable(id: ElapsedTimePublisherCancellable(), cancelInFlight: true),
                    .publisher { playerClient.timeControlStatus().map { .internal(.timeControlStatusResponse(.success($0))) } }
                        .cancellable(id: TimeControlStatusPublisherCancellable(), cancelInFlight: true)
                )

            case let .internal(.incrementPlayCountMetrics(clip)):
                return .run { send in
                    await send(.internal(.incrementActionResponse(.init(catching: { try await apiClient.incrementPlayCount(clip) }))))
                }

            case .internal(.currentItemDidPlayToEndTime(let clipId)):
                if clipId == state.clip.id {
                    let endOfQueue = state.queue.isEmpty || (state.queue.index(id: clipId) ?? 0) >= state.queue.count - 1

                    if state.clip.isScene || state.commentsActivationState.isActive {
                        playerClient.seek(.zero)
                        playerClient.play()
                    } else if endOfQueue {
                        analyticsClient.track(
                            .audioAction(
                                .endOfQueue,
                                clip: state.clip,
                                playbackMetadata: PlaybackMetadata(
                                    isPlaying: state.isPlaying,
                                    elapsedTime: state.elapsedTime,
                                    totalTime: state.totalTime
                                )
                            )
                        )

                        if !state.restartQueueAfterLastSong {
                            playerClient.pause()
                            playerClient.seek(.zero)
                            return .none
                        }

                        // Make sure first item of queue is different than `clip` to prevent infinite trying to play queue
                        if let firstClip = state.queue.first, firstClip != state.clip {
                            state.isReplacingCurrentItem = true
                            state.timeControlStatus = .waitingToPlayAtSpecifiedRate

                            return .run { send in
                                try await Task.sleep(for: .seconds(0.5))
                                await send(.replaceCurrentItem(firstClip, autoPlay: true))
                            }
                        } else {
                            playerClient.pause()
                            playerClient.seek(.zero)
                            return .none
                        }
                    } else {
                        analyticsClient.track(
                            .audioAction(
                                .autoplayNextSong,
                                clip: state.clip,
                                playbackMetadata: PlaybackMetadata(
                                    isPlaying: state.isPlaying,
                                    elapsedTime: state.elapsedTime,
                                    totalTime: state.totalTime
                                )
                            )
                        )
                        if state.stopPlayingAtEndTime {
                            playerClient.pause()
                            playerClient.seek(.zero)
                            return .none
                        }
                        state.isReplacingCurrentItem = true
                        state.timeControlStatus = .waitingToPlayAtSpecifiedRate
                        return .run { send in
                            try await Task.sleep(for: .seconds(0.5))
                            await send(.nextTapped)
                        }
                    }
                }
                return .none

            case .playTapped:
                playerClient.play()
                return .none

            case .shareTapped:
                state.destination = .share(.init(
                    .clip(state.clip),
                    me: state.$me
                ))
                return .none

            case .shareDismissed:
                return .none

            case .internal(.incrementActionResponse(.success)):
                return .none

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

            case .nextTapped:
                guard let index = state.queue.index(id: state.clip.id),
                      index + 1 < state.queue.count else { return .none }
                let nextClip = state.queue[index + 1]
                return .merge(
                    sendDelegate(.loadClipInOmniPlayer(nextClip)),
                    .send(.replaceCurrentItem(nextClip, autoPlay: true))
                )

            case .prevTapped:
                guard let index = state.queue.index(id: state.clip.id),
                      index - 1 >= 0 else { return .none }
                return .run { [clip = state.queue[index - 1]] send in
                    await send(.replaceCurrentItem(clip, autoPlay: true))
                }

            case .toggleLike(let liked):
                state.clip.isLiked = liked
                return .merge(
                    sendDelegate(.toggledLike(state.clip)),
                    .run { [clip = state.clip] send in
                        await send(.internal(.likeResponse((clip.isLiked, clip.isDisliked), .init(catching: { try await apiClientV2.setReaction(clip, clip.isLiked, clip.isDisliked, nil) }))))
                    }
                )

            case .toggleDislike(let disliked):
                state.clip.isDisliked = disliked
                return .merge(
                    sendDelegate(.toggledLike(state.clip)),
                    .run { [clip = state.clip] send in
                        await send(.internal(.likeResponse((clip.isLiked, clip.isDisliked), .init(catching: { try await apiClientV2.setReaction(clip, clip.isLiked, clip.isDisliked, nil) }))))
                    }
                )

            case .likeTapped:
                return .send(.toggleLike(!state.clip.isLiked))

            case .unlikeTapped:
                return .send(.toggleDislike(!state.clip.isDisliked))

            case .internal(.likeResponse(_, .success)):
                return sendDelegate(.toggledLike(state.clip))

            case let .internal(.likeResponse(originals, .failure(error))):
                state.clip.isLiked = originals.0
                state.clip.isDisliked = originals.1
                log.telemetry.error(error)
                return sendDelegate(.toggledLike(state.clip))

            case .internal(.periodicTimeResponse(let time)):
                state.elapsedTime = time
                let seconds = time.roundedSingleDecimalSeconds

                // This 'dance' is needed because there is a _slight_ delay
                // when the drag is ended and the `player.seek` actually reports
                // the new time leading to a flicker. So here we override
                // elapsedTime with the scrub time so it appears correctly
                // then the elapsedTime will catch up on the _next_ report.
                switch state.scrub {
                case .complete(let time):
                    state.elapsedTime = time
                    state.scrub = nil
                    return .send(.lyricsAction(.updateSeconds(seconds)))

                case .scrubbing, nil:
                    return .send(.lyricsAction(.updateSeconds(seconds)))
                }

            case .binding(\.scrub):
                guard case .complete(let time) = state.scrub else { return .none }
                return .run { _ in
                    playerClient.seek(time)
                }

            case .pauseTapped:
                playerClient.pause()
                return .cancel(id: ElapsedTimePublisherCancellable())

            case .lyricsTapped:
                state.isShowingLyrics.toggle()
                return .none

            case .moreTapped:
                state.destination = .songActions(.init(
                    clip: state.clip,
                    playlist: nil,
                    me: state.$me
                ))
                return .none

            case .authorTapped:
                // Handled in parent
                return .none

            case .deleteTapped:
                playerClient.pause()
                state.isDeleting = true
                return .run { [clip = state.clip] send in
                    await send(.internal(.deleteResponse(.init(catching: { try await apiClient.deleteClip(clip) }))))
                }

            case .restartTapped:
                playerClient.seek(.zero)
                return .none

            case .internal(.deleteResponse(.success)):
                state.isDeleting = false
                state.clip.isTrashed = true
                return sendDelegate(.deleted(state.clip))

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

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