/**
 * cli:scaffold-api-client — generate.ts
 *
 * Generates TypeScript service + React hooks (useState/useEffect) for a REST entity.
 * Generated code depends on:
 *   - `@atlashub/smartstack` (npm) for the pre-configured HTTP client `api`
 *     (auth, tenant, retry built-in; returns unwrapped data, not AxiosResponse).
 *   - When `httpClient: 'axios'`, generates a local axios instance instead.
 *
 * URL convention — two strata only, no templated paths:
 *   useScreens=false  →  /api/v1/integration/{plural-kebab}
 *   useScreens=true   →  /api/screens/{plural-kebab}/{list|detail|form|...}
 *
 * Both are imported from `lib/url-conventions.ts` — the SAME helpers used by
 * `scaffold-controller` and `scaffold-screen-controller`. Drift is impossible
 * by construction.
 */
import { buildDirectRoute, buildIntegrationRoute, buildScreenRoute } from '../../../../../lib/url-conventions.js'
import type { ScaffoldApiClientInput, GeneratedFile, ApiCustomAction, ApiScreenColumn } from './types.js'

export function generate(spec: ScaffoldApiClientInput): GeneratedFile[] {
  const files: GeneratedFile[] = []

  for (const entity of spec.entities) {
    const e = entity.name
    const eLower = e.charAt(0).toLowerCase() + e.slice(1)
    const plural = entity.pluralName ?? e + 's'
    const pluralLower = plural.charAt(0).toLowerCase() + plural.slice(1)
    const pluralKebab = toKebab(plural)
    // Route mode: backward compat — useScreens: true overrides routeMode
    const effectiveMode = spec.useScreens ? 'screens' : (spec.routeMode ?? 'direct')
    const isScreenMode = effectiveMode === 'screens'
    const sectionKebab = toKebab(entity.section)
    const directPath = buildDirectRoute(spec.appCode, spec.module, sectionKebab)
    const integrationPath = buildIntegrationRoute(pluralKebab)
    const apiPath = effectiveMode === 'screens'
      ? buildScreenRoute(pluralKebab)
      : effectiveMode === 'integration'
        ? integrationPath
        : directPath

    // Sub-resource support (Fix #1, 2026-05-27): when entity.parentPath is set,
    // API_PATH becomes a function `(parentId) => string` instead of a constant.
    // Every service method/hook takes `parentId` as its first argument, and
    // every URL reference uses `API_PATH(parentId)` instead of `API_PATH`. We
    // refuse the combination with the screen stratum — the screen stratum has
    // no nested URL convention and silently dropping the parent would mask the bug.
    const hasParent = entity.parentPath !== undefined
    if (hasParent && isScreenMode) {
      throw new Error(
        `scaffold-api-client: entity '${e}' carries parentPath but the spec is in screen mode — these are mutually exclusive. Use routeMode: 'direct' or 'integration' for sub-resources.`,
      )
    }
    const parentIdParam = entity.parentIdParam ?? 'parentId'
    // `parentArg` is the leading argument string emitted in every method
    // signature (`parentId: string, `), empty when hasParent === false so all
    // existing CLI consumers keep their old signatures byte-for-byte.
    const parentArg = hasParent ? `${parentIdParam}: string, ` : ''
    const parentArgCall = hasParent ? `${parentIdParam}, ` : ''
    // `apiPathExpr` is the JS-side expression that resolves to the URL prefix:
    //   - flat:    'API_PATH'                       (constant string)
    //   - nested:  'API_PATH(parentId)'             (call site of the helper)
    const apiPathExpr = hasParent ? `API_PATH(${parentIdParam})` : 'API_PATH'
    // `apiPathDecl` is the file-scope binding emitted at the top of the
    // service file. For nested, we emit an arrow function that substitutes
    // `{parentId}` in the template with the runtime parameter value.
    const apiPathDecl = hasParent
      ? `const API_PATH = (${parentIdParam}: string) => \`${entity.parentPath!.replace('{parentId}', '${' + parentIdParam + '}')}\``
      : `const API_PATH = '${apiPath}'`
    const featurePath = `src/features/${spec.module}/${eLower}`
    const clientVar = spec.httpClient === 'axios' ? 'client' : 'api'
    const needsUnwrap = spec.httpClient === 'axios'
    const dr = needsUnwrap ? 'const { data }' : 'const data'
    const clientImport = spec.httpClient === 'axios'
      ? `import axios from 'axios';\n\nconst client = axios.create({ baseURL: '/', withCredentials: true });`
      : `import { api } from '@atlashub/smartstack';`

    // Wave F1 (2026-05-27) + Wave F5 (2026-05-27): pagespec columns shape the
    // TS DTOs in EVERY route mode — direct, integration, screens. Previously
    // gated behind `isScreenMode`, which made `direct`-mode DTOs fall back to
    // `entity.fields.slice(0,5)` (random domain fields) and silently mismatch
    // the C# DTO returned by the controller. The gate is gone: `screenColumns`
    // now wins whenever it is provided; the legacy heuristic only kicks in for
    // entities whose spec never carried column data (full backward compat).
    const useScreenDtos = entity.screenColumns !== undefined
      && (entity.screenColumns.list !== undefined || entity.screenColumns.detail !== undefined)

    const listFields = useScreenDtos
      ? renderListFromColumns(entity.screenColumns!.list)
      : entity.fields.slice(0, 5).map(f => `  ${fieldCamel(f.name)}: ${mapTsType(f.type)};`).join('\n')
    // Bug 8 fix — DetailDto is the **union** of `screenColumns.detail` and
    // `entity.fields`. Prior to this, the edit form fetched DetailDto then tried
    // to pre-fill fields that were only declared in `entity.fields` (e.g. JSON
    // snapshot columns like `localityData`, `addressData`, `parcelData`). Result:
    // « field not found » at runtime even though every other layer (Create/Update
    // DTOs, the form input bindings) knew about them. Detail now always carries
    // every entity field. screenColumns.detail order wins for display fields;
    // entity.fields extras are appended.
    const allFields = useScreenDtos
      ? renderDetailFromColumns(entity.screenColumns!.detail, entity.screenColumns!.list, entity.fields)
      : entity.fields.map(f => `  ${fieldCamel(f.name)}${f.required ? '' : '?'}: ${mapTsType(f.type)};`).join('\n')

    const dashboardTypes = entity.hasDashboard
      ? `

/** One chart datapoint: a label + one or more numeric series. */
export interface ${e}ChartPoint {
  label: string;
  [series: string]: number | string;
}
/** One pie slice. */
export interface ${e}ChartSlice {
  name: string;
  value: number;
}
/**
 * The data a single dashboard widget renders (keyed by widget key in
 * ${e}DashboardDto.widgets). Shape per widget type: kpi/counter → value/delta;
 * chart-line/-bar → points; chart-pie → slices; list → rows/columns.
 * Structurally compatible with the local WidgetResult in
 * @/components/dashboard/types.
 */
export interface ${e}WidgetResult {
  value?: number | string;
  delta?: number;
  unit?: string;
  points?: ${e}ChartPoint[];
  slices?: ${e}ChartSlice[];
  rows?: Record<string, unknown>[];
  columns?: { key: string; label: string }[];
}

export interface ${e}DashboardDto {
  /** Each widget's result, indexed by the SmartDashboard widget key. Absent
   *  keys render the widget's muted "no data" placeholder. */
  widgets: Record<string, ${e}WidgetResult>;
}`
      : ''

    // Filter `kind: 'navigate'` BEFORE collecting custom types — navigation
    // actions never produce a service member, so their payload/response types
    // are not used and must not pollute the imports.
    const customActions = (entity.customActions ?? []).filter(a => a.kind !== 'navigate')

    // Bug 1 fix — collect + emit type declarations for every custom action's
    // `payloadType` / `responseType`. Must be computed BEFORE the types/index.ts
    // push so the declarations land at the top of the emitted file.
    const customTypeDecls = renderCustomTypeDeclarations(customActions, e)
    const customTypeNames = collectCustomTypeNames(customActions, e)
    const customTypeImports = customTypeNames.length > 0
      ? `, ${customTypeNames.join(', ')}`
      : ''

    files.push({
      path: `${featurePath}/types/index.ts`,
      content: `${customTypeDecls}export interface ${e}ListDto {
  id: string;
${listFields}
  createdAt: string;
}

export interface ${e}DetailDto {
  id: string;
${allFields}
  createdAt: string;
  updatedAt: string;
}

/**
 * Convenience alias — when consumers (e.g. scaffold-component) reference the
 * entity without a DTO suffix, resolve it to the detail shape (the most
 * complete view of the entity). Bug 2 fix: prior to this alias, generated
 * pages emitted "import { Demande }" against a module that only exported
 * DemandeListDto + DemandeDetailDto — TS2305 « no exported member ». Now both
 * names compile.
 */
export type ${e} = ${e}DetailDto;

export interface Create${e}Dto {
${entity.fields.filter(f => f.required).map(f => `  ${fieldCamel(f.name)}: ${mapTsType(f.type)};`).join('\n')}
}

export interface Update${e}Dto {
${entity.fields.map(f => `  ${fieldCamel(f.name)}${f.required ? '' : '?'}: ${mapTsType(f.type)};`).join('\n')}
}

/**
 * Lightweight reference shape returned by GET /api/v1/integration/{plural}/lookup.
 * Lookup lives on the integration stratum regardless of useScreens (the
 * screen-driven contract intentionally omits it). Consumed by <EntityLookup>
 * for FK selectors — id is the FK value to persist, displayName is the human
 * label rendered in the combobox.
 */
export interface ${e}RefDto {
  id: string;
  displayName: string;
}

export interface PaginatedResult<T> {
  items: T[];
  /** Mirrors SmartStack.app PaginatedResult<T>.TotalCount (JSON: "totalCount").
   *  ASP.NET serialises C# PascalCase property names to camelCase on the wire;
   *  the previous "total" identifier silently dropped the count for every
   *  paginated endpoint. */
  totalCount: number;
  page: number;
  pageSize: number;
}${dashboardTypes}
`,
    })

    const dashboardServiceMembers = entity.hasDashboard
      ? `,
  getDashboard: async (${parentArg}params?: { startDate?: string; endDate?: string }) => {
    ${dr} = await ${clientVar}.get<${e}DashboardDto>(\`\${${apiPathExpr}}/dashboard\`, { params });
    return data;
  }`
      : ''

    const dashboardImports = entity.hasDashboard
      ? `, ${e}DashboardDto`
      : ''

    // Custom action filtering + custom type collection happens BEFORE the
    // types/index.ts push (see above). Here we only emit the service members
    // since they depend on `apiPathExpr` / `parentArg` already computed.
    const customActionServiceMembers = customActions
      .map((a) => emitCustomActionServiceMember(a, clientVar, dr, parentArg, apiPathExpr))
      .join('')

    const integrationConstDecl = isScreenMode
      ? `\nconst INTEGRATION_PATH = '${integrationPath}';\n`
      : ''
    const lookupBase = isScreenMode ? 'INTEGRATION_PATH' : 'API_PATH'
    const deleteBase = isScreenMode ? 'INTEGRATION_PATH' : 'API_PATH'

    const getAllMethod = isScreenMode
      ? `getAll: async (${parentArg}params?: { page?: number; pageSize?: number; search?: string }) => {
    ${dr} = await ${clientVar}.get<PaginatedResult<${e}ListDto>>(\`\${${apiPathExpr}}/list\`, { params });
    return data;
  }`
      : `getAll: async (${parentArg}params?: { page?: number; pageSize?: number; search?: string }) => {
    ${dr} = await ${clientVar}.get<PaginatedResult<${e}ListDto>>(${apiPathExpr}, { params });
    return data;
  }`

    const getByIdMethod = isScreenMode
      ? `getById: async (${parentArg}id: string) => {
    ${dr} = await ${clientVar}.get<${e}DetailDto>(\`\${${apiPathExpr}}/detail/\${id}\`);
    return data;
  }`
      : `getById: async (${parentArg}id: string) => {
    ${dr} = await ${clientVar}.get<${e}DetailDto>(\`\${${apiPathExpr}}/\${id}\`);
    return data;
  }`

    const createMethod = isScreenMode
      ? `create: async (${parentArg}payload: Create${e}Dto) => {
    ${dr} = await ${clientVar}.post<string>(\`\${${apiPathExpr}}/form\`, payload);
    return data;
  }`
      : `create: async (${parentArg}payload: Create${e}Dto) => {
    ${dr} = await ${clientVar}.post<string>(${apiPathExpr}, payload);
    return data;
  }`

    const updateMethod = isScreenMode
      ? `update: async (${parentArg}id: string, payload: Update${e}Dto) => {
    await ${clientVar}.put(\`\${${apiPathExpr}}/form/\${id}\`, payload);
  }`
      : `update: async (${parentArg}id: string, payload: Update${e}Dto) => {
    await ${clientVar}.put(\`\${${apiPathExpr}}/\${id}\`, payload);
  }`

    // When the entity is nested, lookup / delete also need the parent ID
    // since their URL prefix is the parameterised API_PATH function.
    // INTEGRATION_PATH (used for delete/lookup fallback in screen mode) is a
    // flat URL and never carries a parent — but that combo is rejected above
    // (hasParent && isScreenMode throws), so lookupBase / deleteBase always
    // resolve to apiPathExpr when hasParent is true.
    const lookupExpr = isScreenMode ? '${INTEGRATION_PATH}' : `\${${apiPathExpr}}`
    const deleteExpr = isScreenMode ? '${INTEGRATION_PATH}' : `\${${apiPathExpr}}`

    files.push({
      path: `${featurePath}/services/${eLower}Service.ts`,
      content: `${clientImport}
import type { ${e}ListDto, ${e}DetailDto, Create${e}Dto, Update${e}Dto, ${e}RefDto, PaginatedResult${dashboardImports}${customTypeImports} } from '../types';

${apiPathDecl};${integrationConstDecl}

export const ${eLower}Service = {
  ${getAllMethod},
  ${getByIdMethod},
  getLookup: async (${parentArg}params?: { search?: string; page?: number; pageSize?: number }) => {
    ${dr} = await ${clientVar}.get<PaginatedResult<${e}RefDto>>(\`${lookupExpr}/lookup\`, { params });
    return data;
  },
  ${createMethod},
  ${updateMethod},
  delete: async (${parentArg}id: string) => {
    await ${clientVar}.delete(\`${deleteExpr}/\${id}\`);
  }${dashboardServiceMembers}${customActionServiceMembers},
};
`,
    })

    const hooksContent = generateStateHooks(e, eLower, plural, pluralLower, customActions, entity.hasDashboard ?? false, hasParent, parentIdParam)

    files.push({
      path: `${featurePath}/hooks/use${e}.ts`,
      content: hooksContent,
    })
  }

  return files
}

function fieldCamel(name: string): string {
  return name.charAt(0).toLowerCase() + name.slice(1)
}

function mapTsType(type: string): string {
  const map: Record<string, string> = {
    'string': 'string',
    'int': 'number',
    'integer': 'number',
    'number': 'number',
    'decimal': 'number',
    'bool': 'boolean',
    'boolean': 'boolean',
    'datetime': 'string',
    'date': 'string',
    'guid': 'string',
  }
  const lookup = map[type.toLowerCase()]
  if (!lookup) {
    throw new Error(`scaffold-api-client: unsupported field type '${type}' — add a mapping in mapTsType()`)
  }
  return lookup
}

/**
 * Wave F1 — TS type for a pagespec column. MUST mirror `dotnetTypeFor` in
 * `templates/skills/development/backend/screen-controller/cli/scaffold-screen-controller/generate.ts`
 * one-to-one. The integration test in `__tests__/screen-strata-contract.test.ts`
 * locks the two tables together.
 */
function tsTypeForColumn(formatHint: string | undefined): string {
  const h = (formatHint ?? '').toLowerCase()
  if (h === 'currency' || h === 'number' || h === 'decimal') return 'number'
  if (h === 'integer' || h === 'count') return 'number'
  if (h === 'date' || h === 'datetime') return 'string' // ISO-8601 on the wire
  if (h === 'bool' || h === 'boolean') return 'boolean'
  if (h === 'guid' || h === 'uuid') return 'string'
  return 'string'
}

/**
 * Render the body of `${E}ListDto` from pagespec list-view columns. Returns
 * an empty body when no columns are provided — the surrounding template adds
 * `id` + `createdAt` so the interface is still well-formed (matches the
 * C# safe-minimum fallback).
 */
function renderListFromColumns(columns: ApiScreenColumn[] | undefined): string {
  if (!columns || columns.length === 0) return ''
  return columns.map(c => `  ${c.key}: ${tsTypeForColumn(c.formatHint)};`).join('\n')
}

/**
 * Render the body of `${E}DetailDto`. The MVP shape derives from the detail
 * pagespec when present; otherwise it falls back to the list columns (the
 * detail view is at minimum a superset of the list). Then — Bug 8 fix — appends
 * every `entity.fields` member that is NOT already present, so the edit form's
 * pre-fill (fetched as DetailDto) carries every field the user can edit. Drift
 * between display columns and editable fields stays harmless (extra fields are
 * tolerated by display code) instead of crashing the form with « field not
 * found ».
 *
 * Order :
 *   1. screenColumns.detail keys (display order from BA pagespec)
 *   2. screenColumns.list extras (when no detail-specific spec)
 *   3. entity.fields not yet covered (snapshots, hidden domain fields, etc.)
 *
 * Mirrors how `scaffold-screen-controller` derives `${E}DetailScreenDto` from
 * the same pagespec.columns[] — but the C# side has the domain entity
 * available natively, so the union is implicit there.
 */
function renderDetailFromColumns(
  detail: ApiScreenColumn[] | undefined,
  list: ApiScreenColumn[] | undefined,
  entityFields: { name: string; type: string; required: boolean }[],
): string {
  const columnSource = detail && detail.length > 0 ? detail : (list ?? [])
  const columnKeys = new Set(columnSource.map(c => c.key))
  const columnLines = columnSource.map(c => `  ${c.key}: ${tsTypeForColumn(c.formatHint)};`)
  const extraLines = entityFields
    .filter(f => !columnKeys.has(fieldCamel(f.name)))
    .map(f => `  ${fieldCamel(f.name)}${f.required ? '' : '?'}: ${mapTsType(f.type)};`)
  return [...columnLines, ...extraLines].join('\n')
}

function toKebab(name: string): string {
  return name
    .replace(/([a-z0-9])([A-Z])/g, '$1-$2')
    .replace(/_+/g, '-')
    .toLowerCase()
}

/** Convert kebab-case (`bulk-archive`) to camelCase (`bulkArchive`) for
 *  TypeScript service member names. */
function toCamelCase(code: string): string {
  const parts = code.split('-')
  return parts[0]! + parts.slice(1).map((p) => p.charAt(0).toUpperCase() + p.slice(1)).join('')
}

/** Convert kebab-case (`bulk-archive`) to PascalCase (`BulkArchive`) for
 *  hook names (`useBulkArchive${E}`). */
function toPascalCase(code: string): string {
  return code.split('-').map((p) => p.charAt(0).toUpperCase() + p.slice(1)).join('')
}

/** Built-in TS types we never re-declare. */
const TS_BUILTIN_TYPES = new Set([
  'void', 'string', 'number', 'boolean', 'string[]',
  'unknown', 'never', 'object', 'any',
])

/** Compute the set of names already provided by the entity's own DTOs. We
 *  must not re-emit these as custom types — they would collide with the
 *  interfaces already produced for the entity. */
function entityGeneratedDtoNames(e: string): Set<string> {
  return new Set([
    `${e}ListDto`,
    `${e}DetailDto`,
    `Create${e}Dto`,
    `Update${e}Dto`,
    `${e}RefDto`,
    `${e}DashboardDto`,
    `${e}WidgetResult`,
    `${e}ChartPoint`,
    `${e}ChartSlice`,
    e, // alias
  ])
}

/**
 * Bug 1 helper — collect every type name referenced by a customAction's
 * `payloadType` or `responseType` that needs declaration in the entity's
 * `types/index.ts`. Filters out TS builtins, the entity's own DTOs, and
 * generic forms like `T[]` (array-of-something handled separately).
 */
function collectCustomTypeNames(
  customActions: ApiCustomAction[],
  e: string,
): string[] {
  const generated = entityGeneratedDtoNames(e)
  const seen = new Set<string>()
  const out: string[] = []
  for (const a of customActions) {
    for (const name of [a.payloadType, a.responseType]) {
      if (!name) continue
      if (TS_BUILTIN_TYPES.has(name)) continue
      if (generated.has(name)) continue
      // Strip trailing `[]` (arrays of declared types still need the base type)
      const base = name.endsWith('[]') ? name.slice(0, -2) : name
      if (TS_BUILTIN_TYPES.has(base)) continue
      if (generated.has(base)) continue
      if (seen.has(base)) continue
      seen.add(base)
      out.push(base)
    }
  }
  return out
}

/**
 * Bug 1 helper — emit one `export interface ${name} { ... }` block per custom
 * type. Shape is taken from the matching `payloadShape` / `responseShape`
 * when provided; otherwise emit a permissive `[key: string]: unknown` body
 * plus a TODO comment so the BA author knows to firm it up.
 *
 * The returned string ends with two newlines (or is empty) so it can be
 * prefixed directly to the entity DTO block.
 */
function renderCustomTypeDeclarations(
  customActions: ApiCustomAction[],
  e: string,
): string {
  const generated = entityGeneratedDtoNames(e)
  // Map type name → first non-empty shape we encounter (payloadShape or
  // responseShape). When two actions reference the same name with different
  // shapes we pick the first — the BA contract assumes one canonical shape
  // per type name.
  const shapes = new Map<string, Record<string, string>>()
  const declared = new Set<string>()
  const order: string[] = []
  for (const a of customActions) {
    const pairs: Array<[string | null | undefined, Record<string, string> | undefined]> = [
      [a.payloadType, a.payloadShape],
      [a.responseType === 'void' ? null : a.responseType, a.responseShape],
    ]
    for (const [rawName, shape] of pairs) {
      if (!rawName) continue
      const base = rawName.endsWith('[]') ? rawName.slice(0, -2) : rawName
      if (TS_BUILTIN_TYPES.has(base)) continue
      if (generated.has(base)) continue
      if (!declared.has(base)) {
        declared.add(base)
        order.push(base)
      }
      if (shape && Object.keys(shape).length > 0 && !shapes.has(base)) {
        shapes.set(base, shape)
      }
    }
  }
  if (order.length === 0) return ''
  const blocks = order.map((name) => {
    const shape = shapes.get(name)
    if (shape) {
      const body = Object.entries(shape)
        .map(([field, tsType]) => `  ${field}: ${tsType};`)
        .join('\n')
      return `export interface ${name} {\n${body}\n}`
    }
    // Permissive placeholder — keeps the TypeScript compiler happy while
    // signalling that BA work is needed to firm up the shape.
    return `/**\n * TODO[BA-SHAPE]: replace this permissive placeholder with the real shape.\n * Populate \`customActions[].payloadShape\` (or \`responseShape\`) in the\n * scaffold-api-client spec so this interface lists the actual fields.\n */\nexport interface ${name} {\n  [key: string]: unknown;\n}`
  })
  return blocks.join('\n\n') + '\n\n'
}

/**
 * Emit a single service-object member (e.g. `archive: async (id) => ...`).
 * The leading comma is included so the member can be appended directly
 * after the existing CRUD methods.
 */
function emitCustomActionServiceMember(
  action: ApiCustomAction,
  clientVar: string,
  dr: string,
  parentArg: string,
  apiPathExpr: string,
): string {
  const memberName = toCamelCase(action.code)         // ← `code` drives the TS identifier
  const urlSegment = action.endpoint ?? action.code   // ← `endpoint` (when set) drives the URL
  const responseType = action.responseType
  const isVoid = responseType === 'void'
  const method = action.httpMethod ?? 'post'
  const isGet = method === 'get'
  // Wrap `apiPathExpr` for use inside an emitted JS template-literal:
  //   non-nested  → '${API_PATH}'
  //   nested      → '${API_PATH(parentId)}'
  const pathInTpl = `\${${apiPathExpr}}`

  let signature: string
  let body: string

  if (action.scope === 'row') {
    if (action.payloadType && !isGet) {
      signature = `(${parentArg}id: string, payload: ${action.payloadType})`
      body = isVoid
        ? `await ${clientVar}.${method}(\`${pathInTpl}/\${id}/${urlSegment}\`, payload);`
        : `${dr} = await ${clientVar}.${method}<${responseType}>(\`${pathInTpl}/\${id}/${urlSegment}\`, payload);
    return data;`
    } else {
      signature = `(${parentArg}id: string)`
      body = isVoid
        ? `await ${clientVar}.${method}(\`${pathInTpl}/\${id}/${urlSegment}\`);`
        : `${dr} = await ${clientVar}.${method}<${responseType}>(\`${pathInTpl}/\${id}/${urlSegment}\`);
    return data;`
    }
  } else if (action.scope === 'bulk') {
    if (action.payloadType && !isGet) {
      signature = `(${parentArg}payload: ${action.payloadType})`
      body = isVoid
        ? `await ${clientVar}.${method}(\`${pathInTpl}/bulk/${urlSegment}\`, payload);`
        : `${dr} = await ${clientVar}.${method}<${responseType}>(\`${pathInTpl}/bulk/${urlSegment}\`, payload);
    return data;`
    } else {
      signature = isGet ? `(${parentArg.replace(/, $/, '')})` : `(${parentArg}ids: string[])`
      const args = isGet ? '' : `, { ids }`
      body = isVoid
        ? `await ${clientVar}.${method}(\`${pathInTpl}/bulk/${urlSegment}\`${args});`
        : `${dr} = await ${clientVar}.${method}<${responseType}>(\`${pathInTpl}/bulk/${urlSegment}\`${args});
    return data;`
    }
  } else {
    // header scope
    if (action.payloadType && !isGet) {
      signature = `(${parentArg}payload: ${action.payloadType})`
      body = isVoid
        ? `await ${clientVar}.${method}(\`${pathInTpl}/${urlSegment}\`, payload);`
        : `${dr} = await ${clientVar}.${method}<${responseType}>(\`${pathInTpl}/${urlSegment}\`, payload);
    return data;`
    } else {
      signature = `(${parentArg.replace(/, $/, '')})`
      body = isVoid
        ? `await ${clientVar}.${method}(\`${pathInTpl}/${urlSegment}\`);`
        : `${dr} = await ${clientVar}.${method}<${responseType}>(\`${pathInTpl}/${urlSegment}\`);
    return data;`
    }
  }

  return `,
  ${memberName}: async ${signature}: Promise<${responseType}> => {
    ${body}
  }`
}

/**
 * Emit a single React Query mutation hook for a custom action. Auto-
 * invalidates the LIST_KEY query and (for row-scope actions) the entity
 * detail query so the UI re-fetches after the mutation succeeds.
 */
function emitCustomActionHook(action: ApiCustomAction, entity: string, entityLower: string): string {
  const hookName = `use${toPascalCase(action.code)}${entity}`
  const memberName = toCamelCase(action.code)
  const responseType = action.responseType

  let mutationFnSignature: string
  let mutationFnCall: string
  let invalidations: string

  if (action.scope === 'row') {
    if (action.payloadType) {
      mutationFnSignature = `({ id, payload }: { id: string; payload: ${action.payloadType} })`
      mutationFnCall = `${entityLower}Service.${memberName}(id, payload)`
      invalidations = `qc.invalidateQueries({ queryKey: LIST_KEY });
      qc.invalidateQueries({ queryKey: ['${entityLower}', variables.id] });`
    } else {
      mutationFnSignature = `(id: string)`
      mutationFnCall = `${entityLower}Service.${memberName}(id)`
      invalidations = `qc.invalidateQueries({ queryKey: LIST_KEY });
      qc.invalidateQueries({ queryKey: ['${entityLower}', variables] });`
    }
  } else if (action.scope === 'bulk') {
    if (action.payloadType) {
      mutationFnSignature = `(payload: ${action.payloadType})`
      mutationFnCall = `${entityLower}Service.${memberName}(payload)`
    } else {
      mutationFnSignature = `(ids: string[])`
      mutationFnCall = `${entityLower}Service.${memberName}(ids)`
    }
    invalidations = `qc.invalidateQueries({ queryKey: LIST_KEY });`
  } else {
    // header scope
    if (action.payloadType) {
      mutationFnSignature = `(payload: ${action.payloadType})`
      mutationFnCall = `${entityLower}Service.${memberName}(payload)`
    } else {
      mutationFnSignature = `()`
      mutationFnCall = `${entityLower}Service.${memberName}()`
    }
    invalidations = `qc.invalidateQueries({ queryKey: LIST_KEY });`
  }

  // For row-scope actions the variables symbol is the destructured arg
  // (id or { id, payload }). For bulk/header it's the full args object.
  // The `_, variables` signature works in all cases.
  return `
export function ${hookName}() {
  const qc = useQueryClient();
  return useMutation<${responseType}, Error, ${rxQueryVariables(action)}>({
    mutationFn: ${mutationFnSignature} => ${mutationFnCall},
    onSuccess: (_data, variables) => {
      ${invalidations}
    },
  });
}`
}

/**
 * Compute the TypeScript type of the `variables` argument for the
 * useMutation call — used as the third type parameter so onSuccess gets
 * a typed `variables` arg.
 */
function rxQueryVariables(action: ApiCustomAction): string {
  if (action.scope === 'row') {
    return action.payloadType
      ? `{ id: string; payload: ${action.payloadType} }`
      : 'string'
  }
  if (action.scope === 'bulk') {
    return action.payloadType ? action.payloadType : 'string[]'
  }
  // header
  return action.payloadType ? action.payloadType : 'void'
}

function generateReactQueryHooks(
  e: string, eLower: string, plural: string, pluralLower: string,
  customActions: ApiCustomAction[], hasDashboard: boolean,
): string {
  return `import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { ${eLower}Service } from '../services/${eLower}Service';
import type { Create${e}Dto, Update${e}Dto } from '../types';

const LIST_KEY = ['${pluralLower}'] as const;

export function use${plural}(params?: { page?: number; pageSize?: number; search?: string }) {
  return useQuery({ queryKey: [...LIST_KEY, params], queryFn: () => ${eLower}Service.getAll(params) });
}

export function use${e}(id: string) {
  return useQuery({ queryKey: ['${eLower}', id], queryFn: () => ${eLower}Service.getById(id), enabled: Boolean(id) });
}

export function useCreate${e}() {
  const qc = useQueryClient();
  return useMutation({ mutationFn: (data: Create${e}Dto) => ${eLower}Service.create(data), onSuccess: () => qc.invalidateQueries({ queryKey: LIST_KEY }) });
}

export function useUpdate${e}() {
  const qc = useQueryClient();
  return useMutation({ mutationFn: ({ id, data }: { id: string; data: Update${e}Dto }) => ${eLower}Service.update(id, data), onSuccess: () => qc.invalidateQueries({ queryKey: LIST_KEY }) });
}

export function useDelete${e}() {
  const qc = useQueryClient();
  return useMutation({ mutationFn: (id: string) => ${eLower}Service.delete(id), onSuccess: () => qc.invalidateQueries({ queryKey: LIST_KEY }) });
}

export function use${e}Lookup(params?: { search?: string; page?: number; pageSize?: number }) {
  return useQuery({ queryKey: ['${eLower}', 'lookup', params], queryFn: () => ${eLower}Service.getLookup(params), placeholderData: (previous) => previous, staleTime: 60_000 });
}
${customActions.map((a) => emitCustomActionHook(a, e, eLower)).join('\n')}${hasDashboard ? `
export function useDashboard${e}(params?: { startDate?: string; endDate?: string }) {
  const query = useQuery({ queryKey: ['${eLower}', 'dashboard', params], queryFn: () => ${eLower}Service.getDashboard(params) });
  return { data: query.data, isLoading: query.isLoading, error: query.error };
}` : ''}
`
}

function generateStateHooks(
  e: string, eLower: string, plural: string, _pluralLower: string,
  customActions: ApiCustomAction[], hasDashboard: boolean,
  hasParent: boolean = false, parentIdParam: string = 'parentId',
): string {
  // When the entity is nested under a parent, the service methods take
  // `parentId` as their first argument — and so do the hooks. We thread the
  // value through both the hook signature and the underlying service call.
  // For non-nested entities, `parentSig` / `parentCall` / `parentDep` are all
  // empty strings, so the emitted code stays byte-identical to the legacy form.
  const parentSig = hasParent ? `${parentIdParam}: string, ` : ''
  const parentCall = hasParent ? `${parentIdParam}, ` : ''
  // useEffect deps must include parentId so the hook re-fetches when the
  // parent changes (e.g. selecting a different Site while viewing Rues).
  const parentDep = hasParent ? `${parentIdParam}, ` : ''
  // Mutation arg name when the only parameter is parentId (delete + custom row actions).
  const parentArgAlone = hasParent ? `${parentIdParam}: string` : ''
  // Bug 3 fix — propagate the correct number of arguments from the service
  // member signature to the hook's `mutateAsync`. The previous one-size-fits-all
  // shape `(idOrPayload: unknown)` worked only for row-scope + no payload; every
  // other combination got TS2554 « expected 2 arguments, got 1 ». We now match
  // exactly what `emitCustomActionServiceMember` declared (lines 393, 407, 423),
  // by scope × payloadType:
  //   row    + payload → ({ id, payload })   → service(id, payload)
  //   row    + nothing → (id)                → service(id)
  //   bulk   + payload → (payload)           → service(payload)
  //   bulk   + nothing → (ids: string[])     → service(ids)
  //   header + payload → (payload)           → service(payload)
  //   header + nothing → ()                  → service()
  const customHooks = customActions.map(a => {
    const pascalCode = a.code.split('-').map(s => s.charAt(0).toUpperCase() + s.slice(1)).join('')
    const serviceMethodName = a.code.replace(/-([a-z])/g, (_: string, c: string) => c.toUpperCase())
    const responseType = a.responseType ?? 'void'
    const promiseT = `Promise<${responseType}>`

    let mutateSig: string
    let callArgs: string
    if (a.scope === 'row') {
      if (a.payloadType) {
        mutateSig = `({ id, payload }: { id: string; payload: ${a.payloadType} })`
        callArgs = 'id, payload'
      } else {
        mutateSig = `(id: string)`
        callArgs = 'id'
      }
    } else if (a.scope === 'bulk') {
      if (a.payloadType) {
        mutateSig = `(payload: ${a.payloadType})`
        callArgs = 'payload'
      } else {
        mutateSig = `(ids: string[])`
        callArgs = 'ids'
      }
    } else {
      // header scope
      if (a.payloadType) {
        mutateSig = `(payload: ${a.payloadType})`
        callArgs = 'payload'
      } else {
        mutateSig = `()`
        callArgs = ''
      }
    }
    // When the entity is nested, every service member takes parentId as its
    // first arg — thread it through the call site. We use parentArgAlone in
    // the hook signature (closure pattern, identical to useDelete/useUpdate)
    // rather than re-passing through mutateAsync.
    const fullCallArgs = hasParent
      ? (callArgs ? `${parentIdParam}, ${callArgs}` : parentIdParam)
      : callArgs
    return `
export function use${pascalCode}${e}(${parentArgAlone}) {
  const [isPending, setIsPending] = useState(false);
  const mutateAsync = async ${mutateSig}: ${promiseT} => {
    setIsPending(true);
    try { return await ${eLower}Service.${serviceMethodName}(${fullCallArgs}); }
    finally { setIsPending(false); }
  };
  return { mutateAsync, isPending };
}`
  }).join('\n')

  return `import { useState, useEffect, useCallback } from 'react';
import { ${eLower}Service } from '../services/${eLower}Service';
import type { Create${e}Dto, Update${e}Dto } from '../types';

export function use${plural}(${parentSig}params?: { page?: number; pageSize?: number; search?: string }) {
  const [data, setData] = useState<Awaited<ReturnType<typeof ${eLower}Service.getAll>> | undefined>();
  const [isLoading, setIsLoading] = useState(true);
  const [error, setError] = useState<Error | null>(null);
  const refetch = useCallback(() => {
    setIsLoading(true);
    ${eLower}Service.getAll(${parentCall}params).then(setData).catch(setError).finally(() => setIsLoading(false));
  }, [${parentDep}JSON.stringify(params)]);
  useEffect(() => { refetch(); }, [refetch]);
  return { data, isLoading, error, refetch };
}

export function use${e}(${parentSig}id: string) {
  const [data, setData] = useState<Awaited<ReturnType<typeof ${eLower}Service.getById>> | undefined>();
  const [isLoading, setIsLoading] = useState(true);
  useEffect(() => {
    if (!id) return;
    setIsLoading(true);
    ${eLower}Service.getById(${parentCall}id).then(setData).finally(() => setIsLoading(false));
  }, [${parentDep}id]);
  return { data, isLoading };
}

export function useCreate${e}(${parentArgAlone}) {
  const [isPending, setIsPending] = useState(false);
  const mutateAsync = async (data: Create${e}Dto) => {
    setIsPending(true);
    try { return await ${eLower}Service.create(${parentCall}data); } finally { setIsPending(false); }
  };
  return { mutateAsync, isPending };
}

export function useUpdate${e}(${parentArgAlone}) {
  const [isPending, setIsPending] = useState(false);
  const mutateAsync = async ({ id, data }: { id: string; data: Update${e}Dto }) => {
    setIsPending(true);
    try { return await ${eLower}Service.update(${parentCall}id, data); } finally { setIsPending(false); }
  };
  return { mutateAsync, isPending };
}

export function useDelete${e}(${parentArgAlone}) {
  const [isPending, setIsPending] = useState(false);
  const mutateAsync = async (id: string) => {
    setIsPending(true);
    try { return await ${eLower}Service.delete(${parentCall}id); } finally { setIsPending(false); }
  };
  return { mutateAsync, isPending };
}

export function use${e}Lookup(${parentSig}params?: { search?: string; page?: number; pageSize?: number }) {
  const [data, setData] = useState<Awaited<ReturnType<typeof ${eLower}Service.getLookup>> | undefined>();
  useEffect(() => { ${eLower}Service.getLookup(${parentCall}params).then(setData); }, [${parentDep}JSON.stringify(params)]);
  return { data };
}
${customHooks}${hasDashboard ? `
export function useDashboard${e}(${parentSig}params?: { startDate?: string; endDate?: string }) {
  const [data, setData] = useState<Awaited<ReturnType<typeof ${eLower}Service.getDashboard>>>();
  const [isLoading, setIsLoading] = useState(true);
  const [error, setError] = useState<unknown>(null);
  useEffect(() => {
    setIsLoading(true);
    ${eLower}Service.getDashboard(${parentCall}params)
      .then((d) => { setData(d); setError(null); })
      .catch((err) => setError(err))
      .finally(() => setIsLoading(false));
  }, [${parentDep}JSON.stringify(params)]);
  return { data, isLoading, error };
}` : ''}
`
}
