/**
 * cli:scaffold-component — generate.ts
 *
 * Emits:
 *  - React pages (list, detail, form) under src/features/{module}/{entity}/pages
 *    conforming to the SmartStack customisation-ui baseline
 *    (`@/components/ui/PageTemplate` + `@/components/ui/ResponsiveDataTable` +
 *    token-based Tailwind, lucide-react icons, PermissionGuard + Slot/Fill).
 *  - Locale JSON files under web/{appCode}-web/src/i18n/locales/{fr,en,it,de}/{module}.json
 *    — one file per MODULE (the entities of a module share it). The CLI writer
 *    (index.ts) deep-merges into the existing file, so generating one entity never
 *    wipes a sibling entity's keys. Concurrent generation of two entities in the
 *    SAME module is therefore unsafe (read-modify-write race) — serialize within a
 *    module (see ba-develop/SKILL.md § Phase 3a).
 *
 * Baseline anatomy (derived verbatim from D:\01 - projets\SmartStack.app\features\customisation-ui
 * pages administration/uiConfiguration/{ThemesListPage, PresetsListPage},
 * administration/tenants/OrganisationCreatePage, administration/tenants/TenantDetailPage):
 *
 *  - Every page is wrapped in `<PageTemplate title={t('list.title')} icon={<Icon />} actions={...}>`.
 *  - List views use `<ResponsiveDataTable data columns searchable pagination>`
 *    (wraps the local <DataTable>: columns carry `minBreakpoint` so the default
 *    code/label/actions stay visible and extra columns appear as the screen
 *    widens, plus `truncate` for ellipsis + tooltip on overflowing cells).
 *  - Detail views use card sections with `rounded-[var(--radius-card)] border
 *    border-[var(--border-color)] bg-[var(--bg-card)]`.
 *  - Form views use `.input` class for fields, primary CTA with the
 *    `.btn .btn-primary` helper class (the theme owns the accent fill + hover +
 *    radius + padding — never re-assemble it inline; ui-polish R21 enforces this).
 *  - All colors through CSS vars — no Tailwind color palette (`bg-red-500` etc.).
 *  - Icons from lucide-react only.
 *  - i18n namespace = {module}, keys nested under entity key (lowercased).
 *  - PermissionGuard keys: `{module}.{section}.{action}` — no appCode prefix.
 *  - Slot/Fill points: {entity}.header.actions, {entity}.table.columns.before/after,
 *    {entity}.form.fields.before/after, {entity}.detail.header/sidebar.
 *
 * No legacy patterns from the prior generate.ts are carried forward.
 */
import type { ScaffoldComponentInput, GeneratedFile, ComponentField } from './types.js'

const LOCALES = ['fr', 'en', 'it', 'de'] as const
type Locale = (typeof LOCALES)[number]

const GENERATED_MARKER = '// @generated-by scaffold-component — DO NOT HAND-EDIT (re-run the CLI to update)\n'

/**
 * Convert a flat i18n catalogue (e.g. `{ 'list.title': 'Employés' }`) to the
 * nested form i18next expects in JSON files (`{ list: { title: 'Employés' } }`).
 * Used in enriched mode when `spec.pageSpec.i18nKeys` is provided — the
 * canonical PRD shape stores flat keys (one entry per `t('key')` call) and the
 * generator materialises the nested tree at write time.
 */
function flatToNested(flat: Record<string, string>): Record<string, unknown> {
  const root: Record<string, unknown> = {}
  for (const [dottedKey, value] of Object.entries(flat)) {
    const parts = dottedKey.split('.')
    let cursor: Record<string, unknown> = root
    for (let i = 0; i < parts.length - 1; i++) {
      const part = parts[i]!
      const next = cursor[part]
      if (typeof next === 'object' && next !== null && !Array.isArray(next)) {
        cursor = next as Record<string, unknown>
      } else {
        const fresh: Record<string, unknown> = {}
        cursor[part] = fresh
        cursor = fresh
      }
    }
    cursor[parts[parts.length - 1]!] = value
  }
  return root
}

function translateLabel(label: string, locale: Locale): string {
  const dictionary: Record<string, Record<Locale, string>> = {
    Loading: { fr: 'Chargement…', en: 'Loading…', it: 'Caricamento…', de: 'Wird geladen…' },
    NotFound: { fr: 'Introuvable', en: 'Not found', it: 'Non trovato', de: 'Nicht gefunden' },
    Empty: { fr: 'Aucune donnée', en: 'No data', it: 'Nessun dato', de: 'Keine Daten' },
    Create: { fr: 'Créer', en: 'Create', it: 'Crea', de: 'Erstellen' },
    Edit: { fr: 'Modifier', en: 'Edit', it: 'Modifica', de: 'Bearbeiten' },
    Delete: { fr: 'Supprimer', en: 'Delete', it: 'Elimina', de: 'Löschen' },
    Update: { fr: 'Mettre à jour', en: 'Update', it: 'Aggiorna', de: 'Aktualisieren' },
    Actions: { fr: 'Actions', en: 'Actions', it: 'Azioni', de: 'Aktionen' },
    Save: { fr: 'Enregistrer', en: 'Save', it: 'Salva', de: 'Speichern' },
    Cancel: { fr: 'Annuler', en: 'Cancel', it: 'Annulla', de: 'Abbrechen' },
    Search: { fr: 'Rechercher…', en: 'Search…', it: 'Cerca…', de: 'Suchen…' },
    Subtitle: { fr: 'Gérer les', en: 'Manage', it: 'Gestisci', de: 'Verwalten' },
    Error: { fr: 'Erreur', en: 'Error', it: 'Errore', de: 'Fehler' },
    Dashboard: { fr: 'Tableau de bord', en: 'Dashboard', it: 'Cruscotto', de: 'Dashboard' },
    StartDate: { fr: 'Date de début', en: 'Start date', it: 'Data inizio', de: 'Startdatum' },
    EndDate: { fr: 'Date de fin', en: 'End date', it: 'Data fine', de: 'Enddatum' },
    Alerts: { fr: 'Alertes', en: 'Alerts', it: 'Avvisi', de: 'Warnungen' },
    NoAlerts: { fr: 'Aucune alerte', en: 'No alerts', it: 'Nessun avviso', de: 'Keine Warnungen' },
    SeverityInfo: { fr: 'Info', en: 'Info', it: 'Info', de: 'Info' },
    SeverityWarning: { fr: 'Avertissement', en: 'Warning', it: 'Avviso', de: 'Warnung' },
    SeverityCritical: { fr: 'Critique', en: 'Critical', it: 'Critico', de: 'Kritisch' },
    Kpis: { fr: 'Indicateurs clés', en: 'Key indicators', it: 'Indicatori chiave', de: 'Kennzahlen' },
    Navigation: { fr: 'Navigation', en: 'Navigation', it: 'Navigazione', de: 'Navigation' },
  }
  const entry = dictionary[label]
  if (!entry) throw new Error(`Missing translation for UI label '${label}' — update CLI dictionary`)
  return entry[locale]
}

/** Map widget type to a sensible default Lucide icon. */
const WIDGET_TYPE_ICONS: Record<string, string> = {
  kpi: 'TrendingUp',
  counter: 'Hash',
  'chart-pie': 'PieChart',
  'chart-bar': 'BarChart3',
  'chart-line': 'LineChart',
  list: 'List',
}

/** Convert kebab-case icon name to PascalCase Lucide import (e.g. `file-text` → `FileText`). */
function kebabToPascal(kebab: string): string {
  return kebab.split('-').map(s => s.charAt(0).toUpperCase() + s.slice(1)).join('')
}

function humanize(name: string): string {
  return name
    .replace(/([A-Z])/g, ' $1')
    .replace(/[_-]+/g, ' ')
    .trim()
    .replace(/^./, c => c.toUpperCase())
}

/** kebab → camelCase (`bulk-archive` → `bulkArchive`). Used for mutation
 *  variable names that match the api-client service member naming. */
function toCamel(code: string): string {
  const parts = code.split('-')
  return parts[0]! + parts.slice(1).map(p => p.charAt(0).toUpperCase() + p.slice(1)).join('')
}

function pluralize(singular: string, locale: Locale): string {
  const lowercaseLast = singular.toLowerCase()
  if (locale === 'it') return lowercaseLast.endsWith('a') ? singular.slice(0, -1) + 'e' : singular + 's'
  return singular + 's'
}

/** PascalCase → camelCase (e.g. `FirstName` → `firstName`). */
function fieldToCamel(name: string): string {
  if (name.length === 0) return name
  return name.charAt(0).toLowerCase() + name.slice(1)
}

const I18N_FIELD_PARENTS = new Set(['fields', 'columns', 'filters', 'help'])

function normalizeI18nFieldKeys(flat: Record<string, string>): Record<string, string> {
  const result: Record<string, string> = {}
  for (const [key, value] of Object.entries(flat)) {
    const parts = key.split('.')
    if (parts.length >= 3) {
      const parentSegment = parts[parts.length - 2]!
      if (I18N_FIELD_PARENTS.has(parentSegment)) {
        parts[parts.length - 1] = fieldToCamel(parts[parts.length - 1]!)
      }
    }
    result[parts.join('.')] = value
  }
  return result
}

/**
 * Symmetric to normalizeI18nFieldKeys() — applies the same camelCase
 * normalisation rule on a single labelKey BEFORE it lands in the TSX
 * `t('...')` call. Ensures the key the component requests matches the
 * key the JSON catalogue ships (which goes through normalizeI18nFieldKeys
 * at line ~1400). Without this symmetry, a PRD-emitted `list.columns.Code`
 * (PascalCase) becomes `list.columns.code` in JSON but stays
 * `list.columns.Code` in TSX → untranslated raw key on screen.
 *
 * Path is preserved verbatim — only the leaf is camelCased, and only when
 * its parent is in I18N_FIELD_PARENTS. Idempotent: re-camelCasing a
 * camelCase leaf is a no-op, so legacy pagespecs already in camelCase
 * are unaffected. Action labelKeys (e.g. `list.create`, `form.actions.toggle-actif`)
 * stay verbatim because their parent (`list`, `actions`) is not a field-parent —
 * symmetric with normalizeI18nFieldKeys which leaves them alone too.
 */
function normalizeI18nKey(key: string): string {
  const parts = key.split('.')
  if (parts.length >= 3) {
    const parentSegment = parts[parts.length - 2]!
    if (I18N_FIELD_PARENTS.has(parentSegment)) {
      parts[parts.length - 1] = fieldToCamel(parts[parts.length - 1]!)
    }
  }
  return parts.join('.')
}

function buildTranslations(spec: ScaffoldComponentInput, locale: Locale): Record<string, unknown> {
  const fieldLabels: Record<string, string> = {}
  for (const f of spec.fields) {
    fieldLabels[fieldToCamel(f.name)] = f.label ?? humanize(f.name)
  }

  const entityName = humanize(spec.entity)
  const pluralName = pluralize(entityName, locale)

  return {
    list: {
      title: pluralName,
      subtitle: `${translateLabel('Subtitle', locale)} ${pluralName.toLowerCase()}`,
      search: translateLabel('Search', locale),
      loading: translateLabel('Loading', locale),
      empty: translateLabel('Empty', locale),
      create: translateLabel('Create', locale),
      edit: translateLabel('Edit', locale),
      delete: translateLabel('Delete', locale),
      actionsColumn: translateLabel('Actions', locale),
      error: translateLabel('Error', locale),
      columns: fieldLabels,
    },
    detail: {
      loading: translateLabel('Loading', locale),
      notFound: translateLabel('NotFound', locale),
      edit: translateLabel('Edit', locale),
      fields: fieldLabels,
    },
    form: {
      createTitle: `${translateLabel('Create', locale)} ${entityName}`,
      editTitle: `${translateLabel('Edit', locale)} ${entityName}`,
      submitCreate: translateLabel('Create', locale),
      submitUpdate: translateLabel('Update', locale),
      save: translateLabel('Save', locale),
      cancel: translateLabel('Cancel', locale),
      fields: fieldLabels,
    },
    dashboard: {
      title: `${translateLabel('Dashboard', locale)} ${pluralName.toLowerCase()}`,
      subtitle: pluralName,
      loading: translateLabel('Loading', locale),
      error: translateLabel('Error', locale),
      startDate: translateLabel('StartDate', locale),
      endDate: translateLabel('EndDate', locale),
      // Per-widget labels keyed by widget.key — populated by create-prd from the
      // SmartDashboard widgets (or edit here). Read as dashboard.widget.<key>.
      widget: {} as Record<string, string>,
    },
    home: {
      title: pluralName,
      subtitle: `${translateLabel('Subtitle', locale)} ${pluralName.toLowerCase()}`,
      kpis: translateLabel('Kpis', locale),
      navigation: translateLabel('Navigation', locale),
    },
    breadcrumb: {
      section: humanize(spec.section),
    },
  }
}

function defaultForField(f: ComponentField): string {
  if (f.defaultValue !== undefined && f.defaultValue !== null) {
    if (typeof f.defaultValue === 'string') return `'${f.defaultValue}'`
    return String(f.defaultValue)
  }
  // Multi-valued enum → empty array initial value (typed string[]).
  if (f.multiple && Array.isArray(f.options) && f.options.length > 0) return `[]`
  switch (f.type.toLowerCase()) {
    case 'string': return `''`
    case 'number': case 'int': case 'integer': case 'decimal': return `0`
    case 'bool': case 'boolean': return `false`
    case 'datetime': case 'date': return `''`
    default: return `''`
  }
}

function formDataTypeLiteral(fields: ComponentField[]): string {
  if (fields.length === 0) return `Record<string, unknown>`
  const lines = fields.map(f => `  ${fieldToCamel(f.name)}${f.required ? '' : '?'}: ${tsTypeForField(f)}`)
  return `{\n${lines.join('\n')}\n}`
}

/** Field-aware TS type: multi-valued enums become `string[]`; everything else
 *  follows the scalar `toTsType` mapping. */
function tsTypeForField(f: ComponentField): string {
  if (f.multiple && Array.isArray(f.options) && f.options.length > 0) return 'string[]'
  return toTsType(f.type)
}

function toTsType(type: string): string {
  switch (type.toLowerCase()) {
    case 'string': case 'guid': case 'datetime': case 'date': return 'string'
    case 'number': case 'int': case 'integer': case 'decimal': return 'number'
    case 'bool': case 'boolean': return 'boolean'
    default: return 'string'
  }
}

export function generate(spec: ScaffoldComponentInput): GeneratedFile[] {
  if (spec.fields.length === 0) {
    throw new Error(`scaffold-component: entity '${spec.entity}' has no fields — scaffolding would produce an empty UI`)
  }

  const files: GeneratedFile[] = []
  const e = spec.entity
  const eLower = e.charAt(0).toLowerCase() + e.slice(1)
  const webRoot = spec.webRoot ?? `web/${spec.appCode}-web`
  const plural = e + 's'
  // Pages location. When `pageSpec.filePath` is provided, IT is the source
  // of truth — derive the directory from it. This handles legacy folder
  // naming (e.g. `pages/budgeting/budgets/` instead of the canonical
  // `pages/budgets/budgets/`). Without this override the scaffold would
  // write to a fresh directory and abandon the existing pages, leaving
  // the user with two copies. Falls back to the canonical
  // `src/pages/{appCode}/{module}/{section}` when no pageSpec provided.
  const base = spec.pageSpec?.filePath
    ? spec.pageSpec.filePath.replace(/\\/g, '/').replace(/\/[^/]+\.tsx$/i, '')
    : `src/pages/${spec.appCode.toLowerCase()}/${spec.module}/${spec.section}`

  // When `pageSpec.filePath` matches the current view we want to honour the
  // exact file name from the PRD (e.g. `BudgetsModuleHomePage.tsx`) rather
  // than the entity-derived default. The single-view-per-call invariant
  // (Phase 3a passes `views: [pageSpec.view]`) guarantees the match logic
  // is unambiguous: at most one view in the loop can claim the pageSpec.
  function pathFor(view: string, defaultName: string): string {
    if (spec.pageSpec && spec.pageSpec.view === view && spec.pageSpec.filePath) {
      return spec.pageSpec.filePath.replace(/\\/g, '/')
    }
    return `${base}/${defaultName}`
  }
  const featurePath = `@/features/${spec.module}/${eLower}`
  // Permissions are 4-seg: {app}.{module}.{section}.{action}. The auth
  // service stores them in this shape (ba_permissions.path), and
  // useAuth().hasPermission() does exact-string match. Without the
  // appCode prefix, the match fails → PermissionGuard hides everything →
  // pages render empty in the browser ("aucun changement" symptom).
  // appCode is lowercased to match the DB convention (e.g. TestV2 → testv2).
  const permKey = `${spec.module}.${spec.section}`
  // `spec.section` is kebab-case (URL segment), but `scaffold-routes` emits the
  // `routes` object with camelCase property keys (`typesAffaire`, not `types-affaire`)
  // — so any `routes.<section>` reference in generated TSX must use the same
  // transform. Without it, `routes.types-affaire.detail(item.id)` is parsed by
  // JS as `routes.types - affaire.detail(item.id)` (subtraction), throwing
  // `ReferenceError: affaire is not defined` at the first interaction.
  const sectionCamel = toCamel(spec.section)
  // List file uses the plural entity name (BudgetsListPage.tsx) to match the
  // existing project convention; detail/form/dashboard stay singular.
  const listFileName = `${plural}ListPage.tsx`
  // Form fields exclude computed attributes — they are read-only, projected
  // by the backend, and must NEVER appear as inputs.
  const formFields = spec.fields.filter(f => !f.formula)
  const headerField = spec.fields.find(f => !f.formula) ?? spec.fields[0]
  const initialFormObj = `{\n${formFields.map(f => `    ${fieldToCamel(f.name)}: ${defaultForField(f)},`).join('\n')}\n  }`

  // ─── Shared custom-action infrastructure ─────────────────────────────────
  // Hoisted here so list, detail, AND form blocks can all access it.
  type PageActionMeta = {
    code: string
    kind?: 'api' | 'navigate'
    scope: 'header' | 'row' | 'bulk'
    labelKey: string
    permission: string
    variant?: string
    targetRoute?: string
    targetScreen?: string
    endpoint?: string
    httpMethod?: string
    ucReference?: string
  }
  const STANDARD_CRUD_CODES = new Set(['create', 'edit', 'update', 'delete', 'list', 'detail', 'read'])
  const isNavigate = (a: PageActionMeta): boolean => {
    if (a.kind === 'navigate') return true
    if (a.kind === 'api') return false
    return a.code === 'open' && a.scope === 'row'
  }
  const isApi = (a: PageActionMeta): boolean => !isNavigate(a)
  const hookCodeOf = (a: PageActionMeta): string => a.endpoint ?? a.code
  const toPascal = (code: string): string =>
    code.split('-').map(p => p.charAt(0).toUpperCase() + p.slice(1)).join('')

  const ICON_FOR_CODE: Record<string, string> = {
    open: 'ExternalLink',
    close: 'XCircle',
    archive: 'Archive',
    restore: 'ArchiveRestore',
    duplicate: 'Copy',
    approve: 'Check',
    reject: 'X',
    validate: 'CheckCircle',
    cancel: 'XCircle',
    submit: 'Send',
    'submit-for-review': 'Send',
    activate: 'Power',
    deactivate: 'PowerOff',
    assign: 'UserPlus',
    unassign: 'UserMinus',
    export: 'Download',
    import: 'Upload',
    print: 'Printer',
    share: 'Share2',
    lock: 'Lock',
    unlock: 'Unlock',
    'toggle-actif': 'ToggleRight',
    'set-availability': 'CalendarCheck',
    'map-to-pce': 'ArrowRightLeft',
    'core-alignment': 'GitMerge',
    'analyze-impact': 'Search',
    'sync-from-pce': 'RefreshCw',
    'sync-from-swisstopo': 'MapPin',
    'open-year': 'CalendarPlus',
    'close-year': 'CalendarMinus',
    'publish-to-pce': 'Upload',
    'detect-anomalies': 'AlertTriangle',
    'bulk-map': 'Layers',
  }
  const iconForCode = (code: string): string => ICON_FOR_CODE[code] ?? 'ChevronRight'

  const allCustomActions = ((spec.pageSpec?.actions ?? []) as PageActionMeta[])
    .filter(a => !STANDARD_CRUD_CODES.has(a.code.toLowerCase()))

  /** Render a header action button JSX string (used by list, detail and form). */
  const renderHeaderActionButton = (a: PageActionMeta): string => {
    const Icon = iconForCode(a.code)
    const isPrimary = a.variant === 'primary'
    const className = isPrimary
      ? 'btn btn-primary'
      : 'btn btn-secondary'
    return `            <PermissionGuard permission="${a.permission}">
              <button
                type="button"
                onClick={() => { void handle${toPascal(a.code)}() }}
                className="${className}"
              >
                <${Icon} className="w-4 h-4" />
                {t('${eLower}.${a.labelKey}')}
              </button>
            </PermissionGuard>`
  }

  // ─── ListPage ────────────────────────────────────────────────────────────
  if (spec.views.includes('list')) {
    const customActions = allCustomActions
    const apiActions = customActions.filter(isApi)

    // pageSpec.columns is the PRD-curated list (ba_screens.config.columns →
    // create-prd → pageSpec). When present, use it verbatim so the page
    // shows EXACTLY what the BA asked for — no more, no less. The data model
    // can carry legacy columns (e.g. `description` on the Budget entity) that
    // the BA did not declare for this view; using spec.fields would surface
    // them as untranslated `LIST.COLUMNS.DESCRIPTION` headers.
    // Fallback: spec.fields[0..6] for legacy projects without pageSpec.columns
    // (PRD predates the per-page contract).
    const columnSource = spec.pageSpec?.columns?.length
      ? spec.pageSpec.columns.map((c) => ({
          name: fieldToCamel(c.key),
          // Fix #9 — normalise leaf to match the JSON i18n catalogue
          // (see normalizeI18nKey JSDoc). PRD may emit PascalCase
          // (`list.columns.Code`); JSON normalises to camelCase; TSX must follow.
          labelKey: normalizeI18nKey(c.labelKey),
          sortable: c.sortable ?? false,
        }))
      : spec.fields.slice(0, 6).map((f) => ({
          name: fieldToCamel(f.name),
          labelKey: `list.columns.${fieldToCamel(f.name)}`,
          sortable: true,
        }))

    // Responsive column contract (consumed by <ResponsiveDataTable>): the `code`
    // + `label/libellé` columns — plus the actions column appended below — stay
    // ALWAYS visible; the remaining columns appear progressively as the viewport
    // widens (first extra → md, second → lg, third+ → xl). Every text column also
    // gets `truncate: true` so a long value is clipped with an ellipsis and its
    // full text shown in a tooltip on hover ("popup si le libellé est tronqué").
    // The BA pagespec overrides per column via `priority:'always'` or an explicit
    // `minBreakpoint` (read by index — columnSource is 1:1 with pageSpec.columns
    // in enriched mode).
    const psColumns = spec.pageSpec?.columns
    const isCodeName = (n: string) => /^(code|reference|ref|numero|num)$/i.test(n)
    const isLabelName = (n: string) =>
      /^(label|libelle|libell[eé]|name|nom|title|titre|designation|intitule|intitul[eé])$/i.test(n)
    const codeIdx = columnSource.findIndex((c) => isCodeName(c.name))
    const labelIdx = columnSource.findIndex((c, i) => i !== codeIdx && isLabelName(c.name))
    const alwaysIdx = new Set<number>()
    if (codeIdx >= 0) alwaysIdx.add(codeIdx)
    if (labelIdx >= 0) alwaysIdx.add(labelIdx)
    columnSource.forEach((_c, i) => { if (psColumns?.[i]?.priority === 'always') alwaysIdx.add(i) })
    // Fallback: with no code/label match, keep the first (and second) column
    // visible so a narrow screen never renders an actions-only table.
    if (alwaysIdx.size === 0) {
      alwaysIdx.add(0)
      if (columnSource.length > 1) alwaysIdx.add(1)
    }
    const RESP_TIERS = ['md', 'lg', 'xl'] as const
    let respExtraTier = 0
    const columnEntries = columnSource.map((c, i) => {
      const explicit = psColumns?.[i]?.minBreakpoint
      let minBreakpoint: string | undefined
      if (alwaysIdx.has(i)) minBreakpoint = undefined
      else if (explicit) minBreakpoint = explicit
      else { minBreakpoint = RESP_TIERS[Math.min(respExtraTier, RESP_TIERS.length - 1)]; respExtraTier++ }
      const bp = minBreakpoint ? `, minBreakpoint: '${minBreakpoint}'` : ''
      return `    { key: '${c.name}', label: t('${eLower}.${c.labelKey}'), sortable: ${c.sortable}, truncate: true${bp} }`
    }).join(',\n')

    // pageSpec.filters[] generates a FilterBar above the DataTable. Each entry
    // produces ONE controlled input whose value lives in local React state.
    // For text & select controls we apply client-side filtering on data?.items
    // immediately; date-range UI is rendered but its filtering relies on the
    // hook accepting the values (not yet plumbed — left as v2 enhancement).
    const pageSpecFilters = ((spec.pageSpec?.filters ?? []) as Array<{
      field: string
      labelKey: string
      control?: string
      options?: unknown[]
    }>).map(f => ({
      ...f,
      // Fix #9 — normalise leaf to match the JSON i18n catalogue. One pass
      // here means renderFilterInput() (6 substitution sites for select/text/
      // date-range/boolean labels + placeholders) does not need to be touched.
      labelKey: normalizeI18nKey(f.labelKey),
    }))
    const hasFilters = pageSpecFilters.length > 0
    const reactImport = hasFilters ? `import { useMemo, useState } from 'react'\n` : ''

    // Filter controls that use theme-compliant primitives need their imports on
    // the list page: select (with options) → EnumSelect; date-range → DateInput.
    const ctrlOf = (f: { control?: string }) => (f.control ?? 'text').toLowerCase()
    const usesSelectFilter = pageSpecFilters.some(f => ctrlOf(f) === 'select' && Array.isArray(f.options) && f.options.length > 0)
    const usesDateRangeFilter = pageSpecFilters.some(f => ctrlOf(f) === 'date-range' || ctrlOf(f) === 'daterange')
    const filterPrimitiveImports = [
      usesSelectFilter ? `\nimport { EnumSelect } from '@/components/ui/EnumSelect'` : '',
      usesDateRangeFilter ? `\nimport { DateInput } from '@/components/ui/DateInput'` : '',
    ].join('')

    function renderFilterInput(f: typeof pageSpecFilters[number]): string {
      const ctrl = (f.control ?? 'text').toLowerCase()
      if (ctrl === 'select') {
        const opts = Array.isArray(f.options) ? f.options : []
        // When the PRD declares control="select" but provides no options,
        // fall back to a text input — an empty <select> is non-functional.
        if (opts.length === 0) {
          return `          <div className="flex flex-col gap-1 min-w-[180px]">
            <label className="text-xs font-medium text-[var(--text-muted)]">{t('${eLower}.${f.labelKey}')}</label>
            <input
              type="text"
              value={filters['${f.field}'] ?? ''}
              onChange={(event) => onFilterChange('${f.field}', event.target.value)}
              placeholder={t('${eLower}.${f.labelKey}')}
              className="input"
            />
          </div>`
        }
        const optionObjs = opts.map((opt) => {
          if (typeof opt === 'string') return `{ value: '${opt}', label: '${opt}' }`
          const o = opt as { value?: string; label?: string }
          const val = String(o.value ?? '')
          const lab = String(o.label ?? o.value ?? '')
          return `{ value: '${val}', label: '${lab}' }`
        }).join(', ')
        // Themed dropdown (EnumSelect) instead of the native <select>. The
        // leading empty-value option doubles as the "all" reset; clearable is
        // off because selecting "all" already clears the filter.
        return `          <div className="flex flex-col gap-1 min-w-[180px]">
            <label className="text-xs font-medium text-[var(--text-muted)]">{t('${eLower}.${f.labelKey}')}</label>
            <EnumSelect
              options={[{ value: '', label: t('${eLower}.list.filters.all') }, ${optionObjs}]}
              value={filters['${f.field}'] ?? ''}
              onChange={(v) => onFilterChange('${f.field}', v ?? '')}
              clearable={false}
            />
          </div>`
      }
      if (ctrl === 'date-range' || ctrl === 'daterange') {
        return `          <div className="flex flex-col gap-1">
            <label className="text-xs font-medium text-[var(--text-muted)]">{t('${eLower}.${f.labelKey}')}</label>
            <div className="flex items-center gap-1">
              <DateInput
                value={filters['${f.field}From'] || null}
                onChange={(v) => onFilterChange('${f.field}From', v ?? '')}
              />
              <span className="text-[var(--text-muted)]">→</span>
              <DateInput
                value={filters['${f.field}To'] || null}
                onChange={(v) => onFilterChange('${f.field}To', v ?? '')}
              />
            </div>
          </div>`
      }
      if (ctrl === 'boolean') {
        return `          <label className="flex items-center gap-1 self-end text-sm text-[var(--text-primary)]">
            <input
              type="checkbox"
              checked={filters['${f.field}'] === 'true'}
              onChange={(event) => onFilterChange('${f.field}', event.target.checked ? 'true' : '')}
            />
            {t('${eLower}.${f.labelKey}')}
          </label>`
      }
      // text + fallback
      return `          <div className="flex flex-col gap-1 min-w-[180px]">
            <label className="text-xs font-medium text-[var(--text-muted)]">{t('${eLower}.${f.labelKey}')}</label>
            <input
              type="text"
              value={filters['${f.field}'] ?? ''}
              onChange={(event) => onFilterChange('${f.field}', event.target.value)}
              placeholder={t('${eLower}.${f.labelKey}')}
              className="input"
            />
          </div>`
    }

    const filterStateInitEntries = pageSpecFilters.flatMap(f => {
      const ctrl = (f.control ?? 'text').toLowerCase()
      if (ctrl === 'date-range' || ctrl === 'daterange') {
        return [`'${f.field}From': ''`, `'${f.field}To': ''`]
      }
      return [`'${f.field}': ''`]
    }).join(', ')

    // Bug 4 fix — DTOs emitted by scaffold-api-client are SEALED interfaces (no
    // index signature), so the single cast `item as Record<string, unknown>` is
    // rejected by TypeScript ("conversion may be a mistake — neither type
    // sufficiently overlaps with the other"). The double-cast `as unknown as
    // Record<string, unknown>` is the canonical TS idiom for crossing this
    // boundary safely. We declare it once at filter-block top so each match
    // expression stays readable.
    const filterMatchersJs = pageSpecFilters.map(f => {
      if (f.field === 'q') {
        return `      if (filters['q']) {
        const q = filters['q'].toLowerCase()
        const match = Object.values(item as unknown as Record<string, unknown>).some(v => String(v ?? '').toLowerCase().includes(q))
        if (!match) return false
      }`
      }
      const ctrl = (f.control ?? 'text').toLowerCase()
      if (ctrl === 'select' || ctrl === 'boolean') {
        return `      if (filters['${f.field}'] && String((item as unknown as Record<string, unknown>)['${f.field}'] ?? '') !== filters['${f.field}']) return false`
      }
      if (ctrl === 'date-range' || ctrl === 'daterange') {
        return `      if (filters['${f.field}From'] || filters['${f.field}To']) {
        const cell = (item as unknown as Record<string, unknown>)['${f.field}']
        const cellTime = cell ? new Date(String(cell)).getTime() : NaN
        if (filters['${f.field}From'] && cellTime < new Date(filters['${f.field}From']).getTime()) return false
        if (filters['${f.field}To'] && cellTime > new Date(filters['${f.field}To']).getTime()) return false
      }`
      }
      // text, default
      return `      if (filters['${f.field}']) {
        const cell = String((item as unknown as Record<string, unknown>)['${f.field}'] ?? '').toLowerCase()
        if (!cell.includes(filters['${f.field}'].toLowerCase())) return false
      }`
    }).join('\n')

    const filterStateBlock = hasFilters
      ? `  const [filters, setFilters] = useState<Record<string, string>>({ ${filterStateInitEntries} })
  const onFilterChange = (key: string, value: string) => setFilters((prev) => ({ ...prev, [key]: value }))
`
      : ''

    const customRowActions = customActions.filter(a => a.scope === 'row')
    const customHeaderActions = customActions.filter(a => a.scope === 'header')

    // Hook imports — ONLY for api-bound actions. Navigate actions do not call
    // any hook (they just `navigate(...)`), so importing one would produce an
    // unused-symbol warning at best, a phantom 405 at worst.
    const customHookImports = apiActions
      .map(a => `, use${toPascal(hookCodeOf(a))}${e}`)
      .join('')

    // Lucide icon imports — dedup to avoid duplicate identifiers. Both api +
    // navigate actions need their icon, so the union covers every button.
    const usedIcons = new Set(customActions.map(a => iconForCode(a.code)))
    const customIconImports = Array.from(usedIcons).map(name => `, ${name}`).join('')

    // Mutation declarations — one per api-bound custom action, e.g.
    //   const archiveMutation = useArchiveBudget()
    // Navigate actions emit nothing here.
    const customMutationDecls = apiActions
      .map(a => `  const ${toCamel(hookCodeOf(a))}Mutation = use${toPascal(hookCodeOf(a))}${e}()`)
      .join('\n')

    // Handler functions — bridge button onClick to mutation.mutateAsync (api)
    // or to navigate(targetRoute) (navigate). The two branches keep the same
    // signature shape so the JSX caller is uniform.
    const customHandlerDecls = customActions
      .map(a => {
        const name = `handle${toPascal(a.code)}`
        if (isNavigate(a)) {
          const target = a.targetRoute
            ?? (a.scope === 'row'
              ? `routes.${sectionCamel}.detail(item.id)`
              : `routes.${sectionCamel}.list()`)
          if (a.scope === 'header') {
            return `  const ${name} = () => {\n    navigate(${target})\n  }`
          }
          return `  const ${name} = (item: ${e}) => {\n    navigate(${target})\n  }`
        }
        // kind: 'api' — call the matching React Query mutation.
        const hookVar = `${toCamel(hookCodeOf(a))}Mutation`
        if (a.scope === 'header') {
          return `  const ${name} = async () => {\n    await ${hookVar}.mutateAsync()\n  }`
        }
        // row scope (bulk omitted from this template — needs DataTable selection support)
        return `  const ${name} = async (item: ${e}) => {\n    await ${hookVar}.mutateAsync(item.id)\n  }`
      })
      .join('\n\n')

    const customHeaderActionsJsx = customHeaderActions.map(renderHeaderActionButton).join('\n')

    // Row action buttons JSX — icon-only, danger variant uses error-bg/text.
    const renderRowActionButton = (a: PageActionMeta): string => {
      const Icon = iconForCode(a.code)
      const isDanger = a.variant === 'danger'
      const hoverBg = isDanger ? 'hover:bg-[var(--error-bg)]' : 'hover:bg-[var(--bg-hover)]'
      const hoverText = isDanger ? 'hover:text-[var(--error-text)]' : 'hover:text-[var(--text-primary)]'
      return `              <PermissionGuard permission="${a.permission}">
                <button
                  type="button"
                  onClick={(e) => { e.stopPropagation(); void handle${toPascal(a.code)}(item) }}
                  className="p-1.5 rounded ${hoverBg} text-[var(--text-muted)] ${hoverText}"
                  title={t('${eLower}.${a.labelKey}')}
                >
                  <${Icon} className="w-4 h-4" />
                </button>
              </PermissionGuard>`
    }
    const customRowActionsJsx = customRowActions.map(renderRowActionButton).join('\n')

    const filteredItemsBlock = hasFilters
      ? `  const items = data?.items ?? []
  const filtered = useMemo(() => {
    return items.filter((item) => {
${filterMatchersJs}
      return true
    })
  }, [items, filters])
`
      : `  const filtered = data?.items ?? []
`

    const filterBarJsx = hasFilters
      ? `        <div className="mb-4 flex flex-wrap items-end gap-3 p-3 rounded-[var(--radius-card)] border border-[var(--border-color)] bg-[var(--bg-card)]">
${pageSpecFilters.map(renderFilterInput).join('\n')}
        </div>

`
      : ''

    files.push({
      path: pathFor('list', listFileName),
      content: `${GENERATED_MARKER}${reactImport}import { useNavigate } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import { Loader2, Plus, Pencil, Trash2, List${customIconImports} } from 'lucide-react'
import { Slot } from '@atlashub/smartstack'
import { PermissionGuard } from '@/components/auth/PermissionGuard'
import { PageTemplate } from '@/components/ui/PageTemplate'
import { ResponsiveDataTable, type ResponsiveColumn } from '@/components/ui/ResponsiveDataTable'${filterPrimitiveImports}
import { routes } from '@/extensions/${spec.module}Routes'
import { use${plural}, useDelete${e}${customHookImports} } from '${featurePath}/hooks/use${e}'
import type { ${e} } from '${featurePath}/types'

export function ${plural}ListPage() {
  const { t } = useTranslation('${spec.module}')
  const navigate = useNavigate()
  const { data, isLoading, error } = use${plural}()
  const deleteMutation = useDelete${e}()
${customMutationDecls ? customMutationDecls + '\n' : ''}${filterStateBlock}
  const handleDelete = async (item: ${e}) => {
    await deleteMutation.mutateAsync(item.id)
  }
${customHandlerDecls ? '\n' + customHandlerDecls + '\n' : ''}
${filteredItemsBlock}
  const columns: ResponsiveColumn<${e}>[] = [
${columnEntries},
    {
      key: '__actions',
      label: t('${eLower}.list.actionsColumn'),
      align: 'right',
      render: (item) => (
        <div className="flex items-center justify-end gap-1">
          <PermissionGuard permission="${permKey}.update">
            <button
              type="button"
              onClick={(e) => { e.stopPropagation(); navigate(routes.${sectionCamel}.edit(item.id)) }}
              className="p-1.5 rounded hover:bg-[var(--bg-hover)] text-[var(--text-muted)] hover:text-[var(--text-primary)]"
              title={t('${eLower}.list.edit')}
            >
              <Pencil className="w-4 h-4" />
            </button>
          </PermissionGuard>
          <PermissionGuard permission="${permKey}.delete">
            <button
              type="button"
              onClick={(e) => { e.stopPropagation(); void handleDelete(item) }}
              className="p-1.5 rounded hover:bg-[var(--error-bg)] text-[var(--text-muted)] hover:text-[var(--error-text)]"
              title={t('${eLower}.list.delete')}
            >
              <Trash2 className="w-4 h-4" />
            </button>
          </PermissionGuard>
${customRowActionsJsx}
        </div>
      ),
    },
  ]

  return (
    <PermissionGuard permission="${permKey}.read">
      <PageTemplate
        title={t('${eLower}.list.title')}
        subtitle={t('${eLower}.list.subtitle')}
        icon={<List className="w-6 h-6" />}
        breadcrumbs={[{ label: t('${eLower}.breadcrumb.section') }]}
        actions={
          <div className="flex items-center gap-2">
            <PermissionGuard permission="${permKey}.create">
              <button
                type="button"
                onClick={() => navigate(routes.${sectionCamel}.create())}
                className="btn btn-primary"
              >
                <Plus className="w-4 h-4" />
                {t('${eLower}.list.create')}
              </button>
            </PermissionGuard>
${customHeaderActionsJsx}
          </div>
        }
      >
        <Slot name="${eLower}.header.actions" />

        {error && (
          <div className="mb-4 p-3 rounded-[var(--radius-card)] bg-[var(--error-bg)] border border-[var(--error-border)] text-sm text-[var(--error-text)]">
            {t('${eLower}.list.error')}: {String(error)}
          </div>
        )}

${filterBarJsx}        <Slot name="${eLower}.table.columns.before" />

        {isLoading ? (
          <div className="flex items-center justify-center py-12 text-[var(--text-muted)]">
            <Loader2 className="w-5 h-5 animate-spin mr-2" />
            {t('${eLower}.list.loading')}
          </div>
        ) : (
          <ResponsiveDataTable<${e}>
            data={filtered}
            columns={columns}
            searchable
            searchPlaceholder={t('${eLower}.list.search')}
            pagination={{ pageSize: 20, showSizeSelector: true }}
            getRowKey={(item) => item.id}
            emptyMessage={t('${eLower}.list.empty')}
            onRowClick={(item) => navigate(routes.${sectionCamel}.detail(item.id))}
          />
        )}

        <Slot name="${eLower}.table.columns.after" />
      </PageTemplate>
    </PermissionGuard>
  )
}

export default ${plural}ListPage
`,
    })
  }

  // ─── DetailPage ──────────────────────────────────────────────────────────
  if (spec.views.includes('detail')) {
    // Tab support: when pageSpec.tabs[] exists, group fields into tab panels
    // with URL-synced state (?tab=key). Otherwise render flat.
    type TabMeta = { key: string; labelKey?: string; label?: string; fields?: string[] }
    const detailTabs = (spec.pageSpec?.tabs ?? []) as TabMeta[]
    const hasDetailTabs = detailTabs.length > 0

    const detailExtraImport = hasDetailTabs ? `, useSearchParams` : ''

    function renderFieldDl(fields: typeof spec.fields): string {
      return fields.map(f => {
        const camel = fieldToCamel(f.name)
        // Enum / multiselect detail values resolve to the human label (not the
        // raw stored code); plain/date fields stay verbatim (ISO dates are
        // unambiguous — formatting a date-only value risks a timezone off-by-one).
        let valueExpr: string
        if (isMultiEnumField(f)) {
          valueExpr = `{((data.${camel} as string[] | undefined) ?? []).map((v) => ${optionsArrayLiteral(f.options ?? [])}.find((o) => o.value === v)?.label ?? v).join(', ')}`
        } else if (isEnumField(f)) {
          valueExpr = `{${optionsArrayLiteral(f.options ?? [])}.find((o) => o.value === data.${camel})?.label ?? String(data.${camel} ?? '')}`
        } else {
          valueExpr = `{String(data.${camel} ?? '')}`
        }
        return `            <div>
              <dt className="text-xs font-semibold uppercase tracking-wider text-[var(--text-muted)] mb-1">
                {t('${eLower}.detail.fields.${camel}')}
              </dt>
              <dd className="text-sm text-[var(--text-primary)]">
                ${valueExpr}
              </dd>
            </div>`
      }).join('\n')
    }

    let detailTabHooks = ''
    let detailBodyJsx: string

    if (hasDetailTabs) {
      const defaultTab = detailTabs[0].key
      detailTabHooks = `
  const [searchParams, setSearchParams] = useSearchParams()
  const activeTab = searchParams.get('tab') ?? '${defaultTab}'
  const switchTab = (tab: string) => {
    const p = new URLSearchParams(searchParams)
    tab === '${defaultTab}' ? p.delete('tab') : p.set('tab', tab)
    setSearchParams(p, { replace: true })
  }
`
      const tabBarJsx = detailTabs.map(tab => {
        const label = tab.labelKey ? `t('${eLower}.${tab.labelKey}')` : `'${tab.label ?? tab.key}'`
        return `          <button
            type="button"
            onClick={() => switchTab('${tab.key}')}
            className={\`px-4 py-2 text-sm font-medium border-b-2 transition-colors \${activeTab === '${tab.key}' ? 'border-[var(--color-accent-500)] text-[var(--color-accent-500)]' : 'border-transparent text-[var(--text-muted)] hover:text-[var(--text-primary)]'}\`}
          >
            {${label}}
          </button>`
      }).join('\n')

      const tabPanelsJsx = detailTabs.map(tab => {
        const tabFieldNames = tab.fields ?? spec.fields.map(f => f.name)
        const tabFields = spec.fields.filter(f => tabFieldNames.includes(f.name))
        return `        {activeTab === '${tab.key}' && (
          <div className="p-6 rounded-[var(--radius-card)] border border-[var(--border-color)] bg-[var(--bg-card)]">
            <dl className="grid grid-cols-1 md:grid-cols-2 gap-x-6 gap-y-4">
${renderFieldDl(tabFields)}
            </dl>
          </div>
        )}`
      }).join('\n\n')

      detailBodyJsx = `        <div className="flex gap-1 border-b border-[var(--border-color)] mb-6">
${tabBarJsx}
        </div>

${tabPanelsJsx}`
    } else {
      detailBodyJsx = `        <div className="p-6 rounded-[var(--radius-card)] border border-[var(--border-color)] bg-[var(--bg-card)]">
          <dl className="grid grid-cols-1 md:grid-cols-2 gap-x-6 gap-y-4">
${renderFieldDl(spec.fields)}
          </dl>
        </div>`
    }

    // Custom header actions for detail page — rendered alongside the Edit button.
    const detailHeaderActions = allCustomActions.filter(a => a.scope === 'header')
    const detailApiActions = detailHeaderActions.filter(isApi)
    const hasDetailActions = detailHeaderActions.length > 0

    const detailCustomHookImports = detailApiActions
      .map(a => `, use${toPascal(hookCodeOf(a))}${e}`)
      .join('')

    const detailCustomIconNames = new Set(detailHeaderActions.map(a => iconForCode(a.code)))
    const detailCustomIconImports = Array.from(detailCustomIconNames).map(name => `, ${name}`).join('')

    const detailCustomMutationDecls = detailApiActions
      .map(a => `  const ${toCamel(hookCodeOf(a))}Mutation = use${toPascal(hookCodeOf(a))}${e}()`)
      .join('\n')

    const detailCustomHandlerDecls = detailHeaderActions
      .map(a => {
        const name = `handle${toPascal(a.code)}`
        if (isNavigate(a)) {
          const target = a.targetRoute ?? `routes.${sectionCamel}.list()`
          return `  const ${name} = () => {\n    navigate(${target})\n  }`  // detail actions are always header-scoped
        }
        const hookVar = `${toCamel(hookCodeOf(a))}Mutation`
        return `  const ${name} = async () => {\n    if (id) await ${hookVar}.mutateAsync(id)\n  }`
      })
      .join('\n\n')

    const detailCustomActionsJsx = hasDetailActions
      ? `\n${detailHeaderActions.map(renderHeaderActionButton).join('\n')}`
      : ''

    files.push({
      path: pathFor('detail', `${e}DetailPage.tsx`),
      content: `${GENERATED_MARKER}import { useParams, useNavigate, Navigate${detailExtraImport} } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import { Loader2, Pencil, FileText${detailCustomIconImports} } from 'lucide-react'
import { Slot } from '@atlashub/smartstack'
import { PermissionGuard } from '@/components/auth/PermissionGuard'
import { PageTemplate } from '@/components/ui/PageTemplate'
import { routes } from '@/extensions/${spec.module}Routes'
import { use${e}${detailCustomHookImports} } from '${featurePath}/hooks/use${e}'

export function ${e}DetailPage() {
  const { id } = useParams<{ id: string }>()
  const navigate = useNavigate()
  const { t } = useTranslation('${spec.module}')

  if (!id) return <Navigate to=".." replace />

  const { data, isLoading } = use${e}(id)
${detailCustomMutationDecls ? detailCustomMutationDecls + '\n' : ''}${detailTabHooks}
  if (isLoading) {
    return (
      <div className="flex items-center justify-center py-12 text-[var(--text-muted)]">
        <Loader2 className="w-5 h-5 animate-spin mr-2" />
        {t('${eLower}.detail.loading')}
      </div>
    )
  }
  if (!data) {
    return (
      <div className="p-4 rounded-[var(--radius-card)] bg-[var(--warning-bg)] border border-[var(--warning-border)] text-[var(--warning-text)]">
        {t('${eLower}.detail.notFound')}
      </div>
    )
  }

  const headerTitle = String(data.${fieldToCamel(headerField?.name ?? 'id')} ?? '')
${detailCustomHandlerDecls ? '\n' + detailCustomHandlerDecls + '\n' : ''}
  return (
    <PermissionGuard permission="${permKey}.read">
      <PageTemplate
        title={headerTitle}
        icon={<FileText className="w-6 h-6" />}
        breadcrumbs={[
          { label: t('${eLower}.breadcrumb.section'), href: '..' },
          { label: headerTitle },
        ]}
        actions={
          <div className="flex items-center gap-2">
            <PermissionGuard permission="${permKey}.update">
              <button
                type="button"
                onClick={() => id && navigate(routes.${sectionCamel}.edit(id))}
                className="btn btn-primary"
              >
                <Pencil className="w-4 h-4" />
                {t('${eLower}.detail.edit')}
              </button>
            </PermissionGuard>${detailCustomActionsJsx}
          </div>
        }
      >
        <Slot name="${eLower}.detail.header" context={{ data }} />

${detailBodyJsx}

        <Slot name="${eLower}.detail.sidebar" context={{ data }} />
      </PageTemplate>
    </PermissionGuard>
  )
}

export default ${e}DetailPage
`,
    })
  }

  // ─── FormPage ────────────────────────────────────────────────────────────
  if (spec.views.includes('form')) {
    // FK fields drive the EntityLookup import + per-target lookup hook imports.
    // Empty array → no extra imports, no <EntityLookup>, audit DEV-UI-022 passes
    // because there's no FK to render in the first place.
    const fkFormFields = formFields.filter(isFkField)
    const fkLookupImport = fkFormFields.length > 0
      ? `\nimport { EntityLookup } from '@/components/ui/EntityLookup'`
      : ''
    const fkHookImports = lookupHookImportLines(fkFormFields)
      .map(line => `\n${line}`).join('')

    // Theme-compliant form primitives (scaffold-ui-primitives) — imported only
    // when the page actually renders the matching field type.
    const primitiveImports = [
      formFields.some(isDateField) ? `\nimport { DateInput } from '@/components/ui/DateInput'` : '',
      formFields.some(isEnumField) ? `\nimport { EnumSelect } from '@/components/ui/EnumSelect'` : '',
      formFields.some(isMultiEnumField) ? `\nimport { MultiSelect } from '@/components/ui/MultiSelect'` : '',
    ].join('')

    // Custom header actions for the form — forms have no rows, only header scope.
    // Guarded by isEdit: you can't duplicate/archive an entity that doesn't exist yet.
    const formHeaderActions = allCustomActions.filter(a => a.scope === 'header')
    const formApiActions = formHeaderActions.filter(isApi)
    const hasFormActions = formHeaderActions.length > 0

    const formCustomHookImports = formApiActions
      .map(a => `, use${toPascal(hookCodeOf(a))}${e}`)
      .join('')

    const formCustomIconNames = new Set(formHeaderActions.map(a => iconForCode(a.code)))
    const formCustomIconImports = Array.from(formCustomIconNames).map(name => `, ${name}`).join('')

    const formCustomMutationDecls = formApiActions
      .map(a => `  const ${toCamel(hookCodeOf(a))}Mutation = use${toPascal(hookCodeOf(a))}${e}()`)
      .join('\n')

    const formCustomHandlerDecls = formHeaderActions
      .map(a => {
        const name = `handle${toPascal(a.code)}`
        if (isNavigate(a)) {
          const target = a.targetRoute ?? `routes.${sectionCamel}.list()`
          return `  const ${name} = () => {\n    navigate(${target})\n  }`  // form actions are always header-scoped
        }
        const hookVar = `${toCamel(hookCodeOf(a))}Mutation`
        return `  const ${name} = async () => {\n    if (id) await ${hookVar}.mutateAsync(id)\n  }`
      })
      .join('\n\n')

    const formActionsJsx = hasFormActions
      ? `
        actions={isEdit ? (
          <div className="flex items-center gap-2">
${formHeaderActions.map(renderHeaderActionButton).join('\n')}
          </div>
        ) : undefined}`
      : ''

    files.push({
      path: pathFor('form', `${e}FormPage.tsx`),
      content: `${GENERATED_MARKER}import { useState, useEffect } from 'react'
import type { FormEvent } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import { Loader2, AlertTriangle, FilePen${formCustomIconImports} } from 'lucide-react'
import { Slot } from '@atlashub/smartstack'
import { PermissionGuard } from '@/components/auth/PermissionGuard'
import { PageTemplate } from '@/components/ui/PageTemplate'
import { use${e}, useCreate${e}, useUpdate${e}${formCustomHookImports} } from '${featurePath}/hooks/use${e}'${fkLookupImport}${fkHookImports}${primitiveImports}

type ${e}FormData = ${formDataTypeLiteral(formFields)}

const initial${e}FormData: ${e}FormData = ${initialFormObj}

export function ${e}FormPage() {
  const { id } = useParams<{ id: string }>()
  const navigate = useNavigate()
  const { t } = useTranslation('${spec.module}')
  const isEdit = !!id && id !== 'new'
  const { data: existing } = use${e}(isEdit ? id! : '')
  const createMutation = useCreate${e}()
  const updateMutation = useUpdate${e}()
${formCustomMutationDecls ? formCustomMutationDecls + '\n' : ''}
  const [formData, setFormData] = useState<${e}FormData>(initial${e}FormData)
  const [error, setError] = useState<string | null>(null)

  useEffect(() => {
    if (existing) {
      setFormData(existing as ${e}FormData)
    }
  }, [existing])

  const onChange = <K extends keyof ${e}FormData>(field: K, value: ${e}FormData[K]) => {
    setFormData(prev => ({ ...prev, [field]: value }))
  }

  const handleSubmit = async (event: FormEvent) => {
    event.preventDefault()
    setError(null)
    try {
      if (isEdit && id) {
        await updateMutation.mutateAsync({ id, data: formData })
      } else {
        await createMutation.mutateAsync(formData)
      }
      navigate(-1)
    } catch (err) {
      setError(err instanceof Error ? err.message : String(err))
    }
  }
${formCustomHandlerDecls ? '\n' + formCustomHandlerDecls + '\n' : ''}
  const permission = isEdit ? '${permKey}.update' : '${permKey}.create'
  const isPending = createMutation.isPending || updateMutation.isPending

  return (
    <PermissionGuard permission={permission}>
      <PageTemplate
        width="focused"
        title={isEdit ? t('${eLower}.form.editTitle') : t('${eLower}.form.createTitle')}
        icon={<FilePen className="w-6 h-6" />}
        breadcrumbs={[
          { label: t('${eLower}.breadcrumb.section'), href: '..' },
          { label: isEdit ? t('${eLower}.form.editTitle') : t('${eLower}.form.createTitle') },
        ]}${formActionsJsx}
      >
        {error && (
          <div className="p-4 rounded-[var(--radius-card)] bg-[var(--error-bg)] border border-[var(--error-border)]">
            <div className="flex items-center gap-2 text-[var(--error-text)]">
              <AlertTriangle className="w-5 h-5" />
              <span>{error}</span>
            </div>
          </div>
        )}

        <form onSubmit={handleSubmit} className="space-y-6">
          <div className="p-6 rounded-[var(--radius-card)] border border-[var(--border-color)] bg-[var(--bg-card)] space-y-4">
            <Slot name="${eLower}.form.fields.before" context={{ formData, onChange }} />

${formFields.map(f =>
                isFkField(f) ? renderFkLookupField(f, eLower, e)
              : isMultiEnumField(f) ? renderMultiSelectField(f, eLower, e)
              : isEnumField(f) ? renderEnumSelectField(f, eLower, e)
              : isDateField(f) ? renderDateField(f, eLower, e)
              : renderPlainFormField(f, eLower, e)).join('\n')}

            <Slot name="${eLower}.form.fields.after" context={{ formData, onChange }} />
          </div>

          <div className="flex justify-end gap-2">
            <button
              type="button"
              onClick={() => navigate(-1)}
              disabled={isPending}
              className="btn btn-secondary disabled:opacity-50"
            >
              {t('${eLower}.form.cancel')}
            </button>
            <button
              type="submit"
              disabled={isPending}
              className="btn btn-primary disabled:opacity-50 disabled:cursor-not-allowed"
            >
              {isPending && <Loader2 className="w-4 h-4 animate-spin" />}
              {isEdit ? t('${eLower}.form.submitUpdate') : t('${eLower}.form.submitCreate')}
            </button>
          </div>
        </form>
      </PageTemplate>
    </PermissionGuard>
  )
}

export default ${e}FormPage
`,
    })
  }

  // ─── DashboardPage ──────────────────────────────────────────────────────
  if (spec.views.includes('dashboard')) {
    const dashWidgets = spec.pageSpec?.widgets ?? []
    const widgetsLiteral =
      dashWidgets.length === 0
        ? '[]'
        : '[\n' +
          dashWidgets
            .map((w) => {
              const parts = [
                `key: ${JSON.stringify(w.key)}`,
                `label: ${JSON.stringify(w.label ?? w.key)}`,
                `type: ${JSON.stringify(w.type ?? 'kpi')}`,
              ]
              if (typeof w.col === 'number') parts.push(`col: ${w.col}`)
              if (w.permission) parts.push(`permission: ${JSON.stringify(w.permission)}`)
              return `    { ${parts.join(', ')} }`
            })
            .join(',\n') +
          ',\n  ]'
    files.push({
      path: pathFor('dashboard', `${e}DashboardPage.tsx`),
      content: `${GENERATED_MARKER}import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { LayoutDashboard, AlertTriangle } from 'lucide-react'
import { Slot } from '@atlashub/smartstack'
import { PermissionGuard } from '@/components/auth/PermissionGuard'
import { PageTemplate } from '@/components/ui/PageTemplate'
import { DateInput } from '@/components/ui/DateInput'
import { DashboardGrid } from '@/components/dashboard/DashboardGrid'
import { WidgetRenderer } from '@/components/dashboard/WidgetRenderer'
import type { DashboardWidget } from '@/components/dashboard/types'
import { useDashboard${e} } from '${featurePath}/hooks/use${e}'

// Widgets from the BA SmartDashboard / pageSpec. Editable — add, reorder, retune \`col\`.
const WIDGETS: DashboardWidget[] = ${widgetsLiteral}

export function ${e}DashboardPage() {
  const { t } = useTranslation('${spec.module}')
  const [period, setPeriod] = useState<{ startDate: string | null; endDate: string | null }>({ startDate: null, endDate: null })
  const { data, isLoading, error } = useDashboard${e}({ startDate: period.startDate ?? undefined, endDate: period.endDate ?? undefined })

  return (
    <PermissionGuard permission="${permKey}.read">
      <PageTemplate
        title={t('${eLower}.dashboard.title')}
        subtitle={t('${eLower}.dashboard.subtitle')}
        icon={<LayoutDashboard className="w-6 h-6" />}
        breadcrumbs={[
          { label: t('${eLower}.breadcrumb.section'), href: '..' },
          { label: t('${eLower}.dashboard.title') },
        ]}
      >
        <Slot name="${eLower}.dashboard.header" />

        <div className="mb-4 flex flex-wrap items-end gap-3">
          <DateInput value={period.startDate} onChange={(v) => setPeriod((p) => ({ ...p, startDate: v }))} label={t('${eLower}.dashboard.startDate')} />
          <DateInput value={period.endDate} onChange={(v) => setPeriod((p) => ({ ...p, endDate: v }))} label={t('${eLower}.dashboard.endDate')} />
        </div>

        {error && (
          <div className="mb-4 flex items-center gap-2 rounded-[var(--radius-card)] border border-[var(--error-border)] bg-[var(--error-bg)] p-3 text-sm text-[var(--error-text)]">
            <AlertTriangle className="h-4 w-4" />
            <span>{t('${eLower}.dashboard.error')}</span>
          </div>
        )}

        <Slot name="${eLower}.dashboard.widgets.before" context={{ data }} />

        <DashboardGrid>
          {WIDGETS.map((w) => {
            const widget = { ...w, label: t(\`${eLower}.dashboard.widget.\${w.key}\`, { defaultValue: w.label }) }
            const node = (
              <WidgetRenderer
                key={w.key}
                widget={widget}
                data={data?.widgets?.[w.key]}
                isLoading={isLoading}
                error={Boolean(error)}
              />
            )
            return w.permission
              ? <PermissionGuard key={w.key} permission={w.permission}>{node}</PermissionGuard>
              : node
          })}
        </DashboardGrid>

        <Slot name="${eLower}.dashboard.widgets.after" context={{ data }} />
        <Slot name="${eLower}.dashboard.footer" />
      </PageTemplate>
    </PermissionGuard>
  )
}

export default ${e}DashboardPage
`,
    })
  }

  // ─── KanbanPage (Bug 7) ──────────────────────────────────────────────────
  // Workflow-driven view: groups entities by a status-like field into fixed
  // columns. Cards link to the detail page. Drag-and-drop is opt-in via
  // `kanbanConfig.dnd` and requires a `move` customAction on the api-client
  // side (otherwise scaffold-api-client never emits the `useMoveDemande` hook).
  if (spec.views.includes('kanban')) {
    const cfg = spec.kanbanConfig
    if (!cfg) {
      throw new Error(
        `scaffold-component: entity '${spec.entity}' lists 'kanban' in views but kanbanConfig is missing — declare it at the spec level so the generator knows which field defines the columns.`,
      )
    }
    const groupByCamel = fieldToCamel(cfg.groupBy)
    // titleField defaults to the first non-formula field that exists on the entity.
    const fallbackTitle = spec.fields.find(f => !f.formula)?.name ?? 'id'
    const titleCamel = fieldToCamel(cfg.titleField ?? fallbackTitle)
    const hasSubtitle = Boolean(cfg.subtitleField)
    const subtitleCamel = hasSubtitle ? fieldToCamel(cfg.subtitleField!) : ''
    const dnd = cfg.dnd === true
    // Column metadata baked into the page — keeps the order deterministic
    // (BA controls it, NOT the alphabetical order of grouped keys).
    const kanbanColumns = cfg.columns.map(c => `  { key: '${c.key}', labelKey: '${c.labelKey}' }`).join(',\n')
    const moveImport = dnd ? `, useMove${e}` : ''
    const moveHookDecl = dnd ? `  const moveMutation = useMove${e}()\n` : ''
    const dropHandler = dnd
      ? `
  const handleDrop = async (id: string, newStatus: string) => {
    await moveMutation.mutateAsync({ id, payload: { ${groupByCamel}: newStatus } as never })
  }`
      : ''
    const cardDragProps = dnd
      ? `
                  draggable
                  onDragStart={(event) => event.dataTransfer.setData('text/plain', item.id)}`
      : ''
    const columnDropProps = dnd
      ? `
              onDragOver={(event) => event.preventDefault()}
              onDrop={(event) => { void handleDrop(event.dataTransfer.getData('text/plain'), col.key) }}`
      : ''
    files.push({
      path: pathFor('kanban', `${e}KanbanPage.tsx`),
      content: `${GENERATED_MARKER}import { useMemo } from 'react'
import { useNavigate } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import { Loader2, Trello } from 'lucide-react'
import { Slot } from '@atlashub/smartstack'
import { PermissionGuard } from '@/components/auth/PermissionGuard'
import { PageTemplate } from '@/components/ui/PageTemplate'
import { routes } from '@/extensions/${spec.module}Routes'
import { use${plural}${moveImport} } from '${featurePath}/hooks/use${e}'
import type { ${e} } from '${featurePath}/types'

const KANBAN_COLUMNS = [
${kanbanColumns}
] as const

export function ${e}KanbanPage() {
  const { t } = useTranslation('${spec.module}')
  const navigate = useNavigate()
  const { data, isLoading, error } = use${plural}()
${moveHookDecl}
  // Group items into buckets keyed by KANBAN_COLUMNS[i].key. Items whose
  // ${groupByCamel} does not match any declared column land in the "unassigned"
  // bucket displayed at the end. Keeps schema drift visible instead of silently
  // dropping cards.
  const buckets = useMemo(() => {
    const items = data?.items ?? []
    const map = new Map<string, ${e}[]>()
    for (const col of KANBAN_COLUMNS) map.set(col.key, [])
    const unassigned: ${e}[] = []
    for (const item of items) {
      const key = String((item as unknown as Record<string, unknown>)['${groupByCamel}'] ?? '')
      const target = map.get(key)
      if (target) target.push(item)
      else unassigned.push(item)
    }
    return { map, unassigned }
  }, [data])
${dropHandler}

  return (
    <PermissionGuard permission="${permKey}.read">
      <PageTemplate
        title={t('${eLower}.kanban.title')}
        subtitle={t('${eLower}.kanban.subtitle')}
        icon={<Trello className="w-6 h-6" />}
        breadcrumbs={[
          { label: t('${eLower}.breadcrumb.section'), href: '..' },
          { label: t('${eLower}.kanban.title') },
        ]}
      >
        <Slot name="${eLower}.kanban.header" />

        {error && (
          <div className="mb-4 p-3 rounded-[var(--radius-card)] bg-[var(--error-bg)] border border-[var(--error-border)] text-sm text-[var(--error-text)]">
            {t('${eLower}.kanban.error')}: {String(error)}
          </div>
        )}

        {isLoading ? (
          <div className="flex items-center justify-center py-12 text-[var(--text-muted)]">
            <Loader2 className="w-5 h-5 animate-spin mr-2" />
            {t('${eLower}.kanban.loading')}
          </div>
        ) : (
          <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
            {KANBAN_COLUMNS.map((col) => (
              <div
                key={col.key}
                className="p-3 rounded-[var(--radius-card)] border border-[var(--border-color)] bg-[var(--bg-card)] min-h-[200px]"${columnDropProps}
              >
                <h3 className="text-sm font-semibold uppercase tracking-wider text-[var(--text-muted)] mb-3">
                  {t(\`${eLower}.\${col.labelKey}\`)}
                  <span className="ml-2 text-xs text-[var(--text-muted)]">
                    ({buckets.map.get(col.key)?.length ?? 0})
                  </span>
                </h3>
                <div className="space-y-2">
                  {(buckets.map.get(col.key) ?? []).map((item) => (
                    <button
                      key={item.id}
                      type="button"
                      onClick={() => navigate(routes.${sectionCamel}.detail(item.id))}
                      className="w-full text-left p-3 rounded-[var(--radius-card)] border border-[var(--border-color)] bg-[var(--bg-elevated)] hover:border-[var(--color-accent-500)] transition-colors"${cardDragProps}
                    >
                      <div className="text-sm font-medium text-[var(--text-primary)]">
                        {String(item.${titleCamel} ?? '')}
                      </div>${hasSubtitle ? `
                      <div className="text-xs text-[var(--text-muted)] mt-1">
                        {String(item.${subtitleCamel} ?? '')}
                      </div>` : ''}
                    </button>
                  ))}
                </div>
              </div>
            ))}
            {buckets.unassigned.length > 0 && (
              <div className="p-3 rounded-[var(--radius-card)] border border-dashed border-[var(--warning-border)] bg-[var(--warning-bg)] min-h-[200px]">
                <h3 className="text-sm font-semibold uppercase tracking-wider text-[var(--warning-text)] mb-3">
                  {t('${eLower}.kanban.unassigned')}
                  <span className="ml-2 text-xs">({buckets.unassigned.length})</span>
                </h3>
                <div className="space-y-2">
                  {buckets.unassigned.map((item) => (
                    <button
                      key={item.id}
                      type="button"
                      onClick={() => navigate(routes.${sectionCamel}.detail(item.id))}
                      className="w-full text-left p-3 rounded-[var(--radius-card)] border border-[var(--border-color)] bg-[var(--bg-elevated)] hover:border-[var(--color-accent-500)] transition-colors"
                    >
                      <div className="text-sm font-medium text-[var(--text-primary)]">
                        {String(item.${titleCamel} ?? '')}
                      </div>
                    </button>
                  ))}
                </div>
              </div>
            )}
          </div>
        )}

        <Slot name="${eLower}.kanban.footer" />
      </PageTemplate>
    </PermissionGuard>
  )
}

export default ${e}KanbanPage
`,
    })
  }

  // ─── ReconductionPage (Bug 7) ────────────────────────────────────────────
  // Bulk-renewal pattern: shows eligible entities in a multi-select table with
  // a single "Reconduct selected" header action. Optional twin action "Refuse"
  // when reconductionConfig.withRefuse is true. The bulk hooks must exist in
  // api-client output (declare customActions `reconduct` and `refuse-reconduction`
  // with scope:'bulk' to wire the buttons).
  if (spec.views.includes('reconduction')) {
    const rcfg = spec.reconductionConfig ?? { actionLabelKey: 'reconduction.action.reconduct', withRefuse: false }
    const refuseImport = rcfg.withRefuse ? `, useRefuseReconduction${plural}` : ''
    const refuseHookDecl = rcfg.withRefuse
      ? `  const refuseMutation = useRefuseReconduction${plural}()\n`
      : ''
    const refuseHandler = rcfg.withRefuse
      ? `
  const handleRefuse = async () => {
    if (selectedIds.length === 0) return
    await refuseMutation.mutateAsync(selectedIds)
    setSelectedIds([])
  }`
      : ''
    const refuseButton = rcfg.withRefuse
      ? `
            <button
              type="button"
              onClick={() => void handleRefuse()}
              disabled={selectedIds.length === 0 || refuseMutation.isPending}
              className="px-3 py-2 rounded-[var(--radius-button)] border border-[var(--border-color)] text-sm font-medium text-[var(--text-primary)] hover:bg-[var(--bg-hover)] disabled:opacity-50"
            >
              {t('${eLower}.reconduction.action.refuse')}
            </button>`
      : ''
    files.push({
      path: pathFor('reconduction', `${e}ReconductionPage.tsx`),
      content: `${GENERATED_MARKER}import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Loader2, RefreshCw } from 'lucide-react'
import { Slot } from '@atlashub/smartstack'
import { PermissionGuard } from '@/components/auth/PermissionGuard'
import { PageTemplate } from '@/components/ui/PageTemplate'
import { use${plural}, useReconduct${plural}${refuseImport} } from '${featurePath}/hooks/use${e}'
import type { ${e} } from '${featurePath}/types'

export function ${e}ReconductionPage() {
  const { t } = useTranslation('${spec.module}')
  const { data, isLoading, error } = use${plural}()
  const reconductMutation = useReconduct${plural}()
${refuseHookDecl}  const [selectedIds, setSelectedIds] = useState<string[]>([])

  const toggle = (id: string) =>
    setSelectedIds((prev) =>
      prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id],
    )

  const handleReconduct = async () => {
    if (selectedIds.length === 0) return
    await reconductMutation.mutateAsync(selectedIds)
    setSelectedIds([])
  }${refuseHandler}

  const items: ${e}[] = data?.items ?? []

  return (
    <PermissionGuard permission="${permKey}.update">
      <PageTemplate
        title={t('${eLower}.reconduction.title')}
        subtitle={t('${eLower}.reconduction.subtitle')}
        icon={<RefreshCw className="w-6 h-6" />}
        breadcrumbs={[
          { label: t('${eLower}.breadcrumb.section'), href: '..' },
          { label: t('${eLower}.reconduction.title') },
        ]}
        actions={
          <div className="flex items-center gap-2">
            <button
              type="button"
              onClick={() => void handleReconduct()}
              disabled={selectedIds.length === 0 || reconductMutation.isPending}
              className="btn btn-primary disabled:opacity-50"
            >
              <RefreshCw className="w-4 h-4" />
              {t('${eLower}.${rcfg.actionLabelKey}')}
              {selectedIds.length > 0 && (
                <span className="ml-1 text-xs opacity-80">({selectedIds.length})</span>
              )}
            </button>${refuseButton}
          </div>
        }
      >
        <Slot name="${eLower}.reconduction.header" />

        {error && (
          <div className="mb-4 p-3 rounded-[var(--radius-card)] bg-[var(--error-bg)] border border-[var(--error-border)] text-sm text-[var(--error-text)]">
            {t('${eLower}.reconduction.error')}: {String(error)}
          </div>
        )}

        {isLoading ? (
          <div className="flex items-center justify-center py-12 text-[var(--text-muted)]">
            <Loader2 className="w-5 h-5 animate-spin mr-2" />
            {t('${eLower}.reconduction.loading')}
          </div>
        ) : items.length === 0 ? (
          <div className="p-4 rounded-[var(--radius-card)] border border-[var(--border-color)] bg-[var(--bg-card)] text-sm text-[var(--text-muted)]">
            {t('${eLower}.reconduction.empty')}
          </div>
        ) : (
          <div className="overflow-x-auto rounded-[var(--radius-card)] border border-[var(--border-color)]">
            <table className="w-full text-sm">
              <thead className="bg-[var(--bg-elevated)] text-[var(--text-muted)]">
                <tr>
                  <th className="p-3 text-left w-12">
                    <input
                      type="checkbox"
                      checked={selectedIds.length === items.length && items.length > 0}
                      onChange={() =>
                        setSelectedIds(selectedIds.length === items.length ? [] : items.map((x) => x.id))
                      }
                      aria-label={t('${eLower}.reconduction.selectAll')}
                    />
                  </th>
                  <th className="p-3 text-left font-semibold uppercase text-xs tracking-wider">
                    {t('${eLower}.reconduction.columns.identifier')}
                  </th>
                </tr>
              </thead>
              <tbody>
                {items.map((item) => (
                  <tr
                    key={item.id}
                    className="border-t border-[var(--border-color)] hover:bg-[var(--bg-hover)]"
                  >
                    <td className="p-3">
                      <input
                        type="checkbox"
                        checked={selectedIds.includes(item.id)}
                        onChange={() => toggle(item.id)}
                        aria-label={item.id}
                      />
                    </td>
                    <td className="p-3 text-[var(--text-primary)]">{item.id}</td>
                  </tr>
                ))}
              </tbody>
            </table>
          </div>
        )}

        <Slot name="${eLower}.reconduction.footer" />
      </PageTemplate>
    </PermissionGuard>
  )
}

export default ${e}ReconductionPage
`,
    })
  }

  // ─── Hub pages (AppHome / ModuleHome / SectionHome) ────────────────────
  // Landing pages with concrete KPI cards + navigation links read from
  // pageSpec.widgets[] / pageSpec.quickLinks[] (enriched mode). When no
  // pageSpec data is available (legacy mode), Slot placeholders are emitted.
  // Slots are kept as EXTENSION POINTS (before/after) in both modes.
  type HubViewKind = 'app-home' | 'module-home' | 'section-home'
  type HubViewSuffix = 'AppHome' | 'ModuleHome' | 'SectionHome'
  const hubViews: Array<[HubViewKind, HubViewSuffix]> = [
    ['app-home', 'AppHome'],
    ['module-home', 'ModuleHome'],
    ['section-home', 'SectionHome'],
  ]
  for (const [view, suffix] of hubViews) {
    if (!spec.views.includes(view)) continue
    const componentName = `${plural}${suffix}Page`

    // Extract widgets + quickLinks from pageSpec (enriched mode)
    const widgets = spec.pageSpec?.widgets ?? []
    const quickLinks = spec.pageSpec?.quickLinks ?? []
    const hasWidgets = widgets.length > 0
    const hasQuickLinks = quickLinks.length > 0

    // Collect Lucide icon imports from the data
    const iconSet = new Set(['LayoutGrid', 'ChevronRight'])
    for (const w of widgets) {
      iconSet.add(w.icon ? kebabToPascal(w.icon) : (WIDGET_TYPE_ICONS[w.type ?? 'kpi'] ?? 'TrendingUp'))
    }
    for (const ql of quickLinks) {
      if (ql.icon) iconSet.add(kebabToPascal(ql.icon))
    }
    const iconImports = Array.from(iconSet).sort().join(', ')

    // Build KPI card markup — concrete StatCard divs or fallback Slot
    let kpiContent: string
    if (hasWidgets) {
      const cards = widgets.map(w => {
        const icon = w.icon ? kebabToPascal(w.icon) : (WIDGET_TYPE_ICONS[w.type ?? 'kpi'] ?? 'TrendingUp')
        const label = w.labelKey ? `t('${eLower}.${w.labelKey}')` : `'${w.label ?? w.key}'`
        return `            <div className="p-4 rounded-[var(--radius-card)] border border-[var(--border-color)] bg-[var(--bg-card)]">
              <div className="flex items-center justify-between">
                <span className="text-sm font-medium text-[var(--text-muted)]">{${label}}</span>
                <${icon} className="h-5 w-5 text-[var(--color-accent-500)]" />
              </div>
              <div className="mt-2 text-2xl font-bold text-[var(--text-primary)]">—</div>
            </div>`
      })
      // Keep the Slot after concrete cards for additive extensions
      cards.push(`            <Slot name="${eLower}.home.kpis" />`)
      kpiContent = cards.join('\n')
    } else {
      kpiContent = `            <Slot name="${eLower}.home.kpis" />`
    }

    // Build QuickLink card markup — concrete nav buttons or fallback Slot
    let navContent: string
    if (hasQuickLinks) {
      const cards = quickLinks.map(ql => {
        const icon = ql.icon ? kebabToPascal(ql.icon) : 'ChevronRight'
        const label = ql.labelKey ? `t('${eLower}.${ql.labelKey}')` : `'${ql.label ?? ql.key}'`
        return `            <button
              type="button"
              onClick={() => navigate('#')}
              className="flex items-center gap-3 p-4 rounded-[var(--radius-card)] border border-[var(--border-color)] bg-[var(--bg-card)] hover:border-[var(--color-accent-500)] transition-colors text-left w-full"
            >
              <${icon} className="h-5 w-5 text-[var(--color-accent-500)] shrink-0" />
              <span className="text-sm font-medium text-[var(--text-primary)] flex-1">{${label}}</span>
              <ChevronRight className="h-4 w-4 text-[var(--text-muted)]" />
            </button>`
      })
      // Keep the Slot after concrete cards for additive extensions
      cards.push(`            <Slot name="${eLower}.home.navigation" />`)
      navContent = cards.join('\n')
    } else {
      navContent = `            <Slot name="${eLower}.home.navigation" />`
    }

    files.push({
      path: pathFor(view, `${componentName}.tsx`),
      content: `${GENERATED_MARKER}import { useNavigate } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import { ${iconImports} } from 'lucide-react'
import { Slot } from '@atlashub/smartstack'
import { PermissionGuard } from '@/components/auth/PermissionGuard'
import { PageTemplate } from '@/components/ui/PageTemplate'

export function ${componentName}() {
  const { t } = useTranslation('${spec.module}')
  const navigate = useNavigate()

  return (
    <PermissionGuard permission="${permKey}.read">
      <PageTemplate
        title={t('${eLower}.home.title')}
        subtitle={t('${eLower}.home.subtitle')}
        icon={<LayoutGrid className="w-6 h-6" />}
      >
        <Slot name="${eLower}.home.header" />

        <section className="mb-6">
          <h2 className="text-sm font-semibold uppercase tracking-wider text-[var(--text-muted)] mb-3">
            {t('${eLower}.home.kpis')}
          </h2>
          <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
${kpiContent}
          </div>
        </section>

        <Slot name="${eLower}.home.kpis.after" />

        <section>
          <h2 className="text-sm font-semibold uppercase tracking-wider text-[var(--text-muted)] mb-3">
            {t('${eLower}.home.navigation')}
          </h2>
          <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
${navContent}
          </div>
        </section>

        <Slot name="${eLower}.home.footer" />
      </PageTemplate>
    </PermissionGuard>
  )
}

export default ${componentName}
`,
    })
  }

  // ─── i18n JSON per locale ────────────────────────────────────────────────
  // Enriched mode: pageSpec.i18nKeys is the source of truth (parallel key sets
  // across the 4 locales, validated upstream by persist-prd). Flat keys get
  // materialised to the nested form i18next consumes.
  // Legacy mode: buildTranslations() infers from the field dictionary.
  // i18n catalogues land at `src/i18n/locales/{locale}/{module}.json` —
  // module-level (one JSON per module, NOT per entity), matching the
  // SmartStack project layout where i18n config.ts loads namespaces by
  // module. The CLI writer (index.ts) merges with the existing JSON if
  // any so the 132 keys already present (custom common/list/form/...)
  // are preserved.
  //
  // For hub views (app-home/module-home/section-home), the template always
  // references `home.title`, `home.subtitle`, `home.kpis`, `home.navigation`.
  // PRD pagespecs may omit these — inject them as fallbacks so the page
  // never shows raw i18n keys.
  const isHubView = spec.views.some(v => v === 'app-home' || v === 'module-home' || v === 'section-home')
  for (const locale of LOCALES) {
    let i18nFlat: Record<string, string>
    if (spec.pageSpec) {
      i18nFlat = normalizeI18nFieldKeys({ ...(spec.pageSpec.i18nKeys[locale as keyof typeof spec.pageSpec.i18nKeys] ?? {}) })
      // Inject missing home keys from the legacy builder
      if (isHubView) {
        const legacyHome = buildTranslations(spec, locale as Locale).home as Record<string, string> | undefined
        if (legacyHome) {
          for (const [k, v] of Object.entries(legacyHome)) {
            const flatKey = `home.${k}`
            if (!i18nFlat[flatKey]) i18nFlat[flatKey] = v
          }
        }
      }
    } else {
      i18nFlat = {}
    }
    const innerPayload = spec.pageSpec
      ? flatToNested(i18nFlat)
      : buildTranslations(spec, locale as Locale)
    // Wrap under entity key so multiple entities in the same module
    // coexist without collision: { contact: { list: {...} }, company: { list: {...} } }
    const payload = { [eLower]: innerPayload }
    files.push({
      path: `${webRoot}/src/i18n/locales/${locale}/${spec.module}.json`,
      content: JSON.stringify(payload, null, 2) + '\n',
    })
  }

  return files
}

function toHtmlInputType(type: string): string {
  switch (type.toLowerCase()) {
    case 'number': case 'int': case 'integer': case 'decimal': return 'number'
    case 'bool': case 'boolean': return 'checkbox'
    case 'datetime': return 'datetime-local'
    case 'date': return 'date'
    default: return 'text'
  }
}

function fromInputValue(type: string, expr: string): string {
  switch (type.toLowerCase()) {
    case 'number': case 'int': case 'integer': case 'decimal': return `Number(${expr})`
    case 'bool': case 'boolean': return `(event.target as HTMLInputElement).checked`
    default: return expr
  }
}

// ─── FK lookup helpers — power <EntityLookup> emission on Form pages ──────

/**
 * A field is a FK when the orchestrator (ba-develop Phase 3a) has resolved
 * `fkTo` for it. The shape check is conservative: even if scaffold-component
 * is called legacy-mode without `fkTo` populated, the field still falls back
 * to a plain `<input>` and audit DEV-UI-022 catches the regression.
 */
function isFkField(f: ComponentField): boolean {
  return Boolean(f.fkTo)
}

/** PascalCase → kebab-case plural: `Department` → `departments`. */
function lookupPluralKebab(entity: string): string {
  const plural = entity.endsWith('s') ? entity : `${entity}s`
  return plural
    .replace(/([a-z0-9])([A-Z])/g, '$1-$2')
    .replace(/_+/g, '-')
    .toLowerCase()
}

/** Default endpoint when `fkTo.apiEndpoint` isn't overridden by the caller. */
function defaultLookupEndpoint(fkTo: NonNullable<ComponentField['fkTo']>): string {
  if (fkTo.apiEndpoint) return fkTo.apiEndpoint
  return `/api/${fkTo.module}/${lookupPluralKebab(fkTo.entity)}/lookup`
}

/**
 * Build the conditional `import { useXxxLookup } from '@/features/.../...'`
 * lines for the Form/Detail pages. One line per unique target entity.
 * Empty array → emit nothing.
 */
function lookupHookImportLines(fkFields: ComponentField[]): string[] {
  const seen = new Map<string, string>()
  for (const f of fkFields) {
    if (!f.fkTo) continue
    const key = `${f.fkTo.module}/${f.fkTo.entity}`
    if (seen.has(key)) continue
    const targetLower = f.fkTo.entity.charAt(0).toLowerCase() + f.fkTo.entity.slice(1)
    seen.set(
      key,
      `import { use${f.fkTo.entity}Lookup } from '@/features/${f.fkTo.module}/${targetLower}/hooks/use${f.fkTo.entity}'`,
    )
  }
  return [...seen.values()]
}

/**
 * Emit the JSX for ONE form field when it carries `fkTo`. Wraps
 * <EntityLookup> — no `<input>`, no `<select>`, no datalist. The FK value is
 * persisted as a string (Guid) in formData; the onChange contract matches the
 * generated `<E>FormData` field type (string | null).
 */
function renderFkLookupField(f: ComponentField, eLower: string, e: string): string {
  if (!f.fkTo) throw new Error(`renderFkLookupField called on non-FK field: ${f.name}`)
  const camelName = fieldToCamel(f.name)
  const endpoint = defaultLookupEndpoint(f.fkTo)
  return `            <EntityLookup
              apiEndpoint="${endpoint}"
              value={(formData.${camelName} as string | null) ?? null}
              onChange={(id) => onChange('${camelName}', (id ?? '') as ${e}FormData['${camelName}'])}
              label={t('${eLower}.form.fields.${camelName}')}
              ${f.required ? 'required' : ''}
              disabled={isPending}
            />`
}

// ─── Enum / date helpers — power <EnumSelect>/<MultiSelect>/<DateInput> ───────

function hasSelectOptions(f: ComponentField): boolean {
  return Array.isArray(f.options) && f.options.length > 0
}
function isEnumField(f: ComponentField): boolean {
  return !f.fkTo && hasSelectOptions(f) && f.multiple !== true
}
function isMultiEnumField(f: ComponentField): boolean {
  return !f.fkTo && hasSelectOptions(f) && f.multiple === true
}
function isDateField(f: ComponentField): boolean {
  return !f.fkTo && !hasSelectOptions(f) && /^(date|datetime)$/i.test(f.type)
}

/** Single-quote-safe `[{ value, label }, …]` literal for a JSX options prop. */
function optionsArrayLiteral(options: NonNullable<ComponentField['options']>): string {
  const esc = (s: string) => String(s).replace(/\\/g, '\\\\').replace(/'/g, "\\'")
  return `[${options.map(o => `{ value: '${esc(o.value)}', label: '${esc(o.label)}' }`).join(', ')}]`
}

/** <DateInput> — theme-compliant calendar, replaces native `<input type="date">`. */
function renderDateField(f: ComponentField, eLower: string, e: string): string {
  const camelName = fieldToCamel(f.name)
  return `            <DateInput
              value={(formData.${camelName} as string | null) ?? null}
              onChange={(v) => onChange('${camelName}', (v ?? '') as ${e}FormData['${camelName}'])}
              label={t('${eLower}.form.fields.${camelName}')}
              ${f.required ? 'required' : ''}
              disabled={isPending}
            />`
}

/** <EnumSelect> — theme-compliant single-select dropdown for enum fields. */
function renderEnumSelectField(f: ComponentField, eLower: string, e: string): string {
  const camelName = fieldToCamel(f.name)
  return `            <EnumSelect
              options={${optionsArrayLiteral(f.options ?? [])}}
              value={(formData.${camelName} as string | null) ?? null}
              onChange={(v) => onChange('${camelName}', (v ?? '') as ${e}FormData['${camelName}'])}
              label={t('${eLower}.form.fields.${camelName}')}
              ${f.required ? 'required' : ''}
              disabled={isPending}
            />`
}

/** <MultiSelect> — theme-compliant multi-value chips for multi-valued enums. */
function renderMultiSelectField(f: ComponentField, eLower: string, e: string): string {
  const camelName = fieldToCamel(f.name)
  return `            <MultiSelect
              options={${optionsArrayLiteral(f.options ?? [])}}
              value={(formData.${camelName} as string[] | undefined) ?? []}
              onChange={(v) => onChange('${camelName}', v as ${e}FormData['${camelName}'])}
              label={t('${eLower}.form.fields.${camelName}')}
              ${f.required ? 'required' : ''}
              disabled={isPending}
            />`
}

/**
 * Emit the JSX for ONE non-FK form field — the legacy `<input>` path. Mirrors
 * the historical inline template verbatim so the diff stays minimal when
 * pages mix FK and non-FK fields.
 */
function renderPlainFormField(f: ComponentField, eLower: string, e: string): string {
  const camelName = fieldToCamel(f.name)
  const isBool = /^bool(ean)?$/i.test(f.type)
  if (isBool) {
    return `            <label htmlFor="${camelName}" className="flex items-center gap-2 text-sm font-medium text-[var(--text-primary)]">
              <input
                id="${camelName}"
                type="checkbox"
                checked={!!formData.${camelName}}
                onChange={event => onChange('${camelName}', (event.target as HTMLInputElement).checked as ${e}FormData['${camelName}'])}
                disabled={isPending}
                className="rounded border-[var(--border-color)]"
              />
              {t('${eLower}.form.fields.${camelName}')}
            </label>`
  }
  return `            <div>
              <label htmlFor="${camelName}" className="block text-sm font-medium text-[var(--text-primary)] mb-1">
                {t('${eLower}.form.fields.${camelName}')}${f.required ? ` <span className="text-[var(--error-text)]">*</span>` : ''}
              </label>
              <input
                id="${camelName}"
                type="${toHtmlInputType(f.type)}"
                value={String(formData.${camelName} ?? '')}
                onChange={event => onChange('${camelName}', ${fromInputValue(f.type, `event.target.value`)} as ${e}FormData['${camelName}'])}
                ${f.required ? 'required' : ''}
                disabled={isPending}
                className="input w-full disabled:opacity-50"
              />
            </div>`
}
