import APIClient
import AVFoundation
import CommentsClient
import ComponentLibrary
import ComposableArchitecture
import FeatureBrandedAlert
import FeatureCaptions
import FeatureToasts
import Localization
import OmniPlayerClient
import StatsigClient
import SwiftUI
import Utilities

@Reducer
public struct CommentsThread {
    @ObservableState
    public struct State: Equatable {
        var authorCaption: AuthorCaption?
        var authorUserHandle: String?
        var currentUser: User
        var clipID: ClipID
        var isClipAuthorCurrentUser: Bool
        var userCommentText: String = ""

        var userMentions: [CommentUserMention] = []
        var userMentionSearchSuggestions: [SimpleProfile] = []

        var elapsedTrackTime: CMTime
        var lockedTrackTime: CMTime? // locks upon user typing to prevent time drift

        @Shared(.inMemory(.commentsCountMap)) var commentsCountMap: ClipCommentTotalCountMap = .defaultValue
        @Shared(.inMemory(.commentsAccessMap)) var commentsAccessMap: ClipCommentAccessMap = .defaultValue

        var areCommentsEnabled: Bool {
            return commentsAccessMap.areCommentsEnabledOnClip(clipID.remoteId)
        }

        var pendingComments: [ClipComment] = []
        var comments: [ClipComment] = []

        // Local state for comment routing
        var replyToCommentID: String?

        /*
            If currentReplyParentCommentID is not nil
            it means we are currently replying to a specific
            comment with this parent ID.

            If this is nil it means we are just adding an
            unparented root comment.
         */
        var currentReplyParentCommentID: String?
        var replyCommentsMap: [String: [ClipComment]] = [:]
        var hasMoreRepliesMap: [String: Bool] = [:]
        var hasExpandedComment: [String: Bool] = [:] // Instead of state on item which apparently gets rest on liking a comment
        var totalCommentCount: ClipCommentTotalCountMap.CountStyle {
            return commentsCountMap.commentCountForClip(clipID.remoteId)
        }

        var hasCommentText: Bool {
            !userCommentText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
        }

        var commentTrackTime: Double {
            lockedTrackTime?.seconds ?? elapsedTrackTime.seconds
        }

        var replyRootComment: ClipComment? {
            return comments.first { $0.id == currentReplyParentCommentID }
        }

        var hasReplyComment: Bool {
            return replyRootComment != nil
        }

        var hasAuthorCaption: Bool {
            authorCaption != nil
        }

        var replyRootCommentUserDisplayName: String {
            guard let replyComment = replyRootComment else { return "" }
            return L10n.FeatureComments.replyTo(replyComment.userDisplayName)
        }

        var replyRootCommentContent: String {
            guard let replyComment = replyRootComment else { return "" }
            return replyComment.content
        }

        var replyParentCommentContent: String {
            return replyRootComment?.content ?? ""
        }

        var infiniteScrollLimit: Int {
            return comments.count - CommentsClient.infiniteScrollMarkerOffset
        }

        var isLoading: Bool {
            isRequestingComments && comments.isEmpty
        }

        var noCommentsAndNoAuthorCaption: Bool {
            comments.isEmpty && !hasAuthorCaption
        }

        /*
            Spamming reactions is rate limited on backend
            and this is also a mechanism to limit spamming requests
         */
        var isRequestingComments: Bool = false

        /*
            In order to limit how many retriggers to fetch next page
            are triggered we keep track of the last requesting index

            retriggers need to meet the requirements of CommentsClient
            && retriggers need to be higher than this at a minimum
         */
        var greatestRequestIndex: Int = .zero

        @ObservationStateIgnored @ObservedBox var toastState = ToastReducer.State()
        @ObservationStateIgnored @ObservedBox public var brandedAlert = BrandedAlert.State(style: .noAlert)

        public init(
            authorCaption: AuthorCaption? = nil,
            authorUserHandle: String? = nil,
            currentUser: User,
            clipID: ClipID,
            isClipAuthorCurrentUser: Bool,
            comments: [ClipComment] = [],
            replyCommentsMap: [String: [ClipComment]] = [:],
            elapsedTrackTime: CMTime = .zero
        ) {
            self.authorCaption = authorCaption
            self.authorUserHandle = authorUserHandle
            self.currentUser = currentUser
            self.clipID = clipID
            self.pendingComments = []
            self.comments = comments
            self.replyCommentsMap = replyCommentsMap
            self.currentReplyParentCommentID = nil
            self.greatestRequestIndex = .zero
            self.isClipAuthorCurrentUser = isClipAuthorCurrentUser
            self.elapsedTrackTime = elapsedTrackTime
            self.lockedTrackTime = nil
        }

        mutating func addTemporaryComment(_ content: String) -> String {
            let tempCommentID = "temp-\(UUID().uuidString)"
            pendingComments.append(.init(
                id: tempCommentID,
                clipID: clipID.remoteId,
                userID: currentUser.id,
                userDisplayName: currentUser.displayName ?? "",
                userAvatarUrl: currentUser.avatarImageUrl ?? "",
                userHandle: currentUser.handle,
                content: content,
                createdAt: .now,
                numLikes: .zero,
                numReports: .zero,
                parentID: nil,
                trackTimestamp: commentTrackTime,
                reactionType: nil,
                replyContinuationToken: nil,
                numReplies: .zero
            ))
            return tempCommentID
        }
    }

    public enum Action: BindableAction {
        case task
        case onAppear
        case toastAction(ToastReducer.Action)
        case brandedAlert(BrandedAlert.Action)

        case loadNextPageOfCommentsForClip
        case loadNextReplyPageOfComments(_ commentID: String)

        case openUserMentionSearch
        case onUserCommentTextUpdated(String)
        case onUserMentionSearchItemTapped(SimpleProfile)

        case postComment
        case didTapUserHandle(_ handle: String)
        case addSingleEmoji(String)
        case onItemAppeared(Int)
        case updateCommentReaction(
            _ commentID: String,
            _ reaction: CommentReactionType,
            _ incomingReactionCount: Int
        )
        case expandedComment(_ commentID: String)
        case reportComment(commentID: String, reason: ReportCommentRequest.Reason)
        case deleteComment(_ commentID: String)
        case setReplyTarget(_ commentID: String)
        case clearReplyTarget
        case tapCommentTrackTimestamp(_ trackTimestamp: TimeInterval)
        case timeSyncedCommentTapped(_ commentId: String)

        // On Event Bus Update From Comments Client
        case commentsClient(CommentsClientEvent)

        case delegate(Delegate)
        case binding(BindingAction<State>)
        case `internal`(Internal)

        case omniplayerEvent(OmniPlayerEvent)

        public enum Internal {
            case periodicTimeResponse(CMTime)
        }

        public enum Delegate {
            case dismiss
            /*
                We should separate delegate functions from those used by
                the reducer itself, this is not a duplicate.
             */
            case showUserProfile(_ handle: String)
        }
    }

    @Dependency(APIClient.self) private var apiClient
    @Dependency(\.commentsClient) var commentsClient
    @Dependency(\.commentsClient.eventBus) var commentsEventBus
    @Dependency(\.omniplayerClient.stream) var omniplayerStream
    @Dependency(\.omniplayerClient.seekToFromCommentTrackTimestamp) var omniplayerSeekToFromCommentTrackTimestamp

    public init() {}

    struct UserMentionSearchCancellableId: Hashable {}

    public var body: some ReducerOf<Self> {
        Scope(state: \.brandedAlert, action: \.brandedAlert) {
            BrandedAlert()
        }
        BindingReducer()
        Scope(state: \.toastState, action: \.toastAction) {
            ToastReducer()
        }
        Reduce { state, action in
            struct CommentsClientEventCancellableId: Hashable {}
            struct ElapsedTrackTimeCancellableId: Hashable {}

            switch action {
            case .task:
                return .merge(
                    .channel(
                        commentsEventBus("comments-thread"),
                        send: Action.commentsClient,
                        cancellableId: CommentsClientEventCancellableId()
                    ),
                    .stream(omniplayerStream(), send: Action.omniplayerEvent, cancellableId: ElapsedTrackTimeCancellableId())
                )

            case .omniplayerEvent(let event):
                switch event {
                case .playbackTimeUpdated(currentTime: let time):
                    return .send(.internal(.periodicTimeResponse(time)))
                default:
                    return .none
                }

            case .onAppear:
                state.isRequestingComments = true
                return .send(.loadNextPageOfCommentsForClip)

            case .onItemAppeared(let itemIndex):
                guard
                    !state.isRequestingComments,
                    itemIndex >= state.infiniteScrollLimit,
                    itemIndex > state.greatestRequestIndex
                else { return .none }
                state.greatestRequestIndex = itemIndex
                state.isRequestingComments = true
                return .send(.loadNextPageOfCommentsForClip)

            case .internal(.periodicTimeResponse(let time)):
                let didTimeChange = Int(time.seconds) != Int(state.elapsedTrackTime.seconds)
                guard didTimeChange else { return .none }
                state.elapsedTrackTime = time
                return .none

            case .didTapUserHandle(let handle):
                return .send(.delegate(.showUserProfile(handle)))

            case .loadNextPageOfCommentsForClip:
                state.isRequestingComments = true
                commentsClient.enqueueGetCommentsForClip(state.clipID)
                return .none

            case .loadNextReplyPageOfComments(let commentID):
                commentsClient.enqueueGetRepliesForComment(state.clipID, commentID)
                return .none

            case .addSingleEmoji(let emoji):
                guard state.userCommentText.count < 512 else { return .none }
                let commentWasEmpty = !state.hasCommentText
                state.userCommentText.append(emoji)
                lockTrackTimeIfNeeded(state: &state, when: commentWasEmpty)
                return .none

            case .expandedComment(let commentID):
                state.hasExpandedComment[commentID] = !(state.hasExpandedComment[commentID] ?? false)
                return .none

            case .clearReplyTarget:
                state.currentReplyParentCommentID = nil
                return .none

            case .setReplyTarget(let commentID):
                state.currentReplyParentCommentID = commentID
                lockTrackTimeIfNeeded(state: &state, when: state.hasCommentText)
                return .none

            case .openUserMentionSearch:
                let commentWasEmpty = !state.hasCommentText
                if state.userCommentText.isEmpty || state.userCommentText.last == " " {
                    state.userCommentText += "@"
                } else {
                    state.userCommentText += " @"
                }
                lockTrackTimeIfNeeded(state: &state, when: commentWasEmpty)
                return .none

            case .onUserCommentTextUpdated(let text):
                let commentWasEmpty = !state.hasCommentText
                if text.count > CommentsClient.commentLengthLimit {
                    state.userCommentText = String(text.prefix(CommentsClient.commentLengthLimit))
                } else {
                    state.userCommentText = text
                }
                let commentIsEmpty = !state.hasCommentText

                lockTrackTimeIfNeeded(state: &state, when: commentWasEmpty && !commentIsEmpty)
                unlockTrackTimeIfNeeded(state: &state, when: commentIsEmpty)

                // Clean up invalid mentions (remove from mentions list, but don't modify text)
                state.userMentions = state.userMentions.filter { isValidMention($0, in: text) }
                if let userHandleToFind = extractCurrentMention(from: text) {
                    return .run { [clipID = state.clipID] _ in
                        try await withTaskCancellation(id: UserMentionSearchCancellableId(), cancelInFlight: true) {
                            try await Task.sleep(for: .milliseconds(150)) // Debounce to avoid too many API calls
                            commentsClient.searchForUserByHandle(userHandleToFind, clipID)
                        }
                    }
                } else {
                    state.userMentionSearchSuggestions = []
                    return .none
                }

            case .binding(\.userCommentText):
                // iOS quickType/autocomplete bypasses .onChange and goes directly to .binding
                // Without this, timestamp locking would fail for autocomplete suggestions
                lockTrackTimeIfNeeded(state: &state, when: state.hasCommentText)
                unlockTrackTimeIfNeeded(state: &state, when: !state.hasCommentText)
                return .none

            case .onUserMentionSearchItemTapped(let user):
                let updatedCommentText = replacePendingMention(
                    from: state.userCommentText,
                    for: user,
                    mentions: &state.userMentions
                )
                return .send(.onUserCommentTextUpdated(updatedCommentText))

            case .postComment:
                guard state.hasCommentText else { return .none }
                let tempCommentID: String
                if let _ = state.currentReplyParentCommentID {
                    tempCommentID = ""
                } else {
                    tempCommentID = state.addTemporaryComment(state.userCommentText)
                }

                var mentionsDomain: [Mention] = []
                mentionsDomain = state.userMentions.map {
                    .init(end: $0.end, handle: $0.handle, start: $0.start)
                }

                commentsClient.enqueuePostCommentForClip(
                    state.clipID,
                    tempCommentID,
                    state.currentReplyParentCommentID,
                    state.commentTrackTime,
                    state.userCommentText,
                    mentionsDomain
                )
                state.currentReplyParentCommentID = nil
                state.userCommentText = ""
                state.userMentionSearchSuggestions = []
                state.userMentions = []

                unlockTrackTimeIfNeeded(state: &state, when: true)
                return .none

            case .updateCommentReaction(let commentID, let reaction, let incomingReactionCount):
                commentsClient.enqueueCommentReaction(state.clipID, commentID, reaction, incomingReactionCount)
                return .none

            case .reportComment(let commentID, let reason):
                commentsClient.enqueueReportComment(clipID: state.clipID, commentID: commentID, reportReason: reason)
                return .none

            case .deleteComment(let commentID):
                commentsClient.enqueueDeleteComment(state.clipID, commentID)
                return .none

            case .tapCommentTrackTimestamp(let trackTimestamp):
                let seekTime = CMTime(seconds: trackTimestamp, preferredTimescale: 1000)
                omniplayerSeekToFromCommentTrackTimestamp(seekTime)
                return .none

            case .timeSyncedCommentTapped(let commentId):
                if let existingIndex = state.comments.firstIndex(where: { $0.id == commentId }) {
                    // Move comment to top and set as reply target
                    let comment = state.comments.remove(at: existingIndex)
                    state.comments.insert(comment, at: 0)
                    state.currentReplyParentCommentID = commentId
                    return .none
                } else {
                    // Comment not loaded, fetch it
                    state.replyToCommentID = commentId
                    state.isRequestingComments = true
                    commentsClient.enqueueGetCommentForClip(state.clipID, commentId)
                    return .none
                }

            case .commentsClient(let event):
                /*
                    Do not trigger the following actions here
                    case .loadNextPageOfCommentsForClip
                    case .postComment
                    case .updateCommentReaction

                    These will cause infinite recursion
                 */
                switch event {
                case .didUpdateComments(let commentsThreadMap):
                    state.isRequestingComments = false
                    if let commentThread = commentsThreadMap.threadForClipID(state.clipID) {
                        var newComments = commentThread.allSortedUnparentedComments
                        state.replyCommentsMap = commentThread.allSortedRepliesForComment
                        state.hasMoreRepliesMap = commentThread.hasMoreRepliesMap

                        // Handle deep-linked comment positioning
                        if let replyTargetId = state.replyToCommentID,
                           let targetComment = newComments.first(where: { $0.id == replyTargetId })
                        {
                            // Move target comment to top for visibility
                            newComments.removeAll { $0.id == replyTargetId }
                            newComments.insert(targetComment, at: 0)

                            state.currentReplyParentCommentID = replyTargetId
                            state.replyToCommentID = nil
                        } else if let currentReplyId = state.currentReplyParentCommentID,
                                  let currentReplyComment = newComments.first(where: { $0.id == currentReplyId })
                        {
                            // Preserve existing reply target at top during background updates
                            newComments.removeAll { $0.id == currentReplyId }
                            newComments.insert(currentReplyComment, at: 0)
                        }
                        state.comments = newComments
                    }
                    return .none

                case .didReportComment:
                    return .send(.toastAction(
                        .show(.success(
                            "",
                            .string(L10n.FeatureComments.commentReported),
                            position: .bottom,
                            destination: nil,
                            trailingView: .dismiss
                        ))
                    ))

                case .didResolveTemporaryComment(let commentID):
                    state.pendingComments.removeAll { $0.id == commentID }
                    return .none

                case .didUpdateSuggestedUserMentions(let matchingUsers):
                    state.userMentionSearchSuggestions = matchingUsers
                    return .none

                case let .didReceiveAPIToastingError(userVisibleString):
                    return .send(
                        .toastAction(.show(ToastReducer.State.ToastType.warning(userVisibleString)))
                    )

                case let .didReceiveAPIModalError(userVisibleString):
                    return .send(
                        .brandedAlert(.setStyle(
                            .singleButtonAlert(.custom(SingleButtonAlertStyle.TextCopy(
                                title: L10n.FeatureComments.jailTitle,
                                description: userVisibleString,
                                buttonLabel: L10n.FeatureComments.jailButtonTitle
                            )
                            ))
                        ))
                    )
                }

            case .toastAction,
                 .brandedAlert,
                 .delegate,
                 .binding:
                /* Catch All */
                return .none
            }
        }
    }
}
