/**
 * Prisma Controller Generator
 *
 * Generates model-specific business logic controllers with CURED operations
 * Controllers manage models and can publish/subscribe to events
 */

import type { TemplateContext } from '@specverse/types';
import { buildTransitionMap, isAutoField } from '@specverse/types/spec-rules';
import { generateBehaviorWithHelpers, type BehaviorContext, type BehaviorMetadata } from './behavior-generator.js';

/**
 * Generate Prisma controller for a model
 */
export default function generatePrismaController(context: TemplateContext): string {
  const { controller, model, spec, models: allModels } = context;

  if (!controller) {
    throw new Error('Controller is required in template context');
  }

  if (!model) {
    throw new Error('Model is required for controller generation');
  }

  const controllerName = controller.name;
  const modelName = model.name;
  const rawModelVar = modelName.charAt(0).toLowerCase() + modelName.slice(1);
  // Avoid JavaScript reserved words as local variable names
  const RESERVED_WORDS = new Set(['import', 'export', 'default', 'class', 'function', 'return', 'delete', 'new', 'this', 'switch', 'case', 'break', 'continue', 'for', 'while', 'do', 'if', 'else', 'try', 'catch', 'finally', 'throw', 'typeof', 'instanceof', 'in', 'of', 'let', 'const', 'var', 'void', 'with', 'yield', 'async', 'await', 'enum', 'implements', 'interface', 'package', 'private', 'protected', 'public', 'static', 'super', 'extends']);
  const modelVar = RESERVED_WORDS.has(rawModelVar) ? `${rawModelVar}Item` : rawModelVar;
  // Prisma delegate name always matches the Prisma model's camelCase form —
  // even when the model name is a JS reserved word (prisma.import, prisma.export).
  // Access these via bracket notation so tsc doesn't choke on the keyword.
  const prismaDelegate = RESERVED_WORDS.has(rawModelVar)
    ? `prisma['${rawModelVar}']`
    : `prisma.${rawModelVar}`;
  const curedOps = controller.cured || {};

  // Determine ID type for proper parsing
  const idAttr = (Array.isArray(model.attributes) ? model.attributes : Object.values(model.attributes || {}))
    .find((a: any) => a.name === 'id');
  const idType = idAttr?.type || 'UUID';
  const needsIntParse = idType === 'Integer' || idType === 'Int' || idType === 'Number';

  // Generate custom actions and collect AI behavior stubs
  const customActions = generateCustomActions(controller, modelName, modelVar);

  // Phase 2 — the guards module only exists for models with declared
  // constraints, so import + invocation are gated on this flag. Computed
  // up-front so generateValidateMethod / generateEvolveMethod /
  // generateDeleteMethod can emit the runConstraintGuards call.
  const hasConstraints =
    Array.isArray((model as any).constraints) &&
    (model as any).constraints.length > 0;

  // Build the class body first, then introspect to decide which
  // top-of-file declarations are actually needed. Controllers with no
  // CURVED ops and no prisma-using custom actions (e.g. a controller
  // that only delegates to .ai.ts behaviour) end up with empty
  // bodies — emitting `prisma` / `parseId` / `PrismaClient` import
  // unconditionally produced TS6133 unused-decl errors at every
  // realize run. Gating on actual body content drops those.
  const classBody = [
    generateValidateMethod(model, modelName, hasConstraints),
    curedOps.create ? generateCreateMethod(model, modelName, modelVar, prismaDelegate, controller, allModels, hasConstraints) : '',
    curedOps.retrieve ? generateRetrieveMethod(model, modelName, modelVar, prismaDelegate) : '',
    curedOps.update ? generateUpdateMethod(model, modelName, modelVar, prismaDelegate, controller, allModels, hasConstraints) : '',
    curedOps.evolve ? generateEvolveMethod(model, modelName, modelVar, prismaDelegate, controller, hasConstraints) : '',
    curedOps.delete ? generateDeleteMethod(model, modelName, modelVar, prismaDelegate, controller, hasConstraints) : '',
    customActions.code,
  ].filter(Boolean).join('\n  ');

  const usesPrisma = /\bprisma\b/.test(classBody);
  const usesParseId = /\bparseId\(/.test(classBody);
  // Symbol-presence check on the actual class body — matches the
  // `usesPrisma` pattern. Pre-fix this was a stub `hasEventPublishing`
  // that returned true unconditionally, producing an unused-import
  // TS6133 on every backend-only ProductController (validate-only,
  // no CRUD, no eventBus.publish in the body).
  const usesEventBus = /\beventBus\./.test(classBody);
  const usesAiBehaviors = customActions.needsAiBehaviors;

  // Build top-of-file declarations conditionally.
  const imports = [
    usesPrisma ? `import { PrismaClient } from '@prisma/client';` : '',
    usesEventBus ? `import { eventBus } from '../events/eventBus.js';` : '',
    usesAiBehaviors ? `import * as aiBehaviors from '../behaviors/${modelName}Controller.ai.js';` : '',
    hasConstraints
      ? `import { runGuards as runConstraintGuards } from './${modelName}.guards.js';`
      : '',
  ].filter(Boolean).join('\n');

  const declarations = [
    usesPrisma ? `const prisma = new PrismaClient();` : '',
    usesParseId
      ? `/** Parse ID from string to the correct type for this model */
function parseId(id: string): ${needsIntParse ? 'number' : 'string'} {
  ${needsIntParse ? 'return parseInt(id, 10);' : 'return id;'}
}`
      : '',
  ].filter(Boolean).join('\n\n');

  return `/**
 * ${controllerName}
 * Model-specific business logic for ${modelName}
 * ${controller.description || ''}
 */
${imports ? '\n' + imports + '\n' : ''}
${declarations ? declarations + '\n\n' : ''}/**
 * ${controllerName} class
 */
export class ${controllerName} {
  ${classBody}
}

// Export singleton instance
export const ${modelVar}Controller = new ${controllerName}();
export default ${modelVar}Controller;
`;

}

/**
 * Generate validate method (unified validation for all operations)
 *
 * Phase 2 — when the model has declared constraints, the body ALSO calls
 * `runConstraintGuards(data, op, actor)` from the per-model `.guards.ts`
 * module and appends any constraint violation messages to the errors
 * array. `op:` shape supports the wider CURVED vocabulary including
 * `delete` and `evolve.<transition>`.
 */
function generateValidateMethod(model: any, modelName: string, hasConstraints: boolean): string {
  // The `_context.operation` union widens for Phase 2 so the guards
  // module's matchesOp() can match evolve.<transition> and delete records.
  const opTypeUnion = hasConstraints
    ? `'create' | 'update' | 'evolve' | 'delete' | \`evolve.\${string}\``
    : `'create' | 'update' | 'evolve'`;

  const constraintsCheck = hasConstraints
    ? `
    // Phase 2 — run model.constraints[] guards matching this operation.
    // ctx carries a per-model query helper so subquery sugars (rewritten
    // to async guards by guards-generator) can run their findFirst calls.
    const __guardCtx = {
      query: (modelName: string) => {
        const delegate = (prisma as any)[modelName.charAt(0).toLowerCase() + modelName.slice(1)];
        if (!delegate) return undefined;
        return {
          exists: async (predicate: (e: any) => boolean) => {
            const all = await delegate.findMany();
            return all.some(predicate);
          },
        };
      },
    };
    const constraintViolations = await runConstraintGuards(_data, _context.operation, _actor, __guardCtx);
    for (const v of constraintViolations) errors.push(v.message);`
    : '';

  // Phase 2 actor wiring — validate() ALWAYS accepts _actor (default null)
  // even on unconstrained models, so route handlers can pass it unconditionally
  // without per-model knowledge of whether constraints exist.
  // Slice 15b — validate is async (it awaits runConstraintGuards which may
  // execute subquery sugars). Callers must await this.validate(...).

  return `
  /**
   * Validate ${modelName} data
   * Unified validation method for all operations
   */
  public async validate(
    _data: any,
    _context: { operation: ${opTypeUnion} },
    _actor: any = null
  ): Promise<{ valid: boolean; errors: string[] }> {
    const errors: string[] = [];

    ${generateValidationLogic(model, '_data', '_context')}${constraintsCheck}

    return {
      valid: errors.length === 0,
      errors
    };
  }
`;
}

/**
 * Generate validation logic based on model attributes
 */
function generateValidationLogic(model: any, dataParam: string = '_data', contextParam: string = '_context'): string {
  if (!model.attributes) return '// No validation rules defined';

  const validations: string[] = [];

  const attrList = Array.isArray(model.attributes)
    ? model.attributes.map((a: any) => [a.name, a])
    : Object.entries(model.attributes);
  attrList.forEach(([name, attr]: [string, any]) => {
    // Required validation — skip auto-generated fields (uses shared spec-rules)
    if (attr.required && !isAutoField(name, attr)) {
      validations.push(`
    // ${name} is required
    if (${contextParam}.operation === 'create' && !${dataParam}.${name}) {
      errors.push('${name} is required');
    }`);
    }

    // String length validation
    if (attr.type === 'String' || attr.type === 'string') {
      if (attr.min) {
        validations.push(`
    if (${dataParam}.${name} && ${dataParam}.${name}.length < ${attr.min}) {
      errors.push('${name} must be at least ${attr.min} characters');
    }`);
      }
      if (attr.max) {
        validations.push(`
    if (${dataParam}.${name} && ${dataParam}.${name}.length > ${attr.max}) {
      errors.push('${name} must be at most ${attr.max} characters');
    }`);
      }
    }

    // Enum validation
    if (attr.values && Array.isArray(attr.values)) {
      const values = attr.values.map((v: string) => `'${v}'`).join(', ');
      validations.push(`
    if (${dataParam}.${name} && ![${values}].includes(${dataParam}.${name})) {
      errors.push('${name} must be one of: ${attr.values.join(', ')}');
    }`);
    }

    // Email validation
    if (attr.format === 'email') {
      validations.push(`
    if (${dataParam}.${name} && !/^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/.test(${dataParam}.${name})) {
      errors.push('${name} must be a valid email address');
    }`);
    }
  });

  return validations.join('\n') || '// No validation rules defined';
}

/**
 * Generate create method
 */
function generateCreateMethod(model: any, modelName: string, modelVar: string, prismaDelegate: string, controller: any, allModels?: any[], hasConstraints: boolean = false): string {
  const belongsToLoad = hasConstraints ? generateBelongsToLoad(model, allModels) : '';
  const validateSelf = belongsToLoad ? '__mergedSelf' : 'data';

  return `
  /**
   * Create a new ${modelName}
   */
  public async create(data: any, _actor: any = null): Promise<any> {
${belongsToLoad ? `
    // Phase 2 Slice 15 — Create-time relation loading. For each belongsTo
    // FK present in input, load the related entity and merge into self
    // so constraints traversing self.<rel>.<field> see the full related
    // record (not just the FK id). Mirrors Slice 8's Update pattern but
    // for the create path. Only fires when the model has declared
    // constraints; one extra round-trip per FK in input.
    const __loadedRels: Record<string, any> = {};
    ${belongsToLoad}
    const __mergedSelf = { ...data, ...__loadedRels };
` : ''}
    // Validate input
    const validationResult = await this.validate(${validateSelf}, { operation: 'create' }, _actor);
    if (!validationResult.valid) {
      throw new Error(\`Validation failed: \${validationResult.errors.join(', ')}\`);
    }

    // Transform FK fields to Prisma connect format
    const prismaData = { ...data };
    ${generateFKTransform(model, 'prismaData', allModels)}

    // Create record
    const ${modelVar} = await ${prismaDelegate}.create({
      data: prismaData${generateIncludeRelationships(model)}
    });

    // Publish CURED event
    await eventBus.publish('${modelName}Created', { ...${modelVar}, timestamp: new Date().toISOString() } as any);

    return ${modelVar};
  }
`;
}

/**
 * Phase 2 Slice 15 — emit `if (data.<fkField>) __loadedRels.<relName> = await prisma.<target>.findUnique(...)`
 * statements for each belongsTo relationship. Used by create() to load
 * related entities before constraint guards run.
 *
 * IDs are parsed via parseId() when the target model has an Integer id;
 * UUID/String targets pass through as-is (parseId is the no-op identity
 * function for those).
 */
function generateBelongsToLoad(model: any, allModels?: any[]): string {
  const rels = Array.isArray(model.relationships)
    ? model.relationships
    : Object.values(model.relationships || {});

  const belongsToRels = (rels as any[]).filter(r => r.type === 'belongsTo');
  if (belongsToRels.length === 0) return '';

  const RESERVED_WORDS = new Set(['import', 'export', 'default', 'class', 'function', 'return', 'delete', 'new', 'this', 'switch', 'case', 'break', 'continue', 'for', 'while', 'do', 'if', 'else', 'try', 'catch', 'finally', 'throw', 'typeof', 'instanceof', 'in', 'of', 'let', 'const', 'var', 'void', 'with', 'yield', 'async', 'await', 'enum', 'implements', 'interface', 'package', 'private', 'protected', 'public', 'static', 'super', 'extends']);

  return belongsToRels.map((rel: any) => {
    const relName = rel.name;
    const targetName = rel.target;
    const fkField = `${relName}Id`;
    const targetVar = targetName.charAt(0).toLowerCase() + targetName.slice(1);
    const targetDelegate = RESERVED_WORDS.has(targetVar) ? `prisma['${targetVar}']` : `prisma.${targetVar}`;

    // Determine if target uses int IDs
    let idExpr = `data.${fkField}`;
    if (allModels) {
      const targetModel = allModels.find((m: any) => m.name === targetName);
      if (targetModel) {
        const idAttr = (Array.isArray(targetModel.attributes) ? targetModel.attributes : Object.values(targetModel.attributes || {}))
          .find((a: any) => a.name === 'id');
        const idType = idAttr?.type || 'String';
        if (idType === 'Integer' || idType === 'Int' || idType === 'Number') {
          idExpr = `parseInt(data.${fkField}, 10)`;
        }
      }
    }

    return `if (data.${fkField}) {
      __loadedRels.${relName} = await ${targetDelegate}.findUnique({ where: { id: ${idExpr} } });
    }`;
  }).join('\n    ');
}

/**
 * Generate retrieve method
 */
function generateRetrieveMethod(model: any, modelName: string, modelVar: string, prismaDelegate: string): string {
  return `
  /**
   * Retrieve ${modelName} by ID. Returns null when not found so the
   * route handler can translate that into HTTP 404 instead of 500.
   */
  public async retrieve(id: string): Promise<any | null> {
    return await ${prismaDelegate}.findUnique({
      where: { id: parseId(id) }${generateIncludeRelationships(model)}
    });
  }

  /**
   * Retrieve all ${modelName}s
   */
  public async retrieveAll(options: { skip?: number; take?: number } = {}): Promise<any[]> {
    return await ${prismaDelegate}.findMany({
      skip: options.skip,
      take: options.take${generateIncludeRelationships(model)}
    });
  }
`;
}

/**
 * Generate update method
 */
function generateUpdateMethod(model: any, modelName: string, modelVar: string, prismaDelegate: string, controller: any, allModels?: any[], hasConstraints: boolean = false): string {
  return `
  /**
   * Update ${modelName}
   */
  public async update(id: string, data: any, _actor: any = null): Promise<any> {
${hasConstraints ? `
    // Phase 2 — Update self-from-DB: load the entity first and validate
    // against the MERGED \`loaded + input\` shape so update-time constraints
    // like \`self.poll.votingStatus == "open"\` see the full record even
    // when the caller only sent a partial payload. Costs one extra DB
    // round-trip; only fires for models with declared constraints.
    const __existing = await ${prismaDelegate}.findUnique({ where: { id: parseId(id) }${generateIncludeRelationships(model)} });
    if (!__existing) throw new Error('${modelName} not found');
    const __merged = { ...__existing, ...data };
    const validationResult = await this.validate(__merged, { operation: 'update' }, _actor);
` : `
    // Validate input
    const validationResult = await this.validate(data, { operation: 'update' }, _actor);
`}
    if (!validationResult.valid) {
      throw new Error(\`Validation failed: \${validationResult.errors.join(', ')}\`);
    }

    // Strip nested relations and id — only send scalar fields to Prisma
    const updateData: any = {};
    for (const [key, value] of Object.entries(data)) {
      if (key === 'id') continue;
      if (Array.isArray(value)) continue;
      if (value !== null && typeof value === 'object' && !(value instanceof Date)) continue;
      updateData[key] = value;
    }

    // Transform FK fields to Prisma connect format
    ${generateFKTransform(model, 'updateData', allModels)}

    // Update record
    const ${modelVar} = await ${prismaDelegate}.update({
      where: { id: parseId(id) },
      data: updateData${generateIncludeRelationships(model)}
    });

    // Publish CURED event
    await eventBus.publish('${modelName}Updated', { ...${modelVar}, timestamp: new Date().toISOString() } as any);

    return ${modelVar};
  }
`;
}

/**
 * Generate evolve method (lifecycle-aware updates)
 */
function generateEvolveMethod(model: any, modelName: string, modelVar: string, prismaDelegate: string, controller: any, hasConstraints: boolean = false): string {
  // Extract lifecycle — handle both array and object format
  const lifecycles = Array.isArray(model.lifecycles) ? model.lifecycles :
    (model.lifecycles ? Object.entries(model.lifecycles).map(([name, lc]: [string, any]) => ({ name, ...lc })) : []);
  const lifecycle = lifecycles[0];
  const lifecycleName = lifecycle?.name || 'status';

  // Build transition map using shared spec-rules (flow-aware — expands
  // `flow:` shorthand into a from→[to] map).
  const validTransitions = lifecycle ? buildTransitionMap(lifecycle) : {};

  // Derive states from the transition map when the spec used `flow:` shorthand
  // (states is undefined on the wire, but buildTransitionMap expands the flow).
  // Without this, flow-shorthand lifecycles skipped the transition-enforcement
  // block below entirely, accepting any state→state jump (TODO #76).
  const states = lifecycle?.states && lifecycle.states.length > 0
    ? lifecycle.states.map((s: any) => typeof s === 'string' ? s : s.name)
    : Array.from(new Set([
        ...Object.keys(validTransitions),
        ...(Object.values(validTransitions) as string[][]).flat(),
      ]));

  // Phase 2 — reverse map (currentState -> targetState -> actionName) so
  // evolve() can pass the correct `evolve.<actionName>` op to validate.
  // Falls through to the targetState string for shorthand-flow lifecycles
  // that don't have named transition actions.
  const evolveOpsMap: Record<string, Record<string, string>> = {};
  if (lifecycle?.transitions) {
    for (const [actionName, t] of Object.entries(lifecycle.transitions as Record<string, any>)) {
      const from = (t as any).from;
      const to = (t as any).to;
      if (typeof from === 'string' && typeof to === 'string') {
        evolveOpsMap[from] = evolveOpsMap[from] ?? {};
        evolveOpsMap[from][to] = actionName;
      }
    }
  }

  return `
  /**
   * Evolve ${modelName} through lifecycle "${lifecycleName}"
   * States: ${states.join(' → ')}
   *
   * Accepts either the client shape { toState, lifecycleName? } or
   * the direct column shape { ${lifecycleName}: ... }. The frontend
   * runtime's useTransitionStateMutation always sends the former; the
   * realized smoke-parity script can send either.
   */
  public async evolve(id: string, data: any, _actor: any = null): Promise<any> {
    // Get current record to check lifecycle state
    const current = await ${prismaDelegate}.findUnique({ where: { id: parseId(id) } });
    if (!current) {
      throw new Error('${modelName} not found');
    }

    // Normalize input: translate client { toState: X } shape to the
    // lifecycle's actual column name. If the caller already sent the
    // column directly, keep whatever they sent.
    const targetLifecycle = data?.lifecycleName || '${lifecycleName}';
    const targetState = data?.toState ?? data?.state ?? data?.[targetLifecycle];
    if (!targetState) {
      throw new Error('evolve requires toState (or ${lifecycleName}) in the request body');
    }

    ${states.length > 0 ? `
    // Validate lifecycle transition against declared flow
    const currentState = (current as any)[targetLifecycle];
    const validTransitions: Record<string, string[]> = ${JSON.stringify(validTransitions)};
    const allowed = validTransitions[currentState] || [];
    if (!allowed.includes(targetState)) {
      throw new Error(\`Invalid transition: \${currentState} → \${targetState}. Allowed: \${allowed.join(', ') || 'none'}\`);
    }
    ` : ''}
${hasConstraints ? `
    // Phase 2 — resolve the transition's action name so constraints scoped
    // to \`on: 'evolve.<action>'\` match correctly. EVOLVE_OPS is baked at
    // codegen from the lifecycle definition.
    const EVOLVE_OPS: Record<string, Record<string, string>> = ${JSON.stringify(evolveOpsMap)};
    const currentStateForOp = (current as any)[targetLifecycle];
    const actionName = EVOLVE_OPS[currentStateForOp]?.[targetState] ?? targetState;
    const evolveValidation = await this.validate(
      { ...data, ...current, [targetLifecycle]: targetState },
      { operation: \`evolve.\${actionName}\` as any },
      _actor
    );
    if (!evolveValidation.valid) {
      throw new Error(\`Validation failed: \${evolveValidation.errors.join(', ')}\`);
    }
` : ''}
    // Build the Prisma update payload — only the lifecycle column
    // changes. Strips toState/lifecycleName/state so Prisma doesn't
    // reject unknown fields.
    const updateData: any = { [targetLifecycle]: targetState };

    // Update record
    const ${modelVar} = await ${prismaDelegate}.update({
      where: { id: parseId(id) },
      data: updateData${generateIncludeRelationships(model)}
    });

    // Publish CURED event
    await eventBus.publish('${modelName}Evolved', { ...${modelVar}, timestamp: new Date().toISOString() } as any);

    return ${modelVar};
  }
`;
}

/**
 * Generate delete method
 */
function generateDeleteMethod(model: any, modelName: string, modelVar: string, prismaDelegate: string, controller: any, hasConstraints: boolean = false): string {
  return `
  /**
   * Delete ${modelName}
   */
  public async delete(id: string, _actor: any = null): Promise<void> {
    // Get record before deletion for event
    const ${modelVar} = await ${prismaDelegate}.findUnique({ where: { id: parseId(id) } });
${hasConstraints ? `
    // Phase 2 — run delete-scoped constraint guards against the loaded
    // record. \`self\` is the entity being deleted (loaded above), giving
    // delete-time constraints access to the entity's current field values.
    if (${modelVar}) {
      const deleteValidation = await this.validate(${modelVar}, { operation: 'delete' }, _actor);
      if (!deleteValidation.valid) {
        throw new Error(\`Validation failed: \${deleteValidation.errors.join(', ')}\`);
      }
    }
` : ''}
    await ${prismaDelegate}.delete({
      where: { id: parseId(id) }
    });

    // Publish CURED event
    if (${modelVar}) {
      await eventBus.publish('${modelName}Deleted', { ...${modelVar}, timestamp: new Date().toISOString() } as any);
    }
  }
`;
}

/**
 * Generate custom actions defined in controller
 */
interface CustomActionsResult {
  code: string;
  unmatchedSteps: Array<{ step: string; functionName: string; operationName: string }>;
  needsAiBehaviors: boolean;
}

function generateCustomActions(controller: any, modelName: string, modelVar: string): CustomActionsResult {
  if (!controller.actions || Object.keys(controller.actions).length === 0) {
    return { code: '', unmatchedSteps: [], needsAiBehaviors: false };
  }

  const actions: string[] = [];
  const allUnmatchedSteps: Array<{ step: string; functionName: string; operationName: string }> = [];

  Object.entries(controller.actions).forEach(([actionName, action]: [string, any]) => {
    const params = action.parameters || {};
    const paramNames = Object.keys(params);
    const hasSteps = Array.isArray(action.steps) && action.steps.length > 0;

    // Realize L3 contract: when an action declares `steps:`, the
    // implementation lives in `behaviors/<ModelName>Controller.ai.ts`
    // as an exported async function named EXACTLY `actionName`. Both
    // per-action and per-owner emitters honour this contract. The
    // controller method here just delegates: collect the action's
    // parameters into a single `input` object and forward to the
    // per-action implementation.
    //
    // Pre-this-fix, the controller emitted a per-STEP chain
    // (`step1Result = await aiBehaviors.<verboseStepName>(...)`,
    // `step2Result = await aiBehaviors.<verboseStepName>(step1Result)`,
    // ...) referencing function names that NO emitter produces anymore
    // — per-action and per-owner both export per-action names only.
    // Every controller method was broken at compile time. Surfaced by
    // the 2026-05-11 per-owner trial v4 (TS2339 "Property
    // 'throwImmediatelyIfNoRefreshTokenIsHeldInLocalState' does not
    // exist on type 'typeof import("...")'").
    //
    // Actions WITHOUT steps still go through the legacy
    // behavior-generator path (deterministic shell from
    // requires/ensures/publishes only); they don't need an .ai.ts
    // delegate.
    let body: string;
    let needsAi = false;
    if (hasSteps) {
      // Delegate path. `input` is the bag of declared params, even if
      // the action has zero declared params (then `input = {}`).
      const inputObj = paramNames.length > 0
        ? `{ ${paramNames.join(', ')} }`
        : '{}';
      body = `    const input = ${inputObj};
    return await aiBehaviors.${actionName}(input);`;
      needsAi = true;
    } else {
      // No-steps path — use the legacy behavior-generator that
      // emits requires/ensures/publishes scaffolding.
      const behavior: BehaviorMetadata = {
        preconditions: action.requires || action.preconditions || [],
        steps: [],
        postconditions: action.ensures || action.postconditions || [],
        sideEffects: action.publishes || action.events || [],
        transactional: action.transactional,
      };
      const ctx: BehaviorContext = {
        modelName,
        serviceName: `${modelName}Controller`,
        operationName: actionName,
        prismaModel: modelVar,
        parameterNames: paramNames,
      };
      const result = generateBehaviorWithHelpers(behavior, {}, ctx);
      allUnmatchedSteps.push(...result.unmatchedSteps);
      body = result.body;
      needsAi = result.unmatchedSteps.length > 0;

      if (result.helperMethods.length > 0) {
        actions.push(...result.helperMethods);
      }
    }

    actions.push(`
  /**
   * ${actionName}
   * ${action.description || ''}
   */
  public async ${actionName}(${generateActionParams(action)}): Promise<any> {
${body}
  }`);

    // Aggregate the needsAi signal for the import-line decision below.
    if (needsAi) allUnmatchedSteps.push({
      step: '<delegate-to-ai-ts>',
      functionName: actionName,
      operationName: actionName,
    });
  });

  return {
    code: actions.join('\n'),
    unmatchedSteps: allUnmatchedSteps,
    needsAiBehaviors: allUnmatchedSteps.length > 0,
  };
}

/**
 * Generate action parameters
 */
function generateActionParams(action: any): string {
  if (!action.parameters || Object.keys(action.parameters).length === 0) {
    return '';
  }

  const params = Object.entries(action.parameters).map(([name, param]: [string, any]) => {
    const optional = !param.required;
    return `${name}${optional ? '?' : ''}: ${mapTypeToTypeScript(param.type)}`;
  });

  return params.join(', ');
}

/**
 * Map SpecVerse types to TypeScript types
 */
function mapTypeToTypeScript(type: string): string {
  const typeMap: Record<string, string> = {
    String: 'string',
    Integer: 'number',
    Float: 'number',
    Boolean: 'boolean',
    Date: 'Date',
    DateTime: 'Date',
    UUID: 'string',
    Json: 'any',       // #43C: SpecVerse canonical casing → Prisma's Json
    JSON: 'any',       //       legacy/alt casing
    Jsonb: 'any',      //       Postgres-native variant
    JSONB: 'any'
  };

  return typeMap[type] || 'any';
}

/**
 * Generate FK field transformation for Prisma create/update.
 * Converts flat FK IDs (guesthouseId: "uuid") to Prisma connect format
 * (guesthouse: { connect: { id: "uuid" } })
 */
function generateFKTransform(model: any, varName: string = 'prismaData', allModels?: any[]): string {
  const rels = Array.isArray(model.relationships)
    ? model.relationships
    : Object.values(model.relationships || {});

  const belongsToRels = (rels as any[]).filter(r => r.type === 'belongsTo');
  if (belongsToRels.length === 0) return '';

  return belongsToRels.map((rel: any) => {
    const relName = rel.name;
    const fkField = `${relName}Id`;

    // Determine target model's ID type for correct parsing
    let parseExpr = `${varName}.${fkField}`;
    if (allModels) {
      const targetModel = allModels.find((m: any) => m.name === rel.target);
      if (targetModel) {
        const idAttr = (Array.isArray(targetModel.attributes) ? targetModel.attributes : Object.values(targetModel.attributes || {}))
          .find((a: any) => a.name === 'id');
        const idType = idAttr?.type || 'String';
        if (idType === 'Integer' || idType === 'Int' || idType === 'Number') {
          parseExpr = `parseInt(${varName}.${fkField}, 10)`;
        }
      }
    }

    return `if (${varName}.${fkField}) {
      ${varName}.${relName} = { connect: { id: ${parseExpr} } };
      delete ${varName}.${fkField};
    }`;
  }).join('\n    ');
}

/**
 * Generate include clause for relationships
 */
function generateIncludeRelationships(model: any): string {
  if (!model.relationships) {
    return '';
  }

  // Handle different relationship formats
  let relationshipKeys: string[];

  if (Array.isArray(model.relationships)) {
    // Array format: could be array of strings or array of objects
    relationshipKeys = model.relationships.map((rel: any) => {
      if (typeof rel === 'string') {
        return rel;
      } else if (typeof rel === 'object' && rel.name) {
        return rel.name;
      } else {
        return null;
      }
    }).filter((rel): rel is string => rel !== null);
  } else if (typeof model.relationships === 'object') {
    // Object format: { author: {...}, comments: {...} }
    relationshipKeys = Object.keys(model.relationships);
  } else {
    return '';
  }

  if (relationshipKeys.length === 0) {
    return '';
  }

  const includes = relationshipKeys
    .map(rel => `        ${rel}: true`)
    .join(',\n');

  return `,
      include: {
${includes}
      }`;
}

/**
 * Check if controller publishes events
 */
function hasEventPublishing(curedOps: any, controller: any): boolean {
  // Controllers always publish CURED events (Created, Updated, Deleted, Evolved)
  // so the event bus is always needed
  return true;
}
