import * as core from '@actions/core';
import * as exec from '@actions/exec';
import * as cache from '@actions/cache';
import * as glob from '@actions/glob';
import path from 'path';
import fs from 'fs';
import {unique} from './util.js';

export interface PackageManagerInfo {
  name: string;
  lockFilePatterns: Array<string>;
  getCacheFolderPath: (projectDir?: string) => Promise<string>;
}

interface SupportedPackageManagers {
  npm: PackageManagerInfo;
  pnpm: PackageManagerInfo;
  yarn: PackageManagerInfo;
}
export const supportedPackageManagers: SupportedPackageManagers = {
  npm: {
    name: 'npm',
    lockFilePatterns: ['package-lock.json', 'npm-shrinkwrap.json', 'yarn.lock'],
    getCacheFolderPath: () =>
      getCommandOutputNotEmpty(
        'npm config get cache',
        'Could not get npm cache folder path'
      )
  },
  pnpm: {
    name: 'pnpm',
    lockFilePatterns: ['pnpm-lock.yaml'],
    getCacheFolderPath: () =>
      getCommandOutputNotEmpty(
        'pnpm store path --silent',
        'Could not get pnpm cache folder path'
      )
  },
  yarn: {
    name: 'yarn',
    lockFilePatterns: ['yarn.lock'],
    getCacheFolderPath: async projectDir => {
      const yarnVersion = await getCommandOutputNotEmpty(
        `yarn --version`,
        'Could not retrieve version of yarn',
        projectDir
      );

      core.debug(
        `Consumed yarn version is ${yarnVersion} (working dir: "${
          projectDir || ''
        }")`
      );

      const stdOut = yarnVersion.startsWith('1.')
        ? await getCommandOutput('yarn cache dir', projectDir)
        : await getCommandOutput('yarn config get cacheFolder', projectDir);

      if (!stdOut) {
        throw new Error(
          `Could not get yarn cache folder path for ${projectDir}`
        );
      }
      return stdOut;
    }
  }
};

export const getCommandOutput = async (
  toolCommand: string,
  cwd?: string
): Promise<string> => {
  let {stdout, stderr, exitCode} = await exec.getExecOutput(
    toolCommand,
    undefined,
    {ignoreReturnCode: true, ...(cwd && {cwd})}
  );

  if (exitCode) {
    stderr = !stderr.trim()
      ? `The '${toolCommand}' command failed with exit code: ${exitCode}`
      : stderr;
    throw new Error(stderr);
  }

  return stdout.trim();
};

export const getCommandOutputNotEmpty = async (
  toolCommand: string,
  error: string,
  cwd?: string
): Promise<string> => {
  const stdOut = await getCommandOutput(toolCommand, cwd);
  if (!stdOut) {
    throw new Error(error);
  }
  return stdOut;
};

export const getPackageManagerInfo = async (packageManager: string) => {
  if (packageManager === 'npm') {
    return supportedPackageManagers.npm;
  } else if (packageManager === 'pnpm') {
    return supportedPackageManagers.pnpm;
  } else if (packageManager === 'yarn') {
    return supportedPackageManagers.yarn;
  } else {
    return null;
  }
};

/**
 * getProjectDirectoriesFromCacheDependencyPath is called twice during `restoreCache`
 *  - first through `getCacheDirectories`
 *  - second from `repoHasYarn3ManagedCache`
 *
 *  it contains expensive IO operation and thus should be memoized
 */

let projectDirectoriesMemoized: string[] | null = null;
/**
 * unit test must reset memoized variables
 */
export const resetProjectDirectoriesMemoized = () =>
  (projectDirectoriesMemoized = null);
/**
 * Expands (converts) the string input `cache-dependency-path` to list of directories that
 * may be project roots
 * @param cacheDependencyPath - either a single string or multiline string with possible glob patterns
 *                              expected to be the result of `core.getInput('cache-dependency-path')`
 * @return list of directories and possible
 */
const getProjectDirectoriesFromCacheDependencyPath = async (
  cacheDependencyPath: string
): Promise<string[]> => {
  if (projectDirectoriesMemoized !== null) {
    return projectDirectoriesMemoized;
  }

  const globber = await glob.create(cacheDependencyPath);
  const cacheDependenciesPaths = await globber.glob();

  const existingDirectories: string[] = cacheDependenciesPaths
    .map(path.dirname)
    .filter(unique())
    .map(dirName => fs.realpathSync(dirName))
    .filter(directory => fs.lstatSync(directory).isDirectory());

  if (!existingDirectories.length)
    core.warning(
      `No existing directories found containing cache-dependency-path="${cacheDependencyPath}"`
    );

  projectDirectoriesMemoized = existingDirectories;
  return existingDirectories;
};

/**
 * Finds the cache directories configured for the repo if cache-dependency-path is not empty
 * @param packageManagerInfo - an object having getCacheFolderPath method specific to given PM
 * @param cacheDependencyPath - either a single string or multiline string with possible glob patterns
 *                              expected to be the result of `core.getInput('cache-dependency-path')`
 * @return list of files on which the cache depends
 */
const getCacheDirectoriesFromCacheDependencyPath = async (
  packageManagerInfo: PackageManagerInfo,
  cacheDependencyPath: string
): Promise<string[]> => {
  const projectDirectories =
    await getProjectDirectoriesFromCacheDependencyPath(cacheDependencyPath);
  const cacheFoldersPaths = await Promise.all(
    projectDirectories.map(async projectDirectory => {
      const cacheFolderPath =
        await packageManagerInfo.getCacheFolderPath(projectDirectory);
      core.debug(
        `${packageManagerInfo.name}'s cache folder "${cacheFolderPath}" configured for the directory "${projectDirectory}"`
      );
      return cacheFolderPath;
    })
  );
  // uniq in order to do not cache the same directories twice
  return cacheFoldersPaths.filter(unique());
};

/**
 * Finds the cache directories configured for the repo ignoring cache-dependency-path
 * @param packageManagerInfo - an object having getCacheFolderPath method specific to given PM
 * @return list of files on which the cache depends
 */
const getCacheDirectoriesForRootProject = async (
  packageManagerInfo: PackageManagerInfo
): Promise<string[]> => {
  const cacheFolderPath = await packageManagerInfo.getCacheFolderPath();
  core.debug(
    `${packageManagerInfo.name}'s cache folder "${cacheFolderPath}" configured for the root directory`
  );
  return [cacheFolderPath];
};

/**
 * A function to find the cache directories configured for the repo
 * currently it handles only the case of PM=yarn && cacheDependencyPath is not empty
 * @param packageManagerInfo - an object having getCacheFolderPath method specific to given PM
 * @param cacheDependencyPath - either a single string or multiline string with possible glob patterns
 *                              expected to be the result of `core.getInput('cache-dependency-path')`
 * @return list of files on which the cache depends
 */
export const getCacheDirectories = async (
  packageManagerInfo: PackageManagerInfo,
  cacheDependencyPath: string
): Promise<string[]> => {
  // For yarn, if cacheDependencyPath is set, ask information about cache folders in each project
  // folder satisfied by cacheDependencyPath https://github.com/actions/setup-node/issues/488
  if (packageManagerInfo.name === 'yarn' && cacheDependencyPath) {
    return getCacheDirectoriesFromCacheDependencyPath(
      packageManagerInfo,
      cacheDependencyPath
    );
  }
  return getCacheDirectoriesForRootProject(packageManagerInfo);
};

/**
 * A function to check if the directory is a yarn project configured to manage
 * obsolete dependencies in the local cache
 * @param directory - a path to the folder
 * @return - true if the directory's project is yarn managed
 *  - if there's .yarn/cache folder do not mess with the dependencies kept in the repo, return false
 *  - global cache is not managed by yarn @see https://yarnpkg.com/features/offline-cache, return false
 *  - if local cache is not explicitly enabled (not yarn3), return false
 *  - return true otherwise
 */
const projectHasYarnBerryManagedDependencies = async (
  directory: string
): Promise<boolean> => {
  const workDir = directory || process.env.GITHUB_WORKSPACE || '.';
  core.debug(`check if "${workDir}" has locally managed yarn3 dependencies`);

  // if .yarn/cache directory exists the cache is managed by version control system
  const yarnCacheFile = path.join(workDir, '.yarn', 'cache');
  if (
    fs.existsSync(yarnCacheFile) &&
    fs.lstatSync(yarnCacheFile).isDirectory()
  ) {
    core.debug(
      `"${workDir}" has .yarn/cache - dependencies are kept in the repository`
    );
    return Promise.resolve(false);
  }

  // NOTE: yarn1 returns 'undefined' with return code = 0
  const enableGlobalCache = await getCommandOutput(
    'yarn config get enableGlobalCache',
    workDir
  );
  // only local cache is not managed by yarn
  const managed = enableGlobalCache.includes('false');
  if (managed) {
    core.debug(`"${workDir}" dependencies are managed by yarn 3 locally`);
    return true;
  } else {
    core.debug(`"${workDir}" dependencies are not managed by yarn 3 locally`);
    return false;
  }
};

/**
 * A function to report the repo contains Yarn managed projects
 * @param packageManagerInfo - used to make sure current package manager is yarn
 * @param cacheDependencyPath - either a single string or multiline string with possible glob patterns
 *                              expected to be the result of `core.getInput('cache-dependency-path')`
 * @return - true if all project directories configured to be Yarn managed
 */
export const repoHasYarnBerryManagedDependencies = async (
  packageManagerInfo: PackageManagerInfo,
  cacheDependencyPath: string
): Promise<boolean> => {
  if (packageManagerInfo.name !== 'yarn') return false;

  const yarnDirs = cacheDependencyPath
    ? await getProjectDirectoriesFromCacheDependencyPath(cacheDependencyPath)
    : [''];

  const isManagedList = await Promise.all(
    yarnDirs.map(projectHasYarnBerryManagedDependencies)
  );

  return isManagedList.every(Boolean);
};

export function isGhes(): boolean {
  const ghUrl = new URL(
    process.env['GITHUB_SERVER_URL'] || 'https://github.com'
  );

  const hostname = ghUrl.hostname.trimEnd().toUpperCase();
  const isGitHubHost = hostname === 'GITHUB.COM';
  const isGitHubEnterpriseCloudHost = hostname.endsWith('.GHE.COM');
  const isLocalHost = hostname.endsWith('.LOCALHOST');

  return !isGitHubHost && !isGitHubEnterpriseCloudHost && !isLocalHost;
}

export function isCacheFeatureAvailable(): boolean {
  if (cache.isFeatureAvailable()) return true;

  if (isGhes()) {
    core.warning(
      'Cache action is only supported on GHES version >= 3.5. If you are on version >=3.5 Please check with GHES admin if Actions cache service is enabled or not.'
    );
    return false;
  }

  core.warning(
    'The runner was not able to contact the cache service. Caching will be skipped'
  );

  return false;
}
