// @vitest-environment jsdom
//
// `/admin/<entity>` — per-entity CRUD. It resolves the entity spec from the
// capability map (useParams + useCapabilityManifest + deriveEntityAdmins) and
// renders one of three observable states: a loading skeleton, an "unknown
// entity" banner, or the bound CRUD surface (heading + AutoForm + DataTable).
// We mock @voltro/web (useParams + the AutoForm/DataTable components) and
// @voltro/client (the discovery + access hooks).
//
// The access cases below are the point of this file. The page must gate on the
// DECLARED access the manifest carries, never on a scope string it invented,
// and it must SHOW an `unknown` decision instead of hiding it — a per-row guard
// is the server's to answer, and hiding it empties the admin for every
// multi-tenant app whose subjects hold no global scopes.

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 params = vi.fn<() => { entity?: string }>()

vi.mock('@voltro/web', () => ({
  useParams: () => params(),
  AutoForm: ({ mutation }: { mutation: string }) =>
    createElement('div', { 'data-testid': 'auto-form' }, mutation),
  DataTable: ({ query, rowActions }: { query: string; rowActions?: (r: Record<string, unknown>) => ReactNode }) =>
    createElement('div', { 'data-testid': 'data-table' }, query, rowActions?.({ id: 'r1', entryNo: 'e1' })),
}))

interface ManifestState { manifest: unknown; loading: boolean }
const capabilityManifest = vi.fn<() => ManifestState>()
const entityAdmins = vi.fn<() => Array<Record<string, unknown>>>()
const accessDecision = vi.fn<() => 'allowed' | 'denied' | 'unknown'>()
const mutate = vi.fn()

vi.mock('@voltro/client', () => ({
  useCapabilityManifest: () => capabilityManifest(),
  deriveEntityAdmins: () => entityAdmins(),
  useAccessDecision: () => accessDecision(),
  requiredScopes: (guards?: ReadonlyArray<{ kind: string; scope?: ReadonlyArray<string> }>) =>
    (guards ?? []).flatMap((g) => (g.kind === 'scope' ? [...(g.scope ?? [])] : [])),
  openAccessReason: (guards?: ReadonlyArray<{ kind: string; reason?: string }>) =>
    (guards ?? []).find((g) => g.kind === 'open')?.reason,
  useMutation: () => ({ mutate }),
  useProvenance: () => ({ data: undefined, loading: false, error: undefined }),
}))

const { default: EntityPage, meta, renderMode } = await import('./[entity]/page')

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

let container: HTMLDivElement
let root: Root

// The page uses <T>/useTFn, so wrap renders in the framework's <I18nProvider>.
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 }),
    )
  })
}

const cell = (id: string): string | null =>
  container.querySelector(`[data-testid="${id}"]`)?.textContent ?? null

/** A derived spec in its current shape. */
const spec = (over: Record<string, unknown> = {}): Record<string, unknown> => ({
  table: 'todos',
  columns: [{ name: 'id' }, { name: 'title' }],
  serverOnlyColumns: [],
  sensitiveColumns: [],
  reactive: true,
  pkColumn: 'id',
  editable: true,
  list: { tag: 'todos.list' },
  create: { tag: 'todos.create', guards: [{ kind: 'scope', scope: ['todos:manage'], mode: 'all', resourceScoped: false }] },
  update: {},
  delete: { tag: 'todos.delete' },
  ...over,
})

const showEntity = (over: Record<string, unknown> = {}): void => {
  params.mockReturnValue({ entity: (over['table'] as string) ?? 'todos' })
  capabilityManifest.mockReturnValue({ manifest: {}, loading: false })
  entityAdmins.mockReturnValue([spec(over)])
  render(createElement(EntityPage))
}

beforeEach(() => {
  params.mockReset()
  capabilityManifest.mockReset()
  entityAdmins.mockReset()
  accessDecision.mockReset()
  mutate.mockReset()
  accessDecision.mockReturnValue('allowed')
})

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

describe('entity page — config', () => {
  test('is an SSR page', () => {
    expect(renderMode).toBe('ssr')
    expect(meta({ locale: 'en' }).title).toBe('Entity')
  })
})

describe('entity page — render states', () => {
  test('shows a skeleton while the manifest is still loading', () => {
    params.mockReturnValue({ entity: 'todos' })
    capabilityManifest.mockReturnValue({ manifest: undefined, loading: true })
    entityAdmins.mockReturnValue([])
    render(createElement(EntityPage))
    expect(container.querySelector('.skeleton')).toBeTruthy()
  })

  test('shows an unknown-entity banner when the entity is not in the map', () => {
    params.mockReturnValue({ entity: 'ghosts' })
    capabilityManifest.mockReturnValue({ manifest: {}, loading: false })
    entityAdmins.mockReturnValue([{ table: 'todos' }])
    render(createElement(EntityPage))
    expect(container.querySelector('.banner')!.textContent).toContain('No entity')
    expect(container.textContent).toContain('ghosts')
  })

  test('renders the bound CRUD surface (heading + AutoForm + DataTable) for a known entity', () => {
    showEntity()
    expect(container.querySelector('h1')!.textContent).toBe('todos')
    expect(cell('auto-form')).toBe('todos.create')
    expect(cell('data-table')).toContain('todos.list')
  })
})

describe('entity page — gates on the DECLARED access', () => {
  test('shows the create form when the api says the caller may create', () => {
    accessDecision.mockReturnValue('allowed')
    showEntity()
    expect(cell('auto-form')).toBe('todos.create')
  })

  test('hides it on a DEFINITE denial', () => {
    accessDecision.mockReturnValue('denied')
    showEntity()
    expect(container.querySelector('[data-testid="auto-form"]')).toBeNull()
    // …and the list is still there. A denied write is not a denied read.
    expect(cell('data-table')).toContain('todos.list')
  })

  test('SHOWS it on an indeterminate decision, and says why', () => {
    // The false-deny trap: a per-row guard cannot be pre-computed in a browser.
    // Hiding it would empty the admin for exactly the multi-tenant apps whose
    // subjects carry no global scopes.
    accessDecision.mockReturnValue('unknown')
    showEntity()
    expect(cell('auto-form')).toBe('todos.create')
    expect(container.textContent).toContain('checks permission per row')
  })

  test('names the scope the api ACTUALLY requires, so a refusal is readable', () => {
    showEntity()
    expect(container.textContent).toContain('requires todos:manage')
  })

  test('reports an openAccess decision with its reason', () => {
    showEntity({ create: { tag: 'todos.create', guards: [{ kind: 'open', reason: 'demo seeding' }] } })
    expect(container.textContent).toContain('open access — demo seeding')
  })

  test('says so when a procedure declared no access at all', () => {
    showEntity({ create: { tag: 'todos.create' } })
    expect(container.textContent).toContain('no access decision declared')
  })

  test('renders nothing to create when the api exposes no create mutation', () => {
    showEntity({ create: {} })
    expect(container.querySelector('[data-testid="auto-form"]')).toBeNull()
  })
})

describe('entity page — the three exposure axes', () => {
  test('names the .serverOnly() columns it withheld, and why', () => {
    showEntity({ serverOnlyColumns: ['pwHash', 'internalNote'] })
    expect(container.textContent).toContain('2 server-only columns')
    expect(container.textContent).toContain('pwHash, internalNote')
    expect(container.textContent).toContain('cannot be submitted')
  })

  test('flags .sensitive() columns as export-masked, NOT as hidden', () => {
    // The category error to avoid: `.sensitive()` is the export axis. The value
    // is real wire data the operator is meant to see.
    showEntity({ sensitiveColumns: ['email'] })
    expect(container.textContent).toContain('masked in exports: email')
  })

  test('says nothing when no column is marked', () => {
    showEntity()
    expect(container.textContent).not.toContain('server-only')
    expect(container.textContent).not.toContain('masked in exports')
  })
})

describe('entity page — row identity', () => {
  test('keys the delete on the pk the api declares, not a hard-coded id', () => {
    showEntity({ pkColumn: 'entryNo' })
    const del = container.querySelector('button.danger')!
    act(() => { del.dispatchEvent(new MouseEvent('click', { bubbles: true })) })
    // A hard-coded `id` would have sent `{ id: 'r1' }` — or `{ id: '' }` for a
    // table that has no `id` column at all.
    expect(mutate).toHaveBeenCalledWith({ entryNo: 'e1' })
  })

  test('offers no delete for a table with no addressable row', () => {
    showEntity({ editable: false, pkColumn: undefined })
    expect(container.querySelector('button.danger')).toBeNull()
    expect(container.textContent).toContain('No single primary key')
  })
})
