import Foundation
import Testing
@testable import CodeBurnMenubar

private func combinedUsage(cost: Double = 12.5) -> CombinedUsage {
    CombinedUsage(
        perDevice: [
            CombinedDeviceUsage(
                id: "local",
                name: "MacBook",
                local: true,
                error: nil,
                cost: cost,
                calls: 3,
                sessions: 2,
                inputTokens: 100,
                outputTokens: 50,
                cacheCreateTokens: 10,
                cacheReadTokens: 20,
                totalTokens: 180
            )
        ],
        combined: CombinedUsageTotals(
            cost: cost,
            calls: 3,
            sessions: 2,
            inputTokens: 100,
            outputTokens: 50,
            cacheCreateTokens: 10,
            cacheReadTokens: 20,
            totalTokens: 180,
            deviceCount: 1,
            reachableCount: 1
        )
    )
}

private func claudeConfigSelector(selectedId: String? = nil) -> ClaudeConfigSelector {
    ClaudeConfigSelector(
        selectedId: selectedId,
        options: [
            ClaudeConfigOption(id: "claude-config:work", label: "claude-work", path: "/tmp/claude-work"),
            ClaudeConfigOption(id: "claude-config:personal", label: "claude-personal", path: "/tmp/claude-personal")
        ]
    )
}

private func menubarPayload(cost: Double,
                            combined: CombinedUsage? = nil,
                            claudeConfigs: ClaudeConfigSelector? = nil) -> MenubarPayload {
    MenubarPayload(
        generated: "test",
        current: CurrentBlock(
            label: "Today",
            cost: cost,
            calls: 1,
            sessions: 1,
            oneShotRate: nil,
            inputTokens: 1,
            outputTokens: 1,
            cacheHitPercent: 0,
            codexCredits: nil,
            topActivities: [],
            topModels: [],
            localModelSavings: LocalModelSavings(totalUSD: 0, calls: 0, byModel: [], byProvider: []),
            providers: ["claude": cost],
            topProjects: [],
            modelEfficiency: [],
            topSessions: [],
            retryTax: RetryTax(totalUSD: 0, retries: 0, editTurns: 0, byModel: []),
            routingWaste: RoutingWaste(totalSavingsUSD: 0, baselineModel: "", baselineCostPerEdit: 0, byModel: []),
            tools: [],
            skills: [],
            subagents: [],
            mcpServers: []
        ),
        optimize: OptimizeBlock(findingCount: 0, savingsUSD: 0, topFindings: []),
        history: HistoryBlock(daily: []),
        combined: combined,
        claudeConfigs: claudeConfigs
    )
}

@Suite("AppStore refresh recovery")
@MainActor
struct AppStoreRefreshRecoveryTests {
    @Test("stale visible payload triggers hard recovery without clearing cache")
    func stalePayloadTriggersHardRecoveryWithoutClearingCache() {
        let store = AppStore()
        store.setCachedPayloadForTesting(
            menubarPayload(cost: 92.33),
            period: .today,
            provider: .all,
            fetchedAt: Date().addingTimeInterval(-180)
        )

        #expect(store.todayPayload?.current.cost == 92.33)
        #expect(store.needsInteractivePayloadRefresh)
        #expect(store.needsStatusPayloadRefresh)
        #expect(store.hasStaleInteractivePayload)
        #expect(store.shouldResetInteractiveRefreshPipeline)

        store.resetRefreshState(clearCache: false)

        #expect(store.todayPayload?.current.cost == 92.33)
    }

    @Test("fresh visible payload does not trigger hard recovery")
    func freshPayloadDoesNotTriggerHardRecovery() {
        let store = AppStore()
        store.setCachedPayloadForTesting(
            menubarPayload(cost: 164.06),
            period: .today,
            provider: .all,
            fetchedAt: Date()
        )

        #expect(!store.needsInteractivePayloadRefresh)
        #expect(!store.needsStatusPayloadRefresh)
        #expect(!store.hasStaleInteractivePayload)
        #expect(!store.shouldResetInteractiveRefreshPipeline)
    }

    @Test("payload cache partitions local and combined scope")
    func payloadCachePartitionsByScope() {
        let store = AppStore()
        store.setCachedPayloadForTesting(
            menubarPayload(cost: 10),
            scope: .local,
            period: .today,
            provider: .all,
            fetchedAt: Date()
        )
        store.setCachedPayloadForTesting(
            menubarPayload(cost: 99, combined: combinedUsage(cost: 42)),
            scope: .combined,
            period: .today,
            provider: .all,
            fetchedAt: Date()
        )

        #expect(store.cachedPayloadForTesting(scope: .local, period: .today, provider: .all)?.current.cost == 10)
        #expect(store.cachedPayloadForTesting(scope: .combined, period: .today, provider: .all)?.current.cost == 99)

        store.selectedScope = .combined

        #expect(store.payload.current.cost == 10)
        #expect(store.payload.combined?.combined.cost == 42)
    }

    @Test("multi-day combined selection uses local cache path")
    func multiDayCombinedSelectionUsesLocalCachePath() {
        let store = AppStore()
        let days: Set<String> = ["2026-06-01", "2026-06-02"]
        store.selectedScope = .combined
        store.selectedDays = days

        store.setCachedPayloadForTesting(
            menubarPayload(cost: 18),
            scope: .local,
            period: .today,
            provider: .all,
            days: days,
            fetchedAt: Date()
        )
        store.setCachedPayloadForTesting(
            menubarPayload(cost: 99, combined: combinedUsage(cost: 44)),
            scope: .combined,
            period: .today,
            provider: .all,
            days: days,
            fetchedAt: Date()
        )

        #expect(store.activeScope == .local)
        #expect(store.payload.current.cost == 18)
        #expect(store.payload.combined == nil)
    }

    @Test("combined failure state does not invalidate local badge payload")
    func combinedFailureDoesNotInvalidateLocalBadgePayload() {
        let store = AppStore()
        store.setCachedPayloadForTesting(
            menubarPayload(cost: 31),
            scope: .local,
            period: .today,
            provider: .all,
            fetchedAt: Date()
        )
        store.selectedScope = .combined
        store.setLastErrorForTesting(
            "timeout",
            scope: .combined,
            period: .today,
            provider: .all
        )

        #expect(store.lastError == "timeout")
        #expect(store.menubarPayload?.current.cost == 31)
        #expect(!store.needsStatusPayloadRefresh)
        #expect(store.payload.current.cost == 31)
        #expect(store.payload.combined == nil)
    }

    @Test("switching to combined resets selected provider to all")
    func switchingToCombinedResetsSelectedProviderToAll() {
        let store = AppStore()
        store.suppressRefreshesForTesting()
        store.selectedScope = .local
        store.selectedProvider = .claude

        store.switchTo(scope: .combined)

        #expect(store.selectedScope == .combined)
        #expect(store.selectedProvider == .all)
    }

    @Test("selected Claude config partitions payload cache")
    func selectedClaudeConfigPartitionsPayloadCache() {
        let store = AppStore()
        store.setCachedPayloadForTesting(
            menubarPayload(cost: 10, claudeConfigs: claudeConfigSelector()),
            scope: .local,
            period: .today,
            provider: .all,
            fetchedAt: Date()
        )
        store.setCachedPayloadForTesting(
            menubarPayload(cost: 4, claudeConfigs: claudeConfigSelector(selectedId: "claude-config:work")),
            scope: .local,
            period: .today,
            provider: .all,
            claudeConfigSourceId: "claude-config:work",
            fetchedAt: Date()
        )

        #expect(store.payload.current.cost == 10)

        store.selectedClaudeConfigSourceId = "claude-config:work"

        #expect(store.payload.current.cost == 4)
        #expect(store.cachedPayloadForTesting(scope: .local, period: .today, provider: .all)?.current.cost == 10)
        #expect(store.cachedPayloadForTesting(scope: .local, period: .today, provider: .all, claudeConfigSourceId: "claude-config:work")?.current.cost == 4)
    }

    @Test("Claude config selector is hidden until multiple configs are available")
    func claudeConfigSelectorVisibilityRequiresMultipleConfigs() {
        let store = AppStore()
        store.setCachedPayloadForTesting(
            menubarPayload(cost: 1),
            scope: .local,
            period: .today,
            provider: .all,
            fetchedAt: Date()
        )
        #expect(!store.shouldShowClaudeConfigSelector)

        store.setCachedPayloadForTesting(
            menubarPayload(cost: 2, claudeConfigs: claudeConfigSelector()),
            scope: .local,
            period: .today,
            provider: .all,
            fetchedAt: Date()
        )

        #expect(store.shouldShowClaudeConfigSelector)
        #expect(store.claudeConfigOptions.map(\.label) == ["claude-work", "claude-personal"])
    }

    @Test("selecting Claude config resets provider and combined scope")
    func selectingClaudeConfigResetsProviderAndCombinedScope() {
        let store = AppStore()
        store.suppressRefreshesForTesting()
        store.selectedScope = .combined
        store.selectedProvider = .codex

        store.switchTo(claudeConfigSourceId: "claude-config:work")

        #expect(store.selectedClaudeConfigSourceId == "claude-config:work")
        #expect(store.selectedScope == .local)
        #expect(store.selectedProvider == .all)
    }

    @Test("daily budget warning is suppressed for combined scope")
    func dailyBudgetWarningIsSuppressedForCombinedScope() {
        let defaults = UserDefaults.standard
        let previousDisplayMetric = defaults.object(forKey: "CodeBurnDisplayMetric")
        let previousDailyBudget = defaults.object(forKey: "CodeBurnDailyBudget")
        defer {
            if let previousDisplayMetric {
                defaults.set(previousDisplayMetric, forKey: "CodeBurnDisplayMetric")
            } else {
                defaults.removeObject(forKey: "CodeBurnDisplayMetric")
            }
            if let previousDailyBudget {
                defaults.set(previousDailyBudget, forKey: "CodeBurnDailyBudget")
            } else {
                defaults.removeObject(forKey: "CodeBurnDailyBudget")
            }
        }

        let store = AppStore()
        store.selectedScope = .local
        store.selectedDays = []
        store.displayMetric = .cost
        store.dailyBudget = 10
        store.setCachedPayloadForTesting(
            menubarPayload(cost: 12.5),
            scope: .local,
            period: .today,
            provider: .all,
            fetchedAt: Date()
        )

        #expect(store.isOverDailyBudget)
        #expect(store.shouldShowDailyBudgetWarning)

        store.selectedScope = .combined

        #expect(store.isOverDailyBudget)
        #expect(!store.shouldShowDailyBudgetWarning)
    }

    @Test("missing today status payload needs status refresh")
    func missingTodayStatusPayloadNeedsStatusRefresh() {
        let store = AppStore()

        #expect(store.todayPayload == nil)
        #expect(store.needsStatusPayloadRefresh)
    }

    @Test("missing unattempted payload triggers hard recovery")
    func missingUnattemptedPayloadTriggersHardRecovery() {
        let store = AppStore()

        #expect(!store.hasCachedData)
        #expect(!store.hasAttemptedCurrentKeyLoad)
        #expect(store.needsInteractivePayloadRefresh)
        #expect(store.hasMissingInteractivePayloadWithoutAttempt)
        #expect(store.shouldResetInteractiveRefreshPipeline)
    }

    @Test("orphaned stale in-flight entry does not block stuck-loading recovery")
    func staleInFlightDoesNotBlockRecovery() {
        let store = AppStore()
        // A quiet refresh torn down across sleep/wake can leave an in-flight
        // entry behind for the current key with no cache and no active loading
        // counter, far older than the watchdog window. Recovery must clear it
        // and proceed instead of bailing on the in-flight guard forever.
        store.seedInFlightForTesting(period: .today, provider: .all, insertedAt: Date().addingTimeInterval(-3600))

        #expect(store.isInFlightForTesting(period: .today, provider: .all))

        let canRecover = store.prepareStuckLoadingRecovery()

        #expect(canRecover)
        #expect(!store.isInFlightForTesting(period: .today, provider: .all))
    }

    @Test("healthy in-flight fetch is not killed by recovery")
    func healthyInFlightFetchSurvivesRecovery() {
        let store = AppStore()
        store.seedInFlightForTesting(period: .today, provider: .all, insertedAt: Date())

        let canRecover = store.prepareStuckLoadingRecovery()

        #expect(!canRecover)
        #expect(store.isInFlightForTesting(period: .today, provider: .all))
    }

    @Test("prepareStuckLoadingRecovery clears stale loading bookkeeping for the current key")
    func popoverRecoveryClearsStuckLoading() {
        let store = AppStore()
        // Seed an orphaned in-flight entry older than the 60s watchdog so the
        // stale-clear path runs, mimicking a fetch torn down across sleep/wake.
        store.seedInFlightForTesting(
            period: .today,
            provider: .all,
            insertedAt: Date().addingTimeInterval(-120)
        )
        #expect(store.isInFlightForTesting(period: .today, provider: .all))

        let willFetch = store.prepareStuckLoadingRecovery()

        #expect(willFetch)
        #expect(!store.isInFlightForTesting(period: .today, provider: .all))
    }

}
