Source: lib/config.js

import fs from 'fs';
import path from 'path';
import { logger } from './core/runtime/AvenxLogger.js';
import { setDebugReactivity } from './core/reactive/watcher.js';

/**
 * Find the project root directory by scanning upwards from startDir.
 * Looks for package.json or index.html.
 * @param {string} startDir
 * @returns {string}
 */
function findProjectRoot(startDir = process.cwd()) {
  let currentDir = startDir;

  while (true) {
    const packageJsonPath = path.join(currentDir, 'package.json');
    const indexHtmlPath = path.join(currentDir, 'index.html');

    if (fs.existsSync(packageJsonPath)) {
      try {
        const pkg = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
        if (pkg && (pkg.name !== 'avenx-core' || process.env.NODE_ENV === 'test')) {
          return currentDir;
        }
      } catch {
        return currentDir;
      }
    } else if (fs.existsSync(indexHtmlPath)) {
      return currentDir;
    }

    const parentDir = path.dirname(currentDir);

    if (parentDir === currentDir) {
      break;
    }

    currentDir = parentDir;
  }

  return startDir;
}

/**
 * Computes the Levenshtein distance between two strings.
 * @param {string} a
 * @param {string} b
 * @returns {number}
 */
export function levenshtein(a, b) {
  const tmp = [];
  let i, j;
  for (i = 0; i <= a.length; i++) {
    tmp.push([i]);
  }
  for (j = 0; j <= b.length; j++) {
    tmp[0][j] = j;
  }
  for (i = 1; i <= a.length; i++) {
    for (j = 1; j <= b.length; j++) {
      tmp[i][j] = Math.min(
        tmp[i - 1][j] + 1,
        tmp[i][j - 1] + 1,
        tmp[i - 1][j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1)
      );
    }
  }
  return tmp[a.length][b.length];
}

/**
 * Returns the closest match from allowedKeys based on Levenshtein distance,
 * if it is within a threshold.
 * @param {string} key
 * @param {string[]} allowedKeys
 * @returns {string|null}
 */
export function getClosestKey(key, allowedKeys) {
  let closest = null;
  let minDistance = Infinity;
  for (const allowed of allowedKeys) {
    const dist = levenshtein(key.toLowerCase(), allowed.toLowerCase());
    if (dist < minDistance) {
      minDistance = dist;
      closest = allowed;
    }
  }
  if (minDistance <= 3) {
    return closest;
  }
  return null;
}

/**
 * The configuration keys avenx.config.json accepts.
 *
 * Exported so that every consumer validates against one list. `avenx doctor`
 * kept a second copy and drifted from this one, reporting valid options as
 * unrecognised.
 * @type {{topLevel: string[], server: string[], style: string[], debug: string[], logging: string[], hooks: string[], trace: string[], rewind: string[]}}
 */
export const CONFIG_SCHEMA = {
  topLevel: [
    'srcDir',
    'distDir',
    'templatesDir',
    'mode',
    'server',
    'style',
    'debug',
    'outputName',
    'logging',
    'voidTags',
    'templateGlobals',
    'warnings',
    'treeShakeComponents',
    'preprocessors',
    'alias',
    'hooks',
    'trace',
    'rewind',
    'expressionCacheCapacity',
    'sourceMap',
    'sourcesContent',
  ],
  server: ['port', 'host', 'liveReload', 'headers'],
  style: ['preprocessor', 'sourceMap', 'inlineSourceMap', 'dev'],
  debug: ['debugReactivity'],
  trace: ['redact', 'maxNodes'],
  rewind: ['onConflict', 'maxSnapshotItems'],
  logging: ['level', 'silent'],
  hooks: ['prebuild', 'postbuild'],
};

/**
 * Recursively traverses string values in an object/array/value and replaces
 * $VAR_NAME or ${VAR_NAME} placeholders with process.env.VAR_NAME values.
 * @param {*} val
 * @returns {*}
 */
export function interpolateEnv(val) {
  if (typeof val === 'string') {
    return val.replace(/\$\{?([A-Z0-9_]+)\}?/gi, (_, key) => (process.env[key] !== undefined ? process.env[key] : ''));
  }
  if (Array.isArray(val)) {
    return val.map(interpolateEnv);
  }
  if (val && typeof val === 'object') {
    const result = {};
    for (const [k, v] of Object.entries(val)) {
      result[k] = interpolateEnv(v);
    }
    return result;
  }
  return val;
}

/**
 * Load the Avenx configuration from avenx.config.json file.
 * @param {string} [baseDir] - The base directory of the project.
 */
function loadConfig(baseDir) {
  const defaults = {
    srcDir: 'src',
    distDir: 'dist',
    templatesDir: '.avenxtemplates',
    alias: {},
    hooks: {},
    expressionCacheCapacity: 1000,
    server: {
      port: 3000,
      host: 'localhost',
      liveReload: true,
      headers: {},
    },
    style: {
      preprocessor: 'none',
    },
    debug: {
      debugReactivity: false,
    },
    voidTags: [],
    templateGlobals: [],
    warnings: {},
    preprocessors: {},
    trace: {
      redact: [],
      maxNodes: 0,
    },
    rewind: {
      onConflict: 'safe',
      maxSnapshotItems: 10000,
    },
  };

  const rootDir = baseDir || findProjectRoot(process.cwd());
  const configPath = path.join(rootDir, 'avenx.config.json');

  if (!fs.existsSync(configPath)) {
    setDebugReactivity(defaults.debug.debugReactivity);
    return defaults;
  }

  try {
    const userConfigRaw = JSON.parse(fs.readFileSync(configPath, 'utf8'));
    const userConfig = interpolateEnv(userConfigRaw);

    // templateGlobals holds identifier names, not values, and JavaScript
    // identifiers may start with `$` — which env interpolation would read as a
    // placeholder and blank out, making a name like `$i18n` impossible to
    // declare. Nothing in this option could ever want to be an environment
    // reference, so it is taken verbatim from the file.
    if (userConfigRaw && Array.isArray(userConfigRaw.templateGlobals)) {
      userConfig.templateGlobals = userConfigRaw.templateGlobals;
    }

    if (userConfig && typeof userConfig === 'object' && !Array.isArray(userConfig)) {
      if (
        userConfig.server &&
        typeof userConfig.server.port === 'string' &&
        /^\d+$/.test(userConfig.server.port) &&
        typeof userConfigRaw.server?.port === 'string' &&
        userConfigRaw.server.port.includes('$')
      ) {
        userConfig.server.port = Number(userConfig.server.port);
      }
      const allowedTopLevel = CONFIG_SCHEMA.topLevel;
      for (const key of Object.keys(userConfig)) {
        if (!allowedTopLevel.includes(key)) {
          const closest = getClosestKey(key, allowedTopLevel);
          const suggestion = closest ? `. Did you mean "${closest}"?` : '.';
          logger.warn(`Unknown configuration option "${key}" in avenx.config.json${suggestion} Supported top-level options are: ${allowedTopLevel.join(', ')}.`);
        } else {
          if (key === 'server' && userConfig.server && typeof userConfig.server === 'object' && !Array.isArray(userConfig.server)) {
            const allowedServerKeys = CONFIG_SCHEMA.server;
            for (const subKey of Object.keys(userConfig.server)) {
              if (!allowedServerKeys.includes(subKey)) {
                const closest = getClosestKey(subKey, allowedServerKeys);
                const suggestion = closest ? `. Did you mean "server.${closest}"?` : '.';
                logger.warn(`Unknown configuration option "server.${subKey}" in avenx.config.json${suggestion} Supported options for "server" are: ${allowedServerKeys.join(', ')}.`);
              }
            }
          }
          if (key === 'style' && userConfig.style && typeof userConfig.style === 'object' && !Array.isArray(userConfig.style)) {
            const allowedStyleKeys = CONFIG_SCHEMA.style;
            for (const subKey of Object.keys(userConfig.style)) {
              if (!allowedStyleKeys.includes(subKey)) {
                const closest = getClosestKey(subKey, allowedStyleKeys);
                const suggestion = closest ? `. Did you mean "style.${closest}"?` : '.';
                logger.warn(`Unknown configuration option "style.${subKey}" in avenx.config.json${suggestion} Supported options for "style" are: ${allowedStyleKeys.join(', ')}.`);
              }
            }
          }
          if (key === 'trace' && userConfig.trace && typeof userConfig.trace === 'object' && !Array.isArray(userConfig.trace)) {
            const allowedTraceKeys = CONFIG_SCHEMA.trace;
            for (const subKey of Object.keys(userConfig.trace)) {
              if (!allowedTraceKeys.includes(subKey)) {
                const closest = getClosestKey(subKey, allowedTraceKeys);
                const suggestion = closest ? `. Did you mean "trace.${closest}"?` : '.';
                logger.warn(`Unknown configuration option "trace.${subKey}" in avenx.config.json${suggestion} Supported options for "trace" are: ${allowedTraceKeys.join(', ')}.`);
              }
            }
            if (userConfig.trace.redact !== undefined && !Array.isArray(userConfig.trace.redact)) {
              logger.warn('Configuration option "trace.redact" must be an array of property-path patterns. Ignoring it.');
              delete userConfig.trace.redact;
            }
          }
          if (key === 'rewind' && userConfig.rewind && typeof userConfig.rewind === 'object' && !Array.isArray(userConfig.rewind)) {
            const allowedRewindKeys = CONFIG_SCHEMA.rewind;
            for (const subKey of Object.keys(userConfig.rewind)) {
              if (!allowedRewindKeys.includes(subKey)) {
                const closest = getClosestKey(subKey, allowedRewindKeys);
                const suggestion = closest ? `. Did you mean "rewind.${closest}"?` : '.';
                logger.warn(`Unknown configuration option "rewind.${subKey}" in avenx.config.json${suggestion} Supported options for "rewind" are: ${allowedRewindKeys.join(', ')}.`);
              }
            }
          }
          if (key === 'debug' && userConfig.debug && typeof userConfig.debug === 'object' && !Array.isArray(userConfig.debug)) {
            const allowedDebugKeys = CONFIG_SCHEMA.debug;
            for (const subKey of Object.keys(userConfig.debug)) {
              if (!allowedDebugKeys.includes(subKey)) {
                const closest = getClosestKey(subKey, allowedDebugKeys);
                const suggestion = closest ? `. Did you mean "debug.${closest}"?` : '.';
                logger.warn(`Unknown configuration option "debug.${subKey}" in avenx.config.json${suggestion} Supported options for "debug" are: ${allowedDebugKeys.join(', ')}.`);
              }
            }
          }
          if (key === 'logging' && userConfig.logging && typeof userConfig.logging === 'object' && !Array.isArray(userConfig.logging)) {
            const allowedLoggingKeys = CONFIG_SCHEMA.logging;
            for (const subKey of Object.keys(userConfig.logging)) {
              if (!allowedLoggingKeys.includes(subKey)) {
                const closest = getClosestKey(subKey, allowedLoggingKeys);
                const suggestion = closest ? `. Did you mean "logging.${closest}"?` : '.';
                logger.warn(`Unknown configuration option "logging.${subKey}" in avenx.config.json${suggestion} Supported options for "logging" are: ${allowedLoggingKeys.join(', ')}.`);
              }
            }
          }
          if (key === 'hooks' && userConfig.hooks && typeof userConfig.hooks === 'object' && !Array.isArray(userConfig.hooks)) {
            const allowedHooksKeys = CONFIG_SCHEMA.hooks;
            for (const subKey of Object.keys(userConfig.hooks)) {
              if (!allowedHooksKeys.includes(subKey)) {
                const closest = getClosestKey(subKey, allowedHooksKeys);
                const suggestion = closest ? `. Did you mean "hooks.${closest}"?` : '.';
                logger.warn(`Unknown configuration option "hooks.${subKey}" in avenx.config.json${suggestion} Supported options for "hooks" are: ${allowedHooksKeys.join(', ')}.`);
              }
            }
          }
        }
      }
    }

    if (userConfig.warnings !== undefined) {
      if (typeof userConfig.warnings !== 'object' || userConfig.warnings === null || Array.isArray(userConfig.warnings)) {
        throw new Error('warnings must be an object');
      }
    }

    if (userConfig.alias !== undefined) {
      if (typeof userConfig.alias !== 'object' || userConfig.alias === null || Array.isArray(userConfig.alias)) {
        throw new Error('alias must be an object');
      }
    }

    if (userConfig.hooks !== undefined) {
      if (typeof userConfig.hooks !== 'object' || userConfig.hooks === null || Array.isArray(userConfig.hooks)) {
        throw new Error('hooks must be an object');
      }
      if (userConfig.hooks.prebuild !== undefined && typeof userConfig.hooks.prebuild !== 'string') {
        throw new Error('hooks.prebuild must be a string');
      }
      if (userConfig.hooks.postbuild !== undefined && typeof userConfig.hooks.postbuild !== 'string') {
        throw new Error('hooks.postbuild must be a string');
      }
    }

    if (userConfig.preprocessors !== undefined) {
      if (
        (typeof userConfig.preprocessors !== 'object' || userConfig.preprocessors === null || Array.isArray(userConfig.preprocessors)) &&
        typeof userConfig.preprocessors !== 'function'
      ) {
        throw new Error('preprocessors must be an object or function');
      }
    }

    let preprocessors = defaults.preprocessors;
    if (typeof userConfig.preprocessors === 'function') {
      preprocessors = userConfig.preprocessors;
    } else if (userConfig.preprocessors) {
      preprocessors = { ...defaults.preprocessors, ...userConfig.preprocessors };
    }

    const config = {
      ...defaults,
      ...userConfig,
      server: {
        ...defaults.server,
        ...(userConfig.server || {}),
      },
      style: {
        ...defaults.style,
        ...(userConfig.style || {}),
      },
      debug: {
        ...defaults.debug,
        ...(userConfig.debug || {}),
      },
      warnings: {
        ...defaults.warnings,
        ...(userConfig.warnings || {}),
      },
      hooks: {
        ...defaults.hooks,
        ...(userConfig.hooks || {}),
      },
      rewind: {
        ...defaults.rewind,
        ...(userConfig.rewind || {}),
      },
      preprocessors,
    };

    if (typeof config.debug.debugReactivity !== 'boolean') {
      throw new Error('debug.debugReactivity must be a boolean');
    }

    setDebugReactivity(config.debug.debugReactivity);

    if (typeof config.srcDir !== 'string' || config.srcDir.trim() === '') {
      throw new Error('srcDir must be a non-empty string');
    }
    if (path.isAbsolute(config.srcDir)) {
      throw new Error('srcDir must be a relative path');
    }

    if (typeof config.distDir !== 'string' || config.distDir.trim() === '') {
      throw new Error('distDir must be a non-empty string');
    }
    if (path.isAbsolute(config.distDir)) {
      throw new Error('distDir must be a relative path');
    }

    if (typeof config.templatesDir !== 'string' || config.templatesDir.trim() === '') {
      throw new Error('templatesDir must be a non-empty string');
    }
    if (path.isAbsolute(config.templatesDir)) {
      throw new Error('templatesDir must be a relative path');
    }

    if (!Array.isArray(config.voidTags) || config.voidTags.some((tag) => typeof tag !== 'string' || tag.trim() === '')) {
      throw new Error('voidTags must be an array of non-empty strings');
    }

    // Identifiers a plugin publishes into every component's template scope —
    // `app.mixin()` members, most often. Template validation has no way to see
    // those: they are contributed at runtime, by code the compiler never reads.
    // Declaring them here is how a project says "this name is provided", so a
    // template using it is checked rather than either warned about wrongly or
    // exempted by switching the whole check off.
    if (
      !Array.isArray(config.templateGlobals) ||
      config.templateGlobals.some((name) => typeof name !== 'string' || name.trim() === '')
    ) {
      throw new Error('templateGlobals must be an array of non-empty identifier names');
    }

    const allowedSeverities = ['off', 'ignore', 'warn', 'warning', 'error'];
    for (const [code, severity] of Object.entries(config.warnings)) {
      if (typeof severity !== 'string') {
        throw new Error(`warnings.${code} must be a string severity ("off", "ignore", "warn", "warning", "error")`);
      }
      const normSeverity = severity.trim().toLowerCase();
      if (!allowedSeverities.includes(normSeverity)) {
        throw new Error(`Invalid severity "${severity}" for warning "${code}". Allowed values: "off", "ignore", "warn", "warning", "error"`);
      }
    }

    const allowedConflictPolicies = ['safe', 'force', 'abort'];
    if (!allowedConflictPolicies.includes(config.rewind.onConflict)) {
      throw new Error(
        `Invalid rewind.onConflict "${config.rewind.onConflict}". Allowed values: ${allowedConflictPolicies.map((p) => `"${p}"`).join(', ')}`,
      );
    }
    if (
      typeof config.rewind.maxSnapshotItems !== 'number' ||
      !Number.isInteger(config.rewind.maxSnapshotItems) ||
      config.rewind.maxSnapshotItems <= 0
    ) {
      throw new Error('rewind.maxSnapshotItems must be a positive integer');
    }

    if (typeof config.server.port !== 'number' || config.server.port < 0 || config.server.port > 65535) {
      throw new Error('server.port must be a valid port number (0-65535)');
    }

    if (typeof config.server.host !== 'string' || config.server.host.trim() === '') {
      throw new Error('server.host must be a non-empty string');
    }

    if (typeof config.server.liveReload !== 'boolean') {
      throw new Error('server.liveReload must be a boolean');
    }

    if (
      typeof config.server.headers !== 'object' ||
      config.server.headers === null ||
      Array.isArray(config.server.headers)
    ) {
      throw new Error('server.headers must be an object');
    }
    if (
      config.expressionCacheCapacity !== undefined &&
      (typeof config.expressionCacheCapacity !== 'number' ||
        config.expressionCacheCapacity <= 0 ||
        !Number.isInteger(config.expressionCacheCapacity))
    ) {
      throw new Error('expressionCacheCapacity must be a positive integer');
    }

    return config;
  } catch (err) {
    logger.error(`Invalid avenx.config.json: ${err.message}`);
    if (process.env.NODE_ENV === 'test') {
      throw err;
    }
    process.exit(1);
  }
}

loadConfig.findProjectRoot = findProjectRoot;
loadConfig.interpolateEnv = interpolateEnv;

export default loadConfig;

/**
 * Resolves a path alias (e.g., "@/components/Header") to its absolute or root-relative path.
 * @param {string} importPath - The import string to resolve.
 * @param {object} [config] - The parsed Avenx config object.
 * @param {string} [rootDir] - The root project directory.
 * @returns {string} The resolved file path or original string if no alias matched.
 */
export function resolvePathAlias(importPath, config = {}, rootDir = process.cwd()) {
  if (!importPath || typeof importPath !== 'string') return importPath;
  const safeConfig = config || {};
  const aliases = safeConfig.alias || {};

  for (const [alias, target] of Object.entries(aliases)) {
    if (importPath === alias || importPath.startsWith(alias + '/')) {
      const relativePart = importPath.slice(alias.length);
      const targetPath = path.isAbsolute(target) ? target : path.join(rootDir, target);
      return path.normalize(path.join(targetPath, relativePart));
    }
  }

  return importPath;
}