import Foundation enum AmpUsageParser { static func parse(html: String, now: Date = Date()) throws -> AmpUsageSnapshot { guard let usage = self.parseFreeTierUsage(html) else { if self.looksSignedOut(html) { throw AmpUsageError.notLoggedIn } throw AmpUsageError.parseFailed("Missing Amp Free usage data.") } return AmpUsageSnapshot( freeQuota: usage.quota, freeUsed: usage.used, hourlyReplenishment: usage.hourlyReplenishment, windowHours: usage.windowHours, updatedAt: now) } static func parse(displayText: String, now: Date = Date()) throws -> AmpUsageSnapshot { let text = TextParsing.stripANSICodes(displayText) let identityPattern = #"(?im)^\s*Signed in as\s+([^\s(]+)(?:\s+\(([^\r\n)]+)\))?\s*$"# let identity = self.captures(in: text, pattern: identityPattern) if identity == nil, self.looksSignedOut(text) { throw AmpUsageError.notLoggedIn } let amountPattern = #"([0-9][0-9,]*(?:\.[0-9]+)?)"# let freePattern = #"(?im)^\s*Amp Free:\s*\$?"# + amountPattern + #"\s*/\s*\$?"# + amountPattern + #"\s+remaining(?:\s*\(replenishes\s*\+\$?"# + amountPattern + #"\s*/\s*hour\))?"# let freePercentPattern = #"(?im)^\s*Amp Free:\s*"# + amountPattern + #"\s*%\s+remaining(?:\s+today)?(?:\s*\(resets\s+daily\))?"# let creditsPattern = #"(?im)^\s*Individual credits:\s*\$?"# + amountPattern + #"\s+remaining"# let individualCredits = self.captures(in: text, pattern: creditsPattern)?.first .flatMap(self.number(from:)) let workspacePattern = #"(?im)^\s*Workspace\s+(.+?):\s*\$?"# + amountPattern + #"\s+remaining"# let workspaceBalances: [AmpWorkspaceBalance] = self.allCaptures( in: text, pattern: workspacePattern).compactMap { captures -> AmpWorkspaceBalance? in guard captures.count == 2, let name = self.nonEmpty(captures[0]), let remaining = self.number(from: captures[1]) else { return nil } return AmpWorkspaceBalance(name: name, remaining: remaining) } let freeUsage: FreeTierUsage? = { guard let free = self.captures(in: text, pattern: freePattern), let remaining = self.number(from: free[0]), let quota = self.number(from: free[1]) else { return nil } let hourlyReplenishment = self.number(from: free[2]) ?? 0 let windowHours = hourlyReplenishment > 0 ? max(1, (quota / hourlyReplenishment).rounded()) : nil return FreeTierUsage( quota: quota, used: max(0, quota - remaining), hourlyReplenishment: hourlyReplenishment, windowHours: windowHours, resetDescription: nil) }() let freePercentUsage: FreeTierUsage? = { guard let remainingText = self.captures(in: text, pattern: freePercentPattern)?.first, let remaining = self.number(from: remainingText) else { return nil } let clampedRemaining = min(100, max(0, remaining)) return FreeTierUsage( quota: 100, used: 100 - clampedRemaining, hourlyReplenishment: 0, windowHours: 24, resetDescription: "resets daily") }() let resolvedFreeUsage = freeUsage ?? freePercentUsage guard resolvedFreeUsage != nil || individualCredits != nil || !workspaceBalances.isEmpty else { throw AmpUsageError.parseFailed("Missing Amp usage data.") } return AmpUsageSnapshot( freeQuota: resolvedFreeUsage?.quota, freeUsed: resolvedFreeUsage?.used, hourlyReplenishment: resolvedFreeUsage?.hourlyReplenishment, windowHours: resolvedFreeUsage?.windowHours, individualCredits: individualCredits, workspaceBalances: workspaceBalances, accountEmail: self.nonEmpty(identity?[0]), accountOrganization: self.nonEmpty(identity?[1]), updatedAt: now, freeResetDescription: resolvedFreeUsage?.resetDescription) } private struct FreeTierUsage { let quota: Double let used: Double let hourlyReplenishment: Double let windowHours: Double? let resetDescription: String? } private static func parseFreeTierUsage(_ html: String) -> FreeTierUsage? { let tokens = ["freeTierUsage", "getFreeTierUsage"] for token in tokens { if let object = self.extractObject(named: token, in: html), let usage = self.parseFreeTierUsageObject(object) { return usage } } return nil } private static func parseFreeTierUsageObject(_ object: String) -> FreeTierUsage? { guard let quota = self.number(for: "quota", in: object), let used = self.number(for: "used", in: object), let hourly = self.number(for: "hourlyReplenishment", in: object) else { return nil } let windowHours = self.number(for: "windowHours", in: object) return FreeTierUsage( quota: quota, used: used, hourlyReplenishment: hourly, windowHours: windowHours, resetDescription: nil) } private static func extractObject(named token: String, in text: String) -> String? { guard let tokenRange = text.range(of: token) else { return nil } guard let braceIndex = text[tokenRange.upperBound...].firstIndex(of: "{") else { return nil } var depth = 0 var inString = false var isEscaped = false var index = braceIndex while index < text.endIndex { let char = text[index] if inString { if isEscaped { isEscaped = false } else if char == "\\" { isEscaped = true } else if char == "\"" { inString = false } } else { if char == "\"" { inString = true } else if char == "{" { depth += 1 } else if char == "}" { depth -= 1 if depth == 0 { return String(text[braceIndex...index]) } } } index = text.index(after: index) } return nil } private static func number(for key: String, in text: String) -> Double? { let pattern = "\\b\(NSRegularExpression.escapedPattern(for: key))\\b\\s*:\\s*([0-9]+(?:\\.[0-9]+)?)" guard let regex = try? NSRegularExpression(pattern: pattern) else { return nil } let range = NSRange(text.startIndex.. 1, let valueRange = Range(match.range(at: 1), in: text) else { return nil } return Double(text[valueRange]) } private static func captures(in text: String, pattern: String) -> [String]? { guard let regex = try? NSRegularExpression(pattern: pattern) else { return nil } let range = NSRange(text.startIndex.. [[String]] { guard let regex = try? NSRegularExpression(pattern: pattern) else { return [] } let range = NSRange(text.startIndex.. [String] { (1.. Double? { Double(text.replacingOccurrences(of: ",", with: "")) } private static func nonEmpty(_ text: String?) -> String? { guard let text, !text.isEmpty else { return nil } return text } private static func looksSignedOut(_ html: String) -> Bool { let lower = html.lowercased() if lower.contains("sign in") || lower.contains("log in") || lower.contains("login") { return true } if lower.contains("/login") || lower.contains("ampcode.com/login") { return true } return false } }