/**
 * 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 { matchStep, type StepContext } from './step-conventions.js';
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;
  }
}

/**
 * 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.1.0';

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.
 */
export default async function generateAiBehaviors(context: TemplateContext): Promise<string> {
  const { controller, model } = context;
  if (!controller?.actions) return '';

  const modelName = model?.name || controller.model || 'Model';
  const modelVar = modelName.charAt(0).toLowerCase() + modelName.slice(1);

  // 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 = matchStep(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) {
    // Pure function signature: all inputs as a typed destructured object
    const signature = inputs.length > 0
      ? `input: { ${inputs.map(n => `${n}: any`).join('; ')} }`
      : 'input: Record<string, never>';

    // Inside the body, destructure for readability
    const destructure = inputs.length > 0
      ? `  const { ${inputs.join(', ')} } = input;`
      : '';

    // 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} }`;
    }

    // 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 validator rules may have changed
      const testCode = `export async function ${functionName}(input: any): Promise<any> {\n${body}\n}`;
      const validationError = await validateTypeScript(testCode);
      if (validationError) {
        console.warn(`      [ai-validate] cached ${functionName} failed validation: ${validationError}`);
        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) {
          // Validate the generated body as a standalone function
          const testCode = `export async function ${functionName}(input: any): Promise<any> {\n${body}\n}`;
          const validationError = await validateTypeScript(testCode);
          if (validationError) {
            console.warn(`      [ai-validate] ${functionName} has syntax error: ${validationError}`);
            // Don't cache invalid output; treat as failed generation
            body = `// AI-generated code failed validation: ${validationError}\n  // Step: ${step}\n  throw new Error('AI behavior has invalid syntax — see comment above');`;
            source = 'AI-INVALID';
          } 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`
      : '';

    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.'}
 */
export async function ${functionName}(${signature}): Promise<${returnType}> {
${destructure ? destructure + '\n' : ''}${body}
}`);
  }

  // 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.
 *
 * 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 (no AI needed):
 *   "Find {Model} by {field}" → prisma.model.findUniqueOrThrow(...)
 *   "Create {Model}" → prisma.model.create(...)
 *   "Update {Model} {field} to {value}" → prisma.model.update(...)
 *   "Delete {Model}" → prisma.model.delete(...)
 *   "Transition {Model} to {state}" → prisma.model.update({ status: ... })
 *   "Count {Model}s per {Group}" → prisma.model.groupBy(...)
 *   See step-conventions.ts for the full list.
 *
 * Generated: ${new Date().toISOString().split('T')[0]}
 */

import { PrismaClient } from '@prisma/client';

const prisma = new PrismaClient();

${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,
  };
}
