import { beforeEach, describe, expect, it, vi } from 'vitest'

import { getOverlayState, resetOverlayState } from '../app/overlayStore.js'
import { topupCommands } from '../app/slash/commands/topup.js'
import type { BillingStateResponse } from '../gatewayTypes.js'
import { t } from '../i18n/runtime.js'

vi.mock('../lib/openExternalUrl.js', () => ({
  openExternalUrl: vi.fn(() => true)
}))

const topupCommand = topupCommands.find(cmd => cmd.name === 'topup')!

const ownerState = (overrides: Partial<BillingStateResponse> = {}): BillingStateResponse => ({
  auto_reload: {
    card: { kind: 'canonical' },
    enabled: false,
    reload_to_display: '—',
    reload_to_usd: null,
    threshold_display: '—',
    threshold_usd: null
  },
  balance_display: '$142.50',
  balance_usd: '142.5',
  can_charge: true,
  card: { brand: 'visa', last4: '4242', masked: 'visa ····4242' },
  charge_presets: ['25', '50', '100'],
  charge_presets_display: ['$25', '$50', '$100'],
  cli_billing_enabled: true,
  is_admin: true,
  logged_in: true,
  max_usd: '10000',
  min_usd: '10',
  monthly_cap: {
    is_default_ceiling: true,
    limit_display: '$1000',
    limit_usd: '1000',
    spent_display: '$180',
    spent_this_month_usd: '180'
  },
  ok: true,
  org_name: 'Acme',
  portal_url: 'https://portal/billing?topup=open',
  role: 'OWNER',
  ...overrides
})

const guarded =
  <T>(fn: (r: T) => void) =>
  (r: null | T) => {
    if (r) {
      fn(r)
    }
  }

/** Build a ctx whose rpc routes by method name to a supplied map of results. */
const buildCtx = (results: Record<string, unknown>) => {
  const sys = vi.fn()
  const calls: Array<{ method: string; params: unknown }> = []

  const rpc = vi.fn((method: string, params: unknown) => {
    calls.push({ method, params })

    return Promise.resolve(results[method])
  })

  const ctx = {
    gateway: { rpc },
    guarded,
    guardedErr: vi.fn(),
    sid: 'sid-1',
    stale: () => false,
    transcript: { page: vi.fn(), panel: vi.fn(), sys }
  }

  const run = async (arg: string) => {
    topupCommand.run(arg, ctx as any, 'topup')
    await rpc.mock.results[0]?.value
    await Promise.resolve()
    await Promise.resolve()
  }

  return { calls, ctx, rpc, run, sys }
}

const printed = (sys: ReturnType<typeof vi.fn>) => sys.mock.calls.map(c => c[0]).join('\n')

describe('/billing slash command (overlay-driven)', () => {
  beforeEach(() => {
    resetOverlayState()
  })

  it('not logged in → prompts to log in, no overlay', async () => {
    const { run, sys } = buildCtx({ 'billing.state': { ...ownerState(), logged_in: false, ok: true } })
    await run('')
    expect(sys).toHaveBeenCalled()
    expect(getOverlayState().billing).toBeNull()
  })

  it('bare /billing opens the overlay on the overview screen with state', async () => {
    const { run, rpc } = buildCtx({ 'billing.state': ownerState() })
    await run('')
    expect(rpc).toHaveBeenCalledWith('billing.state', {})
    const billing = getOverlayState().billing
    expect(billing).toBeTruthy()
    expect(billing?.screen).toBe('overview')
    expect(billing?.state.balance_display).toBe('$142.50')
    expect(billing?.state.charge_presets_display).toEqual(['$25', '$50', '$100'])
  })

  it('any sub-command arg is ignored — still opens the overview overlay', async () => {
    const { run } = buildCtx({ 'billing.state': ownerState() })
    await run('buy 100')
    const billing = getOverlayState().billing
    expect(billing?.screen).toBe('overview')
    // No confirm overlay armed directly by the command anymore.
    expect(getOverlayState().confirm).toBeNull()
  })

  // ── Overlay ctx behaviors (RPC + error mapping live in billing.ts) ──

  it('ctx.validate rejects out-of-bounds and sub-cent amounts, accepts valid', async () => {
    const { run } = buildCtx({ 'billing.state': ownerState() })
    await run('')
    const ctx = getOverlayState().billing!.ctx
    expect(ctx.validate('5').error).toBe(t('slashCmd.topup.validate.minimum', '10'))
    expect(ctx.validate('10.005').error).toBe(t('slashCmd.topup.validate.invalid'))
    expect(ctx.validate('100').amount).toBe('100')
    expect(ctx.validate('$50').amount).toBe('50')
  })

  it('ctx.charge → poll → settled', async () => {
    vi.useFakeTimers()

    try {
      const { run, sys } = buildCtx({
        'billing.state': ownerState(),
        'billing.charge': { ok: true, charge_id: 'ch_1', idempotency_key: 'k' },
        'billing.charge_status': { ok: true, status: 'settled', amount_usd: '100' }
      })

      await run('')
      const ctx = getOverlayState().billing!.ctx
      ctx.charge('100')
      await vi.runAllTimersAsync()
      const out = printed(sys)
      expect(out).toContain(t('slashCmd.topup.charge.submitted'))
      expect(out).toContain(t('slashCmd.topup.charge.settled', '$100'))
    } finally {
      vi.useRealTimers()
    }
  })

  it('ctx.charge → poll → failed adds the portal funnel line', async () => {
    vi.useFakeTimers()

    try {
      const { run, sys } = buildCtx({
        'billing.state': ownerState(),
        'billing.charge': { ok: true, charge_id: 'ch_1', idempotency_key: 'k' },
        'billing.charge_status': { ok: true, status: 'failed', reason: 'card_declined' }
      })

      await run('')
      getOverlayState().billing!.ctx.charge('100')
      await vi.runAllTimersAsync()
      const out = printed(sys)
      expect(out).toContain(t('slashCmd.topup.charge.cardDeclined'))
      // Parity with the CLI: a failed poll funnels to the portal (from state.portal_url).
      expect(out).toContain(t('slashCmd.topup.error.portal', 'https://portal/billing?topup=open'))
    } finally {
      vi.useRealTimers()
    }
  })

  it('ctx.charge monthly_cap_exceeded surfaces remaining headroom', async () => {
    const { run, sys } = buildCtx({
      'billing.state': ownerState(),
      'billing.charge': {
        ok: false,
        error: 'monthly_cap_exceeded',
        message: 'Monthly spend cap reached.',
        payload: { remainingUsd: '42.50' },
        portal_url: '/billing?topup=open',
        idempotency_key: 'k'
      }
    })

    await run('')
    getOverlayState().billing!.ctx.charge('100')
    await Promise.resolve()
    await Promise.resolve()
    const out = printed(sys)
    expect(out).toContain(t('slashCmd.topup.error.monthlyCapExceededRemaining', '42.50'))
    expect(out).toContain(t('slashCmd.topup.error.portal', '/billing?topup=open'))
  })

  it('ctx.charge consent_required → one-time portal confirmation copy + portal funnel', async () => {
    const { run, sys } = buildCtx({
      'billing.state': ownerState(),
      'billing.charge': {
        ok: false,
        error: 'consent_required',
        portal_url: '/billing/consent',
        idempotency_key: 'k'
      }
    })

    await run('')
    await getOverlayState().billing!.ctx.charge('100')
    const out = printed(sys)
    expect(out).toContain(t('slashCmd.topup.error.consentRequired'))
    expect(out).toContain(t('slashCmd.topup.error.portal', '/billing/consent'))
  })

  it.each([
    [undefined, t('slashCmd.topup.error.stripeUnavailable', '')],
    [120, t('slashCmd.topup.error.stripeUnavailable', t('slashCmd.topup.error.retryIn', '2'))]
  ])('ctx.charge stripe_unavailable (retry_after=%s) → transient Stripe copy', async (retryAfter, copy) => {
    const { run, sys } = buildCtx({
      'billing.state': ownerState(),
      'billing.charge': {
        ok: false,
        error: 'stripe_unavailable',
        idempotency_key: 'k',
        ...(retryAfter == null ? {} : { retry_after: retryAfter })
      }
    })

    await run('')
    await getOverlayState().billing!.ctx.charge('100')
    const out = printed(sys)
    expect(out).toContain(copy)
    expect(out).not.toContain(t('slashCmd.topup.error.rateLimited', ''))
  })

  it('ctx.charge insufficient_scope → resolves needs_remote_spending (overlay routes to stepup)', async () => {
    const { run } = buildCtx({
      'billing.state': ownerState(),
      'billing.charge': { ok: false, error: 'insufficient_scope', idempotency_key: 'k' }
    })

    await run('')
    const outcome = await getOverlayState().billing!.ctx.charge('100')
    // No separate confirm overlay is armed anymore — the overlay's stepup
    // screen owns the UX; the ctx just reports the outcome.
    expect(outcome).toBe('needs_remote_spending')
    expect(getOverlayState().confirm).toBeNull()
  })

  it.each([[true], [false]])('ctx.requestRemoteSpending → billing.step_up resolves %s', async granted => {
    const { run, calls } = buildCtx({ 'billing.state': ownerState(), 'billing.step_up': { ok: true, granted } })

    await run('')
    expect(await getOverlayState().billing!.ctx.requestRemoteSpending()).toBe(granted)
    expect(calls.find(c => c.method === 'billing.step_up')).toBeTruthy()
  })

  // ── CF-4: revoked-terminal UX (kill the "15-minute zombie button") ──

  it.each([['admin', t('slashCmd.topup.error.revokedByAdmin')]])(
    'ctx.charge remote_spending_revoked (%s) → clears the overlay (no zombie button) + actor copy',
    async (actor, copy) => {
      const { run, sys } = buildCtx({
        'billing.state': ownerState(),
        'billing.charge': {
          ok: false,
          error: 'remote_spending_revoked',
          actor,
          recovery: 'reconnect',
          idempotency_key: 'k'
        }
      })

      await run('')
      getOverlayState().billing!.ctx.charge('100')
      await Promise.resolve()
      await Promise.resolve()
      expect(printed(sys)).toContain(copy)
      expect(getOverlayState().billing).toBeNull()
    }
  )

  it('ctx.charge session_revoked → clears overlay + re-login (not reconnect) copy', async () => {
    const { run, sys } = buildCtx({
      'billing.state': ownerState(),
      'billing.charge': { ok: false, error: 'session_revoked', recovery: 'login', idempotency_key: 'k' }
    })

    await run('')
    getOverlayState().billing!.ctx.charge('100')
    await Promise.resolve()
    await Promise.resolve()
    expect(printed(sys)).toContain(t('slashCmd.topup.error.sessionRevoked'))
    expect(getOverlayState().billing).toBeNull()
  })

  it('ctx.charge → poll transport loss reports an unconfirmed outcome', async () => {
    const { ctx, rpc, run, sys } = buildCtx({
      'billing.state': ownerState(),
      'billing.charge': { ok: true, charge_id: 'ch_1', idempotency_key: 'k' }
    })

    await run('')
    rpc.mockImplementation((method: string) => {
      if (method === 'billing.charge_status') {
        return Promise.reject(new Error('socket closed'))
      }

      return Promise.resolve(method === 'billing.charge' ? { ok: true, charge_id: 'ch_1', idempotency_key: 'k' } : null)
    })
    await getOverlayState().billing!.ctx.charge('100')
    await vi.waitFor(() => expect(printed(sys)).toContain(t('slashCmd.topup.charge.unconfirmed')))
    expect(ctx.guardedErr).toHaveBeenCalled()
  })

  it('ctx.charge cli_billing_disabled / remote_spending_disabled → account-toggle copy', async () => {
    const { run, sys } = buildCtx({
      'billing.state': ownerState(),
      'billing.charge': {
        ok: false,
        error: 'cli_billing_disabled',
        code: 'remote_spending_disabled',
        recovery: 'enable_account_toggle',
        portal_url: '/billing',
        idempotency_key: 'k'
      }
    })

    await run('')
    getOverlayState().billing!.ctx.charge('100')
    await Promise.resolve()
    await Promise.resolve()
    const out = printed(sys)
    expect(out).toContain(t('slashCmd.topup.error.remoteSpendingDisabled'))
    // Account-wide switch is NOT a per-terminal revoke — overlay stays open.
    expect(getOverlayState().billing).toBeTruthy()
  })

  it('ctx.applyAutoReload(true, …) → billing.auto_reload RPC, resolves true', async () => {
    const { run, calls } = buildCtx({
      'billing.state': ownerState(),
      'billing.auto_reload': { ok: true }
    })

    await run('')
    const ok = await getOverlayState().billing!.ctx.applyAutoReload(true, 20, 100)
    expect(ok).toBe(true)
    const ar = calls.find(c => c.method === 'billing.auto_reload')
    expect(ar?.params).toEqual({ enabled: true, threshold: 20, top_up_amount: 100 })
  })

  it('ctx.applyAutoReload(false) → disables (enabled:false, no amounts)', async () => {
    const { run, calls } = buildCtx({
      'billing.state': ownerState({
        auto_reload: {
          card: { kind: 'canonical' },
          enabled: true,
          reload_to_display: '$100',
          reload_to_usd: '100',
          threshold_display: '$20',
          threshold_usd: '20'
        }
      }),
      'billing.auto_reload': { ok: true }
    })

    await run('')
    const ok = await getOverlayState().billing!.ctx.applyAutoReload(false)
    expect(ok).toBe(true)
    const ar = calls.find(c => c.method === 'billing.auto_reload')
    expect(ar?.params).toEqual({ enabled: false })
  })

  it('ctx.applyAutoReload error → resolves false + maps the error', async () => {
    const { run, sys } = buildCtx({
      'billing.state': ownerState(),
      'billing.auto_reload': { ok: false, error: 'monthly_cap_exceeded', message: 'Monthly spend cap reached.' }
    })

    await run('')
    const ok = await getOverlayState().billing!.ctx.applyAutoReload(true, 20, 100)
    expect(ok).toBe(false)
    expect(printed(sys)).toContain(t('slashCmd.topup.error.monthlyCapExceeded'))
  })

  it('ctx.applyKeepGoingOnCredits(on/off) → billing.keep_going_on_credits with just {enabled}', async () => {
    const { run, calls } = buildCtx({
      'billing.state': ownerState({ keep_going_on_credits: false }),
      'billing.keep_going_on_credits': { ok: true }
    })

    await run('')
    expect(await getOverlayState().billing!.ctx.applyKeepGoingOnCredits(true)).toBe(true)
    expect(await getOverlayState().billing!.ctx.applyKeepGoingOnCredits(false)).toBe(true)
    expect(calls.filter(c => c.method === 'billing.keep_going_on_credits').map(c => c.params)).toEqual([
      { enabled: true },
      { enabled: false }
    ])
  })
})
