import Foundation import Combine import Moya final class CreatorChannelReplyDetailViewModel: ObservableObject { private let communityRepository = CreatorCommunityRepository() private let v2CommunityRepository = CreatorChannelCommunityRepository() private let explorerRepository = ExplorerRepository() private let fanTalkRepository = CreatorChannelFanTalkRepository() private var subscription = Set() private var context: CreatorChannelReplyDetailContext? private var onFanTalkRefresh: (() -> Void)? private var onCommunityRefresh: (() -> Void)? private var communityReplyPage = 0 private let communityReplyPageSize = 20 private var communityReplyTotalCount = 0 private var isCommunityReplyLast = false private let fanTalkReplyPageSize = 20 @Published var replyText = "" @Published var parentItem: CreatorChannelReplyDetailDisplayItem? @Published var replies = [CreatorChannelReplyDetailDisplayItem]() @Published var isLoading = false @Published var isLoadingNextPage = false @Published var errorMessage = "" @Published var isShowPopup = false @Published var selectedActionItem: CreatorChannelReplyDetailDisplayItem? @Published var editingItem: CreatorChannelReplyDetailDisplayItem? @Published var deletingItem: CreatorChannelReplyDetailDisplayItem? @Published var isShowDeleteDialog = false var isSendEnabled: Bool { replyText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false } var isEditing: Bool { editingItem != nil } func configure(context: CreatorChannelReplyDetailContext, onFanTalkRefresh: (() -> Void)?, onCommunityRefresh: (() -> Void)? = nil) { guard self.context == nil else { return } self.context = context self.onFanTalkRefresh = onFanTalkRefresh self.onCommunityRefresh = onCommunityRefresh switch context { case .community(_, _, let parentComment): parentItem = CreatorChannelReplyDetailDisplayItem(communityComment: parentComment) fetchCommunityReplies(reset: true) case .communityPostDetail(let creatorId, _, let parentComment): parentItem = CreatorChannelReplyDetailDisplayItem(communityPostDetail: parentComment, creatorId: creatorId) fetchCommunityReplies(reset: true) case .fanTalk(let creatorId, let parentFanTalk): parentItem = CreatorChannelReplyDetailDisplayItem(fanTalk: parentFanTalk) replies = parentFanTalk.creatorReplies.map { CreatorChannelReplyDetailDisplayItem(fanTalkReply: $0, creatorId: creatorId) } } } func canEdit(_ item: CreatorChannelReplyDetailDisplayItem) -> Bool { switch item.source { case .community: return item.isMine case .fanTalk: guard item.id > 0 else { return false } return item.isMine || item.isCreatorReply } } func canDelete(_ item: CreatorChannelReplyDetailDisplayItem) -> Bool { switch item.source { case .community: if item.isMine { return true } if case .community(let creatorId, _, _) = context { return creatorId == UserDefaults.int(forKey: .userId) } if case .communityPostDetail(let creatorId, _, _) = context { return creatorId == UserDefaults.int(forKey: .userId) } return false case .fanTalk: guard item.id > 0 else { return false } if item.isMine || item.isCreatorReply { return true } if case .fanTalk(let creatorId, _) = context { return creatorId == UserDefaults.int(forKey: .userId) } return false } } func selectActionItem(_ item: CreatorChannelReplyDetailDisplayItem) { selectedActionItem = selectedActionItem?.id == item.id ? nil : item } func beginEditing(item: CreatorChannelReplyDetailDisplayItem) { guard canEdit(item) else { return } selectedActionItem = nil editingItem = item replyText = item.content } func cancelEditing() { editingItem = nil replyText = "" } func presentDeleteDialog(item: CreatorChannelReplyDetailDisplayItem) { guard canDelete(item) else { return } selectedActionItem = nil deletingItem = item isShowDeleteDialog = true } func send() { guard isSendEnabled, isLoading == false else { return } if let editingItem { modify(item: editingItem) } else { createReply() } } func confirmDelete() { guard let deletingItem, isLoading == false else { return } isShowDeleteDialog = false switch deletingItem.source { case .community: deleteCommunity(item: deletingItem) case .fanTalk: deleteFanTalk(item: deletingItem) } } func fetchCommunityReplies(reset: Bool = false) { guard isCommunityContext else { return } guard isLoading == false, isLoadingNextPage == false else { return } if reset { communityReplyPage = isCommunityPostDetailContext ? 0 : 1 communityReplyTotalCount = 0 isCommunityReplyLast = false } else if isCommunityReplyLast { return } if reset { isLoading = true } else { isLoadingNextPage = true } let requestPage = communityReplyPage let publisher = communityRepliesPublisher(page: requestPage) publisher .sink { [weak self] result in guard let self else { return } if case .failure(let error) = result { ERROR_LOG(error.localizedDescription) self.applyFailureState() } } receiveValue: { [weak self] response in guard let self else { return } self.isLoading = false self.isLoadingNextPage = false self.handleCommunityRepliesResponse(response, requestPage: requestPage, reset: reset) } .store(in: &subscription) } func fetchNextCommunityRepliesIfNeeded(currentItem: CreatorChannelReplyDetailDisplayItem) { guard currentItem == replies.last else { return } fetchCommunityReplies() } private func createReply() { let content = replyText.trimmingCharacters(in: .whitespacesAndNewlines) switch context { case .community(_, let postId, let parentComment): isLoading = true communityRepository.createCommunityPostComment( comment: content, postId: postId, parentId: parentComment.id, isSecret: parentComment.isSecret ) .sink { [weak self] result in guard let self else { return } if case .failure(let error) = result { ERROR_LOG(error.localizedDescription) self.applyFailureState() } } receiveValue: { [weak self] response in self?.handleCommunityMutationResponse(response) } .store(in: &subscription) case .communityPostDetail(_, let postId, let parentComment): isLoading = true communityRepository.createCommunityPostComment( comment: content, postId: postId, parentId: parentComment.commentId, isSecret: false ) .sink { [weak self] result in guard let self else { return } if case .failure(let error) = result { ERROR_LOG(error.localizedDescription) self.applyFailureState() } } receiveValue: { [weak self] response in self?.handleCommunityMutationResponse(response) } .store(in: &subscription) case .fanTalk(let creatorId, let parentFanTalk): isLoading = true explorerRepository.writeCheers(parentCheersId: parentFanTalk.fanTalkId, creatorId: creatorId, content: content) .sink { [weak self] result in guard let self else { return } if case .failure(let error) = result { ERROR_LOG(error.localizedDescription) self.applyFailureState() } } receiveValue: { [weak self] response in self?.handleFanTalkMutationResponse(response) } .store(in: &subscription) case .none: return } } private func modify(item: CreatorChannelReplyDetailDisplayItem) { let content = replyText.trimmingCharacters(in: .whitespacesAndNewlines) guard content.isEmpty == false, content != item.content else { errorMessage = I18n.Explorer.noChanges isShowPopup = true return } switch item.source { case .community: isLoading = true var request = ModifyCommunityPostCommentRequest(commentId: item.id) request.comment = content communityRepository.modifyComment(request: request) .sink { [weak self] result in guard let self else { return } if case .failure(let error) = result { ERROR_LOG(error.localizedDescription) self.applyFailureState() } } receiveValue: { [weak self] response in self?.handleCommunityMutationResponse(response) } .store(in: &subscription) case .fanTalk: guard item.id > 0 else { return } isLoading = true explorerRepository.modifyCheers(cheersId: item.id, content: content, isActive: nil) .sink { [weak self] result in guard let self else { return } if case .failure(let error) = result { ERROR_LOG(error.localizedDescription) self.applyFailureState() } } receiveValue: { [weak self] response in self?.handleFanTalkMutationResponse(response) } .store(in: &subscription) } } private func deleteCommunity(item: CreatorChannelReplyDetailDisplayItem) { isLoading = true var request = ModifyCommunityPostCommentRequest(commentId: item.id) request.isActive = false communityRepository.modifyComment(request: request) .sink { [weak self] result in guard let self else { return } if case .failure(let error) = result { ERROR_LOG(error.localizedDescription) self.applyFailureState() } } receiveValue: { [weak self] response in self?.handleCommunityMutationResponse(response) } .store(in: &subscription) } private func deleteFanTalk(item: CreatorChannelReplyDetailDisplayItem) { guard item.id > 0 else { return } isLoading = true explorerRepository.modifyCheers(cheersId: item.id, content: nil, isActive: false) .sink { [weak self] result in guard let self else { return } if case .failure(let error) = result { ERROR_LOG(error.localizedDescription) self.applyFailureState() } } receiveValue: { [weak self] response in self?.handleFanTalkMutationResponse(response) } .store(in: &subscription) } private func fetchFanTalkReplies(page: Int = 0) { guard case .fanTalk(let creatorId, let parentFanTalk) = context else { return } fanTalkRepository.getFanTalks(creatorId: creatorId, page: page, size: fanTalkReplyPageSize) .sink { [weak self] result in guard let self else { return } if case .failure(let error) = result { ERROR_LOG(error.localizedDescription) self.applyFailureState() } } receiveValue: { [weak self] response in guard let self else { return } do { let decoded = try JSONDecoder().decode(ApiResponse.self, from: response.data) if let data = decoded.data, decoded.success { if let fanTalk = data.fanTalks.first(where: { $0.fanTalkId == parentFanTalk.fanTalkId }) { self.isLoading = false self.replies = fanTalk.creatorReplies.map { CreatorChannelReplyDetailDisplayItem(fanTalkReply: $0, creatorId: creatorId) } } else if data.hasNext { self.fetchFanTalkReplies(page: page + 1) } else { self.isLoading = false } } else { self.isLoading = false self.errorMessage = decoded.message ?? I18n.Common.commonError self.isShowPopup = true } } catch { ERROR_LOG(error.localizedDescription) self.isLoading = false self.errorMessage = I18n.Common.commonError self.isShowPopup = true } } .store(in: &subscription) } private func handleCommunityMutationResponse(_ response: Moya.Response) { isLoading = false do { let decoded = try JSONDecoder().decode(ApiResponseWithoutData.self, from: response.data) if decoded.success { applyLocalSuccessState() onCommunityRefresh?() fetchCommunityReplies(reset: true) } else { errorMessage = decoded.message ?? I18n.Common.commonError isShowPopup = true } } catch { ERROR_LOG(error.localizedDescription) errorMessage = I18n.Common.commonError isShowPopup = true } } private func handleFanTalkMutationResponse(_ response: Moya.Response) { isLoading = false do { let decoded = try JSONDecoder().decode(ApiResponseWithoutData.self, from: response.data) if decoded.success { applyLocalSuccessState() onFanTalkRefresh?() fetchFanTalkReplies() } else { errorMessage = decoded.message ?? I18n.Common.commonError isShowPopup = true } } catch { ERROR_LOG(error.localizedDescription) errorMessage = I18n.Common.commonError isShowPopup = true } } private func applyLocalSuccessState() { let trimmedReplyText = replyText.trimmingCharacters(in: .whitespacesAndNewlines) if let editingItem { let updatedItem = editingItem.updatingContent(trimmedReplyText) if parentItem?.id == editingItem.id && parentItem?.source == editingItem.source { parentItem = updatedItem } else if let index = replies.firstIndex(where: { $0.id == editingItem.id && $0.source == editingItem.source }) { replies[index] = updatedItem } } if let deletingItem { if parentItem?.id == deletingItem.id && parentItem?.source == deletingItem.source { AppState.shared.back() } else { replies.removeAll { $0.id == deletingItem.id && $0.source == deletingItem.source } } } replyText = "" editingItem = nil deletingItem = nil } private func applyFailureState() { isLoading = false isLoadingNextPage = false errorMessage = I18n.Common.commonError isShowPopup = true } private var isCommunityContext: Bool { if case .community = context { return true } if case .communityPostDetail = context { return true } return false } private var isCommunityPostDetailContext: Bool { if case .communityPostDetail = context { return true } return false } private func communityRepliesPublisher(page: Int) -> AnyPublisher { switch context { case .community(_, _, let parentComment): return communityRepository.getCommentReplyList(commentId: parentComment.id, page: page, size: communityReplyPageSize) case .communityPostDetail(_, _, let parentComment): return v2CommunityRepository.getCommunityCommentReplies(commentId: parentComment.commentId, page: page, size: communityReplyPageSize) default: return Fail(error: MoyaError.underlying(NSError(domain: "CreatorChannelReplyDetail", code: -1), nil)).eraseToAnyPublisher() } } private func handleCommunityRepliesResponse(_ response: Moya.Response, requestPage: Int, reset: Bool) { if isCommunityPostDetailContext { handleCommunityPostDetailRepliesResponse(response, requestPage: requestPage, reset: reset) } else { handleLegacyCommunityRepliesResponse(response, requestPage: requestPage, reset: reset) } } private func handleLegacyCommunityRepliesResponse(_ response: Moya.Response, requestPage: Int, reset: Bool) { do { let decoded = try JSONDecoder().decode(ApiResponse.self, from: response.data) if let data = decoded.data, decoded.success { let newReplies = data.items.map { CreatorChannelReplyDetailDisplayItem(communityComment: $0) } applyCommunityReplies(newReplies, requestPage: requestPage, reset: reset, totalCount: data.totalCount, hasNext: nil) } else { errorMessage = decoded.message ?? I18n.Common.commonError isShowPopup = true } } catch { ERROR_LOG(error.localizedDescription) errorMessage = I18n.Common.commonError isShowPopup = true } } private func handleCommunityPostDetailRepliesResponse(_ response: Moya.Response, requestPage: Int, reset: Bool) { do { let decoded = try JSONDecoder().decode(ApiResponse.self, from: response.data) if let data = decoded.data, decoded.success { let creatorId = contextCreatorId let newReplies = data.replies.map { CreatorChannelReplyDetailDisplayItem(communityPostDetailReply: $0, creatorId: creatorId) } applyCommunityReplies(newReplies, requestPage: requestPage, reset: reset, totalCount: data.replyCount, hasNext: data.hasNext) } else { errorMessage = decoded.message ?? I18n.Common.commonError isShowPopup = true } } catch { ERROR_LOG(error.localizedDescription) errorMessage = I18n.Common.commonError isShowPopup = true } } private var contextCreatorId: Int { switch context { case .community(let creatorId, _, _), .communityPostDetail(let creatorId, _, _), .fanTalk(let creatorId, _): return creatorId case .none: return 0 } } private func applyCommunityReplies(_ newReplies: [CreatorChannelReplyDetailDisplayItem], requestPage: Int, reset: Bool, totalCount: Int, hasNext: Bool?) { if reset || requestPage == (isCommunityPostDetailContext ? 0 : 1) { replies = newReplies } else { replies.append(contentsOf: newReplies) } communityReplyTotalCount = totalCount communityReplyPage = requestPage + 1 isCommunityReplyLast = hasNext.map { !$0 } ?? (replies.count >= totalCount || newReplies.isEmpty) } }