/**
 * Step Conventions for MongoDB native driver — mirrors the prisma library's
 * pattern set but emits native-driver collection code instead of `prisma.X`.
 *
 * Each convention maps a natural-language step pattern to a generated code
 * block. Patterns are tried in order; first match wins. Unmatched steps
 * fall through to the AI-behaviors layer (a separate concern).
 *
 * The emitted code assumes the controller's surrounding scope provides:
 *   - `getCollection(name)` (from `../db/mongoClient.js`)
 *   - `byId(id)` filter helper for `_id`-shaped queries
 *   - `eventBus` for `publish`
 * These are imported by the mongo-native controller-generator regardless,
 * so step-emitted code compiles inline.
 */

import {
  toMethod,
  toVar,
  matchAgainstConventions,
  type SharedConvention,
  type SharedStepContext,
} from '../_shared/step-matching.js';
import { safeEntityName } from '@specverse/types';

export type MongoStepConvention = SharedConvention<MongoStepContext>;

export interface MongoStepContext extends SharedStepContext {
  /** Collection name for the model (lowercased + 's' or storage.collection). */
  collectionName: string;
  /** The full model registry (name → ModelSpec) so conventions can read
   * attributes for default values, types, and FK target collections. */
  models?: Record<string, any>;
}

/**
 * Compute sensible default values for a model's attributes — used by
 * mechanical create/insertMany conventions to fill required fields the
 * action's `args` doesn't supply.
 *
 * Defaults are derived from the spec's attribute declarations:
 *   - `default=<value>` annotation → that value (literal or expression)
 *   - `Integer required` with `min=N` → N
 *   - `Integer required` no min → 0
 *   - `String required` → '""' (empty)
 *   - `Boolean required` → false
 *   - `DateTime auto=now` or with `auto=now` → new Date().toISOString()
 *   - Foreign keys (fields ending in `Id` matching another model) → resolved
 *     from a declared variable of that type (e.g. `userId` ← `user._id`)
 *   - Anything optional or already present in args → omitted
 *
 * Returns an array of `key: expression` strings ready to inline into an
 * object-literal record body. Empty if the model isn't in the registry.
 */
export function deriveModelDefaults(
  modelName: string,
  ctx: MongoStepContext,
): string[] {
  if (!ctx.models) return [];
  const model = ctx.models[modelName] || ctx.models[modelName.charAt(0).toUpperCase() + modelName.slice(1)];
  if (!model) return [];
  const attrs = model.attributes;
  if (!attrs) return [];
  const list = Array.isArray(attrs)
    ? attrs.map((a: any) => [a.name, a])
    : Object.entries(attrs);

  const declaredVars = ctx.declaredVars || new Set();
  const out: string[] = [];

  // Names emitted explicitly by the convention's record-tail (createdAt /
   // updatedAt) — skip in defaults to avoid TS1117 duplicate-property errors.
  const conventionManaged = new Set(['createdAt', 'updatedAt', 'id']);
  for (const [name, attr] of list as [string, any][]) {
    if (!name) continue;
    if (conventionManaged.has(name)) continue;
    // Skip optional fields without explicit defaults
    const required = !!attr.required;
    const hasDefault = attr.default !== undefined;
    if (!required && !hasDefault) continue;
    // Skip if already supplied via spread args (caller overlays)
    // — handled at convention call site by spreading args first.
    if (hasDefault) {
      out.push(`${name}: ${formatDefault(attr.default, attr.type)}`);
      continue;
    }
    // Required field — derive a default from type
    const type = (attr.type || 'String').toLowerCase();
    if (type === 'integer' || type === 'int' || type === 'number' || type === 'float') {
      const min = attr.min ?? 0;
      out.push(`${name}: ${min}`);
    } else if (type === 'boolean') {
      out.push(`${name}: false`);
    } else if (type === 'datetime' || type === 'date') {
      if (attr.auto === 'now') {
        out.push(`${name}: new Date().toISOString()`);
      } else {
        out.push(`${name}: new Date().toISOString()`);
      }
    } else {
      // String / UUID / Text / etc. — try FK resolution first.
      // Exclude self-reference (current model would FK-resolve to itself
      // and emit a self-loop). The current model name's lowercase form
      // is the conventionally-bound variable name.
      const selfVar = modelName.charAt(0).toLowerCase() + modelName.slice(1);
      const fkMatch = name.match(/^(.+)Id$/);
      if (fkMatch && fkMatch[1] !== selfVar && declaredVars.has(fkMatch[1])) {
        out.push(`${name}: (${fkMatch[1]} as any)?._id ?? (${fkMatch[1]} as any)?.id`);
      } else {
        out.push(`${name}: ''`);
      }
    }
  }
  return out;
}

function formatDefault(value: any, type?: string): string {
  if (value === null || value === undefined) return 'null';
  if (typeof value === 'boolean') return String(value);
  if (typeof value === 'number') return String(value);
  if (typeof value === 'string') {
    // Numeric defaults are sometimes parsed as strings
    if (/^-?\d+(\.\d+)?$/.test(value)) return value;
    if (value === 'true' || value === 'false') return value;
    if (value === 'now' && (type === 'DateTime' || type === 'Date')) {
      return 'new Date().toISOString()';
    }
    return JSON.stringify(value);
  }
  return JSON.stringify(value);
}

function toCollection(modelName: string): string {
  return modelName.toLowerCase() + 's';
}

/** Resolve a value reference in step text to a TS expression. */
function resolveValue(rawValue: string, ctx: MongoStepContext): string {
  const value = rawValue.trim().replace(/^['"]|['"]$/g, '');
  if (/^(current\s*time|now|timestamp)$/i.test(value)) return 'new Date().toISOString()';
  if (value === 'true' || value === 'false') return value;
  if (/^-?\d+(\.\d+)?$/.test(value)) return value;

  const declared = ctx.declaredVars || new Set();
  const params = ctx.parameterNames || [];

  const rootMatch = value.match(/^([a-zA-Z_][a-zA-Z0-9_]*)(\.[a-zA-Z0-9_.]+)?$/);
  if (rootMatch) {
    const root = rootMatch[1];
    if (declared.has(root) || params.includes(root)) return value;
    if (params.length > 0) return `args.${value}`;
  }

  // Fallback: quoted string literal so generated code stays valid TS
  if (/\s/.test(value)) return `/* TODO: resolve "${value}" */ null`;
  return `'${value.replace(/'/g, "\\'")}'`;
}

/** Find the most-recent step{N}Result variable in declaredVars — used as
 * the source for `Persist X` / `Save X` patterns where the previous step
 * (an AI pure transform) computed the record to write. */
function mostRecentStepResult(ctx: MongoStepContext): string | null {
  const declared = Array.from(ctx.declaredVars || []);
  const stepResults = declared.filter((v) => /^step\d+Result$/.test(v))
    .sort((a, b) => parseInt(b.slice(4), 10) - parseInt(a.slice(4), 10));
  return stepResults[0] ?? null;
}

function pascal(model: string): string {
  return model.charAt(0).toUpperCase() + model.slice(1);
}

export const MONGO_STEP_CONVENTIONS: MongoStepConvention[] = [
  // --- Find / Lookup by single field ---
  // Matches: "Look up X by Y", "Find X by Y", "Find X by Y or fail with 404"
  {
    name: 'find-by-field',
    pattern: /^(?:look\s+up|find|fetch|get)\s+(\w+)\s+by\s+(\w+)(?:\s+or\s+fail.*)?$/i,
    generateCall: (m, ctx) => {
      const model = m[1];
      const field = m[2];
      const modelVar = safeEntityName(model);
      if (!modelVar) return '';
      const collection = toCollection(model);
      const params = ctx.parameterNames || [];
      const declared = ctx.declaredVars || new Set();
      const idVal = field === 'id'
        ? (params.includes(`${modelVar}Id`) ? `args.${modelVar}Id` : 'args.id')
        : (params.includes(field) ? `args.${field}` : `args.${field}`);

      if (declared.has(modelVar)) {
        return `    // Step ${ctx.stepNum}: Find ${model} by ${field} (already loaded)`;
      }
      declared.add(modelVar);
      const failOnMissing = /or\s+fail/i.test(m[0]);
      // Emit as `let` so subsequent conditional-create steps can reassign
      // the variable when the lookup returned null.
      // Also surface `id` (string) alongside `_id` (ObjectId) for AI-body
      // compatibility — LLMs trained on prisma routinely check `.id`.
      return `    // Step ${ctx.stepNum}: Find ${model} by ${field}
    const ${modelVar}_collection = await getCollection('${collection}');
    let ${modelVar} = await ${modelVar}_collection.findOne({ ${field}: ${idVal} });
    if (${modelVar} && !(${modelVar} as any).id && (${modelVar} as any)._id) (${modelVar} as any).id = String((${modelVar} as any)._id);${failOnMissing ? `
    if (!${modelVar}) throw new Error('${model} not found');` : ''}`;
    },
  },

  // --- Find by composite (two fields) ---
  // Matches: "Look up X by Y and Z", "Find X by Y and Z"
  {
    name: 'find-by-composite',
    pattern: /^(?:look\s+up|find|fetch|get)(?:\s+existing)?\s+(\w+)\s+by\s+(\w+)\s+and\s+(\w+)$/i,
    generateCall: (m, ctx) => {
      const model = m[1];
      const f1 = m[2];
      const f2 = m[3];
      const modelVar = safeEntityName(model);
      if (!modelVar) return '';
      const collection = toCollection(model);
      const declared = ctx.declaredVars || new Set();
      if (declared.has(modelVar)) {
        return `    // Step ${ctx.stepNum}: Find ${model} by ${f1} and ${f2} (already loaded)`;
      }
      declared.add(modelVar);
      // `let` for downstream conditional-create reassignment.
      // Field-source resolution: a field like `userId` typically comes
      // from a prior find/create (`user._id`), not from the action's
      // request body. Check declaredVars first — if there's a model var
      // matching the field's prefix (userId → user, gameId → game),
      // use its `_id`. Otherwise fall back to args. Exclude the CURRENT
      // model var (we're loading it now; it can't reference itself).
      const resolveFieldSource = (f: string) => {
        const stripIdMatch = f.match(/^(.+)Id$/);
        if (stripIdMatch) {
          const candidate = stripIdMatch[1];
          if (candidate !== modelVar && declared.has(candidate)) {
            return `(${candidate} as any)?._id ?? (${candidate} as any)?.id`;
          }
        }
        return `args.${f}`;
      };
      const f1Src = resolveFieldSource(f1);
      const f2Src = resolveFieldSource(f2);
      return `    // Step ${ctx.stepNum}: Find ${model} by ${f1} and ${f2}
    const ${modelVar}_collection = await getCollection('${collection}');
    let ${modelVar} = await ${modelVar}_collection.findOne({ ${f1}: ${f1Src}, ${f2}: ${f2Src} });
    if (${modelVar} && !(${modelVar} as any).id && (${modelVar} as any)._id) (${modelVar} as any).id = String((${modelVar} as any)._id);`;
    },
  },

  // --- Create model record ---
  // Matches: "Create X", "Create new X with ...", "Create existing X with ...".
  // Leading-adjective skip kept in sync with NL_LEADING_ADJECTIVES in
  // _shared/step-matching.ts. Reserved-word safety net via
  // safeEntityName from @specverse/types — returns null when the extracted noun would emit a TS reserved word
  // (e.g. mis-parsed adjectives) by returning '' so the matcher falls
  // through to the AI [WRITE] fallback.
  {
    name: 'create',
    pattern: /^create\s+(?:(?:new|existing|current|the|a|an)\s+)?(\w+)(?:\s+(?:with\s+)?(.+))?/i,
    generateCall: (m, ctx) => {
      const model = m[1];
      const modelVar = safeEntityName(model);
      if (!modelVar) return '';
      const collection = toCollection(model);
      const params = ctx.parameterNames || [];
      const declared = ctx.declaredVars || new Set();
      declared.add(modelVar);
      const dataExpr = params.length > 0 ? `{ ${params.join(', ')} }` : 'args';
      return `    // Step ${ctx.stepNum}: Create ${model}
    const ${modelVar}_collection = await getCollection('${collection}');
    const ${modelVar}_inserted = await ${modelVar}_collection.insertOne(${dataExpr});
    const ${modelVar} = { _id: ${modelVar}_inserted.insertedId, ...${dataExpr} };`;
    },
  },

  // --- Update specific field on previously-loaded model ---
  // Matches: "Update X field to value". Only fires when X has been
  // declared by a prior matched find — otherwise the model var doesn't
  // exist in scope and tsc fails. Falling through to the unmatched path
  // lets the AI layer handle it.
  {
    name: 'update-field',
    pattern: /^update\s+(?:(?:new|existing|current|the|a|an)\s+)?(\w+)\s+(\w+)\s+to\s+(.+)/i,
    generateCall: (m, ctx) => {
      const model = m[1];
      const modelVar = safeEntityName(model);
      if (!modelVar) return '';
      const field = m[2];
      const rawValue = m[3];
      if (!ctx.declaredVars?.has(modelVar)) return '';  // signal "not really matched"
      const collection = toCollection(model);
      const val = resolveValue(rawValue, ctx);
      return `    // Step ${ctx.stepNum}: Update ${model}.${field} to ${rawValue.trim()}
    {
      const ${modelVar}_collection = await getCollection('${collection}');
      await ${modelVar}_collection.updateOne({ _id: ${modelVar}._id }, { $set: { ${field}: ${val} } });
    }`;
    },
  },

  // --- Update field timestamp (e.g. "Update device lastLoginAt timestamp") ---
  {
    name: 'update-field-timestamp',
    pattern: /^update\s+(?:(?:new|existing|current|the|a|an)\s+)?(\w+)\s+(\w+)\s+timestamp$/i,
    generateCall: (m, ctx) => {
      const model = m[1];
      const modelVar = safeEntityName(model);
      if (!modelVar) return '';
      const field = m[2];
      if (!ctx.declaredVars?.has(modelVar)) return '';
      const collection = toCollection(model);
      return `    // Step ${ctx.stepNum}: Update ${model}.${field} timestamp
    {
      const ${modelVar}_collection = await getCollection('${collection}');
      await ${modelVar}_collection.updateOne({ _id: ${modelVar}._id }, { $set: { ${field}: new Date().toISOString() } });
    }`;
    },
  },

  // --- Generic "Update X" (writes args back to record) ---
  {
    name: 'update',
    pattern: /^update\s+(?:(?:new|existing|current|the|a|an)\s+)?(\w+)(?:\s+(.+))?$/i,
    generateCall: (m, ctx) => {
      const model = m[1];
      const modelVar = safeEntityName(model);
      if (!modelVar) return '';
      if (!ctx.declaredVars?.has(modelVar)) return '';
      const collection = toCollection(model);
      return `    // Step ${ctx.stepNum}: Update ${model}
    {
      const ${modelVar}_collection = await getCollection('${collection}');
      await ${modelVar}_collection.updateOne({ _id: ${modelVar}._id }, { $set: args });
    }`;
    },
  },

  // --- Delete model record ---
  {
    name: 'delete',
    pattern: /^delete\s+(?:(?:new|existing|current|the|a|an)\s+)?(\w+)/i,
    generateCall: (m, ctx) => {
      const model = m[1];
      const modelVar = safeEntityName(model);
      if (!modelVar) return '';
      if (!ctx.declaredVars?.has(modelVar)) return '';
      const collection = toCollection(model);
      return `    // Step ${ctx.stepNum}: Delete ${model}
    {
      const ${modelVar}_collection = await getCollection('${collection}');
      await ${modelVar}_collection.deleteOne({ _id: ${modelVar}._id });
    }`;
    },
  },

  // --- Transition to lifecycle state ---
  // Matches: "Transition X to state"
  {
    name: 'transition',
    pattern: /^transition\s+(\w+)\s+to\s+(\w+)/i,
    generateCall: (m, ctx) => {
      const model = m[1];
      const state = m[2];
      const modelVar = safeEntityName(model);
      if (!modelVar) return '';
      if (!ctx.declaredVars?.has(modelVar)) return '';
      const collection = toCollection(model);
      return `    // Step ${ctx.stepNum}: Transition ${model} to ${state}
    if ((${modelVar} as any).status === '${state}') throw new Error('${model} is already ${state}');
    {
      const ${modelVar}_collection = await getCollection('${collection}');
      await ${modelVar}_collection.updateOne({ _id: ${modelVar}._id }, { $set: { status: '${state}' } });
    }`;
    },
  },

  // --- Set field to value (on the controller's primary model) ---
  {
    name: 'set',
    pattern: /^set\s+(\w+)\s+to\s+(.+)/i,
    generateCall: (m, ctx) => {
      const field = m[1];
      const rawValue = m[2];
      const modelVar = toVar(ctx.modelName);
      const val = resolveValue(rawValue, ctx);
      return `    // Step ${ctx.stepNum}: Set ${field} to ${rawValue.trim()}
    {
      const ${modelVar}_collection = await getCollection(COLLECTION_NAME);
      await ${modelVar}_collection.updateOne({ _id: ${modelVar}._id }, { $set: { ${field}: ${val} } });
    }`;
    },
  },

  // --- Increment / Decrement ---
  {
    name: 'increment',
    pattern: /^increment\s+(\w+)\s+by\s+(\w+)/i,
    generateCall: (m, ctx) => {
      const field = m[1];
      const amount = m[2];
      const modelVar = toVar(ctx.modelName);
      return `    // Step ${ctx.stepNum}: Increment ${field} by ${amount}
    {
      const ${modelVar}_collection = await getCollection(COLLECTION_NAME);
      await ${modelVar}_collection.updateOne({ _id: ${modelVar}._id }, { $inc: { ${field}: ${amount} } });
    }`;
    },
  },
  {
    name: 'decrement',
    pattern: /^decrement\s+(\w+)\s+by\s+(\w+)/i,
    generateCall: (m, ctx) => {
      const field = m[1];
      const amount = m[2];
      const modelVar = toVar(ctx.modelName);
      return `    // Step ${ctx.stepNum}: Decrement ${field} by ${amount}
    {
      const ${modelVar}_collection = await getCollection(COLLECTION_NAME);
      await ${modelVar}_collection.updateOne({ _id: ${modelVar}._id }, { $inc: { ${field}: -${amount} } });
    }`;
    },
  },

  // ──────────────────────────────────────────────────────────────────
  // SIDE-EFFECT CONVENTIONS — patterns that the generator handles
  // mechanically. The LLM never sees these; persistence happens
  // deterministically from the spec text.
  // ──────────────────────────────────────────────────────────────────

  // --- Persist / Save / Store {Model} record ---
  // Matches: "Persist refresh token for revocation tracking",
  //          "Save user record", "Store session"
  // Source of the record: most-recent step{N}Result if any (typical case
  // — the prior AI step computed what to persist), else `args`.
  {
    name: 'persist',
    pattern: /^(?:persist|save|store)\s+(\w+(?:\s+\w+)?)(?:\s+(?:for|to|record).*)?$/i,
    generateCall: (m, ctx) => {
      // m[1] may be "refresh token" — collapse to camelCase + collection
      const target = safeEntityName(m[1].replace(/\s+(.)/g, (_, c) => c.toUpperCase()));
      if (!target) return '';
      const collection = toCollection(target);
      const recordSrc = mostRecentStepResult(ctx) ?? 'args';
      return `    // Step ${ctx.stepNum}: Persist ${m[1]}
    {
      const _coll = await getCollection('${collection}');
      await _coll.insertOne(${recordSrc} && typeof ${recordSrc} === 'object' && !Array.isArray(${recordSrc}) ? ${recordSrc} as any : { value: ${recordSrc} });
    }`;
    },
  },

  // --- Conditional create: "If X does not exist, create new X with ..." ---
  // Reuses the model variable already declared by a prior find; mutates
  // it to point at the newly-created record so subsequent steps see it.
  {
    name: 'conditional-create',
    pattern: /^if\s+(\w+)\s+does\s+not\s+exist,?\s+create\s+new\s+(\w+)(?:\s+with\s+.+)?$/i,
    generateCall: (m, ctx) => {
      const modelVar = safeEntityName(m[1]);
      if (!modelVar) return '';
      const Model = pascal(m[2]);
      const collection = toCollection(Model);
      if (!ctx.declaredVars?.has(modelVar)) return '';  // need prior find
      // Reassigns the original `let ${modelVar}` declared by find — so
      // subsequent steps (AI or convention) see the newly-created record
      // when the lookup originally returned null. Defaults come from the
      // model spec (#43K-A): required fields not present in args get
      // their type-default (level: 1, totalResources: '', etc.).
      const defaults = deriveModelDefaults(Model, ctx);
      const defaultsBlock = defaults.length > 0 ? defaults.join(', ') + ',' : '';
      return `    // Step ${ctx.stepNum}: If ${modelVar} does not exist, create new ${Model}
    if (!${modelVar}) {
      const _newRecord = { ${defaultsBlock} ...args, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() };
      const _ins = await (await getCollection('${collection}')).insertOne(_newRecord);
      ${modelVar} = { _id: _ins.insertedId, id: String(_ins.insertedId), ..._newRecord } as any;
    } else if (${modelVar} && !(${modelVar} as any).id && (${modelVar} as any)._id) {
      (${modelVar} as any).id = String((${modelVar} as any)._id);
    }`;
    },
  },

  // --- Conditional update: "If X exists, update Y" ---
  // Updates a single field on the previously-loaded record. Field defaults
  // to lastLoginAt-style timestamp when the step says "update Y" without
  // a "to <value>" clause.
  {
    name: 'conditional-update',
    pattern: /^if\s+(\w+)\s+exists,?\s+update\s+(\w+)(?:\s+(.+))?$/i,
    generateCall: (m, ctx) => {
      const modelVar = safeEntityName(m[1]);
      if (!modelVar) return '';
      const field = m[2];
      const collection = toCollection(m[1]);
      if (!ctx.declaredVars?.has(modelVar)) return '';
      return `    // Step ${ctx.stepNum}: If ${modelVar} exists, update ${field}
    if (${modelVar}) {
      await (await getCollection('${collection}')).updateOne(
        { _id: ${modelVar}._id },
        { $set: { ${field}: new Date().toISOString() } }
      );
    }`;
    },
  },

  // --- Auto-create / Bulk fan-out create ---
  // Matches: "Auto-create player profiles for all available games"
  //          "Create X records for all Y"
  // Pure-mechanical: enumerate the source collection, build a record per
  // item linking it to the most-recently-loaded model var (typically
  // `user`), insertMany. No AI hop — domain-specific field defaults are
  // limited to the link fields (userId, gameId) plus createdAt/updatedAt.
  // Anything richer should be done in a follow-up step (e.g. "Set displayName...").
  {
    name: 'auto-create-loop',
    pattern: /^(?:auto-create|bulk\s+create)\s+(\w+)\s+(?:profile|record|entry|entries)?s?\s+for\s+(?:all\s+)?(?:available\s+)?(\w+)$/i,
    generateCall: (m, ctx) => {
      const Model = pascal(m[1]);
      const targetCollection = toCollection(Model);
      const sourceCollection = m[2].toLowerCase();
      // Identify the "owner" var. Owner is the FIRST-declared non-stepResult
      // var (e.g. `user`); spec authors load the owner before enumerating
      // sources of its children.
      const declared = Array.from(ctx.declaredVars || []);
      const ownerVar = declared.find((v) => !/^step\d+Result$/.test(v) && v !== 'args');
      const sourceSingular = sourceCollection.replace(/s$/, '');
      const linkField = sourceSingular + 'Id';
      const ownerLinkField = ownerVar ? ownerVar + 'Id' : 'ownerId';

      // Pull required-field defaults from the target model spec (#43K-A).
      // Conventions can now read model attributes via ctx.models, so a
      // Player record gets `level: 1, experience: 0, totalResources: '""'`
      // automatically — no AI hop, no missing-required-field errors.
      const modelDefaults = deriveModelDefaults(Model, ctx);
      const defaultsBlock = modelDefaults.length > 0
        ? modelDefaults.filter((d) => !d.startsWith(`${ownerLinkField}:`) && !d.startsWith(`${linkField}:`)).join(', ')
        : '';

      return `    // Step ${ctx.stepNum}: Auto-create ${m[1]} ${m[2]} for all ${m[2]}
    {
      const _allItems = await (await getCollection('${sourceCollection}')).find({}).toArray();
      const _ownerId = ${ownerVar ? `(${ownerVar} as any)?.id ?? (${ownerVar} as any)?._id` : 'null'};
      const _records = _allItems.map((_item: any) => ({
        ${defaultsBlock ? defaultsBlock + ',' : ''}
        ${ownerLinkField}: _ownerId,
        ${linkField}: (_item as any).${sourceSingular}Id ?? (_item as any).id ?? String((_item as any)._id),
        createdAt: new Date().toISOString(),
        updatedAt: new Date().toISOString(),
      }));
      if (_records.length > 0) {
        await (await getCollection('${targetCollection}')).insertMany(_records as any);
      }
    }`;
    },
  },

  // --- "Otherwise create new X record" — pairs with prior conditional ---
  // Emitted as an `else` branch attached to the conditional that came
  // before. We don't enforce ordering at the convention level; if the
  // author writes "Otherwise" without a prior "If ... does not exist,
  // create" the emitted else lands without an if and tsc catches it.
  {
    name: 'otherwise-create',
    pattern: /^otherwise\s+create\s+(?:new\s+)?(\w+)\s+record$/i,
    generateCall: (m, ctx) => {
      const Model = pascal(m[1]);
      const modelVar = safeEntityName(Model);
      if (!modelVar) return '';
      const collection = toCollection(Model);
      // The model var was declared (as `let`) by the find that paired
      // with the prior conditional-update; reassign it here so downstream
      // steps see the new record.
      const wasDeclared = ctx.declaredVars?.has(modelVar);
      const declared = Array.from(ctx.declaredVars || []);
      // Model-driven defaults from the spec — these may include FK fields
      // (userId, gameId) that we'd ALSO derive from declaredVars below.
      const defaults = deriveModelDefaults(Model, ctx);
      // Track names already supplied by defaults so we don't double-emit.
      const supplied = new Set<string>();
      for (const entry of defaults) {
        const colonIdx = entry.indexOf(':');
        if (colonIdx > 0) supplied.add(entry.slice(0, colonIdx).trim());
      }
      // FK assignments from prior-declared model vars — only emit those
      // not already provided by spec defaults (avoids TS1117 dup-keys).
      const fkAssignments = declared
        .filter((v) => v !== modelVar && !/^step\d+Result$/.test(v) && v !== 'args')
        .filter((v) => !supplied.has(v + 'Id'))
        .map((v) => {
          supplied.add(v + 'Id');
          return `${v}Id: (${v} as any)?._id ?? (${v} as any)?.id`;
        })
        .join(', ');
      const defaultsBlock = defaults.length > 0 ? defaults.join(', ') + ',' : '';
      ctx.declaredVars?.add(modelVar);
      return `    // Step ${ctx.stepNum}: Otherwise create new ${Model} record
    else {
      const _newRecord = { ${defaultsBlock} ${fkAssignments ? fkAssignments + ',' : ''} ...args, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() };
      const _ins = await (await getCollection('${collection}')).insertOne(_newRecord);
      ${wasDeclared ? `${modelVar} = { _id: _ins.insertedId, id: String(_ins.insertedId), ..._newRecord } as any;` : `const ${modelVar} = { _id: _ins.insertedId, id: String(_ins.insertedId), ..._newRecord };
      void ${modelVar};`}
    }`;
    },
  },

  // --- Send/Emit/Publish event ---
  // Emits an eventBus.publish call. The payload references the controller's
  // primary model variable IF it was declared by a prior matched step;
  // otherwise the payload is just operation + timestamp (the event still
  // fires, just without a record-level id).
  {
    name: 'send-event',
    pattern: /^(?:send|emit|publish)\s+(\w+)\s+event/i,
    generateCall: (m, ctx) => {
      const event = m[1];
      const modelVar = toVar(ctx.modelName);
      const hasModelVar = ctx.declaredVars?.has(modelVar);
      const payload = hasModelVar
        ? `{ ${modelVar}Id: (${modelVar} as any)?._id, operation: '${ctx.operationName}', timestamp: new Date().toISOString() }`
        : `{ operation: '${ctx.operationName}', timestamp: new Date().toISOString() }`;
      return `    // Step ${ctx.stepNum}: Emit ${event} event
    await eventBus.publish('${event}', ${payload} as any);`;
    },
  },

  // --- Call service ---
  {
    name: 'call-service',
    pattern: /^call\s+(\w+)\.(\w+)/i,
    generateCall: (m, ctx) => {
      const service = m[1];
      const method = m[2];
      const serviceVar = safeEntityName(service);
      if (!serviceVar) return '';
      const args = (ctx.parameterNames || []).join(', ');
      return `    // Step ${ctx.stepNum}: Call ${service}.${method}
    await (${serviceVar} as any).${method}({ ${args} });`;
    },
  },

  // --- Return ---
  // Only matches when the returned value is a single declared identifier
  // OR when the natural-language reference resolves to "the model" (e.g.
  // "Return the user", "Return updated game"). Multi-word phrases like
  // "Return state to caller" don't translate to valid JS, so they fall
  // through to the unmatched-step path and emit a `// TODO:` line.
  {
    name: 'return-model',
    pattern: /^return\s+(?:the\s+|updated\s+|created\s+)?(\w+)\s*$/i,
    generateCall: (m, ctx) => {
      const valueRaw = m[1].trim();
      const declared = ctx.declaredVars || new Set();
      const params = ctx.parameterNames || [];
      const modelVar = toVar(ctx.modelName);
      // Resolve to a declared variable, parameter, or the controller's
      // primary model variable. Anything else doesn't have a JS expression
      // — fall through (return unmatched so the TODO path fires).
      if (valueRaw.toLowerCase() === ctx.modelName.toLowerCase() || valueRaw === modelVar) {
        return `    // Step ${ctx.stepNum}: Return ${ctx.modelName}
    return ${modelVar};`;
      }
      if (declared.has(valueRaw)) {
        return `    // Step ${ctx.stepNum}: Return ${valueRaw}
    return ${valueRaw};`;
      }
      if (params.includes(valueRaw)) {
        return `    // Step ${ctx.stepNum}: Return ${valueRaw}
    return args.${valueRaw};`;
      }
      // Unresolvable — emit a TODO comment + neutral return so generated
      // code still compiles. Caller can replace.
      return `    // Step ${ctx.stepNum}: Return ${valueRaw} — TODO: resolve binding
    return null;`;
    },
  },
];

/**
 * Match a step against the mongo-native conventions.
 *
 * Matched: returns the inline native-driver code to emit AND advances the
 * convention's bookkeeping (e.g. find/create add the model variable to
 * declaredVars).
 *
 * Unmatched: returns the function name + inputs + resultVar that an
 * AI-behaviors layer should produce for this step. The shape mirrors
 * prisma's step-conventions matchStep so the orm-agnostic
 * AI-behaviors-generator can drive either side without branching.
 */
export function matchMongoStep(
  step: string,
  ctx: MongoStepContext,
): {
  matched: boolean;
  call: string;
  helperMethod?: string;
  functionName?: string;
  inputs?: string[];
  resultVar?: string;
} {
  // Mongo's AI-fallback uses `args.X` for parameter inputs because the
  // mongo-native controller-generator wraps the action body with a single
  // `args` parameter (e.g. `register(args)`) and indexes through it
  // rather than destructuring at function entry. Declared variables stay
  // bare (they were declared inside the body by previous steps).
  const aiArgsExpr = (inputs: string[], paramNames: string[]) =>
    inputs.length > 0
      ? `{ ${inputs.map((n) => paramNames.includes(n) ? `${n}: args.${n}` : n).join(', ')} }`
      : '{}';
  return matchAgainstConventions(step, ctx, MONGO_STEP_CONVENTIONS, aiArgsExpr);
}
