text: 'use strict';
  
  var async = require('async');
  var crypto = require('crypto');
  var fs = require('fs');
  var GoogleAuth = require('google-auth-library').GoogleAuth;
  var gcpMetadata = require('gcp-metadata');
  var path = require('path');
  var request = require('request');
  
  class Auth {
    constructor(config) {
      this.authClientPromise = null;
      this.authClient = null;
      this.googleAuthClient = null;
      this.config = config || {};
      this.credentials = null;
      this.environment = {};
      this.projectId = this.config.projectId;
    }
  
    authorizeRequest (reqOpts, callback) {
      this.getToken((err, token) => {
        if (err) {
          callback(err);
          return;
        }
  
        var authorizedReqOpts = Object.assign({}, reqOpts, {
          headers: Object.assign({}, reqOpts.headers, {
            Authorization: `Bearer ${token}`
          })
        });
  
        callback(null, authorizedReqOpts);
      });
    }
  
    getAuthClient (callback) {
      if (this.authClient) {
        // This code works around an issue with context loss with async-listener.
        // Strictly speaking, this should not be necessary as the call to
        // authClientPromise.then(..) below would resolve to the same value.
        // However, async-listener defaults to resuming the `then` callbacks with
        // the context at the point of resolution rather than the context from the
        // point where the `then` callback was added. In this case, the promise
        // will be resolved on the very first incoming http request, and that
        // context will become sticky (will be restored by async-listener) around
        // the `then` callbacks for all subsequent requests.
        //
        // This breaks APM tools like Stackdriver Trace & others and tools like
        // long stack traces (they will provide an incorrect stack trace).
        //
        // NOTE: this doesn't solve the problem generally. Any request concurrent
        // to the first call to this function, before the promise resolves, will
        // still lose context. We don't have a better solution at the moment :(.
        return setImmediate(callback.bind(null, null, this.authClient));
      }
  
      var createAuthClientPromise = (resolve, reject) => {
        var googleAuthClient = this.googleAuthClient = new GoogleAuth();
  
        var config = this.config;
        var keyFile = config.keyFilename || config.keyFile;
  
        var addScope = (err, authClient, projectId) => {
          if (err) {
            reject(err);
            return;
          }
  
          if (authClient.createScopedRequired && authClient.createScopedRequired()) {
            if (!config.scopes || config.scopes.length === 0) {
              var scopeError = new Error('Scopes are required for this request.');
              scopeError.code = 'MISSING_SCOPE';
              reject(scopeError);
              return;
            }
          }
  
          authClient.scopes = config.scopes;
          this.authClient = authClient;
          this.projectId = config.projectId || projectId || authClient.projectId;
  
          resolve(authClient);
        };
  
        if (config.credentials) {
          try {
            var client = googleAuthClient.fromJSON(config.credentials);
            addScope(null, client);
          } catch (e) {
            addScope(e);
          }
        } else if (keyFile) {
          keyFile = path.resolve(process.cwd(), keyFile);
  
          fs.readFile(keyFile, (err, contents) => {
            if (err) {
              reject(err);
              return;
            }
  
            try {
              var client = googleAuthClient.fromJSON(JSON.parse(contents));
              addScope(null, client);
            } catch(e) {
              var authClient = new googleAuthClient.JWT();
              authClient.keyFile = keyFile;
              authClient.email = config.email;
              addScope(null, authClient);
            }
          });
        } else {
          googleAuthClient.getApplicationDefault(addScope);
        }
      };
  
      if (!this.authClientPromise) {
        this.authClientPromise = new Promise(createAuthClientPromise);
      }
  
      this.authClientPromise.then((authClient) => {
        callback(null, authClient);
        // The return null is needed to avoid a spurious warning if the user is
        // using bluebird.
        // See: https://github.com/stephenplusplus/google-auto-auth/issues/28
        return null;
      }).catch(callback);
    }
  
    getCredentials (callback) {
      if (this.credentials) {
        setImmediate(() => {
          callback(null, this.credentials);
        });
        return;
      }
  
      this.getAuthClient((err) => {
        if (err) {
          callback(err);
          return;
        }
  
        this.googleAuthClient.getCredentials((err, credentials) => {
          if (err) {
            callback(err);
            return;
          }
  
          this.credentials = credentials;
  
          callback(null, credentials);
        });
      });
    }
  
    getEnvironment (callback) {
      async.parallel([
        cb => this.isAppEngine(cb),
        cb => this.isCloudFunction(cb),
        cb => this.isComputeEngine(cb),
        cb => this.isContainerEngine(cb)
      ], () => {
        callback(null, this.environment);
      });
    }
  
    getProjectId (callback) {
      if (this.projectId) {
        setImmediate(() => {
          callback(null, this.projectId);
        });
        return;
      }
  
      this.getAuthClient(err => {
        if (err) {
          callback(err);
          return;
        }
  
        callback(null, this.projectId);
      });
    }
  
    getToken (callback) {
      this.getAuthClient((err, client) => {
        if (err) {
          callback(err);
          return;
        }
  
        client.getAccessToken(callback);
      });
    }
  
    isAppEngine (callback) {
      setImmediate(() => {
        var env = this.environment;
  
        if (typeof env.IS_APP_ENGINE === 'undefined') {
          env.IS_APP_ENGINE = !!(process.env.GAE_SERVICE || process.env.GAE_MODULE_NAME);
        }
  
        callback(null, env.IS_APP_ENGINE);
      });
    }
  
    isCloudFunction (callback) {
      setImmediate(() => {
        var env = this.environment;
  
        if (typeof env.IS_CLOUD_FUNCTION === 'undefined') {
          env.IS_CLOUD_FUNCTION = !!process.env.FUNCTION_NAME;
        }
  
        callback(null, env.IS_CLOUD_FUNCTION);
      });
    }
  
    isComputeEngine (callback) {
      var env = this.environment;
  
      if (typeof env.IS_COMPUTE_ENGINE !== 'undefined') {
        setImmediate(() => {
          callback(null, env.IS_COMPUTE_ENGINE);
        });
        return;
      }
  
      request('http://metadata.google.internal', (err, res) => {
        env.IS_COMPUTE_ENGINE = !err && res.headers['metadata-flavor'] === 'Google';
  
        callback(null, env.IS_COMPUTE_ENGINE);
      });
    }
  
    isContainerEngine (callback) {
      var env = this.environment;
  
      if (typeof env.IS_CONTAINER_ENGINE !== 'undefined') {
        setImmediate(() => {
          callback(null, env.IS_CONTAINER_ENGINE);
        });
        return;
      }
  
      gcpMetadata.instance('/attributes/cluster-name')
        .then(() => {
          env.IS_CONTAINER_ENGINE = true;
          callback(null, env.IS_CONTAINER_ENGINE);
        })
        .catch(() => {
          env.IS_CONTAINER_ENGINE = false
          callback(null, env.IS_CONTAINER_ENGINE);
        });
    }
  
    sign (data, callback) {
      this.getCredentials((err, credentials) => {
        if (err) {
          callback(err);
          return;
        }
  
        if (credentials.private_key) {
          this._signWithPrivateKey(data, callback);
        } else {
          this._signWithApi(data, callback);
        }
      });
    }
  
    // `this.getCredentials()` will always have been run by this time
    _signWithApi (data, callback) {
      if (!this.projectId) {
        callback(new Error('Cannot sign data without a project ID.'));
        return;
      }
  
      var client_email = this.credentials.client_email;
  
      if (!client_email) {
        callback(new Error('Cannot sign data without `client_email`.'));
        return;
      }
  
      var idString = `projects/${this.projectId}/serviceAccounts/${client_email}`;
  
      var reqOpts = {
        method: 'POST',
        uri: `https://iam.googleapis.com/v1/${idString}:signBlob`,
        json: {
          bytesToSign: Buffer.from(data).toString('base64')
        }
      };
  
      this.authorizeRequest(reqOpts, (err, authorizedReqOpts) => {
        if (err) {
          callback(err);
          return;
        }
  
        request(authorizedReqOpts, function(err, resp, body) {
          var response = resp.toJSON();
  
          if (!err && response.statusCode < 200 || response.statusCode >= 400) {
            if (typeof response.body === 'object') {
              var apiError = response.body.error;
              err = new Error(apiError.message);
              Object.assign(err, apiError);
            } else {
              err = new Error(response.body);
              err.code = response.statusCode;
            }
          }
  
          callback(err, body && body.signature);
        });
      });
    }
  
    // `this.getCredentials()` will always have been run by this time
    _signWithPrivateKey (data, callback) {
      var sign = crypto.createSign('RSA-SHA256');
      sign.update(data);
      callback(null, sign.sign(this.credentials.private_key, 'base64'));
    }
  }
  
  module.exports = config => {
    return new Auth(config);
  };
  