/**
 * @license
 * Copyright Google LLC All Rights Reserved.
 *
 * Use of this source code is governed by an MIT-style license that can be
 * found in the LICENSE file at https://angular.io/license
 */

import * as Octokit from '@octokit/rest';
import {spawnSync, SpawnSyncOptions, SpawnSyncReturns} from 'child_process';
import {Options as SemVerOptions, parse, SemVer} from 'semver';

import {getConfig, getRepoBaseDir} from '../config';
import {debug, info, yellow} from '../console';
import {DryRunError, isDryRun} from '../dry-run';
import {GithubClient} from './github';
import {getRepositoryGitUrl, GITHUB_TOKEN_GENERATE_URL, GITHUB_TOKEN_SETTINGS_URL} from './github-urls';

/** Github response type extended to include the `x-oauth-scopes` headers presence. */
type RateLimitResponseWithOAuthScopeHeader = Octokit.Response<Octokit.RateLimitGetResponse>&{
  headers: {'x-oauth-scopes': string};
};

/** Describes a function that can be used to test for given Github OAuth scopes. */
export type OAuthScopeTestFunction = (scopes: string[], missing: string[]) => void;

/** Error for failed Git commands. */
export class GitCommandError extends Error {
  constructor(client: GitClient<boolean>, public args: string[]) {
    // Errors are not guaranteed to be caught. To ensure that we don't
    // accidentally leak the Github token that might be used in a command,
    // we sanitize the command that will be part of the error message.
    super(`Command failed: git ${client.omitGithubTokenFromMessage(args.join(' '))}`);
  }
}

/**
 * Common client for performing Git interactions with a given remote.
 *
 * Takes in two optional arguments:
 *   `githubToken`: the token used for authentication in Github interactions, by default empty
 *     allowing readonly actions.
 *   `config`: The dev-infra configuration containing information about the remote. By default
 *     the dev-infra configuration is loaded with its Github configuration.
 **/
export class GitClient<Authenticated extends boolean> {
  /*************************************************
   * Singleton definition and configuration.       *
   *************************************************/
  /** The singleton instance of the authenticated GitClient. */
  private static authenticated: GitClient<true>;
  /** The singleton instance of the unauthenticated GitClient. */
  private static unauthenticated: GitClient<false>;

  /**
   * Static method to get the singleton instance of the unauthorized GitClient, creating it if it
   * has not yet been created.
   */
  static getInstance() {
    if (!GitClient.unauthenticated) {
      GitClient.unauthenticated = new GitClient(undefined);
    }
    return GitClient.unauthenticated;
  }

  /**
   * Static method to get the singleton instance of the authenticated GitClient if it has been
   * generated.
   */
  static getAuthenticatedInstance() {
    if (!GitClient.authenticated) {
      throw Error('The authenticated GitClient has not yet been generated.');
    }
    return GitClient.authenticated;
  }

  /** Build the authenticated GitClient instance. */
  static authenticateWithToken(token: string) {
    if (GitClient.authenticated) {
      throw Error(
          'Cannot generate new authenticated GitClient after one has already been generated.');
    }
    GitClient.authenticated = new GitClient(token);
  }


  /** Whether verbose logging of Git actions should be used. */
  private verboseLogging = true;
  /** The OAuth scopes available for the provided Github token. */
  private _cachedOauthScopes: Promise<string[]>|null = null;
  /**
   * Regular expression that matches the provided Github token. Used for
   * sanitizing the token from Git child process output.
   */
  private _githubTokenRegex: RegExp|null = null;
  /** Short-hand for accessing the default remote configuration. */
  remoteConfig = this._config.github;
  /** Octokit request parameters object for targeting the configured remote. */
  remoteParams = {owner: this.remoteConfig.owner, repo: this.remoteConfig.name};
  /** Instance of the authenticated Github octokit API. */
  github = new GithubClient(this.githubToken);

  /**
   * @param githubToken The github token used for authentication, if provided.
   * @param _config The configuration, containing the github specific configuration.
   * @param _projectRoot The full path to the root of the repository base.
   */
  protected constructor(public githubToken:
                            Authenticated extends true? string: undefined,
                                                  private _config = getConfig(),
                                                  private _projectRoot = getRepoBaseDir()) {
    // If a token has been specified (and is not empty), pass it to the Octokit API and
    // also create a regular expression that can be used for sanitizing Git command output
    // so that it does not print the token accidentally.
    if (typeof githubToken === 'string') {
      this._githubTokenRegex = new RegExp(githubToken, 'g');
    }
  }

  /** Set the verbose logging state of the GitClient instance. */
  setVerboseLoggingState(verbose: boolean): this {
    this.verboseLogging = verbose;
    return this;
  }

  /** Executes the given git command. Throws if the command fails. */
  run(args: string[], options?: SpawnSyncOptions): Omit<SpawnSyncReturns<string>, 'status'> {
    const result = this.runGraceful(args, options);
    if (result.status !== 0) {
      throw new GitCommandError(this, args);
    }
    // Omit `status` from the type so that it's obvious that the status is never
    // non-zero as explained in the method description.
    return result as Omit<SpawnSyncReturns<string>, 'status'>;
  }

  /**
   * Spawns a given Git command process. Does not throw if the command fails. Additionally,
   * if there is any stderr output, the output will be printed. This makes it easier to
   * info failed commands.
   */
  runGraceful(args: string[], options: SpawnSyncOptions = {}): SpawnSyncReturns<string> {
    /** The git command to be run. */
    const gitCommand = args[0];

    if (isDryRun() && gitCommand === 'push') {
      debug(`"git push" is not able to be run in dryRun mode.`);
      throw new DryRunError();
    }

    // To improve the debugging experience in case something fails, we print all executed Git
    // commands to better understand the git actions occuring. Depending on the command being
    // executed, this debugging information should be logged at different logging levels.
    const printFn = (!this.verboseLogging || options.stdio === 'ignore') ? debug : info;
    // Note that we do not want to print the token if it is contained in the command. It's common
    // to share errors with others if the tool failed, and we do not want to leak tokens.
    printFn('Executing: git', this.omitGithubTokenFromMessage(args.join(' ')));

    const result = spawnSync('git', args, {
      cwd: this._projectRoot,
      stdio: 'pipe',
      ...options,
      // Encoding is always `utf8` and not overridable. This ensures that this method
      // always returns `string` as output instead of buffers.
      encoding: 'utf8',
    });

    if (result.stderr !== null) {
      // Git sometimes prints the command if it failed. This means that it could
      // potentially leak the Github token used for accessing the remote. To avoid
      // printing a token, we sanitize the string before printing the stderr output.
      process.stderr.write(this.omitGithubTokenFromMessage(result.stderr));
    }

    return result;
  }

  /** Git URL that resolves to the configured repository. */
  getRepoGitUrl() {
    return getRepositoryGitUrl(this.remoteConfig, this.githubToken);
  }

  /** Whether the given branch contains the specified SHA. */
  hasCommit(branchName: string, sha: string): boolean {
    return this.run(['branch', branchName, '--contains', sha]).stdout !== '';
  }

  /** Gets the currently checked out branch or revision. */
  getCurrentBranchOrRevision(): string {
    const branchName = this.run(['rev-parse', '--abbrev-ref', 'HEAD']).stdout.trim();
    // If no branch name could be resolved. i.e. `HEAD` has been returned, then Git
    // is currently in a detached state. In those cases, we just want to return the
    // currently checked out revision/SHA.
    if (branchName === 'HEAD') {
      return this.run(['rev-parse', 'HEAD']).stdout.trim();
    }
    return branchName;
  }

  /** Gets whether the current Git repository has uncommitted changes. */
  hasUncommittedChanges(): boolean {
    return this.runGraceful(['diff-index', '--quiet', 'HEAD']).status !== 0;
  }

  /** Whether the repo has any local changes. */
  hasLocalChanges(): boolean {
    return this.runGraceful(['diff-index', '--quiet', 'HEAD']).status !== 0;
  }

  /** Sanitizes a given message by omitting the provided Github token if present. */
  omitGithubTokenFromMessage(value: string): string {
    // If no token has been defined (i.e. no token regex), we just return the
    // value as is. There is no secret value that needs to be omitted.
    if (this._githubTokenRegex === null) {
      return value;
    }
    return value.replace(this._githubTokenRegex, '<TOKEN>');
  }

  /**
   * Checks out a requested branch or revision, optionally cleaning the state of the repository
   * before attempting the checking. Returns a boolean indicating whether the branch or revision
   * was cleanly checked out.
   */
  checkout(branchOrRevision: string, cleanState: boolean): boolean {
    if (cleanState) {
      // Abort any outstanding ams.
      this.runGraceful(['am', '--abort'], {stdio: 'ignore'});
      // Abort any outstanding cherry-picks.
      this.runGraceful(['cherry-pick', '--abort'], {stdio: 'ignore'});
      // Abort any outstanding rebases.
      this.runGraceful(['rebase', '--abort'], {stdio: 'ignore'});
      // Clear any changes in the current repo.
      this.runGraceful(['reset', '--hard'], {stdio: 'ignore'});
    }
    return this.runGraceful(['checkout', branchOrRevision], {stdio: 'ignore'}).status === 0;
  }

  /** Gets the latest git tag on the current branch that matches SemVer. */
  getLatestSemverTag(): SemVer {
    const semVerOptions: SemVerOptions = {loose: true};
    const tags = this.runGraceful(['tag', '--sort=-committerdate', '--merged']).stdout.split('\n');
    const latestTag = tags.find((tag: string) => parse(tag, semVerOptions));

    if (latestTag === undefined) {
      throw new Error(
          `Unable to find a SemVer matching tag on "${this.getCurrentBranchOrRevision()}"`);
    }
    return new SemVer(latestTag, semVerOptions);
  }

  /**
   * Assert the GitClient instance is using a token with permissions for the all of the
   * provided OAuth scopes.
   */
  async hasOauthScopes(testFn: OAuthScopeTestFunction): Promise<true|{error: string}> {
    const scopes = await this.getAuthScopesForToken();
    const missingScopes: string[] = [];
    // Test Github OAuth scopes and collect missing ones.
    testFn(scopes, missingScopes);
    // If no missing scopes are found, return true to indicate all OAuth Scopes are available.
    if (missingScopes.length === 0) {
      return true;
    }

    /**
     * Preconstructed error message to log to the user, providing missing scopes and
     * remediation instructions.
     **/
    const error =
        `The provided <TOKEN> does not have required permissions due to missing scope(s): ` +
        `${yellow(missingScopes.join(', '))}\n\n` +
        `Update the token in use at:\n` +
        `  ${GITHUB_TOKEN_SETTINGS_URL}\n\n` +
        `Alternatively, a new token can be created at: ${GITHUB_TOKEN_GENERATE_URL}\n`;

    return {error};
  }

  /**
   * Retrieve the OAuth scopes for the loaded Github token.
   **/
  private getAuthScopesForToken() {
    // If the OAuth scopes have already been loaded, return the Promise containing them.
    if (this._cachedOauthScopes !== null) {
      return this._cachedOauthScopes;
    }
    // OAuth scopes are loaded via the /rate_limit endpoint to prevent
    // usage of a request against that rate_limit for this lookup.
    return this._cachedOauthScopes = this.github.rateLimit.get().then(_response => {
      const response = _response as RateLimitResponseWithOAuthScopeHeader;
      const scopes: string = response.headers['x-oauth-scopes'] || '';
      return scopes.split(',').map(scope => scope.trim());
    });
  }
}