const fs = require('fs');
const path = require('path');
const PayloadProcessor = require('../utils/payload-processor');
const sqlTableExtractor = require('../../../src/utils/sql-table-extractor');
const softDeleteDashboardGuard = require('../../../src/utils/soft-delete-dashboard-guard');

const FORBIDDEN_FRONTEND_FIELDS = ['widgetType', 'layout', 'title', 'subtitle', 'color'];
const ALLOWED_PARAM_TYPES = ['string', 'number', 'boolean', 'date'];
const PLACEHOLDER_REGEX = /(?<!:):([a-zA-Z_][a-zA-Z0-9_]*)/g;
const ALLOWED_CACHE_FIELDS = ['enabled', 'ttl', 'invalidates'];

class DashboardValidator {

  static validateDashboardPayload(payload, payloadDir, options = {}) {
    if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
      throw new Error('Dashboard payload must be a plain object');
    }

    if ('tableName' in payload) {
      throw new Error("Dashboard payload must not contain 'tableName' (use 'widgets' for dashboard)");
    }

    if (!('widgets' in payload) || !Array.isArray(payload.widgets)) {
      throw new Error("Dashboard payload must have 'widgets' array");
    }

    if (payload.widgets.length < 1) {
      throw new Error("Dashboard 'widgets' must have at least one entry");
    }

    this._assertNoFrontendFields(payload, 'payload');

    if ('params' in payload) {
      if (!payload.params || typeof payload.params !== 'object' || Array.isArray(payload.params)) {
        throw new Error("'params' must be a plain object");
      }
      this._validateParamsContract(payload.params);
    }

    const declaredParams = payload.params ? Object.keys(payload.params) : [];

    this._validateWidgets(payload.widgets, declaredParams, payloadDir);

    this._validateSoftDeleteGuard(payload, payloadDir);

    this._validateCacheBlock(payload.cache);

    if (
      payload.cache &&
      payload.cache.enabled === true &&
      Array.isArray(payload.cache.invalidates) &&
      payload.cache.invalidates.length > 0
    ) {
      this._crossReferenceInvalidates(payload, payloadDir, {
        projectName: options.projectName,
        metadataDir: options.metadataDir,
        warn: options.warn
      });
    }

    return { valid: true };
  }

  static _validateWidgets(widgets, declaredParams, payloadDir) {
    const seenIds = new Set();

    widgets.forEach((widget, index) => {
      if (!widget || typeof widget !== 'object' || Array.isArray(widget)) {
        throw new Error(`Widget at index ${index} must be a plain object`);
      }

      if (typeof widget.id !== 'string' || widget.id.trim() === '') {
        throw new Error(`Widget at index ${index} missing 'id'`);
      }

      if (seenIds.has(widget.id)) {
        throw new Error(`Duplicate widget id: '${widget.id}'`);
      }
      seenIds.add(widget.id);

      this._assertNoFrontendFields(widget, `Widget '${widget.id}'`);

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

      if (hasQuery === hasQueries) {
        throw new Error(
          `Widget '${widget.id}' must declare either 'query' or 'queries' (not both, not neither)`
        );
      }

      if (hasQuery) {
        if (typeof widget.query !== 'string') {
          throw new Error(`Widget '${widget.id}' field 'query' must be a string`);
        }
        this._scanWidgetSql(
          widget.id,
          'query',
          widget.query,
          declaredParams,
          payloadDir
        );
      } else {
        if (
          !widget.queries ||
          typeof widget.queries !== 'object' ||
          Array.isArray(widget.queries) ||
          Object.keys(widget.queries).length === 0
        ) {
          throw new Error(`Widget '${widget.id}' field 'queries' must be a non-empty object`);
        }

        for (const [key, value] of Object.entries(widget.queries)) {
          if (typeof value !== 'string') {
            throw new Error(`Widget '${widget.id}' queries.${key} must be a string`);
          }
          this._scanWidgetSql(
            widget.id,
            `queries.${key}`,
            value,
            declaredParams,
            payloadDir
          );
        }
      }
    });
  }

  static _validateParamsContract(params) {
    for (const [key, def] of Object.entries(params)) {
      if (!def || typeof def !== 'object' || Array.isArray(def)) {
        throw new Error(`Param '${key}' must be an object with at least 'type'`);
      }

      if (typeof def.type !== 'string' || !ALLOWED_PARAM_TYPES.includes(def.type)) {
        throw new Error(`Param '${key}' has invalid type '${def.type}'`);
      }

      if ('required' in def && typeof def.required !== 'boolean') {
        throw new Error(`Param '${key}' field 'required' must be boolean`);
      }
    }
  }

  static _assertNoFrontendFields(obj, scopeLabel) {
    for (const field of FORBIDDEN_FRONTEND_FIELDS) {
      if (field in obj) {
        throw new Error(
          `${scopeLabel}: field '${field}' is not allowed in backend payload (frontend concern, separation of concerns)`
        );
      }
    }
  }

  static _scanWidgetSql(widgetId, queryLabel, sqlOrRef, declaredParams, payloadDir) {
    const sqlText = this._resolveSqlText(widgetId, queryLabel, sqlOrRef, payloadDir);
    const found = new Set();
    let match;
    PLACEHOLDER_REGEX.lastIndex = 0;
    while ((match = PLACEHOLDER_REGEX.exec(sqlText)) !== null) {
      found.add(match[1]);
    }

    for (const token of found) {
      if (!declaredParams.includes(token)) {
        throw new Error(
          `Widget '${widgetId}' query '${queryLabel}' uses undeclared placeholder ':${token}' (declare in 'params')`
        );
      }
    }
  }

  /**
   * Checkpoint soft-delete (ERROR) untuk `dashboard create`.
   *
   * Bangun registry tabel soft-delete dari scan payloadDir, lalu cek tiap widget.
   * Bila ada widget yang mereferensikan tabel soft-delete tanpa token `is_deleted`,
   * lempar Error yang memblok `dashboard create`. Registry kosong (project tanpa
   * tabel soft-delete) → no-op, sehingga perilaku project lama tidak berubah.
   *
   * Resolusi SQL di-delegasikan ke `_resolveSqlText` (sudah memverifikasi file:
   * reference pada `_validateWidgets`, jadi di titik ini file dijamin ada).
   */
  static _validateSoftDeleteGuard(payload, payloadDir) {
    const registry = softDeleteDashboardGuard.buildSoftDeleteRegistry(payloadDir);
    if (registry.size === 0) return;

    const violations = softDeleteDashboardGuard.checkDashboardViolations(
      payload,
      (sqlOrRef, widgetId, key) => this._resolveSqlText(
        widgetId,
        key === null ? 'query' : `queries.${key}`,
        sqlOrRef,
        payloadDir
      ),
      registry
    );

    if (violations.length > 0) {
      throw new Error(softDeleteDashboardGuard.formatViolationMessage(violations[0]));
    }
  }

  static _resolveSqlText(widgetId, queryLabel, sqlOrRef, payloadDir) {
    if (!PayloadProcessor.isFileReference(sqlOrRef)) {
      return sqlOrRef;
    }

    const relativePath = sqlOrRef.substring(5);
    const fullPath = path.join(payloadDir || '', relativePath);

    if (!fs.existsSync(fullPath)) {
      throw new Error(
        `Widget '${widgetId}' query '${queryLabel}' file not found: ${fullPath}`
      );
    }

    return fs.readFileSync(fullPath, 'utf8');
  }

  static _validateCacheBlock(cache) {
    if (cache === undefined) return;
    if (!cache || typeof cache !== 'object' || Array.isArray(cache)) {
      throw new Error("'cache' must be a plain object");
    }

    if (!('enabled' in cache) || typeof cache.enabled !== 'boolean') {
      throw new Error("'cache.enabled' is required and must be a boolean");
    }

    if ('ttl' in cache) {
      if (typeof cache.ttl !== 'number' || !Number.isFinite(cache.ttl) || cache.ttl < 0) {
        throw new Error("'cache.ttl' must be a non-negative number (seconds)");
      }
    }

    if ('invalidates' in cache) {
      if (!Array.isArray(cache.invalidates)) {
        throw new Error("'cache.invalidates' must be an array of table names");
      }
      for (const t of cache.invalidates) {
        if (typeof t !== 'string' || t.trim() === '') {
          throw new Error("'cache.invalidates' entries must be non-empty strings");
        }
      }
      const seen = new Set();
      for (const t of cache.invalidates) {
        const k = t.toLowerCase();
        if (seen.has(k)) {
          throw new Error(`'cache.invalidates' contains duplicate entry: '${t}'`);
        }
        seen.add(k);
      }
    }

    for (const k of Object.keys(cache)) {
      if (!ALLOWED_CACHE_FIELDS.includes(k)) {
        throw new Error(`'cache' contains unknown field: '${k}'. Allowed: enabled, ttl, invalidates`);
      }
    }
  }

  static _crossReferenceInvalidates(payload, payloadDir, options) {
    const projectName = options && options.projectName;
    if (typeof projectName !== 'string' || projectName.trim() === '') {
      throw new Error(
        "Cache invalidation cross-reference requires options.projectName (set cache.enabled=false to skip)"
      );
    }

    const metadataDir = (options && options.metadataDir)
      || path.join(process.cwd(), 'metadata');
    const warn = (options && typeof options.warn === 'function')
      ? options.warn
      : console.warn.bind(console);

    const sqlCandidates = new Set();
    for (const widget of payload.widgets) {
      if ('query' in widget) {
        const sqlText = this._resolveSqlText(widget.id, 'query', widget.query, payloadDir);
        for (const t of sqlTableExtractor.extractTables(sqlText)) {
          sqlCandidates.add(t);
        }
      } else if (widget.queries && typeof widget.queries === 'object') {
        for (const [key, value] of Object.entries(widget.queries)) {
          const sqlText = this._resolveSqlText(widget.id, `queries.${key}`, value, payloadDir);
          for (const t of sqlTableExtractor.extractTables(sqlText)) {
            sqlCandidates.add(t);
          }
        }
      }
    }

    const metadataPath = path.join(metadataDir, `${projectName}.json`);
    if (!fs.existsSync(metadataPath)) {
      throw new Error(
        `Cache invalidation cross-reference requires metadata/${projectName}.json (run CRUD generation first or set cache.enabled=false)`
      );
    }

    let metadata;
    try {
      metadata = JSON.parse(fs.readFileSync(metadataPath, 'utf8'));
    } catch (err) {
      throw new Error(
        `Failed to parse metadata/${projectName}.json: ${err.message}`
      );
    }

    const crudTables = new Set();
    const endpoints = (metadata && metadata.endpoints) || {};
    for (const [, ep] of Object.entries(endpoints)) {
      if (ep && ep.type === 'module' && typeof ep.tableName === 'string' && ep.tableName.trim() !== '') {
        crudTables.add(ep.tableName.toLowerCase());
      }
    }

    const expected = new Set();
    const unmatched = new Set();
    for (const t of sqlCandidates) {
      if (crudTables.has(t)) expected.add(t);
      else unmatched.add(t);
    }

    const declared = new Set(payload.cache.invalidates.map(t => t.toLowerCase()));

    const missing = new Set();
    for (const t of expected) if (!declared.has(t)) missing.add(t);

    const extra = new Set();
    for (const t of declared) if (!expected.has(t)) extra.add(t);

    if (missing.size > 0) {
      throw new Error(
        `cache.invalidates is missing tables detected in widget SQL: ${[...missing].sort().join(', ')}. Add them to cache.invalidates.`
      );
    }

    if (extra.size > 0) {
      throw new Error(
        `cache.invalidates contains tables not detected in any widget SQL: ${[...extra].sort().join(', ')}. Remove them or fix the typo.`
      );
    }

    if (unmatched.size > 0) {
      warn(
        `Tables referenced in widget SQL but not registered as CRUD endpoints in metadata: ${[...unmatched].sort().join(', ')}. Cascade invalidation will not trigger for these (might be views, CTE aliases, or cross-project tables).`
      );
    }
  }
}

module.exports = DashboardValidator;
