/**
 * cli:scaffold-business — generate.ts
 * Generates CQRS stack: Commands, Queries, Handlers, DTOs, Validators, Service.
 *
 * Generated code consumes NuGet packages:
 *  - MediatR (IRequest, IRequestHandler)
 *  - FluentValidation (AbstractValidator)
 *  - SmartStack.Application.Common.Interfaces.ICoreDbContext
 *  - SmartStack.Application.Common.Exceptions.NotFoundException
 *  - SmartStack.Application.Common.Models.PaginatedResult
 *
 * Compilation contract: every Command/Query records here has a matching Handler
 * class so MediatR can resolve `IRequestHandler<TRequest, TResponse>` at runtime.
 */
import type {
  ScaffoldBusinessInput,
  GeneratedFile,
  Field,
  BusinessRule,
  BusinessCustomAction,
} from './types.js'

export function generate(spec: ScaffoldBusinessInput): GeneratedFile[] {
  const ns = spec.namespace ?? spec.appCode
  const mod = capitalize(spec.module)
  const e = spec.name
  const plural = spec.pluralName ?? e + 's'
  const appNs = `${ns}.Application.${mod}`
  const reserved = new Set(['Id', 'CreatedAt', 'UpdatedAt', 'DeletedAt', 'TenantId'])
  // userFields includes computed fields — they appear in read DTOs (List/Detail)
  // but NOT in Create/Update Commands, Dtos, Validators or factory calls.
  const userFields = spec.fields.filter(f => !reserved.has(f.name))
  const storedFields = userFields.filter(f => !f.formula)
  const requiredFields = storedFields.filter(f => f.required)
  const updatableFields = storedFields.filter(f => !f.isKey)
  const storedNames = new Set(storedFields.map(f => f.name))
  // Lookup projection: SQL-translatable display field for {Entity}RefDto.
  // Falls back to Id.ToString() when no string field is available.
  const displayField = pickDisplayField(spec.displayNameExpr, storedFields)

  const files: GeneratedFile[] = []

  // 1. DTOs
  files.push({
    path: `src/${ns}.Application/${mod}/DTOs/${e}Dtos.cs`,
    content: `namespace ${appNs}.DTOs;

public record ${e}ListDto(
    Guid Id,
${userFields.slice(0, 5).map(f => `    ${mapType(f)} ${f.name},`).join('\n')}
    DateTime CreatedAt
);

public record ${e}DetailDto(
    Guid Id,
${userFields.map(f => `    ${mapType(f)} ${f.name},`).join('\n')}
    DateTime CreatedAt,
    DateTime UpdatedAt
);

public record Create${e}Dto(
${requiredFields.map(f => `    ${mapType(f)} ${f.name}`).join(',\n')}
);

public record Update${e}Dto(
${updatableFields.map(f => `    ${mapType(f)} ${f.name}`).join(',\n')}
);

/// <summary>
/// Lightweight reference DTO returned by GET /api/{module}/{plural}/lookup.
/// Consumed by the frontend EntityLookup combobox to render FK selectors
/// (Id is the FK value to persist; DisplayName is the human label).
/// </summary>
public record ${e}RefDto(Guid Id, string DisplayName);
`,
  })

  // 2. Commands
  files.push({
    path: `src/${ns}.Application/${mod}/Commands/Create${e}Command.cs`,
    content: `using MediatR;

namespace ${appNs}.Commands;

public record Create${e}Command(${requiredFields.map(f => `${mapType(f)} ${f.name}`).join(', ')}) : IRequest<Guid>;
`,
  })

  files.push({
    path: `src/${ns}.Application/${mod}/Commands/Update${e}Command.cs`,
    content: `using MediatR;

namespace ${appNs}.Commands;

public record Update${e}Command(Guid Id${updatableFields.length > 0 ? ', ' : ''}${updatableFields.map(f => `${mapType(f)} ${f.name}`).join(', ')}) : IRequest<Unit>;
`,
  })

  files.push({
    path: `src/${ns}.Application/${mod}/Commands/Delete${e}Command.cs`,
    content: `using MediatR;

namespace ${appNs}.Commands;

public record Delete${e}Command(Guid Id) : IRequest<Unit>;
`,
  })

  // 3. Queries
  files.push({
    path: `src/${ns}.Application/${mod}/Queries/Get${e}Query.cs`,
    content: `using MediatR;
using SmartStack.Application.Common.Models;
using ${appNs}.DTOs;

namespace ${appNs}.Queries;

public record Get${e}Query(Guid Id) : IRequest<${e}DetailDto?>;
public record Get${plural}Query(int Page = 1, int PageSize = 20, string? Search = null) : IRequest<PaginatedResult<${e}ListDto>>;

/// <summary>
/// Paginated lookup query feeding the frontend EntityLookup combobox.
/// Search is case-insensitive LIKE on the display field (see ${e}Service.GetLookupAsync).
/// </summary>
public record Get${plural}LookupQuery(string? Search = null, int Page = 1, int PageSize = 20) : IRequest<PaginatedResult<${e}RefDto>>;
`,
  })

  // 4. Handlers — MANDATORY: MediatR cannot resolve a request without a matching handler.
  files.push({
    path: `src/${ns}.Application/${mod}/Handlers/Create${e}CommandHandler.cs`,
    content: `using MediatR;
using ${appNs}.Commands;
using ${appNs}.Interfaces;

namespace ${appNs}.Handlers;

public class Create${e}CommandHandler : IRequestHandler<Create${e}Command, Guid>
{
    private readonly I${e}Service _service;

    public Create${e}CommandHandler(I${e}Service service) => _service = service;

    public Task<Guid> Handle(Create${e}Command request, CancellationToken cancellationToken)
        => _service.CreateAsync(request, cancellationToken);
}
`,
  })

  files.push({
    path: `src/${ns}.Application/${mod}/Handlers/Update${e}CommandHandler.cs`,
    content: `using MediatR;
using ${appNs}.Commands;
using ${appNs}.Interfaces;

namespace ${appNs}.Handlers;

public class Update${e}CommandHandler : IRequestHandler<Update${e}Command, Unit>
{
    private readonly I${e}Service _service;

    public Update${e}CommandHandler(I${e}Service service) => _service = service;

    public async Task<Unit> Handle(Update${e}Command request, CancellationToken cancellationToken)
    {
        await _service.UpdateAsync(request, cancellationToken);
        return Unit.Value;
    }
}
`,
  })

  files.push({
    path: `src/${ns}.Application/${mod}/Handlers/Delete${e}CommandHandler.cs`,
    content: `using MediatR;
using ${appNs}.Commands;
using ${appNs}.Interfaces;

namespace ${appNs}.Handlers;

public class Delete${e}CommandHandler : IRequestHandler<Delete${e}Command, Unit>
{
    private readonly I${e}Service _service;

    public Delete${e}CommandHandler(I${e}Service service) => _service = service;

    public async Task<Unit> Handle(Delete${e}Command request, CancellationToken cancellationToken)
    {
        await _service.DeleteAsync(request.Id, cancellationToken);
        return Unit.Value;
    }
}
`,
  })

  files.push({
    path: `src/${ns}.Application/${mod}/Handlers/Get${e}QueryHandler.cs`,
    content: `using MediatR;
using ${appNs}.DTOs;
using ${appNs}.Interfaces;
using ${appNs}.Queries;

namespace ${appNs}.Handlers;

public class Get${e}QueryHandler : IRequestHandler<Get${e}Query, ${e}DetailDto?>
{
    private readonly I${e}Service _service;

    public Get${e}QueryHandler(I${e}Service service) => _service = service;

    public Task<${e}DetailDto?> Handle(Get${e}Query request, CancellationToken cancellationToken)
        => _service.GetByIdAsync(request.Id, cancellationToken);
}
`,
  })

  files.push({
    path: `src/${ns}.Application/${mod}/Handlers/Get${plural}QueryHandler.cs`,
    content: `using MediatR;
using SmartStack.Application.Common.Models;
using ${appNs}.DTOs;
using ${appNs}.Interfaces;
using ${appNs}.Queries;

namespace ${appNs}.Handlers;

public class Get${plural}QueryHandler : IRequestHandler<Get${plural}Query, PaginatedResult<${e}ListDto>>
{
    private readonly I${e}Service _service;

    public Get${plural}QueryHandler(I${e}Service service) => _service = service;

    public Task<PaginatedResult<${e}ListDto>> Handle(Get${plural}Query request, CancellationToken cancellationToken)
        => _service.GetAllAsync(request, cancellationToken);
}
`,
  })

  // Lookup handler — paginated reference list for the frontend EntityLookup combobox.
  files.push({
    path: `src/${ns}.Application/${mod}/Handlers/Get${plural}LookupQueryHandler.cs`,
    content: `using MediatR;
using SmartStack.Application.Common.Models;
using ${appNs}.DTOs;
using ${appNs}.Interfaces;
using ${appNs}.Queries;

namespace ${appNs}.Handlers;

public class Get${plural}LookupQueryHandler : IRequestHandler<Get${plural}LookupQuery, PaginatedResult<${e}RefDto>>
{
    private readonly I${e}Service _service;

    public Get${plural}LookupQueryHandler(I${e}Service service) => _service = service;

    public Task<PaginatedResult<${e}RefDto>> Handle(Get${plural}LookupQuery request, CancellationToken cancellationToken)
        => _service.GetLookupAsync(request, cancellationToken);
}
`,
  })

  // 5. Validators
  const validationRules = spec.businessRules.map(r => renderBusinessRule(r, userFields)).join('\n\n')

  files.push({
    path: `src/${ns}.Application/${mod}/Validators/Create${e}CommandValidator.cs`,
    content: `using FluentValidation;
using ${appNs}.Commands;

namespace ${appNs}.Validators;

public class Create${e}CommandValidator : AbstractValidator<Create${e}Command>
{
    public Create${e}CommandValidator()
    {
${requiredFields.filter(f => f.type === 'string').map(f =>
  `        RuleFor(x => x.${f.name}).NotEmpty()${f.maxLength ? `.MaximumLength(${f.maxLength})` : ''};`
).join('\n')}
${validationRules}
    }
}
`,
  })

  // 5 (cont.) — Update validator. Business rules are entity invariants: they must
  //   hold on modification too, not only on creation. Mirrors the Create validator
  //   over updatableFields (primary key + computed fields excluded). Any rule that
  //   fell back to a `// TODO[BR-…]` comment is refined by the agent's post-scaffold
  //   business-logic pass (see business-layer/SKILL.md § Post-scaffold pass).
  files.push({
    path: `src/${ns}.Application/${mod}/Validators/Update${e}CommandValidator.cs`,
    content: `using FluentValidation;
using ${appNs}.Commands;

namespace ${appNs}.Validators;

public class Update${e}CommandValidator : AbstractValidator<Update${e}Command>
{
    public Update${e}CommandValidator()
    {
        RuleFor(x => x.Id).NotEmpty();
${updatableFields.filter(f => f.required && f.type === 'string').map(f =>
  `        RuleFor(x => x.${f.name}).NotEmpty()${f.maxLength ? `.MaximumLength(${f.maxLength})` : ''};`
).join('\n')}
${validationRules}
    }
}
`,
  })

  // 5b. Custom action artefacts (per-page mode) — Commands + Handlers per action.
  //     Service interface + impl methods are emitted alongside the CRUD ones below.
  for (const action of spec.customActions) {
    const cmdName = `${pascalizeCode(action.code)}${e}Command`
    const handlerName = `${cmdName}Handler`
    const cmdParams = buildCustomActionCommandParams(action)
    const cmdResponse = action.responseDto === 'NoContent' ? 'Unit' : action.responseDto
    files.push({
      path: `src/${ns}.Application/${mod}/Commands/${cmdName}.cs`,
      content: `using MediatR;
${action.payloadDto ? `using ${appNs}.DTOs;\n` : ''}
namespace ${appNs}.Commands;

public record ${cmdName}(${cmdParams}) : IRequest<${cmdResponse}>;
`,
    })

    const serviceCallArgs = customActionServiceCallArgs(action)
    const handlerBody = action.responseDto === 'NoContent'
      ? `        await _service.${pascalizeCode(action.code)}Async(${serviceCallArgs});
        return Unit.Value;`
      : `        return await _service.${pascalizeCode(action.code)}Async(${serviceCallArgs});`

    files.push({
      path: `src/${ns}.Application/${mod}/Handlers/${handlerName}.cs`,
      content: `using MediatR;
using ${appNs}.Commands;
using ${appNs}.Interfaces;

namespace ${appNs}.Handlers;

public class ${handlerName} : IRequestHandler<${cmdName}, ${cmdResponse}>
{
    private readonly I${e}Service _service;

    public ${handlerName}(I${e}Service service) => _service = service;

    public async Task<${cmdResponse}> Handle(${cmdName} request, CancellationToken cancellationToken)
    {
${handlerBody}
    }
}
`,
    })
  }

  // 6. Service Interface
  const customInterfaceMethods = spec.customActions.map(a => {
    const sig = customActionServiceMethodSignature(a)
    return `    ${sig};`
  }).join('\n')

  files.push({
    path: `src/${ns}.Application/${mod}/Interfaces/I${e}Service.cs`,
    content: `using SmartStack.Application.Common.Models;
using ${appNs}.Commands;
using ${appNs}.DTOs;
using ${appNs}.Queries;

namespace ${appNs}.Interfaces;

public interface I${e}Service
{
    Task<Guid> CreateAsync(Create${e}Command command, CancellationToken ct = default);
    Task UpdateAsync(Update${e}Command command, CancellationToken ct = default);
    Task DeleteAsync(Guid id, CancellationToken ct = default);
    Task<${e}DetailDto?> GetByIdAsync(Guid id, CancellationToken ct = default);
    Task<PaginatedResult<${e}ListDto>> GetAllAsync(Get${plural}Query query, CancellationToken ct = default);
    Task<PaginatedResult<${e}RefDto>> GetLookupAsync(Get${plural}LookupQuery query, CancellationToken ct = default);
${customInterfaceMethods}
}
`,
  })

  // 7. Service Implementation — uses IExtensionsDbContext (extension entities live
  //    in the 'extensions' schema; Core entities outside the V1 whitelist are reached
  //    via ICoreDataService — see ExampleService.cs.template). Set<T>() is exposed on
  //    IExtensionsDbContext so the in-memory ExtensionsDbContext can back unit tests.
  files.push({
    path: `src/${ns}.Infrastructure/Services/${mod}/${e}Service.cs`,
    content: `using Microsoft.EntityFrameworkCore;
using SmartStack.Application.Common.Exceptions;
using SmartStack.Application.Common.Models;
using ${ns}.Application.Common.Interfaces;
using ${ns}.Domain.Entities;
using ${appNs}.Commands;
using ${appNs}.DTOs;
using ${appNs}.Interfaces;
using ${appNs}.Queries;

namespace ${ns}.Infrastructure.Services.${mod};

public class ${e}Service : I${e}Service
{
    private readonly IExtensionsDbContext _context;

    public ${e}Service(IExtensionsDbContext context) => _context = context;

    public async Task<Guid> CreateAsync(Create${e}Command command, CancellationToken ct = default)
    {
        var entity = ${e}.Create(${requiredFields.map(f => `command.${f.name}`).join(', ')});
        _context.Set<${e}>().Add(entity);
        await _context.SaveChangesAsync(ct);
        return entity.Id;
    }

    public async Task UpdateAsync(Update${e}Command command, CancellationToken ct = default)
    {
        var entity = await _context.Set<${e}>().FindAsync(new object[] { command.Id }, ct)
            ?? throw new NotFoundException(nameof(${e}), command.Id);
        entity.Update(${updatableFields.map(f => `command.${f.name}`).join(', ')});
        await _context.SaveChangesAsync(ct);
    }

    public async Task DeleteAsync(Guid id, CancellationToken ct = default)
    {
        var entity = await _context.Set<${e}>().FindAsync(new object[] { id }, ct)
            ?? throw new NotFoundException(nameof(${e}), id);
        _context.Set<${e}>().Remove(entity);
        await _context.SaveChangesAsync(ct);
    }

    public async Task<${e}DetailDto?> GetByIdAsync(Guid id, CancellationToken ct = default)
    {
        return await _context.Set<${e}>()
            .Where(x => x.Id == id)
            .Select(x => new ${e}DetailDto(x.Id, ${userFields.map(f => projectField(f, storedNames)).join(', ')}, x.CreatedAt, x.UpdatedAt))
            .FirstOrDefaultAsync(ct);
    }

    public async Task<PaginatedResult<${e}ListDto>> GetAllAsync(Get${plural}Query query, CancellationToken ct = default)
    {
        var q = _context.Set<${e}>().AsQueryable();
        if (!string.IsNullOrEmpty(query.Search))
        {
            q = q.Where(x => EF.Functions.Like(x.${storedFields[0]?.name ?? 'Id'}.ToString(), $"%{query.Search}%"));
        }
        var total = await q.CountAsync(ct);
        var items = await q.OrderByDescending(x => x.CreatedAt)
            .Skip((query.Page - 1) * query.PageSize).Take(query.PageSize)
            .Select(x => new ${e}ListDto(x.Id, ${userFields.slice(0, 5).map(f => projectField(f, storedNames)).join(', ')}, x.CreatedAt))
            .ToListAsync(ct);
        return new PaginatedResult<${e}ListDto>(items, total, query.Page, query.PageSize);
    }

    public async Task<PaginatedResult<${e}RefDto>> GetLookupAsync(Get${plural}LookupQuery query, CancellationToken ct = default)
    {
        // Display field is resolved at scaffold time (see pickDisplayField) so the
        // projection stays SQL-translatable. Search is case-insensitive LIKE on the
        // same field — same column read, single round-trip, EF.Functions.Like only.
        var q = _context.Set<${e}>().AsQueryable();
        if (!string.IsNullOrEmpty(query.Search))
        {
            q = q.Where(x => EF.Functions.Like(${displayField.searchExpr}, $"%{query.Search}%"));
        }
        var total = await q.CountAsync(ct);
        var items = await q.OrderBy(x => ${displayField.orderExpr})
            .Skip((query.Page - 1) * query.PageSize).Take(query.PageSize)
            .Select(x => new ${e}RefDto(x.Id, ${displayField.projectExpr}))
            .ToListAsync(ct);
        return new PaginatedResult<${e}RefDto>(items, total, query.Page, query.PageSize);
    }
${spec.customActions.map(a => emitCustomActionServiceMethod(a, e)).join('\n')}
}
`,
  })

  return files
}

/**
 * Resolve the display projection for `{Entity}RefDto.DisplayName` used by
 * GET /lookup. The frontend EntityLookup combobox renders this string.
 *
 * Priority:
 *   1. `displayNameExpr` — caller-provided PascalCase property name (e.g.
 *      `Name`, `Label`). Trusted verbatim; must be a stored string field of
 *      the same entity. ba-develop Phase 2 derives it from `entité.md`.
 *   2. First stored string field matching `Name` / `Label` / `Code` / `Title`.
 *   3. Any remaining stored string field.
 *   4. Fallback `Id.ToString()` — only when the entity has zero string fields.
 *
 * The three returned expressions are interpolated into the EF Core LINQ
 * pipeline of `GetLookupAsync`:
 *   - `searchExpr` — column read passed to `EF.Functions.Like`. For string
 *     columns it's just `x.Name`. For the Id fallback we coerce via `.ToString()`.
 *   - `orderExpr`  — column used in `OrderBy`. Same as searchExpr modulo casts.
 *   - `projectExpr`— the final string passed to the `${e}RefDto` ctor.
 */
function pickDisplayField(
  displayNameExpr: string | undefined,
  storedFields: Field[],
): { searchExpr: string; orderExpr: string; projectExpr: string } {
  const stringFields = storedFields.filter(f => f.type.toLowerCase() === 'string')

  let chosen: string | null = null
  if (displayNameExpr && storedFields.some(f => f.name === displayNameExpr)) {
    chosen = displayNameExpr
  } else {
    for (const preferred of ['Name', 'Label', 'Code', 'Title']) {
      if (stringFields.some(f => f.name === preferred)) { chosen = preferred; break }
    }
    if (!chosen && stringFields.length > 0) chosen = stringFields[0]!.name
  }

  if (!chosen) {
    return { searchExpr: 'x.Id.ToString()', orderExpr: 'x.Id', projectExpr: 'x.Id.ToString()' }
  }
  return { searchExpr: `x.${chosen}`, orderExpr: `x.${chosen}`, projectExpr: `x.${chosen}` }
}

function mapType(f: Field): string {
  const map: Record<string, string> = {
    'string': 'string',
    'int': 'int',
    'integer': 'int',
    'number': 'decimal',
    'decimal': 'decimal',
    'bool': 'bool',
    'boolean': 'bool',
    'datetime': 'DateTime',
    'date': 'DateOnly',
    'guid': 'Guid',
  }
  const base = map[f.type.toLowerCase()] ?? f.type
  return !f.required && base !== 'string' ? `${base}?` : base
}

/**
 * Render a field accessor inside a `.Select(x => new Dto(...))` projection.
 * - Stored fields → `x.FieldName` (regular column read).
 * - Computed fields (with `formula`) → the formula, with each stored property
 *   reference prefixed by `x.` so EF Core can translate it to SQL. Tokens that
 *   don't match a stored field name (Math.Round, literals, …) are left intact.
 *
 * Wrapped in parentheses so binary expressions (e.g. `(A - B) / A`) compose
 * cleanly inside the comma-separated argument list of the DTO record ctor.
 */
function projectField(f: Field, storedNames: Set<string>): string {
  if (!f.formula) return `x.${f.name}`
  const projected = f.formula.replace(/\b([A-Z][A-Za-z0-9]*)\b/g, (token) =>
    storedNames.has(token) ? `x.${token}` : token,
  )
  return `(${projected})`
}

function capitalize(s: string): string { return s.charAt(0).toUpperCase() + s.slice(1) }

/** Convert a kebab-case action code (`bulk-archive`) to PascalCase (`BulkArchive`)
 *  for C# class / method names. */
function pascalizeCode(code: string): string {
  return code.split('-').map(part => capitalize(part)).join('')
}

// ─── Custom action emission ────────────────────────────────────────────

/**
 * Build the parameter list for a custom action MediatR Command record.
 *  - row scope    → `(Guid Id)` or `(Guid Id, <Payload> Payload)`
 *  - bulk scope   → `(<Payload> Payload)` or `(IReadOnlyList<Guid> Ids)` (no payload fallback)
 *  - header scope → `(<Payload> Payload)` or `()` (no payload fallback)
 */
function buildCustomActionCommandParams(action: BusinessCustomAction): string {
  if (action.scope === 'row') {
    return action.payloadDto ? `Guid Id, ${action.payloadDto} Payload` : 'Guid Id'
  }
  if (action.scope === 'bulk') {
    return action.payloadDto ? `${action.payloadDto} Payload` : 'IReadOnlyList<Guid> Ids'
  }
  // header scope
  return action.payloadDto ? `${action.payloadDto} Payload` : ''
}

/**
 * Build the argument list for the service call inside a Handler. Mirrors
 * `buildCustomActionCommandParams`'s field names ("Id" / "Payload" / "Ids").
 * Always appends `cancellationToken` at the end.
 */
function customActionServiceCallArgs(action: BusinessCustomAction): string {
  const parts: string[] = []
  if (action.scope === 'row') {
    parts.push('request.Id')
    if (action.payloadDto) parts.push('request.Payload')
  } else if (action.scope === 'bulk') {
    parts.push(action.payloadDto ? 'request.Payload' : 'request.Ids')
  } else {
    if (action.payloadDto) parts.push('request.Payload')
  }
  parts.push('cancellationToken')
  return parts.join(', ')
}

/**
 * Build the C# method signature on `I${e}Service` for a custom action.
 *  - `archive` row no-payload  → `Task ArchiveAsync(Guid id, CancellationToken ct = default)`
 *  - `approve` row + payload   → `Task<BudgetDto> ApproveAsync(Guid id, ApproveRequest payload, CancellationToken ct = default)`
 *  - bulk-archive bulk no-pl   → `Task ArchiveAsync(IReadOnlyList<Guid> ids, CancellationToken ct = default)`
 */
function customActionServiceMethodSignature(action: BusinessCustomAction): string {
  const methodName = `${pascalizeCode(action.code)}Async`
  const returnType = action.responseDto === 'NoContent' ? 'Task' : `Task<${action.responseDto}>`
  const params: string[] = []
  if (action.scope === 'row') {
    params.push('Guid id')
    if (action.payloadDto) params.push(`${action.payloadDto} payload`)
  } else if (action.scope === 'bulk') {
    params.push(action.payloadDto ? `${action.payloadDto} payload` : 'IReadOnlyList<Guid> ids')
  } else {
    if (action.payloadDto) params.push(`${action.payloadDto} payload`)
  }
  params.push('CancellationToken ct = default')
  return `${returnType} ${methodName}(${params.join(', ')})`
}

/**
 * Emit the C# implementation block for a custom action service method.
 * Canonical recipes for known state-mutation codes; NotImplementedException
 * stub (with UC reference) for unknown codes — flags the body for manual
 * implementation while keeping the project compilable.
 */
function emitCustomActionServiceMethod(action: BusinessCustomAction, entity: string): string {
  const methodName = `${pascalizeCode(action.code)}Async`
  const returnType = action.responseDto === 'NoContent' ? 'Task' : `Task<${action.responseDto}>`

  const params: string[] = []
  if (action.scope === 'row') {
    params.push('Guid id')
    if (action.payloadDto) params.push(`${action.payloadDto} payload`)
  } else if (action.scope === 'bulk') {
    params.push(action.payloadDto ? `${action.payloadDto} payload` : 'IReadOnlyList<Guid> ids')
  } else {
    if (action.payloadDto) params.push(`${action.payloadDto} payload`)
  }
  params.push('CancellationToken ct = default')
  const signature = `public async ${returnType} ${methodName}(${params.join(', ')})`

  // Canonical recipes for state-mutation codes. Only fire when scope='row',
  // payloadDto is null, responseDto is NoContent — the simplest baseline.
  // More complex variants (scope=bulk, custom payload) fall through to the
  // generic stub regardless of code (the BA must specify the semantics).
  const isCanonicalRow =
    action.scope === 'row' &&
    !action.payloadDto &&
    action.responseDto === 'NoContent'

  let body: string
  if (isCanonicalRow && action.code === 'archive') {
    body = `        var entity = await _context.Set<${entity}>().FindAsync(new object[] { id }, ct)
            ?? throw new NotFoundException(nameof(${entity}), id);
        // Canonical archive recipe — set Status='archived'. If the entity
        // does not carry a Status property, replace this body manually.
        entity.GetType().GetProperty("Status")?.SetValue(entity, "archived");
        await _context.SaveChangesAsync(ct);`
  } else if (isCanonicalRow && action.code === 'restore') {
    body = `        var entity = await _context.Set<${entity}>().FindAsync(new object[] { id }, ct)
            ?? throw new NotFoundException(nameof(${entity}), id);
        // Canonical restore recipe — flip Status back to 'active'.
        entity.GetType().GetProperty("Status")?.SetValue(entity, "active");
        await _context.SaveChangesAsync(ct);`
  } else if (isCanonicalRow && action.code === 'activate') {
    body = `        var entity = await _context.Set<${entity}>().FindAsync(new object[] { id }, ct)
            ?? throw new NotFoundException(nameof(${entity}), id);
        // Canonical activate recipe — IsActive=true.
        entity.GetType().GetProperty("IsActive")?.SetValue(entity, true);
        await _context.SaveChangesAsync(ct);`
  } else if (isCanonicalRow && action.code === 'deactivate') {
    body = `        var entity = await _context.Set<${entity}>().FindAsync(new object[] { id }, ct)
            ?? throw new NotFoundException(nameof(${entity}), id);
        // Canonical deactivate recipe — IsActive=false.
        entity.GetType().GetProperty("IsActive")?.SetValue(entity, false);
        await _context.SaveChangesAsync(ct);`
  } else if (
    action.scope === 'row' &&
    !action.payloadDto &&
    action.responseDto !== 'NoContent' &&
    action.code === 'duplicate'
  ) {
    body = `        var source = await _context.Set<${entity}>().FindAsync(new object[] { id }, ct)
            ?? throw new NotFoundException(nameof(${entity}), id);
        // Canonical duplicate recipe — reflection-based deep clone with new Id.
        // Replace with an explicit copy if you need to alter specific fields
        // (e.g. label += " (copy)").
        var clone = (${entity})source.GetType().GetMethod("Clone")?.Invoke(source, null)!;
        clone.GetType().GetProperty("Id")?.SetValue(clone, Guid.NewGuid());
        _context.Set<${entity}>().Add(clone);
        await _context.SaveChangesAsync(ct);
        return await GetByIdAsync(clone.Id, ct) ?? throw new InvalidOperationException("Duplicated entity vanished");`
  } else if (action.fromStatus || action.toStatus) {
    // ─── Workflow state-transition recipe (SK-001) ─────────────────────
    // When fromStatus/toStatus metadata is supplied, generate a real
    // state-transition body instead of NotImplementedException.
    const lines: string[] = []
    const ucTag = action.ucReference ? `[${action.ucReference}]` : ''

    // Cross-module dependency warning (SK-004)
    if (action.crossModuleDeps && action.crossModuleDeps.length > 0) {
      for (const dep of action.crossModuleDeps) {
        lines.push(`        // BLOCKED: depends on module '${dep}' — stub until delivered.`)
      }
    }

    lines.push(`        var entity = await _context.Set<${entity}>().FindAsync(new object[] { id }, ct)`)
    lines.push(`            ?? throw new NotFoundException(nameof(${entity}), id);`)

    // Guard: check current status
    if (action.fromStatus) {
      lines.push(``)
      lines.push(`        // Guard: entity must be in '${action.fromStatus}' state ${ucTag}`)
      lines.push(`        if (entity.Status != "${action.fromStatus}")`)
      lines.push(`            throw new BusinessRuleException($"Cannot '${action.code}': entity is in '{entity.Status}', expected '${action.fromStatus}'.");`)
    }

    // Guard rules as comments
    if (action.guardRules && action.guardRules.length > 0) {
      lines.push(``)
      for (const rule of action.guardRules) {
        lines.push(`        // Guard ${rule} — TODO: implement rule check`)
      }
    }

    // Flow parameters: apply payload fields (SK-002)
    if (action.flowParameters && action.flowParameters.length > 0) {
      lines.push(``)
      for (const param of action.flowParameters) {
        const pascal = param.charAt(0).toUpperCase() + param.slice(1)
        lines.push(`        entity.${pascal} = payload.${pascal};`)
      }
    }

    // Transition: set new status
    if (action.toStatus) {
      lines.push(``)
      lines.push(`        // Transition → '${action.toStatus}' ${ucTag}`)
      lines.push(`        entity.Status = "${action.toStatus}";`)
    }

    lines.push(`        await _context.SaveChangesAsync(ct);`)

    body = lines.join('\n')
  } else {
    // Unknown / non-canonical code — stub with UC reference comment.
    const ucComment = action.ucReference
      ? `        // TODO[${action.ucReference}]: implement '${action.code}' per the linked use case.`
      : `        // TODO: implement '${action.code}' — no canonical recipe and no UC reference provided.`

    // Cross-module dependency warning (SK-004)
    const depComments = (action.crossModuleDeps ?? [])
      .map(dep => `        // BLOCKED: depends on module '${dep}' — stub until delivered.`)
      .join('\n')

    const stubReturn = action.responseDto === 'NoContent'
      ? '        throw new NotImplementedException();'
      : `        throw new NotImplementedException();
        // return default!;`
    body = `${depComments ? depComments + '\n' : ''}${ucComment}
${stubReturn}`
  }

  return `
    ${signature}
    {
${body}
    }
`
}

/**
 * Convert a PRD business rule into a FluentValidation rule block.
 * Order of precedence:
 *   1. Explicit `expression` field (raw FluentValidation code).
 *   2. Description heuristics (required / range / pattern / comparison).
 *   3. TODO comment (fallback — flagged for manual rewrite).
 */
function renderBusinessRule(rule: BusinessRule, fields: Field[]): string {
  const header = `        // ${rule.id}: ${rule.description}`
  if (rule.expression) {
    const body = rule.expression.trim()
    const statement = body.endsWith(';') ? body : `${body};`
    return `${header}\n        ${statement}`
  }
  const heuristic = deriveHeuristic(rule.description, fields)
  if (heuristic) {
    return `${header}\n${heuristic}`
  }
  return `${header}\n        // TODO[${rule.id}]: No expression and no heuristic matched — translate this rule manually.`
}

function deriveHeuristic(description: string, fields: Field[]): string | null {
  const text = description.toLowerCase()

  // "X is required" / "X must not be empty" / "X cannot be null"
  const requiredField = fields.find(f => {
    const lower = f.name.toLowerCase()
    return text.includes(lower) && (text.includes('required') || text.includes('not be empty') || text.includes('cannot be null') || text.includes('must not be empty'))
  })
  if (requiredField) {
    return `        RuleFor(x => x.${requiredField.name}).NotEmpty().WithMessage("${requiredField.name} is required");`
  }

  // "X must be positive" / "X must be greater than 0"
  const positiveField = fields.find(f => text.includes(f.name.toLowerCase()) && (text.includes('positive') || text.includes('greater than 0')))
  if (positiveField) {
    return `        RuleFor(x => x.${positiveField.name}).GreaterThan(0).WithMessage("${positiveField.name} must be positive");`
  }

  // "X must be before Y" / "X must be less than Y"
  const beforeMatch = /(\w+)\s+(?:must be )?(?:before|less than)\s+(\w+)/.exec(text)
  if (beforeMatch) {
    const left = fields.find(f => f.name.toLowerCase() === beforeMatch[1])
    const right = fields.find(f => f.name.toLowerCase() === beforeMatch[2])
    if (left && right) {
      return `        RuleFor(x => x.${left.name}).LessThan(x => x.${right.name}).WithMessage("${left.name} must be before ${right.name}");`
    }
  }

  // "X must match pattern /regex/" — naive
  const patternMatch = /(\w+)\s+must\s+match\s+([^\s]+)/.exec(text)
  if (patternMatch) {
    const field = fields.find(f => f.name.toLowerCase() === patternMatch[1])
    if (field) {
      return `        RuleFor(x => x.${field.name}).Matches(@"${patternMatch[2]}").WithMessage("${field.name} has invalid format");`
    }
  }

  // "X must be between A and B"
  const rangeMatch = /(\w+)\s+must\s+be\s+between\s+(-?\d+(?:\.\d+)?)\s+and\s+(-?\d+(?:\.\d+)?)/.exec(text)
  if (rangeMatch) {
    const field = fields.find(f => f.name.toLowerCase() === rangeMatch[1])
    if (field) {
      return `        RuleFor(x => x.${field.name}).InclusiveBetween(${rangeMatch[2]}, ${rangeMatch[3]}).WithMessage("${field.name} must be between ${rangeMatch[2]} and ${rangeMatch[3]}");`
    }
  }

  return null
}
