import APIClient
import AVFoundation
import CommentsClient
import ComponentLibrary
import ComposableArchitecture
import EventBusClient
import FeatureCaptions
import FeatureClipDetail
import FeatureComments
import FeatureShare
import FeatureShareSheet
import FeatureToasts
import Localization
import LyricsClient
import NavigationRouterClient
import OmniPlayerClient
import PlayerClient
import PlayerUtilities
import StatsigClient
import SwiftUI
import Utilities

// swiftlint:disable file_length

private let log = Logger(category: "ExpandedPlayerReducer")

@Reducer
public struct ExpandedPlayerReducer {
    @Reducer(state: .equatable)
    public enum Destination {
        case comments(CommentsThread)
        case songActions(SongActions)
        case attribution(RemixOf)
        case remix(RemixActions)
        case share(ShareSheetReducer<Clip>)
    }

    public enum GenerationState: Equatable {
        case idle
        case generating
        case ready([Clip])

        var isReady: Bool {
            if case .ready = self {
                return true
            }
            return false
        }

        var newClips: [Clip] {
            if case .ready(let clips) = self {
                return clips
            }
            return []
        }
    }

    public enum RemixType: Equatable {
        case cover
        case extend

        public init?(from string: String?) {
            switch string {
            case "cover":
                self = .cover
            case "extend":
                self = .extend
            // On unrecognized or nil, return nil
            default:
                return nil
            }
        }
    }

    @ObservableState
    public struct State: Equatable {
        @Presents public var destination: Destination.State?
        public var source: PlaybackSource
        @Shared var player: OmniPlayerPlaybackState
        @Shared var me: Me
        @Shared(.appStorage(.hasSeenNewSongsOmniPlayerTooltip)) var hasSeenNewSongsOmniPlayerTooltip: Bool = false
        @Shared(.inMemory(.playingClipKey)) var playingClip: Clip?
        @Shared(.inMemory(.isPlayingKey)) var isPlaying: Bool = false
        @ObservationStateIgnored @ObservedBox var playbackState: OmniPlayerPlaybackState?
        @Shared(.fileStorage(.savedPrompts)) var savedPrompts:
            [Clip.ID: Prompt] = [:]
        var toast: ToastType?
        var prompt: Prompt?
        var profile: Profile?

        // Hook context when playing from hooks feed
        public var sourceHook: Hook?

        // Prefetched hook for faster navigation to hooks contextual feed
        public var prefetchedHooks: [Hook] = []

        var isMe: Bool {
            clip.userId == me.user.id
        }

        public var canRemix: Bool {
            (isMe || clip.canRemix)
                && !clip.isScene
                && FeatureFlag.create.remixAndAttribution
        }

        var selectedItemIndex: Int {
            didSet {
                // If we're playing the second clip or every other clip for a new generation,
                // show the generate more button. If the user goes back to the first clip,
                // we should still show the buttons.
                if prompt != nil {
                    shouldShowGenerateMoreButton = true
                }
            }
        }

        let initialIndex: Int
        var generationState: GenerationState = .idle
        public var clip: Clip {
            // Use presentation queue for infinite carousel
            guard selectedItemIndex >= 0, selectedItemIndex < player.queue.count else {
                return player.clip
            }
            return player.queue[selectedItemIndex].clip
        }

        // Only play the previous clip if the current clip
        // has been playing for less than the restart threshold
        let clipRestartTimeThreshold: TimeInterval = Constants.clipRestartTimeThreshold

        var lyricsData: LyricsDataV2?

        public init(
            player: Shared<OmniPlayerPlaybackState>,
            me: Shared<Me>,
            prompt: Prompt?, // Used in Swipe-To-Generate-More flows
            autoStart _: Bool,
            initialIndex: Int,
            queue _: [Clip],
            source: PlaybackSource = .omniPlayer,
            sourceHook: Hook? = nil
        ) {
            self._player = player
            self.prompt = prompt
            self._me = me
            self.initialIndex = initialIndex
            self.selectedItemIndex = initialIndex
            self.source = source
            self.sourceHook = sourceHook
        }

        var clipCommentThread: ClipCommentsThread?
        var commentCount: Int? {
            @Shared(.inMemory(.commentsCountMap)) var commentsCountMap: ClipCommentTotalCountMap = .defaultValue
            guard case .known(let count) = commentsCountMap.commentCountForClip(clip.id.remoteId) else {
                return clip.commentCount > 0 ? clip.commentCount : nil
            }
            return count > 0 ? count : nil
        }

        var formattedCommentCount: String? {
            guard let count = commentCount else { return nil }
            return count > 0 ? count.formatted(.number.notation(.compactName)) : nil
        }

        // Used to track if we've opened Comments out of the current queue "context",
        // for example, from a deeplink or notification. This creates a queue override in
        // OmniPlayerClient and lets the user get back in their original queue when tapping
        // next or previous, since opening the comments sheet currently
        // requires the ExpandedPlayer to be open.
        var didOpenCommentsSheetForDifferentClip: Bool = false

        // Trigger for scroll reset - increment this to trigger scroll to top
        var scrollResetTrigger: Int = 0

        // Comment to route to when opening comments sheet
        var deepLinkCommentID: String?

        var remixCount: Int? {
            @Shared(.inMemory(.clipDirectChildrenCount)) var clipDirectChildrenCount: [ClipID: Int] = [:]
            return clipDirectChildrenCount[clip.id]
        }

        var formattedRemixCount: String? {
            guard let count = remixCount else { return nil }
            return count > 0 ? count.formatted(.number.notation(.compactName)) : nil
        }

        var showNewSongsTooltip: Bool {
            !hasSeenNewSongsOmniPlayerTooltip && generationState.isReady
        }

        // Show generate more button when we have a prompt and are on the second clip or later
        var shouldShowGenerateMoreButton: Bool = false

        var shouldShowTimeSyncedLyrics: Bool {
            !clip.isInstrumental && !player.isPlayingNewGeneration
        }

        // Time-synced comments
        var currentTimeSyncedComment: ClipComment?
        var isShowingTimeSyncedComment: Bool = false

        var shouldHideControls: Bool = false
        // Track if the user has tapped "See More" or scrolled down to see
        // additional clip info
        var didTapOrScrollToSeeMore: Bool = false
    }

    public enum Action: BindableAction {
        case destination(PresentationAction<Destination.Action>)
        case addMoreClips([Clip])
        case setToast(ToastType?)
        case delegate(Delegate)
        case `internal`(Internal)
        case binding(BindingAction<State>)
        case commentsClient(CommentsClientEvent)
        case setSelectedClip(Clip)
        case setSelectedIndex(Int)
        case fetchProfile
        case profileResponse(Result<Profile, Error>)
        case followTapped(handle: String, unfollow: Bool?)
        case followTappedResponse(Result<Void, Error>, originalState: Bool)

        case dismissSongActionsSheet

        case clipEvents(EventBusClient.ClipEvent)

        // Song Actions
        case moreTapped
        case likeTapped
        case commentsTapped
        case remixTapped
        case shareTapped
        case attributionTapped(parentClip: ParentClip)
        case authorTapped(handle: String)
        case userMentionTapped(handle: String)
        case generateMoreTapped
        case playGeneratedClips([Clip])
        case generationCompleted([Clip])
        case editPromptTapped
        case upgradeTapped

        case reuseClipMetadata(ClipReuseType)

        // Playbar
        case didStartScrubbing(CMTime)
        case didEndScrubbing(CMTime)
        case playTapped
        case pauseTapped
        case nextTapped
        case prevTapped
        case playTopClipsAtIndex([Clip], Int)

        case dislikeTapped
        case openCommentsSheet
        case commentsDismissed
        case resetScrollPosition
        case resetGenerationState

        case updateClip(Clip)
        case dismissTooltip

        // Clip info
        case didTapSeeMoreChevron
        case didScrollToSeeMore
        case viewHookTapped

        case omniplayerEvent(OmniPlayerEvent)
        case playback(OmniPlayerPlaybackReducer.Action) // Only if source is .hooksFeed
        case task
        case setup

        case didTapChromeToToggleControls(verticalOffset: CGFloat)

        case timeSyncedCommentTapped(_ commentId: String)
        case setDeepLinkCommentId(_ commentId: String)

        case handleRemixDeeplink(remixType: RemixType?, style: String?, lyrics: String?)
        case prefetchHooks
        case triggerAutoplay

        public enum Delegate: Equatable {
            case playTapped
            case pauseTapped
            case nextTapped
            case prevTapped
            case closeTapped
            case didStartScrubbing(CMTime)
            case didEndScrubbing(CMTime)
            case resetScrollPosition
            case editPromptTapped(Clip, Prompt)
            case navigateToProfile(handle: String)
        }

        public enum Internal {
            case playSelectedItem(Int)
            case playbackStateChanged(AVPlayer.TimeControlStatus)
            case playbackTimeUpdated(CMTime)
            case prefetchHooksCompleted(Result<[Hook], Error>)
        }
    }

    @Dependency(\.dismiss) var dismiss
    @Dependency(\.omniplayerClient) var omniplayerClient
    @Dependency(\.eventBus.getOmniplayerChannel) private var getOmniplayerChannel
    @Dependency(\.eventBus.getCreateChannel) var getCreateChannel
    @Dependency(NavigationRouterClient.self) var navigationRouter
    @Dependency(\.continuousClock) var clock
    @Dependency(\.apiClientV2) var apiClientV2
    @Shared(.inMemory(.isPlayingKey)) var isPlaying: Bool = false
    @Dependency(\.eventBus.getClipPublisher) private var getClipPublisher
    @Dependency(\.eventBus.sendClipEvent) var sendClipEvent
    @Dependency(\.clipLineageClient.hydrateRelationshipsForClip) var hydrateRelationshipsForClip

    struct OmniplayerEventsSubscription: Hashable {}

    public init() {}

    public var body: some ReducerOf<Self> {
        BindingReducer()
        Reduce { state, action in
            switch action {
            case .task:
                // Add an extra delay if we're coming from HooksFeed
                var setupDelay: TimeInterval = 0.3
                if state.source == .hooksFeed {
                    setupDelay = 0.5
                }
                hydrateRelationshipsForClip(state.clip.id)
                return .concatenate(
                    .run { [setupDelay] send in
                        try? await Task.sleep(for: .seconds(setupDelay))
                        await send(.setup)
                    },
                    .send(.prefetchHooks)
                )

            case .setup:
                if state.source == .hooksFeed {
                    // Setup playback reducer since we don't have OmniPlayer to handle it for us here
                    state.playbackState = state.player
                    omniplayerClient.setPlaybackConfigurationOverride(.init(
                        repeatMode: .all,
                        shuffle: .off,
                        autoplayMode: .similarClips
                    ))
                }
                return .merge(
                    .stream(
                        omniplayerClient.stream(),
                        send: Action.omniplayerEvent,
                        cancellableId: OmniplayerEventsSubscription()
                    ),
                    .subscribe(getClipPublisher(), send: Action.clipEvents),
                    .send(.fetchProfile),
                    .send(.triggerAutoplay)
                )

            case .fetchProfile:
                return .run { [handle = state.clip.handle] send in
                    await send(.profileResponse(Result(catching: {
                        try await apiClientV2.getProfile(handle, 0, .playCount, false, false)
                    })))
                }

            case .profileResponse(.success(let profile)):
                state.profile = profile
                return .none

            case .profileResponse(.failure(let error)):
                log.telemetry.error(error, message: "Failed to fetch profile.")
                return .none

            case .followTapped(let handle, let unfollow):
                let originalFollowingState = state.profile?.isFollowing ?? false
                state.profile?.isFollowing = !originalFollowingState

                let recommendationMetadata = HooksRecommendationMetadata(
                    contextType: state.source.rawValue,
                    hookId: state.sourceHook?.id,
                    recommendationItemId: state.sourceHook?.recommendationItemId
                )

                return .run { send in
                    await send(.followTappedResponse(Result(catching: {
                        try await apiClientV2.followProfile(handle, unfollow, recommendationMetadata)
                    }), originalState: originalFollowingState))
                }

            case .followTappedResponse(.success, let originalState):
                return .none

            case .followTappedResponse(.failure(let error), let originalState):
                state.profile?.isFollowing = originalState
                log.telemetry.error(error, message: "Failed to follow/unfollow profile.")
                return .send(.setToast(.warning(nil, .string(L10n.FeatureOmniPlayer.errorToast))))

            case .omniplayerEvent(let event):
                return handleOmniplayerEvent(state: &state, event: event)

            case .playTapped:
                return handlePlayTapped(state: &state)

            case .addMoreClips(let clips):
                state.$player.queue.withLock { queue in
                    let startPosition = queue.count
                    let queueClips = clips.enumerated().map { index, clip in
                        QueueClip(clip: clip, position: startPosition + index)
                    }
                    queue.append(contentsOf: queueClips)
                }
                return .none

            case .setSelectedClip(let clip):
                var effects: [Effect<Action>] = [.send(.fetchProfile)]
                guard state.clip.id != clip.id else { return .merge(effects) }
                state.selectedItemIndex = state.player.queue.firstIndex(where: { $0.clip.id == clip.id }) ?? state.initialIndex
                state.didTapOrScrollToSeeMore = false
                return .merge(effects)

            case let .setToast(toast):
                state.toast = toast
                return .none

            case .pauseTapped:
                return handlePauseTapped(state: &state)

            case .nextTapped:
                var effects: [Effect<Action>] = []
                // Dismiss tooltip if showing when user navigates
                if state.showNewSongsTooltip {
                    effects.append(.send(.dismissTooltip))
                }
                // Update local state - no longer wrap to 0, let the queue rotation handle it
                let newIndex = state.selectedItemIndex + 1
                state.selectedItemIndex = newIndex
                if state.didOpenCommentsSheetForDifferentClip {
                    // Clear any queue override that may have been set
                    omniplayerClient.clearQueueOverride(autoPlay: false, cause: .skipForward)
                    state.didOpenCommentsSheetForDifferentClip = false
                }
                if case .ready = state.generationState {
                    state.generationState = .idle
                }
                effects.append(.send(.delegate(.nextTapped)))
                return .merge(effects)

            case .prevTapped:
                return handlePrevTapped(state: &state)

            case .openCommentsSheet:
                return openCommentsSheet(state: &state, for: state.clip, didOpenFromDeeplink: true)

            case .didStartScrubbing(let time):
                return handleDidStartScrubbing(state: &state, time: time)

            case .didEndScrubbing(let time):
                return handleDidEndScrubbing(state: &state, time: time)

            case .remixTapped:
                // Handle remix action here if needed
                state.destination = .remix(.init(
                    me: state.$player.me,
                    clip: state.clip
                ))
                return .none

            case .attributionTapped(let parentClip):
                state.destination = .attribution(.init(
                    parentClip: parentClip,
                    me: state.$me
                ))
                return .none

            case .likeTapped:
                state.$player.clip.withLock { $0.isLiked = !$0.isLiked }
                // Let client handle API call and sending the clip event everywhere
                omniplayerClient.toggleLike(state.player.clip)
                return .none

            case .dislikeTapped:
                state.$player.clip.withLock { $0.isDisliked = !$0.isDisliked }
                omniplayerClient.toggleDislike(state.player.clip)
                return .none

            case .commentsTapped:
                return openCommentsSheet(state: &state, for: state.clip)

            case .commentsDismissed:
                return dismissCommentsSheet(state: &state)

            case .resetScrollPosition:
                state.scrollResetTrigger += 1
                return .send(.delegate(.resetScrollPosition))

            case .shareTapped:
                state.destination = .share(
                    ShareSheetState(item: state.clip)
                )
                return .none

            case .moreTapped:
                // Handle more action here if needed
                sendClipEvent(.showSongActions(clip: state.clip, playlist: nil))
                return .none

            case .authorTapped(let handle):
                switch state.source {
                case .hooksFeed:
                    return .send(.delegate(.navigateToProfile(handle: handle)))
                default:
                    let simpleProfile: SimpleProfile? = state.profile.map { SimpleProfile(from: $0) }
                    navigationRouter.send(route: .profile(handle, recommendationMetadata: nil, simpleProfile: simpleProfile))
                }
                return .none

            case .destination(.presented(.comments(.delegate(.showUserProfile(let handle))))):
                // Handle delegate action being sent back from CommentThread
                state.destination = nil
                navigationRouter.send(route: .profile(handle))
                return .none

            case .dismissSongActionsSheet:
                guard case .songActions = state.destination else { return .none }
                state.destination = nil
                return .none

            case .updateClip(let clip):
                state.$player.clip.withLock { $0 = clip }
                state.$player.queue.withLock { queue in
                    if state.selectedItemIndex >= 0 && state.selectedItemIndex < queue.count {
                        queue[state.selectedItemIndex].clip = clip
                    }
                }
                return .none

            case .commentsClient(.didUpdateComments(let commentCache)):
                guard let commentThread = commentCache.threadForClipID(state.clip.id) else { return .none }
                state.clipCommentThread = commentThread
                return .none

            case .setSelectedIndex(let index):
                guard index >= 0 && index < state.player.queue.count else { return .none }

                var effects: [Effect<Action>] = []

                // Dismiss tooltip if showing when user swipes to next track
                if state.showNewSongsTooltip && index > state.selectedItemIndex {
                    effects.append(.send(.dismissTooltip))
                }

                state.selectedItemIndex = index
                if state.didOpenCommentsSheetForDifferentClip {
                    // Clear any queue override that may have been set
                    let cause: PauseSongCause = index > state.selectedItemIndex ? .skipForward : .skipBackward
                    omniplayerClient.clearQueueOverride(autoPlay: false, cause: cause)
                    state.didOpenCommentsSheetForDifferentClip = false
                }
                if case .ready = state.generationState {
                    state.generationState = .idle
                }

                // Fetch profile for the new song
                effects.append(.send(.fetchProfile))

                return .merge(effects)

            case .internal(.playSelectedItem(let index)):
                omniplayerClient.playClipAtIndex(index)
                return .none

            case .internal(.playbackStateChanged(let status)):
                state.$player.withLock { $0.timeControlStatus = status }
                return .none

            case .internal(.playbackTimeUpdated(let time)):
                state.$player.withLock { $0.elapsedTime = time }
                return .none

            // MARK: Generate more and edit while listening

            case .generateMoreTapped:
                guard let prompt = state.prompt else { return .none }
                state.generationState = .generating
                omniplayerClient.generateMore(prompt)
                return .none

            case .resetGenerationState:
                state.generationState = .idle
                state.shouldShowGenerateMoreButton = false
                return .none

            case .playTopClipsAtIndex(let clips, let index):
                guard clips.indices.contains(index) else { return .none }
                let first = clips[index]
                getOmniplayerChannel().queue(.playClip(first, queue: clips, context: SessionContext(source: .topClips(handle: state.clip.handle))))
                return .none

            case .playGeneratedClips(let clips):
                state.generationState = .idle
                let clipIndex = state.player.queue.firstIndex(where: { $0.clip.id == clips.first?.id }) ?? state.selectedItemIndex
                state.selectedItemIndex = clipIndex
                omniplayerClient.playClipAtIndex(clipIndex)
                return .none

            case .generationCompleted(let clips):
                state.generationState = .ready(clips)
                return .none

            case .editPromptTapped:
                guard let prompt = state.prompt else { return .none }
                return .send(.delegate(.editPromptTapped(state.clip, prompt)))

            case .upgradeTapped:
                navigationRouter.send(.subscriptions)
                return .none

            case .reuseClipMetadata(let reuseType):
                let prompt = Prompt(
                    title: state.clip.title,
                    lyrics: reuseType == .lyrics ? state.clip.prompt : "",
                    styles: reuseType == .styles ? state.clip.tags : "",
                    excludeStyles: reuseType == .styles ? (state.clip.negativeTags ?? "") : "",
                    styleWeight: reuseType == .styles ? state.clip.styleWeight : nil,
                    weirdnessConstraint: reuseType == .styles ? state.clip.weirdnessConstraint : nil
                )
                getCreateChannel().queue(.reusePrompt(prompt: prompt))
                return .none

            case .dismissTooltip:
                state.$hasSeenNewSongsOmniPlayerTooltip.withLock { $0 = true }
                return .none

            case .didTapChromeToToggleControls(let verticalOffset):
                if state.shouldHideControls {
                    state.shouldHideControls = false
                } else if !state.shouldHideControls, abs(verticalOffset) < 5 {
                    state.shouldHideControls = true
                }
                return .none

            case .userMentionTapped(let handle):
                navigationRouter.send(route: .profile(handle))
                return .none

            case .didTapSeeMoreChevron, .didScrollToSeeMore:
                state.didTapOrScrollToSeeMore = true
                return .none

            case .viewHookTapped:
                omniplayerClient.pauseCurrentClip()
                let initialHooks = state.prefetchedHooks
                navigationRouter.send(route: .hooksContextualFeed(initialHooks, 0, nil, .clip(clipId: state.clip.id.remoteId)))
                return .none

            case .timeSyncedCommentTapped(let commentId):
                state.deepLinkCommentID = commentId
                return openCommentsSheet(state: &state, for: state.clip, didOpenFromDeeplink: true)

            case .setDeepLinkCommentId(let commentId):
                state.deepLinkCommentID = commentId
                return .none

            case .handleRemixDeeplink(let remixType, let style, let lyrics):
                return handleRemixDeeplink(state: &state, remixType: remixType, style: style, lyrics: lyrics)

            case .prefetchHooks:
                // Only prefetch if clip has hooks
                guard state.clip.hasHook else { return .none }

                return .run { [clipId = state.clip.id.remoteId] send in
                    await send(.internal(.prefetchHooksCompleted(
                        await Result(catching: {
                            try await apiClientV2.getHooksForClip(clipId, 0, 10)
                        })
                    )))
                }

            case .internal(.prefetchHooksCompleted(let result)):
                switch result {
                case .success(let hooks):
                    state.prefetchedHooks = hooks
                case .failure(let error):
                    log.telemetry.error(error)
                }
                return .none

            case .triggerAutoplay:
                guard state.source == .hooksFeed else { return .none }

                omniplayerClient.fetchAutoplayClips(state.clip)
                return .none

            case .clipEvents(.updateClip(let clip)):
                guard clip != state.clip else { return .none }
                return .run { _ in
                    await omniplayerClient.updateClipInQueue(clip)
                }

            case .binding, .delegate, .destination, .internal, .commentsClient, .playback, .clipEvents:
                return .none
            }
        }
        .ifLet(\.$destination, action: \.destination)
        .ifLet(\.playbackState, action: \.playback) {
            OmniPlayerPlaybackReducer()
        }
        Analytics()
    }
}

public enum ClipReuseType {
    case lyrics
    case styles
}
