'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.jwtClient = null;
    this.projectId = this.config.projectId;
    this.token = this.config.token;
  }

  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 config = this.config;
      var keyFile = config.keyFilename || config.keyFile;

      this.googleAuthClient = new GoogleAuth();

      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;

        if (!this.projectId) {
          this.googleAuthClient.getDefaultProjectId((err, projectId) => {
            // Ignore error, since the user might not require a project ID.

            if (projectId) {
              this.projectId = projectId;
            }

            resolve(authClient);
          });
          return;
        }

        resolve(authClient);
      };

      if (config.credentials) {
        try {
          var client = this.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 = this.googleAuthClient.fromJSON(JSON.parse(contents));
            addScope(null, client);
          } catch(e) {
            // @TODO Find a better way to do this.
            // Ref: https://github.com/googleapis/nodejs-storage/issues/147
            // Ref: https://github.com/google/google-auth-library-nodejs/issues/313
            var client = this.googleAuthClient.fromJSON({
              type: 'jwt-pem-p12',
              client_email: config.email,
              private_key: keyFile
            });
            delete client.key;
            client.keyFile = keyFile;
            this.jwtClient = client;
            addScope(null, client);
          }
        });
      } else {
        this.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;

        if (this.jwtClient) {
          this.jwtClient.authorize(err => {
            if (err) {
              callback(err);
              return;
            }

            this.credentials.private_key = this.jwtClient.key;

            callback(null, this.credentials);
          });
          return;
        }

        callback(null, this.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) {
    if (this.token) {
      setImmediate(callback, null, this.token);
      return;
    }

    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);
};