'use strict';

/**
 * push-runner — Business logic command `data push` (append-only, amandemen A6).
 *
 * Push bersifat append-only: tidak ada pilihan mode. Mode `upsert`/`replace`
 * di-descope dari v1 (User konfirmasi 2026-06-03), sehingga flag `--mode`/`--force`
 * dihapus dari contract dan runner tidak lagi membaca/memvalidasi keduanya.
 *
 * `run()` adalah dispatcher (single vs multi-schema); `runOne(ir, ctx)` mengeksekusi
 * push satu tabel dan mengelola executor-nya sendiri (Phase 03, data-pull-push-v2).
 *
 * Alur dispatcher `run()`:
 *   1. Resolve config DB tujuan (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. Load SDF SEKALI → Map<qualifiedName, IR> (sdf-reader).
 *   4. resolveScope(models, args, schemaList) → daftar IR sesuai flag scope.
 *   4a. Jalur --table:               satu IR → runOne(ir, ctx) → ringkasan single (regresi nol).
 *   4b. Jalur --schema/--all-schemas: topoSortByFk(SUBSET) → urutan parent→child (table-order);
 *       siklus → warning stderr + fallback urutan deklarasi. Loop FAIL-FAST (Q3); tabel tanpa
 *       file input di-SKIP (warning stderr, catat di skipped[], lanjut) → runOne per IR → agregat.
 *
 * Alur `runOne(ir, ctx)` (per tabel; metadata kolom/namespace/PK murni dari SDF IR):
 *   a. Derive nama file dari SDF (aturan IDENTIK pull, relDataFilePath): bersarang
 *      `<input>/<schema>/<table>.json` bila ber-schema, flat `<input>/<table>.json`.
 *      File tidak ada → exit 1. Baca + parseEnvelope + validateEnvelope (shape invalid → exit 1).
 *   b. Column match vs SDF (BUKAN introspeksi DB): tiap envelope.columns[].name harus ada
 *      di SDF dan tipe konsisten. Mismatch → exit 1. No-PK guard (exit 1).
 *   c. Connect (db-executor) + validasi identifier tabel/kolom (anti-injection).
 *   d. INSERT batch + commit per batch: tiap nilai di-decode (value-codec) per dialect tujuan,
 *      INSERT parameterized per baris dirakit via dialect helper (placeholder) dan dijalankan
 *      via executeTransaction (commit per batch). Append: konflik PK = error DB (exit 3).
 *   e. Disconnect (selalu, di finally).
 *
 * Exit code: 0 sukses; 1 operational (file tidak ada, envelope invalid, column mismatch
 * vs SDF, no-PK anomali); 2 usage/precondition (--config wajib, mutual-exclusive, tabel
 * tak terdaftar/ambigu di SDF); 3 database error (koneksi maupun INSERT, termasuk konflik
 * PK pada append). Handler tidak memanggil process.exit() sendiri.
 *
 * @module generators/lib/data/push-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, placeholder } = require('./dialect-kit');
const { createExecutor } = require('./db-executor');
const { decodeValue } = require('./value-codec');
const { parseEnvelope, validateEnvelope } = require('./envelope');
const { topoSortByFk } = require('./table-order');

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 output.
 *
 * `loadConfig` memetakan `DB_TYPE=postgresql` menjadi `dialect='postgres'`; output
 * push (`target_dialect`) memakai bentuk kanonik `postgresql`, konsisten dengan
 * `source_dialect` pull (pull-runner.canonicalSourceDialect). Dialect lain identik.
 *
 * @param {string} dialect - config.dialect ('postgres'|'mysql'|'oracle'|'sqlite')
 * @returns {string} 'postgresql'|'mysql'|'oracle'|'sqlite'
 */
function canonicalTargetDialect(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;
}

/**
 * Validasi tiap kolom envelope terhadap metadata SDF (tanpa menyentuh DB).
 *
 * Setiap `envelope.columns[].name` wajib terdeklarasi di SDF dengan tipe canonical
 * yang konsisten. Kolom SDF yang tidak muncul di envelope diperbolehkan (akan
 * memakai default/NULL DB pada INSERT). Mismatch (kolom tak dikenal di SDF, atau
 * tipe berbeda) → throw exitCode=1.
 *
 * @param {Array<{name: string, type: string}>} envelopeColumns
 * @param {Array<{name: string, type: string}>} sdfColumns
 * @param {string} tableName - untuk pesan error
 * @returns {Array<{name: string, type: string}>} kolom yang akan di-INSERT (urutan envelope)
 * @throws {Error} exitCode=1 bila ada kolom tak dikenal atau tipe berbeda
 */
function matchColumns(envelopeColumns, sdfColumns, tableName) {
    const sdfByName = new Map(sdfColumns.map((c) => [c.name, c]));
    const insertColumns = [];
    for (const ec of envelopeColumns) {
        const sc = sdfByName.get(ec.name);
        if (!sc) {
            throw failWith(
                `Column '${ec.name}' in envelope is not declared in SDF for table '${tableName}'.`,
                1
            );
        }
        if (sc.type !== ec.type) {
            throw failWith(
                `Column '${ec.name}' type mismatch for table '${tableName}': envelope '${ec.type}' vs SDF '${sc.type}'.`,
                1
            );
        }
        insertColumns.push({ name: ec.name, type: ec.type });
    }
    return insertColumns;
}

/**
 * Push satu tabel SDF dari file envelope JSON ke DB. Path input via relDataFilePath
 * (bersarang `<input>/<schema>/<table>.json` bila ber-schema, flat
 * `<input>/<table>.json` bila tanpa schema).
 *
 * Mengelola executor-nya sendiri (buat → disconnect di finally) 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.targetDialect - dialect kanonik (untuk output, dirakit caller)
 * @param {Object} ctx.args - argumen ter-parse (storage-path, batch-size)
 * @param {string} ctx.cwd - working directory
 * @returns {Promise<{schema: (string|null), table: string, rows: number, committed_batches: number}>}
 */
async function runOne(ir, ctx) {
    const { config, args, cwd } = ctx;

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

    // ── a. Derive nama file dari SDF (aturan IDENTIK pull, relDataFilePath) ────────
    // Path = {input}/relDataFilePath(ir), turunan MURNI dari SDF (bukan --table mentah
    // maupun envelope.schema). Tabel ber-schema → bersarang `<schema>/<table>.json`;
    // tanpa schema → flat `<table>.json`. Single-table: file-not-found = exit 1 (tak
    // berubah). Jalur --schema/--all-schemas menyaring file-missing SEBELUM memanggil
    // runOne (skip-missing), jadi guard ini hanya tercapai pada single.
    const inputDir = path.resolve(cwd, args['storage-path']);
    const inFile = path.join(inputDir, relDataFilePath(ir));
    if (!fs.existsSync(inFile)) {
        const rel = path.relative(cwd, inFile).split(path.sep).join('/');
        throw failWith(`Input file not found: ${rel}`, 1);
    }

    let envelope;
    try {
        const raw = fs.readFileSync(inFile, 'utf8');
        envelope = validateEnvelope(parseEnvelope(raw));
    } catch (e) {
        // parseEnvelope/validateEnvelope sudah ber-exitCode=1; baca file gagal lain → 1.
        if (Number.isInteger(e.exitCode)) throw e;
        throw failWith(`Failed to read envelope '${path.relative(cwd, inFile).split(path.sep).join('/')}': ${e.message}`, 1);
    }

    // Cross-check OPSIONAL: path diturunkan dari SDF, bukan envelope. Bila envelope 1.1
    // membawa `schema` yang berbeda dari schema SDF (mis. file dipindah/diedit manual),
    // cetak warning informatif (bukan error) — konsisten perlakuan envelope.table.
    // File 1.0 (tanpa `schema`, undefined) dilewati.
    if (envelope.schema !== undefined && envelope.schema !== schemaName) {
        process.stderr.write(
            `Warning: envelope schema '${envelope.schema}' differs from SDF schema ` +
            `'${schemaName === null ? '(none)' : schemaName}' for table '${ir.tableName}'\n`
        );
    }

    // ── b. Column match vs SDF (tanpa introspeksi DB) + no-PK guard ──────────────
    const insertColumns = matchColumns(envelope.columns, sdfColumns, ir.tableName);

    // 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.
    // Selaras pull-runner (phase-02b Keputusan K3).
    if (pk.length === 0) {
        throw failWith(`Table '${ir.tableName}' has no primary key in SDF (unexpected)`, 1);
    }

    // ── c. Validasi identifier (anti-injection) + siapkan executor ───────────────
    const dialect = createDialect(config.dialect);
    validateIdentifier(dialect, qualifiedName);
    for (const col of insertColumns) validateIdentifier(dialect, col.name);

    const batchSize = normalizeInt(args['batch-size'], DEFAULT_BATCH_SIZE) || DEFAULT_BATCH_SIZE;
    const executor = createExecutor(config);

    // ── d. INSERT batch + commit per batch ───────────────────────────────────────
    // INSERT parameterized per-baris (dirakit via dialect.placeholder), dijalankan
    // per batch dalam SATU executeTransaction → commit per batch. Pilihan per-baris
    // (bukan multi-row VALUES) menjaga paritas lintas-dialect: Oracle tidak mendukung
    // `VALUES (..),(..)` multi-row. Kegagalan satu batch me-rollback batch tsb.;
    // batch sebelumnya tetap ter-commit (risiko partial, didokumentasikan Poin 5/8).
    const colList = insertColumns.map((c) => c.name).join(', ');
    const valuesClause = insertColumns.map((_, i) => placeholder(dialect, i + 1)).join(', ');
    const insertSql = `INSERT INTO ${qualifiedName} (${colList}) VALUES (${valuesClause})`;

    let rowCount = 0;
    let committedBatches = 0;
    try {
        const rows = envelope.rows;
        for (let start = 0; start < rows.length; start += batchSize) {
            const slice = rows.slice(start, start + batchSize);
            const queries = slice.map((row) => ({
                sql: insertSql,
                params: insertColumns.map((c) => decodeValue(row[c.name], c.type, config.dialect))
            }));
            await executor.executeTransaction(queries);
            committedBatches += 1;
            rowCount += slice.length;
        }
    } catch (e) {
        // exitCode yang sudah di-set dipertahankan; selain itu error lapisan DB
        // (connect/INSERT, termasuk konflik PK pada append) dipetakan ke exit 3.
        if (Number.isInteger(e.exitCode)) throw e;
        throw failWith(`Database error during push: ${e.message}`, 3);
    } finally {
        try { await executor.disconnect(); } catch (_e) { /* ignore */ }
    }

    // ── e. Ringkasan per-tabel (tanpa command/target_dialect; dirakit caller) ─────
    return {
        schema: schemaName,
        table: ir.tableName,
        rows: rowCount,
        committed_batches: committedBatches
    };
}

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

    // ── 1. Resolve config DB tujuan (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 pull (data-scope) agar perilaku identik.
    const schemaList = parseSchemaFlag(args.schema);
    validateScopeFlags(args, schemaList);
    const hasTable = args.table != null && args.table !== '';

    // ── 3. 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,
        targetDialect: canonicalTargetDialect(config.dialect),
        args,
        cwd
    };

    // ── 4. 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);

    // ── 4a. Jalur --table (single; regresi nol) ──────────────────────────────────
    if (hasTable) {
        const per = await runOne(subset[0], ctx);
        const summary = {
            command: 'push',
            schema: per.schema,
            table: per.table,
            rows: per.rows,
            target_dialect: ctx.targetDialect,
            committed_batches: per.committed_batches
        };

        if (args.json === true) {
            process.stdout.write(JSON.stringify(summary) + '\n');
        } else {
            process.stdout.write(
                `Pushed ${per.rows} rows to ${per.table} (dialect: ${ctx.targetDialect}, batches: ${per.committed_batches})\n`
            );
        }
        return summary;
    }

    // ── 4b. Jalur --schema/--all-schemas (topo-sort FK parent→child, fail-fast Q3, skip-missing) ──
    // Urutan push diturunkan dari FK SDF SUBSET (bukan seluruh models): parent (tabel
    // direferensikan) SEBELUM child agar INSERT child tidak melanggar FK constraint.
    // Siklus → fallback urutan deklarasi. Catatan (Phase 03): untuk --schema subset, FK
    // ke parent di luar subset → edge diabaikan table-order; child bisa di-push tanpa
    // parent → potensi FK error runtime (exit 3), tanpa pre-warning.
    const scope = schemaList ? 'schema' : 'all-schemas';
    const subsetMap = new Map(subset.map((ir) => [getNamespace(ir).qualifiedName, ir]));
    const { ordered, cycle } = topoSortByFk(subsetMap);
    if (cycle) {
        process.stderr.write('FK cycle detected; falling back to declaration order\n');
    }

    // Skip-missing HANYA di jalur multi: tabel tanpa file input dilewati (warning +
    // catat), bukan error. Path input via relDataFilePath (aturan IDENTIK pull).
    const inputDir = path.resolve(cwd, args['storage-path']);
    const tables = [];
    const skipped = [];
    let totalRows = 0;

    for (const ir of ordered) {
        const inFile = path.join(inputDir, relDataFilePath(ir));
        if (!fs.existsSync(inFile)) {
            process.stderr.write(`Skipped ${ir.tableName}: input file not found\n`);
            skipped.push(ir.tableName);
            continue;
        }

        const per = await runOne(ir, ctx); // fail-fast (Q3)
        tables.push(per);
        totalRows += per.rows;
        if (args.json !== true) {
            process.stdout.write(
                `Pushed ${per.rows} rows to ${per.table} (dialect: ${ctx.targetDialect}, batches: ${per.committed_batches})\n`
            );
        }
    }

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

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

    return aggregate;
}

module.exports = {
    run,
    // Diekspor untuk reuse/test internal.
    _internal: { runOne, matchColumns, normalizeInt, canonicalTargetDialect }
};
