/**
 * AI Behaviors Generator
 *
 * Generates files for behavior steps that couldn't be matched
 * by convention patterns. Tries to use the configured AI engine to
 * generate real implementations; falls back to stubs if AI is unavailable.
 *
 * - Clearly separated from convention-generated code (.ai.ts suffix)
 * - Each function header documents the spec step + generation method
 * - Human reviewable and editable
 * - Regeneratable via `specverse ai regenerate` (TODO)
 * - Falls back to stub if AI unavailable or fails
 *
 * Level separation:
 *   L1/L2: Convention code → inline in controller (trusted, deterministic)
 *   L3:    Quint guards → guards.ts (formally verified)
 *   L4:    AI-generated → behaviors/*.ai.ts (needs human review)
 */

import type { TemplateContext } from '@specverse/types';
import { safeFunctionName } from '@specverse/types';
import { matchStep, type StepContext } from './step-conventions.js';
import { validateImportWhitelist } from '@specverse/engines/ai';
import { createHash } from 'crypto';
import { readFileSync, writeFileSync, existsSync, mkdirSync, unlinkSync } from 'fs';
import { dirname, join } from 'path';

/**
 * Validate generated TypeScript using esbuild's transform.
 * Returns null if valid, or an error message describing the problem.
 *
 * This catches syntax errors (unbalanced braces, unclosed strings, etc.)
 * but not type errors — those need full tsc which requires the project
 * context. That's a separate validation step run by the realize pipeline.
 */
async function validateTypeScript(code: string): Promise<string | null> {
  try {
    const esbuild = await import('esbuild');
    await esbuild.transform(code, {
      loader: 'ts',
      format: 'esm',
      target: 'es2022',
    });
    return null;
  } catch (err: any) {
    const msg = err?.errors?.[0]?.text || err?.message || 'unknown syntax error';
    return msg;
  }
}

/**
 * Type-check the generated body in isolation using the TypeScript compiler
 * API. Returns null if clean, or a concise error string suitable for
 * passing back to the LLM as a fix-up hint. Skipped silently if the `typescript`
 * package isn't installed (the realize pipeline still runs full tsc against
 * the realized output downstream — this is a faster, generator-side filter
 * to catch the most common LLM mistakes before they hit the user).
 *
 * Why bother: the LLM regularly emits patterns that are valid syntax but
 * fail strict tsc — RegExp index access (`m[1]` is `string | undefined`),
 * unused locals, undefined references. Reprompting with the tsc error
 * lets the LLM self-correct without burning a per-step retry.
 */
/**
 * Validate that every dynamic `await import('LIT')` in `code` references
 * a whitelisted library. Returns null if clean; an error message
 * otherwise. The whitelist (and the rationale behind each entry) lives
 * in `engines/src/ai/library-whitelist.ts` — single source of truth.
 *
 * This closes the gap noted in TODO #43K-B-review: pre-fix, the type
 * validator above failed on every dynamic import (whitelisted or not)
 * because the engines workspace has none of the whitelist libs
 * installed; bodies were silently routed through the AI-INVALID
 * fallback regardless of import legality. Bodies importing
 * non-whitelisted libs would crash at runtime in the realized backend.
 * Now we reject them at generation time.
 */
function validateImports(code: string): string | null {
  const offenders = validateImportWhitelist(code);
  if (offenders.length === 0) return null;
  return `import not in whitelist: ${offenders.join(', ')} (allowed: jsonwebtoken | bcryptjs | uuid | crypto | expr-eval)`;
}

async function validateTypeScriptTypes(code: string): Promise<string | null> {
  let ts: any;
  try {
    ts = await import('typescript');
    if (ts.default) ts = ts.default;
  } catch {
    return null;  // typescript not available — skip
  }
  try {
    const fileName = 'aiBehavior.ts';
    const sourceFile = ts.createSourceFile(fileName, code, ts.ScriptTarget.ES2022, true);
    // Mirror the strict shape used by the realized backend's tsconfig so
    // a body that passes here also passes downstream. Failing to match the
    // realized strictness means we'd ship code that fails user-side tsc
    // (defeating the whole point of generator-side validation).
    const compilerOptions: any = {
      target: ts.ScriptTarget.ES2022,
      module: ts.ModuleKind.ESNext,
      moduleResolution: ts.ModuleResolutionKind.NodeNext,
      strict: true,
      noImplicitAny: false,  // body uses `any` extensively for inputs
      noUnusedLocals: true,
      noUnusedParameters: true,
      noImplicitReturns: true,
      noUncheckedIndexedAccess: true,
      noFallthroughCasesInSwitch: true,
      noEmit: true,
      skipLibCheck: true,
      types: [],
      // Without `lib`, tsc can't resolve Promise / Array / RegExp etc., and
      // every body fails the basic-types check before it even gets to the
      // strict-null-check rules we actually want to validate.
      lib: ['lib.es2022.d.ts'],
    };
    const defaultHost = ts.createCompilerHost(compilerOptions);
    const host: any = {
      ...defaultHost,
      getSourceFile: (n: string, target: any) => {
        if (n === fileName) return sourceFile;
        return defaultHost.getSourceFile(n, target);
      },
      writeFile: () => {},
      fileExists: (n: string) => n === fileName || defaultHost.fileExists(n),
      readFile: (n: string) => (n === fileName ? code : defaultHost.readFile(n)),
    };
    const program = ts.createProgram([fileName], compilerOptions, host);
    const diagnostics = [
      ...program.getSyntacticDiagnostics(sourceFile),
      ...program.getSemanticDiagnostics(sourceFile),
    ];
    if (diagnostics.length === 0) return null;
    const formatted = diagnostics.slice(0, 5).map((d: any) => {
      const msg = ts.flattenDiagnosticMessageText(d.messageText, '\n');
      const pos = d.file && d.start !== undefined
        ? d.file.getLineAndCharacterOfPosition(d.start)
        : null;
      return pos
        ? `line ${pos.line + 1}, col ${pos.character + 1}: ${msg}`
        : msg;
    });
    return formatted.join('; ');
  } catch {
    return null;  // never let validation crash the generator
  }
}

/**
 * AI output cache — avoids re-calling Claude for unchanged steps.
 *
 * Cache key: sha256(step + modelName + operationName + inputs + promptVersion)
 * Cache location: $SPECVERSE_USER_CWD/.specverse/ai-cache/<hash>.ts
 *
 * Invalidation: automatic — any change to the step text or inputs
 * produces a new hash. The prompt version is part of the hash so
 * prompt upgrades also invalidate.
 */
const PROMPT_VERSION = '9.8.1';  // 9.8.1: isRealReference excludes member access (.X) — fixes TS6198 on alias-via-cast bodies

function cacheKey(step: string, modelName: string, operationName: string, functionName: string, inputs: string[]): string {
  const payload = JSON.stringify({ step, modelName, operationName, functionName, inputs: [...inputs].sort(), v: PROMPT_VERSION });
  return createHash('sha256').update(payload).digest('hex').slice(0, 16);
}

function cacheDir(): string {
  const cwd = process.env.SPECVERSE_USER_CWD || process.cwd();
  return join(cwd, '.specverse', 'ai-cache');
}

function cacheRead(key: string): string | null {
  const path = join(cacheDir(), `${key}.ts`);
  if (!existsSync(path)) return null;
  try {
    return readFileSync(path, 'utf8');
  } catch {
    return null;
  }
}

function cacheWrite(key: string, body: string): void {
  const dir = cacheDir();
  try {
    if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
    writeFileSync(join(dir, `${key}.ts`), body, 'utf8');
  } catch (err: any) {
    console.warn(`      [ai-cache] write failed: ${err?.message || err}`);
  }
}

/**
 * Generate AI behaviors file for a controller's unmatched steps.
 * Returns empty string if all steps matched conventions (no AI file needed).
 *
 * This is async — it calls the AI engine to generate function bodies.
 */
/** Optional matcher for orm-agnostic step matching. When present, this is
 * used in place of the prisma library's `matchStep` so that the unmatched
 * functions' names + inputs are computed by the same conventions library
 * the consuming controller is using. Without this, mongo-native (or any
 * non-prisma) controllers would diverge: they'd emit `aiBehaviors.X` calls
 * with one inputs set while this generator would emit a function with a
 * different signature. */
type StepMatcher = (step: string, ctx: any) => {
  matched: boolean;
  call?: string;
  helperMethod?: string;
  functionName?: string;
  inputs?: string[];
  resultVar?: string;
};

export default async function generateAiBehaviors(
  context: TemplateContext,
  matcher?: StepMatcher,
): Promise<string> {
  const { controller, model } = context;
  if (!controller?.actions) return '';

  // Default matcher = prisma's matchStep for back-compat. Mongo-native
  // (and any future ORM) passes its own matcher.
  const stepMatcher: StepMatcher = matcher || (matchStep as any);

  const modelName = model?.name || controller.model || 'Model';
  const modelVar = modelName.charAt(0).toLowerCase() + modelName.slice(1);
  // List of all entity-model names available in the realized output. Forwarded
  // to the AI prompt so generated function bodies can reference them by type.
  // Previously left undefined here; only the service-level path declared it,
  // which surfaced as `availableModels is not defined` in the realize warning
  // tail when the controller-level path emitted an *.ai.ts file.
  const availableModels: string[] = ((context as any).models || []).map((m: any) => m?.name).filter(Boolean);

  // Find unmatched steps across all actions.
  // We simulate step execution through matchStep() so declaredVars accumulates
  // exactly as it does in the controller generator — that way the inputs array
  // for each unmatched step matches what will actually be passed at runtime.
  const unmatchedFunctions: Array<{
    functionName: string;
    step: string;
    operationName: string;
    parameterNames: string[];
    inputs: string[];
    returns?: Record<string, string> | string;
  }> = [];

  for (const [actionName, action] of Object.entries(controller.actions) as [string, any][]) {
    const steps = action.steps || [];
    const parameterNames = Object.keys(action.parameters || {});
    const preconditions = action.requires || action.preconditions || [];

    // Simulate precondition variable declarations
    const declaredVars = new Set<string>();
    for (const pc of preconditions) {
      const match = pc.match(/^(\w+)\s+(?:exists|is\s+\w+)$/i);
      if (match) {
        const entity = match[1];
        declaredVars.add(entity.charAt(0).toLowerCase() + entity.slice(1));
      }
    }

    for (let i = 0; i < steps.length; i++) {
      const stepInput = steps[i];
      // Support both string steps and object steps { step, as, returns }
      const stepText = typeof stepInput === 'string' ? stepInput : stepInput?.step;
      const stepAs = typeof stepInput === 'object' ? stepInput?.as : undefined;
      const stepReturns = typeof stepInput === 'object' ? stepInput?.returns : undefined;

      if (typeof stepText !== 'string') continue;

      const ctx: StepContext = {
        modelName,
        prismaModel: modelVar,
        serviceName: `${modelName}Controller`,
        operationName: actionName,
        stepNum: i + 1,
        parameterNames,
        declaredVars,
        resultName: stepAs,
      };

      const result = stepMatcher(stepText, ctx);
      if (!result.matched && result.functionName) {
        // Avoid duplicate function definitions
        const existing = unmatchedFunctions.find(f => f.functionName === result.functionName);
        if (!existing) {
          unmatchedFunctions.push({
            functionName: result.functionName,
            step: stepText,
            operationName: actionName,
            parameterNames,
            inputs: result.inputs || [],
            returns: stepReturns,
          });
        }
      }
    }
  }

  if (unmatchedFunctions.length === 0) return '';

  return generateAiBehaviorsFile({
    ownerName: `${modelName}Controller`,
    unmatchedFunctions: unmatchedFunctions.map(f => ({
      ...f,
      modelName,
    })),
    availableModels,
    spec: context.spec,
  });
}

/**
 * Generate an AI behaviors file from a pre-collected list of unmatched
 * functions. Used by both the controller generator (via generateAiBehaviors
 * above) and the service generator (via collectUnmatched → this function).
 */
export async function generateAiBehaviorsFile(opts: {
  ownerName: string;
  unmatchedFunctions: Array<{
    functionName: string;
    step: string;
    operationName: string;
    parameterNames: string[];
    inputs: string[];
    returns?: Record<string, string> | string;
    modelName: string;
  }>;
  availableModels: string[];
  spec?: any;
}): Promise<string> {
  const { ownerName, unmatchedFunctions, availableModels, spec } = opts;
  if (unmatchedFunctions.length === 0) return '';

  // Start a Claude session for this owner — all unmatched functions share
  // the conversation context, so Claude builds understanding of the spec
  // across calls (mirrors app-demo's session-based AI service)
  let aiService: any = null;
  try {
    const { BehaviorAIService } = await import('@specverse/engines/ai');
    aiService = new BehaviorAIService();
    if (!aiService.isAvailable) {
      aiService = null;
    } else {
      aiService.startSession(ownerName);
    }
  } catch {
    aiService = null;
  }

  // Generate function bodies sequentially — same Claude session for all
  const functions: string[] = [];
  let cacheHits = 0;
  let cacheMisses = 0;
  for (const { functionName, step, operationName, parameterNames, inputs, returns, modelName } of unmatchedFunctions) {
    // Reserved-word safety for the function declaration. `functionName`
    // came from `toMethod(step)` in step-matching; a step text like
    // "Delete" camelCases to `delete` — a JS keyword that's illegal as
    // a function-declaration identifier. Suffix-rename the decl and
    // append a re-export so callers (`aiBehaviors.delete(...)`) still
    // resolve via the re-export's binding. Same shape as the γ-stub
    // renderers in per-owner-runner + per-action-recovery.
    const { name: safeName, reserved: nameReserved } = safeFunctionName(functionName);

    // Pure function signature + destructure are built AFTER the body so we
    // can match what the LLM actually references — strict tsc's
    // noUnusedLocals / noUnusedParameters fire on every input the body
    // didn't touch. We tolerate underuse rather than re-prompting the LLM:
    // the body is correct as-is, we just trim the wrapper to fit it.
    //
    // Three cases:
    //   - body uses some inputs: destructure only those
    //   - body uses none directly but references `input.X`: keep `input`
    //   - body doesn't touch input at all: prefix signature with `_` so
    //     noUnusedParameters is happy
    /** Strip string/template/comment content so identifier-reference checks
     * don't match `reward` inside a kebab-case string like `'reward-not-granted'`
     * or `// reward stays as text`. We replace the literal contents with
     * spaces so positions stay roughly aligned in case of debugging.
     *
     * Critical: template-string `${...}` interpolations are EXPRESSIONS, not
     * literal text — they really do reference `input.X` etc. Strip only the
     * non-interpolation segments of the template so the expression content
     * survives the regex check. */
    const stripLiteralsAndComments = (src: string): string => {
      let out = src
        .replace(/\/\*[\s\S]*?\*\//g, (m) => ' '.repeat(m.length))
        .replace(/\/\/[^\n]*/g, (m) => ' '.repeat(m.length))
        .replace(/(['"])(?:\\.|(?!\1).)*\1/g, (m) => m[0] + ' '.repeat(m.length - 2) + m[0]);
      // Strip text INSIDE backticks but preserve `${...}` expressions.
      // We rebuild template contents by replacing literal-text spans
      // (everything between `\`` and `${`, between `}` and `${`, or
      // between `}` and the closing `\``) with spaces.
      out = out.replace(/`((?:\\.|\$\{[^}]*\}|(?!`).)*)`/g, (full, content) => {
        // Walk the content, copy `${...}` segments verbatim, replace other
        // characters with spaces.
        let result = '`';
        let i = 0;
        while (i < content.length) {
          if (content[i] === '\\' && i + 1 < content.length) {
            result += '  '; i += 2; continue;
          }
          if (content[i] === '$' && content[i + 1] === '{') {
            // copy interpolation verbatim including the `${...}` markers
            const end = content.indexOf('}', i);
            const slice = end >= 0 ? content.slice(i, end + 1) : content.slice(i);
            result += slice;
            i += slice.length;
            continue;
          }
          result += ' ';
          i++;
        }
        return result + '`';
      });
      return out;
    };

    /** Detect whether the body manages its own input access (so adding our
     * own destructure would either duplicate-declare or leave us with
     * unused-locals). Three shapes the LLM emits:
     *   1. `const { X, Y } = input;`           (destructure)
     *   2. `const X = input.X;`                (per-property)
     *   3. `const _X = input.X;`               (per-property with rename)
     * If ANY of these appear, the body is its own input setup and we
     * should leave it alone. */
    const bodyHandlesInputItself = (body: string): boolean => {
      const codeOnly = stripLiteralsAndComments(body);
      // Destructure: `const { ... } = input;`
      if (/(?:const|let|var)\s*\{[^}]+\}\s*=\s*input\b/.test(codeOnly)) return true;
      // Per-property access: any `(const|let|var) <name> = input.<name>`
      if (/(?:const|let|var)\s+[A-Za-z_$][\w$]*\s*=\s*input\.[A-Za-z_$][\w$]*/.test(codeOnly)) return true;
      return false;
    };

    /** Does the body actually USE the local variable `n` (vs only mention
     * it as an object-literal property name like `step1Result:` in a
     * return object, OR as a member access like `inp.token`)? A real
     * usage is `n` not preceded by `.` (which would make it a property
     * access on some other object) and not followed by `:` (object-literal
     * key). The `.` in the lookbehind exclusion catches the case where
     * the LLM aliases input via `const inp = input as any;` and then
     * accesses `inp.token` etc. — the destructured names would otherwise
     * count as referenced and we'd emit an unused-locals destructure. */
    const isRealReference = (n: string, codeOnly: string): boolean => {
      const escaped = n.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
      const re = new RegExp(`(?<![A-Za-z0-9_$.])${escaped}(?![A-Za-z0-9_$:])`, 'g');
      return re.test(codeOnly);
    };

    const buildSignatureAndDestructure = (body: string): { signature: string; destructure: string } => {
      if (inputs.length === 0) {
        // No declared inputs → the param is only ever referenced if the body
        // uses `input` directly. Mirror the non-empty path and underscore-prefix
        // when unused, so the wrapper doesn't emit an unused `input` param
        // (strict-tsc TS6133 — which otherwise fails cache re-validation and
        // forces a needless LLM re-emit). Covers reserved-word renames whose
        // step text carries no inputs (e.g. a function named `return`).
        const codeOnly = stripLiteralsAndComments(body);
        const usesInputObject = /(?<![A-Za-z0-9_$])input(?![A-Za-z0-9_$])/.test(codeOnly);
        return { signature: `${usesInputObject ? 'input' : '_input'}: Record<string, never>`, destructure: '' };
      }
      const codeOnly = stripLiteralsAndComments(body);
      const referenced = inputs.filter((n) => isRealReference(n, codeOnly));
      const usesInputObject = /(?<![A-Za-z0-9_$])input(?![A-Za-z0-9_$])/.test(codeOnly);
      const sigName = (referenced.length > 0 || usesInputObject) ? 'input' : '_input';
      const sig = `${sigName}: { ${inputs.map(n => `${n}: any`).join('; ')} }`;
      // If the body already manages its own input access (destructure or
      // per-property) OR accesses input.X directly, don't add a wrapper
      // destructure — would leave us with unused locals (TS6198) or
      // duplicate declarations (TS2451).
      const bodyAccessesInputProps = /(?<![A-Za-z0-9_$])input\.[A-Za-z_$]/.test(codeOnly);
      const destructure = (referenced.length > 0 && !bodyHandlesInputItself(body) && !bodyAccessesInputProps)
        ? `  const { ${referenced.join(', ')} } = input;`
        : '';
      return { signature: sig, destructure };
    };

    // Build return type from spec declaration (if provided)
    // returns can be:
    //   - a string: "number" → Promise<number>
    //   - an object: { amount: "number", rate: "number" } → Promise<{ amount: number; rate: number }>
    //   - undefined → Promise<any>
    let returnType = 'any';
    if (typeof returns === 'string') {
      returnType = returns;
    } else if (returns && typeof returns === 'object') {
      const fields = Object.entries(returns).map(([k, v]) => `${k}: ${v}`).join('; ');
      returnType = `{ ${fields} }`;
    }

    // Build the validation harness EXACTLY as the function is finally
    // emitted: real signature + the wrapper-added destructure + real
    // return type. Pre-fix the harness was a bare
    // `export async function X(input: any)` with NO destructure — but
    // the LLM is explicitly told (see the retry-hint prose below) to
    // emit ONLY the body that goes AFTER `const { ... } = input;`. So a
    // valid body using bare input references (`return userId + 1`)
    // would spuriously fail re-validation with "Cannot find name
    // 'userId'", forcing regeneration / STUB. The harness must match
    // emission or the gate rejects its own correct output.
    const buildTestCode = (rawBody: string): string => {
      const { signature, destructure } = buildSignatureAndDestructure(rawBody);
      const destructureLine = destructure ? destructure + '\n' : '';
      return `export async function ${safeName}(${signature}): Promise<${returnType}> {\n${destructureLine}${rawBody}\n}`;
    };

    // Check cache first — skip Claude if we've generated this exact step before
    const key = cacheKey(step, modelName, operationName, functionName, inputs);
    let body: string | null = cacheRead(key);
    let source: 'AI-CACHED' | 'AI-GENERATED' | 'AI-INVALID' | 'STUB' = body ? 'AI-CACHED' : 'STUB';
    if (body) {
      // Validate cache entry — a previously valid entry may have been
      // corrupted on disk OR the validation rules may have tightened
      // (e.g. tsc type checks added). A cache hit must still be re-validated
      // through both gates so old bodies don't leak past the new bar.
      const testCode = buildTestCode(body);
      const syntaxError = await validateTypeScript(testCode);
      const typeError = syntaxError ? null : await validateTypeScriptTypes(testCode);
      const importError = (syntaxError || typeError) ? null : validateImports(testCode);
      if (syntaxError || typeError || importError) {
        console.warn(`      [ai-validate] cached ${functionName} failed validation: ${syntaxError || typeError || importError}`);
        body = null;  // Force regeneration
        source = 'STUB';
      } else {
        cacheHits++;
      }
    }

    if (!body && aiService) {
      cacheMisses++;
      try {
        body = await aiService.generateBehavior({
          step,
          modelName,
          operationName,
          functionName,
          parameterNames: inputs,  // the actual inputs to the pure function
          availableModels,
          spec,
          returnType,  // Pass declared return type to Claude
        });

        if (body) {
          // Two-tier validation:
          //   1. esbuild syntax check — fast, catches unbalanced braces, etc.
          //   2. tsc type check — slower, catches `string | undefined` index
          //      access, unused locals, and undefined references the LLM
          //      regularly produces. On tsc failure, we re-prompt the LLM
          //      with the error message appended; only ONE retry to bound
          //      cost. If that still fails we keep the body but mark it
          //      AI-INVALID so the user sees it needs review.
          const testCode = buildTestCode(body);
          const syntaxError = await validateTypeScript(testCode);
          if (syntaxError) {
            console.warn(`      [ai-validate] ${functionName} has syntax error: ${syntaxError}`);
            body = `// AI-generated code failed validation: ${syntaxError}\n  // Step: ${step}\n  throw new Error('AI behavior has invalid syntax — see comment above');`;
            source = 'AI-INVALID';
          } else {
            const typeError = await validateTypeScriptTypes(testCode);
            // Whitelist gate runs after type check so error precedence is
            // syntax > types > imports (most-actionable error first).
            const importError = typeError ? null : validateImports(testCode);
            if (typeError || importError) {
              console.warn(`      [ai-validate] ${functionName} ${typeError ? 'type errors: ' + typeError : 'whitelist violation: ' + importError}`);
              try {
                // Build a retry hint that targets whichever gate failed.
                // Mixed failures (type AND import) get both messages.
                const errorParts: string[] = [];
                if (typeError) errorParts.push(`TypeScript type errors:\n${typeError}`);
                if (importError) errorParts.push(`Import-whitelist violation: ${importError}.\nOnly these libraries may be dynamic-imported: jsonwebtoken, bcryptjs, uuid, crypto, expr-eval. Anything else is forbidden — throw an Error if the step needs an unsupported library.`);
                const retryHint = `Your previous output had problems:\n\n${errorParts.join('\n\n')}\n\nFix these specifically — common type-error causes:\n- RegExp match indices are 'string | undefined'; use non-null assertion or extract to a typed variable\n- Strict null checks: guard or assert before use\n- Don't declare locals you never reference\n\nIMPORTANT: The destructure line \`const { ... } = input;\` is added by the wrapper, NOT by you. Output ONLY the function body that goes AFTER that line — do not repeat the destructure or you will produce duplicate-declaration errors.`;
                const retried = await aiService.generateBehavior({
                  step: `${step}\n\n${retryHint}`,
                  modelName,
                  operationName,
                  functionName,
                  parameterNames: inputs,
                  availableModels,
                  spec,
                  returnType,
                });
                if (retried) {
                  const retryCode = buildTestCode(retried);
                  const retrySyntaxError = await validateTypeScript(retryCode);
                  const retryTypeError = retrySyntaxError ? null : await validateTypeScriptTypes(retryCode);
                  const retryImportError = (retrySyntaxError || retryTypeError) ? null : validateImports(retryCode);
                  if (!retrySyntaxError && !retryTypeError && !retryImportError) {
                    body = retried;
                    source = 'AI-GENERATED';
                    cacheWrite(key, body);
                  } else {
                    // Retry didn't help — keep the original body so the user can fix
                    // manually, but mark it as INVALID so it stands out.
                    source = 'AI-INVALID';
                    cacheWrite(key, body);
                  }
                } else {
                  source = 'AI-INVALID';
                  cacheWrite(key, body);
                }
              } catch {
                source = 'AI-INVALID';
                cacheWrite(key, body);
              }
            } else {
              source = 'AI-GENERATED';
              cacheWrite(key, body);
            }
          }
        }
      } catch {
        // Fall through to stub
      }
    }

    if (!body) {
      body = `  throw new Error('Not implemented: ${functionName} — see behaviors/${modelName}Controller.ai.ts');`;
    } else {
      // Indent body to match function scope
      body = body.split('\n').map(line => line ? '  ' + line : line).join('\n');
    }

    const inputsDoc = inputs.length > 0
      ? ` * Inputs: ${inputs.join(', ')}\n`
      : '';
    const returnsDoc = returnType !== 'any'
      ? ` * Returns: ${returnType}\n`
      : '';

    const reExportLine = nameReserved
      ? `\nexport { ${safeName} as ${functionName} };`
      : '';
    functions.push(`/**
 * ${functionName}
 *
 * Spec step: "${step}"
 * Called by: ${ownerName}.${operationName}()
${inputsDoc}${returnsDoc} * Source: ${source}
 * Generated: ${new Date().toISOString().split('T')[0]}
 *
 * PURE FUNCTION — no database access, no event publishing, no external services.
 * All data comes in via \`input\`; all effects happen in the calling controller.
 * ${source === 'AI-GENERATED'
   ? 'AI-generated implementation. Review and test before deploying.'
   : source === 'AI-CACHED'
   ? 'Restored from AI cache (.specverse/ai-cache/). Delete cache entry to regenerate.'
   : source === 'AI-INVALID'
   ? 'AI returned code with syntax errors — function throws at runtime. Fix or regenerate.'
   : 'STUB — Claude CLI unavailable. Install Claude Code or implement manually.'}
 */
${nameReserved ? 'async' : 'export async'} function ${safeName}(${(() => {
  const { signature: sig } = buildSignatureAndDestructure(body);
  return sig;
})()}): Promise<${returnType}> {
${(() => {
  const { destructure } = buildSignatureAndDestructure(body);
  return destructure ? destructure + '\n' : '';
})()}${body}
}${reExportLine}`);
  }

  // End the session
  if (aiService?.endSession) aiService.endSession();

  // Report cache stats to stdout — picked up by realize pipeline
  if (cacheHits > 0 || cacheMisses > 0) {
    console.log(`      AI cache: ${cacheHits} hit(s), ${cacheMisses} miss(es) for ${ownerName}`);
  }

  return `/**
 * ${ownerName} — AI-Generated Behaviors
 *
 * ⚠️  THIS FILE CONTAINS STUBS FOR STEPS THAT NEED IMPLEMENTATION
 *
 * These functions could not be generated from convention patterns.
 * They are called by ${ownerName} when executing operations.
 *
 * PURE-FUNCTION CONTRACT — these bodies must NOT touch the database, the
 * event bus, or any external service. Persistence and side effects happen
 * in the calling controller; this file does pure transformations only.
 *
 * Options for each function:
 * - Implement manually (recommended for business-critical logic)
 * - Use AI generation: specverse ai generate <function>
 * - Refactor the spec step to use a convention pattern
 *
 * Convention patterns that ARE auto-generated by the realize engine (no
 * AI needed) — these are emitted inline in the controller, not here:
 *   "Find {Model} by {field}" → ORM-specific find call
 *   "Create {Model}"          → ORM-specific create call
 *   "Update {Model} {field} to {value}"
 *   "Delete {Model}"
 *   "Transition {Model} to {state}"
 *   "Count {Model}s per {Group}"
 *   See the ORM's step-conventions module for the full list.
 *
 * Generated: ${new Date().toISOString().split('T')[0]}
 */

${functions.join('\n\n')}
`;
}

/**
 * Regenerate a single owner's AI behaviors file, optionally targeting one
 * function for cache bypass.
 *
 * Used by `specverse ai regenerate <Owner.fn>`. Works on both controllers
 * (actions) and services (operations). When `targetFunction` is provided,
 * its cache entry is deleted before regeneration so Claude is re-consulted
 * for that one function; all other functions in the owner reuse their
 * cache. When `targetFunction` is null, the whole file is rebuilt without
 * touching the cache.
 *
 * `spec` should be the inferred spec (normalized shape: `controllers` and
 * `services` are either objects keyed by name or arrays with `.name`).
 */
export async function regenerateAiBehaviors(opts: {
  spec: any;
  ownerName: string;
  targetFunction: string | null;
  outputDir: string;
  allFunctions?: boolean;
}): Promise<{
  filePath: string;
  targetFound: boolean;
  unmatchedCount: number;
  cacheCleared: number;
}> {
  const { spec, ownerName, targetFunction, outputDir, allFunctions } = opts;

  // Resolve the owner — could be a controller (has .actions) or a service (has .operations).
  // Accept both the "object keyed by name" and "array with .name" shapes.
  const findOwner = (collection: any): any => {
    if (!collection) return null;
    if (Array.isArray(collection)) return collection.find((c: any) => c?.name === ownerName) || null;
    return collection[ownerName] || null;
  };
  const controller = findOwner(spec.controllers);
  const service = findOwner(spec.services);
  if (!controller && !service) {
    throw new Error(
      `Owner '${ownerName}' not found in spec. Looked in controllers and services — ` +
      `check that the name matches exactly (case-sensitive) and that you're pointing ` +
      `at an inferred spec (try 'specs/main-inferred.specly').`
    );
  }

  // Build the unmatched-function list the same way realize does.
  type UnmatchedFn = {
    functionName: string;
    step: string;
    operationName: string;
    parameterNames: string[];
    inputs: string[];
    returns?: Record<string, string> | string;
    modelName: string;
  };
  const unmatched: UnmatchedFn[] = [];
  const allModels: string[] = Object.keys(spec.models || {});

  if (controller) {
    const modelName = controller.model || controller.modelReference || ownerName.replace(/Controller$/, '');
    const modelVar = modelName.charAt(0).toLowerCase() + modelName.slice(1);
    const actions = controller.actions || {};
    for (const [actionName, action] of Object.entries(actions) as [string, any][]) {
      const steps = action.steps || [];
      const parameterNames = Object.keys(action.parameters || {});
      const declaredVars = new Set<string>();
      const preconditions = action.requires || action.preconditions || [];
      for (const pc of preconditions) {
        const m = typeof pc === 'string' ? pc.match(/^(\w+)\s+(?:exists|is\s+\w+)$/i) : null;
        if (m) declaredVars.add(m[1].charAt(0).toLowerCase() + m[1].slice(1));
      }
      for (let i = 0; i < steps.length; i++) {
        const stepInput = steps[i];
        const stepText = typeof stepInput === 'string' ? stepInput : stepInput?.step;
        const stepReturns = typeof stepInput === 'object' ? stepInput?.returns : undefined;
        if (typeof stepText !== 'string') continue;
        const ctx: StepContext = {
          modelName,
          prismaModel: modelVar,
          serviceName: ownerName,
          operationName: actionName,
          stepNum: i + 1,
          parameterNames,
          declaredVars,
          resultName: typeof stepInput === 'object' ? stepInput?.as : undefined,
        };
        const result = matchStep(stepText, ctx);
        if (!result.matched && result.functionName) {
          if (!unmatched.find(f => f.functionName === result.functionName)) {
            unmatched.push({
              functionName: result.functionName,
              step: stepText,
              operationName: actionName,
              parameterNames,
              inputs: result.inputs || [],
              returns: stepReturns,
              modelName,
            });
          }
        }
      }
    }
  } else if (service) {
    // Service operations share the same matchStep logic but without a model prefix.
    const modelName = service.name?.replace(/Service$/, '') || ownerName;
    const operations = service.operations || {};
    const opEntries: [string, any][] = Array.isArray(operations)
      ? operations.map((op: any) => [op.name, op])
      : Object.entries(operations);
    for (const [opName, operation] of opEntries) {
      const steps = operation.steps || operation.implementation?.steps || [];
      const parameterNames = Object.keys(operation.parameters || {});
      const declaredVars = new Set<string>();
      const preconditions = operation.requires || operation.preconditions || [];
      for (const pc of preconditions) {
        const m = typeof pc === 'string' ? pc.match(/^(\w+)\s+(?:exists|is\s+\w+)$/i) : null;
        if (m) declaredVars.add(m[1].charAt(0).toLowerCase() + m[1].slice(1));
      }
      for (let i = 0; i < steps.length; i++) {
        const stepInput = steps[i];
        const stepText = typeof stepInput === 'string' ? stepInput : stepInput?.step;
        const stepReturns = typeof stepInput === 'object' ? stepInput?.returns : undefined;
        if (typeof stepText !== 'string') continue;
        const ctx: StepContext = {
          modelName,
          prismaModel: modelName.charAt(0).toLowerCase() + modelName.slice(1),
          serviceName: ownerName,
          operationName: opName,
          stepNum: i + 1,
          parameterNames,
          declaredVars,
          resultName: typeof stepInput === 'object' ? stepInput?.as : undefined,
        };
        const result = matchStep(stepText, ctx);
        if (!result.matched && result.functionName) {
          if (!unmatched.find(f => f.functionName === result.functionName)) {
            unmatched.push({
              functionName: result.functionName,
              step: stepText,
              operationName: opName,
              parameterNames,
              inputs: result.inputs || [],
              returns: stepReturns,
              modelName: ownerName,
            });
          }
        }
      }
    }
  }

  if (unmatched.length === 0) {
    throw new Error(
      `No AI behaviors to regenerate for '${ownerName}'. All of its steps match ` +
      `built-in convention patterns (see step-conventions.ts). If you expected an ` +
      `AI-generated function, double-check the spec step text.`
    );
  }

  // Find the target function (unless regenerating the whole owner)
  const targetFound = !targetFunction || unmatched.some(f => f.functionName === targetFunction);
  if (targetFunction && !targetFound) {
    const known = unmatched.map(f => f.functionName).join(', ');
    throw new Error(
      `Function '${targetFunction}' not found among '${ownerName}' unmatched functions. ` +
      `Known: ${known || '(none)'}.`
    );
  }

  // Delete the cache entry for the target function (or all of them if --all)
  // so Claude is re-consulted. Other cache entries are untouched so other
  // functions reuse their previous output.
  let cacheCleared = 0;
  const functionsToInvalidate = allFunctions
    ? unmatched.map(f => f.functionName)
    : targetFunction ? [targetFunction] : [];
  for (const fnName of functionsToInvalidate) {
    const fn = unmatched.find(f => f.functionName === fnName)!;
    const key = cacheKey(fn.step, fn.modelName, fn.operationName, fn.functionName, fn.inputs);
    const path = join(cacheDir(), `${key}.ts`);
    if (existsSync(path)) {
      try {
        unlinkSync(path);
        cacheCleared++;
      } catch (err: any) {
        console.warn(`      [ai-cache] failed to delete ${path}: ${err?.message || err}`);
      }
    }
  }

  // Regenerate the full file (cache hits reuse output for non-target functions)
  const code = await generateAiBehaviorsFile({
    ownerName,
    unmatchedFunctions: unmatched,
    availableModels: allModels,
    spec,
  });

  const filePath = join(outputDir, 'backend', 'src', 'behaviors', `${ownerName}.ai.ts`);
  const dir = dirname(filePath);
  if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
  writeFileSync(filePath, code, 'utf8');

  return {
    filePath,
    targetFound,
    unmatchedCount: unmatched.length,
    cacheCleared,
  };
}
