import APIClient
import AsyncAlgorithms
import ComposableArchitecture
import Foundation
import Localization

@DependencyClient
public struct CommentsClient {
    /*
        These should be changed together

        infiniteScrollMarkerOffset is how many from the last
        element in the loaded comments should I retrigger a request

        So if I loaded 2 pages of a thread that has 1000 comments at a page size of 25 comments per page
        with an infiniteScrollMarkerOffset of 5

        the appearing index that would make cause a retrigger would be
        (<loaded-page-count> * <pageSize>) - <infiniteScrollMarkerOffset> = <trigger-index>
        (2 * 25) - 5 = 45

        * Note *
        requests are only made once per reaching trigger so if you reach the trigger
        and there are no more pages to load the loadPage function will not be retriggered
        if the trigger is hit again

     */
    public static let infiniteScrollMarkerOffset: Int = 5
    public static let pageSize: Int = 20
    public static let replyPageSize: Int = 10
    public static let commentLengthLimit: Int = 512

    public var eventBus: (_ busID: String) -> AsyncChannel<CommentsClientEvent> = { _ in AsyncChannel() }
    public var setCommentsActivationState: (_ state: CommentsActivationState) -> Void
    public var setCommentsCount: (_ count: Int, _ clipID: Clip.ID) -> Void

    // Responses for requests are triggered through AsyncChannel
    public var enqueueGetCommentsForClip: (_ clipID: Clip.ID) -> Void
    public var enqueuePostCommentForClip: (
        _ clipID: Clip.ID,
        _ temporaryCommentID: String,
        _ parentCommentID: String?,
        _ trackTimestamp: Double?,
        _ content: String,
        _ userMentions: [Mention]
    ) -> Void
    public var enqueueCommentReaction: (
        _ clipID: Clip.ID,
        _ commentID: String,
        _ reaction: CommentReactionType,
        _ incomingReactionCount: Int
    ) -> Void
    public var enqueueDeleteComment: (_ clipID: Clip.ID, _ commentID: String) -> Void
    public var enqueueReportComment: (_ clipID: Clip.ID, _ commentID: String, _ reportReason: ReportCommentRequest.Reason) -> Void
    public var enqueueGetRepliesForComment: (_ clipID: Clip.ID, _ commentID: String) -> Void
    public var enqueueSetCommentsAccessOnClip: (_ clipID: Clip.ID, _ canComment: Bool) -> Void
    public var enqueueGetCommentForClip: (_ clipID: Clip.ID, _ commentID: String) -> Void

    public var searchForUserByHandle: (_ handle: String, _ clipID: Clip.ID) -> Void
}

extension CommentsClient: DependencyKey {
    public static var liveValue: CommentsClient {
        @Dependency(APIClient.self) var apiClient
        @Dependency(APIClientV2.self) var apiClientV2

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

        /**
            Note:
            Potentially better to use AsyncStream here.

            AsyncChannel is getting cancelled when subscribed to from multiple contexts.
            This keyed channel map is an attempt to subscribe to channels without cancelling in alternate contexts.
         */

        var eventBusMap: [String: AsyncChannel<CommentsClientEvent>] = [:]
        func sendEventToAllChannels(_ event: CommentsClientEvent) async {
            for (_, channel) in eventBusMap {
                await channel.send(event)
            }
        }

        let commentsCache = ClipCommentsCache()
        let pageSize = CommentsClient.pageSize
        let sortOrder = CommentsPage.CommentsSortOrder.newest

        func _internal_sendCommentsValueUpdateEvent() async {
            let commentsCacheValue = await commentsCache.getCurrentCommentsCache()
            let newAccessMap = await commentsCache.getCurrentAccessCache()
            let newCountMap = await commentsCache.getCurrentCountCache()
            Task { @MainActor in
                $commentsAccessMap.withLock { $0 = newAccessMap }
                $commentsCountMap.withLock { $0 = newCountMap }
                await sendEventToAllChannels(.didUpdateComments(commentsCacheValue))
            }
        }

        func _internal_updateCacheWithCommentCountsWithoutSendingEvent(_ clipID: ClipID) async throws {
            let clipRemoteID = clipID.remoteId
            let commentCount = try await apiClient.totalCommentCountOnClip(clipID)
            await commentsCache.updateCountCacheWithClipID(clipRemoteID, commentCount: commentCount.count)
        }

        func _internal_forceCommentCountsWithoutSendingEvent(_ clipID: ClipID, _ forcedCount: Int) async throws {
            let clipRemoteID = clipID.remoteId
            await commentsCache.updateCountCacheWithClipID(clipRemoteID, commentCount: forcedCount)
        }

        return Self(
            eventBus: { busID in
                let bus = eventBusMap[busID] ?? AsyncChannel<CommentsClientEvent>()
                eventBusMap[busID] = bus
                return bus
            },
            setCommentsActivationState: { newState in
                Task { @MainActor in
                    $commentsActivationState.withLock { $0 = newState }
                }
            },
            setCommentsCount: { count, clipID in
                Task { @MainActor in
                    do {
                        try await _internal_forceCommentCountsWithoutSendingEvent(clipID, count)
                    } catch {
                        log.telemetry.error(error)
                    }
                }
            },
            enqueueGetCommentsForClip: { clipID in
                let clipRemoteID = clipID.remoteId
                Task {
                    do {
                        try await withThrowingTaskGroup(of: Void.self) { group in
                            /* Get Comments */
                            group.addTask {
                                let pageCursor = await commentsCache.getCommentsCursorForClip(clipRemoteID)
                                let page = try await apiClient.getCommentsForClip(clipID, pageCursor, pageSize, sortOrder)
                                await commentsCache.updateCacheWithCommentsPage(clipRemoteID: clipRemoteID, page: page)
                            }

                            /* Get Comments Count */
                            group.addTask {
                                try await _internal_updateCacheWithCommentCountsWithoutSendingEvent(clipID)
                            }

                            /* Await for both tasks to complete */
                            try await group.waitForAll()
                        }

                        await _internal_sendCommentsValueUpdateEvent()
                    } catch {
                        log.telemetry.error(error)
                    }
                }
            },
            enqueuePostCommentForClip: { clipID, temporaryCommentID, parentCommentID, trackTimestamp, userCommentText, userMentions in
                let clipRemoteID = clipID.remoteId
                Task {
                    do {
                        let request = PostCommentRequest(
                            content: userCommentText,
                            parentId: parentCommentID,
                            trackTimestamp: trackTimestamp,
                            userMentions: userMentions
                        )
                        let postedComment = try await apiClientV2.postCommentToClip(clipID, request)
                        await commentsCache.addNewCommentToCache(
                            clipRemoteID: clipRemoteID,
                            newComment: ClipComment.fromAPIV2(postedComment)
                        )
                    } catch {
                        /// Rollback the pending comment by deleting it
                        await sendEventToAllChannels(.didResolveTemporaryComment(temporaryCommentID))

                        /// inspect the error to show a toast, letting the user know why the comment didn't post
                        switch error {
                        case let apiError as APIError:
                            switch apiError {
                            case .forbidden:
                                // 403 is in comment jail. show the full blocking modal
                                await sendEventToAllChannels(CommentsClientEvent.didReceiveAPIModalError(apiError.errorDetail ?? L10n.FeatureComments.jailDescription))
                            default:
                                await sendEventToAllChannels(CommentsClientEvent.didReceiveAPIToastingError(apiError.errorDetail ?? L10n.FeatureComments.commentPostErrorGeneric))
                            }

                        default:
                            await sendEventToAllChannels(CommentsClientEvent.didReceiveAPIToastingError(L10n.FeatureComments.commentPostErrorGeneric))
                            log.telemetry.error(error, message: "unhandled error on comment posting.")
                        }

                        /// Bail out the rest of the comment sync handling
                        return
                    }

                    do {
                        try await _internal_updateCacheWithCommentCountsWithoutSendingEvent(clipID)
                    } catch {
                        log.telemetry.error(error, message: "unhandled error on comment posting. couldn't get the comment count?")
                    }
                    await sendEventToAllChannels(.didResolveTemporaryComment(temporaryCommentID))
                    await _internal_sendCommentsValueUpdateEvent()
                }
            },
            enqueueCommentReaction: { clipID, commentID, reactionType, incomingReactionType in
                let clipRemoteID = clipID.remoteId
                Task {
                    do {
                        /*
                            Strategy is:
                            Assume success and only change to failure if actually failed
                         */

                        /* Immediately update */
                        await commentsCache.updateReactionOnCommentInCache(
                            clipRemoteID: clipRemoteID,
                            commentID: commentID,
                            reaction: reactionType,
                            numLikes: reactionType.countValueDelta + incomingReactionType
                        )
                        await _internal_sendCommentsValueUpdateEvent()

                        /* Attempt to update remote value */
                        let postedComment = try await apiClient.updateCommentReaction(
                            commentID,
                            .init(reaction: reactionType)
                        )

                        /* Update using remote value */
                        await commentsCache.updateReactionOnCommentInCache(
                            clipRemoteID: clipRemoteID,
                            commentID: postedComment.id,
                            reaction: postedComment.reaction,
                            numLikes: postedComment.numLikes
                        )

                        await _internal_sendCommentsValueUpdateEvent()

                    } catch {
                        log.telemetry.error(error)
                    }
                }
            },
            enqueueDeleteComment: { clipID, commentID in
                let clipRemoteID = clipID.remoteId
                Task {
                    let deletedCommentResponse = try await apiClient.deleteComment(commentID: commentID)
                    await commentsCache.deleteCommentInCache(
                        clipRemoteID: clipRemoteID,
                        commentID: deletedCommentResponse.id
                    )
                    try await _internal_updateCacheWithCommentCountsWithoutSendingEvent(clipID)
                    await _internal_sendCommentsValueUpdateEvent()
                }
            },
            enqueueReportComment: { clipID, commentID, reportReason in
                let clipRemoteID = clipID.remoteId
                Task {
                    do {
                        _ = try await apiClient.reportComment(commentID: commentID, body: .init(reason: reportReason.rawValue))
                        let reportedComment = await commentsCache.getComment(clipRemoteID: clipRemoteID, commentID: commentID)
                        guard let reportedComment else { return }
                        await sendEventToAllChannels(.didReportComment(reportedComment))
                    } catch {
                        log.telemetry.error(error)
                    }
                }
            },
            enqueueGetRepliesForComment: { clipID, commentID in
                let clipRemoteID = clipID.remoteId
                Task {
                    do {
                        let pageCursor = await commentsCache.getReplyCursorForComment(commentID, clipRemoteID: clipRemoteID)
                        var page = try await apiClient.getRepliesForComment(
                            commentID: commentID,
                            cursor: pageCursor,
                            page_size: replyPageSize
                        )
                        page.updatePageWithClipAndParentID(clipRemoteID, parentID: commentID)
                        await commentsCache.updateCacheWithRepliesPage(clipRemoteID, commentID: commentID, page: page)
                        await _internal_sendCommentsValueUpdateEvent()
                    } catch {
                        log.telemetry.error(error)
                    }
                }
            },
            enqueueSetCommentsAccessOnClip: { clipID, canComment in
                let clipRemoteID = clipID.remoteId
                Task {
                    let toggleResponse = try await apiClient.toggleCommentAbilityOnClip(
                        clipID: clipID, body: .init(clipID: clipRemoteID, canComment: canComment)
                    )

                    await commentsCache.updateCommentAbilityOnClip(
                        clipRemoteID,
                        canComment: toggleResponse.canComment
                    )

                    await _internal_sendCommentsValueUpdateEvent()
                }
            },
            enqueueGetCommentForClip: { clipID, commentID in
                Task {
                    let clipRemoteID = clipID.remoteId

                    // Check if comment already exists
                    let existingComment = await commentsCache.getComment(clipRemoteID: clipRemoteID, commentID: commentID)
                    if existingComment != nil {
                        // Comment already cached, just trigger update event to reposition in UI
                        await _internal_sendCommentsValueUpdateEvent()
                        return
                    }

                    // Fetch comment and the first page of replies
                    do {
                        async let commentTask = apiClientV2.getCommentForClip(clipID, commentID)
                        async let repliesTask: [ClipComment] = {
                            do {
                                let repliesResponse = try await apiClient.getRepliesForComment(commentID, nil, 10)
                                return repliesResponse.replies
                            } catch {
                                log.telemetry.assertionFailure("Failed to fetch replies for \(commentID)")
                                return []
                            }
                        }()
                        let (targetComment, replies) = try await(commentTask, repliesTask)

                        // Add comment and replies to cache
                        await commentsCache.addNewCommentToCache(clipRemoteID: clipRemoteID, newComment: targetComment)
                        for reply in replies {
                            await commentsCache.addNewCommentToCache(clipRemoteID: clipRemoteID, newComment: reply)
                        }
                        await _internal_sendCommentsValueUpdateEvent()
                    } catch {
                        log.telemetry.assertionFailure("Failed to fetch comment \(commentID)")
                    }
                }
            },
            searchForUserByHandle: { handle, clipID in
                Task {
                    do {
                        let usersInThread = await commentsCache.getUsersInThread(clipID.remoteId)

                        let matchingUsers = try await apiClientV2.searchUsers(
                            usersInThread.isEmpty ? nil : usersInThread,
                            nil,
                            handle
                        )
                        await sendEventToAllChannels(.didUpdateSuggestedUserMentions(matchingUsers))
                    } catch {
                        log.telemetry.error(error)
                    }
                }
            }
        )
    }
}

public extension DependencyValues {
    var commentsClient: CommentsClient {
        get { self[CommentsClient.self] }
        set { self[CommentsClient.self] = newValue }
    }
}
