import AnalyticsClient
import APIClient
import AVFoundation
import ComponentLibrary
import ComposableArchitecture
import EventBusClient
import GenAPI
import Localization
import NavigationRouterClient
import OmniPlayerClient
import OpenAPIRuntime
import PlayerUtilities
import StatsigClient
import SwiftUI
import Utilities

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

@Reducer
public struct OmniPlayerReducer {
    @ObservableState
    public struct State: Equatable {
        @Shared public var player: OmniPlayerPlaybackState
        @Shared public var me: Me
        let prompt: Prompt?
        let autoStart: Bool
        let initialIndex: Int

        public var isCollapsed: Bool {
            didSet {
                $isCompactPlayerVisible.withLock { $0 = isCollapsed }
            }
        }

        // Since users can't close OmniPlayerReducer, we only need to check if it's the first time it's being shown
        var hasSeenExpandedPlayerInCurrentSession: Bool = false

        @ObservationStateIgnored @ObservedBox var compactState: CompactPlayerV3.State
        @ObservationStateIgnored @ObservedBox var expandedState: ExpandedPlayerReducer.State

        @Shared(.inMemory(.isPlayingKey)) var isPlaying: Bool = false
        @Shared(.inMemory(.playingClipKey)) var playingClip: Clip?
        @Shared(.inMemory(.isCompactPlayerVisible)) fileprivate var isCompactPlayerVisible: Bool = false
        @Shared(.inMemory(.billingInfo)) var billingInfo: SubscriptionInfoResponse?
        @Shared(.inMemory(.isOnHooksFeed)) var isOnHooksFeed: Bool = false

        // Tracks the current source
        public var context: SessionContext

        // Used to check if the queue has changed
        public var queue: IdentifiedArrayOf<QueueClip>

        // Used to update the expanded player when the app is backgrounded
        public var isBackgrounded: Bool = false

        public init(
            clip: Clip,
            queue: [Clip],
            me: Shared<Me>,
            isExpanded: Bool = false,
            prompt: Prompt? = nil,
            autoStart: Bool = true,
            context: SessionContext
        ) {
            let player: Shared<OmniPlayerPlaybackState> = Shared(value: .init(clip: clip, queue: queue, me: me, isPlayingNewGeneration: prompt != nil))
            self._player = player
            self.prompt = prompt
            self.autoStart = autoStart
            self.initialIndex = queue.firstIndex(where: { $0.id == clip.id }) ?? 0
            self._me = me
            self.isCollapsed = !isExpanded
            self.expandedState = .init(player: player, me: me, prompt: prompt, autoStart: autoStart, initialIndex: initialIndex, queue: queue)
            self.compactState = .init(player: player, autoStart: autoStart, clip: clip)
            self.queue = IdentifiedArrayOf(uniqueElements: queue.enumerated().map { index, clip in
                QueueClip(clip: clip, position: index)
            })
            self.context = context
            self.$isCompactPlayerVisible.withLock { $0 = !isExpanded }
        }
    }

    public enum Action: BindableAction {
        case binding(BindingAction<State>)
        case expanded(ExpandedPlayerReducer.Action)
        case compact(CompactPlayerV3.Action)
        case player(OmniPlayerPlaybackReducer.Action)
        case omniplayerEvent(OmniPlayerEvent)
        case delegate(Delegate)
        case `internal`(Internal)

        case task
        case didBecomeActive
        case didBecomeInactive
        case setExpanded(Bool, shouldRefreshClipInfo: Bool = true)
        case updateClip(Clip)
        case deleteClip(Clip)

        case prepareQueueAndPlay
        case togglePlayPause
        case playClip(at: Int, isExpanded: Bool)
        case openCommentsInExpandedPlayer(context: SessionContext)
        case addMoreClips([Clip])
        case refreshQueueFrom(index: Int)

        case clipEvents(EventBusClient.ClipEvent)

        public enum Delegate {
            case dismiss
            case createTapped
            case clipDeleted(Clip)
            case indexSelected(Int)
            case editPromptTapped(Clip, Prompt)
            case updatedClip(Clip)
            case createWith(Prompt)
        }

        public enum Internal {
            case deleteClipResponse(Clip, Result<Void, Error>)
            case setupPlayer
        }
    }

    enum CancellableId: String {
        case captionPromptToast
    }

    @Dependency(APIClient.self) var apiClient
    @Dependency(\.telemetryClient) var telemetry
    @Dependency(\.eventBus.getClipPublisher) private var getClipPublisher
    @Dependency(NavigationRouterClient.self) var navigationRouter
    @Dependency(VideoCoverClient.self) var videoCoverClient
    @Dependency(\.clipLineageClient.hydrateRelationshipsForClip) private var hydrateRelationshipsForClip
    @Dependency(\.omniplayerClient) var omniplayerClient
    @Dependency(\.omniplayerClient.stream) var omniplayerStream
    @Dependency(\.eventBus.sendClipEvent) private var sendClipEvent

    public init() {}

    private var playerReducer: some ReducerOf<Self> {
        Scope(state: \.player, action: \.player) {
            OmniPlayerPlaybackReducer()
        }
    }

    public var body: some ReducerOf<Self> {
        BindingReducer()
        playerReducer
        Scope(state: \.expandedState, action: \.expanded) {
            ExpandedPlayerReducer()
        }
        Scope(state: \.compactState, action: \.compact) {
            CompactPlayerV3()
        }
        Reduce<State, Action> { state, action in
            struct OmniPlayerClientEventCancellableId: Hashable {}
            switch action {
            case .task:
                return .merge(
                    .subscribe(
                        getClipPublisher(),
                        send: Action.clipEvents
                    ),
                    .stream(omniplayerStream(), send: Action.omniplayerEvent, cancellableId: OmniPlayerClientEventCancellableId())
                )

            case .omniplayerEvent(.clipChanged(let newClip, _)):
                hydrateRelationshipsForClip(newClip.id)
                return .merge(
                    .send(.compact(.updateClip(newClip))),
                    .send(.expanded(.updateClip(newClip)))
                )

            case .omniplayerEvent(.queueUpdated(let newQueue)):
                // Update OmniPlayerReducer's queue state to stay in sync with client
                state.queue = IdentifiedArrayOf(uniqueElements: newQueue.enumerated().map { index, clip in
                    QueueClip(clip: clip, position: index)
                })
                return .none

            case .didBecomeActive:
                state.isBackgrounded = false
                return .none

            case .didBecomeInactive:
                state.isBackgrounded = true
                return .none

            case .prepareQueueAndPlay:
                let queue = state.player.queue
                let clip = state.player.clip
                let playIndex = queue.firstIndex(where: { $0.clip.id == clip.id }) ?? 0
                // If we're playing a new generation, we should set Repeat: Off to prevent swiping
                // at the start or end of the queue
                let shouldSetRepeatModeOff: Bool = state.prompt != nil
                return .concatenate(
                    .send(.expanded(.resetGenerationState)),
                    .merge(
                        .send(.expanded(.setSelectedIndex(playIndex))),
                        .send(.compact(.updateClip(clip))),
                        .send(.player(.setup))
                    ),
                    .run { [me = state.me, context = state.context] _ in
                        try? omniplayerClient.setup(me.user.id)

                        if shouldSetRepeatModeOff {
                            omniplayerClient.setPlaybackConfigurationOverride(PlaybackConfiguration(repeatMode: .off))
                        } else {
                            omniplayerClient.clearPlaybackConfigurationOverrides()
                        }

                        // Convert QueueClips to Clips for the replaceQueue method
                        let clips = queue.map { $0.clip }
                        await omniplayerClient.replaceQueue(clips, playIndex, context)
                    }
                )

            case .playClip(at: let index, let isExpanded):
                guard index < state.player.queue.count else { return .none }
                let clip = state.player.queue[index].clip

                return .run { send in
                    // First update the UI selection state directly
                    await send(.expanded(.setSelectedIndex(index)))
                    await send(.compact(.updateClip(clip)))

                    // Then play using the client - this will also emit events that will update UI
                    omniplayerClient.playClipAtIndex(index)

                    guard isExpanded else { return }
                    await send(.setExpanded(true, shouldRefreshClipInfo: false))
                }

            case .refreshQueueFrom(let index):
                guard index < state.player.queue.count else { return .none }
                let clip = state.player.queue[index].clip

                return .run { send in
                    // Update UI first
                    await send(.compact(.updateClip(clip)))
                    await send(.expanded(.setSelectedClip(clip)))

                    // Play at index and immediately pause to mimic non-auto-play behavior
                    omniplayerClient.playClipAtIndex(index)
                    omniplayerClient.pauseCurrentClip()
                }

            case .setExpanded(let isExpanded, let shouldRefreshClipInfo):
                state.isCollapsed = !isExpanded
                // Reset scroll position when collapsing
                if !isExpanded {
                    return .send(.expanded(.resetScrollPosition))
                }
                if shouldRefreshClipInfo, isExpanded {
                    // Refresh clip info when expanding
                    return .send(.expanded(.fetchProfile))
                }
                return .none

            case .updateClip(let clip):
                return .merge(
                    .send(.expanded(.updateClip(clip))),
                    .send(.compact(.updateClip(clip)))
                )

            case .deleteClip(let clip):
                omniplayerClient.removeClipFromQueue(clip.id)
                return .none

            case .togglePlayPause:
                omniplayerClient.togglePlayPause()
                return .none

            case .clipEvents(.updateClip(let clip)):
                // Always update the queue if the clip is in it
                if let index = state.queue.firstIndex(where: { $0.clip.id == clip.id }) {
                    state.queue[index].clip = clip
                    // Always update the omniplayer client's queue to keep it in sync
                    omniplayerClient.updateClipInQueue(clip)
                }
                guard state.playingClip?.id == clip.id, state.playingClip != clip else { return .none }
                return .send(.updateClip(clip))

            case .clipEvents(.toggledLike(let clip)):
                let shouldUpdatePlayingClip = state.playingClip?.id == clip.id && state.playingClip?.isLiked != clip.isLiked

                if let index = state.queue.firstIndex(where: { $0.clip.id == clip.id }) {
                    state.queue[index].clip = clip
                }

                guard shouldUpdatePlayingClip else { return .none }

                return .merge(
                    .send(.expanded(.updateClip(clip))),
                    .send(.compact(.updateClip(clip)))
                )

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

            case .clipEvents(.removeClipFromPlaylist):
                return .none

            case .clipEvents(.undoDeleteClip):
                return .none

            case .internal(.deleteClipResponse(let clip, .success)):
                sendClipEvent(.removeClip(clip))
                return .none

            case .internal(.deleteClipResponse(_, .failure(let error))):
                log.telemetry.error(error)
                return .merge(
                    .send(.expanded(.dismissSongActionsSheet)),
                    .send(.expanded(.setToast(.success(L10n.FeatureClipDetail.songDeleteFailed, .string(""), position: .top))))
                )

            case .compact(.delegate(.nextTapped)), .expanded(.delegate(.nextTapped)):
                omniplayerClient.playNextClip()
                return .none

            case .expanded(.delegate(.prevTapped)):
                if state.player.displayTime.seconds < 3 {
                    omniplayerClient.playPreviousClip()
                } else {
                    omniplayerClient.restartCurrentClip()
                }
                return .none

            case .compact(.delegate(.playTapped)), .expanded(.delegate(.playTapped)):
                omniplayerClient.playCurrentClip()
                return .none

            case .compact(.delegate(.pauseTapped)), .expanded(.delegate(.pauseTapped)):
                omniplayerClient.pauseCurrentClip()
                return .none

            case .expanded(.delegate(.didStartScrubbing)):
                // Set both scrub state and lastScrubTime immediately to prevent bounce
                _ = playerReducer.reduce(into: &state, action: .player(.binding(.set(\.scrub, .scrubbing))))
                return .none

            case .expanded(.delegate(.didEndScrubbing(let time))):
                _ = playerReducer.reduce(into: &state, action: .player(.binding(.set(\.scrub, .complete(time)))))
                return .none

            case .expanded(.delegate(.closeTapped)):
                return .send(.setExpanded(false))

            case .expanded(.delegate(.resetScrollPosition)):
                // This is handled by the view - no action needed in reducer
                return .none

            case .expanded(.delegate(.editPromptTapped(let clip, let prompt))):
                return .send(.delegate(.editPromptTapped(clip, prompt)))

            case .openCommentsInExpandedPlayer(let context):
                return .run { [clip = state.player.clip, me = state.me] send in
                    let currentQueue = await omniplayerClient.getQueue()

                    if currentQueue.isEmpty {
                        // No player running - start a proper player session with just this clip
                        try? omniplayerClient.setup(me.user.id)
                        await omniplayerClient.replaceQueue([clip], 0, context)
                    } else {
                        // Player already running - use temporary override for comments
                        omniplayerClient.setQueueOverride([clip], .vanilla, context)
                    }

                    await send(.expanded(.setSelectedClip(clip)))
                    await send(.compact(.updateClip(clip)))
                    await send(.setExpanded(true, shouldRefreshClipInfo: false))
                    await send(.expanded(.openCommentsSheet))
                }

            case .addMoreClips(let clips):
                return .send(.expanded(.addMoreClips(clips)))

            case .omniplayerEvent(.moreClipsAdded(let clips)):
                return .send(.expanded(.generationCompleted(clips)))

            case .omniplayerEvent(.errorFetchingClips(let error)):
                guard let error = error as? OpenAPIRuntime.ClientError,
                      let apiError = error.underlyingError as? APIError
                else {
                    return .none
                }

                var effects: [Effect<Action>] = [
                    .send(.expanded(.resetGenerationState)),
                ]

                switch apiError {
                case .insufficientCredits:
                    let message = apiError.insufficientCreditsMessage(billingInfo: state.billingInfo)
                    effects.append(.send(.expanded(.setToast(.warning(nil, .string(message), position: .top)))))

                case .tooManyRunningJobs:
                    effects.append(.send(.expanded(.setToast(.warning(nil, .string(L10n.FeatureCreateClip.capacityTitle), position: .top)))))

                case .clientError, .serverError:
                    effects.append(.send(.expanded(.setToast(.warning(L10n.FeatureCreateClip.error, .string(apiError.localizedDescription), position: .top)))))

                case .invalidHCaptchaToken:
                    effects.append(.send(.expanded(.setToast(.warning(L10n.FeatureCreateClip.errorTitle, .string(L10n.FeatureCreateClip.tryAgain), position: .top)))))

                case .errorMessage(let message):
                    effects.append(.send(.expanded(.setToast(.warning(L10n.FeatureCreateClip.error, .string(message), position: .top)))))

                case .forbidden:
                    effects.append(.send(.expanded(.setToast(.warning(nil, .string(L10n.FeatureCreateClip.error), position: .top)))))
                }

                return .concatenate(effects)

            case .binding, .delegate, .player, .internal, .expanded, .compact, .omniplayerEvent, .clipEvents:
                // Catch-all
                return .none
            }
        }

        Analytics()
    }
}
