// @vitest-environment jsdom
//
// Projects page — the live list + the paywall branch. We mock the app's TYPED
// hook binding `src/lib/api.ts` (useSubscription streams the rows; useMutation
// is the create) — mocking `@voltro/client` underneath it would leave the real
// `createHooks` running over a stub, i.e. the wrong seam. We drive the
// create form, asserting: the SSR'd rows render, the empty state shows, and a
// `projects.create` that rejects with a typed `EntitlementExceeded` flips the
// page into the upgrade CTA (NOT a raw error).

import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'
import { act, createElement, type ReactNode } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { I18nProvider } from '@voltro/i18n'
import enCatalog from '../../locales/en'

const subData = vi.fn<() => { data: unknown }>()
const mutate = vi.fn<(input: { name: string }) => Promise<unknown>>()
// Only the HOOKS are faked. `isEntitlementExceeded` comes through from the real
// module — an earlier version of this file re-implemented it inside the mock,
// which meant the paywall test asserted that a copy of the predicate agreed
// with the literal the same test had just written. That passes in every world,
// including the one where the framework hands `catch` a value with no `_tag`
// at all. `import type` is erased, so importing the original pulls in nothing
// but `@voltro/client`.
vi.mock('../../lib/api', async (importOriginal) => ({
  ...(await importOriginal<typeof import('../../lib/api')>()),
  useSubscription: () => subData(),
  useMutation: () => ({ mutate, pending: false, error: undefined, data: undefined }),
}))

const { default: Projects, meta, renderMode } = await import('./page')

;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true

let container: HTMLDivElement
let root: Root

const render = (node: ReactNode): void => {
  container = document.createElement('div')
  document.body.appendChild(container)
  act(() => {
    root = createRoot(container)
    root.render(
      createElement(I18nProvider, { locale: 'en', messages: enCatalog, defaultLocale: 'en', children: node }),
    )
  })
}

beforeEach(() => {
  subData.mockReset()
  subData.mockReturnValue({ data: undefined })
  mutate.mockReset()
  mutate.mockResolvedValue({ id: 'proj_1', name: 'x', tenantId: 'acme' })
})

afterEach(() => {
  if (root) act(() => root.unmount())
  container?.remove()
  document.body.innerHTML = ''
})

describe('projects — config', () => {
  test('is an SSR page titled Projects (so the parent gate runs per request)', () => {
    expect(renderMode).toBe('ssr')
    expect(meta({ locale: 'en' }).title).toBe('Projects')
  })
})

describe('projects — render', () => {
  test('shows the empty state when there are no projects', () => {
    render(createElement(Projects))
    expect(container.textContent).toContain('No projects yet')
  })

  test('renders one card per streamed project', () => {
    subData.mockReturnValue({ data: [
      { id: 'proj_1', name: 'Acme', tenantId: 'acme', createdAt: '2026-01-01T00:00:00Z' },
      { id: 'proj_2', name: 'Beta', tenantId: 'acme', createdAt: '2026-01-02T00:00:00Z' },
    ] })
    render(createElement(Projects))
    expect(container.querySelectorAll('.card')).toHaveLength(2)
    expect(container.textContent).toContain('Acme')
    expect(container.textContent).toContain('Beta')
  })
})

describe('projects — paywall', () => {
  // React 19 tracks the input's value, so a raw `input.value = …` is ignored —
  // set it through the prototype's native setter so the tracked value changes
  // and `onChange` fires with the new value.
  const setInputValue = (el: HTMLInputElement, value: string): void => {
    const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set
    setter?.call(el, value)
    el.dispatchEvent(new Event('input', { bubbles: true }))
  }
  const submit = async (): Promise<void> => {
    const input = container.querySelector('input')!
    const form = container.querySelector('form')!
    act(() => { setInputValue(input, 'Fourth') })
    await act(async () => {
      form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }))
      // Flush the mutate() promise chain (.then/.catch) + its setState.
      await Promise.resolve()
      await Promise.resolve()
    })
  }

  test('a create that succeeds does NOT show the paywall', async () => {
    render(createElement(Projects))
    await submit()
    expect(mutate).toHaveBeenCalledWith({ name: 'Fourth' })
    expect(container.querySelector('.paywall')).toBeNull()
  })

  test('a typed EntitlementExceeded flips the page into the upgrade CTA', async () => {
    // The rejection value is the TYPED ERROR ITSELF — that is the framework's
    // guarantee for `mutate` / `run` / `signal` / `upload`, and the whole reason
    // this page can branch on `_tag` instead of string-matching a message.
    mutate.mockRejectedValue({ _tag: 'EntitlementExceeded', entitlement: 'projects', limit: 3 })
    render(createElement(Projects))
    await submit()
    expect(container.querySelector('.paywall')).not.toBeNull()
    expect(container.textContent).toContain('hit your plan limit')
  })

  test('an UNTAGGED failure does not paywall — that branch is for a declared outcome', async () => {
    // The negative control, and the exact state the app was in while a rejected
    // write handed back a wrapper with no `_tag`: the create fails, and the user
    // is shown nothing about why. A paywall that appears for every failure is
    // as wrong as one that never appears.
    mutate.mockRejectedValue(new Error('socket hang up'))
    render(createElement(Projects))
    await submit()
    expect(container.querySelector('.paywall')).toBeNull()
  })
})
