'use strict';

/**
 * envelope — Bentuk + I/O envelope file data.
 *
 * Struktur envelope (urutan key wajib dipertahankan):
 *   { format_version, schema, table, source_dialect, columns, rows }
 *
 * `schema` (= `ir.schemaName`, `null` bila tanpa schema) ditambahkan di format
 * version 1.1. File 1.0 (tanpa `schema`) tetap diterima oleh `validateEnvelope`
 * untuk back-compat baca `data push`.
 *
 * Mengacu pada docs/plan/data-pull-push/data-pull-push-03-data-format.md.
 *
 * @module generators/lib/data/envelope
 */

const FORMAT_VERSION = '1.1';

/**
 * Bangun header envelope (tanpa rows).
 *
 * @param {Object} args
 * @param {string|null} [args.schema] - Nama schema (= ir.schemaName; null bila tanpa schema)
 * @param {string} args.table - Nama tabel
 * @param {string} args.sourceDialect - Dialect sumber (mis. 'postgresql')
 * @param {Array<Object>} args.columns - Daftar kolom { name, type, nullable, primary_key }
 * @returns {{format_version: string, schema: (string|null), table: string, source_dialect: string, columns: Array}}
 */
function buildHeader({ schema = null, table, sourceDialect, columns }) {
  return {
    format_version: FORMAT_VERSION,
    schema,
    table,
    source_dialect: sourceDialect,
    columns
  };
}

/**
 * Resolve sink penulisan menjadi fungsi write(chunk).
 * Menerima: fungsi, object dengan method `write`, atau undefined (buffer mode).
 *
 * @param {Function|{write: Function}|undefined} sink
 * @returns {{write: Function, buffer: (string[]|null)}}
 */
function resolveSink(sink) {
  if (sink === undefined || sink === null) {
    const buffer = [];
    return { write: (chunk) => buffer.push(chunk), buffer };
  }
  if (typeof sink === 'function') {
    return { write: sink, buffer: null };
  }
  if (typeof sink.write === 'function') {
    return { write: (chunk) => sink.write(chunk), buffer: null };
  }
  throw new Error('EnvelopeWriter: sink must be a function, an object with a write() method, or undefined');
}

/**
 * Penulis envelope streaming.
 *
 * Menulis header, meng-append rows per batch, lalu menutup array `rows` dan envelope.
 * Cocok untuk tabel besar karena rows tidak perlu ditahan seluruhnya di memori.
 * Output akhir adalah JSON valid dengan urutan key:
 *   format_version, schema, table, source_dialect, columns, rows
 *
 * Penggunaan:
 *   const w = new EnvelopeWriter(fs.createWriteStream(path));
 *   w.writeHeader({ schema, table, sourceDialect, columns });
 *   w.appendRows(batch1);
 *   w.appendRows(batch2);
 *   w.end();
 *
 * Tanpa sink (buffer mode), `end()` mengembalikan string JSON penuh.
 */
class EnvelopeWriter {
  /**
   * @param {Function|{write: Function}} [sink] - Tujuan tulis; kosong = buffer mode
   */
  constructor(sink) {
    const resolved = resolveSink(sink);
    this._write = resolved.write;
    this._buffer = resolved.buffer;
    this._state = 'init'; // init → header → ended
    this._rowsWritten = 0;
  }

  /**
   * Tulis prefix header dan buka array `rows`.
   * @param {Object} header - { schema, table, sourceDialect, columns }
   * @returns {EnvelopeWriter} this
   */
  writeHeader({ schema, table, sourceDialect, columns }) {
    if (this._state !== 'init') {
      throw new Error('EnvelopeWriter: writeHeader() hanya boleh dipanggil sekali di awal');
    }
    const prefix =
      '{' +
      `"format_version":${JSON.stringify(FORMAT_VERSION)},` +
      `"schema":${JSON.stringify(schema ?? null)},` +
      `"table":${JSON.stringify(table)},` +
      `"source_dialect":${JSON.stringify(sourceDialect)},` +
      `"columns":${JSON.stringify(columns)},` +
      '"rows":[';
    this._write(prefix);
    this._state = 'header';
    return this;
  }

  /**
   * Append satu batch rows ke array `rows`.
   * @param {Array<Object>} rows
   * @returns {EnvelopeWriter} this
   */
  appendRows(rows) {
    if (this._state !== 'header') {
      throw new Error('EnvelopeWriter: appendRows() must be called after writeHeader() and before end()');
    }
    if (!Array.isArray(rows)) {
      throw new Error('EnvelopeWriter: appendRows() membutuhkan array rows');
    }
    for (const row of rows) {
      const sep = this._rowsWritten === 0 ? '' : ',';
      this._write(sep + JSON.stringify(row));
      this._rowsWritten++;
    }
    return this;
  }

  /**
   * Tutup array `rows` dan envelope.
   * @returns {string|undefined} String JSON penuh bila buffer mode, selain itu undefined
   */
  end() {
    if (this._state === 'init') {
      throw new Error('EnvelopeWriter: end() dipanggil sebelum writeHeader()');
    }
    if (this._state === 'ended') {
      throw new Error('EnvelopeWriter: end() has already been called');
    }
    this._write(']}');
    this._state = 'ended';
    if (this._buffer) {
      return this._buffer.join('');
    }
    return undefined;
  }
}

/**
 * Parse string JSON envelope menjadi object.
 *
 * @param {string} jsonString
 * @returns {Object} Object envelope
 * @throws {Error} exitCode=1 bila JSON tidak dapat di-parse
 */
function parseEnvelope(jsonString) {
  try {
    return JSON.parse(jsonString);
  } catch (err) {
    const e = new Error(`Envelope tidak dapat di-parse sebagai JSON: ${err.message}`);
    e.exitCode = 1;
    throw e;
  }
}

/**
 * Validasi shape envelope. Throw (exitCode=1) bila tidak valid.
 *
 * Wajib: format_version (ada), table (string), columns (array of {name,type}),
 * rows (array of object). Opsional: `schema` (string atau null bila ada).
 *
 * `format_version` tetap LENIENT (cukup ada/non-null) agar file 1.0 maupun 1.1
 * sama-sama diterima tanpa penolakan versi. `schema` opsional untuk back-compat:
 * file 1.0 (tanpa key `schema`) diterima; bila ada, wajib string atau null.
 *
 * @param {Object} obj
 * @returns {Object} obj yang sama (untuk chaining)
 * @throws {Error} exitCode=1 bila shape tidak valid
 */
function validateEnvelope(obj) {
  const fail = (msg) => {
    const e = new Error(`Envelope tidak valid: ${msg}`);
    e.exitCode = 1;
    throw e;
  };

  if (obj === null || typeof obj !== 'object' || Array.isArray(obj)) {
    fail('harus berupa object');
  }
  if (obj.format_version === undefined || obj.format_version === null) {
    fail('field "format_version" wajib ada');
  }
  // `schema` opsional (back-compat 1.0). Bila absen → diterima; bila ada → wajib
  // string atau null. `null` eksplisit (1.1 tabel schemaless) tetap valid.
  if (obj.schema !== undefined && obj.schema !== null && typeof obj.schema !== 'string') {
    fail('field "schema" wajib berupa string atau null bila ada');
  }
  if (typeof obj.table !== 'string' || obj.table === '') {
    fail('field "table" wajib berupa string non-kosong');
  }
  if (!Array.isArray(obj.columns)) {
    fail('field "columns" wajib berupa array');
  }
  obj.columns.forEach((col, i) => {
    if (col === null || typeof col !== 'object' || Array.isArray(col)) {
      fail(`columns[${i}] harus berupa object`);
    }
    if (typeof col.name !== 'string' || col.name === '') {
      fail(`columns[${i}].name wajib berupa string non-kosong`);
    }
    if (typeof col.type !== 'string' || col.type === '') {
      fail(`columns[${i}].type wajib berupa string non-kosong`);
    }
  });
  if (!Array.isArray(obj.rows)) {
    fail('field "rows" wajib berupa array');
  }
  obj.rows.forEach((row, i) => {
    if (row === null || typeof row !== 'object' || Array.isArray(row)) {
      fail(`rows[${i}] harus berupa object`);
    }
  });

  return obj;
}

module.exports = {
  FORMAT_VERSION,
  buildHeader,
  EnvelopeWriter,
  parseEnvelope,
  validateEnvelope
};
