import Foundation protocol CommentGenerationServiceProtocol: Sendable { func generateComment(from draft: CommentDraft) async throws -> GeneratedComment } enum CommentGenerationServiceFactory { static func makeDefault() -> any CommentGenerationServiceProtocol { if let apiKey = AIConfiguration.apiKey { return OpenAICommentGenerationService(apiKey: apiKey) } return MockCommentGenerationService() } } enum CommentGenerationError: LocalizedError { case emptySubreddit case invalidSubreddit case emptyPostTitle case emptyParentComment case apiUnavailable(String) case invalidResponse var errorDescription: String? { switch self { case .emptySubreddit: "Enter a subreddit to tailor the comment." case .invalidSubreddit: "Subreddit names must be 3–21 characters and use only letters, numbers, or underscores." case .emptyPostTitle: "Enter the post title for thread context." case .emptyParentComment: "Paste the parent comment you're replying to." case .apiUnavailable(let message): message case .invalidResponse: "The AI returned an unexpected response. Please try again." } } } // MARK: - OpenAI struct OpenAICommentGenerationService: CommentGenerationServiceProtocol { let apiKey: String var model: String = "gpt-4o-mini" func generateComment(from draft: CommentDraft) async throws -> GeneratedComment { try CommentDraftValidator.validateForGeneration(from: draft) let subreddit = PostDraftValidator.normalizedSubreddit(draft.subreddit) let prompt = buildPrompt(draft: draft, subreddit: subreddit) var request = URLRequest(url: URL(string: "https://api.openai.com/v1/chat/completions")!) request.httpMethod = "POST" request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") request.setValue("application/json", forHTTPHeaderField: "Content-Type") let body: [String: Any] = [ "model": model, "temperature": 0.85, "response_format": ["type": "json_object"], "messages": [ [ "role": "system", "content": """ You write authentic Reddit comments. Respond with JSON only using keys: body (string, primary comment, max 10000 chars), variants (array of objects with body, score 0-100, reasoning strings). Generate exactly \(draft.variantCount.rawValue) variants ranked by fit. Write like a real Redditor: conversational, community-aware, no corporate tone. Use markdown when requested. Never start with "As an AI" or similar. Match subreddit culture. Be respectful even when disagreeing. """, ], ["role": "user", "content": prompt], ], ] request.httpBody = try JSONSerialization.data(withJSONObject: body) let (data, response) = try await URLSession.shared.data(for: request) guard let http = response as? HTTPURLResponse else { throw CommentGenerationError.apiUnavailable("Couldn't reach the AI service.") } if http.statusCode == 401 { throw CommentGenerationError.apiUnavailable("Invalid OpenAI API key.") } guard (200 ... 299).contains(http.statusCode) else { let message = String(data: data, encoding: .utf8) ?? "HTTP \(http.statusCode)" throw CommentGenerationError.apiUnavailable("AI request failed: \(message)") } return try parseResponse(data) } private func buildPrompt(draft: CommentDraft, subreddit: String) -> String { var lines = [ "Subreddit: r/\(subreddit)", "Comment type: \(draft.commentType.title)", "Post title: \(draft.postTitle.trimmingCharacters(in: .whitespaces))", "Intent: \(draft.intent.title) — \(draft.intent.subtitle)", "Tone: \(draft.tone.title)", "Length: \(draft.length.title) (\(draft.length.wordRange))", "Markdown: \(draft.useMarkdown ? "yes" : "plain text only")", ] if !draft.topic.trimmingCharacters(in: .whitespaces).isEmpty { lines.append("Focus / angle: \(draft.topic.trimmingCharacters(in: .whitespaces))") } if !draft.postBody.trimmingCharacters(in: .whitespaces).isEmpty { lines.append("Post excerpt: \(draft.postBody.trimmingCharacters(in: .whitespaces))") } if draft.commentType == .reply { lines.append("Parent comment to reply to: \(draft.parentComment.trimmingCharacters(in: .whitespaces))") if draft.includeQuote { lines.append("Optionally quote relevant part of parent with > blockquote.") } } return lines.joined(separator: "\n") } private func parseResponse(_ data: Data) throws -> GeneratedComment { struct APIEnvelope: Decodable { struct Choice: Decodable { struct Message: Decodable { let content: String } let message: Message } let choices: [Choice] } struct VariantPayload: Decodable { let body: String let score: Int let reasoning: String } struct Payload: Decodable { let body: String let variants: [VariantPayload] } let envelope = try JSONDecoder().decode(APIEnvelope.self, from: data) guard let content = envelope.choices.first?.message.content.data(using: .utf8) else { throw CommentGenerationError.invalidResponse } let payload = try JSONDecoder().decode(Payload.self, from: content) let body = String(payload.body.prefix(CommentDraftValidator.maxCommentLength)) let variants = payload.variants.map { CommentVariant( body: String($0.body.prefix(CommentDraftValidator.maxCommentLength)), score: min(100, max(0, $0.score)), reasoning: $0.reasoning ) } guard !body.isEmpty else { throw CommentGenerationError.invalidResponse } return GeneratedComment(body: body, variants: variants) } } // MARK: - Mock struct MockCommentGenerationService: CommentGenerationServiceProtocol { func generateComment(from draft: CommentDraft) async throws -> GeneratedComment { try CommentDraftValidator.validateForGeneration(from: draft) try await Task.sleep(for: .milliseconds(900)) let subreddit = PostDraftValidator.normalizedSubreddit(draft.subreddit) let postTitle = draft.postTitle.trimmingCharacters(in: .whitespaces) let body = makePrimaryComment(draft: draft, subreddit: subreddit, postTitle: postTitle) let variants = makeVariants(draft: draft, subreddit: subreddit, postTitle: postTitle) return GeneratedComment(body: body, variants: variants) } private func makePrimaryComment(draft: CommentDraft, subreddit: String, postTitle: String) -> String { let opener: String = switch draft.commentType { case .topLevel: "Great post — " case .reply: draft.includeQuote && !draft.parentComment.isEmpty ? "> \(draft.parentComment.prefix(120))\(draft.parentComment.count > 120 ? "…" : "")\n\n" : "" } let core: String = switch draft.intent { case .addValue: """ I've been lurking r/\(subreddit) for a while and this is one of the better takes on **\(postTitle)** I've seen. A few things that worked for me: - Start small and iterate — don't overthink the first step - The community wiki/FAQ is underrated - Happy to share more detail if anyone's interested Solid discussion thread. """ case .agree: """ 100% agree. This matches my experience with \(postTitle.lowercased()) and I'm glad someone finally said it clearly. The part about taking it one step at a time really resonates. Thanks for posting this to r/\(subreddit). """ case .disagree: """ Respectfully disagree on one point here. I think the common advice around \(postTitle.lowercased()) oversimplifies things. In my experience the nuance matters a lot — context, timing, and individual circumstances change the answer. Not trying to be contrarian, just adding another data point from someone who's been through it. """ case .question: """ Genuine question — when you say this applies broadly, do you mean for beginners or everyone in r/\(subreddit)? I've had mixed results depending on setup. Would love to hear what worked for others on **\(postTitle)**. """ case .funny: """ r/\(subreddit) never disappoints. Read the title, thought "yeah same," then read the post and now I'm questioning every life choice. Saving this for the next time someone asks about \(postTitle.lowercased()). 😅 """ case .empathetic: """ Thanks for sharing this — it's hard to post about \(postTitle.lowercased()) and I appreciate the honesty. You're not alone in feeling this way. Took me a long time to figure out and the r/\(subreddit) community helped more than I expected. """ case .technical: """ For anyone looking for the technical breakdown on **\(postTitle)**: 1. **Root cause** — usually configuration or environment mismatch 2. **Fix** — verify prerequisites first, then apply the minimal change 3. **Verify** — reproduce the original issue and confirm it's resolved Happy to go deeper in a follow-up if useful. """ case .summary: """ **TL;DR:** \(postTitle) — main takeaway is to start simple, validate early, and lean on community resources in r/\(subreddit). Worth a read if you're new to this. """ } let trimmed = adjustLength(core, length: draft.length) return String((opener + trimmed).prefix(CommentDraftValidator.maxCommentLength)) } private func makeVariants(draft: CommentDraft, subreddit: String, postTitle: String) -> [CommentVariant] { let base = makePrimaryComment(draft: draft, subreddit: subreddit, postTitle: postTitle) let altIntents: [CommentIntent] = [.agree, .question, .summary, .addValue, .funny] .filter { $0 != draft.intent } .prefix(draft.variantCount.rawValue - 1) .map { $0 } var variants: [CommentVariant] = [ CommentVariant( body: base, score: 92, reasoning: "Best match for \(draft.intent.title) intent and \(draft.tone.title.lowercased()) tone." ), ] for (index, intent) in altIntents.enumerated() { var altDraft = draft altDraft.intent = intent let body = makePrimaryComment(draft: altDraft, subreddit: subreddit, postTitle: postTitle) variants.append( CommentVariant( body: body, score: 88 - index * 4, reasoning: "Alternative \(intent.title.lowercased()) angle for r/\(subreddit)." ) ) } return Array(variants.prefix(draft.variantCount.rawValue)) } private func adjustLength(_ text: String, length: CommentLength) -> String { switch length { case .short: let sentences = text.components(separatedBy: ". ") return sentences.prefix(2).joined(separator: ". ") + (sentences.count > 1 ? "." : "") case .medium: return text case .long: return text + "\n\nEdit: thanks for the awards kind strangers — didn't expect this to blow up." } } }