// @vitest-environment jsdom
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'

import { App, overviewMemoKey, topCategoryByModel, usageSnapshotProps } from './App'
import { sanitizeProps } from '../electron/telemetry'
import { __resetPolledMemo, hasPolledMemo, primePolledMemo } from './hooks/usePolled'
import { setActiveCurrency } from './lib/format'
import type { DateRange, MenubarPayload, ModelReportRow, OptimizeJsonReport, SpendFlow } from './lib/types'

const stored = new Map<string, string>()
vi.stubGlobal('localStorage', {
  getItem: (key: string) => stored.get(key) ?? null,
  setItem: (key: string, value: string) => stored.set(key, value),
  removeItem: (key: string) => stored.delete(key),
  clear: () => stored.clear(),
})

const mocks = vi.hoisted(() => ({
  getOverview: vi.fn<(period: string, provider: string, range?: DateRange, configSource?: string | null, background?: boolean) => Promise<MenubarPayload>>(),
  getSpendFlow: vi.fn<(period: string, provider: string, range?: DateRange) => Promise<SpendFlow>>(),
  getOptimizeReport: vi.fn<(period: string, provider: string, range?: DateRange) => Promise<OptimizeJsonReport>>(),
  getModels: vi.fn(),
  getSessions: vi.fn(),
  getCompareModels: vi.fn(),
  getCompare: vi.fn(),
  getQuota: vi.fn(),
  getPlans: vi.fn(),
  getActReport: vi.fn(),
  getYield: vi.fn(),
  getDevices: vi.fn(),
  getDevicesScan: vi.fn(),
  getIdentity: vi.fn(),
  cliStatus: vi.fn(),
  getPriceOverrides: vi.fn(),
  getAliases: vi.fn(),
  setCurrency: vi.fn(),
  resetCurrency: vi.fn(),
}))

vi.mock('./lib/ipc', async orig => {
  const actual = await orig<typeof import('./lib/ipc')>()
  return { ...actual, codeburn: mocks }
})

function dateKey(d: Date): string {
  return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
}

function setVisibility(state: 'visible' | 'hidden') {
  Object.defineProperty(document, 'visibilityState', { configurable: true, get: () => state })
  Object.defineProperty(document, 'hidden', { configurable: true, get: () => state === 'hidden' })
}

function overviewPayload(): MenubarPayload {
  const now = new Date()
  return {
    generated: now.toISOString(),
    current: {
      label: 'Last 30 days',
      cost: 12.34,
      calls: 12,
      sessions: 2,
      oneShotRate: null,
      inputTokens: 0,
      outputTokens: 0,
      cacheReadTokens: 0,
      cacheWriteTokens: 0,
      cacheHitPercent: 0,
      codexCredits: 0,
      topActivities: [],
      topModels: [],
      localModelSavings: { totalUSD: 0, calls: 0, byModel: [], byProvider: [] },
      providers: { claude: 10, codex: 2 },
      topProjects: [],
      modelEfficiency: [],
      topSessions: [],
      retryTax: { totalUSD: 0, retries: 0, editTurns: 0, byModel: [] },
      routingWaste: { totalSavingsUSD: 0, baselineModel: '', baselineCostPerEdit: 0, byModel: [] },
      tools: [],
      skills: [],
      subagents: [],
      mcpServers: [],
    },
    optimize: { findingCount: 0, savingsUSD: 0, topFindings: [] },
    history: {
      daily: [
        {
          date: dateKey(now),
          cost: 12.34,
          savingsUSD: 0,
          calls: 12,
          inputTokens: 0,
          outputTokens: 0,
          cacheReadTokens: 0,
          cacheWriteTokens: 0,
          topModels: [],
        },
      ],
    },
  }
}

const CONFIG_A = 'claude-config:aaaa000011112222'
const CONFIG_B = 'claude-desktop:bbbb000011112222'

function withConfigs(payload: MenubarPayload): MenubarPayload {
  return {
    ...payload,
    claudeConfigs: {
      selectedId: null,
      options: [
        { id: CONFIG_A, label: 'Default Claude', path: '/Users/x/.claude' },
        { id: CONFIG_B, label: 'Claude Desktop', path: '/Users/x/Library/Application Support/Claude' },
      ],
    },
  }
}

function installDefaultMocks() {
  for (const mock of Object.values(mocks)) mock.mockReset()
  mocks.getOverview.mockResolvedValue(overviewPayload())
  mocks.getSpendFlow.mockResolvedValue({ period: { label: 'Last 30 days', start: '', end: '' }, models: [], projects: [], links: [] })
  mocks.getOptimizeReport.mockResolvedValue({
    period: { label: 'Last 30 days', start: null, end: null },
    summary: {
      healthScore: 100, healthGrade: 'A', findingCount: 0, periodCostUSD: 0,
      sessions: 0, calls: 0, potentialSavingsTokens: 0, potentialSavingsCostUSD: 0,
      potentialSavingsPercent: 0, costRateUSD: 0,
    },
    findings: [],
  })
  mocks.getModels.mockResolvedValue([])
  mocks.getSessions.mockResolvedValue([])
  mocks.getCompareModels.mockResolvedValue([])
  mocks.getQuota.mockResolvedValue([
    { provider: 'claude', connection: 'disconnected', primary: null, details: [], planLabel: null, footerLines: [] },
    { provider: 'codex', connection: 'disconnected', primary: null, details: [], planLabel: null, footerLines: [] },
  ])
  mocks.getPlans.mockResolvedValue({})
  mocks.getActReport.mockResolvedValue({ totals: { realizedCostUSD: 0, measuredActions: 0 } })
  mocks.getYield.mockResolvedValue({
    period: { label: 'Last 30 days', start: '', end: '' },
    summary: {
      productive: { costUSD: 0, sessions: 0, costPercent: 0, sessionPercent: 0 },
      reverted: { costUSD: 0, sessions: 0, costPercent: 0, sessionPercent: 0 },
      abandoned: { costUSD: 0, sessions: 0, costPercent: 0, sessionPercent: 0 },
      total: { costUSD: 0, sessions: 0 },
      productiveToRevertedCostRatio: null,
    },
    details: [],
  })
  mocks.getIdentity.mockResolvedValue({ name: 'CodeBurn Mac', fingerprint: 'AA:BB:CC' })
  mocks.getDevicesScan.mockResolvedValue({ found: [] })
  mocks.getDevices.mockResolvedValue({
    perDevice: [],
    combined: {
      cost: 0,
      calls: 0,
      sessions: 0,
      inputTokens: 0,
      outputTokens: 0,
      cacheCreateTokens: 0,
      cacheReadTokens: 0,
      totalTokens: 0,
      deviceCount: 1,
      reachableCount: 1,
    },
  })
  mocks.getPriceOverrides.mockResolvedValue({ overrides: [] })
  mocks.getAliases.mockResolvedValue([])
  mocks.setCurrency.mockResolvedValue({ ok: true, stdout: '', stderr: '' })
  mocks.resetCurrency.mockResolvedValue({ ok: true, stdout: '', stderr: '' })
}

describe('App shortcuts', () => {
  beforeEach(() => {
    installDefaultMocks()
    localStorage.clear()
    // Pin the boot period so the provider/config tests below are independent of
    // the app-wide default ('today'); tests that exercise the default set it.
    localStorage.setItem('codeburn.defaultPeriod', '30days')
    document.documentElement.removeAttribute('data-theme')
  })

  it('applies the persisted theme on app boot before Settings mounts', async () => {
    localStorage.setItem('codeburn.theme', 'dark')
    render(<App />)
    await waitFor(() => expect(document.documentElement).toHaveAttribute('data-theme', 'dark'))
    expect(screen.queryByRole('heading', { name: 'General' })).not.toBeInTheDocument()
  })

  it('boots with the persisted default period from Settings', async () => {
    localStorage.setItem('codeburn.defaultPeriod', 'week')
    render(<App />)
    await waitFor(() => expect(mocks.getOverview).toHaveBeenCalledWith('week', 'all'))
  })

  it('boots to today when no default period is persisted', async () => {
    localStorage.removeItem('codeburn.defaultPeriod')
    render(<App />)
    await waitFor(() => expect(mocks.getOverview).toHaveBeenCalledWith('today', 'all'))
  })

  it('switches sections with command-number shortcuts', async () => {
    render(<App />)

    expect(await screen.findByText('Most expensive sessions')).toBeInTheDocument()

    fireEvent.keyDown(document, { key: '2', metaKey: true })
    expect(await screen.findByText('No sessions in this range yet.')).toBeInTheDocument()
  })

  it('keeps command navigation, settings, and refresh shortcuts active without stale hints', async () => {
    render(<App />)

    expect(await screen.findByText('Most expensive sessions')).toBeInTheDocument()
    expect(screen.getByText('⌘1-8')).toBeInTheDocument()
    expect(screen.getAllByText('⌘,').length).toBeGreaterThan(0)
    expect(screen.getByText('⌘R')).toBeInTheDocument()
    expect(screen.queryByText('Command')).not.toBeInTheDocument()
    expect(screen.queryByText('Export view')).not.toBeInTheDocument()

    fireEvent.keyDown(document, { key: '2', metaKey: true })
    expect(await screen.findByText('No sessions in this range yet.')).toBeInTheDocument()

    fireEvent.keyDown(document, { key: '3', metaKey: true })
    expect(await screen.findByText(/PR links are captured as sessions are parsed/)).toBeInTheDocument()

    fireEvent.keyDown(document, { key: '4', metaKey: true })
    expect(await screen.findByText('Cost flow · model → project')).toBeInTheDocument()

    fireEvent.keyDown(document, { key: '5', metaKey: true })
    expect(await screen.findByText('No waste findings in this range yet.')).toBeInTheDocument()

    fireEvent.keyDown(document, { key: '6', metaKey: true })
    expect(await screen.findByText('No model usage in this range yet.')).toBeInTheDocument()

    fireEvent.keyDown(document, { key: '7', metaKey: true })
    expect(await screen.findByText('Need at least two models with usage in this range to compare.')).toBeInTheDocument()

    fireEvent.keyDown(document, { key: '8', metaKey: true })
    expect(await screen.findByText('Not connected. Log in with the Claude CLI.')).toBeInTheDocument()

    fireEvent.keyDown(document, { key: ',', metaKey: true })
    expect((await screen.findAllByText('Settings')).length).toBeGreaterThan(0)
    expect(screen.queryByText('Back')).not.toBeInTheDocument()

    const overviewCalls = mocks.getOverview.mock.calls.length
    fireEvent.keyDown(document, { key: 'r', metaKey: true })
    await waitFor(() => expect(mocks.getOverview.mock.calls.length).toBeGreaterThan(overviewCalls))
  })

  it('re-polls visible section data when period or provider changes', async () => {
    render(<App />)

    fireEvent.keyDown(document, { key: '4', metaKey: true })
    expect(await screen.findByText('Cost flow · model → project')).toBeInTheDocument()

    fireEvent.click(screen.getByText('Today'))

    await waitFor(() => {
      expect(mocks.getOverview).toHaveBeenCalledWith('today', 'all')
      expect(mocks.getSpendFlow).toHaveBeenCalledWith('today', 'all')
    })

    fireEvent.click(screen.getByText('All providers'))
    fireEvent.click(await screen.findByRole('option', { name: 'Claude' }))

    await waitFor(() => {
      expect(mocks.getOverview).toHaveBeenCalledWith('today', 'claude')
      expect(mocks.getSpendFlow).toHaveBeenCalledWith('today', 'claude')
    })
  })

  it('builds the provider picker from providerDetails so display-name providers round-trip their internal id', async () => {
    // grok's display name is "Grok Build"; the picker must show the label but
    // send the internal id `grok` as --provider (which assertProvider accepts).
    const payload = overviewPayload()
    payload.current.providers = { 'grok build': 5, claude: 10 }
    payload.current.providerDetails = [
      { id: 'grok', label: 'Grok Build', cost: 5 },
      { id: 'claude', label: 'Claude', cost: 10 },
    ]
    mocks.getOverview.mockResolvedValue(payload)

    render(<App />)
    expect(await screen.findByText('Most expensive sessions')).toBeInTheDocument()

    fireEvent.click(screen.getByText('All providers'))
    fireEvent.click(await screen.findByRole('option', { name: 'Grok Build' }))

    await waitFor(() => expect(mocks.getOverview).toHaveBeenCalledWith('30days', 'grok'))
  })

  it('lists a detected provider with no spend this period and sorts it last', async () => {
    // Hermes has usage only outside the current period: the CLI still emits it
    // as a detected provider (cost 0), so the picker must show it, at the bottom.
    const payload = overviewPayload()
    payload.current.providers = { claude: 10, hermes: 0 }
    payload.current.providerDetails = [
      { id: 'claude', label: 'Claude', cost: 10 },
      { id: 'hermes', label: 'Hermes', cost: 0 },
    ]
    mocks.getOverview.mockResolvedValue(payload)

    render(<App />)
    expect(await screen.findByText('Most expensive sessions')).toBeInTheDocument()

    fireEvent.click(screen.getByText('All providers'))
    const claudeOption = await screen.findByRole('option', { name: 'Claude' })
    const hermesOption = screen.getByRole('option', { name: 'Hermes' })
    const options = screen.getAllByRole('option')
    // Zero-cost Hermes appears, and sorts after the provider that has spend.
    expect(options.indexOf(hermesOption)).toBeGreaterThan(options.indexOf(claudeOption))

    fireEvent.click(hermesOption)
    await waitFor(() => expect(mocks.getOverview).toHaveBeenCalledWith('30days', 'hermes'))
  })

  it('hides the Claude config picker when the payload carries no claudeConfigs', async () => {
    render(<App />)
    expect(await screen.findByText('Most expensive sessions')).toBeInTheDocument()
    expect(screen.queryByRole('button', { name: 'Claude config source' })).not.toBeInTheDocument()
  })

  it('shows the config picker, re-fetches overview with the flag, and persists the choice', async () => {
    mocks.getOverview.mockResolvedValue(withConfigs(overviewPayload()))
    render(<App />)

    const trigger = await screen.findByRole('button', { name: 'Claude config source' })
    fireEvent.click(trigger)
    fireEvent.click(await screen.findByRole('option', { name: 'Default Claude' }))

    await waitFor(() => expect(mocks.getOverview).toHaveBeenCalledWith('30days', 'all', undefined, CONFIG_A))
    expect(localStorage.getItem('codeburn.claudeConfigSource')).toBe(CONFIG_A)
  })

  it('resets a non-Claude provider filter to all when a config is selected', async () => {
    const payload = withConfigs(overviewPayload())
    payload.current.providers = { claude: 10, codex: 2 }
    mocks.getOverview.mockResolvedValue(payload)
    render(<App />)
    expect(await screen.findByText('Most expensive sessions')).toBeInTheDocument()

    fireEvent.click(screen.getByRole('button', { name: 'Providers' }))
    fireEvent.click(await screen.findByRole('option', { name: 'Codex' }))
    await waitFor(() => expect(mocks.getOverview).toHaveBeenCalledWith('30days', 'codex'))

    fireEvent.click(screen.getByRole('button', { name: 'Claude config source' }))
    fireEvent.click(await screen.findByRole('option', { name: 'Default Claude' }))

    await waitFor(() => expect(mocks.getOverview).toHaveBeenCalledWith('30days', 'all', undefined, CONFIG_A))
    // The Claude-incompatible provider filter must never reach the CLI with the flag.
    expect(mocks.getOverview.mock.calls).not.toContainEqual(['30days', 'codex', undefined, CONFIG_A])
  })

  it('clears the config scope when a non-Claude provider is picked afterwards', async () => {
    const payload = withConfigs(overviewPayload())
    payload.current.providers = { claude: 10, codex: 2 }
    mocks.getOverview.mockResolvedValue(payload)
    render(<App />)
    expect(await screen.findByText('Most expensive sessions')).toBeInTheDocument()

    fireEvent.click(screen.getByRole('button', { name: 'Claude config source' }))
    fireEvent.click(await screen.findByRole('option', { name: 'Default Claude' }))
    await waitFor(() => expect(mocks.getOverview).toHaveBeenCalledWith('30days', 'all', undefined, CONFIG_A))

    fireEvent.click(screen.getByRole('button', { name: 'Providers' }))
    fireEvent.click(await screen.findByRole('option', { name: 'Codex' }))

    await waitFor(() => expect(mocks.getOverview).toHaveBeenCalledWith('30days', 'codex'))
    // The incompatible combination must never reach the CLI.
    expect(mocks.getOverview.mock.calls).not.toContainEqual(['30days', 'codex', undefined, CONFIG_A])
    expect(localStorage.getItem('codeburn.claudeConfigSource')).toBeNull()
  })

  it('boots with the persisted config source and clears it via All Claude configs', async () => {
    localStorage.setItem('codeburn.claudeConfigSource', CONFIG_A)
    mocks.getOverview.mockResolvedValue(withConfigs(overviewPayload()))
    render(<App />)

    await waitFor(() => expect(mocks.getOverview).toHaveBeenCalledWith('30days', 'all', undefined, CONFIG_A))

    fireEvent.click(await screen.findByRole('button', { name: 'Claude config source' }))
    fireEvent.click(await screen.findByRole('option', { name: 'All Claude configs' }))

    await waitFor(() => expect(mocks.getOverview).toHaveBeenCalledWith('30days', 'all'))
    expect(localStorage.getItem('codeburn.claudeConfigSource')).toBeNull()
  })

  it('applies a calendar range to overview and visible section polls', async () => {
    render(<App />)

    fireEvent.keyDown(document, { key: '4', metaKey: true })
    expect(await screen.findByText('Cost flow · model → project')).toBeInTheDocument()
    fireEvent.click(screen.getByRole('button', { name: 'Choose date range' }))

    const to = new Date()
    const from = new Date(to.getFullYear(), to.getMonth(), to.getDate() - 2)
    const fromLabel = from.toLocaleDateString('en-US', { weekday: 'long', month: 'long', day: 'numeric', year: 'numeric' })
    const toLabel = to.toLocaleDateString('en-US', { weekday: 'long', month: 'long', day: 'numeric', year: 'numeric' })
    const range = { from: dateKey(from), to: dateKey(to) }

    fireEvent.mouseDown(screen.getByRole('button', { name: fromLabel }))
    fireEvent.mouseEnter(screen.getByRole('button', { name: toLabel }))
    fireEvent.mouseUp(screen.getByRole('button', { name: toLabel }))

    await waitFor(() => {
      expect(mocks.getOverview).toHaveBeenCalledWith('30days', 'all', range)
      expect(mocks.getSpendFlow).toHaveBeenCalledWith('30days', 'all', range)
    })
    expect(screen.getByRole('button', { name: /–/ })).toBeInTheDocument()
    expect(screen.getByText('30D')).not.toHaveClass('on')
  })

  it('shows no daily budget banner when none is configured', async () => {
    render(<App />)
    expect(await screen.findByText('Most expensive sessions')).toBeInTheDocument()
    expect(screen.queryByText(/daily budget/i)).not.toBeInTheDocument()
  })

  it('shows no banner when today spend is under 80% of the budget', async () => {
    localStorage.setItem('codeburn.dailyBudget', JSON.stringify({ kind: 'usd', value: 100 }))
    render(<App />)
    expect(await screen.findByText('Most expensive sessions')).toBeInTheDocument()
    expect(screen.queryByText(/daily budget/i)).not.toBeInTheDocument()
  })

  it('warns when today spend reaches 80% of the daily budget', async () => {
    localStorage.setItem('codeburn.dailyBudget', JSON.stringify({ kind: 'usd', value: 14 }))
    render(<App />)
    // 12.34 / 14 = 88.1% → warning band
    expect(await screen.findByText("Today's spend is at 88% of your daily budget")).toBeInTheDocument()
  })

  it('alerts and dismisses for the rest of the day when the budget is exceeded', async () => {
    localStorage.setItem('codeburn.dailyBudget', JSON.stringify({ kind: 'usd', value: 10 }))
    render(<App />)
    expect(await screen.findByText('Daily budget exceeded: $12.34 of $10.00')).toBeInTheDocument()

    fireEvent.click(screen.getByRole('button', { name: 'Dismiss' }))
    await waitFor(() => expect(screen.queryByText(/Daily budget exceeded/)).not.toBeInTheDocument())
    expect(localStorage.getItem('codeburn.dailyBudget.dismissed')).toBe(dateKey(new Date()))
  })

  it('evaluates a token budget only on the all-providers view', async () => {
    const payload = overviewPayload()
    payload.history.daily[0]!.inputTokens = 60_000
    payload.history.daily[0]!.outputTokens = 40_000
    mocks.getOverview.mockResolvedValue(payload)
    localStorage.setItem('codeburn.dailyBudget', JSON.stringify({ kind: 'tokens', value: 90_000 }))
    render(<App />)
    expect(await screen.findByText('Daily budget exceeded: 100K of 90K')).toBeInTheDocument()

    // A specific-provider filter zeroes history.daily token fields, so the token
    // cap can no longer be evaluated: the banner must disappear.
    fireEvent.click(screen.getByText('All providers'))
    fireEvent.click(await screen.findByRole('option', { name: 'Claude' }))
    await waitFor(() => expect(mocks.getOverview).toHaveBeenCalledWith('30days', 'claude'))
    await waitFor(() => expect(screen.queryByText(/Daily budget exceeded/)).not.toBeInTheDocument())
  })
})

describe('provider prefetch storm', () => {
  const PROVIDERS = [
    'claude', 'codex', 'gemini', 'grok', 'copilot', 'droid',
    'hermes', 'zcode', 'cursor', 'kiro', 'codewhale', 'openrouter',
  ]

  function manyProviderPayload(): MenubarPayload {
    const base = overviewPayload()
    const providers: Record<string, number> = {}
    PROVIDERS.forEach((id, i) => { providers[id] = PROVIDERS.length - i })
    return { ...base, current: { ...base.current, providers } }
  }

  beforeEach(() => {
    for (const mock of Object.values(mocks)) mock.mockReset()
    mocks.getOverview.mockResolvedValue(manyProviderPayload())
    mocks.getActReport.mockResolvedValue({ totals: { realizedCostUSD: 0, measuredActions: 0 } })
    mocks.getYield.mockResolvedValue({
      period: { label: 'Last 30 days', start: '', end: '' },
      summary: {
        productive: { costUSD: 0, sessions: 0, costPercent: 0, sessionPercent: 0 },
        reverted: { costUSD: 0, sessions: 0, costPercent: 0, sessionPercent: 0 },
        abandoned: { costUSD: 0, sessions: 0, costPercent: 0, sessionPercent: 0 },
        total: { costUSD: 0, sessions: 0 },
        productiveToRevertedCostRatio: null,
      },
      details: [],
    })
    localStorage.clear()
    // Pin the cadence to 30s so the fake-timer soak math below is independent of
    // the app-wide default (bumped to 60s for energy).
    localStorage.setItem('codeburn.refreshInterval', '30s')
    // Pin the boot period so these prefetch assertions are independent of the
    // app-wide default ('today').
    localStorage.setItem('codeburn.defaultPeriod', '30days')
    __resetPolledMemo()
  })

  // With MEMO_MAX too small (< providers + base keys) the base overview key
  // LRU-evicts between polls, blanking the overview and re-arming the prefetch
  // every 30s cycle: 12 redundant full-history parses forever. This asserts the
  // fix — each provider is prefetched EXACTLY ONCE total across three cycles.
  it('prefetches each detected provider exactly once across 3 poll cycles', async () => {
    vi.useFakeTimers()
    try {
      render(<App />)
      // Let the mount overview resolve so `ready` flips and the prefetch arms.
      await act(async () => { await vi.advanceTimersByTimeAsync(3_000) })
      // Three full 30s poll cycles plus the prefetch start delay and staggered
      // per-provider warms (12 × 400ms). A re-arming storm would re-spawn some
      // providers on cycles 2 and 3; the once-per-key guard must prevent it.
      await act(async () => { await vi.advanceTimersByTimeAsync(30_000 * 3 + 12_000) })

      for (const id of PROVIDERS) {
        const spawns = mocks.getOverview.mock.calls.filter(
          // Prefetch warms carry the background-priority flag (5th arg).
          c => c[0] === '30days' && c[1] === id && c[2] === undefined && c[3] === undefined && c[4] === true,
        )
        expect(spawns.length, `prefetch spawns for ${id}`).toBe(1)
      }

      // Sanity: the active 'all' view was polled every cycle (not prefetch-gated).
      const allPolls = mocks.getOverview.mock.calls.filter(c => c[1] === 'all')
      expect(allPolls.length).toBeGreaterThanOrEqual(3)

      // The memo must be sized to hold every warmed provider so none LRU-evict
      // between polls — the eviction that (in the real app) blanked the base
      // overview key and re-armed the prefetch. Under the old fixed cap of 8,
      // 7 of these 12 keys would have been evicted by soak's end.
      for (const id of PROVIDERS) {
        expect(hasPolledMemo(overviewMemoKey(id, '30days', null, null)), `warm key ${id}`).toBe(true)
      }
    } finally {
      vi.useRealTimers()
    }
  })
})

describe('energy: hidden-window polling', () => {
  beforeEach(() => {
    installDefaultMocks()
    localStorage.clear()
    localStorage.setItem('codeburn.refreshInterval', '30s') // pin cadence for the soak math
    __resetPolledMemo()
  })

  // The whole app's data flows through usePolled, which is the ONLY driver of CLI
  // spawns (each codeburn.getX → IPC → spawnCli). This measures that a hidden
  // window issues ZERO new interval spawns, and that visibility resumes them —
  // the unit-level stand-in for the packaged visible-vs-hidden sample.
  it('issues zero new interval spawns while hidden and resumes when visible', async () => {
    vi.useFakeTimers()
    try {
      setVisibility('visible')
      render(<App />)
      // Boot + three visible 30s cadences: the overview section's yield poll (a
      // pure usePolled interval, never prefetched) fires each cadence.
      await act(async () => { await vi.advanceTimersByTimeAsync(3_000) })
      await act(async () => { await vi.advanceTimersByTimeAsync(30_000 * 3) })
      const visibleYield = mocks.getYield.mock.calls.length
      expect(visibleYield).toBeGreaterThan(1) // polling while visible

      // Hidden for five cadences: not a single new spawn on any poller.
      setVisibility('hidden')
      const atHideYield = mocks.getYield.mock.calls.length
      const atHideOverview = mocks.getOverview.mock.calls.length
      await act(async () => { await vi.advanceTimersByTimeAsync(30_000 * 5) })
      expect(mocks.getYield.mock.calls.length).toBe(atHideYield)
      expect(mocks.getOverview.mock.calls.length).toBe(atHideOverview)

      // Back to visible: the stale-by-a-cadence polls catch up immediately.
      setVisibility('visible')
      await act(async () => { document.dispatchEvent(new Event('visibilitychange')) })
      await act(async () => { await vi.advanceTimersByTimeAsync(0) })
      expect(mocks.getYield.mock.calls.length).toBeGreaterThan(atHideYield)
      expect(mocks.getOverview.mock.calls.length).toBeGreaterThan(atHideOverview)
    } finally {
      setVisibility('visible')
      vi.useRealTimers()
      localStorage.clear()
    }
  })
})

describe('currency correctness', () => {
  const USD = { code: 'USD', symbol: '$', rate: 1 }
  const EUR = { code: 'EUR', symbol: '€', rate: 0.9 }

  beforeEach(() => {
    installDefaultMocks()
    // Reset the module-level display currency so a prior test never bleeds in.
    setActiveCurrency(USD)
    localStorage.clear()
    // Pin the boot period so the memo keys below match the app's boot fetch,
    // independent of the app-wide default ('today').
    localStorage.setItem('codeburn.defaultPeriod', '30days')
    __resetPolledMemo()
  })

  it('never regresses the applied currency to a memo-served (stale) payload during a switch', async () => {
    const usd = { ...overviewPayload(), currency: USD }
    // A stale EUR payload cached for `claude`, as if warmed before a currency
    // change. The claude fetch is left pending so `switching` stays true and the
    // memo-served EUR payload is what's on screen during the assertion window.
    const eur = { ...overviewPayload(), currency: EUR }
    mocks.getOverview.mockImplementation((_period: string, provider: string) =>
      provider === 'claude' ? new Promise<MenubarPayload>(() => {}) : Promise.resolve(usd))
    primePolledMemo(overviewMemoKey('claude', '30days', null, null), eur)

    render(<App />)
    // Boot on the USD ('all') view.
    expect(await screen.findByText('Most expensive sessions')).toBeInTheDocument()
    expect(screen.queryByText(/€/)).not.toBeInTheDocument()

    // Switch to claude: usePolled paints the memoized EUR payload (switching) while
    // its fresh fetch hangs. The currency effect must NOT apply that stale EUR.
    fireEvent.click(screen.getByText('All providers'))
    fireEvent.click(await screen.findByRole('option', { name: 'Claude' }))
    await waitFor(() => expect(mocks.getOverview).toHaveBeenCalledWith('30days', 'claude'))

    expect(screen.queryByText(/€/)).not.toBeInTheDocument()
  })

  it('clears the instant-switch memo and force-refreshes when currency is reset', async () => {
    render(<App />)
    expect(await screen.findByText('Most expensive sessions')).toBeInTheDocument()

    // A warmed entry (as the prefetcher would leave one) that must be purged so a
    // later switch can't repaint a payload computed under the old currency.
    primePolledMemo('sentinel-warmed-key', { stale: true })
    expect(hasPolledMemo('sentinel-warmed-key')).toBe(true)

    fireEvent.keyDown(document, { key: ',', metaKey: true })
    const overviewCalls = mocks.getOverview.mock.calls.length
    fireEvent.click(await screen.findByRole('button', { name: 'Reset to USD' }))

    await waitFor(() => expect(mocks.resetCurrency).toHaveBeenCalled())
    // Memo purged and the active view force-refreshed so the new currency lands fast.
    await waitFor(() => expect(mocks.getOverview.mock.calls.length).toBeGreaterThan(overviewCalls))
    expect(hasPolledMemo('sentinel-warmed-key')).toBe(false)
  })
})

describe('usage_snapshot telemetry props', () => {
  // The renderer builds these props; the main-process sanitizer (sanitizeProps)
  // is the last gate before the wire. Test the composition, which is what ships.
  function enrichedPayload(): MenubarPayload {
    const p = overviewPayload()
    p.current.cost = 42
    p.current.topModels = [
      { name: 'claude-opus-4-8', cost: 30, savingsUSD: 0, savingsBaselineModel: '', calls: 400 },
      { name: 'M'.repeat(80), cost: 0.5, savingsUSD: 0, savingsBaselineModel: '', calls: 2 },
    ]
    p.current.topActivities = [
      { name: 'coding', cost: 20, savingsUSD: 0, turns: 100, oneShotRate: 0.6123 },
      { name: 'debugging', cost: 10, savingsUSD: 0, turns: 40, oneShotRate: null },
    ]
    p.current.mcpServers = [
      { name: 'context7', calls: 5 },
      { name: 'S'.repeat(80), calls: 250 },
      { name: 'shadcn', calls: 1500 },
    ]
    p.current.skills = [
      { name: 'graphify', turns: 3, cost: 0 },
      { name: 'council', turns: 150, cost: 0 },
    ]
    // A path-like project name that MUST NEVER reach telemetry: the snapshot never
    // reads topProjects, and this guards against a future field accidentally doing so.
    p.current.topProjects = [{
      name: '/Users/torukmakto/secret-client/private-repo',
      cost: 42, savingsUSD: 0, sessions: 1, avgCostPerSession: 42, sessionDetails: [],
    }]
    return p
  }

  it('includes MCP servers and skills as names + bucketed usage', () => {
    const props = sanitizeProps(usageSnapshotProps(enrichedPayload()))

    const mcp = props.mcpServers as Array<{ name: string; callBucket: string }>
    expect(mcp.map(m => [m.name.slice(0, 8), m.callBucket])).toEqual([
      ['context7', '1-10'],
      ['SSSSSSSS', '100-1k'],
      ['shadcn', '1k+'],
    ])

    const skills = props.skills as Array<{ name: string; callBucket: string }>
    // Skills are measured in turns; buckets mirror the count scale.
    expect(skills).toEqual([
      { name: 'graphify', callBucket: '1-10' },
      { name: 'council', callBucket: '100-1k' },
    ])
  })

  it('truncates over-long names at the 64-char sanitizer cap', () => {
    const props = sanitizeProps(usageSnapshotProps(enrichedPayload()))
    const mcp = props.mcpServers as Array<{ name: string }>
    const models = props.models as Array<{ name: string }>
    expect(mcp[1]!.name.length).toBe(64)
    expect(models[1]!.name.length).toBe(64)
  })

  it('never leaks a filesystem path or project name', () => {
    const serialized = JSON.stringify(sanitizeProps(usageSnapshotProps(enrichedPayload())))
    expect(serialized).not.toContain('/Users/')
    expect(serialized).not.toContain('secret-client')
    expect(serialized).not.toContain('private-repo')
  })

  function modelRow(over: Partial<ModelReportRow> & Pick<ModelReportRow, 'model' | 'modelDisplayName'>): ModelReportRow {
    return {
      provider: 'claude', providerDisplayName: 'Claude', category: null,
      inputTokens: 0, outputTokens: 0, cacheWriteTokens: 0, cacheReadTokens: 0, totalTokens: 0,
      costUSD: 0, savingsUSD: 0, savingsBaselineModel: '', calls: 0, credits: null, ...over,
    }
  }

  it('caps providers at 8, sorted by cost descending, bucketed', () => {
    const p = enrichedPayload()
    p.current.providers = {
      claude: 500, codex: 40, gemini: 5, cursor: 0.5, antigravity: 300,
      copilot: 20, windsurf: 2, amp: 0.1, cline: 0.02,
    }
    const props = sanitizeProps(usageSnapshotProps(p))
    const providers = props.providers as Array<{ name: string; costBucket: string }>
    expect(providers).toHaveLength(8)
    expect(providers).toEqual([
      { name: 'claude', costBucket: '200-1k' },
      { name: 'antigravity', costBucket: '200-1k' },
      { name: 'codex', costBucket: '10-50' },
      { name: 'copilot', costBucket: '10-50' },
      { name: 'gemini', costBucket: '1-10' },
      { name: 'windsurf', costBucket: '1-10' },
      { name: 'cursor', costBucket: '<1' },
      { name: 'amp', costBucket: '<1' },
    ])
  })

  it('crosses each top model with its dominant task category when the report joins', () => {
    // The overview model name is the display/short name; for Claude that equals
    // modelDisplayName, which is how the by-model report row joins back.
    const rows = [
      modelRow({ model: 'claude-opus-4-20260101', modelDisplayName: 'claude-opus-4-8', topCategory: 'coding' }),
    ]
    const props = sanitizeProps(usageSnapshotProps(enrichedPayload(), topCategoryByModel(rows)))
    const models = props.models as Array<Record<string, unknown>>
    expect(models[0]).toEqual({ name: 'claude-opus-4-8', costBucket: '10-50', topCategory: 'coding' })
    // A model the report has no category for carries name + costBucket only, never a fabricated cross.
    expect(Object.keys(models[1]!).sort()).toEqual(['costBucket', 'name'])
  })

  it('still emits a valid snapshot without topCategory when the by-model fetch fails', () => {
    // The graceful-degradation path: usageSnapshotProps is called with no category map.
    const props = sanitizeProps(usageSnapshotProps(enrichedPayload()))
    const models = props.models as Array<Record<string, unknown>>
    for (const m of models) expect(Object.keys(m).sort()).toEqual(['costBucket', 'name'])
    // Everything else the snapshot carries is intact.
    expect((props.mcpServers as unknown[]).length).toBe(3)
    expect((props.skills as unknown[]).length).toBe(2)
    expect(props.categories).toEqual([
      { name: 'coding', oneShotRate: 0.61 },
      { name: 'debugging', oneShotRate: -1 },
    ])
  })
})
