Source: bin/utils.js

import fs from 'fs';
import path from 'path';
import readline from 'node:readline';
import { execSync } from 'child_process';
import { red, yellow, gray } from './colors.js';

/**
 * Helper to parse input names into PascalCase and kebab-case.
 * Supports camelCase, kebab-case, snake_case, and PascalCase.
 * @param {string} inputName - The input name from CLI.
 * @returns {{capitalizedName: string, folderFileName: string}}
 */
export function parseName(inputName) {
  let processedName = inputName;
  if (inputName === inputName.toUpperCase() && inputName !== inputName.toLowerCase()) {
    processedName = inputName.toLowerCase();
  }
  const parts = processedName.split(/(?<=[a-z0-9])(?=[A-Z])|[-_]/).filter(Boolean);
  const capitalizedName = parts.map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join('');
  const folderFileName = parts.map((part) => part.toLowerCase()).join('-');
  return { capitalizedName, folderFileName };
}

/**
 * Checks if git status is clean or prompts user if there are unstaged changes.
 * @returns {boolean|Promise<boolean>}
 */
export function checkGitStatus() {
  try {
    const output = execSync('git status --porcelain', {
      encoding: 'utf8',
    });

    if (!output.trim()) {
      return true;
    }

    console.warn(yellow('⚠️ You have unstaged changes in your repository.'));

    if (!process.stdin.isTTY || !process.stdout.isTTY) {
      return true;
    }

    const rl = readline.createInterface({
      input: process.stdin,
      output: process.stdout,
    });

    return new Promise((resolve) => {
      rl.question('Do you want to proceed? (y/N) ', (answer) => {
        rl.close();

        if (answer.trim().toLowerCase() === 'y') {
          resolve(true);
        } else {
          console.log(gray('Operation cancelled.'));
          resolve(false);
        }
      });
    });
  } catch {
    return true;
  }
}

/**
 * Prompts the user with a question on the command line.
 * @param {string} query - The question query.
 * @param {string} [defaultValue] - The default response.
 * @param {function(string): (boolean|string)} [validator] - Optional function validating input.
 * @returns {Promise<string>}
 */
export function promptQuestion(query, defaultValue, validator = null) {
  const rl = readline.createInterface({
    input: process.stdin,
    output: process.stdout,
  });

  return new Promise((resolve) => {
    const ask = () => {
      rl.question(query, (answer) => {
        let trimmed = answer.trim();
        if (trimmed === '' && defaultValue !== undefined) {
          trimmed = defaultValue;
        }
        if (validator) {
          const valid = validator(trimmed);
          if (valid === true) {
            rl.close();
            resolve(trimmed);
          } else {
            console.log(red(`❌ ${valid}`));
            ask();
          }
        } else {
          rl.close();
          resolve(trimmed);
        }
      });
    };
    ask();
  });
}

/**
 * Reads a template, checking custom template overrides in templatesDir and templates/ folder first.
 * @param {string} baseDir
 * @param {object} config
 * @param {string} frameworkDir
 * @param {string} subfolder
 * @param {string} filename
 * @param {string|null} [templateName]
 * @returns {string}
 */
export function readTemplate(baseDir, config, frameworkDir, subfolder, filename, templateName = null) {
  const dirs = [config?.templatesDir || '.avenxtemplates', 'templates'].filter(
    (dir, idx, self) => dir && self.indexOf(dir) === idx
  );

  if (templateName) {
    const ext = path.extname(filename);
    const basename = filename.replace(/\.template$/, '');

    for (const dir of dirs) {
      const candidatePaths = [
        path.join(baseDir, dir, subfolder, templateName, filename),
        path.join(baseDir, dir, subfolder, `${templateName}.${filename}`),
        path.join(baseDir, dir, subfolder, `${basename}.${templateName}.template`),
        path.join(baseDir, dir, templateName, filename),
        path.join(baseDir, dir, `${templateName}.${filename}`),
        path.join(baseDir, dir, `${templateName}.${subfolder}${ext}.template`),
        path.join(baseDir, dir, `${templateName}${ext}.template`),
      ];

      for (const candidatePath of candidatePaths) {
        if (fs.existsSync(candidatePath)) {
          return fs.readFileSync(candidatePath, 'utf-8');
        }
      }
    }
  }

  for (const dir of dirs) {
    const localStructuredPath = path.join(baseDir, dir, subfolder, filename);
    if (fs.existsSync(localStructuredPath)) {
      return fs.readFileSync(localStructuredPath, 'utf-8');
    }

    const localFlatPath = path.join(baseDir, dir, filename);
    if (fs.existsSync(localFlatPath)) {
      return fs.readFileSync(localFlatPath, 'utf-8');
    }
  }

  const globalPath = path.join(frameworkDir, 'templates', subfolder, filename);
  return fs.readFileSync(globalPath, 'utf-8');
}

/**
 * Reports a CLI error and marks the process as failed.
 * @param {string} message
 */
export function fail(message) {
  console.error(red(`❌ Error: ${message}`));
  process.exitCode = 1;
}

/**
 * Stops generation if any target path already exists.
 * @param {string} baseDir
 * @param {string} type
 * @param {string} name
 * @param {string[]} targetPaths
 * @returns {boolean}
 */
export function abortIfGeneratedPathExists(baseDir, type, name, targetPaths) {
  const existingPath = targetPaths.find((targetPath) => fs.existsSync(targetPath));
  if (!existingPath) {
    return false;
  }

  fail(
    `${type} '${name}' already exists at ${path.relative(baseDir, existingPath)}. ` +
      'Remove the existing file or choose a different name.',
  );
  return true;
}

/**
 * Cross-platform directory watcher with recursive support fallback.
 * Node 18 on Linux does not support fs.watch(dir, { recursive: true }).
 * @param {string} dirPath - Directory to watch.
 * @param {Function} callback - Event callback (eventType, filename).
 * @returns {{close: Function}|object} FSWatcher or compatible watcher object with close() method.
 */
export function watchDirectory(dirPath, callback) {
  try {
    return fs.watch(dirPath, { recursive: true }, callback);
  } catch (err) {
    if (err && err.code === 'ERR_FEATURE_UNAVAILABLE_ON_PLATFORM') {
      return createRecursiveWatcherFallback(dirPath, callback);
    }
    throw err;
  }
}

/**
 * Fallback recursive watcher for platforms/Node versions lacking native recursive watch.
 * Walks directory tree and registers individual fs.watch instances.
 * @param {string} rootPath - Root directory to watch.
 * @param {Function} callback - Event callback.
 * @returns {{close: Function}}
 */
function createRecursiveWatcherFallback(rootPath, callback) {
  const watchers = new Map();

  function scanAndWatch(currentDir) {
    if (!fs.existsSync(currentDir)) return;

    if (!watchers.has(currentDir)) {
      try {
        const watcher = fs.watch(currentDir, (eventType, filename) => {
          const relativeDir = path.relative(rootPath, currentDir);
          const relativeFile = filename
            ? (relativeDir ? path.join(relativeDir, filename) : filename).replace(/\\/g, '/')
            : (relativeDir ? relativeDir.replace(/\\/g, '/') : '');

          const fullPath = filename ? path.join(currentDir, filename) : currentDir;
          try {
            if (fs.existsSync(fullPath) && fs.statSync(fullPath).isDirectory()) {
              scanAndWatch(fullPath);
            }
          } catch {
            // Ignore stat errors on deleted / inaccessible entries
          }

          callback(eventType, relativeFile);
        });

        watchers.set(currentDir, watcher);
      } catch {
        // Ignore watch errors on transient dirs or permission errors
      }
    }

    try {
      const entries = fs.readdirSync(currentDir, { withFileTypes: true });
      for (const entry of entries) {
        if (entry.isDirectory()) {
          scanAndWatch(path.join(currentDir, entry.name));
        }
      }
    } catch {
      // Ignore read errors on inaccessible dirs
    }
  }

  scanAndWatch(rootPath);

  return {
    close() {
      for (const watcher of watchers.values()) {
        try {
          watcher.close();
        } catch {
          // Ignore close errors
        }
      }
      watchers.clear();
    },
  };
}