import { act, render, renderHook, screen, waitFor } from '@testing-library/react'
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'
import { CheckoutProvider } from './CheckoutProvider.js'
import { useCheckout } from './useCheckout.js'
import { useCheckoutConfirm } from './useCheckoutConfirm.js'
import { isCheckoutProviderError } from './errors.js'
import type { CheckoutClient } from './client.js'

beforeEach(() => sessionStorage.clear())

function Consumer({ label }: { label: string }) {
  const { phase, submit } = useCheckout()
  return (
    <div>
      <span data-testid={`phase-${label}`}>{phase}</span>
      <button onClick={() => void submit({ cartId: 'c1', priceMode: 'inclusive', currency: 'usd', buyerCountry: 'US' })}>
        go-{label}
      </button>
    </div>
  )
}

describe('single poll loop (money-path invariant)', () => {
  it('two useCheckout consumers under one provider → at most ONE in-flight getStatus', async () => {
    let inFlight = 0
    let maxConcurrent = 0
    const client: CheckoutClient = {
      start: vi.fn().mockResolvedValue({ orderId: 'o1' }), // no clientSecret → straight to settling
      getStatus: vi.fn().mockImplementation(async () => {
        inFlight += 1
        maxConcurrent = Math.max(maxConcurrent, inFlight)
        await new Promise((r) => setTimeout(r, 10))
        inFlight -= 1
        return { status: 'charging' } // keep polling so several ticks happen
      }),
    }
    render(
      <CheckoutProvider client={client} cartId="c1">
        <Consumer label="a" />
        <Consumer label="b" />
      </CheckoutProvider>,
    )
    screen.getByText('go-a').click()
    await waitFor(() => expect(screen.getByTestId('phase-a').textContent).toBe('settling'))
    // both consumers see the same shared phase (one state, not two)
    expect(screen.getByTestId('phase-b').textContent).toBe('settling')
    // let a few poll ticks elapse
    await new Promise((r) => setTimeout(r, 60))
    expect(maxConcurrent).toBe(1) // exactly one poll loop, owned by the provider singleton
  })
})

describe('settle_timeout (poll budget exhaustion)', () => {
  beforeEach(() => {
    vi.useFakeTimers()
  })

  afterEach(() => {
    vi.useRealTimers()
  })

  it('non-terminal status past POLL_BUDGET_MS → settle_timeout, record kept', async () => {
    const client: CheckoutClient = {
      start: vi.fn().mockResolvedValue({ orderId: 'o1' }),
      getStatus: vi.fn().mockResolvedValue({ status: 'charging' }),
    }
    render(
      <CheckoutProvider client={client} cartId="c1">
        <Consumer label="a" />
      </CheckoutProvider>,
    )
    await act(async () => {
      screen.getByText('go-a').click()
      // flush the async submit → settling transition before the poll loop owns timers
      await Promise.resolve()
      await Promise.resolve()
      await Promise.resolve()
    })
    expect(screen.getByTestId('phase-a').textContent).toBe('settling')
    expect(sessionStorage.getItem('checkout:c1')).not.toBeNull()

    await act(async () => {
      // step through the backoff window so async getStatus ticks complete between advances
      for (let elapsed = 0; elapsed <= 120_001; elapsed += 8_000) {
        await vi.advanceTimersByTimeAsync(8_000)
        await Promise.resolve()
        await Promise.resolve()
      }
    })

    expect(screen.getByTestId('phase-a').textContent).toBe('settle_timeout')
    expect(sessionStorage.getItem('checkout:c1')).not.toBeNull()
  })
})

describe('hook split (§5 blocking mechanic)', () => {
  it('useCheckout outside a provider throws CheckoutProviderError, NOT a payment-provider error', () => {
    try {
      renderHook(() => useCheckout())
      throw new Error('should have thrown')
    } catch (e) {
      expect(isCheckoutProviderError(e)).toBe(true)
    }
  })

  it('useCheckoutConfirm outside a provider throws CheckoutProviderError', () => {
    try {
      renderHook(() => useCheckoutConfirm())
      throw new Error('should have thrown')
    } catch (e) {
      expect(isCheckoutProviderError(e)).toBe(true)
    }
  })
})

// billing-react mock for co-drive + retry tests
const confirmMock = vi.hoisted(() => vi.fn())
vi.mock('@platform-modules/billing-react', () => ({
  useConfirmPayment: () => ({ confirm: confirmMock, status: 'idle', error: null }),
  isPaymentConfirmError: (e: unknown) =>
    typeof e === 'object' && e !== null && (e as { name?: string }).name === 'PaymentConfirmError',
}))

function CoDriveHarness() {
  const checkout = useCheckout()
  const { confirm } = useCheckoutConfirm()
  return (
    <div>
      <span data-testid="checkout-phase">{checkout.phase}</span>
      <button
        onClick={() =>
          void checkout.submit({ cartId: 'c1', priceMode: 'inclusive', currency: 'usd', buyerCountry: 'US' })
        }
      >
        submit
      </button>
      <button onClick={() => void confirm({ returnUrl: 'https://app.test/return' })}>confirm</button>
      <button onClick={() => checkout.retry()}>retry</button>
    </div>
  )
}

describe('hook split co-drive (§11)', () => {
  beforeEach(() => {
    confirmMock.mockReset()
    confirmMock.mockResolvedValue({ kind: 'succeeded' })
  })

  it('useCheckoutConfirm confirm() flips phase that sibling useCheckout reads', async () => {
    const client: CheckoutClient = {
      start: vi.fn().mockResolvedValue({ orderId: 'o1', clientSecret: 'cs_1' }),
      getStatus: vi.fn().mockResolvedValue({ status: 'charging' }),
    }
    render(
      <CheckoutProvider client={client} cartId="c1">
        <CoDriveHarness />
      </CheckoutProvider>,
    )
    screen.getByText('submit').click()
    await waitFor(() => expect(screen.getByTestId('checkout-phase').textContent).toBe('awaiting_payment'))
    screen.getByText('confirm').click()
    await waitFor(() => expect(screen.getByTestId('checkout-phase').textContent).toBe('settling'))
  })

  it('client-succeeded with server charging stays settling, never paid (§9)', async () => {
    vi.useFakeTimers()
    try {
      const getStatus = vi.fn().mockResolvedValue({ status: 'charging' })
      const client: CheckoutClient = {
        start: vi.fn().mockResolvedValue({ orderId: 'o1', clientSecret: 'cs_1' }),
        getStatus,
      }
      render(
        <CheckoutProvider client={client} cartId="c1">
          <CoDriveHarness />
        </CheckoutProvider>,
      )
      await act(async () => {
        screen.getByText('submit').click()
        await Promise.resolve()
        await Promise.resolve()
        await Promise.resolve()
      })
      expect(screen.getByTestId('checkout-phase').textContent).toBe('awaiting_payment')
      await act(async () => {
        screen.getByText('confirm').click()
        await Promise.resolve()
        await Promise.resolve()
        await Promise.resolve()
      })
      expect(screen.getByTestId('checkout-phase').textContent).toBe('settling')

      await act(async () => {
        await vi.advanceTimersByTimeAsync(0)
        await Promise.resolve()
        await Promise.resolve()
        await Promise.resolve()
      })
      expect(getStatus).toHaveBeenCalledWith('o1')
      expect(screen.getByTestId('checkout-phase').textContent).toBe('settling')
      expect(screen.getByTestId('checkout-phase').textContent).not.toBe('paid')
    } finally {
      vi.useRealTimers()
    }
  })
})

describe('retry from settle_timeout (§6)', () => {
  beforeEach(() => {
    vi.useFakeTimers()
  })

  afterEach(() => {
    vi.useRealTimers()
  })

  it('settle_timeout + persisted record → retry() re-enters settling and re-polls', async () => {
    const getStatus = vi.fn().mockResolvedValue({ status: 'charging' })
    const client: CheckoutClient = {
      start: vi.fn().mockResolvedValue({ orderId: 'o1' }),
      getStatus,
    }
    render(
      <CheckoutProvider client={client} cartId="c1">
        <CoDriveHarness />
      </CheckoutProvider>,
    )
    await act(async () => {
      screen.getByText('submit').click()
      await Promise.resolve()
      await Promise.resolve()
      await Promise.resolve()
    })
    expect(screen.getByTestId('checkout-phase').textContent).toBe('settling')

    await act(async () => {
      for (let elapsed = 0; elapsed <= 120_001; elapsed += 8_000) {
        await vi.advanceTimersByTimeAsync(8_000)
        await Promise.resolve()
        await Promise.resolve()
      }
    })
    expect(screen.getByTestId('checkout-phase').textContent).toBe('settle_timeout')
    expect(sessionStorage.getItem('checkout:c1')).not.toBeNull()
    getStatus.mockClear()
    vi.useRealTimers()

    screen.getByText('retry').click()
    await waitFor(() => expect(screen.getByTestId('checkout-phase').textContent).toBe('settling'))
    await waitFor(() => expect(getStatus).toHaveBeenCalled())
  })

  it('settle_timeout without persisted record → retry() is a no-op', async () => {
    const client: CheckoutClient = {
      start: vi.fn().mockResolvedValue({ orderId: 'o1' }),
      getStatus: vi.fn().mockResolvedValue({ status: 'charging' }),
    }
    render(
      <CheckoutProvider client={client} cartId="c1">
        <CoDriveHarness />
      </CheckoutProvider>,
    )
    await act(async () => {
      screen.getByText('submit').click()
      await Promise.resolve()
      await Promise.resolve()
      await Promise.resolve()
    })
    await act(async () => {
      for (let elapsed = 0; elapsed <= 120_001; elapsed += 8_000) {
        await vi.advanceTimersByTimeAsync(8_000)
        await Promise.resolve()
        await Promise.resolve()
      }
    })
    expect(screen.getByTestId('checkout-phase').textContent).toBe('settle_timeout')
    sessionStorage.removeItem('checkout:c1')

    screen.getByText('retry').click()
    expect(screen.getByTestId('checkout-phase').textContent).toBe('settle_timeout')
  })
})