/**
 * MongoDB Native Driver — Controller Generator
 *
 * Generates per-model controllers exposing CURVED operations via the native
 * MongoDB driver (collection-level insertOne / findOne / updateOne / deleteOne).
 *
 * Scope (MVP — TODO #43):
 *   - Validate / Create / Retrieve / Update / Delete fully wired
 *   - Evolve emits a lifecycle-aware update when the model declares states;
 *     transitions are validated against the declared flow before update
 *   - Custom actions emit AI-behavior stubs (callable, but the body is left
 *     as a TODO with the spec steps inline) — same pattern Prisma uses
 *
 * Out of scope here (deferred follow-up TODOs):
 *   - $transaction wrapping for multi-document writes (MongoDB sessions)
 *   - Aggregation-pipeline-driven retrieve variants (joins via $lookup)
 *   - Inline behavior-step → driver-call generation parity with the
 *     prisma step-conventions library
 *
 * Collection naming: lowercase + 's' (User → users) unless the model
 * declares `storage.collection`. ID field is ObjectId-aware: a 24-hex
 * string is parsed via `new ObjectId(id)`; anything else is kept as-is so
 * specs that use string ids (UUIDs, slugs) keep working unchanged.
 */
import type { TemplateContext } from '@specverse/types';
import { buildTransitionMap, isAutoField } from '@specverse/types/spec-rules';

export default function generateMongoNativeController(context: TemplateContext): string {
  const { controller, model, models } = context as any;
  if (!controller) throw new Error('Controller is required in template context');
  if (!model) throw new Error('Model is required for controller generation');

  const controllerName = controller.name;
  const modelName = model.name;
  const modelVar = lowerFirst(modelName);
  const collection = collectionName(model);
  const curedOps = controller.cured || {};

  // Build a name → ModelSpec registry once so step-conventions can read
  // attribute defaults (level: 1, totalResources: '', etc.) and FK targets
  // when emitting create/insertMany code (#43K-A).
  const modelRegistry: Record<string, any> = {};
  if (Array.isArray(models)) {
    for (const m of models) if (m?.name) modelRegistry[m.name] = m;
  } else if (models && typeof models === 'object') {
    Object.assign(modelRegistry, models);
  }

  const customActions = generateCustomActions(controller, modelRegistry);

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

  const validate = generateValidateMethod(model, modelName, hasConstraints);
  const create = curedOps.create ? generateCreateMethod(model, modelName, modelVar, collection, hasConstraints) : '';
  const retrieve = curedOps.retrieve ? generateRetrieveMethod(modelName, modelVar, collection) : '';
  const update = curedOps.update ? generateUpdateMethod(modelName, modelVar, collection, hasConstraints) : '';
  const evolve = curedOps.evolve ? generateEvolveMethod(model, modelName, modelVar, collection, hasConstraints) : '';
  const del = curedOps.delete ? generateDeleteMethod(modelName, modelVar, collection, hasConstraints) : '';

  const hasEventPublishing =
    curedOps.create || curedOps.update || curedOps.evolve || curedOps.delete;

  return `/**
 * ${controllerName}
 * Model-specific business logic for ${modelName} (MongoDB native driver)
 * ${controller.description || ''}
 */
import { ObjectId, type Filter, type Document } from 'mongodb';
import { getCollection } from '../db/mongoClient.js';
${hasEventPublishing || customActions.needsAiBehaviors ? `import { eventBus } from '../events/eventBus.js';` : ''}
${customActions.needsAiBehaviors ? `import * as aiBehaviors from '../behaviors/${controllerName}.ai.js';` : ''}
${hasConstraints ? `import { runGuards as runConstraintGuards } from './${modelName}.guards.js';` : ''}

const COLLECTION_NAME = '${collection}';

/** Parse an id string to the value the collection expects: ObjectId for
 * 24-hex-character ids, otherwise pass-through (UUIDs, slugs, ints-as-string). */
function parseId(id: string): ObjectId | string {
  if (typeof id === 'string' && /^[a-fA-F0-9]{24}$/.test(id)) return new ObjectId(id);
  return id;
}

/** _id-aware filter builder. */
function byId(id: string): Filter<Document> {
  return { _id: parseId(id) as any };
}

export class ${controllerName} {
${validate}
${create}
${retrieve}
${update}
${evolve}
${del}
${customActions.code}
}

export const ${modelVar}Controller = new ${controllerName}();
export default ${modelVar}Controller;
`;
}

function lowerFirst(s: string): string {
  return s.charAt(0).toLowerCase() + s.slice(1);
}

function collectionName(model: any): string {
  if (model?.storage?.collection) return String(model.storage.collection);
  // Lowercase-pluralized: User → users, OrderItem → orderitems
  return model.name.toLowerCase() + 's';
}

/**
 * Phase 2 — when the model has declared constraints, validate() ALSO
 * calls runConstraintGuards from the per-model .guards.ts and appends
 * any constraint violation messages to errors[]. Op union widens to
 * include 'delete' + evolve.<transition>.
 */
function generateValidateMethod(model: any, modelName: string, hasConstraints: boolean): string {
  const opTypeUnion = hasConstraints
    ? `'create' | 'update' | 'evolve' | 'delete' | \`evolve.\${string}\``
    : `'create' | 'update' | 'evolve'`;

  const constraintsCheck = hasConstraints
    ? `
    // Phase 2 — run model.constraints[] guards matching this operation.
    // Slice 15b — ctx carries a per-model query helper for subquery sugars.
    const __guardCtx = {
      query: (modelName: string) => ({
        exists: async (predicate: (e: any) => boolean) => {
          const __col = await getCollection(modelName.toLowerCase() + 's');
          const all = await __col.find({}).toArray();
          return all.some(predicate);
        },
      }),
    };
    const constraintViolations = await runConstraintGuards(_data, _context.operation, _actor, __guardCtx);
    for (const v of constraintViolations) errors.push(v.message);`
    : '';

  // Phase 2 actor wiring + Slice 15b — validate is async (awaits subquery
  // execution in guard ctx); callers must await this.validate(...).

  return `
  /**
   * Validate ${modelName} data — runs before create / update / evolve / delete.
   */
  public async validate(
    _data: any,
    _context: { operation: ${opTypeUnion} },
    _actor: any = null
  ): Promise<{ valid: boolean; errors: string[] }> {
    const errors: string[] = [];
${generateValidationLogic(model)}${constraintsCheck}
    return { valid: errors.length === 0, errors };
  }
`;
}

function generateValidationLogic(model: any): string {
  if (!model.attributes) return '    // No validation rules defined';
  const attrList = Array.isArray(model.attributes)
    ? model.attributes.map((a: any) => [a.name, a])
    : Object.entries(model.attributes);
  const out: string[] = [];
  attrList.forEach(([name, attr]: [string, any]) => {
    if (attr.required && !isAutoField(name, attr)) {
      out.push(`    if (_context.operation === 'create' && !_data.${name}) errors.push('${name} is required');`);
    }
    if (attr.type === 'String' || attr.type === 'string') {
      if (attr.min) out.push(`    if (_data.${name} && _data.${name}.length < ${attr.min}) errors.push('${name} must be at least ${attr.min} characters');`);
      if (attr.max) out.push(`    if (_data.${name} && _data.${name}.length > ${attr.max}) errors.push('${name} must be at most ${attr.max} characters');`);
    }
    if (attr.values && Array.isArray(attr.values)) {
      const values = attr.values.map((v: string) => `'${v}'`).join(', ');
      out.push(`    if (_data.${name} && ![${values}].includes(_data.${name})) errors.push('${name} must be one of: ${attr.values.join(', ')}');`);
    }
    if (attr.format === 'email') {
      out.push(`    if (_data.${name} && !/^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/.test(_data.${name})) errors.push('${name} must be a valid email address');`);
    }
  });
  return out.join('\n') || '    // No validation rules defined';
}

function generateCreateMethod(model: any, modelName: string, modelVar: string, collection: string, hasConstraints: boolean = false): string {
  const belongsToLoad = hasConstraints ? generateMongoBelongsToLoad(model) : '';
  const validateSelf = belongsToLoad ? '__mergedSelf' : 'data';

  return `
  /**
   * Create a new ${modelName}.
   */
  public async create(data: any, _actor: any = null): Promise<any> {
${belongsToLoad ? `
    // Phase 2 Slice 15 — Create-time relation loading. Mirrors prisma's
    // pattern: for each belongsTo FK in input, load the related doc and
    // merge into self so constraint guards traversing self.<rel> see
    // the full related entity, not just the FK id.
    const __loadedRels: Record<string, any> = {};
    ${belongsToLoad}
    const __mergedSelf = { ...data, ...__loadedRels };
` : ''}
    const validation = await this.validate(${validateSelf}, { operation: 'create' }, _actor);
    if (!validation.valid) throw new Error(\`Validation failed: \${validation.errors.join(', ')}\`);

    const collection = await getCollection(COLLECTION_NAME);
    const result = await collection.insertOne({ ...data });
    const ${modelVar} = { _id: result.insertedId, ...data };

    await eventBus.publish('${modelName}Created', { ...${modelVar}, timestamp: new Date().toISOString() } as any);
    return ${modelVar};
  }
`;
}

function generateMongoBelongsToLoad(model: any): string {
  const rels = Array.isArray(model.relationships)
    ? model.relationships
    : Object.values(model.relationships || {});
  const belongsToRels = (rels as any[]).filter(r => r.type === 'belongsTo');
  if (belongsToRels.length === 0) return '';

  return belongsToRels.map((rel: any) => {
    const relName = rel.name;
    const targetName = rel.target;
    const fkField = `${relName}Id`;
    // Mongo collections are typically named after the model in some casing.
    // Use the same convention the rest of the generator uses (COLLECTION_NAME
    // is per-controller). For cross-model loads, use the target's plural-ish
    // collection name. Since collection naming varies, we use a runtime helper.
    return `if (data.${fkField}) {
      const __col_${relName} = await getCollection('${targetName.toLowerCase()}s');
      __loadedRels.${relName} = await __col_${relName}.findOne(byId(data.${fkField}));
    }`;
  }).join('\n    ');
}

function generateRetrieveMethod(modelName: string, modelVar: string, collection: string): string {
  return `
  /**
   * Retrieve ${modelName} by id. Returns null when not found.
   */
  public async retrieve(id: string): Promise<any | null> {
    const collection = await getCollection(COLLECTION_NAME);
    return await collection.findOne(byId(id));
  }

  /**
   * Retrieve a page of ${modelName}s.
   */
  public async retrieveAll(options: { skip?: number; take?: number } = {}): Promise<any[]> {
    const collection = await getCollection(COLLECTION_NAME);
    const cursor = collection.find({});
    if (options.skip) cursor.skip(options.skip);
    if (options.take) cursor.limit(options.take);
    return await cursor.toArray();
  }
`;
}

function generateUpdateMethod(modelName: string, modelVar: string, collection: string, hasConstraints: boolean = false): string {
  return `
  /**
   * Update ${modelName}.
   */
  public async update(id: string, data: any, _actor: any = null): Promise<any> {
    const collection = await getCollection(COLLECTION_NAME);
${hasConstraints ? `
    // Phase 2 — Update self-from-DB. Load + merge before validate so
    // update-time constraints see the full entity, not just the partial input.
    const __existing = await collection.findOne(byId(id));
    if (!__existing) throw new Error('${modelName} not found');
    const __merged = { ...__existing, ...data };
    const validation = await this.validate(__merged, { operation: 'update' }, _actor);
` : `
    const validation = await this.validate(data, { operation: 'update' }, _actor);
`}
    if (!validation.valid) throw new Error(\`Validation failed: \${validation.errors.join(', ')}\`);

    // Strip nested objects + id — only scalar fields are written.
    const updateData: any = {};
    for (const [key, value] of Object.entries(data)) {
      if (key === 'id' || key === '_id') continue;
      if (Array.isArray(value)) continue;
      if (value !== null && typeof value === 'object' && !(value instanceof Date)) continue;
      updateData[key] = value;
    }

    await collection.updateOne(byId(id), { $set: updateData });
    const ${modelVar} = await collection.findOne(byId(id));
    if (!${modelVar}) throw new Error('${modelName} not found after update');

    await eventBus.publish('${modelName}Updated', { ...${modelVar}, timestamp: new Date().toISOString() } as any);
    return ${modelVar};
  }
`;
}

function generateEvolveMethod(model: any, modelName: string, modelVar: string, collection: string, hasConstraints: boolean = false): string {
  const lifecycles = Array.isArray(model.lifecycles)
    ? model.lifecycles
    : (model.lifecycles ? Object.entries(model.lifecycles).map(([name, lc]: [string, any]) => ({ name, ...lc })) : []);
  const lifecycle = lifecycles[0];
  const lifecycleName = lifecycle?.name || 'status';
  const validTransitions = lifecycle ? buildTransitionMap(lifecycle) : {};
  // Derive states from the transition map when the spec used `flow:` shorthand
  // (states is undefined on the wire, but buildTransitionMap expands the flow).
  const states = lifecycle?.states && lifecycle.states.length > 0
    ? lifecycle.states.map((s: any) => typeof s === 'string' ? s : s.name)
    : Array.from(new Set([
        ...Object.keys(validTransitions),
        ...Object.values(validTransitions).flat(),
      ]));

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

  return `
  /**
   * Evolve ${modelName} through lifecycle "${lifecycleName}"
   * States: ${states.join(' → ') || '(none declared)'}
   */
  public async evolve(id: string, data: any, _actor: any = null): Promise<any> {
    const collection = await getCollection(COLLECTION_NAME);
    const current = await collection.findOne(byId(id));
    if (!current) throw new Error('${modelName} not found');

    const targetLifecycle = data?.lifecycleName || '${lifecycleName}';
    const targetState = data?.toState ?? data?.state ?? data?.[targetLifecycle];
    if (!targetState) throw new Error('evolve requires toState (or ${lifecycleName}) in the request body');

    ${states.length > 0 ? `
    const currentState = (current as any)[targetLifecycle];
    const validTransitions: Record<string, string[]> = ${JSON.stringify(validTransitions)};
    const allowed = validTransitions[currentState] || [];
    if (!allowed.includes(targetState)) {
      throw new Error(\`Invalid transition: \${currentState} → \${targetState}. Allowed: \${allowed.join(', ') || 'none'}\`);
    }
    ` : ''}
${hasConstraints ? `
    // Phase 2 — resolve the transition's action name so constraints
    // scoped to \`on: 'evolve.<action>'\` match correctly. EVOLVE_OPS is
    // baked at codegen from the lifecycle definition.
    const EVOLVE_OPS: Record<string, Record<string, string>> = ${JSON.stringify(evolveOpsMap)};
    const currentStateForOp = (current as any)[targetLifecycle];
    const actionName = EVOLVE_OPS[currentStateForOp]?.[targetState] ?? targetState;
    const evolveValidation = await this.validate(
      { ...data, ...current, [targetLifecycle]: targetState },
      { operation: \`evolve.\${actionName}\` as any },
      _actor
    );
    if (!evolveValidation.valid) {
      throw new Error(\`Validation failed: \${evolveValidation.errors.join(', ')}\`);
    }
` : ''}
    await collection.updateOne(byId(id), { $set: { [targetLifecycle]: targetState } });
    const ${modelVar} = await collection.findOne(byId(id));
    if (!${modelVar}) throw new Error('${modelName} not found after evolve');

    await eventBus.publish('${modelName}Evolved', { ...${modelVar}, timestamp: new Date().toISOString() } as any);
    return ${modelVar};
  }
`;
}

function generateDeleteMethod(modelName: string, modelVar: string, collection: string, hasConstraints: boolean = false): string {
  return `
  /**
   * Delete ${modelName}.
   */
  public async delete(id: string, _actor: any = null): Promise<void> {
    const collection = await getCollection(COLLECTION_NAME);
    const ${modelVar} = await collection.findOne(byId(id));
${hasConstraints ? `
    // Phase 2 — run delete-scoped constraint guards against the loaded record.
    if (${modelVar}) {
      const deleteValidation = await this.validate(${modelVar}, { operation: 'delete' }, _actor);
      if (!deleteValidation.valid) {
        throw new Error(\`Validation failed: \${deleteValidation.errors.join(', ')}\`);
      }
    }
` : ''}
    await collection.deleteOne(byId(id));
    if (${modelVar}) {
      await eventBus.publish('${modelName}Deleted', { ...${modelVar}, timestamp: new Date().toISOString() } as any);
    }
  }
`;
}

interface CustomActionsResult {
  code: string;
  needsAiBehaviors: boolean;
}

/**
 * Custom actions emit a per-step body using `matchMongoStep`. The same
 * matcher is passed to the AI-behaviors-generator (via realize/index.ts)
 * so both sides accumulate the same `declaredVars` set and produce
 * matching function names + inputs for unmatched steps.
 *
 * Per step:
 *   - matched → emit conventional native-driver code inline
 *   - unmatched → emit `const stepNResult = await aiBehaviors.<funcName>({...});`
 *     (the aiBehaviors function comes from the controller's `*.ai.ts`
 *     file, generated by AI-behaviors-generator using the SAME matcher).
 */
import { matchMongoStep, type MongoStepContext } from './step-conventions.js';

function generateCustomActions(controller: any, modelRegistry: Record<string, any> = {}): CustomActionsResult {
  if (!controller.actions || Object.keys(controller.actions).length === 0) {
    return { code: '', needsAiBehaviors: false };
  }
  const CRUD_NAMES = new Set(['create', 'retrieve', 'retrieveAll', 'update', 'evolve', 'delete', 'validate']);
  const modelName = controller.model || (controller.name || '').replace(/Controller$/, '') || 'Model';
  const collectionName = modelName.toLowerCase() + 's';
  const out: string[] = [];
  let needsAiBehaviors = false;
  for (const [actionName, action] of Object.entries<any>(controller.actions)) {
    if (CRUD_NAMES.has(actionName)) {
      // Surface the collision so spec authors aren't silently losing
      // a declared action. Going forward this could escalate to an
      // error or auto-rename; for now a warning keeps existing specs
      // building while making the dropped behaviour visible.
      console.warn(
        `⚠️  ${controller.name || 'Controller'}.${actionName} — behaviour-derived action collides with the auto-generated CURVED \`${actionName}\` op. Dropped to avoid TS2393 duplicate-implementation. Rename the behaviour (e.g. \`${actionName}Soft\` / \`hardDelete\`) if you need the custom logic.`
      );
      continue;
    }
    const steps: any[] = Array.isArray(action.steps) ? action.steps : [];
    const stepsHeader = steps.length > 0
      ? steps.map((s: any) => `   *   - ${typeof s === 'string' ? s : (s.action || JSON.stringify(s))}`).join('\n')
      : '   *   (no spec steps declared)';

    const declaredVars = new Set<string>();
    const stepBodies: string[] = [];
    let usesArgs = false;
    let actionRefersToAi = false;
    steps.forEach((rawStep: any, i: number) => {
      const stepText = typeof rawStep === 'string' ? rawStep : (rawStep?.step || rawStep?.action);
      if (typeof stepText !== 'string') {
        stepBodies.push(`    // Step ${i + 1}: (non-string step ignored)`);
        return;
      }
      const ctx: MongoStepContext = {
        modelName,
        collectionName,
        serviceName: controller.name || 'Controller',
        operationName: actionName,
        stepNum: i + 1,
        parameterNames: Object.keys(action.parameters || {}),
        declaredVars,
        models: modelRegistry,
      };
      const result = matchMongoStep(stepText, ctx);
      stepBodies.push(result.call);
      if (/\bargs\./.test(result.call)) usesArgs = true;
      if (!result.matched) actionRefersToAi = true;
    });

    if (actionRefersToAi) needsAiBehaviors = true;
    const argsParam = usesArgs ? 'args: any = {}' : '_args: any = {}';
    let combined = stepBodies.join('\n\n');
    // Drop the `const stepNResult =` declaration when no later step
    // references the result. Strict tsc's noUnusedLocals applies to
    // locals regardless of underscore prefix, so the only safe fix is
    // to omit the assignment entirely. The await still fires.
    const stepResultRe = /const\s+(step\d+Result)\s*=/g;
    let mres: RegExpExecArray | null;
    const declared: string[] = [];
    while ((mres = stepResultRe.exec(combined)) !== null) declared.push(mres[1]);
    for (const name of declared) {
      const refCount = (combined.match(new RegExp(`\\b${name}\\b`, 'g')) || []).length;
      if (refCount <= 1) {
        // Only the declaration itself — drop the binding, keep the await.
        combined = combined.replace(new RegExp(`const\\s+${name}\\s*=\\s*`), '');
      }
    }
    const body = steps.length > 0
      ? combined + `\n    return { success: true };`
      : `    throw new Error('${controller.name || 'Controller'}.${actionName} is not implemented');`;

    out.push(`
  /**
   * ${actionName}
   * ${action.description || ''}
   *
   * Spec steps:
${stepsHeader}
   */
  public async ${actionName}(${argsParam}): Promise<any> {
${body}
  }
`);
  }
  return { code: out.join('\n'), needsAiBehaviors };
}
