import CodexBarCore
import Foundation

struct SessionEquivalentBurnEstimate: Equatable, Sendable {
    let medianWeeklyPercentPerWindow: Double
    let sampleCount: Int
}

struct SessionEquivalentForecast: Equatable, Sendable {
    static let sessionWindowMinutes = 300
    static let weeklyWindowMinutes = 10080
    static let resetTolerance: TimeInterval = 2 * 60

    let estimatedWindowsToExhaustWeekly: Double
    let windowsUntilReset: Int
    let availableWindowsUntilReset: Double
    let sampleCount: Int
    let weeklyResetsAt: Date
    let weeklyUsedPercent: Double
    let weeklyWindowID: String?

    init(
        estimatedWindowsToExhaustWeekly: Double,
        windowsUntilReset: Int,
        availableWindowsUntilReset: Double? = nil,
        sampleCount: Int,
        weeklyResetsAt: Date,
        weeklyUsedPercent: Double,
        weeklyWindowID: String? = nil)
    {
        self.estimatedWindowsToExhaustWeekly = estimatedWindowsToExhaustWeekly
        self.windowsUntilReset = windowsUntilReset
        self.availableWindowsUntilReset = availableWindowsUntilReset ?? Double(windowsUntilReset)
        self.sampleCount = sampleCount
        self.weeklyResetsAt = weeklyResetsAt
        self.weeklyUsedPercent = weeklyUsedPercent
        self.weeklyWindowID = weeklyWindowID
    }

    static func make(
        sessionWindow: RateWindow,
        weeklyWindow: RateWindow,
        burnEstimate: SessionEquivalentBurnEstimate,
        weeklyWindowID: String? = nil,
        now: Date,
        workDays: Int?,
        calendar: Calendar = .current) -> Self?
    {
        guard !sessionWindow.isSyntheticPlaceholder,
              sessionWindow.windowMinutes.map({ PlanUtilizationSeriesName.session.canonicalWindowMinutes($0) })
              == self.sessionWindowMinutes,
              weeklyWindow.windowMinutes.map({ PlanUtilizationSeriesName.weekly.canonicalWindowMinutes($0) })
              == self.weeklyWindowMinutes,
              let weeklyResetsAt = weeklyWindow.resetsAt,
              weeklyWindow.usedPercent.isFinite,
              (0...100).contains(weeklyWindow.usedPercent),
              burnEstimate.medianWeeklyPercentPerWindow.isFinite,
              burnEstimate.medianWeeklyPercentPerWindow > 0,
              burnEstimate.sampleCount >= SessionEquivalentBurnEstimator.minimumSampleCount
        else {
            return nil
        }

        let sessionSeconds = TimeInterval(Self.sessionWindowMinutes * 60)
        let weeklySeconds = TimeInterval(Self.weeklyWindowMinutes * 60)
        if let sessionResetsAt = sessionWindow.resetsAt {
            let sessionRemaining = sessionResetsAt.timeIntervalSince(now)
            guard sessionRemaining.isFinite,
                  sessionRemaining > 0,
                  sessionRemaining <= sessionSeconds + Self.resetTolerance
            else {
                return nil
            }
        }

        let weeklyRemaining = weeklyResetsAt.timeIntervalSince(now)
        guard weeklyRemaining.isFinite,
              weeklyRemaining > 0,
              weeklyRemaining <= weeklySeconds + Self.resetTolerance
        else {
            return nil
        }

        let remainingWeeklyPercent = (100 - weeklyWindow.usedPercent).clamped(to: 0...100)
        guard remainingWeeklyPercent > 0 else { return nil }
        let estimatedWindows = remainingWeeklyPercent / burnEstimate.medianWeeklyPercentPerWindow
        guard estimatedWindows.isFinite, estimatedWindows >= 0 else { return nil }

        let remainingSeconds = Self.effectiveRemainingSeconds(
            from: now,
            to: weeklyResetsAt,
            workDays: workDays,
            calendar: calendar)
        guard remainingSeconds >= 0 else { return nil }
        let availableWindowsUntilReset = remainingSeconds / sessionSeconds
        let windowsUntilReset = Int(floor(availableWindowsUntilReset))

        return Self(
            estimatedWindowsToExhaustWeekly: estimatedWindows,
            windowsUntilReset: windowsUntilReset,
            availableWindowsUntilReset: availableWindowsUntilReset,
            sampleCount: burnEstimate.sampleCount,
            weeklyResetsAt: weeklyResetsAt,
            weeklyUsedPercent: weeklyWindow.usedPercent,
            weeklyWindowID: weeklyWindowID)
    }

    func applies(to weeklyWindow: RateWindow, windowID: String?) -> Bool {
        guard weeklyWindow.windowMinutes.map({ PlanUtilizationSeriesName.weekly.canonicalWindowMinutes($0) })
            == Self.weeklyWindowMinutes,
            let resetsAt = weeklyWindow.resetsAt
        else {
            return false
        }
        return self.weeklyWindowID == windowID
            && abs(resetsAt.timeIntervalSince(self.weeklyResetsAt)) < 2 * 60
            && abs(weeklyWindow.usedPercent - self.weeklyUsedPercent) < 0.001
    }

    private static func effectiveRemainingSeconds(
        from now: Date,
        to resetsAt: Date,
        workDays: Int?,
        calendar: Calendar) -> TimeInterval
    {
        let wallClockSeconds = max(0, resetsAt.timeIntervalSince(now))
        guard let workDays, workDays >= 2, workDays < 7 else { return wallClockSeconds }

        var workSeconds: TimeInterval = 0
        var cursor = now
        while cursor < resetsAt {
            guard let nextDay = calendar.date(
                byAdding: .day,
                value: 1,
                to: calendar.startOfDay(for: cursor)),
                nextDay > cursor
            else {
                return wallClockSeconds
            }
            let sliceEnd = min(nextDay, resetsAt)
            if Self.isWorkday(cursor, workDays: workDays, calendar: calendar) {
                workSeconds += sliceEnd.timeIntervalSince(cursor)
            }
            cursor = sliceEnd
        }
        return workSeconds
    }

    private static func isWorkday(_ date: Date, workDays: Int, calendar: Calendar) -> Bool {
        let weekday = calendar.component(.weekday, from: date)
        let isoWeekday = weekday == 1 ? 7 : weekday - 1
        return isoWeekday <= workDays
    }
}

enum SessionEquivalentBurnEstimator {
    static let defaultSampleLimit = 7
    static let minimumSampleCount = 3
    private static let observationAlignmentTolerance: TimeInterval = 0
    private static let resetEquivalenceTolerance = SessionEquivalentForecast.resetTolerance

    private struct SessionGroup {
        let resetsAt: Date
        var entries: [PlanUtilizationHistoryEntry]
        var maximumUsedPercent: Double
    }

    private struct BurnObservation {
        let sessionUsedPercent: Double
        let weeklyEntry: PlanUtilizationHistoryEntry
    }

    static func estimate(
        histories: [PlanUtilizationSeriesHistory],
        currentSessionResetsAt: Date?,
        now: Date,
        sampleLimit: Int = Self.defaultSampleLimit) -> SessionEquivalentBurnEstimate?
    {
        guard sampleLimit > 0,
              let sessionHistory = histories.first(where: {
                  $0.name == .session
                      && $0.name.canonicalWindowMinutes($0.windowMinutes)
                      == SessionEquivalentForecast.sessionWindowMinutes
              }),
              let weeklyHistory = histories.first(where: {
                  $0.name == .weekly
                      && $0.name.canonicalWindowMinutes($0.windowMinutes)
                      == SessionEquivalentForecast.weeklyWindowMinutes
              })
        else {
            return nil
        }

        let sessionDuration = TimeInterval(SessionEquivalentForecast.sessionWindowMinutes * 60)
        let weeklyDuration = TimeInterval(SessionEquivalentForecast.weeklyWindowMinutes * 60)
        guard Self.isChronologicallyOrdered(sessionHistory.entries),
              Self.isChronologicallyOrdered(weeklyHistory.entries)
        else {
            return nil
        }
        if let currentSessionResetsAt {
            let currentSessionRemaining = currentSessionResetsAt.timeIntervalSince(now)
            guard currentSessionRemaining.isFinite,
                  currentSessionRemaining > 0,
                  currentSessionRemaining <= sessionDuration + Self.resetEquivalenceTolerance
            else {
                return nil
            }
        }

        var groups: [SessionGroup] = []
        groups.reserveCapacity(sessionHistory.entries.count)
        for entry in sessionHistory.entries {
            guard entry.usedPercent.isFinite,
                  (0...100).contains(entry.usedPercent),
                  let resetsAt = entry.resetsAt,
                  Self.isPlausibleReset(
                      resetsAt,
                      capturedAt: entry.capturedAt,
                      duration: sessionDuration)
            else {
                continue
            }
            if let lastIndex = groups.indices.last,
               abs(groups[lastIndex].resetsAt.timeIntervalSince(resetsAt)) <= Self.resetEquivalenceTolerance
            {
                groups[lastIndex].entries.append(entry)
                groups[lastIndex].maximumUsedPercent = max(groups[lastIndex].maximumUsedPercent, entry.usedPercent)
            } else {
                guard groups.last.map({ $0.resetsAt <= resetsAt }) ?? true else { return nil }
                groups.append(SessionGroup(
                    resetsAt: resetsAt,
                    entries: [entry],
                    maximumUsedPercent: entry.usedPercent))
            }
        }

        let completedActiveGroups = groups.reversed().compactMap { group -> SessionGroup? in
            let precedesCurrentSession = currentSessionResetsAt.map {
                group.resetsAt < $0.addingTimeInterval(-Self.resetEquivalenceTolerance)
            } ?? true
            guard precedesCurrentSession,
                  group.resetsAt <= now,
                  group.maximumUsedPercent > 0
            else {
                return nil
            }
            return group
        }

        let weeklyEntries = weeklyHistory.entries.filter { entry in
            entry.usedPercent.isFinite
                && (0...100).contains(entry.usedPercent)
                && entry.resetsAt.map {
                    Self.isPlausibleReset($0, capturedAt: entry.capturedAt, duration: weeklyDuration)
                } == true
        }
        guard !weeklyEntries.isEmpty else { return nil }

        var burns: [Double] = []
        let candidateGroups = completedActiveGroups.prefix(sampleLimit)
        burns.reserveCapacity(candidateGroups.count)
        for group in candidateGroups {
            guard let fullAllowanceBurn = Self.normalizedBurn(
                for: group,
                weeklyEntries: weeklyEntries,
                sessionDuration: sessionDuration)
            else { continue }
            burns.append(fullAllowanceBurn)
        }

        guard burns.count >= Self.minimumSampleCount else { return nil }
        burns.sort()
        let middle = burns.count / 2
        let median = burns.count.isMultiple(of: 2)
            ? (burns[middle - 1] + burns[middle]) / 2
            : burns[middle]
        guard median.isFinite, median > 0 else { return nil }
        return SessionEquivalentBurnEstimate(
            medianWeeklyPercentPerWindow: median,
            sampleCount: burns.count)
    }

    private static func normalizedBurn(
        for group: SessionGroup,
        weeklyEntries: [PlanUtilizationHistoryEntry],
        sessionDuration: TimeInterval) -> Double?
    {
        guard let firstSessionEntry = group.entries.first,
              let lastSessionEntry = group.entries.last
        else {
            return nil
        }

        var observations: [BurnObservation] = []
        let windowStart = group.resetsAt.addingTimeInterval(-sessionDuration)
        if let weeklyStart = Self.nearestEntry(
            to: windowStart,
            entries: weeklyEntries,
            tolerance: Self.resetEquivalenceTolerance,
            requireNotAfterTarget: true),
            weeklyStart.capturedAt <= windowStart,
            weeklyStart.capturedAt < firstSessionEntry.capturedAt
        {
            observations.append(BurnObservation(sessionUsedPercent: 0, weeklyEntry: weeklyStart))
        }

        for sessionEntry in group.entries {
            guard let weeklyEntry = Self.nearestEntry(
                to: sessionEntry.capturedAt,
                entries: weeklyEntries,
                tolerance: Self.observationAlignmentTolerance)
            else {
                continue
            }
            observations.append(BurnObservation(
                sessionUsedPercent: sessionEntry.usedPercent,
                weeklyEntry: weeklyEntry))
        }

        if group.maximumUsedPercent >= 100,
           let weeklyEnd = Self.nearestEntry(
               to: group.resetsAt,
               entries: weeklyEntries,
               tolerance: Self.resetEquivalenceTolerance,
               requireNotAfterTarget: true),
           weeklyEnd.capturedAt <= group.resetsAt,
           lastSessionEntry.capturedAt < weeklyEnd.capturedAt
        {
            observations.append(BurnObservation(sessionUsedPercent: 100, weeklyEntry: weeklyEnd))
        }

        observations.sort { lhs, rhs in
            if lhs.weeklyEntry.capturedAt != rhs.weeklyEntry.capturedAt {
                return lhs.weeklyEntry.capturedAt < rhs.weeklyEntry.capturedAt
            }
            return lhs.sessionUsedPercent < rhs.sessionUsedPercent
        }
        guard let start = observations.first,
              let end = observations.last,
              start.weeklyEntry.capturedAt < end.weeklyEntry.capturedAt,
              let startReset = start.weeklyEntry.resetsAt,
              let endReset = end.weeklyEntry.resetsAt,
              abs(startReset.timeIntervalSince(endReset)) <= Self.resetEquivalenceTolerance
        else {
            return nil
        }

        let sessionConsumption = end.sessionUsedPercent - start.sessionUsedPercent
        let weeklyBurn = end.weeklyEntry.usedPercent - start.weeklyEntry.usedPercent
        guard sessionConsumption.isFinite,
              sessionConsumption > 0,
              weeklyBurn.isFinite,
              weeklyBurn > 0
        else {
            return nil
        }
        let fullAllowanceBurn = 100 * weeklyBurn / sessionConsumption
        guard fullAllowanceBurn.isFinite, fullAllowanceBurn > 0 else { return nil }
        return fullAllowanceBurn
    }

    private static func nearestEntry(
        to target: Date,
        entries: [PlanUtilizationHistoryEntry],
        tolerance: TimeInterval,
        requireNotAfterTarget: Bool = false) -> PlanUtilizationHistoryEntry?
    {
        var lower = 0
        var upper = entries.count
        while lower < upper {
            let middle = (lower + upper) / 2
            if entries[middle].capturedAt < target {
                lower = middle + 1
            } else {
                upper = middle
            }
        }

        var candidates: [PlanUtilizationHistoryEntry] = []
        if lower < entries.count {
            candidates.append(entries[lower])
        }
        if lower > 0 {
            candidates.append(entries[lower - 1])
        }
        return candidates
            .filter { !requireNotAfterTarget || $0.capturedAt <= target }
            .filter { abs($0.capturedAt.timeIntervalSince(target)) <= tolerance }
            .min { lhs, rhs in
                abs(lhs.capturedAt.timeIntervalSince(target)) < abs(rhs.capturedAt.timeIntervalSince(target))
            }
    }

    private static func isChronologicallyOrdered(_ entries: [PlanUtilizationHistoryEntry]) -> Bool {
        guard entries.allSatisfy(\.capturedAt.timeIntervalSinceReferenceDate.isFinite) else { return false }
        return zip(entries, entries.dropFirst()).allSatisfy { pair in
            pair.0.capturedAt <= pair.1.capturedAt
        }
    }

    private static func isPlausibleReset(
        _ resetsAt: Date,
        capturedAt: Date,
        duration: TimeInterval) -> Bool
    {
        let remaining = resetsAt.timeIntervalSince(capturedAt)
        return remaining.isFinite
            && remaining >= -Self.resetEquivalenceTolerance
            && remaining <= duration + Self.resetEquivalenceTolerance
    }
}

private struct SessionEquivalentBurnCacheKey: Equatable {
    static let idleTimeBucketSeconds: TimeInterval = 60

    let historyRevision: Int
    let historySelectionIdentity: String
    let currentSessionResetsAt: Date?
    let weeklyWindowID: String?
    let idleTimeBucket: Int64?
}

struct SessionEquivalentBurnCacheEntry {
    fileprivate let key: SessionEquivalentBurnCacheKey
    fileprivate let estimate: SessionEquivalentBurnEstimate?
}

@MainActor
extension UsageStore {
    func sessionEquivalentForecast(
        provider: UsageProvider,
        sessionWindow: RateWindow,
        weeklyWindow: RateWindow,
        weeklyWindowID: String? = nil,
        historyIdentity: String? = nil,
        historySelection: PlanUtilizationHistorySelection? = nil,
        now: Date = .init()) -> SessionEquivalentForecast?
    {
        guard sessionWindow.windowMinutes.map({ PlanUtilizationSeriesName.session.canonicalWindowMinutes($0) })
            == SessionEquivalentForecast.sessionWindowMinutes
        else {
            return nil
        }
        let currentSessionResetsAt = sessionWindow.resetsAt
        guard currentSessionResetsAt?.timeIntervalSinceReferenceDate.isFinite ?? true else {
            return nil
        }

        let selection = historySelection ?? self.planUtilizationHistorySelection(for: provider)
        guard self.sessionEquivalentHistoryIdentityMatches(
            provider: provider,
            accountKey: selection.accountKey,
            historyIdentity: historyIdentity)
        else {
            return nil
        }
        let cacheKey = SessionEquivalentBurnCacheKey(
            historyRevision: self.planUtilizationHistoryRevision,
            historySelectionIdentity: selection.cacheIdentity,
            currentSessionResetsAt: currentSessionResetsAt,
            weeklyWindowID: weeklyWindowID,
            idleTimeBucket: currentSessionResetsAt == nil
                ? Int64(floor(now.timeIntervalSinceReferenceDate /
                        SessionEquivalentBurnCacheKey.idleTimeBucketSeconds))
                : nil)
        let burnEstimate: SessionEquivalentBurnEstimate?
        if let cached = self.sessionEquivalentBurnCache[provider.instanceID], cached.key == cacheKey {
            burnEstimate = cached.estimate
        } else {
            burnEstimate = SessionEquivalentBurnEstimator.estimate(
                histories: selection.histories,
                currentSessionResetsAt: currentSessionResetsAt,
                now: now)
            self.sessionEquivalentHistoryScanCount &+= 1
            self.sessionEquivalentBurnCache[provider.instanceID] = SessionEquivalentBurnCacheEntry(
                key: cacheKey,
                estimate: burnEstimate)
        }

        guard let burnEstimate else { return nil }
        return SessionEquivalentForecast.make(
            sessionWindow: sessionWindow,
            weeklyWindow: weeklyWindow,
            burnEstimate: burnEstimate,
            weeklyWindowID: weeklyWindowID,
            now: now,
            workDays: self.settings.weeklyProgressWorkDays)
    }

    #if DEBUG
    var _sessionEquivalentHistoryScanCountForTesting: Int {
        self.sessionEquivalentHistoryScanCount
    }
    #endif
}
