import Foundation protocol TitleOptimizationServiceProtocol: Sendable { func optimizeTitles(from draft: TitleDraft) async throws -> TitleOptimizationResult } enum TitleOptimizationServiceFactory { static func make(usesLiveAI: Bool) -> any TitleOptimizationServiceProtocol { if usesLiveAI, let apiKey = AIConfiguration.apiKey { return OpenAITitleOptimizationService(apiKey: apiKey) } return MockTitleOptimizationService() } } enum TitleOptimizationError: LocalizedError { case emptyTopic case emptySubreddit case emptyTitle case apiUnavailable(String) case invalidResponse var errorDescription: String? { switch self { case .emptyTopic: "Enter a topic or keywords to guide title generation." case .emptySubreddit: "Enter a subreddit to tailor titles for that community." case .emptyTitle: "Enter a title to optimize, or provide a topic to generate from scratch." case .apiUnavailable(let message): UserFacingError.sanitized(message) ?? UserFacingError.generic case .invalidResponse: UserFacingError.generic } } } // MARK: - OpenAI struct OpenAITitleOptimizationService: TitleOptimizationServiceProtocol { let apiKey: String var model: String = AIRequestConfiguration.model func optimizeTitles(from draft: TitleDraft) async throws -> TitleOptimizationResult { guard !draft.subreddit.trimmingCharacters(in: .whitespaces).isEmpty else { throw TitleOptimizationError.emptySubreddit } guard !draft.topic.trimmingCharacters(in: .whitespaces).isEmpty else { throw TitleOptimizationError.emptyTopic } guard !draft.originalTitle.trimmingCharacters(in: .whitespaces).isEmpty else { throw TitleOptimizationError.emptyTitle } 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.timeoutInterval = AIRequestConfiguration.requestTimeout request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") request.setValue("application/json", forHTTPHeaderField: "Content-Type") let variantCount = draft.variantCount.rawValue let body: [String: Any] = [ "model": model, "temperature": 0.6, "max_tokens": AIRequestConfiguration.titleOptimizerMaxTokens(variantCount: variantCount), "response_format": ["type": "json_object"], "messages": [ [ "role": "system", "content": """ Optimize Reddit titles. JSON only: {"variants":[{"title":"...","score":85}]}. Return exactly \(variantCount) variants. Titles under 300 chars. Match subreddit, goal, and tone. """, ], ["role": "user", "content": prompt], ], ] request.httpBody = try JSONSerialization.data(withJSONObject: body) let data: Data let response: URLResponse do { (data, response) = try await NetworkClient.aiData(for: request) } catch let error as URLError { throw TitleOptimizationError.apiUnavailable(UserFacingError.networkMessage(for: error)) } if let http = response as? HTTPURLResponse, !(200 ... 299).contains(http.statusCode) { throw TitleOptimizationError.apiUnavailable( OpenAIHTTPError.userMessage(statusCode: http.statusCode, data: data) ) } return try parseResponse(data, draft: draft) } private func buildPrompt(draft: TitleDraft, subreddit: String) -> String { var lines = [ "Subreddit: r/\(subreddit)", "Topic: \(draft.topic.trimmingCharacters(in: .whitespaces))", "Original title: \(draft.originalTitle.trimmingCharacters(in: .whitespaces))", "Goal: \(draft.titleGoal.title) — \(draft.titleGoal.subtitle)", "Tone: \(draft.tone.title)", "Post type: \(draft.postType.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("Flair: \(draft.flair)") } return lines.joined(separator: "\n") } private func parseResponse(_ data: Data, draft: TitleDraft) throws -> TitleOptimizationResult { struct APIEnvelope: Decodable { struct Choice: Decodable { struct Message: Decodable { let content: String } let message: Message } let choices: [Choice] } struct VariantPayload: Decodable { let title: String let score: Int enum CodingKeys: String, CodingKey { case title, score } init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) title = try container.decode(String.self, forKey: .title) score = try container.decode(FlexibleInt.self, forKey: .score).value } } struct Payload: Decodable { let variants: [VariantPayload] } let envelope = try JSONDecoder().decode(APIEnvelope.self, from: data) guard let content = envelope.choices.first?.message.content, let payloadData = AIJSONDecoding.payloadData(from: content) else { throw TitleOptimizationError.invalidResponse } let payload: Payload do { payload = try JSONDecoder().decode(Payload.self, from: payloadData) } catch { throw TitleOptimizationError.invalidResponse } guard !payload.variants.isEmpty else { throw TitleOptimizationError.invalidResponse } let original = draft.originalTitle.trimmingCharacters(in: .whitespaces) let analysis = TitleAnalysisEngine.analyze(title: original, goal: draft.titleGoal) let defaultReasoning = "\(draft.titleGoal.title) hook" let variants = payload.variants.map { TitleVariant( title: String($0.title.prefix(PostDraftValidator.maxTitleLength)), score: clampScore($0.score), reasoning: defaultReasoning ) } return TitleOptimizationResult(analysis: analysis, variants: variants) } private func clampScore(_ value: Int) -> Int { min(100, max(0, value)) } } struct MockTitleOptimizationService: TitleOptimizationServiceProtocol { func optimizeTitles(from draft: TitleDraft) async throws -> TitleOptimizationResult { guard !draft.subreddit.trimmingCharacters(in: .whitespaces).isEmpty else { throw TitleOptimizationError.emptySubreddit } guard !draft.topic.trimmingCharacters(in: .whitespaces).isEmpty else { throw TitleOptimizationError.emptyTopic } let hasTitle = !draft.originalTitle.trimmingCharacters(in: .whitespaces).isEmpty if !hasTitle { throw TitleOptimizationError.emptyTitle } let subreddit = draft.subreddit.replacingOccurrences(of: "r/", with: "") let topic = draft.topic.trimmingCharacters(in: .whitespaces) let original = draft.originalTitle.trimmingCharacters(in: .whitespaces) let analysis = TitleAnalysisEngine.analyze(title: original, goal: draft.titleGoal) let variants = makeVariants( original: original, topic: topic, subreddit: subreddit, tone: draft.tone, goal: draft.titleGoal, count: draft.variantCount.rawValue ) return TitleOptimizationResult(analysis: analysis, variants: variants) } private func makeVariants( original: String, topic: String, subreddit: String, tone: PostTone, goal: TitleGoal, count: Int ) -> [TitleVariant] { let templates = variantTemplates( original: original, topic: topic, subreddit: subreddit, tone: tone, goal: goal ) return Array(templates.prefix(count)).enumerated().map { index, template in TitleVariant( title: template.title, score: template.score - index * 2, reasoning: template.reasoning ) } } private func variantTemplates( original: String, topic: String, subreddit: String, tone: PostTone, goal: TitleGoal ) -> [(title: String, score: Int, reasoning: String)] { let base = original.isEmpty ? topic : original var results: [(title: String, score: Int, reasoning: String)] = [] switch goal { case .curiosity: results = [ ("The \(topic) trick nobody on r/\(subreddit) talks about", 92, "Curiosity gap with community reference"), ("I tried \(base) for 30 days — here's what actually happened", 89, "Personal experiment hook"), ("Why \(topic) is quietly changing everything (and most people miss it)", 87, "Insider knowledge angle"), ("\(base) — am I the only one who noticed this?", 84, "Relatability + intrigue"), ("What r/\(subreddit) taught me about \(topic) that I wish I knew sooner", 82, "Community wisdom framing"), ("The uncomfortable truth about \(topic)", 80, "Bold curiosity opener"), ("\(base): a deep dive you didn't know you needed", 78, "Value promise"), ("This \(topic) approach changed my mind completely", 76, "Transformation narrative"), ] case .question: results = [ ("What's your experience with \(topic)?", 91, "Direct community question"), ("Am I wrong about \(base)?", 88, "Invites debate and replies"), ("How do you approach \(topic) on r/\(subreddit)?", 86, "Subreddit-specific ask"), ("Is \(topic) actually worth it in 2026?", 84, "Timely relevance question"), ("Anyone else struggling with \(base)?", 82, "Shared struggle hook"), ("What's the best \(topic) advice you've ever received?", 80, "Experience-sharing prompt"), ("\(base) — what am I missing here?", 78, "Humble knowledge seeker"), ("Hot take or common knowledge: \(topic)?", 76, "Opinion solicitation"), ] case .listicle: results = [ ("7 \(topic) tips that actually work on r/\(subreddit)", 93, "Numbered list with community anchor"), ("5 mistakes everyone makes with \(base)", 90, "Problem-awareness listicle"), ("3 reasons \(topic) is underrated right now", 87, "Concise numbered hook"), ("10 things I learned about \(topic) the hard way", 85, "Lessons-learned format"), ("The top 5 \(topic) resources you need in 2026", 83, "Resource roundup angle"), ("\(base): 4 key takeaways from my experience", 81, "Summary-style list"), ("6 signs you're ready for \(topic)", 79, "Checklist engagement"), ("8 \(topic) hacks r/\(subreddit) swears by", 77, "Community-endorsed list"), ] case .emotional: results = [ ("\(base) hit me harder than I expected", 91, "Emotional vulnerability"), ("I wasn't ready for what \(topic) taught me", 88, "Personal revelation"), ("This \(topic) moment changed everything for me", 86, "Transformation story"), ("Honestly, \(base) has been on my mind lately", 84, "Authentic confession"), ("The \(topic) journey nobody prepares you for", 82, "Empathy-driven framing"), ("Why \(base) still keeps me up at night", 80, "Emotional intensity"), ("\(topic) — a love letter to r/\(subreddit)", 78, "Community appreciation"), ("I finally understand \(base) and it's bittersweet", 76, "Reflective tone"), ] case .direct: results = [ ("\(base) — a complete guide for r/\(subreddit)", 92, "Clear value proposition"), ("Everything you need to know about \(topic)", 89, "Comprehensive promise"), ("\(topic): what works, what doesn't, and why", 87, "Balanced direct framing"), ("My honest take on \(base)", 85, "Straightforward opinion"), ("\(topic) explained simply", 83, "Accessibility focus"), ("The definitive \(base) breakdown", 81, "Authority positioning"), ("\(topic) for beginners — start here", 79, "Audience targeting"), ("\(base): facts, not hype", 77, "No-nonsense angle"), ] case .provocative: results = [ ("Unpopular opinion: \(base) is overrated", 93, "Classic debate starter"), ("\(topic) is a scam and here's why", 90, "Strong contrarian hook"), ("Hot take: r/\(subreddit) is wrong about \(topic)", 87, "Community challenge"), ("Stop doing \(base) — seriously", 85, "Command attention"), ("\(topic) culture has gone too far", 83, "Cultural critique"), ("I'll say it: \(base) isn't that impressive", 81, "Mild controversy"), ("Why everyone is sleeping on the real \(topic) issue", 79, "Contrarian insight"), ("\(base) — fight me", 77, "Maximum engagement bait"), ] } if tone == .humorous { results = results.map { variant in (title: variant.title + " 😅", score: variant.score - 3, reasoning: variant.reasoning + " (humorous tone)") } } else if tone == .professional { results = results.map { variant in (title: "[Analysis] " + variant.title, score: variant.score - 2, reasoning: variant.reasoning + " (professional tone)") } } return results.sorted { $0.score > $1.score } } }