import { describe, it, expect } from 'vitest'
import { generate } from '../generate.js'
import type { ScaffoldComponentInput, PageSpecMin } from '../types.js'

function fixture(overrides: Partial<ScaffoldComponentInput> = {}): ScaffoldComponentInput {
  return {
    module: 'crm',
    appCode: 'TestV2',
    entity: 'Contact',
    section: 'directory',
    views: ['list', 'detail', 'form'],
    fields: [
      { name: 'firstName', type: 'string', required: true },
      { name: 'lastName', type: 'string', required: true },
      { name: 'email', type: 'string', required: false },
    ],
    projectPath: '/web',
    ...overrides,
  }
}

describe('scaffold-component / generate', () => {
  it('emits no local usePermissions stub anywhere in any page', () => {
    const files = generate(fixture())
    for (const file of files.filter((f) => f.path.endsWith('Page.tsx'))) {
      expect(file.content).not.toMatch(/function\s+usePermissions\s*\(/)
      expect(file.content).not.toMatch(/const\s+usePermissions\s*=/)
    }
  })

  it('every page imports PageTemplate from @/components/ui/PageTemplate', () => {
    const files = generate(fixture())
    const pages = files.filter((f) => f.path.endsWith('Page.tsx'))
    expect(pages.length).toBeGreaterThan(0)
    for (const page of pages) {
      expect(page.content).toMatch(/import\s*\{\s*PageTemplate\s*\}\s*from\s*['"]@\/components\/ui\/PageTemplate['"]/)
    }
  })

  it('every permission="…" literal matches {module}.{section}.{action} (3 lowercase segments, no appCode prefix)', () => {
    const files = generate(fixture())
    const permRe = /permission\s*=\s*['"]([^'"]+)['"]/g
    const validShape = /^[a-z][a-z0-9-]*\.[a-z][a-z0-9-]*\.[a-z][a-z0-9-]*$/
    let matched = 0
    for (const page of files.filter((f) => f.path.endsWith('Page.tsx'))) {
      let m: RegExpExecArray | null
      while ((m = permRe.exec(page.content)) !== null) {
        matched++
        expect(m[1]).toMatch(validShape)
        expect(m[1].split('.').length).toBe(3)
        expect(m[1].startsWith('crm.')).toBe(true)
      }
    }
    expect(matched).toBeGreaterThan(0)
  })

  it('list / detail / form pages wrap content in <PageTemplate>', () => {
    const files = generate(fixture())
    // List uses plural ('ContactsListPage'); detail/form stay singular
    // (matches the SmartStack project convention).
    const expectedNames: Record<string, string> = {
      List: 'ContactsListPage.tsx',
      Detail: 'ContactDetailPage.tsx',
      Form: 'ContactFormPage.tsx',
    }
    for (const [, fileName] of Object.entries(expectedNames)) {
      const page = files.find((f) => f.path.endsWith(fileName))!
      expect(page).toBeDefined()
      expect(page.content).toMatch(/<PageTemplate\b/)
    }
  })

  it('preserves PermissionGuard from @atlashub/smartstack on mutating actions', () => {
    const files = generate(fixture())
    const list = files.find((f) => f.path.endsWith('ContactsListPage.tsx'))!
    expect(list.content).toMatch(/from\s+['"]@atlashub\/smartstack['"]/)
    // Create button is wrapped
    expect(list.content).toMatch(/<PermissionGuard\s+permission=['"]crm\.directory\.create['"]/)
  })

  it('generates hub pages (app-home / module-home / section-home) with PageTemplate + tokens', () => {
    const files = generate(fixture({ views: ['app-home', 'module-home', 'section-home'] }))
    const expectedFiles = ['ContactsAppHomePage.tsx', 'ContactsModuleHomePage.tsx', 'ContactsSectionHomePage.tsx']
    for (const filename of expectedFiles) {
      const page = files.find((f) => f.path.endsWith(filename))!
      expect(page, `missing ${filename}`).toBeDefined()
      // Wraps content in <PageTemplate>
      expect(page.content).toMatch(/<PageTemplate\b/)
      // Uses CSS tokens (no raw Tailwind colors)
      expect(page.content).not.toMatch(/bg-white\b/)
      expect(page.content).not.toMatch(/bg-(red|blue|green|yellow|gray|slate|zinc)-\d/)
      // Permission check is 4-seg with appCode prefix
      expect(page.content).toMatch(/<PermissionGuard\s+permission=['"]crm\.directory\.read['"]/)
      // useTranslation namespace is the module
      expect(page.content).toMatch(/useTranslation\(['"]crm['"]\)/)
      // No hand-written legacy imports
      expect(page.content).not.toMatch(/from\s+['"]@\/hooks\//)
      expect(page.content).not.toMatch(/from\s+['"]@\/services\//)
    }
  })

  it('emits 4 locale files per entity (fr/en/it/de)', () => {
    const files = generate(fixture())
    const locales = files.filter((f) => /\/i18n\/locales\/(fr|en|it|de)\/crm\.json$/.test(f.path))
    expect(locales).toHaveLength(4)
    for (const loc of locales) {
      const parsed = JSON.parse(loc.content)
      expect(parsed).toHaveProperty('contact')
      expect(parsed.contact).toHaveProperty('list')
      expect(parsed.contact).toHaveProperty('detail')
      expect(parsed.contact).toHaveProperty('form')
    }
  })

  it('isolates i18n keys when two entities share the same module', () => {
    const filesA = generate(fixture({ entity: 'Contact', module: 'crm' }))
    const filesB = generate(fixture({
      entity: 'Company',
      module: 'crm',
      section: 'companies',
      fields: [
        { name: 'name', type: 'string', required: true },
        { name: 'industry', type: 'string', required: false },
      ],
    }))
    const frA = JSON.parse(filesA.find((f) => /fr\/crm\.json$/.test(f.path))!.content)
    const frB = JSON.parse(filesB.find((f) => /fr\/crm\.json$/.test(f.path))!.content)

    // Each entity has its own top-level key — no collision
    expect(frA).toHaveProperty('contact')
    expect(frB).toHaveProperty('company')
    expect(frA.contact).toHaveProperty('list')
    expect(frA.contact).toHaveProperty('detail')
    expect(frB.company).toHaveProperty('list')
    expect(frB.company).toHaveProperty('detail')

    // Titles are entity-specific
    expect(frA.contact.list.title).not.toBe(frB.company.list.title)
  })

  it('prefixes all t() calls with entity key in generated pages', () => {
    const files = generate(fixture())
    const pages = files.filter((f) => f.path.endsWith('Page.tsx'))
    for (const page of pages) {
      // Every t('...') call must start with the entity prefix 'contact.'
      const tCalls = page.content.match(/t\('([^']+)'\)/g) ?? []
      for (const call of tCalls) {
        const key = call.match(/t\('([^']+)'\)/)![1]
        expect(key.startsWith('contact.')).toBe(true)
      }
      // No bare t('list.'), t('form.'), t('detail.'), t('breadcrumb.') etc.
      expect(page.content).not.toMatch(/t\('(?:list|form|detail|dashboard|home|breadcrumb)\./)
    }
  })

  it('converts kebab-case section to camelCase for routes.* property access', () => {
    // Regression guard for the REFERENTIELS class of crashes: when the
    // section is multi-word kebab (e.g. `types-affaire`), the generated
    // `navigate(routes.types-affaire.detail(id))` is parsed by JS as
    // `routes.types - affaire.detail(id)` and throws ReferenceError at the
    // first interaction. The fix mirrors what scaffold-routes already does
    // (`kebabToCamel(entity.section)`) so both CLIs agree on the property
    // name: `routes.typesAffaire`.
    const files = generate(fixture({ section: 'types-affaire' }))
    const pages = files.filter(f => f.path.endsWith('Page.tsx'))
    for (const page of pages) {
      // Camel form MUST appear wherever routes.<section>.<view>() is referenced.
      if (page.content.includes('routes.')) {
        expect(page.content, page.path).toMatch(/routes\.typesAffaire\./)
        expect(page.content, page.path).not.toMatch(/routes\.types-affaire\./)
      }
    }
  })

  it('leaves single-word sections unchanged (camel of `contacts` is `contacts`)', () => {
    const files = generate(fixture()) // section: 'directory'
    const pages = files.filter(f => f.path.endsWith('Page.tsx'))
    for (const page of pages) {
      if (page.content.includes('routes.')) {
        expect(page.content, page.path).toMatch(/routes\.directory\./)
      }
    }
  })
})

// ─── Enriched mode (Phase 3a, post-pageSpec) ────────────────────────────

function pageSpecFixture(overrides: Partial<PageSpecMin> = {}): PageSpecMin {
  return {
    screenCode: 'SCR-CONTACT-LIST',
    module: 'crm',
    appCode: 'testv2',
    section: 'directory',
    entity: 'Contact',
    view: 'list',
    filePath: 'src/pages/testv2/crm/directory/ContactListPage.tsx',
    permission: 'crm.directory.read',
    actions: [
      { code: 'create', scope: 'header', labelKey: 'list.create', permission: 'crm.directory.create', variant: 'primary' },
    ],
    columns: [
      { key: 'firstName', labelKey: 'list.columns.firstName', sortable: true },
      { key: 'lastName', labelKey: 'list.columns.lastName', sortable: true },
    ],
    i18nKeys: {
      fr: {
        'list.title': 'Contacts métier',
        'list.subtitle': 'Annuaire CRM personnalisé',
        'list.create': 'Nouveau contact',
        'list.search': 'Filtrer…',
        'list.loading': 'Chargement…',
        'list.empty': 'Aucun contact à afficher',
        'list.columns.firstName': 'Prénom',
        'list.columns.lastName': 'Nom',
      },
      en: {
        'list.title': 'Business contacts',
        'list.subtitle': 'CRM custom directory',
        'list.create': 'New contact',
        'list.search': 'Filter…',
        'list.loading': 'Loading…',
        'list.empty': 'No contact to show',
        'list.columns.firstName': 'First name',
        'list.columns.lastName': 'Last name',
      },
      it: {
        'list.title': 'Contatti business',
        'list.subtitle': 'Rubrica CRM',
        'list.create': 'Nuovo contatto',
        'list.search': 'Filtra…',
        'list.loading': 'Caricamento…',
        'list.empty': 'Nessun contatto',
        'list.columns.firstName': 'Nome',
        'list.columns.lastName': 'Cognome',
      },
      de: {
        'list.title': 'Geschäftskontakte',
        'list.subtitle': 'Benutzerdefiniertes CRM-Verzeichnis',
        'list.create': 'Neuer Kontakt',
        'list.search': 'Filtern…',
        'list.loading': 'Wird geladen…',
        'list.empty': 'Kein Kontakt anzuzeigen',
        'list.columns.firstName': 'Vorname',
        'list.columns.lastName': 'Nachname',
      },
    },
    needsRefinement: false,
    specHash: 'a'.repeat(64),
    ...overrides,
  }
}

describe('scaffold-component / generate — enriched mode', () => {
  it('uses pageSpec.i18nKeys verbatim instead of buildTranslations()', () => {
    const files = generate(fixture({ pageSpec: pageSpecFixture() }))
    const fr = files.find((f) => /\/i18n\/locales\/fr\/crm\.json$/.test(f.path))!
    expect(fr).toBeDefined()
    const parsed = JSON.parse(fr.content)
    // The pageSpec catalogue overrides the legacy auto-generated dictionary —
    // these are project-specific values that buildTranslations() would never
    // produce on its own (humanize 'firstName' → 'First name', not 'Prénom métier').
    expect(parsed.contact.list.title).toBe('Contacts métier')
    expect(parsed.contact.list.subtitle).toBe('Annuaire CRM personnalisé')
    expect(parsed.contact.list.create).toBe('Nouveau contact')
    expect(parsed.contact.list.empty).toBe('Aucun contact à afficher')
    expect(parsed.contact.list.columns.firstName).toBe('Prénom')
    expect(parsed.contact.list.columns.lastName).toBe('Nom')
  })

  it('normalizes PascalCase field keys from pageSpec.i18nKeys to camelCase', () => {
    const ps = pageSpecFixture({
      i18nKeys: {
        fr: {
          'list.title': 'Contacts',
          'list.columns.FirstName': 'Prénom',
          'list.columns.LastName': 'Nom',
          'form.fields.FirstName': 'Prénom',
          'form.fields.EstActif': 'Actif',
          'detail.fields.Code': 'Code',
          'list.filters.SecteurId': 'Secteur',
        },
        en: { 'list.title': 'Contacts', 'list.columns.FirstName': 'First name', 'form.fields.FirstName': 'First name', 'form.fields.EstActif': 'Active', 'detail.fields.Code': 'Code', 'list.filters.SecteurId': 'Sector' },
        it: { 'list.title': 'Contatti', 'list.columns.FirstName': 'Nome', 'form.fields.FirstName': 'Nome', 'form.fields.EstActif': 'Attivo', 'detail.fields.Code': 'Codice', 'list.filters.SecteurId': 'Settore' },
        de: { 'list.title': 'Kontakte', 'list.columns.FirstName': 'Vorname', 'form.fields.FirstName': 'Vorname', 'form.fields.EstActif': 'Aktiv', 'detail.fields.Code': 'Code', 'list.filters.SecteurId': 'Sektor' },
      },
    })
    const files = generate(fixture({ pageSpec: ps }))
    const fr = files.find((f) => /\/i18n\/locales\/fr\/crm\.json$/.test(f.path))!
    const parsed = JSON.parse(fr.content)
    // PascalCase field keys normalized to camelCase (matching t() calls)
    expect(parsed.contact.list.columns.firstName).toBe('Prénom')
    expect(parsed.contact.list.columns.lastName).toBe('Nom')
    expect(parsed.contact.form.fields.firstName).toBe('Prénom')
    expect(parsed.contact.form.fields.estActif).toBe('Actif')
    expect(parsed.contact.detail.fields.code).toBe('Code')
    expect(parsed.contact.list.filters.secteurId).toBe('Secteur')
    // Non-field keys stay as-is
    expect(parsed.contact.list.title).toBe('Contacts')
    // PascalCase originals should NOT exist
    expect(parsed.contact.list.columns.FirstName).toBeUndefined()
    expect(parsed.contact.form.fields.FirstName).toBeUndefined()
  })

  it('produces parallel locale catalogues from pageSpec.i18nKeys (4 locales nested)', () => {
    const files = generate(fixture({ pageSpec: pageSpecFixture() }))
    for (const locale of ['fr', 'en', 'it', 'de'] as const) {
      const file = files.find((f) => f.path === `web/TestV2-web/src/i18n/locales/${locale}/crm.json`)!
      expect(file, `missing locale ${locale}`).toBeDefined()
      const parsed = JSON.parse(file.content)
      expect(parsed.contact).toBeDefined()
      expect(parsed.contact.list).toBeDefined()
      expect(parsed.contact.list.columns).toBeDefined()
      expect(parsed.contact.list.columns.firstName).toBeDefined()
    }
  })

  it('respects webRoot override for i18n file paths', () => {
    const files = generate(fixture({ webRoot: 'web/custom-web', pageSpec: pageSpecFixture() }))
    const fr = files.find((f) => f.path === 'web/custom-web/src/i18n/locales/fr/crm.json')
    expect(fr, 'i18n file should be at web/custom-web/...').toBeDefined()
    const wrongPath = files.find((f) => f.path === 'web/TestV2-web/src/i18n/locales/fr/crm.json')
    expect(wrongPath, 'should NOT use default web/{appCode}-web path').toBeUndefined()
  })

  it('falls back to buildTranslations() (legacy) when pageSpec is absent', () => {
    // No pageSpec → legacy dictionary outputs 'Contacts' (plural humanize) for list.title
    const files = generate(fixture())
    const fr = files.find((f) => /\/i18n\/locales\/fr\/crm\.json$/.test(f.path))!
    const parsed = JSON.parse(fr.content)
    expect(parsed.contact.list.title).toBe('Contacts')
    // No 'subtitle' override from pageSpec — legacy "Gérer les contacts"
    expect(parsed.contact.list.subtitle).toContain('Gérer')
  })

  it('legacy mode and enriched mode emit the same number of files (no schema drift)', () => {
    const legacyFiles = generate(fixture()).map((f) => f.path).sort()
    const enrichedFiles = generate(fixture({ pageSpec: pageSpecFixture() })).map((f) => f.path).sort()
    // Enriched mode honours pageSpec.filePath as the page output target;
    // legacy mode uses the derived `src/pages/{appCode}/{module}/{section}/...` path.
    // The PATHS may differ (e.g. legacy folder name) but the FILE COUNT
    // must stay identical so no view is silently dropped.
    expect(enrichedFiles.length).toBe(legacyFiles.length)
  })

  it('honours pageSpec.filePath verbatim — output target matches the legacy folder', () => {
    // pageSpec.filePath = 'src/pages/testv2/crm/directory/ContactListPage.tsx' (the
    // fixture path, lifted as-is from the canonical PageSpec contract). Even when
    // the project's actual folder layout differs from the derived
    // `src/pages/{module}/{section}/...`, the scaffold MUST write at the path
    // declared in the pageSpec — that's the contract's "source of truth" rule.
    const ps = pageSpecFixture({ filePath: 'src/pages/legacy-budgeting/budgets/BudgetsListPage.tsx' })
    const files = generate(fixture({
      pageSpec: ps,
      module: 'budgets',
      entity: 'Budget',
      section: 'budgets',
      views: ['list'],
    }))
    const listPage = files.find((f) => f.path.endsWith('BudgetsListPage.tsx'))!
    expect(listPage).toBeDefined()
    expect(listPage.path).toBe('src/pages/legacy-budgeting/budgets/BudgetsListPage.tsx')
  })

  it('uses pageSpec.columns verbatim — does NOT pull legacy columns from spec.fields', () => {
    // The data model can carry attributes the BA did NOT include in the list
    // view (e.g. legacy `description` column). Without honouring pageSpec.columns
    // the scaffold renders them as untranslated `LIST.COLUMNS.DESCRIPTION` headers.
    const ps = pageSpecFixture({
      columns: [
        { key: 'code', labelKey: 'list.columns.code', sortable: true },
        { key: 'label', labelKey: 'list.columns.label', sortable: true },
        { key: 'initialAmount', labelKey: 'list.columns.initialAmount', sortable: true },
      ],
    })
    const files = generate(fixture({
      pageSpec: ps,
      // Inject a legacy `description` field that the BA did NOT declare for this view.
      fields: [
        { name: 'code', type: 'string', required: true },
        { name: 'label', type: 'string', required: true },
        { name: 'description', type: 'string', required: false },
        { name: 'initialAmount', type: 'number', required: true },
      ],
      module: 'budgets',
      entity: 'Budget',
      section: 'budgets',
      views: ['list'],
    }))
    const list = files.find((f) => f.path.endsWith('ContactsListPage.tsx'))
      ?? files.find((f) => /ListPage\.tsx$/.test(f.path))!
    // The pageSpec-declared columns appear:
    expect(list.content).toMatch(/key:\s*'code'/)
    expect(list.content).toMatch(/key:\s*'label'/)
    expect(list.content).toMatch(/key:\s*'initialAmount'/)
    // The legacy `description` column does NOT appear (would surface as
    // `LIST.COLUMNS.DESCRIPTION` in the rendered page header).
    expect(list.content).not.toMatch(/key:\s*'description'/)
    // labelKey from pageSpec is honoured (path verbatim, leaf normalised to
    // camelCase by normalizeI18nKey — symmetric with the JSON catalogue which
    // goes through normalizeI18nFieldKeys). camelCase leaves are idempotent
    // so this test still passes verbatim.
    expect(list.content).toMatch(/t\('budget\.list\.columns\.code'\)/)
    expect(list.content).toMatch(/t\('budget\.list\.columns\.initialAmount'\)/)
  })

  it('renders a FilterBar when pageSpec.filters[] is non-empty', () => {
    const ps = pageSpecFixture({
      columns: [{ key: 'label', labelKey: 'list.columns.label', sortable: true }],
      // Add filters: text + select + date-range — each produces a different control.
      filters: [
        { field: 'label', labelKey: 'list.filters.label', control: 'text' },
        {
          field: 'status',
          labelKey: 'list.filters.status',
          control: 'select',
          options: ['active', 'archived'],
        },
        { field: 'startDate', labelKey: 'list.filters.startDate', control: 'date-range' },
      ],
    } as Parameters<typeof pageSpecFixture>[0])
    const files = generate(fixture({ pageSpec: ps, views: ['list'] }))
    const list = files.find((f) => /ListPage\.tsx$/.test(f.path))!
    // The component imports useState + useMemo (filter state).
    expect(list.content).toMatch(/import\s*\{\s*useMemo,\s*useState\s*\}\s*from\s*'react'/)
    // FilterBar container renders.
    expect(list.content).toMatch(/flex\s+flex-wrap\s+items-end\s+gap-3/)
    // Text filter input is wired:
    expect(list.content).toMatch(/onFilterChange\(\s*'label'/)
    // Select filter has its option set + "all" default:
    expect(list.content).toMatch(/<option value="active">active<\/option>/)
    expect(list.content).toMatch(/<option value="archived">archived<\/option>/)
    expect(list.content).toMatch(/contact\.list\.filters\.all/)
    // Date-range produces 2 date inputs:
    expect(list.content).toMatch(/onFilterChange\('startDateFrom'/)
    expect(list.content).toMatch(/onFilterChange\('startDateTo'/)
    // DataTable consumes the filtered list.
    expect(list.content).toMatch(/data=\{filtered\}/)
  })

  it('emits NO filter state imports when pageSpec.filters is empty', () => {
    const ps = pageSpecFixture({ filters: [] } as Parameters<typeof pageSpecFixture>[0])
    const files = generate(fixture({ pageSpec: ps, views: ['list'] }))
    const list = files.find((f) => /ListPage\.tsx$/.test(f.path))!
    // No useMemo / useState import — the legacy template stays clean.
    expect(list.content).not.toMatch(/import\s*\{\s*useMemo,\s*useState\s*\}\s*from\s*'react'/)
    // No FilterBar container.
    expect(list.content).not.toMatch(/flex\s+flex-wrap\s+items-end\s+gap-3/)
    // DataTable still consumes a `filtered` slot — but it's a plain `data?.items ?? []` shorthand.
    expect(list.content).toMatch(/data=\{filtered\}/)
    expect(list.content).toMatch(/const filtered = data\?\.items \?\? \[\]/)
  })

  it('Fix #9 — normalises pagespec column labelKey leaf to match the JSON i18n catalogue (PascalCase → camelCase)', () => {
    // The PRD sometimes emits PascalCase leaves (`list.columns.Code`,
    // `list.columns.EstActif`). The JSON catalogue normalises them to
    // camelCase via normalizeI18nFieldKeys (line ~1400). Without the
    // symmetric normalisation on the TSX side, t('…Code') resolves nothing
    // and the screen shows the raw key. This is THE reason every table
    // column header rendered the literal key on REFERENTIELS.
    const ps = pageSpecFixture({
      columns: [
        { key: 'code', labelKey: 'list.columns.Code', sortable: true },
        { key: 'estActif', labelKey: 'list.columns.EstActif', sortable: true },
      ],
    })
    const files = generate(fixture({ pageSpec: ps, views: ['list'] }))
    const list = files.find((f) => /ListPage\.tsx$/.test(f.path))!
    // The leaf is camelCased in the t() call:
    expect(list.content).toMatch(/t\('contact\.list\.columns\.code'\)/)
    expect(list.content).toMatch(/t\('contact\.list\.columns\.estActif'\)/)
    // Negative — the PascalCase leaf must NOT leak into the TSX:
    expect(list.content).not.toMatch(/t\('contact\.list\.columns\.Code'\)/)
    expect(list.content).not.toMatch(/t\('contact\.list\.columns\.EstActif'\)/)
  })

  it('Fix #9 — normalises pagespec filter labelKey leaf across every control type', () => {
    // Same normalisation applied at the source — one pass on pageSpecFilters
    // covers text, select, date-range and boolean labels + placeholders
    // (6 substitution sites in renderFilterInput) without touching them.
    const ps = pageSpecFixture({
      filters: [
        { field: 'code', labelKey: 'list.filters.Code', control: 'text' },
        { field: 'status', labelKey: 'list.filters.Status', control: 'select', options: ['a'] },
        { field: 'startDate', labelKey: 'list.filters.StartDate', control: 'date-range' },
        { field: 'estActif', labelKey: 'list.filters.EstActif', control: 'boolean' },
      ],
    } as Parameters<typeof pageSpecFixture>[0])
    const files = generate(fixture({ pageSpec: ps, views: ['list'] }))
    const list = files.find((f) => /ListPage\.tsx$/.test(f.path))!
    // All four control types resolve the camelCased leaf:
    expect(list.content).toMatch(/t\('contact\.list\.filters\.code'\)/)
    expect(list.content).toMatch(/t\('contact\.list\.filters\.status'\)/)
    expect(list.content).toMatch(/t\('contact\.list\.filters\.startDate'\)/)
    expect(list.content).toMatch(/t\('contact\.list\.filters\.estActif'\)/)
    // Negative — no PascalCase leaves leak into any of the 6 render sites:
    expect(list.content).not.toMatch(/t\('contact\.list\.filters\.Code'\)/)
    expect(list.content).not.toMatch(/t\('contact\.list\.filters\.Status'\)/)
    expect(list.content).not.toMatch(/t\('contact\.list\.filters\.StartDate'\)/)
    expect(list.content).not.toMatch(/t\('contact\.list\.filters\.EstActif'\)/)
  })

  it('Fix #9 — does NOT mangle action labelKey (parent not in I18N_FIELD_PARENTS)', () => {
    // Action labels live under `list.<code>` / `form.actions.<code>` —
    // their parent is NOT one of {fields, columns, filters, help}, so
    // normalizeI18nFieldKeys leaves them alone in the JSON. The TSX must
    // mirror that and keep them verbatim too, otherwise we would break
    // kebab-case action codes like `toggle-actif`.
    const ps = pageSpecFixture({
      actions: [
        { code: 'create', scope: 'header', labelKey: 'list.create', permission: 'crm.directory.create', variant: 'primary' },
        { code: 'toggle-actif', scope: 'row', labelKey: 'list.toggle-actif', permission: 'crm.directory.update', variant: 'secondary' },
      ],
    })
    const files = generate(fixture({ pageSpec: ps, views: ['list'] }))
    const list = files.find((f) => /ListPage\.tsx$/.test(f.path))!
    // Action labels stay verbatim (2-seg path: parts.length < 3 → skip),
    // and the kebab-case `toggle-actif` is preserved exactly:
    expect(list.content).toMatch(/t\('contact\.list\.create'\)/)
    expect(list.content).toMatch(/t\('contact\.list\.toggle-actif'\)/)
  })

  it('renders row-scope custom actions as icon buttons in the actions column', () => {
    const ps = pageSpecFixture({
      actions: [
        { code: 'create', scope: 'header', labelKey: 'list.create', permission: 'crm.directory.create', variant: 'primary' },
        { code: 'archive', scope: 'row', labelKey: 'list.archive', permission: 'crm.directory.update', variant: 'secondary' },
        { code: 'duplicate', scope: 'row', labelKey: 'list.duplicate', permission: 'crm.directory.create', variant: 'secondary' },
      ],
    })
    const files = generate(fixture({ pageSpec: ps, views: ['list'] }))
    const list = files.find((f) => /ListPage\.tsx$/.test(f.path))!
    // Hooks for archive + duplicate imported alongside CRUD hooks
    expect(list.content).toMatch(/import\s*\{\s*useContacts,\s*useDeleteContact, useArchiveContact, useDuplicateContact\s*\}/)
    // Mutation declarations
    expect(list.content).toMatch(/const archiveMutation = useArchiveContact\(\)/)
    expect(list.content).toMatch(/const duplicateMutation = useDuplicateContact\(\)/)
    // Handler functions wired to mutations
    expect(list.content).toMatch(/const handleArchive = async \(item: Contact\) => \{[\s\S]*?archiveMutation\.mutateAsync\(item\.id\)/)
    expect(list.content).toMatch(/const handleDuplicate = async \(item: Contact\) => \{[\s\S]*?duplicateMutation\.mutateAsync\(item\.id\)/)
    // Icon imports include Archive + Copy (from the lucide map)
    expect(list.content).toMatch(/Archive,/)
    expect(list.content).toMatch(/Copy/)
    // Row buttons rendered with PermissionGuard + 4-seg permission
    expect(list.content).toMatch(/<PermissionGuard\s+permission="crm\.directory\.update">[\s\S]*?onClick=\{\(e\) => \{[\s\S]*?handleArchive\(item\)/)
    expect(list.content).toMatch(/title=\{t\('contact\.list\.archive'\)\}/)
  })

  it('renders header-scope custom actions next to the Create button', () => {
    const ps = pageSpecFixture({
      actions: [
        { code: 'create', scope: 'header', labelKey: 'list.create', permission: 'crm.directory.create', variant: 'primary' },
        { code: 'export', scope: 'header', labelKey: 'list.export', permission: 'crm.directory.read', variant: 'secondary' },
      ],
    })
    const files = generate(fixture({ pageSpec: ps, views: ['list'] }))
    const list = files.find((f) => /ListPage\.tsx$/.test(f.path))!
    // The header actions are wrapped in a `<div className="flex items-center gap-2">` so multiple buttons fit
    expect(list.content).toMatch(/actions=\{[\s\S]*?<div className="flex items-center gap-2">/)
    // Export button uses the secondary variant style + Download icon
    expect(list.content).toMatch(/\{t\('contact\.list\.export'\)\}/)
    expect(list.content).toMatch(/Download/)
    // Header action handler does NOT take an item parameter
    expect(list.content).toMatch(/const handleExport = async \(\) => \{[\s\S]*?exportMutation\.mutateAsync\(\)/)
  })

  it('skips standard CRUD codes from custom actions iteration (no double-emit)', () => {
    const ps = pageSpecFixture({
      actions: [
        // 'edit' and 'delete' are standard CRUD — already hardcoded; skip
        { code: 'edit', scope: 'row', labelKey: 'list.edit', permission: 'crm.directory.update', variant: 'secondary' },
        { code: 'delete', scope: 'row', labelKey: 'list.delete', permission: 'crm.directory.delete', variant: 'danger' },
        // 'archive' is non-CRUD → renders
        { code: 'archive', scope: 'row', labelKey: 'list.archive', permission: 'crm.directory.update', variant: 'secondary' },
      ],
    })
    const files = generate(fixture({ pageSpec: ps, views: ['list'] }))
    const list = files.find((f) => /ListPage\.tsx$/.test(f.path))!
    // No useEditContact import (CRUD codes are skipped from custom iteration)
    expect(list.content).not.toMatch(/useEditContact/)
    // archive IS imported as a custom action hook
    expect(list.content).toMatch(/useArchiveContact/)
    // useDeleteContact is the standard hardcoded import (appears in both
    // import statement AND in `useDelete${e}()` call — 2 occurrences total).
    // Custom-action iteration must NOT add a third reference.
    expect(list.content.match(/useDeleteContact/g)?.length).toBe(2)
  })

  it('renders danger-variant row actions with error-bg on hover', () => {
    const ps = pageSpecFixture({
      actions: [
        { code: 'reject', scope: 'row', labelKey: 'list.reject', permission: 'crm.directory.update', variant: 'danger' },
      ],
    })
    const files = generate(fixture({ pageSpec: ps, views: ['list'] }))
    const list = files.find((f) => /ListPage\.tsx$/.test(f.path))!
    // The danger variant uses error-bg for hover state (matches the legacy delete button styling)
    expect(list.content).toMatch(/handleReject\(item\)[\s\S]*?className="p-1\.5 rounded hover:bg-\[var\(--error-bg\)\]/)
  })

  it('emits NO custom action artefacts when pageSpec.actions is empty (legacy)', () => {
    // The fixture pageSpec has no actions[] beyond a default 'create' header
    // (which is a standard CRUD code skipped from custom iteration).
    const files = generate(fixture({ pageSpec: pageSpecFixture(), views: ['list'] }))
    const list = files.find((f) => /ListPage\.tsx$/.test(f.path))!
    // No extra hook imports beyond the standard CRUD ones
    expect(list.content).not.toMatch(/useArchive|useDuplicate|useApprove|useReject/)
    // No mutation declarations beyond `deleteMutation`
    expect(list.content).not.toMatch(/Mutation = use(?!Delete|Contacts)/)
  })
})

describe('scaffold-component / generate — list page custom actions kind:navigate', () => {
  it('emits a navigate(targetRoute) handler for a kind:navigate row action, no hook', () => {
    const ps = pageSpecFixture({
      actions: [
        // The "Ouvrir" case — row click on the line should NAVIGATE to the
        // detail page, not POST /{id}/open (which is what produces the 405).
        {
          code: 'open',
          kind: 'navigate',
          scope: 'row',
          labelKey: 'list.open',
          permission: 'crm.directory.read',
          targetRoute: 'routes.directory.detail(item.id)',
        },
      ],
    })
    const files = generate(fixture({ pageSpec: ps, views: ['list'] }))
    const list = files.find((f) => /ListPage\.tsx$/.test(f.path))!
    // NO hook import for the navigate action
    expect(list.content).not.toMatch(/useOpenContact/)
    // NO mutation declaration
    expect(list.content).not.toMatch(/openMutation/)
    // The handler navigates via React Router — synchronous, no mutateAsync
    expect(list.content).toMatch(/const handleOpen = \(item: Contact\) => \{[\s\S]*?navigate\(routes\.directory\.detail\(item\.id\)\)/)
    // The button still renders (PermissionGuard + onClick → handleOpen)
    expect(list.content).toMatch(/handleOpen\(item\)/)
  })

  it('emits a navigate(targetRoute) handler for a kind:navigate header action', () => {
    const ps = pageSpecFixture({
      actions: [
        {
          code: 'openHistory',
          kind: 'navigate',
          scope: 'header',
          labelKey: 'list.openHistory',
          permission: 'crm.directory.read',
          targetRoute: 'routes.directory.history()',
        },
      ],
    })
    const files = generate(fixture({ pageSpec: ps, views: ['list'] }))
    const list = files.find((f) => /ListPage\.tsx$/.test(f.path))!
    // Synchronous handler — no `async`, no `await`
    expect(list.content).toMatch(/const handleOpenHistory = \(\) => \{[\s\S]*?navigate\(routes\.directory\.history\(\)\)/)
    expect(list.content).not.toMatch(/useOpenHistoryContact/)
    expect(list.content).not.toMatch(/openHistoryMutation/)
  })

  it('mixes kind:api and kind:navigate actions on the same page', () => {
    const ps = pageSpecFixture({
      actions: [
        // CRUD standard — skipped from custom iteration
        { code: 'create', scope: 'header', labelKey: 'list.create', permission: 'crm.directory.create', variant: 'primary' },
        // api-bound custom — needs hook + mutation
        { code: 'archive', kind: 'api', scope: 'row', labelKey: 'list.archive', permission: 'crm.directory.update', variant: 'secondary' },
        // navigate-bound custom — needs router only
        { code: 'open', kind: 'navigate', scope: 'row', labelKey: 'list.open', permission: 'crm.directory.read', targetRoute: 'routes.directory.detail(item.id)' },
      ],
    })
    const files = generate(fixture({ pageSpec: ps, views: ['list'] }))
    const list = files.find((f) => /ListPage\.tsx$/.test(f.path))!
    // api action: hook + mutation
    expect(list.content).toMatch(/useArchiveContact/)
    expect(list.content).toMatch(/archiveMutation\.mutateAsync\(item\.id\)/)
    // navigate action: NO hook, NO mutation, just navigate()
    expect(list.content).not.toMatch(/useOpenContact/)
    expect(list.content).not.toMatch(/openMutation/)
    expect(list.content).toMatch(/handleOpen = \(item: Contact\) => \{[\s\S]*?navigate\(routes\.directory\.detail\(item\.id\)\)/)
    // Both buttons reach the actions column
    expect(list.content).toMatch(/handleArchive\(item\)/)
    expect(list.content).toMatch(/handleOpen\(item\)/)
  })

  it('hook name follows endpoint when code != endpoint (override pattern)', () => {
    // The BA wants the UI label "Sync from PCE" but the backend route is the
    // legacy `sync-from-proconcept`. scaffold-api-client generates the hook
    // named after the endpoint so the request URL and the React Query key
    // stay aligned. scaffold-component MUST import the same name.
    const ps = pageSpecFixture({
      actions: [
        {
          code: 'syncFromPce',
          kind: 'api',
          scope: 'header',
          labelKey: 'list.syncFromPce',
          permission: 'crm.directory.update',
          endpoint: 'sync-from-proconcept',
        },
      ],
    })
    const files = generate(fixture({ pageSpec: ps, views: ['list'] }))
    const list = files.find((f) => /ListPage\.tsx$/.test(f.path))!
    // Hook name derived from endpoint (the URL is the source of truth)
    expect(list.content).toMatch(/useSyncFromProconceptContact/)
    expect(list.content).toMatch(/syncFromProconceptMutation = useSyncFromProconceptContact\(\)/)
    expect(list.content).toMatch(/handleSyncFromPce = async \(\) => \{[\s\S]*?syncFromProconceptMutation\.mutateAsync\(\)/)
    // The button label still uses the BA labelKey (camelCase) — UI label stays
    // independent of the URL segment.
    expect(list.content).toMatch(/\{t\('contact\.list\.syncFromPce'\)\}/)
  })
})

describe('scaffold-component / generate — FK fields render as <EntityLookup>', () => {
  it('emits <EntityLookup> for a same-module FK and imports the lookup hook', () => {
    const files = generate(fixture({
      entity: 'Employee',
      module: 'hr',
      section: 'employees',
      views: ['form'],
      fields: [
        { name: 'firstName', type: 'string', required: true },
        {
          name: 'departmentId',
          type: 'guid',
          required: true,
          fkTo: { entity: 'Department', module: 'hr' },
        },
      ],
    }))
    const form = files.find((f) => /FormPage\.tsx$/.test(f.path))!
    // Import surface — EntityLookup component + per-target lookup hook
    expect(form.content).toMatch(/import \{ EntityLookup \} from '@\/components\/ui\/EntityLookup'/)
    expect(form.content).toMatch(/import \{ useDepartmentLookup \} from '@\/features\/hr\/department\/hooks\/useDepartment'/)
    // FK field is rendered as <EntityLookup>, not <input>
    expect(form.content).toMatch(
      /<EntityLookup\s+apiEndpoint="\/api\/hr\/departments\/lookup"\s+value=\{\(formData\.departmentId as string \| null\) \?\? null\}/,
    )
    expect(form.content).toMatch(/label=\{t\('employee\.form\.fields\.departmentId'\)\}/)
    // Non-FK fields still get the plain <input>
    expect(form.content).toMatch(/id="firstName"[\s\S]*?type="text"/)
    // FK field never gets a plain <input> with name/id ending in "Id"
    expect(form.content).not.toMatch(/<input[^>]*id="departmentId"/)
  })

  it('emits the required attribute on the EntityLookup when the FK is required', () => {
    const files = generate(fixture({
      entity: 'Employee',
      module: 'hr',
      section: 'employees',
      views: ['form'],
      fields: [
        { name: 'mgrId', type: 'guid', required: true, fkTo: { entity: 'Manager', module: 'hr' } },
      ],
    }))
    const form = files.find((f) => /FormPage\.tsx$/.test(f.path))!
    // The `required` attribute is present (no value — JSX boolean attribute shorthand)
    expect(form.content).toMatch(/<EntityLookup[\s\S]*?\brequired\b[\s\S]*?disabled=\{isPending\}/)
  })

  it('omits the EntityLookup import when no FK is declared (legacy form unchanged)', () => {
    const files = generate(fixture()) // no fkTo in fixture fields
    const form = files.find((f) => /FormPage\.tsx$/.test(f.path))!
    expect(form.content).not.toMatch(/EntityLookup/)
    expect(form.content).not.toMatch(/use[A-Z]\w*Lookup/)
  })

  it('honours a custom apiEndpoint override (cross-module / Core)', () => {
    const files = generate(fixture({
      entity: 'Demand',
      module: 'requests',
      section: 'demands',
      views: ['form'],
      fields: [
        {
          name: 'requesterUserId',
          type: 'guid',
          required: true,
          fkTo: { entity: 'User', module: 'core', apiEndpoint: '/api/core/users/lookup' },
        },
      ],
    }))
    const form = files.find((f) => /FormPage\.tsx$/.test(f.path))!
    expect(form.content).toMatch(/apiEndpoint="\/api\/core\/users\/lookup"/)
    expect(form.content).toMatch(/import \{ useUserLookup \} from '@\/features\/core\/user\/hooks\/useUser'/)
  })

  it('deduplicates lookup hook imports when multiple FKs target the same entity', () => {
    const files = generate(fixture({
      entity: 'Assignment',
      module: 'hr',
      section: 'assignments',
      views: ['form'],
      fields: [
        { name: 'fromEmployeeId', type: 'guid', required: true, fkTo: { entity: 'Employee', module: 'hr' } },
        { name: 'toEmployeeId', type: 'guid', required: true, fkTo: { entity: 'Employee', module: 'hr' } },
      ],
    }))
    const form = files.find((f) => /FormPage\.tsx$/.test(f.path))!
    const matches = form.content.match(/import \{ useEmployeeLookup \}/g) ?? []
    expect(matches.length).toBe(1)
    // But both FK fields are rendered
    expect(form.content).toMatch(/label=\{t\('assignment\.form\.fields\.fromEmployeeId'\)\}/)
    expect(form.content).toMatch(/label=\{t\('assignment\.form\.fields\.toEmployeeId'\)\}/)
  })

  it('never emits a raw <input name="...Id"> or <select value={...Id}> when fkTo is present (DEV-UI-022 contract)', () => {
    const files = generate(fixture({
      entity: 'Order',
      module: 'sales',
      section: 'orders',
      views: ['form'],
      fields: [
        { name: 'clientId', type: 'guid', required: true, fkTo: { entity: 'Client', module: 'crm' } },
      ],
    }))
    const form = files.find((f) => /FormPage\.tsx$/.test(f.path))!
    // The patterns DEV-UI-022 will reject
    expect(form.content).not.toMatch(/<input[^>]*\b(name|id)\s*=\s*["']clientId["']/)
    expect(form.content).not.toMatch(/<select[^>]*value=\{[^}]*clientId/)
  })
})

// ─── Form page custom actions ──────────────────────────────────────────

describe('scaffold-component / generate — form page custom actions', () => {
  it('renders header-scoped custom actions on form page, guarded by isEdit', () => {
    const ps = pageSpecFixture({
      view: 'form',
      filePath: 'src/pages/testv2/crm/directory/ContactFormPage.tsx',
      actions: [
        { code: 'create', scope: 'header', labelKey: 'form.create', permission: 'crm.directory.create', variant: 'primary' },
        { code: 'duplicate', scope: 'header', labelKey: 'form.actions.duplicate', permission: 'crm.directory.create', variant: 'secondary' },
        { code: 'toggle-actif', scope: 'header', labelKey: 'form.actions.toggle-actif', permission: 'crm.directory.execute', variant: 'secondary' },
      ],
    })
    const files = generate(fixture({ pageSpec: ps, views: ['form'] }))
    const form = files.find((f) => /FormPage\.tsx$/.test(f.path))!
    // Hook imports for api-bound actions (create is CRUD — skipped)
    expect(form.content).toMatch(/useDuplicateContact/)
    expect(form.content).toMatch(/useToggleActifContact/)
    // Mutation declarations
    expect(form.content).toMatch(/duplicateMutation = useDuplicateContact\(\)/)
    expect(form.content).toMatch(/toggleActifMutation = useToggleActifContact\(\)/)
    // Handlers use `id` from closure (not item parameter)
    expect(form.content).toMatch(/const handleDuplicate = async \(\) => \{[\s\S]*?if \(id\) await duplicateMutation\.mutateAsync\(id\)/)
    expect(form.content).toMatch(/const handleToggleActif = async \(\) => \{[\s\S]*?if \(id\) await toggleActifMutation\.mutateAsync\(id\)/)
    // actions prop guarded by isEdit
    expect(form.content).toMatch(/actions=\{isEdit \?/)
    // PermissionGuard with 4-seg permission
    expect(form.content).toMatch(/<PermissionGuard permission="crm\.directory\.create">[\s\S]*?handleDuplicate/)
    expect(form.content).toMatch(/<PermissionGuard permission="crm\.directory\.execute">[\s\S]*?handleToggleActif/)
  })

  it('renders navigate-kind form action without hooks', () => {
    const ps = pageSpecFixture({
      view: 'form',
      filePath: 'src/pages/testv2/crm/directory/ContactFormPage.tsx',
      actions: [
        {
          code: 'openHistory',
          kind: 'navigate',
          scope: 'header',
          labelKey: 'form.actions.openHistory',
          permission: 'crm.directory.read',
          targetRoute: 'routes.directory.history()',
        },
      ],
    })
    const files = generate(fixture({ pageSpec: ps, views: ['form'] }))
    const form = files.find((f) => /FormPage\.tsx$/.test(f.path))!
    // NO hook import or mutation for navigate action
    expect(form.content).not.toMatch(/useOpenHistoryContact/)
    expect(form.content).not.toMatch(/openHistoryMutation/)
    // Synchronous navigate handler
    expect(form.content).toMatch(/const handleOpenHistory = \(\) => \{[\s\S]*?navigate\(routes\.directory\.history\(\)\)/)
  })

  it('emits NO action artefacts on form page when no custom actions (backward compat)', () => {
    const files = generate(fixture({ views: ['form'] }))
    const form = files.find((f) => /FormPage\.tsx$/.test(f.path))!
    // No custom hook imports
    expect(form.content).not.toMatch(/useArchive|useDuplicate|useToggle/)
    // No actions prop on PageTemplate
    expect(form.content).not.toMatch(/actions=\{/)
    // Standard save/cancel still present
    expect(form.content).toMatch(/form\.cancel/)
    expect(form.content).toMatch(/form\.submitCreate/)
  })

  it('ignores row-scoped actions on form page (forms have no rows)', () => {
    const ps = pageSpecFixture({
      view: 'form',
      filePath: 'src/pages/testv2/crm/directory/ContactFormPage.tsx',
      actions: [
        { code: 'archive', scope: 'row', labelKey: 'list.archive', permission: 'crm.directory.update', variant: 'secondary' },
      ],
    })
    const files = generate(fixture({ pageSpec: ps, views: ['form'] }))
    const form = files.find((f) => /FormPage\.tsx$/.test(f.path))!
    // Row-scoped action MUST NOT appear on the form page
    expect(form.content).not.toMatch(/useArchiveContact/)
    expect(form.content).not.toMatch(/handleArchive/)
    expect(form.content).not.toMatch(/actions=\{/)
  })
})

// ─── Detail page custom actions ────────────────────────────────────────

describe('scaffold-component / generate — detail page custom actions', () => {
  it('renders custom header actions alongside the Edit button on detail page', () => {
    const ps = pageSpecFixture({
      view: 'detail',
      filePath: 'src/pages/testv2/crm/directory/ContactDetailPage.tsx',
      actions: [
        { code: 'duplicate', scope: 'header', labelKey: 'detail.actions.duplicate', permission: 'crm.directory.create', variant: 'secondary' },
        { code: 'toggle-actif', scope: 'header', labelKey: 'detail.actions.toggle-actif', permission: 'crm.directory.execute', variant: 'secondary' },
      ],
    })
    const files = generate(fixture({ pageSpec: ps, views: ['detail'] }))
    const detail = files.find((f) => /DetailPage\.tsx$/.test(f.path))!
    // Hook imports for custom actions
    expect(detail.content).toMatch(/useDuplicateContact/)
    expect(detail.content).toMatch(/useToggleActifContact/)
    // The Edit button is still present alongside custom actions
    expect(detail.content).toMatch(/detail\.edit/)
    // Custom action buttons appear inside the actions div
    expect(detail.content).toMatch(/handleDuplicate/)
    expect(detail.content).toMatch(/handleToggleActif/)
    // Handlers use id from closure
    expect(detail.content).toMatch(/const handleDuplicate = async \(\) => \{[\s\S]*?if \(id\) await duplicateMutation\.mutateAsync\(id\)/)
  })

  it('detail page with no custom actions still renders Edit button', () => {
    const files = generate(fixture({ views: ['detail'] }))
    const detail = files.find((f) => /DetailPage\.tsx$/.test(f.path))!
    expect(detail.content).toMatch(/detail\.edit/)
    expect(detail.content).not.toMatch(/useDuplicate|useToggle|useArchive/)
  })
})

// ─── Heuristic: "open" row action without explicit kind → navigate ─────

describe('scaffold-component / generate — open action heuristic', () => {
  it('treats row-scoped "open" as navigate when kind is omitted', () => {
    const ps = pageSpecFixture({
      actions: [
        { code: 'create', scope: 'header', labelKey: 'list.create', permission: 'crm.directory.create', variant: 'primary' },
        { code: 'open', scope: 'row', labelKey: 'list.actions.open', permission: 'crm.directory.read' },
      ],
    })
    const files = generate(fixture({ pageSpec: ps, views: ['list'] }))
    const list = files.find((f) => /ListPage\.tsx$/.test(f.path))!
    expect(list.content).not.toMatch(/useOpenContact/)
    expect(list.content).not.toMatch(/openMutation/)
    expect(list.content).toMatch(/handleOpen = \(item: Contact\) => \{[\s\S]*?navigate\(routes\.directory\.detail\(item\.id\)\)/)
  })

  it('respects explicit kind:api on "open" (override heuristic)', () => {
    const ps = pageSpecFixture({
      actions: [
        { code: 'open', kind: 'api', scope: 'row', labelKey: 'list.actions.open', permission: 'crm.directory.execute' },
      ],
    })
    const files = generate(fixture({ pageSpec: ps, views: ['list'] }))
    const list = files.find((f) => /ListPage\.tsx$/.test(f.path))!
    expect(list.content).toMatch(/useOpenContact/)
    expect(list.content).toMatch(/openMutation\.mutateAsync\(item\.id\)/)
  })

  it('row navigate fallback targets detail(item.id), not list()', () => {
    const ps = pageSpecFixture({
      actions: [
        { code: 'view-report', kind: 'navigate', scope: 'row', labelKey: 'list.actions.view-report', permission: 'crm.directory.read' },
      ],
    })
    const files = generate(fixture({ pageSpec: ps, views: ['list'] }))
    const list = files.find((f) => /ListPage\.tsx$/.test(f.path))!
    expect(list.content).toMatch(/navigate\(routes\.directory\.detail\(item\.id\)\)/)
  })
})

// ─── Filter q → cross-field search ────────────────────────────────────

describe('scaffold-component / generate — filter q cross-field search', () => {
  it('generates Object.values search for filter field "q"', () => {
    const ps = pageSpecFixture({
      filters: [
        { field: 'q', labelKey: 'list.filters.q', control: 'text' },
        { field: 'estActif', labelKey: 'list.filters.estActif', control: 'boolean' },
      ],
    })
    const files = generate(fixture({ pageSpec: ps, views: ['list'] }))
    const list = files.find((f) => /ListPage\.tsx$/.test(f.path))!
    // Bug 4 fix — double-cast `as unknown as Record<string, unknown>` is the
    // canonical TS idiom for crossing into a permissive index-signature shape
    // when the source DTO is sealed.
    expect(list.content).toMatch(/Object\.values\(item as unknown as Record<string, unknown>\)\.some/)
    expect(list.content).not.toMatch(/\(item as unknown as Record<string, unknown>\)\['q'\]/)
  })

  it('Bug 4 — every "as Record<string, unknown>" cast is preceded by "as unknown"', () => {
    // Defence-in-depth: walk every emitted page and make sure the single-cast
    // `item as Record<string, unknown>` does NOT slip back in. The single cast
    // is rejected by TypeScript ("conversion may be a mistake") whenever the
    // DTO is sealed — which is the default for scaffold-api-client output.
    const ps = pageSpecFixture({
      filters: [
        { field: 'q', labelKey: 'list.filters.q', control: 'text' },
        { field: 'estActif', labelKey: 'list.filters.estActif', control: 'boolean' },
        { field: 'createdAt', labelKey: 'list.filters.createdAt', control: 'date-range' },
        { field: 'category', labelKey: 'list.filters.category', control: 'text' },
      ],
    })
    const files = generate(fixture({ pageSpec: ps, views: ['list'] }))
    const list = files.find((f) => /ListPage\.tsx$/.test(f.path))!
    // No single-cast occurrence anywhere.
    expect(list.content).not.toMatch(/[^n] as Record<string, unknown>/)
    // Every double-cast occurrence is fully formed.
    const allCasts = (list.content.match(/as unknown as Record<string, unknown>/g) ?? []).length
    expect(allCasts).toBeGreaterThanOrEqual(3) // q + select + date-range + text
  })
})

// ─── Bug 9 — fieldToCamel on headerField in DetailPage ───────────────

describe('scaffold-component / generate — Bug 9 (header field normalised to camelCase)', () => {
  // Reproducer: BA pagespec declared the detail header field as `Numero`
  // (PascalCase). The generated DemandeDetailPage.tsx emitted
  // `data.Numero` while the DTO serialises camelCase `numero` → TS2551
  // "Property 'Numero' does not exist on type 'DemandeDetailDto'. Did you mean
  // 'numero'?" (Typescript actually suggests the correct casing.)
  it('headerTitle uses camelCase field accessor even when BA field name is PascalCase', () => {
    const files = generate(fixture({
      fields: [
        { name: 'Numero', type: 'string', labelKey: 'detail.fields.numero' },
        { name: 'Status', type: 'string', labelKey: 'detail.fields.status' },
      ],
      views: ['detail'],
    }))
    const detail = files.find((f) => /DetailPage\.tsx$/.test(f.path))!
    expect(detail.content).toMatch(/data\.numero/)
    expect(detail.content).not.toMatch(/data\.Numero/)
  })

  it('normalises kebab-style field names too (defence-in-depth)', () => {
    // Defensive : even though BA convention is camelCase or PascalCase, ensure
    // the normaliser handles odd shapes without leaking into the page.
    const files = generate(fixture({
      fields: [
        { name: 'CreatedAt', type: 'datetime', labelKey: 'detail.fields.createdAt' },
      ],
      views: ['detail'],
    }))
    const detail = files.find((f) => /DetailPage\.tsx$/.test(f.path))!
    expect(detail.content).toMatch(/data\.createdAt/)
    expect(detail.content).not.toMatch(/data\.CreatedAt/)
  })
})

// ─── Icon mapping ─────────────────────────────────────────────────────

describe('scaffold-component / generate — action icon mapping', () => {
  it('maps "open" → ExternalLink, "set-availability" → CalendarCheck, unknown → ChevronRight', () => {
    const ps = pageSpecFixture({
      actions: [
        { code: 'open', scope: 'row', labelKey: 'list.actions.open', permission: 'crm.directory.read' },
        { code: 'set-availability', kind: 'api', scope: 'row', labelKey: 'list.actions.set-availability', permission: 'crm.directory.update' },
        { code: 'some-unknown-action', kind: 'api', scope: 'row', labelKey: 'list.actions.unknown', permission: 'crm.directory.execute' },
      ],
    })
    const files = generate(fixture({ pageSpec: ps, views: ['list'] }))
    const list = files.find((f) => /ListPage\.tsx$/.test(f.path))!
    expect(list.content).toMatch(/<ExternalLink className="w-4 h-4"/)
    expect(list.content).toMatch(/<CalendarCheck className="w-4 h-4"/)
    expect(list.content).toMatch(/<ChevronRight className="w-4 h-4"/)
  })
})

// ─── Bug 7 — kanban view first-class ────────────────────────────────────

describe('scaffold-component / generate — Bug 7 (kanban view)', () => {
  it('emits KanbanPage when views includes "kanban"', () => {
    const files = generate(fixture({
      entity: 'Demande',
      section: 'demandes',
      fields: [
        { name: 'numero', type: 'string', required: true },
        { name: 'status', type: 'string', required: true },
      ],
      views: ['kanban'],
      kanbanConfig: {
        groupBy: 'status',
        columns: [
          { key: 'open', labelKey: 'kanban.columns.open' },
          { key: 'closed', labelKey: 'kanban.columns.closed' },
        ],
      },
    }))
    const kanban = files.find(f => /KanbanPage\.tsx$/.test(f.path))
    expect(kanban).toBeDefined()
    expect(kanban!.content).toMatch(/<PageTemplate/)
    expect(kanban!.content).toMatch(/use(Demandes|Contacts)\(\)/) // depends on plural
    expect(kanban!.content).toMatch(/KANBAN_COLUMNS/)
  })

  it('groups items by the configured field (status by default)', () => {
    const files = generate(fixture({
      entity: 'Demande',
      section: 'demandes',
      fields: [
        { name: 'Numero', type: 'string', required: true },  // PascalCase from BA
        { name: 'Status', type: 'string', required: true },
      ],
      views: ['kanban'],
      kanbanConfig: {
        groupBy: 'Status',
        columns: [
          { key: 'open', labelKey: 'kanban.columns.open' },
          { key: 'closed', labelKey: 'kanban.columns.closed' },
        ],
      },
    }))
    const kanban = files.find(f => /KanbanPage\.tsx$/.test(f.path))!
    // groupBy field accessor MUST be camelCase to match the DTO shape.
    expect(kanban.content).toMatch(/\['status'\]/)
    expect(kanban.content).not.toMatch(/\['Status'\]/)
    // Each column key is preserved verbatim (status value, not field name).
    expect(kanban.content).toMatch(/key: 'open', labelKey: 'kanban\.columns\.open'/)
    expect(kanban.content).toMatch(/key: 'closed', labelKey: 'kanban\.columns\.closed'/)
  })

  it('cards display titleField content', () => {
    const files = generate(fixture({
      entity: 'Demande',
      section: 'demandes',
      fields: [
        { name: 'numero', type: 'string', required: true },
        { name: 'status', type: 'string', required: true },
      ],
      views: ['kanban'],
      kanbanConfig: {
        groupBy: 'status',
        titleField: 'numero',
        columns: [{ key: 'open', labelKey: 'kanban.columns.open' }],
      },
    }))
    const kanban = files.find(f => /KanbanPage\.tsx$/.test(f.path))!
    expect(kanban.content).toMatch(/item\.numero/)
  })

  it('throws when "kanban" is in views but kanbanConfig is missing', () => {
    expect(() => generate(fixture({
      entity: 'Demande',
      section: 'demandes',
      views: ['kanban'],
      // kanbanConfig intentionally missing
    }))).toThrow(/kanbanConfig is missing/i)
  })

  it('emits drag handlers + move mutation import when dnd: true', () => {
    const files = generate(fixture({
      entity: 'Demande',
      section: 'demandes',
      fields: [
        { name: 'numero', type: 'string', required: true },
        { name: 'status', type: 'string', required: true },
      ],
      views: ['kanban'],
      kanbanConfig: {
        groupBy: 'status',
        dnd: true,
        columns: [{ key: 'open', labelKey: 'kanban.columns.open' }],
      },
    }))
    const kanban = files.find(f => /KanbanPage\.tsx$/.test(f.path))!
    expect(kanban.content).toMatch(/useMoveDemande/)
    expect(kanban.content).toMatch(/draggable/)
    expect(kanban.content).toMatch(/onDrop=/)
  })
})

// ─── Bug 7 — reconduction view first-class ───────────────────────────────

describe('scaffold-component / generate — Bug 7 (reconduction view)', () => {
  it('emits ReconductionPage when views includes "reconduction"', () => {
    const files = generate(fixture({
      entity: 'Demande',
      section: 'demandes',
      fields: [{ name: 'numero', type: 'string', required: true }],
      views: ['reconduction'],
    }))
    const page = files.find(f => /ReconductionPage\.tsx$/.test(f.path))
    expect(page).toBeDefined()
    expect(page!.content).toMatch(/<PageTemplate/)
    expect(page!.content).toMatch(/useReconductDemandes/)
  })

  it('reconduction button is disabled when no rows selected', () => {
    const files = generate(fixture({
      entity: 'Demande',
      section: 'demandes',
      fields: [{ name: 'numero', type: 'string', required: true }],
      views: ['reconduction'],
    }))
    const page = files.find(f => /ReconductionPage\.tsx$/.test(f.path))!
    expect(page.content).toMatch(/selectedIds\.length === 0/)
  })

  it('emits refuse button + useRefuseReconduction hook when withRefuse: true', () => {
    const files = generate(fixture({
      entity: 'Demande',
      section: 'demandes',
      fields: [{ name: 'numero', type: 'string', required: true }],
      views: ['reconduction'],
      reconductionConfig: {
        actionLabelKey: 'reconduction.action.reconduct',
        withRefuse: true,
      },
    }))
    const page = files.find(f => /ReconductionPage\.tsx$/.test(f.path))!
    expect(page.content).toMatch(/useRefuseReconductionDemandes/)
    expect(page.content).toMatch(/handleRefuse/)
  })

  it('does NOT emit refuse hook when withRefuse: false (default)', () => {
    const files = generate(fixture({
      entity: 'Demande',
      section: 'demandes',
      fields: [{ name: 'numero', type: 'string', required: true }],
      views: ['reconduction'],
    }))
    const page = files.find(f => /ReconductionPage\.tsx$/.test(f.path))!
    expect(page.content).not.toMatch(/useRefuseReconduction/)
  })
})
