'use strict';

/**
 * Payload Runner - Shared library untuk seluruh payload subcommand.
 *
 * Dipakai oleh 4 contract di generators/cli/payload/:
 * - generate : non-interactive (--config dan --table wajib disertakan)
 * - validate : mode --validate (read-only schema comparison)
 * - diff     : mode --diff (detailed schema comparison)
 * - sync     : mode --sync (update payload dari database, archive file lama)
 *
 * Inline migration v4.0.0: dipindah dari generators/create-payload.js
 * ke generators/lib/payload/payload-runner.js. parseArguments()/showHelp()/main()
 * dihapus karena cli-entry.js + contract handler menangani argv parsing.
 * process.exit() di method run() dikonversi menjadi throw / return, sesuai
 * konvensi inline migration (pattern guide: handler tidak boleh process.exit()).
 *
 * Exit code semantics:
 * - run() return normal -> exit 0 via cli-entry.js
 * - run() throw error   -> exit 1 via cli-entry.js
 * - Untuk mode validate/diff/sync, drift atau error sama-sama trigger throw
 *   (semantik "action required" untuk MCP/LSP).
 */

const fs = require('fs');
const path = require('path');

const { DatabaseIntrospector, loadDriver } = require('../utils/database-introspector');
const { resolveConfig, printDefaultConfigWarning } = require('../utils/config-resolver');
const {
    DEFAULT_AUDIT_COLUMNS,
    detectAuditAlignment
} = require('../utils/audit-columns');
const { compareSchemaStrict } = require('./schema-diff');

// Kolom audit yang di-handle otomatis oleh RESTForge runtime (base-model).
// Konstanta dipakai dari shared util agar source-of-truth tunggal lintas
// modul (sync, validate, endpoint create). Kolom ini di-exclude dari payload
// hasil generate karena nilainya selalu di-set runtime saat create/update.
//
// Bila tabel tidak memiliki satupun kolom dari list ini, generator otomatis
// menyertakan "auditColumns": false di payload supaya template generator
// men-set this.auditColumns = null di model. Tanpa flag ini, runtime akan
// mencoba inject kolom audit ke INSERT dan menyebabkan error 500.

// Deteksi environment - Bun compiled binary atau Node.js/Bun script
const isBun = typeof Bun !== 'undefined';

// Detection logic untuk compiled binary:
// 1. Cek apakah argv[1] mengandung $bunfs (Bun virtual filesystem)
// 2. Cek apakah execPath bukan path ke bun binary standar
// 3. Cek apakah nama executable mengandung 'restforge'
function detectBunCompiled() {
    if (!isBun) return false;

    const hasVirtualFS = process.argv[1] && process.argv[1].includes('$bunfs');
    const execPathLower = process.execPath.toLowerCase();
    const isBunStandardPath = execPathLower.includes('bun') &&
                              (execPathLower.endsWith('bun') || execPathLower.endsWith('bun.exe'));
    const isRestforgeBinary = execPathLower.includes('restforge');

    return hasVirtualFS || isRestforgeBinary || !isBunStandardPath;
}

const isBunCompiled = detectBunCompiled();
const isCompiledBinary = isBunCompiled;
const isScript = !isCompiledBinary;

/**
 * Fungsi untuk mendapatkan working directory yang benar.
 * Per pattern inline migration: panggil di handler-level (atau constructor),
 * BUKAN di module-level. Sebab unit test melakukan chdir ke temp dir setelah
 * module sudah di-load, sehingga snapshot module-level tidak ter-recognize.
 * @returns {string} Working directory path
 */
function getWorkingDirectory() {
  if (isCompiledBinary) {
    const exeDir = path.dirname(process.execPath);
    // Jika binary berada di node_modules (dijalankan via npx/npm),
    // gunakan process.cwd() agar resolve ke project directory user
    if (exeDir.includes('node_modules')) {
      return process.cwd();
    }
    // Standalone binary: gunakan directory dimana binary berada
    return exeDir;
  } else {
    return process.cwd();
  }
}

// Suppress experimental warnings untuk compiled binary environment
if (isCompiledBinary) {
  process.removeAllListeners('warning');
}


/**
 * Class untuk Payload Generator (non-interactive only)
 */
class PayloadGenerator {
  constructor() {
    this.workingDir = getWorkingDirectory();
    this.db = new DatabaseIntrospector();
    this.outputDir = path.join(this.workingDir, 'payload');
  }

  /**
   * Ensure output directory exists
   */
  ensureOutputDir() {
    if (!fs.existsSync(this.outputDir)) {
      fs.mkdirSync(this.outputDir, { recursive: true });
      console.log(`Output directory created: ${this.outputDir}`);
    }
  }

  /**
   * Generate dateTimeFields config from column types
   * @param {Object} columnTypes - Map of column_name -> data_type
   * @param {Array} fieldNames - Array of field names
   * @returns {Object} dateTimeFields configuration
   */
  generateDateTimeFields(columnTypes, fieldNames) {
    const dateTimeFields = {};

    for (const fieldName of fieldNames) {
      const dataType = (columnTypes[fieldName] || '').toLowerCase();

      if (dataType === 'date') {
        dateTimeFields[fieldName] = {
          type: 'date',
          format: 'dd/MM/yyyy'
        };
      } else if (['timestamp', 'timestamp without time zone', 'timestamp with time zone'].includes(dataType)
        || dataType.startsWith('timestamp')) {
        dateTimeFields[fieldName] = {
          type: 'timestamp',
          format: 'dd/MM/yyyy HH:mm'
        };
      } else if (['time', 'time without time zone', 'time with time zone'].includes(dataType)) {
        dateTimeFields[fieldName] = {
          type: 'time',
          format: 'HH:mm'
        };
      } else if (dataType === 'datetime') {
        // MySQL datetime type
        dateTimeFields[fieldName] = {
          type: 'timestamp',
          format: 'dd/MM/yyyy HH:mm'
        };
      }
    }

    return dateTimeFields;
  }

  /**
   * Enrich detailedColumns dengan flag `is_unique` berdasarkan output
   * db.getConstraints(). Hanya constraint UNIQUE single-column yang ditandai;
   * UNIQUE composite (multi-column) di-skip karena tidak dapat diatribusikan
   * ke satu kolom secara semantik (uniqueness berlaku pada kombinasi kolom,
   * bukan kolom individual).
   *
   * PRIMARY KEY tidak di-tag is_unique karena sudah di-handle terpisah via
   * `isVarcharLikePk` / `isUuidNativeType` di generateFieldValidation.
   *
   * Pemanggil bertanggung jawab fetch constraints via `db.getConstraints(table)`
   * sebelum memanggil method ini.
   *
   * @param {Array} detailedColumns - Array from getDetailedColumnInfo()
   * @param {Array} constraints - Array from getConstraints() with shape {name, type, columns}
   * @returns {Array} New array dengan flag is_unique pada kolom yang relevan
   */
  enrichDetailedColumnsWithConstraints(detailedColumns, constraints) {
    if (!Array.isArray(detailedColumns)) return [];
    if (!Array.isArray(constraints) || constraints.length === 0) return detailedColumns;

    const uniqueColumns = new Set();
    for (const c of constraints) {
      if (c.type === 'UNIQUE' && Array.isArray(c.columns) && c.columns.length === 1) {
        uniqueColumns.add(c.columns[0]);
      }
    }

    if (uniqueColumns.size === 0) return detailedColumns;

    return detailedColumns.map(col => (
      uniqueColumns.has(col.column_name) ? { ...col, is_unique: true } : col
    ));
  }

  /**
   * Enrich detailedColumns dengan menandai kolom boolean berdasarkan output
   * db.getBooleanColumns() (deteksi penanda CHECK `IN ('true','false')` pada
   * dialect varchar mysql/oracle/sqlite). Kolom yang cocok di-set
   * data_type='boolean' agar generateFieldValidation menghasilkan type:'boolean',
   * konsisten dengan PostgreSQL yang punya kolom BOOLEAN native.
   *
   * Default kolom varchar berbentuk string ('true'/'false', kadang dengan tanda
   * kutip) dinormalisasi ke 'true'/'false' tanpa kutip agar default boolean
   * terdeteksi di generateFieldValidation.
   *
   * @param {Array} detailedColumns - Array from getDetailedColumnInfo()
   * @param {string[]} booleanColumns - Nama kolom boolean (lowercase) dari getBooleanColumns()
   * @returns {Array} New array dengan data_type='boolean' pada kolom yang cocok
   */
  enrichDetailedColumnsWithBoolean(detailedColumns, booleanColumns) {
    if (!Array.isArray(detailedColumns)) return [];
    if (!Array.isArray(booleanColumns) || booleanColumns.length === 0) return detailedColumns;

    const boolSet = new Set(booleanColumns.map(c => String(c).toLowerCase()));

    return detailedColumns.map(col => {
      if (!boolSet.has(String(col.column_name).toLowerCase())) return col;

      let def = col.column_default;
      if (typeof def === 'string') {
        const stripped = def.trim().replace(/^'(.*)'$/, '$1').toLowerCase();
        if (stripped === 'true' || stripped === 'false') def = stripped;
      }

      return { ...col, data_type: 'boolean', udt_name: 'boolean', column_default: def };
    });
  }

  /**
   * Generate fieldValidation array from detailed column info
   * Includes: primary key (UUID/auto-generate), numeric, boolean, date/time, and
   * string fields yang membawa constraint actionable dari DB introspection
   * (NOT NULL -> required, character_maximum_length -> maxLength,
   * UNIQUE single-column -> unique). Nullable string tanpa constraint tetap
   * di-skip agar payload tidak bloat.
   *
   * Untuk derive `unique`, kolom harus punya flag `is_unique: true` yang
   * di-inject oleh `enrichDetailedColumnsWithConstraints()` sebelum dipanggil.
   *
   * @param {Array} detailedColumns - Array from getDetailedColumnInfo() (opsional di-enrich dengan is_unique)
   * @param {Array} fieldNames - Array of selected field names
   * @param {string} primaryKey - Primary key field name
   * @returns {Array} fieldValidation array
   */
  generateFieldValidation(detailedColumns, fieldNames, primaryKey) {
    const fieldValidation = [];

    // Build lookup map from detailed columns
    const columnMap = {};
    for (const col of detailedColumns) {
      columnMap[col.column_name] = col;
    }

    for (const fieldName of fieldNames) {
      const col = columnMap[fieldName];
      if (!col) continue;

      const dataType = (col.data_type || '').toLowerCase();
      const udtName = (col.udt_name || '').toLowerCase();
      const columnDefault = (col.column_default || '').toLowerCase();
      const isPrimaryKey = fieldName === primaryKey;

      // --- Primary key detection ---
      // Native UUID type (PostgreSQL `uuid`) -> type: 'uuid' + primaryKey + autoGenerate.
      //
      // Varchar/text PK -> type: 'string' + primaryKey + autoGenerate + required.
      // Berlaku baik untuk varchar PK dengan DEFAULT (PostgreSQL uuid_generate_v4,
      // Oracle SYS_GUID) maupun tanpa DEFAULT (MySQL dengan BEFORE INSERT trigger
      // UUID(), atau PK yang di-handle runtime). Output identik lintas database
      // karena base-model runtime selalu auto-generate UUID via uuidv7() untuk PK
      // kosong, independen dari DEFAULT/trigger database. Konvensi project:
      //   PostgreSQL: VARCHAR(70) PRIMARY KEY DEFAULT uuid_generate_v4()::VARCHAR
      //   Oracle:     VARCHAR2(70) DEFAULT SYS_GUID() PRIMARY KEY
      //   MySQL:      VARCHAR(70) PRIMARY KEY (UUID via trigger)
      const isUuidNativeType = dataType === 'uuid' || udtName === 'uuid';
      const isVarcharLikePk = isPrimaryKey
        && ['character varying', 'varchar', 'text'].includes(dataType)
        && !columnDefault.includes('nextval');

      if (isPrimaryKey && isUuidNativeType) {
        fieldValidation.push({
          name: fieldName,
          type: 'uuid',
          constraints: {
            primaryKey: true,
            autoGenerate: true
          }
        });
        continue;
      }

      if (isVarcharLikePk) {
        const constraints = {
          primaryKey: true,
          autoGenerate: true,
          required: true
        };
        // Defense in depth: derive maxLength dari character_maximum_length agar
        // user yang kirim PK manual (override autoGenerate) tetap divalidasi
        // di application layer sebelum hit DB. UUID v7 generated value (36 char)
        // selalu fit di varchar(>=36); maxLength tidak mengganggu autoGenerate.
        if (col.character_maximum_length) {
          constraints.maxLength = col.character_maximum_length;
        }
        fieldValidation.push({
          name: fieldName,
          type: 'string',
          constraints
        });
        continue;
      }

      // --- MySQL auto_increment primary key: SKIP ---
      const extraInfo = (col.extra || '').toLowerCase();
      if (isPrimaryKey && extraInfo.includes('auto_increment')) {
        continue;
      }

      // --- Integer types ---
      // PostgreSQL: integer, bigint, smallint, serial, bigserial
      // MySQL: int, tinyint, smallint, mediumint, bigint (normalized to lowercase by getDetailedColumnInfo)
      // Oracle: already normalized to 'integer' by getDetailedColumnInfo when data_scale=0
      if (['integer', 'bigint', 'smallint', 'serial', 'bigserial', 'smallserial',
           'int', 'tinyint', 'mediumint'].includes(dataType) ||
          ['int4', 'int8', 'int2', 'serial4', 'serial8', 'serial2'].includes(udtName)) {

        // Skip serial/auto-increment primary key
        if (isPrimaryKey && (dataType.includes('serial') || columnDefault.includes('nextval'))) {
          continue;
        }

        // Skip MySQL tinyint(1) — handled as boolean below
        // (MySQL tinyint(1) is commonly used for boolean, but data_type from information_schema is 'tinyint')
        // We'll check boolean pattern below instead

        const entry = {
          name: fieldName,
          type: 'integer',
          constraints: {}
        };

        // Default value
        if (col.column_default !== null && !(col.column_default + '').includes('nextval')) {
          const defaultVal = parseInt(col.column_default, 10);
          if (!isNaN(defaultVal)) {
            entry.constraints.default = defaultVal;
          }
        }

        fieldValidation.push(entry);
        continue;
      }

      // --- Numeric/Decimal types ---
      // PostgreSQL: numeric, decimal, real, double precision
      // MySQL: decimal, float, double
      // Oracle: already normalized to 'numeric' by getDetailedColumnInfo when data_scale>0
      if (['numeric', 'decimal', 'real', 'double precision', 'float', 'double'].includes(dataType) ||
          ['numeric', 'float4', 'float8'].includes(udtName)) {

        const entry = {
          name: fieldName,
          type: 'number',
          constraints: {}
        };

        // Precision from database
        if (col.numeric_precision !== null && col.numeric_scale !== null && col.numeric_scale > 0) {
          entry.constraints.precision = col.numeric_scale;
        }

        // Default value
        if (col.column_default !== null) {
          const defaultVal = parseFloat(col.column_default);
          if (!isNaN(defaultVal)) {
            entry.constraints.default = defaultVal;
          }
        }

        fieldValidation.push(entry);
        continue;
      }

      // --- Boolean type ---
      // PostgreSQL: boolean / bool
      // MySQL: tinyint(1) — NOT detected here, MySQL uses varchar with 'true'/'false' in RESTForge
      // Oracle: uses varchar2 with CHECK constraint for 'true'/'false' in RESTForge
      if (dataType === 'boolean' || udtName === 'bool') {
        const entry = {
          name: fieldName,
          type: 'boolean',
          constraints: {}
        };

        // Default value
        if (col.column_default !== null) {
          if (columnDefault === 'true') {
            entry.constraints.default = true;
          } else if (columnDefault === 'false') {
            entry.constraints.default = false;
          }
        }

        fieldValidation.push(entry);
        continue;
      }

      // --- Date type ---
      if (dataType === 'date') {
        const entry = {
          name: fieldName,
          type: 'date',
          constraints: {
            format: 'dd/MM/yyyy'
          }
        };

        fieldValidation.push(entry);
        continue;
      }

      // --- Timestamp / Datetime types ---
      // PostgreSQL: timestamp, timestamp without time zone, timestamp with time zone
      // MySQL: timestamp, datetime
      // Oracle: already normalized to 'timestamp' by getDetailedColumnInfo
      if (['timestamp', 'timestamp without time zone', 'timestamp with time zone', 'datetime'].includes(dataType)
        || dataType.startsWith('timestamp')) {
        const entry = {
          name: fieldName,
          type: 'datetime',
          constraints: {
            format: 'dd/MM/yyyy HH:mm:ss'
          }
        };

        // Detect auto-generate: now(), CURRENT_TIMESTAMP, SYSTIMESTAMP
        if (columnDefault.includes('now') || columnDefault.includes('current_timestamp')
          || columnDefault.includes('systimestamp')) {
          entry.constraints.autoGenerate = true;
        }

        fieldValidation.push(entry);
        continue;
      }

      // --- Time types ---
      if (['time', 'time without time zone', 'time with time zone'].includes(dataType)) {
        const entry = {
          name: fieldName,
          type: 'time',
          constraints: {
            format: 'HH:mm:ss'
          }
        };

        fieldValidation.push(entry);
        continue;
      }

      // --- String fields ---
      // VARCHAR/TEXT/CHAR non-PK: derive validation actionable dari DB introspection.
      //   - required: dari is_nullable = 'NO' (NOT NULL di database)
      //   - maxLength: dari character_maximum_length
      //   - unique:   dari flag is_unique yang di-inject via enrichDetailedColumnsWithConstraints()
      // PK varchar sudah di-handle di branch isVarcharLikePk di atas (continue).
      // Defensive guard `isPrimaryKey` mencegah duplicate entry bila PK varchar
      // lolos branch isVarcharLikePk (mis. PK dengan nextval default).
      // Nullable string tanpa constraint sama sekali tetap di-skip agar payload
      // tidak bloat dengan entry kosong yang tidak menambah validasi runtime.
      if (['character varying', 'varchar', 'text', 'character', 'char'].includes(dataType)
        || ['varchar', 'text', 'bpchar'].includes(udtName)) {

        if (isPrimaryKey) continue;

        const entry = {
          name: fieldName,
          type: 'string',
          constraints: {}
        };

        if (col.is_nullable === 'NO' || col.is_nullable === false) {
          entry.constraints.required = true;
        }

        if (col.character_maximum_length) {
          entry.constraints.maxLength = col.character_maximum_length;
        }

        if (col.is_unique === true) {
          entry.constraints.unique = true;
        }

        if (Object.keys(entry.constraints).length === 0) {
          continue;
        }

        fieldValidation.push(entry);
        continue;
      }
    }

    return fieldValidation;
  }


  /**
   * Main run method
   * @param {Object} args - Command line arguments
   */
  async run(args) {
    try {
      // MANDATORY: Database config harus disediakan (eksplisit via --config atau via default)
      const resolved = resolveConfig(args.config, this.workingDir);
      if (!resolved) {
        console.error('='.repeat(60));
        console.error('ERROR: Database configuration required');
        console.error('='.repeat(60));
        console.error();
        console.error('Usage:');
        console.error('  npx restforge payload generate --config=<filename.env> --table=<table-name>');
        console.error();
        console.error('Example:');
        console.error('  npx restforge payload generate --config=mini-inventory.env --table=users');
        console.error('  npx restforge payload generate --config=database.env --table=orders');
        console.error();
        console.error('The config file must be in the config/ directory or current directory.');
        console.error();
        console.error("Tip: set a default with 'npx restforge config set-default --config=<file>' to omit --config in future runs.");
        console.error();
        throw new Error('Database configuration required');
      }

      if (resolved.source === 'default') {
        printDefaultConfigWarning(resolved.defaultName);
      }

      // Load database config
      console.log('='.repeat(60));
      console.log('Loading Database Configuration');
      console.log('='.repeat(60));
      console.log();

      const configPath = resolved.path;

      // Load config - throw jika gagal
      if (!this.db.loadConfig(configPath)) {
        console.error();
        console.error('FATAL: Failed to load database configuration');
        console.error(`Config file: ${configPath}`);
        console.error();
        throw new Error('Failed to load database configuration');
      }

      // Connect to database - throw jika gagal
      const connected = await this.db.connect();
      if (!connected) {
        console.error();
        console.error('FATAL: Failed to connect to database');
        console.error('Please check your database configuration and connection.');
        console.error();
        throw new Error('Failed to connect to database');
      }

      console.log();
      console.log('Database connected successfully');
      console.log();

      // Set output directory if provided
      if (args.output) {
        this.outputDir = path.isAbsolute(args.output)
          ? args.output
          : path.join(this.workingDir, args.output);
      }

      // Schema validation modes: --validate, --diff, --sync
      if (args.validate || args.diff || args.sync) {
        const validator = new SchemaValidator(this.db, this.outputDir);
        const targetTable = args.table || null;

        let result;
        if (args.sync) {
          result = await validator.runSync(targetTable, this);
        } else if (args.diff) {
          result = await validator.runDiff(targetTable);
        } else {
          result = await validator.runValidate(targetTable);
        }

        // Cleanup
        await this.db.close();

        // Drift, error, atau target tidak ditemukan -> throw (exit 1 via cli-entry.js).
        // Semantik "action required" untuk MCP/LSP integration.
        // Marked silent karena verdict + summary sudah di-print oleh runValidate/
        // runDiff/runSync; pesan "Error: Payload schema action required: ..."
        // hanya menambah noise di stderr untuk human user.
        const hasDrift = (result?.drift ?? 0) > 0;
        const hasError = (result?.error ?? 0) > 0;
        const targetMissing = targetTable && (result?.total ?? 0) === 0;

        if (hasDrift || hasError || targetMissing) {
          const reasons = [];
          if (hasDrift) reasons.push(`drift=${result.drift}`);
          if (hasError) reasons.push(`error=${result.error}`);
          if (targetMissing) reasons.push(`target '${targetTable}' not found`);
          const err = new Error(`Payload schema action required: ${reasons.join(', ')}`);
          err.silent = true;
          err.exitCode = 1;
          throw err;
        }

        return;
      }

      // Default mode generate: --table wajib (interactive mode sudah dihapus)
      await this.runNonInteractive(args);

      await this.db.close();

      console.log();
      console.log('Done.');
      console.log();
    } catch (error) {
      // Cleanup resources lalu re-throw agar cli-entry.js handle exit code.
      try { await this.db.close(); } catch (_e) { /* ignore close errors */ }
      throw error;
    }
  }

  /**
   * Run in non-interactive mode
   * @param {Object} args - Command line arguments
   */
  async runNonInteractive(args) {
    console.log();
    console.log('Running in non-interactive mode...');
    console.log();

    // Pre-check: verify table exists before attempting introspection
    if (this.db.pool) {
      const exists = await this.db.tableExists(args.table);
      if (!exists) {
        console.error(`Error: Table '${args.table}' does not exist.`);
        await this.db.close();
        throw new Error(`Table '${args.table}' does not exist`);
      }
    }

    const payloadData = {
      tableName: args.table,
      primaryKey: args.primaryKey || null
    };

    // Get primary key from database if not provided
    if (!payloadData.primaryKey && this.db.pool) {
      payloadData.primaryKey = await this.db.getPrimaryKey(args.table);
    }

    if (!payloadData.primaryKey) {
      // Guess from table name
      const tableNamePart = args.table.split('.').pop();
      payloadData.primaryKey = tableNamePart.replace(/s$/, '') + '_id';
    }

    console.log(`Table: ${payloadData.tableName}`);
    console.log(`Primary Key: ${payloadData.primaryKey}`);

    // Get fields from database
    if (this.db.pool) {
      const columns = await this.db.getColumns(args.table);
      if (columns.length > 0) {
        // Filter kolom audit yang di-handle otomatis oleh runtime.
        const excludedPresent = columns.filter(col => DEFAULT_AUDIT_COLUMNS.includes(col));
        payloadData.fieldName = columns.filter(col => !DEFAULT_AUDIT_COLUMNS.includes(col));
        if (excludedPresent.length > 0) {
          console.log(`Auto-managed columns excluded: ${excludedPresent.join(', ')}`);
        } else {
          // Tabel tidak punya satupun kolom audit standar: emit auditColumns: false
          // supaya generator template men-set this.auditColumns = null di model.
          payloadData.auditColumns = false;
          console.log('No audit columns detected in table: setting auditColumns: false');
        }
      }
    }

    if (!payloadData.fieldName || payloadData.fieldName.length === 0) {
      console.error(`Error: Cannot create payload — no fields found for table '${args.table}'.`);
      await this.db.close();
      throw new Error(`No fields found for table '${args.table}'`);
    }

    console.log(`Fields: ${payloadData.fieldName.length}`);

    // Generate query sebagai file external (file:query/...)
    const baseFilename = args.table.replace(/[._]/g, '-');
    const sqlFilename = `${baseFilename}-datatables.sql`;
    const sqlLines = payloadData.fieldName.map((field, idx) => {
      const prefix = idx === 0 ? 'SELECT ' : '       ';
      const suffix = idx === payloadData.fieldName.length - 1 ? '' : ',';
      return `${prefix}a.${field}${suffix}`;
    });
    const sqlContent = `${sqlLines.join('\n')}\nFROM ${payloadData.tableName} a\n`;
    payloadData.datatablesQuery = `file:query/${sqlFilename}`;

    // Generate searchable columns
    const excludePatterns = ['_id', '_at', '_by', 'created', 'updated', 'deleted'];
    payloadData.datatablesWhere = payloadData.fieldName.filter(field => {
      const fieldLower = field.toLowerCase();
      return !excludePatterns.some(pattern => fieldLower.includes(pattern));
    });
    payloadData.datatablesWhere.push('all');

    // Enable all actions
    payloadData.action = {
      datatables: true,
      create: true,
      update: true,
      delete: true,
      first: true,
      lookup: true,
      read: true
    };

    // Generate dateTimeFields and fieldValidation
    if (this.db.pool) {
      const columnTypes = await this.db.getColumnTypes(args.table);
      const dateTimeFields = this.generateDateTimeFields(columnTypes, payloadData.fieldName);
      if (Object.keys(dateTimeFields).length > 0) {
        payloadData.dateTimeFields = dateTimeFields;
      }

      // Generate fieldValidation for special fields
      const detailedColumns = await this.db.getDetailedColumnInfo(args.table);
      const constraints = await this.db.getConstraints(args.table);
      const booleanColumns = typeof this.db.getBooleanColumns === 'function'
        ? await this.db.getBooleanColumns(args.table)
        : [];
      let enrichedColumns = this.enrichDetailedColumnsWithConstraints(detailedColumns, constraints);
      enrichedColumns = this.enrichDetailedColumnsWithBoolean(enrichedColumns, booleanColumns);
      const fieldValidation = this.generateFieldValidation(enrichedColumns, payloadData.fieldName, payloadData.primaryKey);
      if (fieldValidation.length > 0) {
        payloadData.fieldValidation = fieldValidation;
      }

      // uniqueConstraints (Issue #13): map nama constraint UNIQUE -> [fields] dari
      // introspeksi DB (variabel `constraints` di atas). Dikonsumsi template
      // MySQL/Oracle saat codegen (Phase 03) agar response 409 menyertakan field
      // penyebab konflik. Cakupan = SEMUA UNIQUE (single + composite); nama & kolom
      // disimpan apa adanya dari introspector (tanpa normalisasi case di sisi RDF).
      // Selalu array eksplisit ([] bila tidak ada UNIQUE) agar bentuk konsisten.
      // Hanya ditulis di path normal (di dalam guard this.db.pool, introspeksi
      // tersedia); path tanpa koneksi DB tidak menyentuh field ini.
      payloadData.uniqueConstraints = (constraints || [])
        .filter((c) => c && c.type === 'UNIQUE')
        .map((c) => ({ name: c.name, fields: c.columns }));
    }

    // Save payload
    this.ensureOutputDir();
    const filename = baseFilename + '.json';
    const outputPath = path.join(this.outputDir, filename);

    // Tulis file SQL external ke payload/query/<table>-datatables.sql
    const queryDir = path.join(this.outputDir, 'query');
    if (!fs.existsSync(queryDir)) {
      fs.mkdirSync(queryDir, { recursive: true });
    }
    const sqlOutputPath = path.join(queryDir, sqlFilename);
    fs.writeFileSync(sqlOutputPath, sqlContent, 'utf8');

    fs.writeFileSync(outputPath, JSON.stringify(payloadData, null, 4), 'utf8');
    console.log();
    console.log(`Payload saved: ${outputPath}`);
    console.log(`Query saved:   ${sqlOutputPath}`);
  }
}

// ============================================================================
// SCHEMA VALIDATOR - Validate & Diff payload vs database schema
// ============================================================================

/**
 * Class untuk validasi payload JSON terhadap schema database aktual.
 * Mendukung mode: validate, diff, dan sync.
 */
class SchemaValidator {
  /**
   * @param {DatabaseIntrospector} db - Instance DatabaseIntrospector yang sudah terkoneksi
   * @param {string} outputDir - Direktori payload
   */
  constructor(db, outputDir) {
    this.db = db;
    this.outputDir = outputDir;
  }

  /**
   * Cari semua file payload JSON di outputDir.
   * @returns {Array<{filePath: string, fileName: string, payload: Object}>}
   */
  findPayloadFiles() {
    if (!fs.existsSync(this.outputDir)) {
      return [];
    }

    const files = fs.readdirSync(this.outputDir)
      .filter(f => f.endsWith('.json') && !f.includes('.archive.'));

    const results = [];
    for (const fileName of files) {
      const filePath = path.join(this.outputDir, fileName);
      try {
        const content = fs.readFileSync(filePath, 'utf8');
        const payload = JSON.parse(content);
        if (payload.tableName && payload.fieldName) {
          results.push({ filePath, fileName, payload });
        }
      } catch (e) {
        // Skip file yang bukan valid payload JSON
      }
    }
    return results;
  }

  /**
   * Cari file payload untuk table tertentu.
   * Pencocokan berdasarkan tableName di dalam payload atau nama file.
   * @param {string} tableName - Nama table (misal: core.supplier atau supplier)
   * @returns {Object|null} { filePath, fileName, payload } atau null
   */
  findPayloadForTable(tableName) {
    const allPayloads = this.findPayloadFiles();

    // Exact match pada tableName
    let match = allPayloads.find(p => p.payload.tableName === tableName);
    if (match) return match;

    // Match tanpa schema prefix
    const tableOnly = tableName.includes('.') ? tableName.split('.').pop() : tableName;
    match = allPayloads.find(p => {
      const payloadTable = p.payload.tableName.includes('.')
        ? p.payload.tableName.split('.').pop()
        : p.payload.tableName;
      return payloadTable === tableOnly;
    });
    if (match) return match;

    // Match berdasarkan filename pattern (kebab-case default + legacy snake_case)
    const expectedFiles = [
      tableName.replace(/[._]/g, '-') + '.json',
      tableName.replace('.', '_') + '.json',
      tableOnly.replace(/_/g, '-') + '.json',
      tableOnly + '.json'
    ];
    match = allPayloads.find(p => expectedFiles.includes(p.fileName));
    return match || null;
  }

  /**
   * Format hasil perbandingan ke output console.
   * Konsisten dengan `endpoint create` (audit-column-aware).
   *
   * @param {Object} comparison - Hasil dari compareSchemaStrict()
   * @param {string} fileName - Nama file payload
   * @param {boolean} showDiffDetail - Tampilkan detail per-column drift
   */
  printComparisonResult(comparison, fileName, showDiffDetail = false) {
    // Pad status icon ke lebar tetap 7 char ([DRIFT]/[ERROR]) supaya kolom
    // fileName tampil rata antar baris.
    const statusIcon = comparison.status === 'ok' ? '[OK]   '
      : comparison.status === 'drift' ? '[DRIFT]'
      : '[ERROR]';

    console.log(`  ${statusIcon} ${fileName} (${comparison.tableName})`);

    if (comparison.status === 'error') {
      console.log(`         ${comparison.summary}`);
      return;
    }

    if (comparison.status === 'ok') {
      if (showDiffDetail) {
        console.log('         Schema is in sync');
      }
      return;
    }

    // Status: drift
    console.log(`         ${comparison.summary}`);

    if (!showDiffDetail) return;

    if (Array.isArray(comparison.removed)) {
      for (const item of comparison.removed) {
        console.log(`         [-] ${item.column}    (in payload, not in database)`);
      }
    }
    if (Array.isArray(comparison.typeChanges)) {
      for (const item of comparison.typeChanges) {
        console.log(`         [~] ${item.column}    (type: ${item.payloadType} -> ${item.databaseType})`);
      }
    }
    if (Array.isArray(comparison.added)) {
      for (const item of comparison.added) {
        const nullable = item.nullable ? ', nullable' : ', not null';
        console.log(`         [+] ${item.column}    (${item.type}${nullable}) (in database, not in payload)`);
      }
    }
    if (Array.isArray(comparison.auditMissing) && comparison.auditMissing.length > 0) {
      const cols = comparison.auditMissing.join(', ');
      console.log(`         [+] ${cols}`);
      console.log('             (required by auditColumns=true, not in database)');
    }
  }

  /**
   * Jalankan validasi untuk satu table atau semua payload.
   * @param {string|null} tableName - Nama table spesifik, atau null untuk semua
   * @returns {Promise<Object>} { total, ok, drift, error, results }
   */
  async runValidate(tableName) {
    console.log();
    console.log('='.repeat(60));
    console.log('SCHEMA VALIDATION - Payload vs Database');
    console.log('='.repeat(60));
    console.log();

    let payloads;
    if (tableName) {
      const found = this.findPayloadForTable(tableName);
      if (!found) {
        console.log(`  Payload file not found for table: ${tableName}`);
        console.log(`  Searched in: ${this.outputDir}`);
        console.log();
        return { total: 0, ok: 0, drift: 0, error: 0, results: [] };
      }
      payloads = [found];
    } else {
      payloads = this.findPayloadFiles();
      if (payloads.length === 0) {
        console.log(`  No payload files found in: ${this.outputDir}`);
        console.log();
        return { total: 0, ok: 0, drift: 0, error: 0, results: [] };
      }
    }

    console.log(`  Payload directory : ${this.outputDir}`);
    console.log(`  Files to validate : ${payloads.length}`);
    console.log();
    console.log('-'.repeat(60));
    console.log();

    const results = [];
    let ok = 0, drift = 0, error = 0;

    for (const { fileName, payload } of payloads) {
      const comparison = await compareSchemaStrict(payload, this.db, { payloadDir: this.outputDir });
      results.push({ fileName, comparison });
      this.printComparisonResult(comparison, fileName, false);

      if (comparison.status === 'ok') ok++;
      else if (comparison.status === 'drift') drift++;
      else error++;
    }

    // Summary
    console.log();
    console.log('-'.repeat(60));
    console.log();
    console.log('  Summary:');
    console.log(`    Total  : ${payloads.length} payload(s)`);
    console.log(`    OK     : ${ok}`);
    if (drift > 0) console.log(`    Drift  : ${drift}`);
    if (error > 0) console.log(`    Error  : ${error}`);
    console.log();

    if (drift > 0 || error > 0) {
      console.log('  Use --diff for detailed comparison.');
      console.log('  Use --sync to update payload files automatically.');
      console.log();
    }

    return { total: payloads.length, ok, drift, error, results };
  }

  /**
   * Jalankan diff detail untuk satu table atau semua payload.
   * @param {string|null} tableName - Nama table spesifik, atau null untuk semua
   * @returns {Promise<Object>} { total, ok, drift, error, results }
   */
  async runDiff(tableName) {
    console.log();
    console.log('='.repeat(60));
    console.log('SCHEMA DIFF - Payload vs Database');
    console.log('='.repeat(60));
    console.log();

    let payloads;
    if (tableName) {
      const found = this.findPayloadForTable(tableName);
      if (!found) {
        console.log(`  Payload file not found for table: ${tableName}`);
        console.log(`  Searched in: ${this.outputDir}`);
        console.log();
        return { total: 0, ok: 0, drift: 0, error: 0, results: [] };
      }
      payloads = [found];
    } else {
      payloads = this.findPayloadFiles();
      if (payloads.length === 0) {
        console.log(`  No payload files found in: ${this.outputDir}`);
        console.log();
        return { total: 0, ok: 0, drift: 0, error: 0, results: [] };
      }
    }

    console.log(`  Payload directory : ${this.outputDir}`);
    console.log(`  Files to compare  : ${payloads.length}`);
    console.log();

    const results = [];
    let ok = 0, drift = 0, error = 0;

    for (const { fileName, payload } of payloads) {
      console.log('-'.repeat(60));
      console.log();

      const comparison = await compareSchemaStrict(payload, this.db, { payloadDir: this.outputDir });
      results.push({ fileName, comparison });
      this.printComparisonResult(comparison, fileName, true);
      console.log();

      if (comparison.status === 'ok') ok++;
      else if (comparison.status === 'drift') drift++;
      else error++;
    }

    // Summary
    console.log('-'.repeat(60));
    console.log();
    console.log('  Summary:');
    console.log(`    Total  : ${payloads.length} payload(s)`);
    console.log(`    OK     : ${ok}`);
    if (drift > 0) console.log(`    Drift  : ${drift}`);
    if (error > 0) console.log(`    Error  : ${error}`);
    console.log();

    if (drift > 0) {
      console.log('  Use --sync to update payload files automatically.');
      console.log();
    }

    return { total: payloads.length, ok, drift, error, results };
  }

  /**
   * Tentukan nomor archive berikutnya untuk sebuah file.
   * Scan file .archive.001, .archive.002, dst. dan return nomor berikutnya.
   * @param {string} filePath - Path file payload asli
   * @returns {number} Nomor archive berikutnya
   */
  getNextArchiveNumber(filePath) {
    const dir = path.dirname(filePath);
    const baseName = path.basename(filePath);
    const files = fs.readdirSync(dir);
    let maxNumber = 0;

    const pattern = new RegExp(`^${baseName.replace(/\./g, '\\.')}\\.archive\\.(\\d+)$`);
    for (const file of files) {
      const match = file.match(pattern);
      if (match) {
        const num = parseInt(match[1], 10);
        if (num > maxNumber) maxNumber = num;
      }
    }

    return maxNumber + 1;
  }

  /**
   * Archive file payload lama sebelum overwrite.
   * Rename ke {filename}.archive.001, .002, dst.
   * @param {string} filePath - Path file payload
   * @returns {string} Path file archive yang dibuat
   */
  archiveFile(filePath) {
    const nextNum = this.getNextArchiveNumber(filePath);
    const archiveName = `${filePath}.archive.${String(nextNum).padStart(3, '0')}`;
    fs.renameSync(filePath, archiveName);
    return archiveName;
  }

  /**
   * Jalankan sync: update payload yang drift dengan data dari database.
   * File lama di-archive sebelum overwrite.
   * @param {string|null} tableName - Nama table spesifik, atau null untuk semua
   * @param {PayloadGenerator} generator - Instance PayloadGenerator untuk re-generate
   * @returns {Promise<Object>} { total, synced, skipped, error }
   */
  async runSync(tableName, generator) {
    console.log();
    console.log('='.repeat(60));
    console.log('SCHEMA SYNC - Update Payload from Database');
    console.log('='.repeat(60));
    console.log();

    let payloads;
    if (tableName) {
      const found = this.findPayloadForTable(tableName);
      if (!found) {
        console.log(`  Payload file not found for table: ${tableName}`);
        console.log(`  Searched in: ${this.outputDir}`);
        console.log();
        return { total: 0, synced: 0, skipped: 0, error: 0 };
      }
      payloads = [found];
    } else {
      payloads = this.findPayloadFiles();
      if (payloads.length === 0) {
        console.log(`  No payload files found in: ${this.outputDir}`);
        console.log();
        return { total: 0, synced: 0, skipped: 0, error: 0 };
      }
    }

    console.log(`  Payload directory : ${this.outputDir}`);
    console.log(`  Files to check    : ${payloads.length}`);
    console.log();
    console.log('-'.repeat(60));
    console.log();

    let synced = 0, skipped = 0, errorCount = 0;

    for (const { filePath, fileName, payload } of payloads) {
      const comparison = await compareSchemaStrict(payload, this.db, { payloadDir: this.outputDir });

      if (comparison.status === 'error') {
        console.log(`  [ERROR]   ${fileName} - ${comparison.summary}`);
        errorCount++;
        continue;
      }

      if (comparison.status === 'ok') {
        console.log(`  [SKIP]    ${fileName} - already in sync`);
        skipped++;
        continue;
      }

      // Status: drift (termasuk audit drift) — archive + regenerate.
      const archivePath = this.archiveFile(filePath);
      const archiveName = path.basename(archivePath);
      console.log(`  [ARCHIVE] ${fileName} -> ${archiveName}`);

      // Re-generate payload dari database. regeneratePayload menerapkan audit
      // columns resolution secara internal (set auditColumns: false bila DB
      // tidak punya kolom audit standar).
      try {
        const updatedPayload = await this.regeneratePayload(payload, comparison, { fileName });
        fs.writeFileSync(filePath, JSON.stringify(updatedPayload, null, 4), 'utf8');
        console.log(`  [SYNCED]  ${fileName} - ${comparison.summary}`);
        synced++;
      } catch (e) {
        // Restore dari archive jika gagal
        fs.renameSync(archivePath, filePath);
        console.log(`  [ERROR]   ${fileName} - sync failed: ${e.message}`);
        errorCount++;
      }
    }

    // Summary
    console.log();
    console.log('-'.repeat(60));
    console.log();
    console.log('  Summary:');
    console.log(`    Total    : ${payloads.length} payload(s)`);
    console.log(`    Synced   : ${synced}`);
    console.log(`    Skipped  : ${skipped}`);
    if (errorCount > 0) console.log(`    Error    : ${errorCount}`);
    console.log();

    return { total: payloads.length, synced, skipped, error: errorCount };
  }

  /**
   * Regenerate payload berdasarkan perubahan yang terdeteksi.
   * Mempertahankan konfigurasi payload lama (action, filters, dll),
   * hanya update fieldName, fieldValidation, dateTimeFields, query, dan
   * (sejak Phase 03) field `auditColumns` bila terjadi misalignment dengan
   * tabel database.
   *
   * @param {Object} oldPayload - Payload JSON lama
   * @param {Object} comparison - Hasil comparePayloadWithDatabase
   * @param {Object} [options]
   * @param {string} [options.fileName] - Nama file payload untuk pesan log
   * @returns {Promise<Object>} Updated payload
   */
  async regeneratePayload(oldPayload, comparison, options = {}) {
    const tableName = oldPayload.tableName;

    // Ambil data terbaru dari database
    const dbColumns = await this.db.getColumns(tableName);
    const columnTypes = await this.db.getColumnTypes(tableName);
    const detailedColumns = await this.db.getDetailedColumnInfo(tableName);
    const constraints = typeof this.db.getConstraints === 'function'
      ? await this.db.getConstraints(tableName)
      : [];
    const primaryKey = await this.db.getPrimaryKey(tableName) || oldPayload.primaryKey;

    // Bangun fieldName baru: pertahankan urutan field lama, tambahkan field baru di akhir.
    // Kolom yang sebelumnya dicantumkan user di payload (termasuk kolom audit default)
    // dipertahankan. Kolom audit default yang tidak pernah ada di payload tidak
    // ditambahkan otomatis karena di-handle runtime.
    const newFieldName = [];
    // Pertahankan field lama yang masih ada di database
    for (const field of oldPayload.fieldName) {
      if (dbColumns.includes(field)) {
        newFieldName.push(field);
      }
    }
    // Tambahkan field baru dari database, skip kolom audit default
    for (const col of dbColumns) {
      if (newFieldName.includes(col)) continue;
      if (DEFAULT_AUDIT_COLUMNS.includes(col)) continue;
      newFieldName.push(col);
    }

    // Build payload baru, pertahankan konfigurasi lama
    const updatedPayload = { ...oldPayload };
    updatedPayload.primaryKey = primaryKey;
    updatedPayload.fieldName = newFieldName;

    // Re-generate datatablesQuery
    const fieldsStr = newFieldName.join(', ');
    updatedPayload.datatablesQuery = `select ${fieldsStr} from ${tableName}`;

    // Re-generate exportQuery jika ada
    if (oldPayload.exportQuery) {
      updatedPayload.exportQuery = `select ${fieldsStr} from ${tableName}`;
    }

    // Re-generate datatablesWhere: pertahankan yang masih valid, tambahkan field baru yang searchable
    if (oldPayload.datatablesWhere) {
      const excludePatterns = ['_id', '_at', '_by', 'created', 'updated', 'deleted'];
      const validWhere = oldPayload.datatablesWhere.filter(
        w => w === 'all' || newFieldName.includes(w)
      );
      // Tambahkan field baru yang searchable
      for (const col of comparison.added) {
        const fieldLower = col.column.toLowerCase();
        const isSearchable = !excludePatterns.some(pattern => fieldLower.includes(pattern));
        if (isSearchable && !validWhere.includes(col.column)) {
          // Sisipkan sebelum 'all'
          const allIdx = validWhere.indexOf('all');
          if (allIdx >= 0) {
            validWhere.splice(allIdx, 0, col.column);
          } else {
            validWhere.push(col.column);
          }
        }
      }
      updatedPayload.datatablesWhere = validWhere;
    }

    // Re-generate dateTimeFields
    const tempGenerator = new PayloadGenerator();
    const dateTimeFields = tempGenerator.generateDateTimeFields(columnTypes, newFieldName);
    if (Object.keys(dateTimeFields).length > 0) {
      updatedPayload.dateTimeFields = dateTimeFields;
    } else {
      delete updatedPayload.dateTimeFields;
    }

    // Re-generate fieldValidation
    const booleanColumns = typeof this.db.getBooleanColumns === 'function'
      ? await this.db.getBooleanColumns(tableName)
      : [];
    let enrichedColumns = tempGenerator.enrichDetailedColumnsWithConstraints(detailedColumns, constraints);
    enrichedColumns = tempGenerator.enrichDetailedColumnsWithBoolean(enrichedColumns, booleanColumns);
    const fieldValidation = tempGenerator.generateFieldValidation(enrichedColumns, newFieldName, primaryKey);
    if (fieldValidation.length > 0) {
      updatedPayload.fieldValidation = fieldValidation;
    } else {
      delete updatedPayload.fieldValidation;
    }

    // uniqueConstraints (Issue #13): regenerasi map nama UNIQUE -> [fields] dari
    // introspeksi terbaru (variabel `constraints` di atas). Posisi & bentuk
    // konsisten dengan path generate (setelah fieldValidation). Selalu array
    // eksplisit ([] bila tidak ada UNIQUE) agar diff RDF antar versi minimal.
    updatedPayload.uniqueConstraints = (constraints || [])
      .filter((c) => c && c.type === 'UNIQUE')
      .map((c) => ({ name: c.name, fields: c.columns }));

    // Audit columns resolution (Phase 03):
    // Pastikan field `auditColumns` di payload aligned dengan struktur tabel
    // database. Logic ini mencegah inkonsistensi antara `payload sync` (yang
    // sebelumnya tidak menyentuh auditColumns) dan `endpoint create` (yang
    // sudah audit-column-aware sejak Phase 01).
    this.resolveAuditColumnsForSync(updatedPayload, oldPayload, dbColumns, {
      tableName,
      fileName: options.fileName
    });

    return updatedPayload;
  }

  /**
   * Resolve nilai `auditColumns` untuk payload hasil sync. Memutasi
   * `updatedPayload` in-place.
   *
   * Aturan (lihat phase-03-audit-aware-sync.md):
   *   - oldPayload.auditColumns === false/null -> preserve (no-op)
   *   - oldPayload.auditColumns object form    -> warn, no auto-override
   *   - oldPayload.auditColumns === true + DB tidak full audit -> override
   *     ke false + warning ke stderr
   *   - oldPayload.auditColumns tidak di-set (default true) + DB tidak full
   *     audit -> set false + info message
   *   - DB punya keempat kolom audit standar -> preserve apapun bentuknya
   *
   * "DB tidak full audit" mencakup `none` (tidak satupun kolom audit) dan
   * `partial` (1-3 kolom audit). Partial di-treat sebagai incomplete: shape
   * audit tidak lengkap, generator tidak bisa inject 4 kolom sambil sebagian
   * tidak exist. Default strict yang aman.
   *
   * @param {Object} updatedPayload - Payload yang akan ditulis (mutated)
   * @param {Object} oldPayload - Payload lama (untuk inspect state asli)
   * @param {string[]} dbColumns - Daftar kolom database
   * @param {Object} ctx
   * @param {string} ctx.tableName - Nama table
   * @param {string} [ctx.fileName] - Nama file payload untuk pesan log
   * @returns {void}
   */
  resolveAuditColumnsForSync(updatedPayload, oldPayload, dbColumns, ctx) {
    const tableName = ctx.tableName;
    const fileLabel = ctx.fileName || `${tableName}.json`;
    const alignment = detectAuditAlignment(dbColumns);
    const hasField = Object.prototype.hasOwnProperty.call(oldPayload, 'auditColumns');
    const value = hasField ? oldPayload.auditColumns : undefined;

    // Case 1: explicit false/null -> preserve (no-op)
    if (value === false || value === null) {
      return;
    }

    // Case 2: object form (custom column mapping) -> warn, no auto-override
    if (value && typeof value === 'object' && !Array.isArray(value)) {
      console.warn(
        `[restforge] Custom auditColumns object detected for "${tableName}". ` +
        'Cannot auto-resolve. Verify column names manually.'
      );
      return;
    }

    // Case 3: DB punya semua kolom audit -> preserve apa adanya
    if (alignment.all) {
      return;
    }

    // Case 4: DB tidak full audit (none atau partial) + payload default/true
    // -> set updatedPayload.auditColumns = false
    if (value === true) {
      console.warn(
        `[restforge] Resetting auditColumns: true -> false for table "${tableName}" ` +
        'because audit columns missing in database'
      );
    } else {
      console.log(
        `[restforge] Set "auditColumns": false in ${fileLabel} ` +
        'because audit columns missing in database'
      );
    }
    updatedPayload.auditColumns = false;
  }
}

module.exports = {
  PayloadGenerator,
  SchemaValidator
};
