/**
 * Postgres native (pg) — schema.sql DDL generator.
 *
 * Emits a single `schema.sql` script with one CREATE TABLE statement per
 * spec model, plus indexes derived from `unique` and FK declarations.
 * Designed to be run via `npm run db:setup` (which pipes the file through
 * `psql "$POSTGRES_URL"`). Idempotent: every CREATE is `IF NOT EXISTS`.
 *
 * Type mapping (best-effort, kept simple):
 *   String, UUID, Text, Email     → TEXT
 *   Integer, Int                  → INTEGER
 *   BigInt                        → BIGINT
 *   Float, Number                 → DOUBLE PRECISION
 *   Boolean                       → BOOLEAN
 *   DateTime, Date                → TIMESTAMPTZ
 *   UUID                          → UUID
 *   Json, Jsonb                   → JSONB
 *
 * Primary key:
 *   - Composite via `model.keys: [a, b]` or `model.primaryKey: [a, b]` →
 *     PRIMARY KEY (a, b) constraint at the end of the table; the columns
 *     are forced NOT NULL and the synthesised id column is suppressed.
 *   - `id UUID required`           → id UUID PRIMARY KEY DEFAULT gen_random_uuid()
 *   - any other "id" required      → id TEXT PRIMARY KEY
 *
 * Composite unique constraints:
 *   - `model.unique: [[a, b], [c, d]]` → UNIQUE (a, b) constraint per
 *     entry. Mixed: a single string array `[a, b]` is interpreted as ONE
 *     composite (NOT two single-column ones) — use the
 *     attribute-level `unique: true` for single-column uniqueness.
 *
 * Partial indexes:
 *   - `attribute.index: { where: "deleted_at IS NULL", unique: true }`
 *     emits `CREATE [UNIQUE] INDEX ... WHERE deleted_at IS NULL` after
 *     the table. Adopt for soft-delete + tenant-scoped uniqueness.
 *
 * Out of scope (deferred — see TODO #43L): RLS policies, check
 * constraints derived from `min/max` ranges, materialised views.
 */
import type { TemplateContext } from '@specverse/types';

export default function generatePgSchemaSql(context: TemplateContext): string {
  const { spec, models } = context as any;

  const allModels: Array<[string, any]> = [];
  if (Array.isArray(models) && models.length > 0) {
    for (const m of models) if (m?.name) allModels.push([m.name, m]);
  } else if (spec?.components) {
    const components = Array.isArray(spec.components)
      ? spec.components
      : Object.values(spec.components);
    for (const comp of components as any[]) {
      const ms = comp?.models;
      if (!ms) continue;
      const entries: [string, any][] = Array.isArray(ms)
        ? ms.map((m: any) => [m.name, m])
        : Object.entries(ms);
      for (const [name, m] of entries) {
        if (name) allModels.push([name, m]);
      }
    }
  }

  const banner = `-- ============================================================
-- ${spec?.metadata?.component || 'Application'} — schema.sql
-- Generated by @specverse/engines (postgres-native).
-- Run via: psql "$POSTGRES_URL" -f src/db/schema.sql
-- Re-runnable: every statement is IF NOT EXISTS / idempotent.
-- ============================================================
CREATE EXTENSION IF NOT EXISTS "pgcrypto";  -- for gen_random_uuid()
`;

  if (allModels.length === 0) {
    return banner + '\n-- No models declared. (Add one to your spec to populate this file.)\n';
  }

  const tableBlocks = allModels.map(([name, model]) => generateTable(name, model));
  return banner + '\n' + tableBlocks.join('\n\n') + '\n';
}

function generateTable(modelName: string, model: any): string {
  const table = model?.storage?.table || (modelName.toLowerCase() + 's');
  const attrs = model.attributes;
  if (!attrs) {
    return `-- ${modelName}\nCREATE TABLE IF NOT EXISTS "${table}" (\n  id TEXT PRIMARY KEY\n);`;
  }
  const list: [string, any][] = Array.isArray(attrs)
    ? attrs.map((a: any) => [a.name, a])
    : Object.entries(attrs);

  // Composite primary key — read either `keys` or `primaryKey`. Both
  // accepted shapes: array of strings (one composite) OR a single string
  // (degenerate single-column PK; used by some specs as a way to point
  // at a non-`id` column).
  const compositePk = parseColumnList(model.keys ?? model.primaryKey);
  // Composite uniques — array of arrays, each defining one constraint.
  const compositeUniques = parseCompositeUnique(model.unique);

  const columns: string[] = [];
  const indexes: string[] = [];
  let hasIdColumn = false;
  const compositePkSet = new Set(compositePk);

  for (const [colName, attr] of list) {
    if (!colName) continue;
    // When this column is part of a composite PK, force NOT NULL and
    // skip the column-level PRIMARY KEY (the constraint goes at the end
    // of the CREATE TABLE).
    const partOfCompositePk = compositePkSet.has(colName);
    const { sql, isPk, isUnique } = columnDef(colName, attr, modelName, partOfCompositePk);
    if (isPk) hasIdColumn = true;
    columns.push('  ' + sql);

    if (isUnique && !isPk && !partOfCompositePk) {
      indexes.push(
        `CREATE UNIQUE INDEX IF NOT EXISTS "${table}_${colName}_uq" ON "${table}" ("${colName}");`,
      );
    } else if (colName.endsWith('Id') && colName !== 'id' && !partOfCompositePk) {
      // FK columns — index for join performance.
      indexes.push(
        `CREATE INDEX IF NOT EXISTS "${table}_${colName}_idx" ON "${table}" ("${colName}");`,
      );
    }

    // Partial indexes — declared at the attribute level so the column +
    // its specialised index live together in the spec. `attr.index` is
    // either `true` (plain index, no WHERE) or `{ where, unique }`.
    const partial = parsePartialIndex(attr.index);
    if (partial) {
      const indexName = `${table}_${colName}${partial.unique ? '_uq' : '_idx'}_partial`;
      const uniqueClause = partial.unique ? 'UNIQUE ' : '';
      const whereClause = partial.where ? ` WHERE ${partial.where}` : '';
      indexes.push(
        `CREATE ${uniqueClause}INDEX IF NOT EXISTS "${indexName}" ON "${table}" ("${colName}")${whereClause};`,
      );
    }
  }

  // Composite PK takes precedence over the synthesised id column. Skip
  // the synthesised id only if the composite covers every "id" need.
  if (compositePk.length === 0 && !hasIdColumn) {
    columns.unshift('  "id" TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text');
  }

  // createdAt / updatedAt — provide defaults if not declared.
  const declaredNames = new Set(list.map(([n]) => n));
  if (!declaredNames.has('createdAt')) {
    columns.push('  "createdAt" TIMESTAMPTZ NOT NULL DEFAULT NOW()');
  }
  if (!declaredNames.has('updatedAt')) {
    columns.push('  "updatedAt" TIMESTAMPTZ NOT NULL DEFAULT NOW()');
  }

  // Lifecycle columns.
  const lifecycles = Array.isArray(model.lifecycles)
    ? model.lifecycles
    : (model.lifecycles ? Object.entries(model.lifecycles).map(([name, lc]: [string, any]) => ({ name, ...lc })) : []);
  for (const lc of lifecycles) {
    const lcName = lc?.name;
    if (!lcName || declaredNames.has(lcName)) continue;
    const initial = deriveInitialState(lc);
    const defaultClause = initial ? ` DEFAULT '${initial.replace(/'/g, "''")}'` : '';
    columns.push(`  "${lcName}" TEXT NOT NULL${defaultClause}`);
  }

  // Table-level constraints (composite PK + composite uniques) go at
  // the end of the column list so the table reads top-to-bottom in the
  // natural order: columns, then constraints.
  if (compositePk.length > 0) {
    columns.push(`  PRIMARY KEY (${compositePk.map(quoteIdent).join(', ')})`);
  }
  for (const cols of compositeUniques) {
    columns.push(`  UNIQUE (${cols.map(quoteIdent).join(', ')})`);
  }

  const indexBlock = indexes.length > 0 ? '\n\n' + indexes.join('\n') : '';
  return `-- ${modelName}\nCREATE TABLE IF NOT EXISTS "${table}" (\n${columns.join(',\n')}\n);${indexBlock}`;
}

interface ColumnDefResult {
  sql: string;
  isPk: boolean;
  isUnique: boolean;
}

function columnDef(name: string, attr: any, _modelName: string, forceNotNull: boolean): ColumnDefResult {
  const required = !!attr.required || forceNotNull;
  const unique = !!attr.unique;
  const type = (attr.type || 'String').toLowerCase();
  const isIdColumn = name === 'id';

  // When the column is part of a composite PK, suppress the column-level
  // PRIMARY KEY — the table-level PRIMARY KEY (a, b) constraint covers it.
  if (isIdColumn && !forceNotNull) {
    if (type === 'uuid') {
      return { sql: `"id" UUID PRIMARY KEY DEFAULT gen_random_uuid()`, isPk: true, isUnique: true };
    }
    return { sql: `"id" TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text`, isPk: true, isUnique: true };
  }

  let sqlType: string;
  switch (type) {
    case 'integer':
    case 'int':
      sqlType = 'INTEGER';
      break;
    case 'bigint':
      sqlType = 'BIGINT';
      break;
    case 'float':
    case 'number':
    case 'double':
      sqlType = 'DOUBLE PRECISION';
      break;
    case 'boolean':
    case 'bool':
      sqlType = 'BOOLEAN';
      break;
    case 'datetime':
    case 'date':
    case 'timestamp':
      sqlType = 'TIMESTAMPTZ';
      break;
    case 'uuid':
      sqlType = 'UUID';
      break;
    case 'json':
    case 'jsonb':
      sqlType = 'JSONB';
      break;
    default:
      sqlType = 'TEXT';
  }

  const nullness = required ? ' NOT NULL' : '';
  const uniqueClause = unique ? ' UNIQUE' : '';
  const defaultClause = formatColumnDefault(attr.default, type);
  return {
    sql: `"${name}" ${sqlType}${nullness}${uniqueClause}${defaultClause}`,
    isPk: false,
    isUnique: unique,
  };
}

/** Derive the initial state from a lifecycle declaration. Handles:
 *  - explicit `initial: stateName`
 *  - explicit states[] array — first entry
 *  - `flow: "a -> b -> c"` shorthand — first arrow-source
 */
function deriveInitialState(lc: any): string | null {
  if (!lc) return null;
  if (typeof lc.initial === 'string') return lc.initial;
  if (Array.isArray(lc.states) && lc.states.length > 0) {
    const first = lc.states[0];
    return typeof first === 'string' ? first : (first?.name ?? null);
  }
  if (typeof lc.flow === 'string') {
    const head = lc.flow.split(/\s*->\s*/)[0]?.trim();
    return head || null;
  }
  if (lc.transitions && typeof lc.transitions === 'object') {
    const first = Object.values(lc.transitions)[0];
    if (typeof first === 'string') return first.split(/\s*->\s*/)[0]?.trim() || null;
  }
  return null;
}

function formatColumnDefault(value: any, type: string): string {
  if (value === undefined || value === null) return '';
  if (typeof value === 'boolean') return ` DEFAULT ${value}`;
  if (typeof value === 'number') return ` DEFAULT ${value}`;
  if (typeof value === 'string') {
    if (/^-?\d+(\.\d+)?$/.test(value)) return ` DEFAULT ${value}`;
    if (value === 'true' || value === 'false') return ` DEFAULT ${value}`;
    if (value === 'now' && (type === 'datetime' || type === 'date' || type === 'timestamp')) {
      return ` DEFAULT NOW()`;
    }
    return ` DEFAULT '${value.replace(/'/g, "''")}'`;
  }
  return '';
}

/** Parse a `keys:` / `primaryKey:` declaration. Accepts:
 *   - undefined / empty → []
 *   - string → [string]   (single-column PK named explicitly; degenerate)
 *   - string[] → as-is    (composite PK)
 *   - string[][] → flatten the first entry (defensive — some specs nest)
 */
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 a composite-unique declaration. Accepts:
 *   - undefined → []
 *   - string[]  → [[...]]   (single composite spanning the listed columns)
 *   - string[][] → as-is    (multiple composites)
 *
 * Single-column uniqueness is intentionally left at the attribute level
 * (`attr.unique: true`) so the spec author signals intent at the right
 * scope. Mixing single + composite at this entry would be ambiguous.
 */
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 [];
}

interface PartialIndex {
  where: string | null;
  unique: boolean;
}

/** Parse an attribute-level `index` declaration. Accepts:
 *   - undefined / false → null         (no extra index)
 *   - true → { where: null, unique: false }   (plain index, no WHERE — same
 *     as a regular index but explicit; useful for non-FK columns)
 *   - { where, unique } → as-is
 */
function parsePartialIndex(raw: any): PartialIndex | null {
  if (raw === undefined || raw === null || raw === false) return null;
  if (raw === true) return { where: null, unique: false };
  if (typeof raw === 'object') {
    const where = typeof raw.where === 'string' && raw.where.trim() !== ''
      ? raw.where.trim()
      : null;
    const unique = !!raw.unique;
    // No `where` AND no `unique` → equivalent to `index: true`. Still
    // emit so the spec author's explicit declaration stays meaningful.
    return { where, unique };
  }
  return null;
}

function quoteIdent(name: string): string {
  return '"' + name.replace(/"/g, '""') + '"';
}
