Source: lib/utils.js

// Copyright (c) 2025, The Robot Web Tools Contributors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
//     http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

const fs = require('fs');
const fsPromises = require('fs/promises');
const path = require('path');

/**
 * Ensure directory exists, create recursively if needed (async)
 * Replaces: fse.ensureDir() / fse.mkdirs()
 * @param {string} dirPath - Path to directory
 * @returns {Promise<void>}
 */
async function ensureDir(dirPath) {
  try {
    await fsPromises.mkdir(dirPath, { recursive: true });
  } catch (err) {
    // Ignore if directory already exists
    if (err.code !== 'EEXIST') throw err;
  }
}

/**
 * Ensure directory exists, create recursively if needed (sync)
 * Replaces: fse.mkdirSync()
 * @param {string} dirPath - Path to directory
 */
function ensureDirSync(dirPath) {
  try {
    fs.mkdirSync(dirPath, { recursive: true });
  } catch (err) {
    // Ignore if directory already exists
    if (err.code !== 'EEXIST') throw err;
  }
}

/**
 * Check if path exists (async)
 * Replaces: fse.exists()
 * @param {string} filePath - Path to check
 * @returns {Promise<boolean>}
 */
async function pathExists(filePath) {
  try {
    await fsPromises.access(filePath);
    return true;
  } catch {
    return false;
  }
}

/**
 * Empty a directory (remove all contents but keep the directory)
 * Replaces: fse.emptyDir()
 * @param {string} dirPath - Path to directory
 * @returns {Promise<void>}
 */
async function emptyDir(dirPath) {
  try {
    const files = await fsPromises.readdir(dirPath);
    await Promise.all(
      files.map((file) =>
        fsPromises.rm(path.join(dirPath, file), {
          recursive: true,
          force: true,
        })
      )
    );
  } catch (err) {
    // Ignore if directory doesn't exist
    if (err.code !== 'ENOENT') throw err;
  }
}

/**
 * Copy file or directory recursively
 * Replaces: fse.copy()
 * @param {string} src - Source path
 * @param {string} dest - Destination path
 * @param {object} options - Copy options
 * @returns {Promise<void>}
 */
async function copy(src, dest, options = {}) {
  const opts = {
    recursive: true,
    force: options.overwrite !== false,
    ...options,
  };
  await fsPromises.cp(src, dest, opts);
}

/**
 * Read and parse JSON file synchronously
 * Replaces: fse.readJsonSync()
 * @param {string} filePath - Path to JSON file
 * @param {object} options - Read options
 * @returns {any} Parsed JSON data
 */
function readJsonSync(filePath, options = {}) {
  const content = fs.readFileSync(filePath, options.encoding || 'utf8');
  return JSON.parse(content);
}

/**
 * Remove file or directory (async)
 * Replaces: fse.remove()
 * @param {string} filePath - Path to remove
 * @returns {Promise<void>}
 */
async function remove(filePath) {
  try {
    await fsPromises.rm(filePath, { recursive: true, force: true });
  } catch (err) {
    // Ignore if path doesn't exist
    if (err.code !== 'ENOENT') throw err;
  }
}

/**
 * Remove file or directory (sync)
 * Replaces: fse.removeSync()
 * @param {string} filePath - Path to remove
 */
function removeSync(filePath) {
  try {
    fs.rmSync(filePath, { recursive: true, force: true });
  } catch (err) {
    // Ignore if path doesn't exist
    if (err.code !== 'ENOENT') throw err;
  }
}

/**
 * Write file with content (async)
 * Replaces: fse.writeFile()
 * @param {string} filePath - Path to file
 * @param {string|Buffer} data - Content to write
 * @param {object} options - Write options
 * @returns {Promise<void>}
 */
async function writeFile(filePath, data, options = {}) {
  await fsPromises.writeFile(filePath, data, options);
}

/**
 * Create directory (async)
 * Replaces: fse.mkdir()
 * @param {string} dirPath - Path to directory
 * @param {object} options - mkdir options
 * @returns {Promise<void>}
 */
async function mkdir(dirPath, options = {}) {
  await fsPromises.mkdir(dirPath, options);
}

/**
 * Detect Ubuntu codename from /etc/os-release
 * @returns {string|null} Ubuntu codename (e.g., 'noble', 'jammy') or null if not detectable
 */
function detectUbuntuCodename() {
  if (process.platform !== 'linux') {
    return null;
  }

  try {
    const osRelease = fs.readFileSync('/etc/os-release', 'utf8');
    const match = osRelease.match(/^VERSION_CODENAME=(.*)$/m);
    return match ? match[1].trim() : null;
  } catch {
    return null;
  }
}

/**
 * Check if two numbers are equal within a given tolerance.
 *
 * This function compares two numbers using both relative and absolute tolerance,
 * matching the behavior of the 'is-close' npm package.
 *
 * The comparison uses the formula:
 *   abs(a - b) <= max(rtol * max(abs(a), abs(b)), atol)
 *
 * Implementation checks:
 *   1. Absolute tolerance: abs(a - b) <= atol
 *   2. Relative tolerance: abs(a - b) / max(abs(a), abs(b)) <= rtol
 *
 * @param {number} a - The first number to compare
 * @param {number} b - The second number to compare
 * @param {number} [rtol=1e-9] - The relative tolerance parameter (default: 1e-9)
 * @param {number} [atol=0.0] - The absolute tolerance parameter (default: 0.0)
 * @returns {boolean} True if the numbers are close within the tolerance
 *
 * @example
 * isClose(1.0, 1.0) // true - exact equality
 * isClose(1.0, 1.1, 0.01) // false - relative diff: 0.1/1.1 ≈ 0.091 > 0.01
 * isClose(10, 10.00001, 1e-6) // true - relative diff: 0.00001/10 = 1e-6 <= 1e-6
 * isClose(0, 0.05, 0, 0.1) // true - absolute diff: 0.05 <= 0.1 (atol)
 */
function isClose(a, b, rtol = 1e-9, atol = 0.0) {
  // Handle exact equality
  if (a === b) {
    return true;
  }

  // Handle non-finite numbers
  if (!Number.isFinite(a) || !Number.isFinite(b)) {
    return false;
  }

  const absDiff = Math.abs(a - b);

  // Check absolute tolerance first (optimization)
  if (atol >= absDiff) {
    return true;
  }

  // Check relative tolerance
  const relativeScaler = Math.max(Math.abs(a), Math.abs(b));

  // Handle division by zero when both values are zero or very close to zero
  if (relativeScaler === 0) {
    return true; // Both are zero, already handled by absolute tolerance
  }

  const relativeDiff = absDiff / relativeScaler;

  return rtol >= relativeDiff;
}

/**
 * Compare two semantic version strings.
 *
 * Supports version strings in the format: x.y.z or x.y.z.w
 * where x, y, z, w are integers.
 *
 * @param {string} version1 - First version string (e.g., '1.2.3')
 * @param {string} version2 - Second version string (e.g., '1.2.4')
 * @param {string} operator - Comparison operator: '<', '<=', '>', '>=', '==', '!='
 * @returns {boolean} Result of the comparison
 *
 * @example
 * compareVersions('1.2.3', '1.2.4', '<')   // true
 * compareVersions('2.0.0', '1.9.9', '>')   // true
 * compareVersions('1.2.3', '1.2.3', '==')  // true
 * compareVersions('1.2.3', '1.2.3', '>=')  // true
 */
function compareVersions(version1, version2, operator) {
  // Parse version strings into arrays of integers
  const v1Parts = version1.split('.').map((part) => parseInt(part, 10));
  const v2Parts = version2.split('.').map((part) => parseInt(part, 10));

  // Pad arrays to same length with zeros
  const maxLength = Math.max(v1Parts.length, v2Parts.length);
  while (v1Parts.length < maxLength) v1Parts.push(0);
  while (v2Parts.length < maxLength) v2Parts.push(0);

  // Compare each part
  let cmp = 0;
  for (let i = 0; i < maxLength; i++) {
    if (v1Parts[i] > v2Parts[i]) {
      cmp = 1;
      break;
    } else if (v1Parts[i] < v2Parts[i]) {
      cmp = -1;
      break;
    }
  }

  // Apply operator
  switch (operator) {
    case '<':
      return cmp < 0;
    case '<=':
      return cmp <= 0;
    case '>':
      return cmp > 0;
    case '>=':
      return cmp >= 0;
    case '==':
    case '===':
      return cmp === 0;
    case '!=':
    case '!==':
      return cmp !== 0;
    default:
      throw new Error(`Invalid operator: ${operator}`);
  }
}

module.exports = {
  // General utilities
  detectUbuntuCodename,
  isClose,

  // File system utilities (async)
  ensureDir,
  mkdirs: ensureDir, // Alias for fs-extra compatibility
  exists: pathExists, // Renamed to avoid conflict with deprecated fs.exists
  pathExists,
  emptyDir,
  copy,
  remove,
  writeFile,
  mkdir,

  // File system utilities (sync)
  ensureDirSync,
  mkdirSync: ensureDirSync, // Alias for fs-extra compatibility
  removeSync,
  readJsonSync,

  compareVersions,
};