/**
 * Database Introspector - Multi-database schema introspection
 *
 * Mendukung: PostgreSQL, MySQL, Oracle.
 *
 * Class ini di-extract dari cli/create-payload.js (Fase 12A) agar dapat
 * dipakai bersama oleh subcommand baru (dbschema:list, dbschema:describe)
 * tanpa duplikasi.
 *
 * @module lib/utils/database-introspector
 */

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

// Database drivers - lazy loaded based on DB_TYPE
let pg = null;
let mysql2 = null;
let oracledb = null;
let betterSqlite3 = null;

/**
 * Lazy-load database driver berdasarkan DB_TYPE.
 *
 * @param {string} dbType - 'postgresql' | 'mysql' | 'oracle' | 'sqlite'
 * @returns {Object} Driver module
 */
function loadDriver(dbType) {
  switch (dbType) {
    case 'postgresql':
      if (!pg) {
        try { pg = require('pg'); } catch (e) {
          throw new Error('PostgreSQL driver (pg) not installed. Run: npm install pg');
        }
      }
      return pg;
    case 'mysql':
      if (!mysql2) {
        try { mysql2 = require('mysql2/promise'); } catch (e) {
          throw new Error('MySQL driver (mysql2) not installed. Run: npm install mysql2');
        }
      }
      return mysql2;
    case 'oracle':
      if (!oracledb) {
        try { oracledb = require('oracledb'); } catch (e) {
          throw new Error('Oracle driver (oracledb) not installed. Run: npm install oracledb');
        }
      }
      return oracledb;
    case 'sqlite':
      if (!betterSqlite3) {
        try { betterSqlite3 = require('better-sqlite3'); } catch (e) {
          throw new Error('SQLite driver (better-sqlite3) not installed. Run: npm install better-sqlite3');
        }
      }
      return betterSqlite3;
    default:
      throw new Error(`Unsupported DB_TYPE: ${dbType}. Supported: postgresql, mysql, oracle, sqlite`);
  }
}

/**
 * Ekstrak nama kolom yang membawa penanda boolean `<col> IN ('true','false')`
 * dari teks CHECK constraint / DDL. Dipakai introspeksi untuk mengenali kembali
 * kolom VARCHAR sebagai boolean pada dialect non-native (mysql/oracle/sqlite),
 * penanda yang di-emit otomatis oleh dbschema-kit create-table/alter-table.
 *
 * Toleran terhadap variasi quoting identifier ("col" / `col` / [col]),
 * urutan nilai ('true','false' atau 'false','true'), serta charset introducer
 * MySQL (mis. _utf8mb4'true'). Nama kolom dikembalikan lowercase.
 *
 * @param {string} text - Teks CHECK clause atau CREATE TABLE DDL
 * @returns {string[]} Daftar nama kolom boolean (lowercase)
 */
function extractBooleanCheckColumns(text) {
  const cols = [];
  if (!text || typeof text !== 'string') return cols;
  // `\\?` mentoleransi backslash-escaped quote yang dikembalikan MySQL pada
  // CHECK_CLAUSE (mis. _utf8mb4\'true\'); SQLite/Oracle memakai 'true' polos.
  const re = /["`\[]?([A-Za-z_][A-Za-z0-9_]*)["`\]]?\s+IN\s*\(\s*(?:_\w+)?\\?'(true|false)\\?'\s*,\s*(?:_\w+)?\\?'(true|false)\\?'\s*\)/gi;
  let m;
  while ((m = re.exec(text)) !== null) {
    const a = m[2].toLowerCase();
    const b = m[3].toLowerCase();
    if ((a === 'true' && b === 'false') || (a === 'false' && b === 'true')) {
      cols.push(m[1].toLowerCase());
    }
  }
  return cols;
}

// PostgreSQL FK action codes -> human-readable
const PG_FK_ACTION = {
  a: 'NO ACTION',
  r: 'RESTRICT',
  c: 'CASCADE',
  n: 'SET NULL',
  d: 'SET DEFAULT'
};

/**
 * Class untuk Database Introspection - Multi-database support.
 * Mendukung: PostgreSQL, MySQL, Oracle.
 */
class DatabaseIntrospector {
  constructor(options = {}) {
    this.pool = null;
    this.connection = null; // untuk Oracle
    this.config = null;
    this.dbType = 'postgresql'; // default
    // Quiet mode: when true, suppress informational console.log messages
    // ("Config loaded:", "Database connected", "Database connection closed").
    // Dipakai oleh subcommand yang emit JSON ke stdout (mis. dbschema:list)
    // agar stdout tidak terkontaminasi. Error/warning tetap dilaporkan via console.error.
    this.quiet = options.quiet === true;
  }

  /**
   * Load database configuration dari file .env.
   *
   * Path resolution mengikuti pattern `restforge` runtime: kalau path yang
   * di-pass tidak ada, fallback search di `process.cwd()/config/<basename>`.
   * Konsisten dengan UX `restforge validate --config=db-connection.env` di
   * mana user cukup menyebut nama file tanpa folder prefix.
   *
   * @param {string} configPath - Path ke file konfigurasi (boleh absolute,
   *   relative, atau hanya nama file)
   * @returns {boolean} Success status
   */
  loadConfig(configPath) {
    let resolvedPath = configPath;

    if (!fs.existsSync(resolvedPath)) {
      const basename = path.basename(configPath);
      const candidates = [
        path.join(process.cwd(), 'config', basename)
      ];
      for (const candidate of candidates) {
        if (fs.existsSync(candidate)) {
          resolvedPath = candidate;
          break;
        }
      }
    }

    if (!fs.existsSync(resolvedPath)) {
      console.error(`Error: Config file not found: ${configPath}`);
      return false;
    }

    try {
      const content = fs.readFileSync(resolvedPath, 'utf8');
      const config = {};

      content.split('\n').forEach(line => {
        line = line.trim();
        if (!line || line.startsWith('#') || line.startsWith('Konfigurasi')) {
          return;
        }

        const equalIndex = line.indexOf('=');
        if (equalIndex > 0) {
          const key = line.substring(0, equalIndex).trim();
          let value = line.substring(equalIndex + 1).trim();
          // Remove inline comments
          const commentIndex = value.indexOf('#');
          if (commentIndex > 0) {
            value = value.substring(0, commentIndex).trim();
          }
          config[key] = value;
        }
      });

      // Detect DB_TYPE
      const dbTypeRaw = (config.DB_TYPE || 'postgresql').toLowerCase();
      if (dbTypeRaw === 'postgres' || dbTypeRaw === 'pg') {
        this.dbType = 'postgresql';
      } else if (dbTypeRaw === 'mysql' || dbTypeRaw === 'mariadb') {
        this.dbType = 'mysql';
      } else if (dbTypeRaw === 'oracle' || dbTypeRaw === 'oracledb') {
        this.dbType = 'oracle';
      } else if (dbTypeRaw === 'sqlite' || dbTypeRaw === 'sqlite3') {
        this.dbType = 'sqlite';
      } else {
        this.dbType = dbTypeRaw;
      }

      // Validate required keys.
      //
      // SQLite adalah file-based: tidak ada host/port/user/password. Yang wajib
      // hanya path ke file database, diterima via DB_FILE (konsisten dengan
      // dbschema-kit connection.js) atau DB_NAME sebagai fallback. Server-based
      // dialect (postgresql/mysql/oracle) tetap mewajibkan kredensial koneksi.
      const requiredKeys = this.dbType === 'sqlite'
        ? [] // dicek terpisah di bawah karena boleh DB_FILE ATAU DB_NAME
        : ['DB_HOST', 'DB_PORT', 'DB_USER', 'DB_PASSWORD', 'DB_NAME'];
      const missingKeys = requiredKeys.filter(key => !config[key]);

      if (missingKeys.length > 0) {
        console.error(`Error: Missing required config keys: ${missingKeys.join(', ')}`);
        return false;
      }

      if (this.dbType === 'sqlite' && !config.DB_FILE && !config.DB_NAME) {
        console.error('Error: Missing required config key for SQLite: DB_FILE (atau DB_NAME) berisi path file database');
        return false;
      }

      this.config = config;
      if (!this.quiet) {
        if (this.dbType === 'sqlite') {
          // SQLite file-based: host:port tidak relevan, tampilkan path file saja.
          console.log(`Config loaded: [sqlite] ${config.DB_FILE || config.DB_NAME}`);
        } else {
          console.log(`Config loaded: [${this.dbType}] ${config.DB_NAME}@${config.DB_HOST}:${config.DB_PORT}`);
        }
      }
      return true;
    } catch (error) {
      console.error(`Error loading config file: ${error.message}`);
      return false;
    }
  }

  /**
   * Connect to database berdasarkan DB_TYPE
   * @returns {Promise<boolean>} Success status
   */
  async connect() {
    if (!this.config) {
      console.error('Error: Database config not loaded');
      return false;
    }

    try {
      const driver = loadDriver(this.dbType);

      switch (this.dbType) {
        case 'postgresql': {
          this.pool = new driver.Pool({
            host: this.config.DB_HOST,
            port: parseInt(this.config.DB_PORT),
            user: this.config.DB_USER,
            password: this.config.DB_PASSWORD,
            database: this.config.DB_NAME
          });
          const client = await this.pool.connect();
          await client.query('SELECT 1');
          client.release();
          break;
        }

        case 'mysql': {
          this.pool = driver.createPool({
            host: this.config.DB_HOST,
            port: parseInt(this.config.DB_PORT),
            user: this.config.DB_USER,
            password: this.config.DB_PASSWORD,
            database: this.config.DB_NAME,
            waitForConnections: true,
            connectionLimit: 5,
            dateStrings: true
          });
          const conn = await this.pool.getConnection();
          await conn.query('SELECT 1');
          conn.release();
          break;
        }

        case 'oracle': {
          driver.initOracleClient && driver.initOracleClient();
          this.pool = await driver.createPool({
            user: this.config.DB_USER,
            password: this.config.DB_PASSWORD,
            connectString: `//${this.config.DB_HOST}:${this.config.DB_PORT}/${this.config.DB_NAME}`,
            poolMin: 1,
            poolMax: 5,
            poolTimeout: 60
          });
          const conn = await this.pool.getConnection();
          await conn.execute('SELECT 1 FROM DUAL');
          await conn.close();
          break;
        }

        case 'sqlite': {
          // better-sqlite3 bersifat synchronous: konstruktor langsung membuka
          // file. Mode readonly + fileMustExist dipakai karena introspeksi
          // bersifat read-only dan men-generate payload dari DB yang sudah ada;
          // tanpa fileMustExist, file yang salah path akan dibuat kosong dan
          // berujung error "table not found" yang membingungkan.
          const Database = driver;
          const filePath = this.config.DB_FILE || this.config.DB_NAME;
          this.pool = new Database(filePath, { readonly: true, fileMustExist: true });
          this.pool.prepare('SELECT 1').get();
          break;
        }

        default:
          throw new Error(`Unsupported DB_TYPE: ${this.dbType}`);
      }

      if (!this.quiet) {
        console.log(`Database connected successfully [${this.dbType}]`);
      }
      return true;
    } catch (error) {
      console.error(`Database connection failed: ${error.message}`);
      return false;
    }
  }

  /**
   * Close database connection. Idempotent — panggilan kedua jadi no-op
   * setelah `this.pool` di-null-kan. Ini mencegah pesan "Database connection
   * closed" muncul dua kali ketika caller punya cleanup ganda (mis. cleanup
   * eksplisit + cleanup di catch block).
   */
  async close() {
    if (!this.pool) return;
    try {
      if (this.dbType === 'oracle') {
        await this.pool.close(0);
      } else if (this.dbType === 'sqlite') {
        // better-sqlite3 Database.close() synchronous, tidak punya .end()
        this.pool.close();
      } else {
        await this.pool.end();
      }
    } catch (e) {
      // ignore close errors
    }
    this.pool = null;
    if (!this.quiet) {
      console.log('Database connection closed');
    }
  }

  /**
   * Alias untuk close() — beberapa subcommand baru memakai nama disconnect
   * untuk konsistensi dengan terminologi connect/disconnect.
   */
  async disconnect() {
    return this.close();
  }

  /**
   * Execute query abstraction - returns array of row objects with lowercase keys
   * @param {string} sql - SQL query
   * @param {Array|Object} params - Query parameters (array untuk postgres/mysql, object untuk oracle named bind)
   * @returns {Promise<Array>} Array of row objects
   */
  async query(sql, params = []) {
    switch (this.dbType) {
      case 'postgresql': {
        const result = await this.pool.query(sql, params);
        return result.rows;
      }
      case 'mysql': {
        const [rows] = await this.pool.query(sql, params);
        return rows;
      }
      case 'oracle': {
        const conn = await this.pool.getConnection();
        try {
          const result = await conn.execute(sql, params, { outFormat: oracledb.OUT_FORMAT_OBJECT });
          // Oracle returns UPPERCASE keys, normalize to lowercase
          return (result.rows || []).map(row => {
            const normalized = {};
            for (const key of Object.keys(row)) {
              normalized[key.toLowerCase()] = row[key];
            }
            return normalized;
          });
        } finally {
          await conn.close();
        }
      }
      case 'sqlite': {
        // better-sqlite3 synchronous. Placeholder positional '?' kompatibel
        // dengan SQL/PRAGMA table-valued function (mis. pragma_table_info(?)).
        // .all() valid untuk statement yang mengembalikan row (SELECT/PRAGMA).
        const stmt = this.pool.prepare(sql);
        return stmt.all(...(params || []));
      }
      default:
        return [];
    }
  }

  /**
   * Parse schema dan table name
   * @param {string} tableName - Table name dengan atau tanpa schema
   * @returns {Object} { schema, table }
   */
  parseTableName(tableName) {
    if (tableName.includes('.')) {
      const [schema, table] = tableName.split('.', 2);
      return { schema, table };
    }

    // Default schema per database type
    switch (this.dbType) {
      case 'postgresql': return { schema: 'public', table: tableName };
      case 'mysql': return { schema: this.config.DB_NAME, table: tableName };
      case 'oracle': return { schema: this.config.DB_USER.toUpperCase(), table: tableName };
      // SQLite tidak mengenal schema (hanya database 'main'); schema diabaikan
      // di seluruh query introspeksi sqlite (memakai sqlite_master / PRAGMA).
      case 'sqlite': return { schema: 'main', table: tableName };
      default: return { schema: 'public', table: tableName };
    }
  }

  /**
   * Get primary key dari table
   * @param {string} tableName - Nama table
   * @returns {Promise<string|null>} Primary key column name
   */
  async getPrimaryKey(tableName) {
    if (!this.pool) return null;

    try {
      const { schema, table } = this.parseTableName(tableName);

      switch (this.dbType) {
        case 'postgresql': {
          const fullTableName = `${schema}.${table}`;
          const rows = await this.query(
            `SELECT a.attname
             FROM pg_index i
             JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = ANY(i.indkey)
             WHERE i.indrelid = $1::regclass AND i.indisprimary`,
            [fullTableName]
          );
          return rows.length > 0 ? rows[0].attname : null;
        }

        case 'mysql': {
          const rows = await this.query(
            `SELECT column_name
             FROM information_schema.key_column_usage
             WHERE table_schema = ? AND table_name = ? AND constraint_name = 'PRIMARY'
             ORDER BY ordinal_position LIMIT 1`,
            [schema, table]
          );
          // MySQL 8 information_schema mengembalikan key UPPERCASE (COLUMN_NAME).
          // Tanpa fallback ini PK tidak terdeteksi -> payload jatuh ke tebakan
          // nama PK yang salah (konsisten dengan pola getColumns mysql).
          return rows.length > 0 ? (rows[0].column_name || rows[0].COLUMN_NAME) : null;
        }

        case 'oracle': {
          const rows = await this.query(
            `SELECT ucc.column_name
             FROM user_constraints uc
             JOIN user_cons_columns ucc ON uc.constraint_name = ucc.constraint_name
             WHERE uc.table_name = :1 AND uc.constraint_type = 'P'
             ORDER BY ucc.position`,
            [table.toUpperCase()]
          );
          return rows.length > 0 ? rows[0].column_name.toLowerCase() : null;
        }

        case 'sqlite': {
          // pragma_table_info.pk = 0 (bukan PK) atau urutan kolom dalam PK
          // composite (1-based). Ambil kolom pertama (pk=1) konsisten dengan
          // dialect lain yang mengembalikan satu kolom PK.
          const rows = await this.query(
            `SELECT name FROM pragma_table_info(?) WHERE pk > 0 ORDER BY pk`,
            [table]
          );
          return rows.length > 0 ? rows[0].name : null;
        }
      }
      return null;
    } catch (error) {
      console.warn(`  Warning: Could not fetch primary key: ${error.message}`);
      return null;
    }
  }

  /**
   * Check apakah table exists di database
   * @param {string} tableName - Nama table (optional schema-qualified)
   * @returns {Promise<boolean>} True jika table ada
   */
  async tableExists(tableName) {
    if (!this.pool) return false;

    try {
      const { schema, table } = this.parseTableName(tableName);

      switch (this.dbType) {
        case 'postgresql': {
          const rows = await this.query(
            `SELECT 1 FROM information_schema.tables
             WHERE table_schema = $1 AND table_name = $2
             LIMIT 1`,
            [schema, table]
          );
          return rows.length > 0;
        }

        case 'mysql': {
          const rows = await this.query(
            `SELECT 1 FROM information_schema.tables
             WHERE table_schema = ? AND table_name = ?
             LIMIT 1`,
            [schema, table]
          );
          return rows.length > 0;
        }

        case 'oracle': {
          const rows = await this.query(
            `SELECT 1 FROM user_tables WHERE table_name = :1`,
            [table.toUpperCase()]
          );
          return rows.length > 0;
        }

        case 'sqlite': {
          const rows = await this.query(
            `SELECT 1 FROM sqlite_master
             WHERE type IN ('table', 'view') AND name = ?
             LIMIT 1`,
            [table]
          );
          return rows.length > 0;
        }
      }
      return false;
    } catch (error) {
      console.warn(`  Warning: Could not check table existence: ${error.message}`);
      return false;
    }
  }

  /**
   * Get all columns dari table
   * @param {string} tableName - Nama table
   * @returns {Promise<Array<string>>} Array of column names
   */
  async getColumns(tableName) {
    if (!this.pool) return [];

    try {
      const { schema, table } = this.parseTableName(tableName);

      switch (this.dbType) {
        case 'postgresql': {
          const rows = await this.query(
            `SELECT column_name FROM information_schema.columns
             WHERE table_schema = $1 AND table_name = $2
             ORDER BY ordinal_position`,
            [schema, table]
          );
          return rows.map(r => r.column_name);
        }

        case 'mysql': {
          const rows = await this.query(
            `SELECT column_name FROM information_schema.columns
             WHERE table_schema = ? AND table_name = ?
             ORDER BY ordinal_position`,
            [schema, table]
          );
          return rows.map(r => r.COLUMN_NAME || r.column_name);
        }

        case 'oracle': {
          const rows = await this.query(
            `SELECT column_name FROM user_tab_columns
             WHERE table_name = :1
             ORDER BY column_id`,
            [table.toUpperCase()]
          );
          return rows.map(r => r.column_name.toLowerCase());
        }

        case 'sqlite': {
          const rows = await this.query(
            `SELECT name FROM pragma_table_info(?) ORDER BY cid`,
            [table]
          );
          return rows.map(r => r.name);
        }
      }
      return [];
    } catch (error) {
      console.warn(`  Warning: Could not fetch columns: ${error.message}`);
      return [];
    }
  }

  /**
   * Get column types dari table
   * @param {string} tableName - Nama table
   * @returns {Promise<Object>} Map of column_name -> data_type
   */
  async getColumnTypes(tableName) {
    if (!this.pool) return {};

    try {
      const { schema, table } = this.parseTableName(tableName);
      const types = {};

      switch (this.dbType) {
        case 'postgresql': {
          const rows = await this.query(
            `SELECT column_name, data_type FROM information_schema.columns
             WHERE table_schema = $1 AND table_name = $2
             ORDER BY ordinal_position`,
            [schema, table]
          );
          rows.forEach(r => { types[r.column_name] = r.data_type; });
          break;
        }

        case 'mysql': {
          const rows = await this.query(
            `SELECT column_name, data_type FROM information_schema.columns
             WHERE table_schema = ? AND table_name = ?
             ORDER BY ordinal_position`,
            [schema, table]
          );
          rows.forEach(r => {
            const colName = r.COLUMN_NAME || r.column_name;
            const dataType = r.DATA_TYPE || r.data_type;
            types[colName] = dataType;
          });
          break;
        }

        case 'oracle': {
          const rows = await this.query(
            `SELECT column_name, data_type FROM user_tab_columns
             WHERE table_name = :1
             ORDER BY column_id`,
            [table.toUpperCase()]
          );
          rows.forEach(r => { types[r.column_name.toLowerCase()] = r.data_type.toLowerCase(); });
          break;
        }

        case 'sqlite': {
          const rows = await this.query(
            `SELECT name, type FROM pragma_table_info(?) ORDER BY cid`,
            [table]
          );
          // Declared type direfleksikan apa adanya (lowercased). generateDateTimeFields
          // mencocokkan terhadap 'date'/'timestamp'/'time'/'datetime'; tipe DDL sqlite
          // RESTForge memakai DATE/TIMESTAMP sehingga match deterministik.
          rows.forEach(r => { types[r.name] = (r.type || '').toLowerCase(); });
          break;
        }
      }

      return types;
    } catch (error) {
      console.warn(`  Warning: Could not fetch column types: ${error.message}`);
      return {};
    }
  }

  /**
   * Get detailed column info dari table (type, default, nullable, precision)
   * Returns normalized format across all databases
   * @param {string} tableName - Nama table
   * @returns {Promise<Array>} Array of column detail objects
   */
  async getDetailedColumnInfo(tableName) {
    if (!this.pool) return [];

    try {
      const { schema, table } = this.parseTableName(tableName);

      switch (this.dbType) {
        case 'postgresql': {
          return await this.query(
            `SELECT column_name, data_type, udt_name, column_default, is_nullable,
                    character_maximum_length, numeric_precision, numeric_scale
             FROM information_schema.columns
             WHERE table_schema = $1 AND table_name = $2
             ORDER BY ordinal_position`,
            [schema, table]
          );
        }

        case 'mysql': {
          const rows = await this.query(
            `SELECT column_name, data_type, column_type, column_default, is_nullable,
                    character_maximum_length, numeric_precision, numeric_scale, extra
             FROM information_schema.columns
             WHERE table_schema = ? AND table_name = ?
             ORDER BY ordinal_position`,
            [schema, table]
          );
          // Normalize MySQL result to match PostgreSQL format
          return rows.map(r => ({
            column_name: r.COLUMN_NAME || r.column_name,
            data_type: (r.DATA_TYPE || r.data_type || '').toLowerCase(),
            udt_name: (r.DATA_TYPE || r.data_type || '').toLowerCase(),
            column_default: r.COLUMN_DEFAULT || r.column_default,
            is_nullable: r.IS_NULLABLE || r.is_nullable,
            character_maximum_length: r.CHARACTER_MAXIMUM_LENGTH || r.character_maximum_length,
            numeric_precision: r.NUMERIC_PRECISION || r.numeric_precision,
            numeric_scale: r.NUMERIC_SCALE || r.numeric_scale,
            extra: r.EXTRA || r.extra || ''
          }));
        }

        case 'oracle': {
          const rows = await this.query(
            `SELECT column_name, data_type, data_default, nullable,
                    char_length, data_precision, data_scale
             FROM user_tab_columns
             WHERE table_name = :1
             ORDER BY column_id`,
            [table.toUpperCase()]
          );
          // Normalize Oracle result to match PostgreSQL format
          return rows.map(r => {
            const oracleType = (r.data_type || '').toLowerCase();
            // Map Oracle types to PostgreSQL-like types for consistency
            let normalizedType = oracleType;
            if (oracleType.startsWith('timestamp')) normalizedType = 'timestamp';
            else if (oracleType === 'number' && r.data_scale === 0) normalizedType = 'integer';
            else if (oracleType === 'number') normalizedType = 'numeric';
            else if (oracleType === 'varchar2' || oracleType === 'nvarchar2') normalizedType = 'character varying';
            else if (oracleType === 'clob' || oracleType === 'nclob') normalizedType = 'text';

            return {
              column_name: r.column_name.toLowerCase(),
              data_type: normalizedType,
              udt_name: normalizedType,
              column_default: r.data_default ? r.data_default.trim() : null,
              is_nullable: r.nullable === 'Y' ? 'YES' : 'NO',
              character_maximum_length: r.char_length,
              numeric_precision: r.data_precision,
              numeric_scale: r.data_scale
            };
          });
        }

        case 'sqlite': {
          // Alias kolom "notnull" ke `nn`: NOTNULL adalah keyword SQLite sehingga
          // alias unquoted `AS notnull` memicu syntax error.
          const rows = await this.query(
            `SELECT name, type, "notnull" AS nn, dflt_value, pk
             FROM pragma_table_info(?) ORDER BY cid`,
            [table]
          );
          // Normalisasi declared type sqlite -> format PostgreSQL-like agar
          // generateFieldValidation (yang mencocokkan terhadap data_type seperti
          // 'varchar'/'integer'/'decimal'/'date'/'timestamp') berperilaku identik
          // lintas dialect. Tipe DDL sqlite RESTForge: VARCHAR(n), TEXT, INTEGER,
          // BIGINT, DECIMAL(p,s), DATE, TIMESTAMP (boolean & uuid -> VARCHAR).
          const STRING_BASES = ['varchar', 'character varying', 'char', 'character', 'nvarchar', 'nchar', 'text', 'clob'];
          return rows.map(r => {
            const declared = (r.type || '').trim().toLowerCase();
            const paren = declared.match(/^([a-z0-9_ ]+?)\s*\(([^)]*)\)\s*$/);
            const base = paren ? paren[1].trim() : declared;
            const argParts = paren && paren[2].trim() !== ''
              ? paren[2].split(',').map(s => parseInt(s.trim(), 10)).filter(n => !Number.isNaN(n))
              : [];

            let charMaxLen = null;
            let numericPrecision = null;
            let numericScale = null;
            if (argParts.length > 0) {
              if (STRING_BASES.includes(base)) {
                charMaxLen = argParts[0];
              } else {
                numericPrecision = argParts[0];
                numericScale = argParts.length > 1 ? argParts[1] : 0;
              }
            }

            return {
              column_name: r.name,
              data_type: base,
              udt_name: base,
              column_default: r.dflt_value !== undefined && r.dflt_value !== null ? String(r.dflt_value) : null,
              is_nullable: Number(r.nn) === 1 ? 'NO' : 'YES',
              character_maximum_length: charMaxLen,
              numeric_precision: numericPrecision,
              numeric_scale: numericScale
            };
          });
        }
      }
      return [];
    } catch (error) {
      console.warn(`  Warning: Could not fetch detailed column info: ${error.message}`);
      return [];
    }
  }

  /**
   * Get NOT NULL fields dari table
   * @param {string} tableName - Nama table
   * @returns {Promise<Array<string>>} Array of NOT NULL column names
   */
  async getNotNullFields(tableName) {
    if (!this.pool) return [];

    try {
      const { schema, table } = this.parseTableName(tableName);

      switch (this.dbType) {
        case 'postgresql': {
          const rows = await this.query(
            `SELECT column_name FROM information_schema.columns
             WHERE table_schema = $1 AND table_name = $2 AND is_nullable = 'NO'`,
            [schema, table]
          );
          return rows.map(r => r.column_name);
        }

        case 'mysql': {
          const rows = await this.query(
            `SELECT column_name FROM information_schema.columns
             WHERE table_schema = ? AND table_name = ? AND is_nullable = 'NO'`,
            [schema, table]
          );
          return rows.map(r => r.COLUMN_NAME || r.column_name);
        }

        case 'oracle': {
          const rows = await this.query(
            `SELECT column_name FROM user_tab_columns
             WHERE table_name = :1 AND nullable = 'N'`,
            [table.toUpperCase()]
          );
          return rows.map(r => r.column_name.toLowerCase());
        }

        case 'sqlite': {
          const rows = await this.query(
            `SELECT name FROM pragma_table_info(?) WHERE "notnull" = 1`,
            [table]
          );
          return rows.map(r => r.name);
        }
      }
      return [];
    } catch (error) {
      console.warn(`  Warning: Could not fetch NOT NULL fields: ${error.message}`);
      return [];
    }
  }

  /**
   * Get table constraints (primary key, unique)
   * @param {string} tableName - Nama table
   * @returns {Promise<Array>} Array of constraints
   */
  async getConstraints(tableName) {
    if (!this.pool) return [];

    try {
      const { schema, table } = this.parseTableName(tableName);

      switch (this.dbType) {
        case 'postgresql': {
          const rows = await this.query(
            `SELECT tc.constraint_name, tc.constraint_type,
                    array_agg(kcu.column_name::text ORDER BY kcu.ordinal_position) as columns
             FROM information_schema.table_constraints tc
             JOIN information_schema.key_column_usage kcu
               ON tc.constraint_name = kcu.constraint_name
               AND tc.table_schema = kcu.table_schema AND tc.table_name = kcu.table_name
             WHERE tc.table_schema = $1 AND tc.table_name = $2
               AND tc.constraint_type IN ('PRIMARY KEY', 'UNIQUE')
             GROUP BY tc.constraint_name, tc.constraint_type
             ORDER BY CASE tc.constraint_type WHEN 'PRIMARY KEY' THEN 1 WHEN 'UNIQUE' THEN 2 ELSE 3 END`,
            [schema, table]
          );
          return rows.map(row => ({
            name: row.constraint_name,
            type: row.constraint_type,
            columns: row.columns
          }));
        }

        case 'mysql': {
          const rows = await this.query(
            `SELECT tc.constraint_name, tc.constraint_type,
                    GROUP_CONCAT(kcu.column_name ORDER BY kcu.ordinal_position) as columns_str
             FROM information_schema.table_constraints tc
             JOIN information_schema.key_column_usage kcu
               ON tc.constraint_name = kcu.constraint_name
               AND tc.table_schema = kcu.table_schema AND tc.table_name = kcu.table_name
             WHERE tc.table_schema = ? AND tc.table_name = ?
               AND tc.constraint_type IN ('PRIMARY KEY', 'UNIQUE')
             GROUP BY tc.constraint_name, tc.constraint_type`,
            [schema, table]
          );
          return rows.map(row => ({
            name: row.CONSTRAINT_NAME || row.constraint_name,
            type: row.CONSTRAINT_TYPE || row.constraint_type,
            columns: (row.COLUMNS_STR || row.columns_str || '').split(',')
          }));
        }

        case 'oracle': {
          const rows = await this.query(
            `SELECT uc.constraint_name, uc.constraint_type
             FROM user_constraints uc
             WHERE uc.table_name = :1 AND uc.constraint_type IN ('P', 'U')`,
            [table.toUpperCase()]
          );
          // Fetch columns for each constraint
          const result = [];
          for (const row of rows) {
            const colRows = await this.query(
              `SELECT column_name FROM user_cons_columns
               WHERE constraint_name = :1 ORDER BY position`,
              [row.constraint_name]
            );
            result.push({
              name: row.constraint_name.toLowerCase(),
              type: row.constraint_type === 'P' ? 'PRIMARY KEY' : 'UNIQUE',
              columns: colRows.map(c => c.column_name.toLowerCase())
            });
          }
          return result;
        }

        case 'sqlite': {
          const result = [];

          // PRIMARY KEY: dari pragma_table_info. Menangani INTEGER PRIMARY KEY
          // (rowid alias) yang TIDAK muncul di pragma_index_list. Composite PK
          // diurutkan berdasarkan kolom pk (1-based).
          const pkRows = await this.query(
            `SELECT name, pk FROM pragma_table_info(?) WHERE pk > 0 ORDER BY pk`,
            [table]
          );
          if (pkRows.length > 0) {
            result.push({
              name: `pk_${table}`,
              type: 'PRIMARY KEY',
              columns: pkRows.map(r => r.name)
            });
          }

          // UNIQUE: pragma_index_list origin = 'u' (UNIQUE constraint), bukan
          // 'pk' (primary key, sudah ditangani) atau 'c' (CREATE INDEX manual).
          const uniqueIdx = await this.query(
            `SELECT name FROM pragma_index_list(?) WHERE origin = 'u'`,
            [table]
          );
          for (const idx of uniqueIdx) {
            const cols = await this.query(
              `SELECT name FROM pragma_index_info(?) ORDER BY seqno`,
              [idx.name]
            );
            result.push({
              name: idx.name,
              type: 'UNIQUE',
              columns: cols.map(c => c.name)
            });
          }

          return result;
        }
      }
      return [];
    } catch (error) {
      console.warn(`  Warning: Could not fetch constraints: ${error.message}`);
      return [];
    }
  }

  /**
   * Get CHECK constraints for a table (PostgreSQL, Fase 1).
   *
   * Mengembalikan `{ name, clause }` per CHECK constraint, di mana `clause` adalah
   * definisi lengkap dari `pg_get_constraintdef` (mis.
   * `CHECK ((is_deleted = ...) OR (...))`). Cukup untuk:
   *   - mengenali CHECK konsistensi soft-delete lewat NAMA deterministik
   *     (`chk_<table>_soft_delete_consistency`, R8) di introspect-mapper (R6), dan
   *   - parse CHECK generik single-field (`age >= 0`, dll.) di introspect-mapper.
   *
   * Hanya PostgreSQL yang diimplementasikan: Fase 1 soft-delete adalah PostgreSQL-only,
   * dan dialect lain (mysql/oracle) memakai penanda boolean `col IN ('true','false')`
   * yang sudah ditangani jalur `getBooleanColumns` terpisah. Memasukkan penanda boolean
   * itu ke `checks` justru akan menimbulkan false drift di diff dbschema-kit (sisi SDF
   * tidak menyimpan penanda boolean di `checks`), maka non-PostgreSQL mengembalikan [].
   *
   * Degradasi anggun: bila katalog tidak tersedia, error di-catch dan dikembalikan []
   * (mengikuti pola `getBooleanColumns`).
   *
   * @param {string} tableName - Nama table (boleh schema.tablename atau tablename)
   * @returns {Promise<Array<{name:string, clause:string}>>}
   */
  async getCheckConstraints(tableName) {
    if (!this.pool) return [];

    // Fase 1: introspeksi CHECK hanya untuk PostgreSQL (lihat doc di atas).
    if (this.dbType !== 'postgresql') return [];

    try {
      const { schema, table } = this.parseTableName(tableName);
      const rows = await this.query(
        `SELECT con.conname AS name,
                pg_get_constraintdef(con.oid) AS clause
         FROM pg_constraint con
         JOIN pg_class rel ON rel.oid = con.conrelid
         JOIN pg_namespace ns ON ns.oid = rel.relnamespace
         WHERE con.contype = 'c'
           AND ns.nspname = $1 AND rel.relname = $2
         ORDER BY con.conname`,
        [schema, table]
      );
      return rows.map(r => ({ name: r.name, clause: r.clause }));
    } catch (error) {
      console.warn(`  Warning: Could not fetch check constraints: ${error.message}`);
      return [];
    }
  }

  /**
   * Deteksi kolom boolean pada dialect varchar (mysql/oracle/sqlite) lewat
   * penanda CHECK `<col> IN ('true','false')` yang di-emit dbschema-kit.
   *
   * PostgreSQL memakai tipe BOOLEAN native sehingga sudah terdeteksi di
   * getDetailedColumnInfo (data_type 'boolean'); method ini mengembalikan []
   * untuk postgresql. Degradasi anggun: bila katalog CHECK tidak tersedia
   * (mis. MySQL < 8.0.16 atau Oracle < 12c tanpa search_condition_vc), error
   * di-catch dan dikembalikan [] (kolom tetap diperlakukan string).
   *
   * @param {string} tableName - Nama table
   * @returns {Promise<string[]>} Nama kolom boolean (lowercase)
   */
  async getBooleanColumns(tableName) {
    if (!this.pool) return [];

    try {
      const { schema, table } = this.parseTableName(tableName);
      let texts = [];

      switch (this.dbType) {
        case 'sqlite': {
          const rows = await this.query(
            `SELECT sql FROM sqlite_master WHERE type = 'table' AND name = ?`,
            [table]
          );
          texts = rows.map(r => r.sql || '');
          break;
        }

        case 'mysql': {
          const rows = await this.query(
            `SELECT cc.CHECK_CLAUSE AS clause
             FROM information_schema.CHECK_CONSTRAINTS cc
             JOIN information_schema.TABLE_CONSTRAINTS tc
               ON cc.CONSTRAINT_NAME = tc.CONSTRAINT_NAME
              AND cc.CONSTRAINT_SCHEMA = tc.CONSTRAINT_SCHEMA
             WHERE tc.TABLE_SCHEMA = ? AND tc.TABLE_NAME = ? AND tc.CONSTRAINT_TYPE = 'CHECK'`,
            [schema, table]
          );
          texts = rows.map(r => r.CHECK_CLAUSE || r.clause || '');
          break;
        }

        case 'oracle': {
          // search_condition_vc (VARCHAR2) tersedia Oracle 12c+; lebih mudah
          // di-query daripada kolom LONG search_condition.
          const rows = await this.query(
            `SELECT search_condition_vc AS clause FROM user_constraints
             WHERE table_name = :1 AND constraint_type = 'C'`,
            [table.toUpperCase()]
          );
          texts = rows.map(r => r.clause || r.search_condition_vc || '');
          break;
        }

        default:
          // postgresql: boolean native, tidak perlu penanda
          return [];
      }

      const cols = new Set();
      for (const t of texts) {
        for (const c of extractBooleanCheckColumns(t)) cols.add(c);
      }
      return Array.from(cols);
    } catch (error) {
      console.warn(`  Warning: Could not detect boolean columns: ${error.message}`);
      return [];
    }
  }

  /**
   * List all tables (and views) in database.
   *
   * Mendukung dua signature:
   *   - Legacy: listTables(schemaFilter)             -> hanya BASE TABLE (preserve old behavior)
   *   - New:    listTables({ schemaFilter, includeSystem }) -> BASE TABLE + VIEW
   *
   * Return shape: array of { schema, name, type, table, fullName }.
   * Field `table` dan `fullName` dipertahankan sebagai alias agar caller
   * lama (mis. listTablesWorkflow di create-payload.js) tidak break.
   *
   * @param {Object|string|null} arg - options object atau legacy schemaFilter
   * @returns {Promise<Array<{schema:string,name:string,type:'table'|'view',table:string,fullName:string}>>}
   */
  async listTables(arg = null) {
    if (!this.pool) return [];

    // Detect signature mode
    let schemaFilter = null;
    let includeSystem = false;
    let modernMode = false;

    if (arg !== null && typeof arg === 'object' && !Array.isArray(arg)) {
      modernMode = true;
      schemaFilter = arg.schemaFilter !== undefined ? arg.schemaFilter : null;
      includeSystem = arg.includeSystem === true;
    } else {
      // Legacy: arg is string | null | undefined
      schemaFilter = arg || null;
    }

    try {
      let rawRows = [];

      switch (this.dbType) {
        case 'postgresql': {
          if (modernMode) {
            const sql = `
              SELECT table_schema AS schema, table_name AS name,
                CASE table_type
                  WHEN 'BASE TABLE' THEN 'table'
                  WHEN 'VIEW' THEN 'view'
                  ELSE 'table'
                END AS type
              FROM information_schema.tables
              WHERE table_type IN ('BASE TABLE', 'VIEW')
                AND ($1::text IS NULL OR table_schema = $1)
                AND ($2::boolean = true OR table_schema NOT IN ('pg_catalog', 'information_schema'))
              ORDER BY table_schema, table_name`;
            rawRows = await this.query(sql, [schemaFilter, includeSystem]);
          } else {
            // Legacy path - preserve old behavior (BASE TABLE only, no view)
            let sql = `SELECT table_schema, table_name FROM information_schema.tables
                       WHERE table_type = 'BASE TABLE'
                       AND table_schema NOT IN ('pg_catalog', 'information_schema')`;
            const params = [];
            if (schemaFilter) {
              sql += ` AND table_schema = $1`;
              params.push(schemaFilter);
            }
            sql += ` ORDER BY table_schema, table_name`;
            const rows = await this.query(sql, params);
            rawRows = rows.map(r => ({
              schema: r.table_schema,
              name: r.table_name,
              type: 'table'
            }));
          }
          break;
        }

        case 'mysql': {
          if (modernMode) {
            const sql = `
              SELECT table_schema AS \`schema\`, table_name AS name,
                CASE table_type
                  WHEN 'BASE TABLE' THEN 'table'
                  WHEN 'VIEW' THEN 'view'
                  ELSE 'table'
                END AS type
              FROM information_schema.tables
              WHERE table_type IN ('BASE TABLE', 'VIEW')
                AND (? IS NULL OR table_schema = ?)
                AND (? = 1 OR table_schema NOT IN ('mysql', 'information_schema', 'performance_schema', 'sys'))
              ORDER BY table_schema, table_name`;
            const rows = await this.query(sql, [schemaFilter, schemaFilter, includeSystem ? 1 : 0]);
            rawRows = rows.map(r => ({
              schema: r.schema || r.SCHEMA || r.TABLE_SCHEMA || r.table_schema,
              name: r.name || r.NAME || r.TABLE_NAME || r.table_name,
              type: r.type || r.TYPE || 'table'
            }));
          } else {
            // Legacy path - preserve old behavior
            const dbName = schemaFilter || this.config.DB_NAME;
            const rows = await this.query(
              `SELECT table_schema, table_name FROM information_schema.tables
               WHERE table_schema = ? AND table_type = 'BASE TABLE'
               ORDER BY table_name`,
              [dbName]
            );
            rawRows = rows.map(row => ({
              schema: row.TABLE_SCHEMA || row.table_schema,
              name: row.TABLE_NAME || row.table_name,
              type: 'table'
            }));
          }
          break;
        }

        case 'oracle': {
          if (modernMode) {
            const sql = `
              SELECT owner AS schema, table_name AS name, 'table' AS type
                FROM all_tables
               WHERE (:schemaFilter IS NULL OR owner = UPPER(:schemaFilter2))
                 AND (:includeSystem = 1 OR owner NOT IN ('SYS', 'SYSTEM', 'XDB', 'OUTLN', 'CTXSYS', 'MDSYS', 'WMSYS'))
                 AND table_name NOT LIKE 'BIN$%'
              UNION ALL
              SELECT owner AS schema, view_name AS name, 'view' AS type
                FROM all_views
               WHERE (:schemaFilter3 IS NULL OR owner = UPPER(:schemaFilter4))
                 AND (:includeSystem2 = 1 OR owner NOT IN ('SYS', 'SYSTEM', 'XDB', 'OUTLN', 'CTXSYS', 'MDSYS', 'WMSYS'))
              ORDER BY 1, 2`;
            const sysFlag = includeSystem ? 1 : 0;
            const rows = await this.query(sql, {
              schemaFilter,
              schemaFilter2: schemaFilter,
              schemaFilter3: schemaFilter,
              schemaFilter4: schemaFilter,
              includeSystem: sysFlag,
              includeSystem2: sysFlag
            });
            rawRows = rows.map(r => ({
              schema: (r.schema || '').toLowerCase(),
              name: (r.name || '').toLowerCase(),
              type: r.type || 'table'
            }));
          } else {
            // Legacy path - preserve old behavior
            const owner = schemaFilter ? schemaFilter.toUpperCase() : this.config.DB_USER.toUpperCase();
            const rows = await this.query(
              `SELECT owner, table_name FROM all_tables
               WHERE owner = :1 AND table_name NOT LIKE 'BIN$%'
               ORDER BY table_name`,
              [owner]
            );
            rawRows = rows.map(row => ({
              schema: row.owner.toLowerCase(),
              name: row.table_name.toLowerCase(),
              type: 'table'
            }));
          }
          break;
        }

        case 'sqlite': {
          // SQLite tidak punya schema; schemaFilter diabaikan. Tabel internal
          // (sqlite_*, mis. sqlite_sequence) di-skip kecuali includeSystem.
          // Legacy mode: BASE TABLE saja; modern mode: table + view.
          const typeList = modernMode ? "('table', 'view')" : "('table')";
          let sql = `SELECT name, type FROM sqlite_master WHERE type IN ${typeList}`;
          if (!includeSystem) {
            sql += " AND name NOT LIKE 'sqlite_%'";
          }
          sql += ' ORDER BY name';
          const rows = await this.query(sql, []);
          rawRows = rows.map(r => ({
            schema: 'main',
            name: r.name,
            type: r.type === 'view' ? 'view' : 'table'
          }));
          break;
        }

        default:
          return [];
      }

      // Decorate dengan alias `table` dan `fullName` agar caller lama tidak break
      return rawRows.map(r => ({
        schema: r.schema,
        name: r.name,
        type: r.type,
        table: r.name,
        fullName: `${r.schema}.${r.name}`
      }));
    } catch (error) {
      console.warn(`  Warning: Could not list tables: ${error.message}`);
      return [];
    }
  }

  /**
   * Get foreign keys for a table.
   * @param {string} tableName - Nama table (boleh schema.tablename atau tablename)
   * @returns {Promise<Array>} Array of foreign key objects:
   *   {
   *     name: string,
   *     columns: string[],
   *     references: { schema: string|null, table: string, columns: string[] },
   *     onDelete: string|null,
   *     onUpdate: string|null
   *   }
   */
  async getForeignKeys(tableName) {
    if (!this.pool) return [];

    try {
      const { schema, table } = this.parseTableName(tableName);

      switch (this.dbType) {
        case 'postgresql': {
          const sql = `
            SELECT
              c.conname AS name,
              ns2.nspname AS ref_schema,
              cl2.relname AS ref_table,
              c.confdeltype AS del_type,
              c.confupdtype AS upd_type,
              (SELECT array_agg(a.attname::text ORDER BY pos.ord)
                 FROM unnest(c.conkey) WITH ORDINALITY AS pos(attnum, ord)
                 JOIN pg_attribute a ON a.attrelid = c.conrelid AND a.attnum = pos.attnum) AS columns,
              (SELECT array_agg(a.attname::text ORDER BY pos.ord)
                 FROM unnest(c.confkey) WITH ORDINALITY AS pos(attnum, ord)
                 JOIN pg_attribute a ON a.attrelid = c.confrelid AND a.attnum = pos.attnum) AS ref_columns
            FROM pg_constraint c
            JOIN pg_class cl ON cl.oid = c.conrelid
            JOIN pg_namespace ns ON ns.oid = cl.relnamespace
            JOIN pg_class cl2 ON cl2.oid = c.confrelid
            JOIN pg_namespace ns2 ON ns2.oid = cl2.relnamespace
            WHERE c.contype = 'f'
              AND ns.nspname = $1 AND cl.relname = $2
            ORDER BY c.conname`;
          const rows = await this.query(sql, [schema, table]);
          return rows.map(r => ({
            name: r.name,
            columns: r.columns || [],
            references: {
              schema: r.ref_schema,
              table: r.ref_table,
              columns: r.ref_columns || []
            },
            onDelete: PG_FK_ACTION[r.del_type] || null,
            onUpdate: PG_FK_ACTION[r.upd_type] || null
          }));
        }

        case 'mysql': {
          const sql = `
            SELECT
              rc.constraint_name AS name,
              rc.delete_rule AS on_delete,
              rc.update_rule AS on_update,
              rc.unique_constraint_schema AS ref_schema,
              rc.referenced_table_name AS ref_table,
              GROUP_CONCAT(kcu.column_name ORDER BY kcu.ordinal_position) AS columns_str,
              GROUP_CONCAT(kcu.referenced_column_name ORDER BY kcu.ordinal_position) AS ref_columns_str
            FROM information_schema.referential_constraints rc
            JOIN information_schema.key_column_usage kcu
              ON rc.constraint_name = kcu.constraint_name
              AND rc.constraint_schema = kcu.constraint_schema
            WHERE rc.constraint_schema = ?
              AND kcu.table_name = ?
              AND kcu.referenced_table_name IS NOT NULL
            GROUP BY rc.constraint_name, rc.delete_rule, rc.update_rule,
                     rc.unique_constraint_schema, rc.referenced_table_name
            ORDER BY rc.constraint_name`;
          const rows = await this.query(sql, [schema, table]);
          return rows.map(r => {
            const name = r.CONSTRAINT_NAME || r.constraint_name || r.name;
            const onDelete = r.DELETE_RULE || r.delete_rule || r.on_delete;
            const onUpdate = r.UPDATE_RULE || r.update_rule || r.on_update;
            const refSchema = r.UNIQUE_CONSTRAINT_SCHEMA || r.unique_constraint_schema || r.ref_schema;
            const refTable = r.REFERENCED_TABLE_NAME || r.referenced_table_name || r.ref_table;
            const colsStr = r.COLUMNS_STR || r.columns_str || '';
            const refColsStr = r.REF_COLUMNS_STR || r.ref_columns_str || '';
            return {
              name,
              columns: colsStr ? colsStr.split(',') : [],
              references: {
                schema: refSchema,
                table: refTable,
                columns: refColsStr ? refColsStr.split(',') : []
              },
              onDelete: onDelete || null,
              onUpdate: onUpdate || null
            };
          });
        }

        case 'oracle': {
          const sql = `
            SELECT
              c.constraint_name AS name,
              c.delete_rule AS on_delete,
              cr.owner AS ref_schema,
              cr.table_name AS ref_table,
              cc.column_name AS column_name,
              cc.position AS pos,
              ccr.column_name AS ref_column
            FROM all_constraints c
            JOIN all_cons_columns cc
              ON cc.constraint_name = c.constraint_name AND cc.owner = c.owner
            JOIN all_constraints cr
              ON cr.constraint_name = c.r_constraint_name AND cr.owner = c.r_owner
            JOIN all_cons_columns ccr
              ON ccr.constraint_name = cr.constraint_name AND ccr.owner = cr.owner
              AND ccr.position = cc.position
            WHERE c.constraint_type = 'R'
              AND c.owner = :ownerSchema
              AND c.table_name = :tableName
            ORDER BY c.constraint_name, cc.position`;
          const rows = await this.query(sql, {
            ownerSchema: schema.toUpperCase(),
            tableName: table.toUpperCase()
          });
          // Group by constraint name
          const grouped = {};
          for (const r of rows) {
            const name = (r.name || '').toLowerCase();
            if (!grouped[name]) {
              grouped[name] = {
                name,
                columns: [],
                references: {
                  schema: (r.ref_schema || '').toLowerCase(),
                  table: (r.ref_table || '').toLowerCase(),
                  columns: []
                },
                onDelete: r.on_delete || null,
                onUpdate: null // Oracle does not support ON UPDATE
              };
            }
            grouped[name].columns.push((r.column_name || '').toLowerCase());
            grouped[name].references.columns.push((r.ref_column || '').toLowerCase());
          }
          return Object.values(grouped);
        }

        case 'sqlite': {
          // pragma_foreign_key_list: kolom id (group FK composite), seq (urutan
          // kolom), table (tabel referensi), from/to (kolom lokal/referensi),
          // on_update/on_delete. SQLite tidak menamai FK constraint, jadi nama
          // disintesis fk_<table>_<id>. references.schema null (tanpa schema).
          const rows = await this.query(
            `SELECT id, seq, "table" AS ref_table, "from" AS col, "to" AS ref_col,
                    on_update, on_delete
             FROM pragma_foreign_key_list(?) ORDER BY id, seq`,
            [table]
          );
          const grouped = {};
          for (const r of rows) {
            const id = r.id;
            if (!grouped[id]) {
              grouped[id] = {
                name: `fk_${table}_${id}`,
                columns: [],
                references: {
                  schema: null,
                  table: r.ref_table,
                  columns: []
                },
                onDelete: r.on_delete || null,
                onUpdate: r.on_update || null
              };
            }
            grouped[id].columns.push(r.col);
            grouped[id].references.columns.push(r.ref_col);
          }
          return Object.values(grouped);
        }
      }
      return [];
    } catch (error) {
      console.warn(`  Warning: Could not fetch foreign keys: ${error.message}`);
      return [];
    }
  }

  /**
   * Get indexes for a table.
   * @param {string} tableName - Nama table (boleh schema.tablename atau tablename)
   * @returns {Promise<Array<{name:string,columns:string[],unique:boolean,primary:boolean}>>}
   */
  async getIndexes(tableName) {
    if (!this.pool) return [];

    try {
      const { schema, table } = this.parseTableName(tableName);

      switch (this.dbType) {
        case 'postgresql': {
          const sql = `
            SELECT
              i.relname AS name,
              ix.indisunique AS is_unique,
              ix.indisprimary AS is_primary,
              (SELECT array_agg(a.attname::text ORDER BY pos.ord)
                 FROM unnest(ix.indkey::int[]) WITH ORDINALITY AS pos(attnum, ord)
                 JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = pos.attnum) AS columns
            FROM pg_class i
            JOIN pg_index ix ON ix.indexrelid = i.oid
            JOIN pg_class t ON t.oid = ix.indrelid
            JOIN pg_namespace n ON n.oid = t.relnamespace
            WHERE n.nspname = $1 AND t.relname = $2
            ORDER BY i.relname`;
          const rows = await this.query(sql, [schema, table]);
          return rows.map(r => ({
            name: r.name,
            columns: r.columns || [],
            unique: r.is_unique === true,
            primary: r.is_primary === true
          }));
        }

        case 'mysql': {
          const sql = `
            SELECT
              index_name AS name,
              GROUP_CONCAT(column_name ORDER BY seq_in_index) AS columns_str,
              MAX(non_unique = 0) AS is_unique,
              MAX(index_name = 'PRIMARY') AS is_primary
            FROM information_schema.statistics
            WHERE table_schema = ? AND table_name = ?
            GROUP BY index_name
            ORDER BY index_name`;
          const rows = await this.query(sql, [schema, table]);
          return rows.map(r => {
            const name = r.NAME || r.name;
            const colsStr = r.COLUMNS_STR || r.columns_str || '';
            const isUnique = r.IS_UNIQUE !== undefined ? r.IS_UNIQUE : r.is_unique;
            const isPrimary = r.IS_PRIMARY !== undefined ? r.IS_PRIMARY : r.is_primary;
            return {
              name,
              columns: colsStr ? colsStr.split(',') : [],
              unique: Number(isUnique) === 1,
              primary: Number(isPrimary) === 1
            };
          });
        }

        case 'oracle': {
          const sql = `
            SELECT
              i.index_name AS name,
              i.uniqueness AS uniqueness,
              ic.column_name AS column_name,
              ic.column_position AS pos,
              CASE WHEN c.constraint_type = 'P' THEN 'Y' ELSE 'N' END AS is_primary
            FROM all_indexes i
            JOIN all_ind_columns ic
              ON ic.index_name = i.index_name AND ic.index_owner = i.owner
            LEFT JOIN all_constraints c
              ON c.index_name = i.index_name AND c.owner = i.owner AND c.constraint_type = 'P'
            WHERE i.owner = :ownerSchema AND i.table_name = :tableName
            ORDER BY i.index_name, ic.column_position`;
          const rows = await this.query(sql, {
            ownerSchema: schema.toUpperCase(),
            tableName: table.toUpperCase()
          });
          // Group by index name
          const grouped = {};
          for (const r of rows) {
            const name = (r.name || '').toLowerCase();
            if (!grouped[name]) {
              grouped[name] = {
                name,
                columns: [],
                unique: r.uniqueness === 'UNIQUE',
                primary: r.is_primary === 'Y'
              };
            }
            grouped[name].columns.push((r.column_name || '').toLowerCase());
          }
          return Object.values(grouped);
        }

        case 'sqlite': {
          // pragma_index_list: name, unique (0/1), origin ('pk'|'u'|'c').
          // INTEGER PRIMARY KEY (rowid) tidak punya index terpisah sehingga
          // tidak muncul di sini, konsisten dengan realita storage sqlite.
          const idxRows = await this.query(
            `SELECT name, "unique" AS is_unique, origin FROM pragma_index_list(?) ORDER BY name`,
            [table]
          );
          const result = [];
          for (const idx of idxRows) {
            const cols = await this.query(
              `SELECT name FROM pragma_index_info(?) ORDER BY seqno`,
              [idx.name]
            );
            result.push({
              name: idx.name,
              columns: cols.map(c => c.name),
              unique: Number(idx.is_unique) === 1,
              primary: idx.origin === 'pk'
            });
          }
          return result;
        }
      }
      return [];
    } catch (error) {
      console.warn(`  Warning: Could not fetch indexes: ${error.message}`);
      return [];
    }
  }

  /**
   * Validate a SELECT/CTE statement against the live database without executing it.
   * Uses EXPLAIN under the hood — pure planner, zero rows touched.
   *
   * SQL is treated as an opaque string — caller is responsible for SELECT-only
   * enforcement. Method does not inspect SQL content.
   *
   * @param {string} sql - SQL string (assumed already validated as SELECT/WITH by caller)
   * @returns {Promise<{ok: boolean, error?: {code: string, message: string, hint?: string}}>}
   */
  /**
   * List user schemas (skip system schemas).
   *
   * Added in Phase 14 to support multi-schema introspect via `dbschema:introspect
   * --all-schemas`. Returns lowercase schema names sorted alphabetically.
   *
   * @returns {Promise<Array<string>>}
   */
  async listUserSchemas() {
    if (!this.pool) return [];

    try {
      switch (this.dbType) {
        case 'postgresql': {
          const rows = await this.query(
            `SELECT schema_name FROM information_schema.schemata
             WHERE schema_name NOT LIKE 'pg\\_%' ESCAPE '\\'
               AND schema_name <> 'information_schema'
             ORDER BY schema_name`,
            []
          );
          return rows.map(r => String(r.schema_name).toLowerCase());
        }

        case 'mysql': {
          const rows = await this.query(
            `SELECT schema_name FROM information_schema.schemata
             WHERE schema_name NOT IN ('mysql', 'sys', 'performance_schema', 'information_schema')
             ORDER BY schema_name`,
            []
          );
          return rows.map(r => {
            const v = r.SCHEMA_NAME || r.schema_name;
            return String(v).toLowerCase();
          });
        }

        case 'oracle': {
          // Oracle 12c+ exposes oracle_maintained flag; fall back to a static
          // blacklist for older releases by ignoring the flag column gracefully.
          const sql = `
            SELECT username
              FROM all_users
             WHERE username NOT IN (
                'SYS', 'SYSTEM', 'XDB', 'OUTLN', 'MDSYS', 'CTXSYS', 'EXFSYS',
                'WMSYS', 'ANONYMOUS', 'ORDDATA', 'ORDPLUGINS', 'ORDSYS', 'OLAPSYS',
                'LBACSYS', 'DBSNMP', 'APPQOSSYS', 'AUDSYS', 'GSMADMIN_INTERNAL'
             )
               AND username NOT LIKE 'APEX\\_%' ESCAPE '\\'
             ORDER BY username`;
          const rows = await this.query(sql, []);
          return rows.map(r => String(r.username).toLowerCase());
        }

        case 'sqlite': {
          // SQLite tidak mengenal konsep schema bernama (hanya database 'main'
          // dan optional 'temp'/attached). Tidak ada user schema untuk diiterasi.
          return [];
        }
      }
      return [];
    } catch (error) {
      console.warn(`  Warning: Could not list schemas: ${error.message}`);
      return [];
    }
  }

  /**
   * Describe kolom output dari arbitrary SELECT query tanpa mengkonsumsi row.
   * Pendekatan: wrap query dalam subquery `WHERE false LIMIT 0` agar DB engine
   * tetap resolve column metadata (alias, JOIN, subquery) tanpa execute baris.
   *
   * Dipakai oleh schema-diff untuk extract kolom dari viewQuery/datatablesQuery/
   * exportQuery, sehingga JOIN field (mis. `category_name`) bisa di-treat sebagai
   * valid column saat drift check terhadap `fieldName`.
   *
   * @param {string} sql - SELECT statement
   * @returns {Promise<{ok: boolean, columns?: string[], error?: {code: string, message: string}}>}
   */
  async describeQueryColumns(sql) {
    if (!this.pool) {
      throw new Error('Database not connected');
    }

    const trimmed = (sql || '').trim().replace(/;+\s*$/, '');
    if (!trimmed) {
      return { ok: false, error: { code: 'empty_sql', message: 'SQL query is empty' } };
    }

    switch (this.dbType) {
      case 'postgresql': {
        const wrapped = `SELECT * FROM (${trimmed}) _describe WHERE false`;
        try {
          const result = await this.pool.query(wrapped);
          const columns = (result.fields || []).map(f => String(f.name).toLowerCase());
          return { ok: true, columns };
        } catch (err) {
          return {
            ok: false,
            error: {
              code: err.code || 'unknown',
              message: err.message
            }
          };
        }
      }

      case 'mysql': {
        const wrapped = `SELECT * FROM (${trimmed}) _describe WHERE 1=0`;
        try {
          const [, fields] = await this.pool.query(wrapped);
          const columns = (fields || []).map(f => String(f.name).toLowerCase());
          return { ok: true, columns };
        } catch (err) {
          return {
            ok: false,
            error: {
              code: err.code || (err.errno ? String(err.errno) : 'unknown'),
              message: err.sqlMessage || err.message
            }
          };
        }
      }

      case 'oracle': {
        // Oracle melarang identifier tak-ber-quote diawali underscore (harus
        // diawali huruf), sehingga alias `_describe` memicu ORA-00911 (invalid
        // character). Gunakan alias valid `rf_describe`.
        const wrapped = `SELECT * FROM (${trimmed}) rf_describe WHERE 1=0`;
        const conn = await this.pool.getConnection();
        try {
          const result = await conn.execute(wrapped, [], { maxRows: 0 });
          const columns = (result.metaData || []).map(m => String(m.name).toLowerCase());
          return { ok: true, columns };
        } catch (err) {
          return {
            ok: false,
            error: {
              code: err.errorNum
                ? `ORA-${String(err.errorNum).padStart(5, '0')}`
                : 'unknown',
              message: err.message
            }
          };
        } finally {
          try { await conn.close(); } catch (e) { /* ignore */ }
        }
      }

      case 'sqlite': {
        // prepare() me-resolve metadata kolom tanpa mengeksekusi baris.
        // stmt.columns() mengembalikan deskriptor kolom untuk statement
        // yang mengembalikan data (SELECT).
        try {
          const stmt = this.pool.prepare(trimmed);
          const columns = (stmt.columns() || []).map(c => String(c.name).toLowerCase());
          return { ok: true, columns };
        } catch (err) {
          return {
            ok: false,
            error: {
              code: err.code || 'unknown',
              message: err.message
            }
          };
        }
      }

      default:
        throw new Error(`Unsupported DB_TYPE: ${this.dbType}`);
    }
  }

  async validateQuery(sql) {
    if (!this.pool) {
      throw new Error('Database not connected');
    }

    switch (this.dbType) {
      case 'postgresql': {
        try {
          await this.pool.query(`EXPLAIN ${sql}`);
          return { ok: true };
        } catch (err) {
          return {
            ok: false,
            error: {
              code: err.code || 'unknown',
              message: err.message,
              ...(err.hint ? { hint: err.hint } : {})
            }
          };
        }
      }

      case 'mysql': {
        try {
          await this.pool.query(`EXPLAIN ${sql}`);
          return { ok: true };
        } catch (err) {
          return {
            ok: false,
            error: {
              code: err.code || (err.errno ? String(err.errno) : 'unknown'),
              message: err.sqlMessage || err.message
            }
          };
        }
      }

      case 'oracle': {
        const conn = await this.pool.getConnection();
        try {
          await conn.execute(`EXPLAIN PLAN FOR ${sql}`);
          return { ok: true };
        } catch (err) {
          return {
            ok: false,
            error: {
              code: err.errorNum
                ? `ORA-${String(err.errorNum).padStart(5, '0')}`
                : 'unknown',
              message: err.message
            }
          };
        } finally {
          try { await conn.close(); } catch (e) { /* ignore */ }
        }
      }

      case 'sqlite': {
        // prepare() melakukan validasi sintaks + resolusi schema tanpa
        // mengeksekusi statement, ekuivalen dengan peran EXPLAIN di dialect lain.
        try {
          this.pool.prepare(sql);
          return { ok: true };
        } catch (err) {
          return {
            ok: false,
            error: {
              code: err.code || 'unknown',
              message: err.message
            }
          };
        }
      }

      default:
        throw new Error(`Unsupported DB_TYPE: ${this.dbType}`);
    }
  }
}

module.exports = {
  DatabaseIntrospector,
  loadDriver
};
