import AppKit import SwiftUI @MainActor @Observable final class CommentWriterViewModel { var draft = CommentDraft() var selectedTab: CommentWriterTab = .compose var isGenerating = false var errorMessage: String? var successMessage: String? var variants: [CommentVariant] = [] var selectedVariantID: UUID? var showOverwriteConfirmation = false private let generationService: any CommentGenerationServiceProtocol private let emptyDraft = CommentDraft() init(generationService: (any CommentGenerationServiceProtocol)? = nil) { self.generationService = generationService ?? CommentGenerationServiceFactory.makeDefault() } var formattedSubreddit: String { let name = PostDraftValidator.normalizedSubreddit(draft.subreddit) guard !name.isEmpty else { return "r/subreddit" } return "r/\(name)" } var usesLiveAI: Bool { AIConfiguration.usesLiveAI } var hasDraftChanges: Bool { draft != emptyDraft } var canGenerate: Bool { CommentDraftValidator.canGenerate(from: draft, isGenerating: isGenerating) } var canExport: Bool { let body = activeCommentBody.trimmingCharacters(in: .whitespaces) guard !body.isEmpty, body.count <= CommentDraftValidator.maxCommentLength else { return false } return (try? CommentDraftValidator.validateForGeneration(from: draft)) != nil } var activeCommentBody: String { if let selectedID = selectedVariantID, let variant = variants.first(where: { $0.id == selectedID }) { return variant.body } return draft.body } var bestVariant: CommentVariant? { variants.max(by: { $0.score < $1.score }) } var needsOverwriteConfirmation: Bool { !draft.body.trimmingCharacters(in: .whitespaces).isEmpty } func selectCommentType(_ type: CommentType) { draft.commentType = type if type == .topLevel { draft.parentComment = "" } clearMessages() } func selectVariant(_ variant: CommentVariant) { selectedVariantID = variant.id clearMessages() } func applyVariant(_ variant: CommentVariant) { draft.body = variant.body selectedVariantID = variant.id successMessage = "Variant applied." errorMessage = nil } func generateComment() async { guard canGenerate else { return } if needsOverwriteConfirmation { showOverwriteConfirmation = true return } await performGenerate(replaceExisting: true) } func confirmOverwriteAndGenerate() async { showOverwriteConfirmation = false await performGenerate(replaceExisting: true) } func cancelOverwriteConfirmation() { showOverwriteConfirmation = false } func copyToClipboard() { let content = activeCommentBody.trimmingCharacters(in: .whitespaces) guard !content.isEmpty else { errorMessage = CommentDraftValidationError.emptyComment.localizedDescription successMessage = nil return } guard content.count <= CommentDraftValidator.maxCommentLength else { errorMessage = CommentDraftValidationError.commentTooLong.localizedDescription successMessage = nil return } NSPasteboard.general.clearContents() NSPasteboard.general.setString(content, forType: .string) successMessage = "Comment copied to clipboard." errorMessage = nil } func openInReddit(openInApp: (URL) -> Void) { let urlString = draft.postURL.trimmingCharacters(in: .whitespaces) guard !urlString.isEmpty else { errorMessage = "Add a Reddit post URL to open the thread." successMessage = nil return } guard CommentDraftValidator.isValidRedditURL(urlString) else { errorMessage = CommentDraftValidationError.invalidPostURL.localizedDescription successMessage = nil return } guard let url = URL(string: urlString) else { return } openInApp(url) successMessage = "Opened thread in Reddit. Paste your comment when ready." errorMessage = nil } func resetDraft() { draft = CommentDraft() selectedTab = .compose variants = [] selectedVariantID = nil clearMessages() } func clearMessages() { errorMessage = nil successMessage = nil } func notifyDraftEdited() { clearMessages() } private func performGenerate(replaceExisting: Bool) async { isGenerating = true errorMessage = nil successMessage = nil do { let result = try await generationService.generateComment(from: draft) if replaceExisting || draft.body.trimmingCharacters(in: .whitespaces).isEmpty { draft.body = String(result.body.prefix(CommentDraftValidator.maxCommentLength)) } variants = result.variants selectedVariantID = result.variants.first?.id selectedTab = .preview let engine = usesLiveAI ? "OpenAI" : "local AI templates" successMessage = "Generated comment with \(result.variants.count) variants using \(engine)." } catch { errorMessage = error.localizedDescription } isGenerating = false } }