'use strict';

/**
 * Contract: fast-track (verb global)
 *
 * Orkestrator alur pembangunan aplikasi RESTForge dari rencana di
 * `docs/plan/fast-track-command.md`. Alur dialog: input konfigurasi (LICENSE +
 * database) -> menu scope -> preview + konfirmasi -> eksekusi.
 *
 * Scope REST API (dan bagian backend pada scope ALL) dieksekusi NYATA dengan
 * mengorkestrasi command restforge existing (urutan mengikuti referensi
 * `restforge-playbook/fast-track.mjs` dan `quick-start.mjs`):
 *   ensureEnv -> validate -> config set-default -> schema migrate (folder)
 *   -> per tabel: payload generate + endpoint create.
 * Setelah generate, command menawarkan menjalankan runtime server di window
 * CMD baru (Windows).
 *
 * Scope Frontend (dan bagian frontend pada ALL) juga dieksekusi NYATA: payload
 * migrate (RDF -> UDF) per tabel -> restforge-designer activate -> generate.
 *
 * Catatan: contract ini global verb (snapshot key = `fast-track`), file berada
 * di root `generators/cli/` sehingga ter-discover otomatis sejajar dengan `init`.
 */

const path = require('node:path');
const fs = require('node:fs');
const http = require('node:http');
const readline = require('node:readline');
const { spawnSync } = require('node:child_process');

// Loader SDF yang SAMA dengan `schema migrate`. Bersifat rekursif sehingga
// struktur per-schema (mis. `schema/public/visitors.js`) ikut terbaca, dan
// mengembalikan IR model dengan relasi ter-normalisasi (sumber FK yang andal).
const { loadSchemaPath } = require('../lib/dbschema-kit/loader');

// Pembaca .env (parse KEY=VALUE, preserve diabaikan) dan resolver default config.
// Dipakai agar nilai fast-track mengikuti file config yang sudah ada, bukan
// semata DEFAULTS hardcode.
const { readEnvFile } = require('../lib/utils/env-manager');
const { getDefaultConfig, resolveConfigFilePath } = require('../lib/utils/config-resolver');

// ---------------------------------------------------------------------------
// Default konfigurasi (selaras dengan restforge-playbook/fast-track.mjs)
// ---------------------------------------------------------------------------

const DEFAULTS = {
    LICENSE: '8ECD-92A4-86BB-698Q',
    SERVER_ADDRESS: '127.0.0.1',
    SERVER_PORT: '3000',
    WEB_SERVER_PORT: '8000',
    DB_TYPE: 'postgresql',
    DB_HOST: '127.0.0.1',
    DB_PORT: '5432',
    DB_USER: 'postgres',
    DB_PASSWORD: 'postgres1234',
    DB_NAME: 'dbsandbox',
    DB_FILE: './data/sandbox.db'
};

const DEFAULT_CONFIG_FILE = 'db-connection.env';

// Default DB_PORT/DB_USER/DB_NAME menyesuaikan DB_TYPE yang dipilih; bila tidak
// terdaftar, fallback ke DEFAULTS.
const DB_TYPE_DEFAULTS = {
    postgresql: { DB_PORT: '5432', DB_USER: 'postgres' },
    postgres:   { DB_PORT: '5432', DB_USER: 'postgres' },
    mysql:      { DB_PORT: '3306', DB_USER: 'root' },
    oracle:     { DB_PORT: '1521', DB_USER: 'oracle', DB_NAME: 'ORCL' }
};

// Pilihan scope generate. Frontend selalu menyertakan REST API: payload migrate
// (RDF -> UDF) butuh file RDF dari pipeline REST API, dan runtime UI fetch ke
// REST API server. Maka tidak ada opsi "frontend only". Default '2' (REST API +
// frontend) selaras dengan pola dialog lain yang memakai default saat Enter.
const SCOPES = {
    '1': { key: 'backend', label: 'REST API only',              backend: true, frontend: false },
    '2': { key: 'all',     label: 'REST API + Frontend app',    backend: true, frontend: true }
};
const DEFAULT_SCOPE = '2';

// Map DB_TYPE (nilai pada db-connection.env) ke token flag `--database` pada
// endpoint create. Env memakai 'postgresql', CLI mengharapkan 'postgres'.
const DB_FLAG = {
    postgresql: 'postgres',
    postgres: 'postgres',
    mysql: 'mysql',
    oracle: 'oracle',
    sqlite: 'sqlite'
};

// ---------------------------------------------------------------------------
// Helper output
// ---------------------------------------------------------------------------

const LEADER_WIDTH = 36;

/**
 * Membangun baris ber-leader titik: `label .......... value`.
 */
function leader(label, value, width = LEADER_WIDTH, fill = '.') {
    let line = `${label} `;
    while (line.length < width) line += fill;
    return `${line} ${value}`;
}

function rule(char = '=', width = 64) {
    return char.repeat(width);
}

/**
 * Membuat satu prompter berbasis queue baris untuk dipakai sepanjang command.
 *
 * Memakai `rl.question` berturut-turut pada input yang di-pipe tidak reliable:
 * baris yang datang pada celah antar-prompt ter-emit tanpa handler dan hilang,
 * lalu EOF membuat prompt berikutnya menggantung. Pendekatan ini menampung
 * setiap event `line` ke dalam queue sehingga tidak ada input yang hilang, dan
 * mengembalikan string kosong saat EOF (input habis).
 */
function createPrompter() {
    const rl = readline.createInterface({
        input: process.stdin,
        output: process.stdout
    });
    const lineQueue = [];
    const waiters = [];
    let closed = false;

    rl.on('line', (line) => {
        if (waiters.length > 0) waiters.shift()(line);
        else lineQueue.push(line);
    });
    rl.on('close', () => {
        closed = true;
        while (waiters.length > 0) waiters.shift()('');
    });

    const ask = (message) => {
        return new Promise((resolve) => {
            // Saat input di-pipe (bukan TTY), readline tidak meng-echo newline,
            // sehingga prompt berurutan menumpuk di satu baris. Echo newline
            // manual agar transcript non-interaktif tetap terbaca.
            const finish = (val) => {
                if (!process.stdin.isTTY) process.stdout.write('\n');
                resolve(val);
            };

            // Input sudah ter-buffer (umumnya mode pipe): tampilkan label prompt
            // langsung lalu konsumsi dari queue, tanpa line-editor.
            if (lineQueue.length > 0) {
                process.stdout.write(message);
                finish(lineQueue.shift());
                return;
            }
            if (closed) {
                process.stdout.write(message);
                finish('');
                return;
            }

            // Mode interaktif: serahkan rendering prompt ke readline via
            // setPrompt/prompt agar readline mengetahui lebar prompt dan
            // line-editing (backspace, panah, Ctrl+U) berfungsi benar. Menulis
            // prompt manual membuat readline me-refresh baris dari kolom 0 dengan
            // prompt default ("> ") sehingga teks prompt asli terhapus saat backspace.
            rl.setPrompt(message);
            rl.prompt();
            waiters.push(finish);
        });
    };

    return { ask, close: () => rl.close(), rl };
}

/**
 * Error yang menghentikan command tanpa pesan "Error: ..." tambahan dari
 * cli-entry (dialog sudah mencetak konteksnya sendiri).
 */
function stop(message) {
    const err = new Error(message || 'fast-track stopped');
    err.silent = true;
    return err;
}

/**
 * Masking license untuk tampilan preview: grup pertama dan terakhir terlihat,
 * grup tengah disamarkan (mis. `8ECD-****-****-698Q`). Hanya untuk tampilan —
 * nilai asli tetap dipakai untuk penulisan env dan command.
 */
function maskLicense(license) {
    if (!license || typeof license !== 'string') return license;
    const groups = license.split('-');
    if (groups.length >= 3) {
        return groups
            .map((g, i) => (i === 0 || i === groups.length - 1) ? g : '*'.repeat(g.length))
            .join('-');
    }
    if (license.length > 8) {
        return `${license.slice(0, 4)}${'*'.repeat(license.length - 8)}${license.slice(-4)}`;
    }
    return license;
}

/**
 * Representasi database untuk baris preview, berdasarkan konfigurasi input.
 */
function describeDatabase(cfg) {
    if (cfg.DB_TYPE === 'sqlite') return `sqlite @ ${cfg.DB_FILE}`;
    return `${cfg.DB_TYPE} @ ${cfg.DB_HOST}:${cfg.DB_PORT}/${cfg.DB_NAME}`;
}

// ---------------------------------------------------------------------------
// Pengumpulan data preview (read-only, tanpa efek samping)
// ---------------------------------------------------------------------------

const FALLBACK_TABLES = [
    'customer', 'supplier', 'warehouse', 'item-project',
    'stock-inbound', 'stock-inbound-item'
];

/**
 * Mengumpulkan nama tabel dari folder schema bila ada (rekursif, termasuk
 * subfolder per schema seperti `schema/public/*.js`); bila tidak, memakai
 * daftar fallback yang dipadkan sampai 30 entri agar contoh kanonik (30 tabel)
 * tetap dapat dirender. Nama yang dikumpulkan adalah kebab tabel agar count
 * preview konsisten dengan file payload/endpoint yang akan dibuat.
 */
function collectTables(schemaDir) {
    let names = [];
    let realSchema = false;

    try {
        if (schemaDir && fs.existsSync(schemaDir) && fs.statSync(schemaDir).isDirectory()) {
            names = loadModels(schemaDir).map((m) => m.kebab).sort();
            if (names.length > 0) realSchema = true;
        }
    } catch (_err) {
        names = [];
    }

    if (!realSchema) {
        names = FALLBACK_TABLES.slice();
        let n = names.length + 1;
        while (names.length < 30) {
            names.push(`entity-${String(n).padStart(2, '0')}`);
            n += 1;
        }
    }

    return { names, realSchema };
}

// ---------------------------------------------------------------------------
// Preflight: cek dependency restforge-designer (sebelum input konfigurasi)
// ---------------------------------------------------------------------------

function checkDesigner() {
    console.log('');
    console.log('[Preflight]');

    // Probe nyata: jalankan `restforge-designer --version`.
    const r = spawnSync('cmd', ['/S', '/C', 'restforge-designer --version'], { encoding: 'utf8' });
    const found = !r.error && r.status === 0;
    let version = '';
    if (found && r.stdout) version = r.stdout.trim().split(/\r?\n/)[0];

    if (!found) {
        console.log(leader('  > restforge-designer', 'NOT FOUND', 32));
        console.log('');
        console.log(`  ${rule('=', 60)}`);
        console.log('  ERROR: restforge-designer is required but not installed.');
        console.log(`  ${rule('=', 60)}`);
        console.log('  fast-track generates the frontend app using restforge-designer.');
        console.log('  Download and install it from:');
        console.log('');
        console.log('    https://restforge.dev/download.html');
        console.log('');
        console.log('  Then re-run this command.');
        console.log('');
        throw stop('restforge-designer not installed');
    }
    console.log(leader('  > restforge-designer', `${version || 'installed'}  OK`, 32));
}

// ---------------------------------------------------------------------------
// Resolusi file config sumber nilai (mengikuti file config yang sudah ada)
// ---------------------------------------------------------------------------

/** Daftar nama file .env di folder config/ (non-rekursif). */
function listConfigEnvFiles(cwd) {
    const dir = path.join(cwd, 'config');
    if (!fs.existsSync(dir)) return [];
    return fs.readdirSync(dir, { withFileTypes: true })
        .filter((e) => e.isFile() && e.name.endsWith('.env'))
        .map((e) => e.name);
}

/**
 * Tentukan file config yang menjadi SUMBER nilai prompt fast-track, lalu baca
 * nilainya. Prioritas:
 *   1. --config eksplisit            -> hormati nama itu.
 *   2. Tepat 1 file .env di config/  -> pakai file itu.
 *   3. >1 file .env di config/       -> pakai default (config get-default /
 *      .restforge/defaults.json); bila default belum di-set -> berhenti.
 *   4. 0 file                        -> fallback DEFAULT_CONFIG_FILE tanpa
 *      pre-fill (init akan membuat file ini, perilaku lama).
 *
 * @returns {{ configName: string, fileCfg: Object }} fileCfg = nilai env
 *          terbaca ({} bila file belum ada).
 */
function resolveSourceConfig(cwd, explicitConfig) {
    let configName;

    if (explicitConfig) {
        configName = explicitConfig;
    } else {
        const files = listConfigEnvFiles(cwd);
        if (files.length === 1) {
            configName = files[0];
        } else if (files.length > 1) {
            const def = getDefaultConfig(cwd);
            if (!def) {
                console.log('');
                console.log(`  ERROR: ${files.length} config files found in config/ but no default is set.`);
                console.log('  Set which one fast-track should follow:');
                console.log('');
                console.log('    npx restforge config set-default --config=<file>');
                console.log('');
                console.log('  Or pass it explicitly: fast-track ... --config=<file>');
                console.log('');
                throw stop('multiple config files, no default set');
            }
            configName = def.config;
        } else {
            // 0 file: pertahankan perilaku lama (init akan membuat file ini).
            return { configName: DEFAULT_CONFIG_FILE, fileCfg: {} };
        }
    }

    const { data } = readEnvFile(resolveConfigFilePath(configName, cwd));
    return { configName, fileCfg: data || {} };
}

// ---------------------------------------------------------------------------
// Fase input konfigurasi (LICENSE + database), mengikuti fast-track.mjs
// ---------------------------------------------------------------------------

async function collectConfig(args, ask, fileCfg = {}) {
    console.log('');
    console.log(rule('='));
    console.log('  RESTForge Fast-Track  —  Configuration');
    console.log(rule('='));
    console.log('');
    console.log('  Enter configuration (press Enter to use the default value):');
    console.log('');

    const askField = async (label, def) => {
        const input = (await ask(`  ${label} (${def}): `)).trim();
        return input || def;
    };

    const cfg = {};

    // LICENSE: bila --license diberikan dipakai sebagai default; jika tidak,
    // ikuti file config bila ada, baru jatuh ke DEFAULTS.
    const licenseDefault = args.license || fileCfg.LICENSE || DEFAULTS.LICENSE;
    cfg.LICENSE = await askField('LICENSE', licenseDefault);

    // Alamat & port. Label user-facing REST_API_PORT, namun TETAP disimpan dan
    // ditulis ke db-connection.env sebagai SERVER_PORT (key yang dikonsumsi
    // `restforge serve`). WEB_SERVER_PORT adalah port frontend (`npx serve`),
    // dialirkan via `payload migrate --port` — tidak masuk runtime config, jadi
    // selalu dari DEFAULTS (tidak tersimpan di file env).
    cfg.SERVER_ADDRESS = await askField('SERVER_ADDRESS', fileCfg.SERVER_ADDRESS || DEFAULTS.SERVER_ADDRESS);
    cfg.SERVER_PORT = await askField('REST_API_PORT', fileCfg.SERVER_PORT || DEFAULTS.SERVER_PORT);
    cfg.WEB_SERVER_PORT = await askField('WEB_SERVER_PORT', DEFAULTS.WEB_SERVER_PORT);

    // DB_TYPE menentukan atribut yang diminta berikutnya.
    console.log('');
    console.log('  Available DB_TYPE: postgresql, mysql, oracle, sqlite');
    cfg.DB_TYPE = (await askField('DB_TYPE', fileCfg.DB_TYPE || DEFAULTS.DB_TYPE)).toLowerCase();

    if (cfg.DB_TYPE === 'sqlite') {
        console.log('');
        console.log('  SQLite mode: DB_HOST, DB_PORT, DB_USER, DB_PASSWORD are ignored.');
        console.log('  The database file path is set in DB_FILE.');
        console.log('');
        cfg.DB_FILE = await askField('DB_FILE (.db file path)', fileCfg.DB_FILE || DEFAULTS.DB_FILE);
        cfg.DB_NAME = cfg.DB_FILE;
    } else {
        const dbDef = DB_TYPE_DEFAULTS[cfg.DB_TYPE] || {};
        cfg.DB_HOST = await askField('DB_HOST', fileCfg.DB_HOST || DEFAULTS.DB_HOST);
        cfg.DB_PORT = await askField('DB_PORT', fileCfg.DB_PORT || dbDef.DB_PORT || DEFAULTS.DB_PORT);
        cfg.DB_USER = await askField('DB_USER', fileCfg.DB_USER || dbDef.DB_USER || DEFAULTS.DB_USER);
        cfg.DB_PASSWORD = await askField('DB_PASSWORD', fileCfg.DB_PASSWORD || DEFAULTS.DB_PASSWORD);
        cfg.DB_NAME = await askField('DB_NAME', fileCfg.DB_NAME || dbDef.DB_NAME || DEFAULTS.DB_NAME);
    }

    return cfg;
}

// ---------------------------------------------------------------------------
// Fase pemilihan scope generate (menu)
// ---------------------------------------------------------------------------

// Item menu scope (teks tampil sama persis dengan menu angka lama agar perubahan
// hanya pada CARA memilih, bukan label). `key` memetakan ke SCOPES.
const SCOPE_MENU = [
    { key: '1', text: 'Generate REST API Only' },
    { key: '2', text: 'Generate REST API with Frontend Application' }
];

/**
 * Selektor menu interaktif gaya CLI installer (create-next-app): tombol panah
 * ↑/↓ menggeser sorotan, Enter mengeksekusi pilihan. HANYA untuk TTY interaktif;
 * pemanggil wajib menyediakan fallback non-TTY (lihat selectScope).
 *
 * Handoff stdin: `createPrompter` memegang readline di process.stdin. Selama menu
 * aktif, readline di-pause DAN listener `keypress`-nya dilepas sementara (disimpan
 * lalu dipulihkan) agar tidak ada line-editing yang ikut bereaksi terhadap tombol.
 * Setelah pilihan dibuat, raw mode, listener, dan readline dikembalikan ke semula
 * sehingga prompt-prompt berikutnya tetap berfungsi normal.
 *
 * @param {Object} opts
 * @param {string} opts.title - Judul menu
 * @param {string[]} opts.items - Teks tiap opsi
 * @param {number} opts.initialIndex - Indeks sorotan awal
 * @param {Object} opts.prompter - Hasil createPrompter (untuk pause/resume rl)
 * @returns {Promise<number>} indeks opsi yang dipilih
 */
function arrowSelect({ title, items, initialIndex, prompter }) {
    return new Promise((resolve) => {
        const stdin = process.stdin;
        const rl = prompter && prompter.rl;
        let index = Math.max(0, Math.min(initialIndex || 0, items.length - 1));

        // Suspend prompter: pause readline + lepas listener keypress-nya agar hanya
        // handler menu yang aktif (mencegah echo/line-edit ganda).
        if (rl && typeof rl.pause === 'function') rl.pause();
        readline.emitKeypressEvents(stdin);
        const savedKeypress = stdin.listeners('keypress').slice();
        for (const l of savedKeypress) stdin.removeListener('keypress', l);

        const prevRaw = stdin.isRaw === true;
        if (typeof stdin.setRawMode === 'function') stdin.setRawMode(true);
        stdin.resume();

        console.log('');
        console.log(`  ${title}`);
        console.log('');

        const renderLines = () => {
            for (let i = 0; i < items.length; i++) {
                const active = i === index;
                const pointer = active ? '>' : ' ';
                const label = active ? `\x1b[36m${items[i]}\x1b[0m` : items[i];
                process.stdout.write(`\x1b[2K  ${pointer} ${label}\n`);
            }
        };
        const moveCursorUp = () => process.stdout.write(`\x1b[${items.length}A`);

        renderLines();

        const cleanup = () => {
            stdin.removeListener('keypress', onKeypress);
            if (typeof stdin.setRawMode === 'function') stdin.setRawMode(prevRaw);
            for (const l of savedKeypress) stdin.on('keypress', l);
            if (rl && typeof rl.resume === 'function') rl.resume();
        };

        const onKeypress = (_str, key) => {
            if (!key) return;
            if (key.name === 'up') {
                index = (index - 1 + items.length) % items.length;
                moveCursorUp();
                renderLines();
            } else if (key.name === 'down') {
                index = (index + 1) % items.length;
                moveCursorUp();
                renderLines();
            } else if (key.name === 'return' || key.name === 'enter') {
                cleanup();
                process.stdout.write('\n');
                resolve(index);
            } else if (key.ctrl && key.name === 'c') {
                cleanup();
                process.stdout.write('\n');
                process.exit(130); // konvensi SIGINT
            }
        };

        stdin.on('keypress', onKeypress);
    });
}

async function selectScope(prompter) {
    const ask = prompter.ask;

    // Fallback non-TTY (input di-pipe: smoke/CI/transcript): pertahankan PERSIS
    // prompt angka lama. Selektor panah butuh raw TTY mode yang tidak tersedia di
    // sini, jadi fallback menjaga alur non-interaktif tetap berfungsi byte-identik.
    if (!process.stdin.isTTY) {
        console.log('');
        console.log('  Select what to generate:');
        console.log('');
        for (const m of SCOPE_MENU) console.log(`    ${m.key}. ${m.text}`);
        console.log('');

        let scope = null;
        while (!scope) {
            const choice = (await ask(`  Choice (1-2) [${DEFAULT_SCOPE}]: `)).trim() || DEFAULT_SCOPE;
            scope = SCOPES[choice];
            if (!scope) console.log('  Invalid choice. Enter 1 or 2.');
        }
        return scope;
    }

    // TTY interaktif: selektor panah ↑/↓ + Enter.
    const initialIndex = SCOPE_MENU.findIndex((m) => m.key === DEFAULT_SCOPE);
    const chosen = await arrowSelect({
        title: 'Select what to generate:',
        items: SCOPE_MENU.map((m) => m.text),
        initialIndex: initialIndex >= 0 ? initialIndex : 0,
        prompter
    });
    return SCOPES[SCOPE_MENU[chosen].key];
}

// ---------------------------------------------------------------------------
// Fase pemilihan plugin designer + toggle authentication
// ---------------------------------------------------------------------------

// Plugin default bila pilihan tidak tersedia / parsing list gagal.
const DESIGNER_DEFAULT_PLUGIN = 'vanilla-js-basic';

// Default konfigurasi auth, selaras dengan `restforge-designer init`
// (mod.rs: auth_app_code/auth_api_url/idle_timeout). Dipakai saat user memilih
// mengaktifkan authentication pada plugin yang mendukung (Auth != No).
const DESIGNER_AUTH_DEFAULTS = {
    appCode: 'K5BK0H3ATT',
    authApiUrl: 'https://restforge.dev/api/auth',
    idleTimeoutMinutes: 30
};

const ANSI_RE = /\x1b\[[0-9;]*m/g;

/**
 * Jalankan `restforge-designer plugins list` dan kembalikan stdout mentah, atau
 * null bila gagal (binary tidak ada / exit non-zero). Tidak butuh aktivasi license
 * (list hanya membaca metadata plugin built-in).
 */
function runDesignerPluginsList() {
    const r = spawnSync('cmd', ['/S', '/C', 'restforge-designer plugins list'], { encoding: 'utf8' });
    if (r.error || r.status !== 0) return null;
    return r.stdout || '';
}

/**
 * Parse output tabel `plugins list` (comfy-table UTF8_FULL) menjadi array
 * `{ id, auth }`. Baris data ditandai border luar `│` + separator kolom `┆`;
 * baris pemisah (├ ╞ ┌ └) dan header (`ID`) dilewati. ANSI di-strip dulu. Kolom
 * Auth (indeks 3) menentukan apakah plugin mendukung authentication.
 *
 * Catatan: tidak ada output JSON di designer, jadi parsing tabel adalah satu-satunya
 * antarmuka. Parser defensif: bila 0 baris terbaca, pemanggil fallback ke default.
 */
function parseDesignerPlugins(stdout) {
    const out = [];
    const seen = new Set();
    for (const rawLine of String(stdout || '').split(/\r?\n/)) {
        const line = rawLine.replace(ANSI_RE, '');
        // Baris data wajib punya border luar `│`; separator inner bisa `┆` (UTF8_FULL)
        // atau `│`. Baris pemisah (├ ┌ └ ╞) tidak mengandung `│`, jadi otomatis lewat.
        if (!line.includes('│')) continue;
        const cells = line.split(/[│┆]/).map((c) => c.trim()).filter((c) => c.length > 0);
        // Minimal 4 kolom (ID, Name, Version, Auth). Baris wrap continuation berkolom
        // sedikit tersaring di sini.
        if (cells.length < 4) continue;
        const id = cells[0];
        if (id === 'ID' || seen.has(id)) continue;
        seen.add(id);
        out.push({ id, auth: cells[3] || '' });
    }
    return out;
}

/** Plugin mendukung authentication bila kolom Auth bukan kosong dan bukan "No". */
function pluginHasAuth(auth) {
    const v = String(auth || '').trim().toLowerCase();
    return v !== '' && v !== 'no';
}

/**
 * Pilih plugin designer (sebelum generate) lalu tentukan toggle auth. Menetapkan
 * `ctx.plugin` dan `ctx.authEnabled`. Menampilkan tabel `plugins list` asli sebagai
 * konteks, lalu selektor panah (TTY) atau prompt angka (non-TTY). Untuk plugin yang
 * mendukung auth (mis. vanilla-js-auth/custom), tanyakan apakah auth diaktifkan;
 * vanilla-js-basic (Auth No) langsung tanpa pertanyaan.
 *
 * Fallback aman: bila list gagal di-parse, pakai DESIGNER_DEFAULT_PLUGIN tanpa auth.
 */
async function selectDesignerPlugin(ctx, prompter) {
    const raw = runDesignerPluginsList();
    const plugins = raw ? parseDesignerPlugins(raw) : [];

    console.log('');
    if (raw && raw.trim()) console.log(raw.trimEnd());

    if (plugins.length === 0) {
        console.log(`  (could not read plugin list; using default '${DESIGNER_DEFAULT_PLUGIN}')`);
        ctx.plugin = DESIGNER_DEFAULT_PLUGIN;
        ctx.authEnabled = false;
        return;
    }

    const defaultIdx = Math.max(0, plugins.findIndex((p) => p.id === DESIGNER_DEFAULT_PLUGIN));
    const items = plugins.map((p) => `${p.id}  (auth: ${p.auth || 'No'})`);

    let chosenIdx;
    if (process.stdin.isTTY) {
        chosenIdx = await arrowSelect({
            title: 'Select a designer plugin:',
            items,
            initialIndex: defaultIdx,
            prompter
        });
    } else {
        console.log('');
        console.log('  Select a designer plugin:');
        console.log('');
        items.forEach((it, i) => console.log(`    ${i + 1}. ${it}`));
        console.log('');
        const def = String(defaultIdx + 1);
        chosenIdx = null;
        while (chosenIdx === null) {
            const c = (await prompter.ask(`  Choice (1-${plugins.length}) [${def}]: `)).trim() || def;
            const idx = parseInt(c, 10) - 1;
            if (idx >= 0 && idx < plugins.length) chosenIdx = idx;
            else console.log(`  Invalid choice. Enter 1-${plugins.length}.`);
        }
    }

    const chosen = plugins[chosenIdx];
    ctx.plugin = chosen.id;

    if (pluginHasAuth(chosen.auth)) {
        const ans = (await prompter.ask(`  Use authentication for '${chosen.id}'? (Y/n): `)).trim().toLowerCase();
        ctx.authEnabled = !(ans === 'n' || ans === 'no');
    } else {
        ctx.authEnabled = false;
    }
}

/**
 * Suntikkan blok auth ke payload agregator frontend agar plugin auth (deteksi via
 * `payload.auth.is_some()`) men-generate aplikasi dengan authentication. Bentuk blok
 * mengikuti `restforge-designer init` (init.rs): top-level `auth` + `appConfig.authAppCode`.
 * Mengembalikan true bila berhasil ditulis.
 */
function injectDesignerAuth(aggregatorPath) {
    if (!fs.existsSync(aggregatorPath)) return false;
    let payload;
    try {
        payload = JSON.parse(fs.readFileSync(aggregatorPath, 'utf8'));
    } catch (_err) {
        return false;
    }
    if (!payload.appConfig || typeof payload.appConfig !== 'object') payload.appConfig = {};
    payload.appConfig.authAppCode = DESIGNER_AUTH_DEFAULTS.appCode;
    payload.auth = {
        appCode: DESIGNER_AUTH_DEFAULTS.appCode,
        authApiUrl: DESIGNER_AUTH_DEFAULTS.authApiUrl,
        idleTimeoutMinutes: DESIGNER_AUTH_DEFAULTS.idleTimeoutMinutes
    };
    fs.writeFileSync(aggregatorPath, JSON.stringify(payload, null, 2));
    return true;
}

// ---------------------------------------------------------------------------
// Fase preview + konfirmasi
// ---------------------------------------------------------------------------

async function confirmDefaultMode(ctx, ask) {
    console.log('');
    console.log(rule('='));
    console.log('  RESTForge Fast-Track');
    console.log(rule('='));
    console.log('');
    console.log(`  Project    : ${ctx.project}`);
    console.log(`  Generate   : ${ctx.scope.label}`);
    console.log(`  Schema     : ${ctx.schemaFlag}   (${ctx.tables.length} SDF files)`);
    console.log(`  Config     : ${ctx.configFlag}   (will be written)`);
    console.log(`  License    : ${maskLicense(ctx.cfg.LICENSE)}`);
    console.log(`  REST API   : ${ctx.cfg.SERVER_ADDRESS}:${ctx.cfg.SERVER_PORT}   (SERVER_PORT)`);
    console.log(`  Web server : ${ctx.cfg.SERVER_ADDRESS}:${ctx.cfg.WEB_SERVER_PORT}`);
    console.log(`  Database   : ${describeDatabase(ctx.cfg)}`);
    if (ctx.scope.frontend) {
        console.log(`  Plugin     : ${ctx.plugin || DESIGNER_DEFAULT_PLUGIN}${ctx.authEnabled ? '   (auth enabled)' : ''}`);
    }
    console.log('  Mode       : sync   (use --overwrite to drop & regenerate)');
    console.log('');
    const answer = (await ask('  Continue? (Y/n): ')).trim().toLowerCase();
    console.log('');
    if (answer === 'n' || answer === 'no') {
        console.log('  Aborted. No changes were made.');
        throw stop('user aborted');
    }
}

async function confirmOverwriteMode(ctx, ask) {
    console.log('');
    console.log(rule('='));
    console.log('  RESTForge Fast-Track  —  --overwrite MODE (DESTRUCTIVE)');
    console.log(rule('='));
    console.log('');
    console.log(`  Project  : ${ctx.project}`);
    console.log(`  Generate : ${ctx.scope.label}`);
    console.log(`  License  : ${maskLicense(ctx.cfg.LICENSE)}`);
    console.log(`  Database : ${describeDatabase(ctx.cfg)}`);
    if (ctx.scope.frontend) {
        console.log(`  Plugin   : ${ctx.plugin || DESIGNER_DEFAULT_PLUGIN}${ctx.authEnabled ? '   (auth enabled)' : ''}`);
    }
    console.log('');
    console.log('  The following actions will run:');
    if (ctx.scope.backend) {
        console.log(`    • DROP TABLE : ${ctx.tables.length} tables            (DATA WILL BE LOST)`);
    }
    const overwriteTargets = [
        ctx.scope.backend && 'RDF, endpoint',
        ctx.scope.frontend && 'frontend'
    ].filter(Boolean).join(', ');
    console.log(`    • Overwrite  : affected ${overwriteTargets} files`);
    console.log('    • Archive    : old files backed up before overwrite');
    console.log('                   (e.g. payload/visitors.json.archive.001)');
    console.log('');
    console.log('  This action cannot be undone.');
    const answer = (await ask('  Continue? (y/N): ')).trim().toLowerCase();
    console.log('');
    if (answer !== 'y' && answer !== 'yes') {
        console.log('  Aborted. No changes were made.');
        throw stop('user aborted');
    }
}

// ---------------------------------------------------------------------------
// Eksekusi nyata (REST API) — orkestrasi command restforge existing
//
// Daftar command mengikuti referensi yang sudah terbukti di
// restforge-playbook/fast-track.mjs dan quick-start.mjs:
//   init -> isi env -> validate -> config set-default -> schema migrate (folder)
//   -> per tabel: payload generate + endpoint create -> (opsional) serve.
// ---------------------------------------------------------------------------

function phase(title) {
    console.log('');
    console.log(rule('='));
    console.log(`  ${title}`);
    console.log(rule('='));
}

/** Jalankan command CMD inline; hentikan pipeline bila gagal. */
function run(cmd, cwd, { allowNonZero = false } = {}) {
    console.log(`\n#${cmd}`);
    const r = spawnSync('cmd', ['/S', '/C', cmd], { cwd, stdio: 'inherit' });
    if (allowNonZero) {
        if (r.error) console.log(`\n  [WARN] ${cmd}\n  ${r.error.message}`);
        return r.status;
    }
    if (r.error) {
        console.log(`\n  [FAILED] ${cmd}\n  ${r.error.message}`);
        throw stop(`command failed to start: ${cmd}`);
    }
    if (r.status !== 0) {
        console.log(`\n  [FAILED] exit code ${r.status}: ${cmd}`);
        throw stop(`command exited with code ${r.status}`);
    }
    return r.status;
}

/** Perbarui VALUE sejumlah KEY pada file .env tanpa mengubah baris lain. */
function updateEnvFile(filePath, values) {
    const content = fs.readFileSync(filePath, 'utf8');
    const eol = content.includes('\r\n') ? '\r\n' : '\n';
    const lines = content.split(/\r?\n/);
    const remaining = new Set(Object.keys(values));
    for (let i = 0; i < lines.length; i++) {
        const m = lines[i].match(/^(\s*)([A-Za-z_][A-Za-z0-9_]*)\s*=/);
        if (m && remaining.has(m[2])) {
            lines[i] = `${m[1]}${m[2]}=${values[m[2]]}`;
            remaining.delete(m[2]);
        }
    }
    for (const k of remaining) lines.push(`${k}=${values[k]}`);
    fs.writeFileSync(filePath, lines.join(eol));
}

/** Susun nilai env yang akan ditulis dari konfigurasi input. */
function envValuesFromCfg(cfg) {
    const v = {
        LICENSE: cfg.LICENSE,
        SERVER_ADDRESS: cfg.SERVER_ADDRESS,
        SERVER_PORT: cfg.SERVER_PORT,
        DB_TYPE: cfg.DB_TYPE
    };
    if (cfg.DB_TYPE === 'sqlite') {
        v.DB_FILE = cfg.DB_FILE;
        v.DB_NAME = cfg.DB_NAME;
    } else {
        v.DB_HOST = cfg.DB_HOST;
        v.DB_PORT = cfg.DB_PORT;
        v.DB_USER = cfg.DB_USER;
        v.DB_PASSWORD = cfg.DB_PASSWORD;
        v.DB_NAME = cfg.DB_NAME;
    }
    return v;
}

/** Pastikan config/<file> ada (init bila belum) lalu isi nilai dari input. */
function ensureEnv(ctx) {
    const configPath = path.join(ctx.cwd, 'config', ctx.configFlag);
    if (!fs.existsSync(configPath)) {
        run('npx restforge init --force', ctx.cwd);
    }
    if (!fs.existsSync(configPath)) {
        throw stop(`config file not found after init: ${configPath}`);
    }
    updateEnvFile(configPath, envValuesFromCfg(ctx.cfg));
    console.log(`  ~ config/${ctx.configFlag} written from input.`);
}

const AUDIT_COLS = new Set(['created_at', 'created_by', 'updated_at', 'updated_by']);

/**
 * Turunkan kebab dari NAMA TABEL (bukan nama file) dengan normalisasi
 * `[._]` -> `-`, identik dengan penamaan file output `payload generate`
 * (payload-runner: baseFilename = table.replace(/[._]/g, '-')). Dengan ini
 * `endpoint create --payload=<kebab>.json` dan `payload migrate --name=<kebab>.json`
 * menemukan file payload yang benar, baik untuk SDF bernama underscore (hasil
 * `schema introspect`, mis. `visitor_categories.js`) maupun kebab.
 */
function tableToKebab(table) {
    return table.replace(/[._]/g, '-');
}

/**
 * Muat seluruh SDF di folder memakai loader dbschema-kit yang SAMA dengan
 * `schema migrate`. Loader bersifat REKURSIF sehingga struktur per-schema
 * (mis. `schema/public/visitors.js`) ikut terbaca; flat readdir lama hanya
 * membaca top-level sehingga subfolder schema tidak dikenali.
 *
 * Mengembalikan descriptor per tabel (deterministik dari SDF, tanpa DB):
 *   { kebab, table, fks[{childCol,parentTable,parentCol}], displayCols }
 *
 * FK dibaca dari `relations` (belongsTo) pada IR model. IR menormalkan KEDUA
 * gaya format: blok `relations:` eksplisit (gaya `schema introspect`) DAN inline
 * `fk:<parent>.<col>` pada field (di-auto-promote ke relations oleh ir-builder).
 * displayCols = kolom code/name di luar PK & kolom audit, dipakai sebagai kolom
 * display saat tabel ini menjadi parent FK (untuk `payload sync --expand-fk`).
 */
function loadModels(schemaDir) {
    const models = loadSchemaPath(schemaDir);
    const entries = [];
    for (const model of models.values()) {
        const table = model.tableName;
        const primaryKey = Array.isArray(model.primaryKey) ? model.primaryKey : [];

        const fks = [];
        for (const rel of Object.values(model.relations || {})) {
            if (rel.type !== 'belongsTo' || !rel.localKey || !rel.target) continue;
            fks.push({
                childCol: rel.localKey,
                parentTable: rel.target,
                parentCol: rel.references || null
            });
        }

        const displayCols = Object.keys(model.fields || {}).filter((name) => {
            const field = model.fields[name];
            const isPk = (field && field.pk === true) || primaryKey.includes(name);
            return !isPk && !AUDIT_COLS.has(name) && /(code|name)/i.test(name);
        });

        entries.push({ kebab: tableToKebab(table), table, fks, displayCols });
    }
    entries.sort((a, b) => a.table.localeCompare(b.table));
    return entries;
}

/** Bangun daftar model dari folder schema (rekursif, via loader bersama). */
function buildTableEntries(schemaDir) {
    return loadModels(schemaDir);
}

/**
 * Turunkan daftar `--fk-columns` untuk `payload sync --expand-fk` dari SDF:
 * `<parentTable>.<displayCol>` untuk tiap kolom display (code/name) tiap parent.
 * Kosong bila tidak ada kolom display terdeteksi (jatuh ke mode AUTO command).
 */
function fkColumnsForEntry(entry, byTable) {
    const cols = [];
    for (const fk of entry.fks) {
        const parent = byTable.get(fk.parentTable);
        const disp = parent ? parent.displayCols : [];
        for (const dc of disp) cols.push(`${fk.parentTable}.${dc}`);
    }
    return cols;
}

/** Jeda async sederhana. */
function sleep(ms) {
    return new Promise((resolve) => setTimeout(resolve, ms));
}

/** Satu kali GET <url>; resolve true bila HTTP 200 dan (jika JSON) status:'ok'. */
function pingOnce(url, perReqTimeoutMs) {
    return new Promise((resolve) => {
        const req = http.get(url, (res) => {
            let body = '';
            res.on('data', (c) => { body += c; });
            res.on('end', () => {
                let ok = res.statusCode === 200;
                if (ok && body) {
                    // Bila body JSON, pastikan status:'ok'. Bila bukan JSON, cukup andalkan 200.
                    try { ok = JSON.parse(body).status === 'ok'; } catch { /* keep 200 */ }
                }
                resolve(ok);
            });
        });
        req.setTimeout(perReqTimeoutMs, () => { req.destroy(); resolve(false); });
        req.on('error', () => resolve(false));
    });
}

/** Poll <url> sampai sehat atau timeout. Dipakai setelah serve di-spawn. */
async function waitForHealth(url, { timeoutMs = 30000, intervalMs = 600 } = {}) {
    const start = Date.now();
    while (Date.now() - start < timeoutMs) {
        if (await pingOnce(url, 2000)) return { ok: true, elapsedMs: Date.now() - start };
        await sleep(intervalMs);
    }
    return { ok: false, elapsedMs: Date.now() - start };
}

/** Host untuk health URL: 0.0.0.0/kosong -> localhost (mirror banner runtime). */
function healthHost(serverAddress) {
    return (!serverAddress || serverAddress === '0.0.0.0') ? 'localhost' : serverAddress;
}

/** Bila port dipakai, hentikan proses lama (Windows) sebelum serve. */
function freePort(port) {
    const res = spawnSync('cmd', ['/S', '/C', `netstat -ano | findstr :${port}`], { encoding: 'utf8' });
    const pids = new Set();
    for (const line of (res.stdout || '').split(/\r?\n/)) {
        const p = line.trim().split(/\s+/);
        if (p.length >= 5 && /LISTENING/i.test(p[3]) && p[1].endsWith(`:${port}`)) {
            if (/^\d+$/.test(p[4]) && p[4] !== '0') pids.add(p[4]);
        }
    }
    if (pids.size === 0) return;
    console.log(`  Port ${port} in use (PID ${[...pids].join(', ')}); stopping old process...`);
    for (const pid of pids) spawnSync('cmd', ['/S', '/C', `taskkill /PID ${pid} /F`], { stdio: 'inherit' });
}

/** Pipeline REST API nyata: env -> validate -> migrate -> payload+endpoint. */
function runBackendPipeline(ctx) {
    const dbFlag = DB_FLAG[ctx.cfg.DB_TYPE] || ctx.cfg.DB_TYPE;
    const cfgArg = `--config=${ctx.configFlag}`;

    phase('[1/4] Config, license & database validation');
    ensureEnv(ctx);
    run(`npx restforge validate ${cfgArg} --auto-create-db`, ctx.cwd);
    run(`npx restforge config set-default ${cfgArg}`, ctx.cwd);

    phase('[2/4] Schema migrate (SDF -> database)');
    // Mode --overwrite: drop tabel lalu re-create (destruktif). Tanpa --overwrite:
    // CREATE TABLE IF NOT EXISTS (tabel existing dilewati).
    const dropFlag = ctx.overwrite ? ' --drop=true' : '';
    run(`npx restforge schema migrate --path=${ctx.schemaFlag} ${cfgArg} --auto-create-db${dropFlag}`, ctx.cwd);

    phase('[3/4] Payload RDF (generate)');
    for (const t of ctx.tableEntries) {
        run(`npx restforge payload generate --table=${t.table} ${cfgArg}`, ctx.cwd);
    }

    phase('[3b/4] FK expansion (payload sync --expand-fk)');
    const fkTables = ctx.tableEntries.filter((t) => t.fks.length > 0);
    if (fkTables.length === 0) {
        console.log('  No FK to expand (RDF stays flat).');
    } else {
        for (const t of fkTables) {
            const cols = fkColumnsForEntry(t, ctx.byTable);
            const colFlag = cols.length ? ` --fk-columns=${cols.join(',')}` : '';
            run(`npx restforge payload sync --table=${t.table} --expand-fk${colFlag}`, ctx.cwd);
        }
        // Catatan: fieldNameLookup pada parent TIDAK disuntik. Frontend default
        // memakai kolom `name` untuk display dropdown; fieldNameLookup hanya untuk
        // custom display dan diserahkan ke user (atau command khusus).
    }

    phase('[4/4] REST endpoints');
    for (const t of ctx.tableEntries) {
        run(`npx restforge endpoint create --project=${ctx.project} --name=${t.kebab} --payload=${t.kebab}.json ${cfgArg} --database=${dbFlag} --force`, ctx.cwd);
    }
}

/**
 * Pipeline frontend nyata: migrate RDF->UDF per tabel (agregator di-akumulasi),
 * lalu designer activate + generate. Urutan mengikuti fast-track.mjs/quick-start.
 * appCode default = project, sehingga agregator = frontend/payload/<project>.json.
 */
function runFrontendPipeline(ctx) {
    const cfgArg = `--config=${ctx.configFlag}`;
    const frontendDir = path.join(ctx.cwd, 'frontend');
    const appCode = ctx.project;
    // Plugin terpilih (default basic bila selectDesignerPlugin tidak dijalankan).
    const plugin = ctx.plugin || DESIGNER_DEFAULT_PLUGIN;
    const pluginArg = `--plugin=${plugin}`;

    phase('[F1/3] Migrate RDF -> UDF (per tabel, agregator di-akumulasi)');
    fs.mkdirSync(path.join(frontendDir, 'payload'), { recursive: true });
    for (const t of ctx.tableEntries) {
        // Lewati tabel yang murni FK-parent (tanpa FK sendiri): page-nya dibuat
        // otomatis via JOIN auto-discovery dari child (lihat scenario-12).
        const isPureParent = t.fks.length === 0 && ctx.parentTables.has(t.table);
        if (isPureParent) {
            console.log(`  (skip ${t.kebab}: auto-discovered via JOIN from child)`);
            continue;
        }
        run(`npx restforge payload migrate --project=${ctx.project} --name=${t.kebab}.json --output=frontend/payload ${cfgArg} --port=${ctx.cfg.WEB_SERVER_PORT} ${pluginArg} --overwrite`, ctx.cwd);
    }

    // Auth: suntik blok auth ke agregator sebelum generate bila user mengaktifkannya.
    // Plugin auth mendeteksi mode lewat keberadaan blok `auth` di payload (init.rs).
    if (ctx.authEnabled) {
        const aggregatorPath = path.join(frontendDir, 'payload', `${appCode}.json`);
        const ok = injectDesignerAuth(aggregatorPath);
        console.log(ok
            ? `  ~ authentication enabled in ${appCode}.json (authAppCode=${DESIGNER_AUTH_DEFAULTS.appCode})`
            : `  [WARN] could not inject auth block into ${aggregatorPath}; app will generate without auth`);
    }

    phase('[F2/3] Activate restforge-designer');
    run(`restforge-designer activate --key=${ctx.cfg.LICENSE}`, frontendDir, { allowNonZero: true });

    phase('[F3/3] Generate frontend application');
    // Hapus index.html lama agar landing page diregenerasi sesuai set page terbaru.
    run(`if exist apps\\${ctx.project}\\index.html del /Q apps\\${ctx.project}\\index.html`, frontendDir, { allowNonZero: true });
    run(`restforge-designer generate --payload=payload/${appCode}.json --output=./apps/${ctx.project} ${pluginArg} --overwrite`, frontendDir);
}

/**
 * Tulis launcher start REST API mandiri di folder kerja (lokasi pemanggilan
 * fast-track). Windows -> server-start.bat, Linux/macOS -> server-start.sh.
 * Perintah serve sama dengan maybeRunServer (+ --watch), selaras dengan
 * referensi backend-v4/server-start.bat. NODE_ENV sengaja tidak di-set: shell
 * biasa membiarkan NODE_ENV kosong sehingga logger memakai pino-pretty (rapi).
 */
function writeServerStartScript(ctx) {
    const serveCmd = `npx restforge serve --project=${ctx.project} --config=${ctx.configFlag} --watch`;
    const isWin = process.platform === 'win32';
    const file = path.join(ctx.cwd, isWin ? 'server-start.bat' : 'server-start.sh');
    let content;
    if (isWin) {
        content = [
            '@echo off',
            'REM Start RESTForge REST API (runtime server). Generated by fast-track.',
            'cd /d "%~dp0"',
            `call ${serveCmd}`,
            ''
        ].join('\r\n');
    } else {
        content = [
            '#!/usr/bin/env bash',
            '# Start RESTForge REST API (runtime server). Generated by fast-track.',
            'set -e',
            'cd "$(dirname "$0")"',
            serveCmd,
            ''
        ].join('\n');
    }
    fs.writeFileSync(file, content);
    if (!isWin) {
        try { fs.chmodSync(file, 0o755); } catch { /* abaikan bila FS tak dukung chmod */ }
    }
    return file;
}

function printFinalSummary(ctx) {
    const parts = [];
    if (ctx.scope.backend) parts.push('REST API generated');
    if (ctx.scope.frontend) parts.push('frontend app generated');
    console.log('');
    console.log(rule('='));
    console.log(`  DONE  —  ${parts.join(' + ')}   [scope: ${ctx.scope.label}]`);
    console.log(rule('='));
    if (ctx.scope.backend) {
        console.log(`  Backend  : payload/ , examples/${ctx.project}/  (${ctx.tableEntries.length} endpoint)`);
        if (ctx.serverStartFile) {
            console.log(`  Start    : ${path.basename(ctx.serverStartFile)}  (start REST API manually)`);
        }
    }
    if (ctx.scope.frontend) {
        console.log(`  Frontend : frontend/apps/${ctx.project}/  (start: npx serve . -l ${ctx.cfg.WEB_SERVER_PORT})`);
    }
    console.log(rule('='));
}

/** Konfirmasi lalu jalankan runtime server di window CMD baru. */
async function maybeRunServer(ctx, ask) {
    // Samakan dengan pola server-start.bat: serve + --watch (auto-restart pada
    // perubahan src/). Format log rapi (pino-pretty) berasal dari NODE_ENV
    // development yang di-set saat spawn di bawah.
    const serveCmd = `npx restforge serve --project=${ctx.project} --config=${ctx.configFlag} --watch`;
    console.log('');
    const answer = (await ask('  Run Runtime Server now in a new window? (Y/n): ')).trim().toLowerCase();
    if (answer === 'n' || answer === 'no') {
        console.log(`  Skipped. Start later:  ${serveCmd}`);
        return;
    }
    freePort(ctx.cfg.SERVER_PORT);
    const title = `RESTForge Server - ${ctx.project}`;
    console.log(`\n  Opening new window: "${title}"`);
    console.log(`#${serveCmd}`);
    // server.js (dispatcher) memaksa NODE_ENV=production untuk proses cli command,
    // sehingga fast-track berjalan production dan secara default akan mewariskannya
    // ke serve -> logger memakai format JSON mentah (verbose). Set development agar
    // runtime server memakai pino-pretty (rapi), konsisten dengan playbook yang
    // menjalankan serve dari plain `node`.
    const serveEnv = { ...process.env, NODE_ENV: 'development' };
    const r = spawnSync('cmd', ['/C', 'start', title, 'cmd', '/k', serveCmd], { cwd: ctx.cwd, stdio: 'inherit', env: serveEnv });
    if (r.error) {
        console.log(`  Failed to open server window: ${r.error.message}`);
        return;
    }
    console.log('  ✓ Server window opened. Keep it open. Stop with Ctrl+C.');

    // Health check: tunggu runtime benar-benar siap menerima request sebelum
    // lanjut (mis. ke frontend). URL = banner runtime: /api/<project>/health.
    const host = healthHost(ctx.cfg.SERVER_ADDRESS);
    const healthUrl = `http://${host}:${ctx.cfg.SERVER_PORT}/api/${ctx.project}/health`;
    console.log(`\n  Waiting for server health: ${healthUrl}`);
    const h = await waitForHealth(healthUrl, { timeoutMs: 30000, intervalMs: 600 });
    if (h.ok) {
        console.log(`  ✓ Server healthy (${(h.elapsedMs / 1000).toFixed(1)}s).`);
    } else {
        console.log(`  ⚠ Health check timed out after ${(h.elapsedMs / 1000).toFixed(0)}s.`);
        console.log('    Server may still be starting; check the server window for errors.');
    }
}

/** Konfirmasi lalu jalankan aplikasi frontend (app-start.bat) di window CMD baru. */
async function maybeRunFrontend(ctx, ask) {
    const appDir = path.join(ctx.cwd, 'frontend', 'apps', ctx.project);
    const webPort = ctx.cfg.WEB_SERVER_PORT;
    // Jalankan langsung `npx serve . -l <port>` (identik untuk Windows & Linux),
    // tidak bergantung pada app-start.bat/.sh. File launcher itu urusan generator.
    const serveCmd = `npx serve . -l ${webPort}`;
    console.log('');
    const answer = (await ask('  Run Frontend Application now in a new window? (Y/n): ')).trim().toLowerCase();
    if (answer === 'n' || answer === 'no') {
        console.log(`  Skipped. Start later (in ${appDir}):  ${serveCmd}`);
        return;
    }
    const indexHtml = path.join(appDir, 'index.html');
    if (!fs.existsSync(indexHtml)) {
        console.log(`  Frontend app not found: ${indexHtml}`);
        console.log('  Frontend generation may have failed; cannot launch.');
        return;
    }
    freePort(webPort);
    const title = `RESTForge Frontend - ${ctx.project}`;
    console.log(`\n  Opening new window: "${title}"`);
    console.log(`#${serveCmd}`);
    const r = spawnSync('cmd', ['/C', 'start', title, 'cmd', '/k', serveCmd], { cwd: appDir, stdio: 'inherit' });
    if (r.error) {
        console.log(`  Failed to open frontend window: ${r.error.message}`);
    } else {
        console.log(`  ✓ Frontend window opened (WEB_SERVER_PORT ${webPort}).`);
        console.log(`    Open: http://localhost:${webPort}/index.html`);
    }
}

// ---------------------------------------------------------------------------
// Contract
// ---------------------------------------------------------------------------

module.exports = {
    verb: 'fast-track',
    description: 'Generate REST API + frontend app dari SDF (eksekusi nyata) + opsi runtime server',
    category: 'generation',
    flags: {
        project: {
            type: 'string',
            required: true,
            description: 'Nama project/aplikasi (mis. visitors-app)'
        },
        'schema-path': {
            type: 'string',
            required: true,
            description: 'Folder berisi SDF aplikasi (relatif cwd)'
        },
        config: {
            type: 'string',
            required: false,
            default: null,
            description: 'Nama file env target di folder config/. Bila kosong, fast-track mengikuti file config yang ada: 1 file dipakai langsung, >1 file pakai config get-default'
        },
        license: {
            type: 'string',
            required: false,
            default: null,
            description: 'License key (XXXX-XXXX-XXXX-XXXX). Bila diisi dipakai sebagai default prompt LICENSE'
        },
        overwrite: {
            type: 'boolean',
            required: false,
            default: false,
            description: 'Mode destruktif: drop table & regenerate (perlu konfirmasi y/N)'
        }
    },
    examples: [
        'npx restforge fast-track --project=visitors-app --schema-path=./schema',
        'npx restforge fast-track --project=visitors-app --schema-path=./schema --config=db-connection.env',
        'npx restforge fast-track --project=visitors-app --schema-path=./schema --overwrite'
    ],
    async handler(args) {
        const cwd = process.cwd();
        const schemaDir = path.resolve(cwd, args['schema-path']);
        const { names: tables } = collectTables(schemaDir);

        const prompter = createPrompter();
        try {
            // 0) Resolusi file config sumber: tentukan file mana yang diikuti dan
            //    baca nilainya untuk pre-fill default prompt.
            const { configName, fileCfg } = resolveSourceConfig(cwd, args.config);

            // 1) Input konfigurasi (LICENSE + database), gaya fast-track.mjs.
            //    Default tiap field mengikuti fileCfg bila tersedia.
            const cfg = await collectConfig(args, prompter.ask, fileCfg);

            // 2) Menu pemilihan scope generate (REST API / frontend / all).
            const scope = await selectScope(prompter);

            // 3) Preflight designer hanya bila scope mencakup frontend.
            if (scope.frontend) {
                checkDesigner();
            }

            const ctx = {
                cwd,
                project: args.project,
                schemaFlag: args['schema-path'],
                configFlag: configName,
                tables,
                cfg,
                scope,
                overwrite: args.overwrite === true
            };

            // Backend dan frontend sama-sama butuh daftar tabel dari folder SDF
            // (frontend memetakan tiap tabel ke page UDF via payload-nya).
            if (scope.backend || scope.frontend) {
                if (!fs.existsSync(schemaDir) || !fs.statSync(schemaDir).isDirectory()) {
                    console.log(`\n  ERROR: schema folder not found: ${schemaDir}`);
                    throw stop('schema folder not found');
                }
                ctx.tableEntries = buildTableEntries(schemaDir);
                if (ctx.tableEntries.length === 0) {
                    console.log(`\n  ERROR: no SDF (.js) files in: ${schemaDir}`);
                    throw stop('no SDF files');
                }
                // Relasi FK lintas-tabel: peta tabel + himpunan tabel parent.
                ctx.byTable = new Map(ctx.tableEntries.map((e) => [e.table, e]));
                ctx.parentTables = new Set();
                for (const e of ctx.tableEntries) {
                    for (const fk of e.fks) ctx.parentTables.add(fk.parentTable);
                }
            }

            // 4) Pemilihan plugin designer + toggle auth (hanya bila frontend).
            //    Sebelum preview agar pilihan ikut tampil di ringkasan & dipakai
            //    pipeline frontend (payload migrate --plugin + generate --plugin).
            if (ctx.scope.frontend) {
                await selectDesignerPlugin(ctx, prompter);
            }

            // 5) Preview + konfirmasi (sesuai mode).
            if (ctx.overwrite) {
                await confirmOverwriteMode(ctx, prompter.ask);
            } else {
                await confirmDefaultMode(ctx, prompter.ask);
            }

            // 6) Eksekusi sesuai scope.
            if (ctx.scope.backend) {
                runBackendPipeline(ctx);
                // Launcher start REST API mandiri (sesuai OS), di folder kerja.
                ctx.serverStartFile = writeServerStartScript(ctx);
            }
            if (ctx.scope.frontend) {
                runFrontendPipeline(ctx);
            }

            printFinalSummary(ctx);

            // 7) Tawarkan menjalankan service: runtime server (backend) lalu
            //    aplikasi frontend, masing-masing di window CMD baru.
            if (ctx.scope.backend) {
                await maybeRunServer(ctx, prompter.ask);
            }
            if (ctx.scope.frontend) {
                await maybeRunFrontend(ctx, prompter.ask);
            }
        } finally {
            prompter.close();
        }
    }
};

// Test seam (hanya aktif via env): mengekspos helper internal agar dapat diuji
// terpisah tanpa menjalankan pipeline penuh. Pada penggunaan CLI normal env ini
// tidak di-set sehingga export tetap berupa contract murni.
if (process.env.FASTTRACK_TEST === '1') {
    module.exports.__test = {
        loadModels, buildTableEntries, fkColumnsForEntry, tableToKebab,
        parseDesignerPlugins, pluginHasAuth, injectDesignerAuth,
        DESIGNER_DEFAULT_PLUGIN, DESIGNER_AUTH_DEFAULTS
    };
}
