/**
 * Prisma Schema Generator
 *
 * Generates Prisma schema from SpecVerse models
 */

import type { TemplateContext } from '@specverse/types';
import { pluralize } from '@specverse/types';
import { resolveOneModel } from '@specverse/engines/realize';

/**
 * Generate Prisma schema
 */
export default function generatePrismaSchema(context: TemplateContext): string {
  const { spec, models, implType } = context;

  const allModels = models || spec?.models || [];

  if (allModels.length === 0) {
    throw new Error('No models found in context for schema generation');
  }

  // 53C — defensive collision resolution for direct invocations of this
  // generator (test fixtures, ad-hoc scripts) that bypass the realize
  // pipeline. The realizeAll() entrypoint runs the same pass at the top
  // via normalizeAttributeRelationshipCollisions — so when we get here
  // from the normal pipeline, this is a no-op (no collisions remain).
  // See engines/src/realize/normalize-collisions.ts for the full why.
  for (const model of allModels) {
    resolveOneModel(model);
  }

  // Build relation map: track all references to each target model
  // so we can add @relation("name") when a target is referenced multiple times
  const relationMap = buildRelationMap(allModels);

  // Build missing back-references: when Model A has hasMany B but B has no belongsTo A,
  // Prisma requires the back-reference field on B. We auto-generate these.
  const backRefs = buildMissingBackRefs(allModels, relationMap);

  // Build set of "ParentModel->ChildModel" for hasOne relations
  // Used to add @unique to FK fields on the child side of a hasOne
  const hasOneTargets = new Set<string>();
  for (const m of allModels) {
    const rels = Array.isArray(m.relationships) ? m.relationships : Object.values(m.relationships || {});
    for (const r of rels as any[]) {
      if (r.type === 'hasOne') {
        hasOneTargets.add(`${m.name}->${r.target}`);
      }
    }
  }

  // Generate header — thread the storage-factory choice (53A).
  // realize/index.ts:realizeAll passes the resolved storage factory through
  // `additionalContext.storageFactory`. Falls back to implType for callers
  // that don't thread storage (e.g. direct schema-only generation paths).
  const header = generateHeader(implType, context.storageFactory, (context as any).devDatabase);

  // Collector for Prisma enum declarations derived from model lifecycles.
  // Generated as a side-effect of generateModelSchema and emitted at the
  // top level (Prisma requires enums declared OUTSIDE model blocks).
  const enumCollector = new Map<string, string[]>();

  // Generate models
  const modelSchemas = allModels.map((model: any) =>
    generateModelSchema(model, relationMap, backRefs, hasOneTargets, allModels, enumCollector)
  ).join('\n\n');

  // Emit collected enums before models, so models can reference them.
  let enumBlock = '';
  if (enumCollector.size > 0) {
    const enumDecls: string[] = [];
    for (const [enumName, values] of enumCollector) {
      const body = values.map((v) => `  ${v}`).join('\n');
      enumDecls.push(`enum ${enumName} {\n${body}\n}`);
    }
    enumBlock = enumDecls.join('\n\n') + '\n\n';
  }

  return `${header}\n\n${enumBlock}${modelSchemas}`;
}

/**
 * Build a relation name map that assigns consistent names to both sides of each relation.
 *
 * Prisma requires that when @relation("name") is used, BOTH sides use the same name.
 * We need disambiguation when a model is referenced by multiple fields across all models.
 *
 * Returns: Map<"SourceModel.fieldName", relationName | null>
 *   - null means no @relation annotation needed
 *   - string means use @relation("name") on this field
 */
function buildRelationMap(allModels: any[]): Map<string, string | null> {
  // Step 1: Collect all relations grouped by target model
  type RelRef = { sourceModel: string; fieldName: string; relType: string };
  const targetRefs = new Map<string, RelRef[]>();

  for (const model of allModels) {
    const relationships = Array.isArray(model.relationships)
      ? model.relationships
      : Object.values(model.relationships || {});

    for (const rel of relationships as any[]) {
      const target = rel.target;
      if (!target) continue;

      const fieldName = rel.name || (rel.type === 'hasMany' || rel.type === 'manyToMany'
        ? pluralize(target.toLowerCase())
        : target.toLowerCase());

      if (!targetRefs.has(target)) {
        targetRefs.set(target, []);
      }
      targetRefs.get(target)!.push({
        sourceModel: model.name,
        fieldName,
        relType: rel.type
      });
    }
  }

  // Step 2: For ALL belongsTo relations, find the matching parent-side name.
  // Since we always name relations, every belongsTo must use the parent's canonical name.
  const nameMap = new Map<string, string | null>();

  for (const [target, refs] of targetRefs) {
    for (const ref of refs) {
      const key = `${ref.sourceModel}.${ref.fieldName}`;
      if (ref.relType === 'belongsTo') {
        // Child side: find the matching parent-side relation
        const parentRefs = targetRefs.get(ref.sourceModel) || [];
        const matchingParent = parentRefs.find(p =>
          p.sourceModel === target &&
          (p.relType === 'hasMany' || p.relType === 'hasOne')
        );
        if (matchingParent) {
          // Use the parent's canonical name so both sides match
          nameMap.set(key, `${matchingParent.sourceModel}_${matchingParent.fieldName}`);
        }
        // If no matching parent, leave unmapped — getRelationName will use default
      }
      // hasMany/hasOne/manyToMany use default naming in getRelationName
    }
  }

  return nameMap;
}

/**
 * Build missing back-reference fields.
 * When Model A has `hasMany B` or `hasOne B`, Prisma requires B to have a
 * corresponding FK + relation field pointing back to A.
 * If B already has a `belongsTo A`, it's covered. Otherwise we need to inject one.
 *
 * Returns: Map<targetModelName, Array<{ fkField, relationField }>>
 */
function buildMissingBackRefs(
  allModels: any[],
  relationMap: Map<string, string | null>
): Map<string, string[]> {
  // Build set of existing belongsTo relations: "ChildModel->ParentModel"
  const existingBelongsTo = new Set<string>();
  for (const model of allModels) {
    const relationships = Array.isArray(model.relationships)
      ? model.relationships
      : Object.values(model.relationships || {});
    for (const rel of relationships as any[]) {
      if (rel.type === 'belongsTo') {
        existingBelongsTo.add(`${model.name}->${rel.target}`);
      }
    }
  }

  // For each hasMany/hasOne, check if the target has a belongsTo back
  const backRefs = new Map<string, string[]>();

  for (const model of allModels) {
    const relationships = Array.isArray(model.relationships)
      ? model.relationships
      : Object.values(model.relationships || {});
    for (const rel of relationships as any[]) {
      if (rel.type !== 'hasMany' && rel.type !== 'hasOne') continue;

      const target = rel.target;
      const backKey = `${target}->${model.name}`;

      if (!existingBelongsTo.has(backKey)) {
        // Target model is missing a belongsTo for this relation — inject one
        const fieldName = rel.name || (rel.type === 'hasMany'
          ? pluralize(target.toLowerCase())
          : target.toLowerCase());
        // Always use the parent's canonical relation name
        const relName = `${model.name}_${fieldName}`;

        // Check if the parent model has multiple hasMany/hasOne relations to the same target
        // If so, include the field name in the FK to avoid collisions
        const parentRelsToSameTarget = relationships.filter((r: any) =>
          r.target === target && (r.type === 'hasMany' || r.type === 'hasOne')
        );
        const needsFieldInFk = parentRelsToSameTarget.length > 1;
        // Back-ref FK column — same camelCase convention as the forward
        // belongsTo path above (see line ~455).
        const fkSuffix = needsFieldInFk
          ? fieldName + 'Id'
          : model.name.charAt(0).toLowerCase() + model.name.slice(1) + 'Id';
        const fkName = fkSuffix;
        const fkPadding = ' '.repeat(Math.max(1, 15 - fkName.length));
        const refFieldName = needsFieldInFk
          ? fieldName + model.name
          : model.name.charAt(0).toLowerCase() + model.name.slice(1);
        const refPadding = ' '.repeat(Math.max(1, 15 - refFieldName.length));

        let relDef = `${refFieldName}${refPadding}${model.name}`;
        relDef += ` @relation("${relName}", fields: [${fkName}], references: [id])`;

        if (!backRefs.has(target)) {
          backRefs.set(target, []);
        }
        const uniqueModifier = rel.type === 'hasOne' ? ' @unique' : '';
        backRefs.get(target)!.push(`${fkName}${fkPadding}String${uniqueModifier}`);
        backRefs.get(target)!.push(relDef);
      }
    }
  }

  // Also handle the reverse: belongsTo without matching hasMany/hasOne on parent
  // Build set of existing hasMany/hasOne: "ParentModel->ChildModel"
  const existingHasRelation = new Set<string>();
  for (const model of allModels) {
    const rels = Array.isArray(model.relationships)
      ? model.relationships
      : Object.values(model.relationships || {});
    for (const rel of rels as any[]) {
      if (rel.type === 'hasMany' || rel.type === 'hasOne') {
        existingHasRelation.add(`${model.name}->${rel.target}`);
      }
    }
  }

  for (const model of allModels) {
    const relationships = Array.isArray(model.relationships)
      ? model.relationships
      : Object.values(model.relationships || {});
    for (const rel of relationships as any[]) {
      if (rel.type !== 'belongsTo') continue;

      const parent = rel.target;
      const forwardKey = `${parent}->${model.name}`;

      if (!existingHasRelation.has(forwardKey)) {
        // Parent model is missing a hasMany/hasOne for this belongsTo — inject array
        const fieldName = rel.name || parent.toLowerCase();
        const relName = getRelationName(model.name, fieldName, parent, relationMap);
        const arrayFieldName = pluralize(model.name.charAt(0).toLowerCase() + model.name.slice(1));
        const padding = ' '.repeat(Math.max(1, 15 - arrayFieldName.length));

        if (!backRefs.has(parent)) {
          backRefs.set(parent, []);
        }
        backRefs.get(parent)!.push(`${arrayFieldName}${padding}${model.name}[] @relation("${relName}")`);
      }
    }
  }

  return backRefs;
}

/**
 * Get the relation name for a given source model + field.
 * ALWAYS returns a name — Prisma accepts explicit names even when not strictly required,
 * and it avoids all ambiguity issues with complex schemas.
 *
 * For hasMany/hasOne: name = "SourceModel_fieldName" (canonical, parent-side)
 * For belongsTo: looks up the matching parent-side name from the relation map
 */
function getRelationName(
  sourceModel: string,
  fieldName: string,
  target: string,
  relationMap: Map<string, string | null>
): string {
  // Check if there's a mapped name (for belongsTo matching a parent's hasMany)
  const mapped = relationMap.get(`${sourceModel}.${fieldName}`);
  if (mapped) return mapped;

  // Default: use canonical naming
  return `${sourceModel}_${fieldName}`;
}

/**
 * Map a storage factory's `technology.database` (or factory name) to the
 * Prisma datasource provider string. Centralised so both the threaded-
 * factory path and the legacy implType path agree on the mapping.
 *
 * Recognised values produce the canonical Prisma provider string:
 *   - "postgresql" / "postgres"       → "postgresql"
 *   - "mysql" / "mariadb"             → "mysql"
 *   - "sqlite"                        → "sqlite"
 *   - "mongodb"                       → "mongodb"
 *   - "sqlserver" / "mssql"           → "sqlserver"
 *   - "cockroachdb"                   → "cockroachdb"
 *
 * Anything we don't recognise returns undefined so the caller falls back
 * to its existing default rather than emitting an invalid provider string.
 */
/** Map a raw provider/technology string to the canonical Prisma provider. */
function normalizeProviderString(raw: string): string | undefined {
  const r = String(raw || '').toLowerCase();
  if (!r) return undefined;
  if (r.includes('postgres')) return 'postgresql'; // covers postgres + postgresql
  if (r.includes('mariadb') || r.includes('mysql')) return 'mysql';
  if (r.includes('sqlite')) return 'sqlite';
  if (r.includes('mongodb') || r.includes('mongo')) return 'mongodb';
  if (r.includes('sqlserver') || r.includes('mssql')) return 'sqlserver';
  if (r.includes('cockroach')) return 'cockroachdb';
  return undefined;
}

function deriveProviderFromStorage(storageFactory: any): string | undefined {
  if (!storageFactory) return undefined;
  // Prefer technology.database (canonical: factories ship "postgresql",
  // "mongodb", etc.); fall back to factory.name (e.g. "PostgreSQL15") for
  // older factories or when the technology block was lost in a deep clone.
  return normalizeProviderString(
    String(storageFactory?.technology?.database ?? storageFactory?.name ?? '')
  );
}

/**
 * Generate Prisma schema header
 *
 * Provider resolution:
 *   1. `devDatabase.provider` — the development environment's DB override,
 *      threaded from realize/index.ts (resolved from
 *      `environments.development.database`). SAME source the backend .env
 *      generator uses, so the schema's provider and the .env's DATABASE_URL
 *      always agree (TODO #75: a manifest declaring the production-grade
 *      PostgreSQL15 storage factory + `development: sqlite` must realize a
 *      sqlite schema for zero-deps `npm run setup`, not a postgres schema fed
 *      a `file:` URL).
 *   2. `storageFactory` (the manifest's `storage.database` capability) — used
 *      when no dev-environment DB override is declared.
 *   3. `implType.configuration.orm.provider` — legacy path for direct callers.
 *   4. `"sqlite"` — final fallback so a manifest-less schema-gen still
 *      produces a valid Prisma file.
 */
function generateHeader(implType: any, storageFactory?: any, devDatabase?: any): string {
  const devProvider = normalizeProviderString(devDatabase?.provider ?? '');
  const provider =
    devProvider ||
    deriveProviderFromStorage(storageFactory) ||
    implType?.configuration?.orm?.provider ||
    'sqlite';

  return `// Generated Prisma schema from SpecVerse specification
// This is your Prisma schema file
// learn more about it in the docs: https://pris.ly/d/prisma-schema

generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "${provider}"
  url      = ${provider === 'sqlite' ? '"file:./dev.db"' : 'env("DATABASE_URL")'}
}`;
}

/**
 * Generate a single model schema
 */
function generateModelSchema(
  model: any,
  relationMap: Map<string, string | null>,
  backRefs: Map<string, string[]>,
  hasOneTargets: Set<string>,
  allModels?: any[],
  enumCollector?: Map<string, string[]>,
): string {
  const modelName = model.name;
  let schema = `model ${modelName} {\n`;

  // Convert attributes from object to array if needed
  const attributes = Array.isArray(model.attributes)
    ? model.attributes
    : Object.values(model.attributes || {});

  // Convert relationships from object to array if needed
  const relationships = Array.isArray(model.relationships)
    ? model.relationships
    : Object.values(model.relationships || {});

  // 43B prep — when a composite PK is declared, the column-level @id
  // directive must NOT appear on the lone `id` column (Prisma rejects
  // having both forms). Compute once and thread into generateField.
  const compositePkForFields = parseColumnList(model.keys ?? model.primaryKey);
  const hasCompositePk = compositePkForFields.length > 1;

  // Add attributes
  attributes.forEach((attr: any) => {
    schema += `  ${generateField(attr, model, { suppressColumnId: hasCompositePk })}\n`;
  });

  // Add lifecycle status fields (if model has lifecycles).
  //
  // Each lifecycle's state literals become a Prisma `enum` declared at the
  // schema's top level. The field's type is the enum, not a plain `String`.
  // This preserves the state vocabulary in source so the realize→analyse
  // round-trip oracle can recover lifecycle states (previously, only the
  // default state survived — the others were silently dropped).
  //
  // Prisma enum value identifier rules: `^[a-zA-Z][a-zA-Z0-9_]*$`. SpecVerse
  // states are already snake_case (enforced by the convention prompt) so they
  // satisfy this without transformation. If a state somehow contains an
  // illegal char, fall back to plain String so we don't emit an invalid schema.
  const ENUM_VALUE_REGEX = /^[a-zA-Z][a-zA-Z0-9_]*$/;
  const rawLifecycles = model.lifecycles || [];
  const lifecycleList = Array.isArray(rawLifecycles)
    ? rawLifecycles
    : Object.entries(rawLifecycles).map(([name, lc]: [string, any]) => ({ name, ...(typeof lc === 'object' ? lc : {}) }));
  for (const lifecycle of lifecycleList) {
    const fieldName = lifecycle.name || 'status';
    const exists = attributes.some((a: any) => a.name === fieldName);
    if (!exists && lifecycle.states?.length > 0) {
      const defaultState = lifecycle.states[0];
      const padding = ' '.repeat(Math.max(1, 15 - fieldName.length));
      const allValid = lifecycle.states.every((s: string) => ENUM_VALUE_REGEX.test(s));
      if (allValid && enumCollector) {
        const enumName = `${modelName}${fieldName.charAt(0).toUpperCase()}${fieldName.slice(1)}`;
        enumCollector.set(enumName, lifecycle.states);
        schema += `  ${fieldName}${padding}${enumName} @default(${defaultState})\n`;
      } else {
        // Fallback for invalid identifiers or when no collector — preserves the
        // pre-fix behavior so downstream code never sees an undefined enum ref.
        schema += `  ${fieldName}${padding}String @default("${defaultState}")\n`;
      }
    }
  }

  // Add relationships
  relationships.forEach((rel: any) => {
    const fields = generateRelationship(rel, model, relationMap, hasOneTargets, allModels);
    fields.forEach(field => {
      schema += `  ${field}\n`;
    });
  });

  // Add auto-generated back-references (for hasMany/hasOne without corresponding belongsTo)
  const modelBackRefs = backRefs.get(modelName);
  if (modelBackRefs) {
    for (const line of modelBackRefs) {
      schema += `  ${line}\n`;
    }
  }

  // 43B — composite primary key. Spec author writes `model.keys: [a, b]`
  // (or `model.primaryKey: [a, b]`) → Prisma `@@id([a, b])` block at the
  // end of the model. Mirrors postgres-native's parseColumnList shape so
  // both adapters honour the same spec field.
  const compositePk = parseColumnList(model.keys ?? model.primaryKey);
  if (compositePk.length > 1) {
    schema += `  @@id([${compositePk.join(', ')}])\n`;
  }

  // 43D — composite unique constraints. `model.unique: [[a, b]]` → one
  // `@@unique([a, b])` per inner array. Single-column uniqueness stays at
  // the attribute level (`attr.unique: true`) — same convention as pg-native.
  const compositeUniques = parseCompositeUnique(model.unique);
  for (const cols of compositeUniques) {
    if (cols.length > 0) {
      schema += `  @@unique([${cols.join(', ')}])\n`;
    }
  }

  // 43D — partial indexes. Prisma 5+ accepts `where:` on `@@index`. Spec
  // shape: `attr.index: { where: "...", unique?: bool }` produces
  // `@@index([attrName], where: "...")` (or `@@unique` when unique=true).
  for (const attr of attributes as any[]) {
    if (!attr?.index || typeof attr.index !== 'object') continue;
    const where = typeof attr.index.where === 'string' ? attr.index.where : null;
    if (!where) continue; // plain (non-partial) indexes go through @unique / column-level @@index
    const isUnique = attr.index.unique === true;
    const directive = isUnique ? '@@unique' : '@@index';
    // Where-clauses are SQL fragments; wrap in single quotes after escaping.
    const escaped = where.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
    schema += `  ${directive}([${attr.name}], where: "${escaped}")\n`;
  }

  schema += `}`;

  return schema;
}

// ── Composite-key + composite-unique parsers (mirror pg-native) ─────────────

/** Parse `model.keys` / `model.primaryKey`. Accepts:
 *   - undefined / null → []
 *   - string → [string]
 *   - string[] → as-is
 *   - string[][] → flatten the first row (defensive)
 * Mirrors postgres-native's parseColumnList so the same spec field
 * works across both adapters.
 */
function parseColumnList(raw: any): string[] {
  if (raw === undefined || raw === null) return [];
  if (typeof raw === 'string') return [raw];
  if (!Array.isArray(raw)) return [];
  if (raw.length === 0) return [];
  if (typeof raw[0] === 'string') {
    return raw.filter((x: any): x is string => typeof x === 'string');
  }
  if (Array.isArray(raw[0])) {
    return (raw[0] as any[]).filter((x: any): x is string => typeof x === 'string');
  }
  return [];
}

/** Parse `model.unique`. Accepts:
 *   - undefined / null → []
 *   - string[] → [[...]]   (single composite spanning the listed columns)
 *   - string[][] → as-is   (multiple composites)
 */
function parseCompositeUnique(raw: any): string[][] {
  if (raw === undefined || raw === null) return [];
  if (!Array.isArray(raw) || raw.length === 0) return [];
  if (typeof raw[0] === 'string') {
    return [raw.filter((x: any): x is string => typeof x === 'string')];
  }
  if (Array.isArray(raw[0])) {
    return (raw as any[][])
      .map((row) => row.filter((x: any): x is string => typeof x === 'string'))
      .filter((row) => row.length > 0);
  }
  return [];
}

/**
 * Detect timestamp-shaped field names (53B).
 *
 * Matches `<word>At` where word starts with a lowercase letter:
 *   createdAt, updatedAt, publishedAt, expiresAt, deletedAt, joinedAt, ...
 *
 * Excludes:
 *   - `at` alone (no preceding word; ambiguous, could be a noun)
 *   - `*Bat`, `*Cat`, `*Hat` etc. (the literal letter "at" appearing
 *     mid-word, not as a date suffix) — the lowercase-then-At pattern
 *     restricts this to camelCase compound names.
 *   - Pascal-case `CreatedAt` (we ONLY want the camelCase form Prisma uses)
 *
 * Type-promotion guard (false-positive avoidance): we promote String /
 * Number / DateTime to DateTime, but NOT plainly unrelated types like
 * Boolean, Json, or relation types. Prisma's `@updatedAt` decorator
 * requires DateTime; any other type would fail compilation regardless
 * of the field name.
 */
function isTimestampFieldName(name: string): boolean {
  if (!name) return false;
  // Need at least one char before `At` to avoid bare "at" matching.
  // The leading char must be lowercase to lock onto camelCase compounds —
  // this is the convention every Prisma timestamp field follows in the
  // realized output (see autoTimestampFields list a few lines below).
  return /^[a-z][a-zA-Z0-9]*At$/.test(name);
}

/**
 * Generate a field definition
 *
 * `opts.suppressColumnId` (43B): when the model declares a composite
 * primary key (`model.keys: [a, b]`), the `id` column — if present —
 * must NOT carry `@id` because the model-level `@@id([a, b])` block
 * already declares the PK. Prisma rejects having both forms.
 */
function generateField(
  attr: any,
  model: any,
  opts: { suppressColumnId?: boolean } = {},
): string {
  const name = attr.name;
  let prismaType = mapTypeToPrisma(attr.type, attr.dbMapping?.columnType);

  // 53B — promote `*At` field names to DateTime when the spec/dbMapping
  // produced a non-DateTime type. Prevents the realize-time emission of
  //   `createdAt   String @default(now())`
  // which Prisma rejects with the (cryptic) "fields marked with @updatedAt
  // must be of type DateTime" error.
  //
  // We only promote from semantically-compatible source types — String
  // (the most common analyse-time miscategorisation), Number/Int (when
  // the source code stored unix-millis as a number), and DateTime
  // (no-op, pinned for clarity). NOT promoted: Boolean, Json, relation
  // types — those couldn't legally be timestamps.
  if (isTimestampFieldName(name) && prismaType !== 'DateTime') {
    if (prismaType === 'String' || prismaType === 'Int' || prismaType === 'BigInt' || prismaType === 'Float') {
      prismaType = 'DateTime';
    }
  }

  const isOptional = !(attr.required || attr.constraints?.required);
  const isUnique = attr.unique || attr.constraints?.unique;
  const metadata = model?.metadata || {};

  let modifiers = '';

  let hasDefault = false;

  // Primary key detection
  if (name.toLowerCase() === 'id' && !opts.suppressColumnId) {
    modifiers += ' @id';
    if (metadata.id === 'uuid' || metadata.id === 'auto' || prismaType === 'String') {
      modifiers += ' @default(uuid())';
      hasDefault = true;
    } else if (metadata.id === 'integer' || prismaType === 'Int') {
      modifiers += ' @default(autoincrement())';
      hasDefault = true;
    }
  } else if (isUnique) {
    modifiers += ' @unique';
  }

  // updatedAt gets @updatedAt (Prisma auto-updates), not @default(now())
  if (name === 'updatedAt' || name === 'updated_at') {
    modifiers += ' @updatedAt';
    hasDefault = true; // Prevent @default from being added below
  }

  // Auto-generated fields (auto=now, auto=uuid, etc.) — skip if already handled
  if (attr.auto && !hasDefault) {
    const autoValue = typeof attr.auto === 'string' ? attr.auto.toLowerCase() : attr.auto;
    if (autoValue === 'now' || autoValue === true) {
      if (prismaType === 'DateTime') {
        modifiers += ' @default(now())';
        hasDefault = true;
      }
    } else if (autoValue === 'uuid' || autoValue === 'uuid4') {
      if (prismaType === 'String') {
        modifiers += ' @default(uuid())';
        hasDefault = true;
      }
    } else if (autoValue === 'autoincrement') {
      if (prismaType === 'Int') {
        modifiers += ' @default(autoincrement())';
        hasDefault = true;
      }
    }
  }

  // Fallback: common auto-timestamp fields without explicit auto property
  if (!hasDefault && prismaType === 'DateTime') {
    const autoTimestampFields = ['joinedAt', 'registeredAt', 'enrolledAt', 'startedAt', 'completedAt', 'verifiedAt'];
    if (autoTimestampFields.includes(name)) {
      modifiers += ' @default(now())';
      hasDefault = true;
    }
    // Audit createdAt without auto property
    if ((name === 'createdAt' || name === 'created_at') && metadata.audit) {
      modifiers += ' @default(now())';
      hasDefault = true;
    }
  }

  // Soft delete fields
  if (metadata.softDelete && isSoftDeleteField(name)) {
    if (name === 'isDeleted' || name === 'is_deleted') {
      modifiers += ' @default(false)';
    }
  }

  // Version field
  if (metadata.version && isVersionField(name)) {
    modifiers += ' @default(0)';
  }

  // Database-specific type annotations
  if (attr.dbMapping?.columnType === 'TEXT') {
    modifiers += ' @db.Text';
  } else if (attr.dbMapping?.columnType?.startsWith('VARCHAR')) {
    const match = attr.dbMapping.columnType.match(/VARCHAR\((\d+)\)/);
    if (match) {
      modifiers += ` @db.VarChar(${match[1]})`;
    }
  }

  const padding = ' '.repeat(Math.max(1, 15 - name.length));
  const typeStr = isOptional ? `${prismaType}?` : prismaType;

  return `${name}${padding}${typeStr}${modifiers}`;
}

/**
 * Generate relationship fields
 */
function generateRelationship(rel: any, model: any, relationMap: Map<string, string | null>, hasOneTargets: Set<string>, allModels?: any[]): string[] {
  const fields: string[] = [];
  const name = rel.name || rel.target.toLowerCase();
  const padding = ' '.repeat(Math.max(1, 15 - name.length));
  const isOptional = rel.optional || false;

  // Always add explicit relation names for Prisma — avoids all ambiguity
  const relName = getRelationName(model.name, name, rel.target, relationMap);
  const relAnnotation = ` @relation("${relName}")`;

  switch (rel.type) {
    case 'belongsTo':
      // Foreign key field — use camelCase, matching the casing of
      // every other attribute the generator emits. Earlier revisions
      // used snake_case here, which inconsistently mixed
      // `createdAt` (camel) with `category_id` (snake) in the same
      // schema and leaked through to API responses, breaking
      // camelCase FK lookups on the frontend.
      const fkBase = rel.foreignKey || name + 'Id';
      const fkPadding = ' '.repeat(Math.max(1, 15 - fkBase.length));
      // Add @unique if the parent has a hasOne relation pointing to this model
      const isUniqueFK = hasOneTargets.has(`${rel.target}->${model.name}`);
      // FK type must match the target model's ID type
      let fkType = 'String';
      if (allModels) {
        const targetModel = allModels.find((m: any) => m.name === rel.target);
        if (targetModel) {
          const idAttr = (Array.isArray(targetModel.attributes) ? targetModel.attributes : Object.values(targetModel.attributes || {}))
            .find((a: any) => a.name === 'id');
          if (idAttr) {
            const idType = idAttr.type || 'String';
            fkType = mapTypeToPrisma(idType);
          }
        }
      }
      fields.push(`${fkBase}${fkPadding}${fkType}${isOptional ? '?' : ''}${isUniqueFK ? ' @unique' : ''}`);

      // Relation field with @relation including fields/references + optional name
      let relationDef = `${name}${padding}${rel.target}${isOptional ? '?' : ''}`;
      relationDef += ` @relation(`;
      if (relName) {
        relationDef += `"${relName}", `;
      }
      relationDef += `fields: [${fkBase}], references: [id]`;

      // Check if parent model declares cascade on its hasMany/hasOne side
      let onDelete = rel.onDelete;
      if (!onDelete && allModels) {
        const parentModel = allModels.find((m: any) => m.name === rel.target);
        if (parentModel) {
          const parentRels = Array.isArray(parentModel.relationships)
            ? parentModel.relationships
            : Object.values(parentModel.relationships || {});
          const parentRel = parentRels.find((pr: any) =>
            (pr.type === 'hasMany' || pr.type === 'hasOne') &&
            (pr.target === model.name || pr.targetModel === model.name));
          if (parentRel?.cascade || parentRel?.cascadeDelete) {
            onDelete = 'Cascade';
          }
        }
      }
      if (onDelete) {
        relationDef += `, onDelete: ${onDelete}`;
      }
      if (rel.onUpdate) {
        relationDef += `, onUpdate: ${rel.onUpdate}`;
      }

      relationDef += ')';
      fields.push(relationDef);
      break;

    case 'hasOne':
      // Prisma requires hasOne relation fields to be optional
      fields.push(`${name}${padding}${rel.target}?${relAnnotation}`);
      break;

    case 'hasMany':
      const pluralName = rel.name || pluralize(rel.target.toLowerCase());
      const manyPadding = ' '.repeat(Math.max(1, 15 - pluralName.length));
      fields.push(`${pluralName}${manyPadding}${rel.target}[]${relAnnotation}`);
      break;

    case 'manyToMany':
      const manyName = rel.name || pluralize(rel.target.toLowerCase());
      const m2mPadding = ' '.repeat(Math.max(1, 15 - manyName.length));
      fields.push(`${manyName}${m2mPadding}${rel.target}[]${relAnnotation}`);
      break;
  }

  return fields;
}

/**
 * Convert camelCase to snake_case
 */
function camelToSnake(str: string): string {
  return str.replace(/[A-Z]/g, letter => `_${letter.toLowerCase()}`);
}

/**
 * Map SpecVerse type to Prisma type
 */
function mapTypeToPrisma(type: string, dbType?: string): string {
  // Use database type if provided
  if (dbType) {
    if (dbType.startsWith('VARCHAR') || dbType === 'TEXT') return 'String';

    const typeMap: Record<string, string> = {
      'INTEGER': 'Int',
      'BIGINT': 'BigInt',
      'DECIMAL': 'Decimal',
      'BOOLEAN': 'Boolean',
      'DATE': 'DateTime',
      'TIMESTAMP': 'DateTime',
      'UUID': 'String',
      'JSONB': 'Json',
      'JSON': 'Json',
      'FLOAT': 'Float'
    };

    if (typeMap[dbType]) return typeMap[dbType];
  }

  // Infer from SpecVerse type
  const typeLower = type.toLowerCase();

  if (typeLower.includes('string') || typeLower.includes('text')) return 'String';
  if (typeLower.includes('int')) return 'Int';
  if (typeLower.includes('bool')) return 'Boolean';
  if (typeLower.includes('date') || typeLower.includes('time')) return 'DateTime';
  if (typeLower.includes('float') || typeLower.includes('decimal')) return 'Float';
  if (typeLower.includes('json')) return 'Json';

  return 'String'; // default
}

/**
 * Helper functions
 */
function isAuditField(name: string): boolean {
  const auditFields = ['createdAt', 'updatedAt', 'createdBy', 'updatedBy',
                       'created_at', 'updated_at', 'created_by', 'updated_by'];
  return auditFields.includes(name);
}

function isSoftDeleteField(name: string): boolean {
  const softDeleteFields = ['deletedAt', 'isDeleted', 'deleted_at', 'is_deleted'];
  return softDeleteFields.includes(name);
}

function isVersionField(name: string): boolean {
  const versionFields = ['version', 'versionNumber', 'version_number'];
  return versionFields.includes(name);
}

