// packages/commerce-catalog-react/src/useProductBySlug.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 { useProductBySlug } from './useProductBySlug.js'
import type { CatalogClient } from './client.js'

const prod = (slug: string): Product => ({
  id: 'p1', kind: 'physical', vendorId: null, slug, 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 { full, wrapper }
}

describe('useProductBySlug', () => {
  it('fetches on mount and returns the product (audience defaults to public)', async () => {
    const getProductBySlug = vi.fn().mockResolvedValue(prod('tee'))
    const { wrapper } = mk({ getProductBySlug })
    const { result } = renderHook(() => useProductBySlug('tee'), { wrapper })
    expect(result.current.loading).toBe(true)
    await waitFor(() => expect(result.current.loading).toBe(false))
    expect(result.current.product?.slug).toBe('tee')
    expect(getProductBySlug).toHaveBeenCalledWith('tee', expect.objectContaining({ audience: 'public' }))
  })

  it('null product (not found) is not an error', async () => {
    const { wrapper } = mk({ getProductBySlug: async () => null })
    const { result } = renderHook(() => useProductBySlug('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({ getProductBySlug: async () => { throw new Error('bad slug') } })
    const { result } = renderHook(() => useProductBySlug('@@'), { wrapper })
    await waitFor(() => expect(result.current.error).toBeInstanceOf(Error))
    expect(result.current.error?.message).toBe('bad slug')
  })

  it('seeded: loading false, product from seed, NO mount fetch', async () => {
    const getProductBySlug = vi.fn().mockResolvedValue(prod('tee'))
    const { wrapper } = mk({ getProductBySlug })
    const { result } = renderHook(
      () => useProductBySlug('tee', undefined, { initialData: prod('tee') }),
      { wrapper },
    )
    expect(result.current.loading).toBe(false)
    expect(result.current.product?.slug).toBe('tee')
    await waitFor(() => expect(getProductBySlug).not.toHaveBeenCalled())
  })

  it('seeded then reload() refetches via the client', async () => {
    const getProductBySlug = vi.fn().mockResolvedValue(prod('tee-v2'))
    const { wrapper } = mk({ getProductBySlug })
    const { result } = renderHook(
      () => useProductBySlug('tee', undefined, { initialData: prod('tee') }),
      { wrapper },
    )
    act(() => result.current.reload())
    await waitFor(() => expect(result.current.product?.slug).toBe('tee-v2'))
    expect(getProductBySlug).toHaveBeenCalledTimes(1)
  })

  it('seeded then slug and ReadOpts change refetches (key includes slug, audience, vendorId, now)', async () => {
    const getProductBySlug = vi.fn()
      .mockResolvedValueOnce(prod('mug'))
      .mockResolvedValueOnce(prod('mug-admin'))
      .mockResolvedValueOnce(prod('mug-vendor'))
      .mockResolvedValueOnce(prod('mug-now'))
    const { wrapper } = mk({ getProductBySlug })
    let slug = 'tee'
    let opts: Partial<ReadOpts> | undefined = undefined
    const { result, rerender } = renderHook(
      () => useProductBySlug(slug, opts, { initialData: prod('tee') }),
      { wrapper },
    )
    expect(result.current.loading).toBe(false)
    expect(result.current.product?.slug).toBe('tee')
    await waitFor(() => expect(getProductBySlug).not.toHaveBeenCalled())
    slug = 'mug'
    rerender()
    await waitFor(() => expect(result.current.product?.slug).toBe('mug'))
    expect(getProductBySlug).toHaveBeenCalledTimes(1)
    expect(getProductBySlug).toHaveBeenLastCalledWith('mug', expect.objectContaining({ audience: 'public' }))
    // audience-only change (slug held) — bites a dropped `audience` key field
    opts = { audience: 'admin' }
    rerender()
    await waitFor(() => expect(getProductBySlug).toHaveBeenCalledTimes(2))
    expect(getProductBySlug).toHaveBeenLastCalledWith('mug', expect.objectContaining({ audience: 'admin' }))
    // vendorId-only change (slug+audience held) — bites a dropped `vendorId` key field
    opts = { audience: 'admin', vendorId: 'v1' }
    rerender()
    await waitFor(() => expect(getProductBySlug).toHaveBeenCalledTimes(3))
    expect(getProductBySlug).toHaveBeenLastCalledWith('mug', expect.objectContaining({ vendorId: 'v1' }))
    // now-only change (slug+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(getProductBySlug).toHaveBeenCalledTimes(4))
  })

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

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