import Foundation

public enum OpenAIDashboardParser {
    /// Extracts the signed-in email from the embedded `client-bootstrap` JSON payload, if present.
    ///
    /// The Codex usage dashboard currently ships a JSON blob in:
    /// `<script type="application/json" id="client-bootstrap">…</script>`.
    /// WebKit `document.body.innerText` often does not include the email, so we parse it from HTML.
    public static func parseSignedInEmailFromClientBootstrap(html: String) -> String? {
        guard let data = self.clientBootstrapJSONData(fromHTML: html) else { return nil }
        guard let json = try? JSONSerialization.jsonObject(with: data, options: []) else { return nil }

        // Fast path: common structure.
        if let dict = json as? [String: Any] {
            if let session = dict["session"] as? [String: Any],
               let user = session["user"] as? [String: Any],
               let email = user["email"] as? String,
               email.contains("@")
            {
                return email.trimmingCharacters(in: .whitespacesAndNewlines)
            }

            if let user = dict["user"] as? [String: Any],
               let email = user["email"] as? String,
               email.contains("@")
            {
                return email.trimmingCharacters(in: .whitespacesAndNewlines)
            }
        }

        // Fallback: BFS scan for an email key/value.
        var queue: [Any] = [json]
        var seen = 0
        while !queue.isEmpty, seen < 4000 {
            let cur = queue.removeFirst()
            seen += 1
            if let dict = cur as? [String: Any] {
                for (k, v) in dict {
                    if k.lowercased() == "email", let email = v as? String, email.contains("@") {
                        return email.trimmingCharacters(in: .whitespacesAndNewlines)
                    }
                    queue.append(v)
                }
            } else if let arr = cur as? [Any] {
                queue.append(contentsOf: arr)
            }
        }
        return nil
    }

    /// Extracts the auth status from `client-bootstrap`, if present.
    /// Expected values include `logged_in` and `logged_out`.
    public static func parseAuthStatusFromClientBootstrap(html: String) -> String? {
        guard let data = self.clientBootstrapJSONData(fromHTML: html) else { return nil }
        guard let json = try? JSONSerialization.jsonObject(with: data, options: []) else { return nil }
        guard let dict = json as? [String: Any] else { return nil }
        if let authStatus = dict["authStatus"] as? String, !authStatus.isEmpty {
            return authStatus.trimmingCharacters(in: .whitespacesAndNewlines)
        }
        return nil
    }

    public static func parseCodeReviewRemainingPercent(bodyText: String) -> Double? {
        let cleaned = bodyText.replacingOccurrences(of: "\r", with: "\n")
        for regex in self.codeReviewRegexes {
            let range = NSRange(cleaned.startIndex..<cleaned.endIndex, in: cleaned)
            guard let match = regex.firstMatch(in: cleaned, options: [], range: range),
                  match.numberOfRanges >= 2,
                  let r = Range(match.range(at: 1), in: cleaned)
            else { continue }
            if let val = Double(cleaned[r]) { return min(100, max(0, val)) }
        }
        return nil
    }

    public static func parseCreditsRemaining(bodyText: String) -> Double? {
        let cleaned = bodyText.replacingOccurrences(of: "\r", with: "\n")
        let patterns = [
            #"credits\s*remaining[^0-9]*([0-9][0-9.,]*)"#,
            #"remaining\s*credits[^0-9]*([0-9][0-9.,]*)"#,
            #"credit\s*balance[^0-9]*([0-9][0-9.,]*)"#,
        ]
        for pattern in patterns {
            if let val = TextParsing.firstNumber(pattern: pattern, text: cleaned) { return val }
        }
        return nil
    }

    public static func parseRateLimits(
        bodyText: String,
        now: Date = .init()) -> (primary: RateWindow?, secondary: RateWindow?)
    {
        let cleaned = bodyText.replacingOccurrences(of: "\r", with: "\n")
        let lines = cleaned
            .split(whereSeparator: \.isNewline)
            .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
            .filter { !$0.isEmpty }

        let primary = self.parseRateWindow(
            lines: lines,
            match: self.isFiveHourLimitLine,
            windowMinutes: 5 * 60,
            now: now)
        let secondary = self.parseRateWindow(
            lines: lines,
            match: self.isWeeklyLimitLine,
            windowMinutes: 7 * 24 * 60,
            now: now)
        return (primary, secondary)
    }

    public static func parseCodeReviewLimit(bodyText: String, now: Date = .init()) -> RateWindow? {
        let cleaned = bodyText.replacingOccurrences(of: "\r", with: "\n")
        let lines = cleaned
            .split(whereSeparator: \.isNewline)
            .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
            .filter { !$0.isEmpty }

        return self.parseRateWindow(
            lines: lines,
            match: self.isCodeReviewLimitLine,
            windowMinutes: nil,
            now: now)
    }

    public static func parsePlanFromHTML(html: String) -> String? {
        if let data = self.clientBootstrapJSONData(fromHTML: html),
           let plan = self.findPlan(in: data)
        {
            return plan
        }
        if let data = self.nextDataJSONData(fromHTML: html),
           let plan = self.findPlan(in: data)
        {
            return plan
        }
        return nil
    }

    public static func parseCreditEvents(rows: [[String]]) -> [CreditEvent] {
        let formatter = self.creditDateFormatter()

        return rows.compactMap { row in
            guard row.count >= 3 else { return nil }
            let dateString = row[0]
            let service = row[1].trimmingCharacters(in: .whitespacesAndNewlines)
            let amountString = row[2]
            guard let date = formatter.date(from: dateString) else { return nil }
            let creditsUsed = Self.parseCreditsUsed(amountString)
            return CreditEvent(date: date, service: service, creditsUsed: creditsUsed)
        }
        .sorted { $0.date > $1.date }
    }

    private static func parseCreditsUsed(_ text: String) -> Double {
        guard let raw = self.firstNumberToken(in: text) else { return 0 }
        let token = raw
            .replacingOccurrences(of: "\u{00A0}", with: "")
            .replacingOccurrences(of: "\u{202F}", with: "")
            .replacingOccurrences(of: " ", with: "")
        let hasComma = token.contains(",")
        let hasDot = token.contains(".")
        if hasComma, hasDot {
            return TextParsing.firstNumber(pattern: #"([0-9][0-9.,\s\p{Zs}]*)"#, text: token) ?? 0
        }
        if hasComma {
            if self.usesLocalizedDecimalCommaCreditLabel(text) {
                return Double(token.replacingOccurrences(of: ",", with: ".")) ?? 0
            }
            if token.range(of: #"^\d{1,3}(,\d{3})+$"#, options: .regularExpression) != nil {
                return Double(token.replacingOccurrences(of: ",", with: "")) ?? 0
            }
            return Double(token.replacingOccurrences(of: ",", with: ".")) ?? 0
        }
        return Double(token) ?? 0
    }

    private static func usesLocalizedDecimalCommaCreditLabel(_ text: String) -> Bool {
        text
            .lowercased()
            .contains("crédit")
    }

    private static func firstNumberToken(in text: String) -> String? {
        guard let regex = try? NSRegularExpression(
            pattern: #"([0-9][0-9.,\s\p{Zs}]*)"#,
            options: [])
        else {
            return nil
        }
        let range = NSRange(text.startIndex..<text.endIndex, in: text)
        guard let match = regex.firstMatch(in: text, options: [], range: range),
              match.numberOfRanges >= 2,
              let tokenRange = Range(match.range(at: 1), in: text)
        else {
            return nil
        }
        return String(text[tokenRange])
    }

    // MARK: - Private

    private static let codeReviewRegexes: [NSRegularExpression] = {
        let patterns = [
            #"Code\s*review[^0-9%]*([0-9]{1,3})%\s*remaining"#,
            #"Core\s*review[^0-9%]*([0-9]{1,3})%\s*remaining"#,
        ]
        return patterns.compactMap { pattern in
            try? NSRegularExpression(pattern: pattern, options: [.caseInsensitive])
        }
    }()

    private static let creditDateFormatterKey = "OpenAIDashboardParser.creditDateFormatter"
    private static let clientBootstrapNeedle = Data("id=\"client-bootstrap\"".utf8)
    private static let nextDataNeedle = Data("id=\"__NEXT_DATA__\"".utf8)
    private static let scriptCloseNeedle = Data("</script>".utf8)

    private static func creditDateFormatter() -> DateFormatter {
        let threadDict = Thread.current.threadDictionary
        if let cached = threadDict[self.creditDateFormatterKey] as? DateFormatter {
            return cached
        }
        let formatter = DateFormatter()
        formatter.locale = Locale(identifier: "en_US_POSIX")
        formatter.dateFormat = "MMM d, yyyy"
        threadDict[self.creditDateFormatterKey] = formatter
        return formatter
    }

    private static func clientBootstrapJSONData(fromHTML html: String) -> Data? {
        let data = Data(html.utf8)
        guard let idRange = data.range(of: self.clientBootstrapNeedle) else { return nil }

        guard let openTagEnd = data[idRange.upperBound...].firstIndex(of: UInt8(ascii: ">")) else { return nil }
        let contentStart = data.index(after: openTagEnd)
        guard let closeRange = data.range(
            of: self.scriptCloseNeedle,
            options: [],
            in: contentStart..<data.endIndex)
        else {
            return nil
        }
        let rawData = data[contentStart..<closeRange.lowerBound]
        let trimmed = self.trimASCIIWhitespace(Data(rawData))
        return trimmed.isEmpty ? nil : trimmed
    }

    private static func nextDataJSONData(fromHTML html: String) -> Data? {
        let data = Data(html.utf8)
        guard let idRange = data.range(of: self.nextDataNeedle) else { return nil }

        guard let openTagEnd = data[idRange.upperBound...].firstIndex(of: UInt8(ascii: ">")) else { return nil }
        let contentStart = data.index(after: openTagEnd)
        guard let closeRange = data.range(
            of: self.scriptCloseNeedle,
            options: [],
            in: contentStart..<data.endIndex)
        else {
            return nil
        }
        let rawData = data[contentStart..<closeRange.lowerBound]
        let trimmed = self.trimASCIIWhitespace(Data(rawData))
        return trimmed.isEmpty ? nil : trimmed
    }

    private static func trimASCIIWhitespace(_ data: Data) -> Data {
        guard !data.isEmpty else { return data }
        var start = data.startIndex
        var end = data.endIndex

        while start < end, data[start].isASCIIWhitespace {
            start = data.index(after: start)
        }
        while end > start {
            let prev = data.index(before: end)
            if data[prev].isASCIIWhitespace {
                end = prev
            } else {
                break
            }
        }
        return data.subdata(in: start..<end)
    }

    private static func parseRateWindow(
        lines: [String],
        match: (String) -> Bool,
        windowMinutes: Int?,
        now: Date) -> RateWindow?
    {
        for idx in lines.indices where match(lines[idx]) {
            let end = min(lines.count - 1, idx + 5)
            let windowLines = Array(lines[idx...end])

            var percentValue: Double?
            var isRemaining = true
            for line in windowLines {
                if let percent = self.parsePercent(from: line) {
                    percentValue = percent.value
                    isRemaining = percent.isRemaining
                    break
                }
            }

            guard let percentValue else { continue }
            let usedPercent = isRemaining ? max(0, min(100, 100 - percentValue)) : max(0, min(100, percentValue))

            let resetLine = windowLines.first { $0.localizedCaseInsensitiveContains("reset") }
            let resetDescription = resetLine?.trimmingCharacters(in: .whitespacesAndNewlines)
            let resetsAt = resetLine.flatMap { self.parseResetDate(from: $0, now: now) }
            let fallbackDescription = resetsAt.map { UsageFormatter.resetDescription(from: $0) }

            return RateWindow(
                usedPercent: usedPercent,
                windowMinutes: windowMinutes,
                resetsAt: resetsAt,
                resetDescription: resetDescription ?? fallbackDescription)
        }
        return nil
    }

    private static func parsePercent(from line: String) -> (value: Double, isRemaining: Bool)? {
        guard let percent = TextParsing.firstNumber(pattern: #"([0-9]{1,3})\s*%"#, text: line) else { return nil }
        let lower = line.lowercased()
        let isRemaining = lower.contains("remaining") || lower.contains("left")
        let isUsed = lower.contains("used") || lower.contains("spent") || lower.contains("consumed")
        if isUsed { return (percent, false) }
        if isRemaining { return (percent, true) }
        return (percent, true)
    }

    private static func isFiveHourLimitLine(_ line: String) -> Bool {
        let lower = line.lowercased()
        if lower.contains("5h") { return true }
        if lower.range(of: #"\b5\s*h\b"#, options: .regularExpression) != nil { return true }
        if lower.contains("5-hour") { return true }
        if lower.contains("5 hour") { return true }
        return false
    }

    private static func isWeeklyLimitLine(_ line: String) -> Bool {
        let lower = line.lowercased()
        if lower.contains("weekly") { return true }
        if lower.contains("7-day") { return true }
        if lower.contains("7 day") { return true }
        if lower.contains("7d") { return true }
        if lower.range(of: #"\b7\s*d\b"#, options: .regularExpression) != nil { return true }
        return false
    }

    private static func isCodeReviewLimitLine(_ line: String) -> Bool {
        let lower = line.lowercased()
        guard lower.contains("code review") || lower.contains("core review") else { return false }
        if lower.contains("github code review") { return false }
        return true
    }

    private static func parseResetDate(from line: String, now: Date) -> Date? {
        var raw = line.trimmingCharacters(in: .whitespacesAndNewlines)
        raw = raw.replacingOccurrences(of: #"(?i)^resets?:?\s*"#, with: "", options: .regularExpression)
        raw = raw.replacingOccurrences(of: " at ", with: " ", options: .caseInsensitive)
        raw = raw.replacingOccurrences(of: " on ", with: " ", options: .caseInsensitive)
        raw = raw.replacingOccurrences(of: #"\s+"#, with: " ", options: .regularExpression)
            .trimmingCharacters(in: .whitespacesAndNewlines)

        let calendar = Calendar(identifier: .gregorian)
        let monthDayFormatter = DateFormatter()
        monthDayFormatter.locale = Locale(identifier: "en_US_POSIX")
        monthDayFormatter.timeZone = TimeZone.current
        monthDayFormatter.dateFormat = "MMM d"

        var candidate = raw
        let lower = candidate.lowercased()
        var usedRelativeDay = false

        if lower.contains("today") {
            usedRelativeDay = true
            let dateText = monthDayFormatter.string(from: now)
            candidate = candidate.replacingOccurrences(of: "today", with: dateText, options: .caseInsensitive)
        } else if lower.contains("tomorrow") {
            usedRelativeDay = true
            if let tomorrow = calendar.date(byAdding: .day, value: 1, to: now) {
                let dateText = monthDayFormatter.string(from: tomorrow)
                candidate = candidate.replacingOccurrences(of: "tomorrow", with: dateText, options: .caseInsensitive)
            }
        }

        if let weekdayMatch = self.weekdayMatch(in: candidate) {
            usedRelativeDay = true
            let target = self.nextWeekdayDate(weekday: weekdayMatch.weekday, now: now, calendar: calendar)
            let dateText = monthDayFormatter.string(from: target)
            candidate = candidate.replacingOccurrences(
                of: weekdayMatch.matched,
                with: dateText,
                options: .caseInsensitive)
        }

        let formatter = DateFormatter()
        formatter.locale = Locale(identifier: "en_US_POSIX")
        formatter.timeZone = TimeZone.current
        formatter.defaultDate = now

        let formats = [
            "MMM d h:mma",
            "MMM d, h:mma",
            "MMM d h:mm a",
            "MMM d, h:mm a",
            "MMM d HH:mm",
            "MMM d, HH:mm",
            "MMM d",
            "M/d h:mma",
            "M/d h:mm a",
            "M/d/yyyy h:mm a",
            "M/d/yy h:mm a",
            "M/d",
            "yyyy-MM-dd HH:mm",
            "yyyy-MM-dd h:mm a",
            "yyyy-MM-dd",
        ]

        for format in formats {
            formatter.dateFormat = format
            if let date = formatter.date(from: candidate) {
                if usedRelativeDay, date < now {
                    if lower.contains("today"),
                       let bumped = calendar.date(byAdding: .day, value: 1, to: date)
                    {
                        return bumped
                    }
                    if let bumped = calendar.date(byAdding: .day, value: 7, to: date) {
                        return bumped
                    }
                }
                return date
            }
        }
        return nil
    }

    private struct WeekdayMatch {
        let matched: String
        let weekday: Int
    }

    private static func weekdayMatch(in text: String) -> WeekdayMatch? {
        // The optional "(day)?" suffix requires each alternative to be long enough that
        // adding "day" reproduces the full weekday name. "wed"+"day"="wedday" and
        // "sat"+"day"="satday", so the longer prefixes "wednes" and "satur" are needed
        // to cover the full "Wednesday" and "Saturday" forms — mirroring how the existing
        // "tue|tues" and "thu|thur|thurs" alternatives layer up to "tuesday"/"thursday".
        let pattern = #"\b(mon|tue|tues|wed|wednes|thu|thur|thurs|fri|sat|satur|sun)(day)?\b"#
        guard let regex = try? NSRegularExpression(pattern: pattern, options: [.caseInsensitive]) else { return nil }
        let range = NSRange(text.startIndex..<text.endIndex, in: text)
        guard let match = regex.firstMatch(in: text, options: [], range: range),
              let r = Range(match.range(at: 0), in: text)
        else { return nil }
        let matched = String(text[r])
        let lower = matched.lowercased()
        let weekday = switch lower.prefix(3) {
        case "mon": 2
        case "tue": 3
        case "wed": 4
        case "thu": 5
        case "fri": 6
        case "sat": 7
        default: 1
        }
        return WeekdayMatch(matched: matched, weekday: weekday)
    }

    private static func nextWeekdayDate(weekday: Int, now: Date, calendar: Calendar) -> Date {
        let currentWeekday = calendar.component(.weekday, from: now)
        var delta = weekday - currentWeekday
        if delta < 0 { delta += 7 }
        guard let next = calendar.date(byAdding: .day, value: delta, to: calendar.startOfDay(for: now)) else {
            return now
        }
        return next
    }

    private static func findPlan(in data: Data) -> String? {
        guard let json = try? JSONSerialization.jsonObject(with: data, options: []) else { return nil }
        return self.findPlan(in: json)
    }

    private static func findPlan(in json: Any) -> String? {
        var queue: [Any] = [json]
        var seen = 0
        while !queue.isEmpty, seen < 6000 {
            let cur = queue.removeFirst()
            seen += 1
            if let dict = cur as? [String: Any] {
                for (k, v) in dict {
                    if let plan = self.planCandidate(forKey: k, value: v) { return plan }
                    queue.append(v)
                }
            } else if let arr = cur as? [Any] {
                queue.append(contentsOf: arr)
            }
        }
        return nil
    }

    private static func planCandidate(forKey key: String, value: Any) -> String? {
        guard self.isPlanKey(key) else { return nil }
        if let str = value as? String {
            return self.normalizePlanValue(str)
        }
        if let dict = value as? [String: Any] {
            if let name = dict["name"] as? String, let plan = self.normalizePlanValue(name) { return plan }
            if let display = dict["displayName"] as? String, let plan = self.normalizePlanValue(display) { return plan }
            if let tier = dict["tier"] as? String, let plan = self.normalizePlanValue(tier) { return plan }
        }
        return nil
    }

    private static func isPlanKey(_ key: String) -> Bool {
        let lower = key.lowercased()
        return lower.contains("plan") || lower.contains("tier") || lower.contains("subscription")
    }

    private static func normalizePlanValue(_ value: String) -> String? {
        let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
        guard !trimmed.isEmpty else { return nil }
        let lower = trimmed.lowercased()
        let allowed = [
            "free",
            "plus",
            "pro",
            "team",
            "enterprise",
            "business",
            "edu",
            "education",
            "gov",
            "premium",
            "essential",
        ]
        guard allowed.contains(where: { lower.contains($0) }) else { return nil }
        return CodexPlanFormatting.displayName(trimmed) ?? UsageFormatter.cleanPlanName(trimmed)
    }
}

extension UInt8 {
    fileprivate var isASCIIWhitespace: Bool {
        switch self {
        case 9, 10, 13, 32: true
        default: false
        }
    }
}
