import Foundation protocol PostGenerationServiceProtocol: Sendable { func generatePost(from draft: PostDraft) async throws -> GeneratedPost } enum PostGenerationServiceFactory { static func makeDefault() -> any PostGenerationServiceProtocol { if let apiKey = AIConfiguration.apiKey { return OpenAIPostGenerationService(apiKey: apiKey) } return MockPostGenerationService() } } enum PostGenerationError: LocalizedError { case emptyTopic case emptySubreddit case invalidSubreddit case apiUnavailable(String) case invalidResponse var errorDescription: String? { switch self { case .emptyTopic: "Enter a topic or keywords for AI generation." case .emptySubreddit: "Enter a subreddit to tailor the post." case .invalidSubreddit: "Subreddit names must be 3–21 characters and use only letters, numbers, or underscores." case .apiUnavailable(let message): message case .invalidResponse: "The AI returned an unexpected response. Please try again." } } } // MARK: - OpenAI struct OpenAIPostGenerationService: PostGenerationServiceProtocol { let apiKey: String var model: String = "gpt-4o-mini" func generatePost(from draft: PostDraft) async throws -> GeneratedPost { try PostDraftValidator.validateForGeneration(topic: draft.topic, subreddit: draft.subreddit) let subreddit = PostDraftValidator.normalizedSubreddit(draft.subreddit) let topic = draft.topic.trimmingCharacters(in: .whitespaces) let prompt = buildPrompt(draft: draft, subreddit: subreddit, topic: topic) 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.8, "response_format": ["type": "json_object"], "messages": [ [ "role": "system", "content": """ You write Reddit posts. Respond with JSON only using keys: title (string, max 300 chars), body (string), suggestedFlair (string or null), \ pollOptions (array of 2-6 strings when post type is poll, otherwise null). Respect NSFW, spoiler, and OC tags. Match the requested tone and post type. """, ], ["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 PostGenerationError.apiUnavailable("Couldn't reach the AI service.") } if http.statusCode == 401 { throw PostGenerationError.apiUnavailable("Invalid OpenAI API key. Check AIConfiguration or OPENAI_API_KEY.") } guard (200 ... 299).contains(http.statusCode) else { let message = String(data: data, encoding: .utf8) ?? "HTTP \(http.statusCode)" throw PostGenerationError.apiUnavailable("AI request failed: \(message)") } return try parseResponse(data, postType: draft.postType) } private func buildPrompt(draft: PostDraft, subreddit: String, topic: String) -> String { var lines = [ "Subreddit: r/\(subreddit)", "Post type: \(draft.postType.title)", "Topic: \(topic)", "Tone: \(draft.tone.title)", ] if draft.isNSFW { lines.append("Tag: NSFW") } if draft.isSpoiler { lines.append("Tag: Spoiler") } if draft.isOC { lines.append("Tag: Original Content") } if !draft.flair.isEmpty { lines.append("Preferred flair: \(draft.flair)") } switch draft.postType { case .text: lines.append("Write a full text post with markdown formatting.") case .image: lines.append("Write a short image caption and optional supporting context in body.") case .link: lines.append("Write a link post description explaining why r/\(subreddit) should care.") case .video: lines.append("Write a video post description with a compelling hook.") case .poll: lines.append("Write poll context in body and provide \(draft.pollOptions.count) distinct poll options.") } return lines.joined(separator: "\n") } private func parseResponse(_ data: Data, postType: RedditPostType) throws -> GeneratedPost { struct APIEnvelope: Decodable { struct Choice: Decodable { struct Message: Decodable { let content: String } let message: Message } let choices: [Choice] } struct Payload: Decodable { let title: String let body: String let suggestedFlair: String? let pollOptions: [String]? } let envelope = try JSONDecoder().decode(APIEnvelope.self, from: data) guard let content = envelope.choices.first?.message.content.data(using: .utf8) else { throw PostGenerationError.invalidResponse } let payload = try JSONDecoder().decode(Payload.self, from: content) let title = String(payload.title.prefix(PostDraftValidator.maxTitleLength)) let body = String(payload.body.prefix(PostDraftValidator.maxBodyLength)) var pollOptions = payload.pollOptions if postType == .poll { pollOptions = pollOptions?.map { $0.trimmingCharacters(in: .whitespaces) }.filter { !$0.isEmpty } if let pollOptions, pollOptions.count < 2 { throw PostGenerationError.invalidResponse } } else { pollOptions = nil } return GeneratedPost( title: title, body: body, suggestedFlair: payload.suggestedFlair, pollOptions: pollOptions ) } } // MARK: - Mock struct MockPostGenerationService: PostGenerationServiceProtocol { func generatePost(from draft: PostDraft) async throws -> GeneratedPost { try PostDraftValidator.validateForGeneration(topic: draft.topic, subreddit: draft.subreddit) try await Task.sleep(for: .milliseconds(900)) let subreddit = PostDraftValidator.normalizedSubreddit(draft.subreddit) let topic = draft.topic.trimmingCharacters(in: .whitespaces) let tone = draft.tone.title.lowercased() let title = makeTitle(topic: topic, subreddit: subreddit, tone: draft.tone, tags: draft) let body = makeBody( topic: topic, subreddit: subreddit, tone: tone, postType: draft.postType, tags: draft ) let flair = suggestFlair(for: draft.postType, draft: draft) let pollOptions = draft.postType == .poll ? makePollOptions(topic: topic, subreddit: subreddit) : nil return GeneratedPost( title: String(title.prefix(PostDraftValidator.maxTitleLength)), body: String(body.prefix(PostDraftValidator.maxBodyLength)), suggestedFlair: flair, pollOptions: pollOptions ) } private func makeTitle( topic: String, subreddit: String, tone: PostTone, tags: PostDraft ) -> String { let prefix = tags.isNSFW ? "[NSFW] " : "" let base: String = switch tone { case .humorous: "I tried \(topic) so you don't have to — r/\(subreddit) was not wrong" case .professional: "Analysis: \(topic) — findings from r/\(subreddit)" case .informative: "Everything you need to know about \(topic) [Guide]" case .controversial: "Unpopular opinion: \(topic) is overrated. Change my mind." case .storytelling: "How \(topic) completely changed my perspective" case .casual: "Anyone else obsessed with \(topic) lately?" } return prefix + base } private func makeBody( topic: String, subreddit: String, tone: String, postType: RedditPostType, tags: PostDraft ) -> String { var sections: [String] = [] if tags.isSpoiler { sections.append("> **Spoiler warning** — details below.") } if tags.isOC { sections.append("*Original content — created by me.*") } let core: String = switch postType { case .text: """ Hey r/\(subreddit), I've been diving deep into **\(topic)** and wanted to share what I've learned. **Key points:** - Started exploring this after seeing it trending here - The community's take has been incredibly helpful - Still have a few open questions (see below) **My experience:** This started as a casual experiment but turned into something I genuinely care about. Would love to hear how others in this subreddit approach \(topic). **Questions for the community:** 1. What's your go-to resource for \(topic)? 2. Any common mistakes beginners should avoid? 3. Who else here is working on something similar? Written in a \(tone) tone — happy to refine based on feedback! """ case .image: """ Visual breakdown of **\(topic)** — hope this helps someone in r/\(subreddit). Happy to answer questions in the comments! """ case .link: """ Found this resource on **\(topic)** and thought r/\(subreddit) would find it useful. **Why it matters:** concise overview with practical takeaways for this community. """ case .video: """ Just watched this breakdown of **\(topic)** and had to share with r/\(subreddit). The section that really nails it is around the middle — curious if you agree. """ case .poll: """ Curious where r/\(subreddit) stands on **\(topic)**. Vote below and share your reasoning! """ } sections.append(core) return sections.joined(separator: "\n\n") } private func makePollOptions(topic: String, subreddit: String) -> [String] { [ "Love it — best thing for r/\(subreddit)", "It's fine, but overhyped", "Not for me", "Still figuring out \(topic)", ] } private func suggestFlair(for postType: RedditPostType, draft: PostDraft) -> String { if !draft.flair.isEmpty { return draft.flair } if draft.isNSFW { return "NSFW" } return switch postType { case .text: "Discussion" case .image: "Media" case .link: "News" case .video: "Video" case .poll: "Poll" } } }