'use strict';

/**
 * pull-runner — Business logic command `data pull`.
 *
 * `run()` adalah dispatcher (single vs multi-schema); `runOne(ir, ctx)` mengeksekusi
 * pull satu tabel dan mengelola executor-nya sendiri (Phase 02, data-pull-push-v2).
 *
 * Alur dispatcher `run()`:
 *   1. Resolve config DB (config-resolver) → loadConfig (dbschema-kit/connection); SEKALI.
 *   2. parseSchemaFlag + validateScopeFlags (sebelum load SDF / koneksi DB): tepat satu
 *      dari --table / --schema / --all-schemas (selain itu → exit 2).
 *   3. Validasi --format (hanya 'json').
 *   4. Load SDF SEKALI → Map<qualifiedName, IR> (sdf-reader).
 *   5. resolveScope(models, args, schemaList) → daftar IR sesuai flag scope.
 *   5a. Jalur --table:               satu IR → runOne(ir, ctx) → ringkasan single.
 *   5b. Jalur --schema/--all-schemas: iterasi subset FAIL-FAST (Q3) → runOne per IR →
 *       ringkasan agregat. Tabel yang sudah selesai sebelum error tetap tertulis.
 *
 * Alur `runOne(ir, ctx)` (per tabel; metadata kolom/namespace/PK murni dari SDF IR):
 *   a. Cek file output; tolak bila sudah ada tanpa --force (sebelum konek/tulis).
 *   b. Validasi identifier tabel/kolom (anti-injection) + no-PK guard.
 *   c. Connect (db-executor) + SELECT bertahap (keyset PK tunggal, OFFSET fallback composite).
 *   d. Tulis envelope streaming (EnvelopeWriter) + encode nilai per kolom (value-codec).
 *   e. Disconnect (selalu) + cleanup file partial saat error.
 *
 * Exit code: 0 sukses; 1 operational (file exists, baca SDF gagal); 2 usage/precondition
 * (--config wajib, mutual-exclusive, tabel tak terdaftar, --format invalid); 3 database
 * error. Handler tidak memanggil process.exit() sendiri.
 *
 * @module generators/lib/data/pull-runner
 */

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

const { resolveConfig, printDefaultConfigWarning } = require('../utils/config-resolver');
const { loadConfig } = require('../dbschema-kit/connection');
const {
    loadSdf,
    irToColumns,
    getNamespace,
    getPrimaryKey
} = require('./sdf-reader');
const { parseSchemaFlag, validateScopeFlags, resolveScope, relDataFilePath } = require('./data-scope');
const { createDialect, validateIdentifier } = require('./dialect-kit');
const { createExecutor } = require('./db-executor');
const { encodeValue } = require('./value-codec');
const { EnvelopeWriter } = require('./envelope');
const { QueryBuilder } = require('../../../src/core/db/query-builder');

const DEFAULT_BATCH_SIZE = 1000;

/**
 * Buat Error dengan exitCode terlampir (override default dispatcher).
 * @param {string} message
 * @param {number} exitCode
 * @returns {Error}
 */
function failWith(message, exitCode) {
    const err = new Error(message);
    err.exitCode = exitCode;
    return err;
}

/**
 * Petakan `config.dialect` internal ke bentuk DB_TYPE kanonik untuk envelope.
 *
 * `loadConfig` memetakan `DB_TYPE=postgresql` menjadi `dialect='postgres'`; envelope
 * (header `source_dialect`) dan round-trip `data push` (Phase 03) memakai bentuk
 * kanonik `postgresql`. Dialect `mysql`/`oracle`/`sqlite` identik di kedua sisi.
 *
 * @param {string} dialect - config.dialect ('postgres'|'mysql'|'oracle'|'sqlite')
 * @returns {string} 'postgresql'|'mysql'|'oracle'|'sqlite'
 */
function canonicalSourceDialect(dialect) {
    return dialect === 'postgres' ? 'postgresql' : dialect;
}

/**
 * Normalisasi nilai integer non-negatif dari flag.
 * @param {*} value
 * @param {number|null} fallback
 * @returns {number|null}
 */
function normalizeInt(value, fallback) {
    if (typeof value === 'number' && Number.isFinite(value) && value >= 0) {
        return Math.trunc(value);
    }
    return fallback;
}

/**
 * Encode satu row mentah menjadi representasi envelope, hanya kolom whitelist SDF
 * dalam urutan deklarasi field.
 * @param {Object} row
 * @param {Array<{name: string, type: string}>} columns
 * @returns {Object}
 */
function encodeRow(row, columns) {
    const out = {};
    for (const col of columns) {
        out[col.name] = encodeValue(row[col.name], col.type);
    }
    return out;
}

/**
 * Baca rows tabel secara bertahap dan kirim tiap batch (sudah ter-encode) ke onBatch.
 *
 * - Keyset bila PK tunggal: ORDER BY pk, tiap batch WHERE pk > last LIMIT n.
 * - OFFSET fallback bila PK composite: ORDER BY pk LIMIT n OFFSET m.
 * - `limit` membatasi total rows; berhenti saat tercapai.
 *
 * @param {Object} opts
 * @param {Object} opts.executor - db-executor (dbUtils untuk QueryBuilder)
 * @param {Object} opts.dialect - instance dialect
 * @param {string} opts.queryTable - nama tabel target (qualified bila ber-schema)
 * @param {Array<{name: string, type: string}>} opts.columns - kolom whitelist SDF
 * @param {string[]} opts.pk - daftar kolom primary key
 * @param {number} opts.batchSize - ukuran batch
 * @param {number|null} opts.limit - batas total rows
 * @param {Function} opts.onBatch - callback(encodedRows) per batch
 * @returns {Promise<number>} total rows yang di-pull
 */
async function pullInBatches({ executor, dialect, queryTable, columns, pk, batchSize, limit, onBatch }) {
    const columnNames = columns.map((c) => c.name);
    const useKeyset = pk.length === 1;
    // `run()` menjamin PK non-kosong (no-PK = hard error sebelum mencapai sini),
    // sehingga ORDER BY selalu memakai kolom PK; tak ada fallback seluruh-kolom.
    const orderCols = pk;

    let total = 0;
    let lastKey = null;
    let offset = 0;

    for (;;) {
        if (limit !== null && total >= limit) break;

        let perBatch = batchSize;
        if (limit !== null) {
            perBatch = Math.min(batchSize, limit - total);
        }
        if (perBatch <= 0) break;

        const qb = new QueryBuilder(executor, dialect, queryTable);
        qb.select(...columnNames);

        if (useKeyset) {
            if (lastKey !== null && lastKey !== undefined) {
                qb.where(pk[0], '>', lastKey);
            }
            qb.orderBy(pk[0], 'asc');
            qb.limit(perBatch);
        } else {
            for (const col of orderCols) qb.orderBy(col, 'asc');
            qb.limit(perBatch);
            qb.offset(offset);
        }

        const rows = await qb.get();
        if (!rows || rows.length === 0) break;

        if (useKeyset) {
            lastKey = rows[rows.length - 1][pk[0]];
        } else {
            offset += rows.length;
        }

        onBatch(rows.map((row) => encodeRow(row, columns)));
        total += rows.length;

        if (rows.length < perBatch) break;
    }

    return total;
}

/**
 * Pull satu tabel SDF ke file envelope JSON (path via relDataFilePath: bersarang
 * `<output>/<schema>/<table>.json` bila ber-schema, flat `<output>/<table>.json`
 * bila tanpa schema).
 *
 * Mengelola executor-nya sendiri (buat → disconnect, termasuk cleanup file partial
 * saat error) agar isolasi per-tabel jelas dan jalur multi-schema dapat memanggilnya
 * berulang tanpa kebocoran koneksi. Metadata kolom, namespace, dan PK murni dari IR.
 *
 * @param {Object} ir - IR model SDF satu tabel
 * @param {Object} ctx - konteks resolusi sekali-pakai
 * @param {Object} ctx.config - config DB hasil loadConfig
 * @param {string} ctx.sourceDialect - dialect kanonik untuk header envelope
 * @param {Object} ctx.args - argumen ter-parse (storage-path, force, limit, batch-size)
 * @param {string} ctx.cwd - working directory
 * @returns {Promise<{schema: (string|null), table: string, rows: number, file: string, source_dialect: string}>}
 */
async function runOne(ir, ctx) {
    const { config, sourceDialect, args, cwd } = ctx;

    const columns = irToColumns(ir);
    const { schemaName, qualifiedName } = getNamespace(ir);
    const rawPk = getPrimaryKey(ir);
    const pk = Array.isArray(rawPk) ? rawPk : [];

    // ── a. Cek file output (SEBELUM menulis/konek) ───────────────────────────────
    // Path diturunkan dari relDataFilePath (aturan seragam Q2): tabel ber-schema →
    // bersarang `<schema>/<table>.json` (mis. `sales/order_item.json`); tabel tanpa
    // schema → flat `<table>.json` (mis. `visitors.json`). Helper bersama dengan push
    // (data-scope) agar derivasi tidak drift. queryTable SELECT tetap memakai
    // qualifiedName (identitas DB).
    const outputDir = path.resolve(cwd, args['storage-path']);
    const outFile = path.join(outputDir, relDataFilePath(ir));
    if (fs.existsSync(outFile) && args.force !== true) {
        const rel = path.relative(cwd, outFile).split(path.sep).join('/');
        throw failWith(
            `Output file already exists: ${rel}. Use --force to overwrite.`,
            1
        );
    }

    // ── b. Validasi identifier (anti-injection) + no-PK guard ────────────────────
    const dialect = createDialect(config.dialect);
    validateIdentifier(dialect, qualifiedName);
    for (const col of columns) validateIdentifier(dialect, col.name);

    // No-PK = anomali: schema-validator MEWAJIBKAN primary key, jadi IR yang lolos load
    // SDF pasti punya PK. Bila tetap kosong (mis. IR diinjeksi), gagal jelas lebih aman
    // daripada melanjutkan dengan urutan baris tak tentu.
    if (pk.length === 0) {
        throw failWith(`Table '${ir.tableName}' has no primary key in SDF (unexpected)`, 1);
    }

    const executor = createExecutor(config);

    const batchSize = normalizeInt(args['batch-size'], DEFAULT_BATCH_SIZE) || DEFAULT_BATCH_SIZE;
    const limit = normalizeInt(args.limit, null);

    // ── c-d. SELECT bertahap + tulis envelope streaming (sinkron) ─────────────────
    // Penulisan via fs.writeSync (bukan WriteStream) menjaga streaming — tiap chunk
    // langsung ditulis ke OS tanpa menahan seluruh envelope di memory — sekaligus
    // sinkron sehingga satu-satunya async di runOne() adalah query DB.
    let rowCount = 0;
    let fd = null;
    try {
        // Buat direktori parent dari outFile (bukan sekadar outputDir): pada layout
        // bersarang, outFile berada di subfolder schema (mis. `<storage>/main/`) yang
        // belum tentu ada. path.dirname mencakup subfolder schema maupun flat root.
        fs.mkdirSync(path.dirname(outFile), { recursive: true });
        fd = fs.openSync(outFile, 'w');

        const writer = new EnvelopeWriter((chunk) => fs.writeSync(fd, chunk));
        writer.writeHeader({ schema: schemaName, table: ir.tableName, sourceDialect, columns });

        rowCount = await pullInBatches({
            executor,
            dialect,
            queryTable: qualifiedName,
            columns,
            pk,
            batchSize,
            limit,
            onBatch: (encodedRows) => writer.appendRows(encodedRows)
        });

        writer.end();
        fs.closeSync(fd);
        fd = null;
    } catch (e) {
        // Tutup fd SEBELUM unlink (Windows menolak unlink file yang masih terbuka).
        if (fd !== null) {
            try { fs.closeSync(fd); } catch (_e) { /* ignore */ }
            fd = null;
        }
        // Bersihkan file partial agar tidak meninggalkan envelope tak lengkap.
        try { fs.unlinkSync(outFile); } catch (_e) { /* sudah tidak ada */ }
        // exitCode yang sudah di-set (mis. dari QueryBuilder/validasi) dipertahankan;
        // selain itu error lapisan DB (connect/select) dipetakan ke exit 3.
        if (Number.isInteger(e.exitCode)) throw e;
        throw failWith(`Database error during pull: ${e.message}`, 3);
    } finally {
        try { await executor.disconnect(); } catch (_e) { /* ignore */ }
    }

    // ── e. Ringkasan per-tabel (tanpa `command`; dirakit caller) ─────────────────
    const relFile = path.relative(cwd, outFile).split(path.sep).join('/');
    return {
        schema: schemaName,
        table: ir.tableName,
        rows: rowCount,
        file: relFile,
        source_dialect: sourceDialect
    };
}

/**
 * Jalankan command `data pull` (dispatcher: single-table vs multi-schema).
 * @param {Object} args - argumen ter-parse dari contract
 * @returns {Promise<Object>} ringkasan single { command, schema, table, rows, file, source_dialect }
 *   atau agregat { command, scope, table_count, total_rows, tables: [...] }
 */
async function run(args) {
    const cwd = process.cwd();

    // ── 1. Resolve config DB (SEKALI) ────────────────────────────────────────────
    const resolved = resolveConfig(args.config, cwd);
    if (!resolved) {
        process.stderr.write(
            "Tip: set a default with 'npx restforge config set-default --config=<file>' to omit --config in future runs\n"
        );
        throw failWith('--config=<file> is required.', 2);
    }
    if (resolved.source === 'default') {
        printDefaultConfigWarning(resolved.defaultName);
    }

    let config;
    try {
        config = loadConfig(resolved.path);
    } catch (e) {
        throw failWith(e.message, 2);
    }

    // ── 2. Validasi scope flags (SEBELUM SDF/DB) ─────────────────────────────────
    // Tepat satu dari --table / --schema / --all-schemas (selain itu → exit 2).
    // Helper bersama dengan push (data-scope) agar perilaku identik.
    const schemaList = parseSchemaFlag(args.schema);
    validateScopeFlags(args, schemaList);
    const hasTable = args.table != null && args.table !== '';

    // ── 3. Validasi --format ─────────────────────────────────────────────────────
    if (args.format !== 'json') {
        throw failWith(
            `Unsupported --format='${args.format}'. Only 'json' is supported in this version.`,
            2
        );
    }

    // ── 4. Load SDF (SEKALI) ─────────────────────────────────────────────────────
    const schemaPath = args['schema-path'];
    const absSchemaPath = path.resolve(cwd, schemaPath);
    let models;
    try {
        models = loadSdf(absSchemaPath);
    } catch (e) {
        // Error baca SDF (path tidak ada, parse gagal) = operational.
        throw failWith(e.message, 1);
    }

    const ctx = {
        config,
        sourceDialect: canonicalSourceDialect(config.dialect),
        args,
        cwd
    };

    // ── 5. Resolusi scope → daftar IR ────────────────────────────────────────────
    // --table → [ir] (exit 2 bila tak terdaftar/ambigu); --schema → subset (exit 2
    // bila 0-match); --all-schemas → seluruh IR (boleh kosong → agregat 0 tabel).
    const subset = resolveScope(models, args, schemaList);

    // ── 5a. Jalur --table (single; regresi nol) ──────────────────────────────────
    if (hasTable) {
        const per = await runOne(subset[0], ctx);
        const summary = { command: 'pull', ...per };

        if (args.json === true) {
            process.stdout.write(JSON.stringify(summary) + '\n');
        } else {
            process.stdout.write(
                `Pulled ${per.rows} rows from ${per.table} -> ${per.file} (dialect: ${per.source_dialect})\n`
            );
        }
        return summary;
    }

    // ── 5b. Jalur --schema/--all-schemas (fail-fast Q3; agregat) ─────────────────
    // Iterasi mengikuti urutan insertion Map (file SDF ter-sort alfabetis). Error
    // pada satu tabel menghentikan loop (fail-fast); tabel yang sudah selesai
    // sebelum error tetap tertulis (konsekuensi Q3/R4, didokumentasikan Phase 03).
    const scope = schemaList ? 'schema' : 'all-schemas';
    const tables = [];
    let totalRows = 0;
    for (const ir of subset) {
        const per = await runOne(ir, ctx);
        tables.push(per);
        totalRows += per.rows;
        if (args.json !== true) {
            process.stdout.write(
                `Pulled ${per.rows} rows from ${per.table} -> ${per.file} (dialect: ${per.source_dialect})\n`
            );
        }
    }

    const aggregate = {
        command: 'pull',
        scope,
        table_count: tables.length,
        total_rows: totalRows,
        tables
    };

    if (args.json === true) {
        process.stdout.write(JSON.stringify(aggregate) + '\n');
    } else {
        process.stdout.write(`Done: ${tables.length} tables, ${totalRows} rows total\n`);
    }

    return aggregate;
}

module.exports = {
    run,
    // Diekspor untuk reuse/test internal.
    _internal: { runOne, pullInBatches, encodeRow, normalizeInt, canonicalSourceDialect }
};
