'use strict';

const fs = require('fs');
const path = require('path');
const FileUtils = require('../utils/file-utils');

const VALID_DATABASES = new Set(['postgres', 'mysql', 'oracle']);
const DB_MODULE_PATH = {
  postgres: '@restforgejs/platform/src/utils/db',
  mysql: '@restforgejs/platform/src/utils/db-mysql',
  oracle: '@restforgejs/platform/src/utils/db-oracle'
};

/**
 * Static class generator yang menulis file module dashboard
 * ke src/modules/{project}/{dashboardName}.js dengan SQL embedded
 * langsung sebagai template literal di setiap widget block.
 *
 * dashboardName harus mengandung prefix 'dash-' (validation di CLI glue);
 * nama tersebut dipakai apa adanya sebagai filename dan URL segment.
 *
 * SQL files dibaca saat generation time dari payloadDir, lalu
 * di-embed ke output. Runtime hanya butuh helpers + db, tidak ada
 * file I/O di critical path handler.
 */
class DashboardGenerator {

  // -------- Public helpers --------

  /**
   * Extract list path SQL file (relatif terhadap payload directory) yang
   * direferensikan widget. Berguna untuk reporting di CLI glue (mis.
   * menampilkan list file yang dibaca saat generate).
   *
   * @param {Array} payloadWidgets
   * @returns {Array<string>} mis. ['query/author-sales.sql', ...]
   */
  static collectSqlFileReferences(payloadWidgets) {
    const refs = [];
    if (!Array.isArray(payloadWidgets)) return refs;

    for (const widget of payloadWidgets) {
      if (!widget || typeof widget !== 'object') continue;

      if (typeof widget.query === 'string' && widget.query.startsWith('file:')) {
        refs.push(widget.query.substring(5));
        continue;
      }

      if (widget.queries && typeof widget.queries === 'object') {
        for (const ref of Object.values(widget.queries)) {
          if (typeof ref === 'string' && ref.startsWith('file:')) {
            refs.push(ref.substring(5));
          }
        }
      }
    }
    return refs;
  }

  /**
   * Tulis file module dashboard ke src/modules/{project}/dash-{name}.js.
   * SQL di-embed langsung sebagai template literal â€” tidak ada SQL file copy.
   *
   * @param {string} projectName
   * @param {string} dashboardName
   * @param {Object} payload    payload yang sudah lulus DashboardValidator
   * @param {Object} options    { force: boolean, payloadDir: string }
   * @returns {{filePath:string, generated:boolean, archivedPath:string|null}}
   */
  static createDashboardModule(projectName, dashboardName, payload, options = {}) {
    if (typeof projectName !== 'string' || projectName.trim() === '') {
      throw new Error('projectName must be a non-empty string');
    }
    if (typeof dashboardName !== 'string' || dashboardName.trim() === '') {
      throw new Error('dashboardName must be a non-empty string');
    }
    if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
      throw new Error('payload must be a plain object');
    }
    if (!Array.isArray(payload.widgets)) {
      throw new Error("payload.widgets must be an array");
    }
    if (typeof options.payloadDir !== 'string' || options.payloadDir.trim() === '') {
      throw new Error("options.payloadDir is required (path to payload directory containing SQL files)");
    }

    const database = options.database || 'postgres';
    if (!VALID_DATABASES.has(database)) {
      throw new Error(
        `Dashboard generator does not support database '${database}'. Supported: postgres, mysql, oracle.`
      );
    }

    const force = Boolean(options.force);
    const payloadDir = options.payloadDir;

    const paths = FileUtils.setupModuleDirectories(projectName);

    const paramsContract = (payload.params && typeof payload.params === 'object' && !Array.isArray(payload.params))
      ? payload.params
      : {};

    const filePath = path.join(paths.moduleDir, `${dashboardName}.js`);

    let archivedPath = null;
    if (fs.existsSync(filePath)) {
      if (!force) {
        throw new Error(
          `Dashboard module already exists at '${filePath}'. Pass options.force=true to overwrite.`
        );
      }
      archivedPath = this._archiveExistingFile(filePath);
    }

    const cacheEnabled = !!(payload.cache && payload.cache.enabled === true);
    const cacheConfig = cacheEnabled
      ? {
          enabled: true,
          ttl: typeof payload.cache.ttl === 'number' ? payload.cache.ttl : null,
          projectName,
          dashboardName
        }
      : null;

    const source = this._renderModuleSource(
      payload.widgets,
      paramsContract,
      payloadDir,
      database,
      cacheConfig
    );

    fs.writeFileSync(filePath, source, 'utf8');

    return { filePath, generated: true, archivedPath };
  }

  // -------- Internal helpers --------

  /**
   * Baca file SQL dari disk (saat generation time), trim trailing whitespace,
   * lalu escape karakter yang berbahaya di template literal JS:
   *   - backtick (`) â†’ \`
   *   - dollar-brace (${) â†’ \${
   *
   * Comment SQL dibiarkan apa adanya (defensive: bisa berisi info dialect /
   * output columns yang berguna saat audit).
   */
  static _readAndEscapeSqlFile(fullPath) {
    const raw = fs.readFileSync(fullPath, 'utf8');
    const trimmed = raw.replace(/\s+$/, '');
    return trimmed.replace(/`/g, '\\`').replace(/\$\{/g, '\\${');
  }

  /**
   * Resolve `file:query/foo.sql` ke full path absolute (relatif terhadap
   * payloadDir), kemudian baca + escape isinya.
   *
   * Throw bila widget pakai inline SQL (bukan `file:` reference).
   */
  static _resolveAndReadSql(ref, context, payloadDir) {
    if (typeof ref !== 'string') {
      throw new Error(`${context}: query reference must be a string`);
    }
    if (!ref.startsWith('file:')) {
      throw new Error(
        `${context}: inline SQL is not supported, use 'file:query/<name>.sql' reference`
      );
    }
    const relPath = ref.substring(5).replace(/\\/g, '/');
    const fullPath = path.join(payloadDir, relPath);
    return this._readAndEscapeSqlFile(fullPath);
  }

  /**
   * Render code block per widget. Untuk single query: 1 task push.
   * Untuk multi queries: N task push (1 per key) di dalam if-block.
   */
  static _renderWidgetBlock(widget, payloadDir) {
    if (!widget || typeof widget !== 'object' || Array.isArray(widget)) {
      throw new Error('Widget must be a plain object');
    }
    const id = widget.id;
    if (typeof id !== 'string' || id.trim() === '') {
      throw new Error("Widget missing 'id'");
    }

    const hasQuery = 'query' in widget;
    const hasQueries = 'queries' in widget;

    if (hasQuery && hasQueries) {
      throw new Error(`Widget '${id}': declare either 'query' or 'queries', not both`);
    }
    if (!hasQuery && !hasQueries) {
      throw new Error(`Widget '${id}': must declare 'query' or 'queries'`);
    }

    if (hasQuery) {
      const sqlText = this._resolveAndReadSql(widget.query, `Widget '${id}'`, payloadDir);
      return this._renderSingleQueryBlock(id, sqlText);
    }

    if (
      !widget.queries ||
      typeof widget.queries !== 'object' ||
      Array.isArray(widget.queries) ||
      Object.keys(widget.queries).length === 0
    ) {
      throw new Error(`Widget '${id}': 'queries' must be a non-empty object`);
    }

    const innerBlocks = [];
    for (const [key, ref] of Object.entries(widget.queries)) {
      const sqlText = this._resolveAndReadSql(ref, `Widget '${id}' queries.${key}`, payloadDir);
      innerBlocks.push(this._renderInnerKeyedBlock(id, key, sqlText));
    }
    return this._renderMultiQueryBlock(id, innerBlocks);
  }

  static _renderSingleQueryBlock(id, sqlText) {
    return `    if (selected.has('${id}')) {
      const compiled = helpers.substitutePlaceholders(
        \`${sqlText}\`,
        effectiveParams,
        dialect
      );
      tasks.push({ widgetId: '${id}', key: null, sql: compiled.sql, params: compiled.params });
    }`;
  }

  static _renderMultiQueryBlock(id, innerBlocks) {
    return `    if (selected.has('${id}')) {
${innerBlocks.join('\n')}
    }`;
  }

  static _renderInnerKeyedBlock(id, key, sqlText) {
    return `      {
        const compiled = helpers.substitutePlaceholders(
          \`${sqlText}\`,
          effectiveParams,
          dialect
        );
        tasks.push({ widgetId: '${id}', key: '${key}', sql: compiled.sql, params: compiled.params });
      }`;
  }

  static _renderModuleSource(widgets, paramsContract, payloadDir, database, cacheConfig) {
    const allWidgetIds = widgets.map((w) => w && w.id);
    const widgetIdsLiteral = JSON.stringify(allWidgetIds);
    const paramsLiteral = JSON.stringify(paramsContract, null, 2);

    const dbModulePath = DB_MODULE_PATH[database];

    const widgetBlocks = widgets
      .map((w) => this._renderWidgetBlock(w, payloadDir))
      .join('\n\n');

    const cacheEnabled = !!(cacheConfig && cacheConfig.enabled === true);
    const cacheImport = cacheEnabled
      ? `const cacheManager = require('@restforgejs/platform/src/utils/cache-manager');\n`
      : '';
    const cacheCheckBlock = cacheEnabled
      ? this._renderCacheCheckBlock(cacheConfig)
      : '';
    const cacheSetBlock = cacheEnabled
      ? this._renderCacheSetBlock(cacheConfig)
      : '';

    return `'use strict';

const express = require('express');
const helpers = require('@restforgejs/platform/src/utils/dashboard-helpers');
const db = require('${dbModulePath}');
${cacheImport}
const dialect = '${database}';
const paramsContract = ${paramsLiteral};

const allWidgetIds = ${widgetIdsLiteral};

const router = express.Router();

router.post('/dashboard', async (req, res) => {
  try {
    const effectiveParams = helpers.validateAndApplyParams(
      paramsContract,
      req.body && req.body.params
    );
    const selected = helpers.filterWidgets(
      allWidgetIds,
      req.body && req.body.widgets
    );

    const tasks = [];
${cacheCheckBlock}
${widgetBlocks}

    const settled = await Promise.allSettled(
      tasks.map((t) => db.executeQuery(t.sql, t.params))
    );

    const data = {};
    for (let i = 0; i < tasks.length; i++) {
      const t = tasks[i];
      const s = settled[i];
      if (s.status === 'rejected') {
        data[t.widgetId] = { error: (s.reason && s.reason.message) || 'Query execution failed' };
        continue;
      }
      const rows = s.value || [];
      if (t.key === null) {
        data[t.widgetId] = { items: rows };
      } else {
        if (!data[t.widgetId]) data[t.widgetId] = {};
        if (data[t.widgetId].error) continue;
        data[t.widgetId][t.key] = helpers.collapseRows(rows);
      }
    }

    for (const widgetId of Object.keys(data)) {
      data[widgetId] = helpers.lowercaseKeysDeep(data[widgetId]);
      data[widgetId] = helpers.stringifyNumericDeep(data[widgetId]);
    }

    const response = { success: true, data };${cacheSetBlock}
    res.json(response);
  } catch (err) {
    if (err && err.name === 'HttpError') {
      return res.status(err.status).json({ success: false, message: err.message });
    }
    return res.status(500).json({
      success: false,
      message: (err && err.message) || 'Internal Server Error'
    });
  }
});

module.exports = router;
`;
  }

  /**
   * Render block cache check yang disisip setelah `const tasks = []` dan
   * sebelum widget tasks. Bila cache hit, handler return early dengan
   * cached envelope.
   *
   * Catatan:
   *   - `'<projectName>'` dan `'<dashboardName>'` di-substitute saat generation
   *     time menjadi string literal di output (validator CLI sudah membatasi
   *     karakter alphanumerik + dash, jadi aman tanpa escaping tambahan).
   *   - `cacheManager.enabled` adalah getter (existing di cache-manager.js),
   *     bukan method `isEnabled()`.
   */
  static _renderCacheCheckBlock(cacheConfig) {
    return `
    // --- Cache check (auto-generated dari payload cache.enabled) ---
    let cacheKey = null;
    if (cacheManager.enabled) {
      const crypto = require('crypto');
      const body = req.body || {};
      const isEmpty = Object.keys(body).length === 0;
      const id = isEmpty
        ? 'all'
        : crypto.createHash('md5').update(JSON.stringify(body)).digest('hex').substring(0, 8);

      cacheKey = cacheManager.buildKey({
        module: '${cacheConfig.projectName}',
        endpoint: '${cacheConfig.dashboardName}',
        type: 'dashboard',
        id
      });

      const cached = await cacheManager.get(cacheKey);
      if (cached) return res.json(cached);
    }
`;
  }

  /**
   * Render block cache set yang disisip setelah loop normalisasi (lowercaseKeysDeep
   * + stringifyNumericDeep) dan sebelum `res.json(response)`. TTL literal:
   *   - bila cacheConfig.ttl numeric â†’ emit angka literal
   *   - bila cacheConfig.ttl null â†’ emit `null` agar `cacheManager.set`
   *     fallback ke env CACHE_TTL.
   */
  static _renderCacheSetBlock(cacheConfig) {
    const ttlLiteral = cacheConfig.ttl === null ? 'null' : String(cacheConfig.ttl);
    return `

    // --- Cache set (auto-generated, hanya untuk success response) ---
    if (cacheKey) {
      await cacheManager.set(cacheKey, response, ${ttlLiteral});
    }`;
  }

  /**
   * Archive sibling pakai pola `dash-{name}.js.archive.YYYYMMDD-HHMMSS` (tanpa
   * trailing `.js`) supaya tidak ikut di-load oleh main module loader yang
   * scan `*.js` di src/modules/{project}/.
   */
  static _archiveExistingFile(filePath) {
    const d = new Date();
    const pad = (n) => String(n).padStart(2, '0');
    const timestamp =
      `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}` +
      `-${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}`;
    const archivePath = `${filePath}.archive.${timestamp}`;
    fs.copyFileSync(filePath, archivePath);
    return archivePath;
  }
}

module.exports = DashboardGenerator;
