// packages/commerce-catalog-react/src/useProductById.test.tsx
import { act, renderHook, waitFor } from '@testing-library/react'
import type { ReactNode } from 'react'
import { describe, expect, it, vi } from 'vitest'
import type { Product, ReadOpts } from '@platform-modules/commerce-catalog'
import { CatalogProvider } from './CatalogProvider.js'
import { useProductById } from './useProductById.js'
import type { CatalogClient } from './client.js'

const prod = (id: string): Product => ({
  id, kind: 'physical', vendorId: null, slug: 's', title: 'T', status: 'active',
  media: [], tags: [], createdAt: new Date(), updatedAt: new Date(), variants: [],
})

function mk(client: Partial<CatalogClient>) {
  const full: CatalogClient = {
    getProductBySlug: async () => null,
    getProductById: async () => null,
    listProducts: async () => ({ items: [], total: 0, page: 1, pageSize: 20 }),
    ...client,
  }
  const wrapper = ({ children }: { children: ReactNode }) => (
    <CatalogProvider client={full}>{children}</CatalogProvider>
  )
  return { wrapper }
}

describe('useProductById', () => {
  it('fetches by id on mount (audience defaults to public)', async () => {
    const getProductById = vi.fn().mockResolvedValue(prod('p1'))
    const { wrapper } = mk({ getProductById })
    const { result } = renderHook(() => useProductById('p1'), { wrapper })
    await waitFor(() => expect(result.current.loading).toBe(false))
    expect(result.current.product?.id).toBe('p1')
    expect(getProductById).toHaveBeenCalledWith('p1', expect.objectContaining({ audience: 'public' }))
  })

  it('null product (not found) is not an error', async () => {
    const { wrapper } = mk({ getProductById: async () => null })
    const { result } = renderHook(() => useProductById('ghost'), { wrapper })
    await waitFor(() => expect(result.current.loading).toBe(false))
    expect(result.current.product).toBeNull()
    expect(result.current.error).toBeNull()
  })

  it('captures a thrown core error into error state', async () => {
    const { wrapper } = mk({ getProductById: async () => { throw new Error('bad id') } })
    const { result } = renderHook(() => useProductById('@@'), { wrapper })
    await waitFor(() => expect(result.current.error?.message).toBe('bad id'))
  })

  it('seeded: loading false, NO mount fetch; reload() refetches', async () => {
    const getProductById = vi.fn().mockResolvedValue(prod('p2'))
    const { wrapper } = mk({ getProductById })
    const { result } = renderHook(
      () => useProductById('p1', undefined, { initialData: prod('p1') }),
      { wrapper },
    )
    expect(result.current.loading).toBe(false)
    expect(result.current.product?.id).toBe('p1')
    act(() => result.current.reload())
    await waitFor(() => expect(result.current.product?.id).toBe('p2'))
    expect(getProductById).toHaveBeenCalledTimes(1)
  })

  it('seeded then id and ReadOpts change refetches (key includes id, audience, vendorId, now)', async () => {
    const getProductById = vi.fn()
      .mockResolvedValueOnce(prod('p2'))
      .mockResolvedValueOnce(prod('p2-admin'))
      .mockResolvedValueOnce(prod('p2-vendor'))
      .mockResolvedValueOnce(prod('p2-now'))
    const { wrapper } = mk({ getProductById })
    let id = 'p1'
    let opts: Partial<ReadOpts> | undefined = undefined
    const { result, rerender } = renderHook(
      () => useProductById(id, opts, { initialData: prod('p1') }),
      { wrapper },
    )
    expect(result.current.loading).toBe(false)
    expect(result.current.product?.id).toBe('p1')
    await waitFor(() => expect(getProductById).not.toHaveBeenCalled())
    id = 'p2'
    rerender()
    await waitFor(() => expect(result.current.product?.id).toBe('p2'))
    expect(getProductById).toHaveBeenCalledTimes(1)
    expect(getProductById).toHaveBeenLastCalledWith('p2', expect.objectContaining({ audience: 'public' }))
    // audience-only change (id held) — bites a dropped `audience` key field
    opts = { audience: 'admin' }
    rerender()
    await waitFor(() => expect(getProductById).toHaveBeenCalledTimes(2))
    expect(getProductById).toHaveBeenLastCalledWith('p2', expect.objectContaining({ audience: 'admin' }))
    // vendorId-only change (id+audience held) — bites a dropped `vendorId` key field
    opts = { audience: 'admin', vendorId: 'v1' }
    rerender()
    await waitFor(() => expect(getProductById).toHaveBeenCalledTimes(3))
    expect(getProductById).toHaveBeenLastCalledWith('p2', expect.objectContaining({ vendorId: 'v1' }))
    // now-only change (id+audience+vendorId held) — bites a dropped `now` key field
    opts = { audience: 'admin', vendorId: 'v1', now: new Date('2026-02-02T00:00:00.000Z') }
    rerender()
    await waitFor(() => expect(getProductById).toHaveBeenCalledTimes(4))
  })

  it('does not commit state after unmount (cancel flag)', async () => {
    let resolve!: (v: Product | null) => void
    const getProductById = vi.fn().mockReturnValue(new Promise<Product | null>((r) => (resolve = r)))
    const { wrapper } = mk({ getProductById })
    const { result, unmount } = renderHook(() => useProductById('p1'), { wrapper })
    unmount()
    act(() => resolve(prod('p1')))
    expect(result.current.product).toBeNull()
  })

  it('throws CatalogProviderError outside a provider', () => {
    expect(() => renderHook(() => useProductById('p1'))).toThrow()
  })
})
