/**
 * @license
 * Copyright 2025 Google LLC
 * SPDX-License-Identifier: Apache-2.0
 */

import path from 'node:path';
import os from 'node:os';
import * as crypto from 'node:crypto';
import * as fs from 'node:fs';
import { fileURLToPath } from 'node:url';

export const GEMINI_DIR = '.gemini';
export const GOOGLE_ACCOUNTS_FILENAME = 'google_accounts.json';
export const TRUSTED_FOLDERS_FILENAME = 'trustedFolders.json';

/**
 * Returns the home directory.
 * If GEMINI_CLI_HOME environment variable is set, it returns its value.
 * Otherwise, it returns the user's home directory.
 */
export function homedir(): string {
  const envHome = process.env['GEMINI_CLI_HOME'];
  if (envHome) {
    return envHome;
  }
  return os.homedir();
}

/**
 * Returns the operating system's default directory for temporary files.
 */
export function tmpdir(): string {
  return os.tmpdir();
}

/**
 * Replaces the home directory with a tilde.
 * @param path - The path to tildeify.
 * @returns The tildeified path.
 */
export function tildeifyPath(path: string): string {
  const homeDir = homedir();
  if (path.startsWith(homeDir)) {
    return path.replace(homeDir, '~');
  }
  return path;
}

/**
 * Shortens a path string if it exceeds maxLen, prioritizing the start and end segments.
 * Example: /path/to/a/very/long/file.txt -> /path/.../long/file.txt
 */
export function shortenPath(filePath: string, maxLen: number = 35): string {
  if (filePath.length <= maxLen) {
    return filePath;
  }

  const simpleTruncate = () => {
    const keepLen = Math.floor((maxLen - 3) / 2);
    if (keepLen <= 0) {
      return filePath.substring(0, maxLen - 3) + '...';
    }
    const start = filePath.substring(0, keepLen);
    const end = filePath.substring(filePath.length - keepLen);
    return `${start}...${end}`;
  };

  type TruncateMode = 'start' | 'end' | 'center';

  const truncateComponent = (
    component: string,
    targetLength: number,
    mode: TruncateMode,
  ): string => {
    if (component.length <= targetLength) {
      return component;
    }

    if (targetLength <= 0) {
      return '';
    }

    if (targetLength <= 3) {
      if (mode === 'end') {
        return component.slice(-targetLength);
      }
      return component.slice(0, targetLength);
    }

    if (mode === 'start') {
      return `${component.slice(0, targetLength - 3)}...`;
    }

    if (mode === 'end') {
      return `...${component.slice(component.length - (targetLength - 3))}`;
    }

    const front = Math.ceil((targetLength - 3) / 2);
    const back = targetLength - 3 - front;
    return `${component.slice(0, front)}...${component.slice(
      component.length - back,
    )}`;
  };

  const parsedPath = path.parse(filePath);
  const root = parsedPath.root;
  const separator = path.sep;

  // Get segments of the path *after* the root
  const relativePath = filePath.substring(root.length);
  const segments = relativePath.split(separator).filter((s) => s !== ''); // Filter out empty segments

  // Handle cases with no segments after root (e.g., "/", "C:\") or only one segment
  if (segments.length <= 1) {
    // Fall back to simple start/end truncation for very short paths or single segments
    return simpleTruncate();
  }

  const firstDir = segments[0];
  const lastSegment = segments[segments.length - 1];
  const startComponent = root + firstDir;

  const endPartSegments = [lastSegment];
  let endPartLength = lastSegment.length;

  // Iterate backwards through the middle segments
  for (let i = segments.length - 2; i > 0; i--) {
    const segment = segments[i];
    const newLength =
      startComponent.length +
      separator.length +
      3 + // for "..."
      separator.length +
      endPartLength +
      separator.length +
      segment.length;

    if (newLength <= maxLen) {
      endPartSegments.unshift(segment);
      endPartLength += separator.length + segment.length;
    } else {
      break;
    }
  }

  const components = [firstDir, ...endPartSegments];
  const componentModes: TruncateMode[] = components.map((_, index) => {
    if (index === 0) {
      return 'start';
    }
    if (index === components.length - 1) {
      return 'end';
    }
    return 'center';
  });

  const separatorsCount = endPartSegments.length + 1;
  const fixedLen = root.length + separatorsCount * separator.length + 3; // ellipsis length
  const availableForComponents = maxLen - fixedLen;

  const trailingFallback = () => {
    const ellipsisTail = `...${separator}${lastSegment}`;
    if (ellipsisTail.length <= maxLen) {
      return ellipsisTail;
    }

    if (root) {
      const rootEllipsisTail = `${root}...${separator}${lastSegment}`;
      if (rootEllipsisTail.length <= maxLen) {
        return rootEllipsisTail;
      }
    }

    if (root && `${root}${lastSegment}`.length <= maxLen) {
      return `${root}${lastSegment}`;
    }

    if (lastSegment.length <= maxLen) {
      return lastSegment;
    }

    // As a final resort (e.g., last segment itself exceeds maxLen), fall back to simple truncation.
    return simpleTruncate();
  };

  if (availableForComponents <= 0) {
    return trailingFallback();
  }

  const minLengths = components.map((component, index) => {
    if (index === 0) {
      return Math.min(component.length, 1);
    }
    if (index === components.length - 1) {
      return component.length; // Never truncate the last segment when possible.
    }
    return Math.min(component.length, 1);
  });

  const minTotal = minLengths.reduce((sum, len) => sum + len, 0);
  if (availableForComponents < minTotal) {
    return trailingFallback();
  }

  const budgets = components.map((component) => component.length);
  let currentTotal = budgets.reduce((sum, len) => sum + len, 0);

  const pickIndexToReduce = () => {
    let bestIndex = -1;
    let bestScore = -Infinity;
    for (let i = 0; i < budgets.length; i++) {
      if (budgets[i] <= minLengths[i]) {
        continue;
      }
      const isLast = i === budgets.length - 1;
      const score = (isLast ? 0 : 1_000_000) + budgets[i];
      if (score > bestScore) {
        bestScore = score;
        bestIndex = i;
      }
    }
    return bestIndex;
  };

  while (currentTotal > availableForComponents) {
    const index = pickIndexToReduce();
    if (index === -1) {
      return trailingFallback();
    }
    budgets[index]--;
    currentTotal--;
  }

  const truncatedComponents = components.map((component, index) =>
    truncateComponent(component, budgets[index], componentModes[index]),
  );

  const truncatedFirst = truncatedComponents[0];
  const truncatedEnd = truncatedComponents.slice(1).join(separator);
  const result = `${root}${truncatedFirst}${separator}...${separator}${truncatedEnd}`;

  if (result.length > maxLen) {
    return trailingFallback();
  }

  return result;
}

/**
 * Calculates the relative path from a root directory to a target path.
 * If targetPath is relative, it is returned as-is.
 * Returns '.' if the target path is the same as the root directory.
 *
 * @param targetPath The absolute or relative path to make relative.
 * @param rootDirectory The absolute path of the directory to make the target path relative to.
 * @returns The relative path from rootDirectory to targetPath.
 */
export function makeRelative(
  targetPath: string,
  rootDirectory: string,
): string {
  if (!path.isAbsolute(targetPath)) {
    return targetPath;
  }
  const resolvedRootDirectory = path.resolve(rootDirectory);
  const relativePath = path.relative(resolvedRootDirectory, targetPath);

  // If the paths are the same, path.relative returns '', return '.' instead
  return relativePath || '.';
}

/**
 * Escape paths for at-commands.
 *
 *  - Windows: double quoted if they contain special chars, otherwise bare
 *  - POSIX: backslash-escaped
 */
export function escapePath(filePath: string): string {
  if (process.platform === 'win32') {
    // Windows: Double quote if it contains special chars
    if (/[\s&()[\]{}^=;!'+,`~%$@#]/.test(filePath)) {
      return `"${filePath}"`;
    }
    return filePath;
  } else {
    // POSIX: Backslash escape
    return filePath.replace(/([ \t()[\]{};|*?$`'"#&<>!~\\])/g, '\\$1');
  }
}

/**
 * Unescapes paths for at-commands.
 *
 *  - Windows: double quoted if they contain special chars, otherwise bare
 *  - POSIX: backslash-escaped
 */
export function unescapePath(filePath: string): string {
  if (process.platform === 'win32') {
    if (
      filePath.length >= 2 &&
      filePath.startsWith('"') &&
      filePath.endsWith('"')
    ) {
      return filePath.slice(1, -1);
    }
    return filePath;
  } else {
    return filePath.replace(/\\(.)/g, '$1');
  }
}

/**
 * Generates a unique hash for a project based on its root path.
 * @param projectRoot The absolute path to the project's root directory.
 * @returns A SHA256 hash of the project root path.
 */
export function getProjectHash(projectRoot: string): string {
  return crypto.createHash('sha256').update(projectRoot).digest('hex');
}

/**
 * Resolves a path to an absolute path with forward slashes, preserving the
 * original case of every segment.
 *
 * Use this for paths that will be surfaced to the user (e.g. `/memory list`,
 * `--- Context from: ... ---` headers) or used as the storage form passed
 * through to file I/O. For comparison/dedup keys on case-insensitive
 * filesystems use `normalizePath` instead.
 */
export function toAbsolutePath(p: string): string {
  const isWindows = process.platform === 'win32';
  const pathModule = isWindows ? path.win32 : path;
  return pathModule.resolve(p).replace(/\\/g, '/');
}

/**
 * Normalizes a path for reliable comparison across platforms.
 * - Resolves to an absolute path.
 * - Converts all path separators to forward slashes.
 * - On case-insensitive platforms (Windows, macOS), converts to lowercase.
 *
 * Use this for comparison keys (Set/Map lookups, equality checks). For paths
 * that will be displayed to the user or persisted as identifiers, use
 * `toAbsolutePath` instead so the original casing is preserved.
 */
export function normalizePath(p: string): string {
  const absolute = toAbsolutePath(p);
  const platform = process.platform;
  const isCaseInsensitive = platform === 'win32' || platform === 'darwin';
  return isCaseInsensitive ? absolute.toLowerCase() : absolute;
}

/**
 * Checks if a path is a subpath of another path.
 * @param parentPath The parent path.
 * @param childPath The child path.
 * @returns True if childPath is a subpath of parentPath, false otherwise.
 */
export function isSubpath(parentPath: string, childPath: string): boolean {
  const platform = process.platform;
  const isWindows = platform === 'win32';
  const isDarwin = platform === 'darwin';
  const pathModule = isWindows ? path.win32 : path;

  // Resolve both paths to absolute to ensure consistent comparison,
  // especially when mixing relative and absolute paths or when casing differs.
  let p = pathModule.resolve(parentPath);
  let c = pathModule.resolve(childPath);

  // On Windows, path.relative is case-insensitive.
  // On POSIX (including Darwin), path.relative is case-sensitive.
  // We want it to be case-insensitive on Darwin to match user expectation and sandbox policy.
  if (isDarwin) {
    p = p.toLowerCase();
    c = c.toLowerCase();
  }

  const relative = pathModule.relative(p, c);

  return (
    !relative.startsWith(`..${pathModule.sep}`) &&
    relative !== '..' &&
    !pathModule.isAbsolute(relative)
  );
}

/**
 * Type guard to verify a value is a string and does not contain null bytes.
 */
export function isValidPathString(p: unknown): p is string {
  return typeof p === 'string' && !p.includes('\0');
}

/**
 * Asserts that a value is a valid path string, throwing an Error otherwise.
 */
export function assertValidPathString(p: unknown): asserts p is string {
  if (!isValidPathString(p)) {
    throw new Error(`Invalid path: ${String(p)}`);
  }
}

/**
 * Resolves a path to its real path, sanitizing it first.
 * - Removes 'file://' protocol if present.
 * - Decodes URI components (e.g. %20 -> space).
 * - Resolves symbolic links using fs.realpathSync.
 *
 * @param pathStr The path string to resolve.
 * @returns The resolved real path.
 */
export function resolveToRealPath(pathStr: string): string {
  assertValidPathString(pathStr);
  let resolvedPath = pathStr;

  try {
    if (resolvedPath.startsWith('file://')) {
      resolvedPath = fileURLToPath(resolvedPath);
    }

    resolvedPath = decodeURIComponent(resolvedPath);
  } catch {
    // Ignore error (e.g. malformed URI), keep path from previous step
  }

  return robustRealpath(path.resolve(resolvedPath));
}

function robustRealpath(p: string, visited = new Set<string>()): string {
  const key = process.platform === 'win32' ? p.toLowerCase() : p;
  if (visited.has(key)) {
    throw new Error(`Infinite recursion detected in robustRealpath: ${p}`);
  }
  visited.add(key);
  try {
    return fs.realpathSync(p);
  } catch (e: unknown) {
    if (
      e &&
      typeof e === 'object' &&
      'code' in e &&
      (e.code === 'ENOENT' ||
        e.code === 'EISDIR' ||
        e.code === 'ENAMETOOLONG' ||
        e.code === 'ENOTDIR')
    ) {
      try {
        const stat = fs.lstatSync(p);
        if (stat.isSymbolicLink()) {
          const target = fs.readlinkSync(p);
          const resolvedTarget = path.resolve(path.dirname(p), target);
          return robustRealpath(resolvedTarget, visited);
        }
      } catch (lstatError: unknown) {
        // Not a symlink, or lstat failed. Re-throw if it's not an expected
        // ENOENT (e.g., a permissions error), otherwise resolve parent.
        if (
          !(
            lstatError &&
            typeof lstatError === 'object' &&
            'code' in lstatError &&
            (lstatError.code === 'ENOENT' ||
              lstatError.code === 'EISDIR' ||
              lstatError.code === 'ENAMETOOLONG' ||
              lstatError.code === 'ENOTDIR')
          )
        ) {
          throw lstatError;
        }
      }
      const parent = path.dirname(p);
      if (parent === p) return p;
      return path.join(robustRealpath(parent, visited), path.basename(p));
    }
    throw e;
  }
}

/**
 * Deduplicates an array of paths and ensures all paths are absolute.
 */
export function deduplicateAbsolutePaths(paths?: string[] | null): string[] {
  if (!paths || paths.length === 0) return [];

  const uniquePathsMap = new Map<string, string>();
  for (const p of paths) {
    if (!path.isAbsolute(p)) {
      throw new Error(`Path must be absolute: ${p}`);
    }

    const key = toPathKey(p);
    if (!uniquePathsMap.has(key)) {
      uniquePathsMap.set(key, p);
    }
  }

  return Array.from(uniquePathsMap.values());
}

/**
 * Returns a stable string key for a path to be used in comparisons or Map lookups.
 */
export function toPathKey(p: string): string {
  // Normalize path segments
  let norm = path.normalize(p);

  // Strip trailing slashes (except for root paths)
  if (norm.length > 1 && (norm.endsWith('/') || norm.endsWith('\\'))) {
    // On Windows, don't strip the slash from a drive root (e.g., "C:\\")
    if (!/^[a-zA-Z]:[\\/]$/.test(norm)) {
      norm = norm.slice(0, -1);
    }
  }

  // Convert to lowercase on case-insensitive platforms
  const platform = process.platform;
  const isCaseInsensitive = platform === 'win32' || platform === 'darwin';
  return isCaseInsensitive ? norm.toLowerCase() : norm;
}

/**
 * Verifies if a path is a trusted system directory.
 */
export function isTrustedSystemPath(filePath: string): boolean {
  const normPath = normalizePath(filePath);

  // 1. Explicitly reject paths in current working directory to prevent RCE
  // Exclude root directories to avoid inadvertently rejecting all system paths.
  // Bypass this restriction in secure, hermetic environments (e.g., Bazel/Blaze).
  const isHermeticEnv =
    !!process.env['TEST_SRCDIR'] ||
    !!process.env['TEST_WORKSPACE'] ||
    !!process.env['BAZEL_TEST'] ||
    !!process.env['RUNFILES_DIR'];

  const normCwd = normalizePath(process.cwd());
  const isRoot = normCwd === '/' || /^[a-zA-Z]:[\\/]?$/.test(normCwd);
  if (!isRoot && isSubpath(normCwd, normPath)) {
    return isHermeticEnv;
  }

  // 2. Allow standard system directories
  const platform = process.platform;
  if (platform === 'win32') {
    const trustedPrefixes = [
      process.env['SystemRoot'] || 'C:\\Windows',
      process.env['ProgramFiles'] || 'C:\\Program Files',
      process.env['ProgramFiles(x86)'] || 'C:\\Program Files (x86)',
    ].map((p) => normalizePath(p));

    return trustedPrefixes.some(
      (prefix) => normPath === prefix || normPath.startsWith(prefix + '/'),
    );
  } else {
    const trustedPrefixes = [
      '/usr/bin',
      '/bin',
      '/usr/local/bin',
      '/opt/homebrew/bin',
      '/opt/homebrew/Cellar',
      '/usr/local/Cellar',
      '/usr/sbin',
      '/sbin',
      // 1P internal hermetic execution paths
      '/google/bin',
      '/google/src/cloud',
    ].map((p) => normalizePath(p));

    return trustedPrefixes.some(
      (prefix) => normPath === prefix || normPath.startsWith(prefix + '/'),
    );
  }
}

/**
 * Defensively resolves and sanitizes a file path generated by the LLM,
 * stripping user-facing reference prefixes if necessary.
 */
export function resolveDefensiveToolPath(
  filePath: string,
  targetDir: string,
): string {
  const cleanPath = filePath.replace(/\0/g, '');

  try {
    const literalPath = path.resolve(targetDir, cleanPath);

    // If the file literally exists on disk as-is, return the resolved literal path immediately
    if (fs.existsSync(literalPath)) {
      return cleanPath;
    }

    // If the model supplied a leading @ prefix and the literal path doesn't exist:
    if (cleanPath.startsWith('@') && cleanPath.length > 1) {
      if (cleanPath.startsWith('@/') || cleanPath.startsWith('@\\')) {
        const stripped = cleanPath.substring(1).replace(/^[\\/]+/, '');
        return stripped.length > 0 ? stripped : cleanPath;
      }

      const strippedPath = cleanPath.substring(1).replace(/^[\\/]+/, '');

      // Check if a literal directory/file starting with '@' exists for the first segment.
      // If it does, we should preserve the '@' prefix.
      const parts = strippedPath.split(/[\\/]/);
      const firstSegment = parts[0];
      if (firstSegment) {
        const literalFirstSegment = path.resolve(targetDir, '@' + firstSegment);
        if (fs.existsSync(literalFirstSegment)) {
          return cleanPath;
        }

        // Otherwise, strip the '@' prefix to resolve to the standard directory name,
        // preventing the accidental creation of literal '@'-prefixed directories (e.g. '@src', '@policies')
        // when creating new files or directories.
        return strippedPath;
      }
    }
  } catch {
    // Fallback to original path if any filesystem or resolution error occurs
  }

  // Fallback: return the original path
  return cleanPath;
}
