var AWS = require('../core');
var crypto = require('crypto');
var fs = require('fs');
var path = require('path');
var iniLoader = AWS.util.iniLoader;

// Tracking refresh attempt to ensure refresh is not attempted more than once every 30 seconds.
var lastRefreshAttemptTime = 0;

/**
 * Throws error is key is not present in token object.
 *
 * @param token [Object] Object to be validated.
 * @param key [String] The key to be validated on the object.
 */
var validateTokenKey = function validateTokenKey(token, key) {
  if (!token[key]) {
    throw AWS.util.error(
      new Error('Key "' + key + '" not present in SSO Token'),
      { code: 'SSOTokenProviderFailure' }
    );
  }
};

/**
 * Calls callback function with or without error based on provided times in case
 * of unsuccessful refresh.
 *
 * @param currentTime [number] current time in milliseconds since ECMAScript epoch.
 * @param tokenExpireTime [number] token expire time in milliseconds since ECMAScript epoch.
 * @param callback [Function] Callback to call in case of error.
 */
var refreshUnsuccessful = function refreshUnsuccessful(
  currentTime,
  tokenExpireTime,
  callback
) {
  if (tokenExpireTime > currentTime) {
    // Cached token is still valid, return.
    callback(null);
  } else {
    // Token invalid, throw error requesting user to sso login.
    throw AWS.util.error(
      new Error('SSO Token refresh failed. Please log in using "aws sso login"'),
      { code: 'SSOTokenProviderFailure' }
    );
  }
};

/**
 * Represents token loaded from disk derived from the AWS SSO device grant authorication flow.
 *
 * ## Using SSO Token Provider
 *
 * This provider is checked by default in the Node.js environment in TokenProviderChain.
 * To use the SSO Token Provider, simply add your SSO Start URL and Region to the
 * ~/.aws/config file in the following format:
 *
 *     [default]
 *     sso_start_url = https://d-abc123.awsapps.com/start
 *     sso_region = us-east-1
 *
 * ## Using custom profiles
 *
 * The SDK supports loading token for separate profiles. This can be done in two ways:
 *
 * 1. Set the `AWS_PROFILE` environment variable in your process prior to loading the SDK.
 * 2. Directly load the AWS.SSOTokenProvider:
 *
 * ```javascript
 * var ssoTokenProvider = new AWS.SSOTokenProvider({profile: 'myprofile'});
 * ```
 *
 * @!macro nobrowser
 */
AWS.SSOTokenProvider = AWS.util.inherit(AWS.Token, {
  /**
   * Expiry window of five minutes.
   */
  expiryWindow: 5 * 60,

  /**
   * Creates a new token object from cached access token.
   *
   * @param options [map] a set of options
   * @option options profile [String] (AWS_PROFILE env var or 'default')
   *   the name of the profile to load.
   * @option options callback [Function] (err) Token is eagerly loaded
   *   by the constructor. When the callback is called with no error, the
   *   token has been loaded successfully.
   */
  constructor: function SSOTokenProvider(options) {
    AWS.Token.call(this);

    options = options || {};

    this.expired = true;
    this.profile = options.profile || process.env.AWS_PROFILE || AWS.util.defaultProfile;
    this.get(options.callback || AWS.util.fn.noop);
  },

  /**
   * Reads sso_start_url from provided profile, and reads token from
   * ~/.aws/sso/cache/<sha1-of-utf8-encoded-value-from-sso_start_url>.json
   *
   * Throws an error if required fields token and expiresAt are missing.
   * Throws an error if token has expired and metadata to perform refresh is
   * not available.
   * Attempts to refresh the token if it's within 5 minutes before expiry time.
   *
   * @api private
   */
  load: function load(callback) {
    var self = this;
    var profiles = iniLoader.loadFrom({ isConfig: true });
    var profile = profiles[this.profile] || {};

    if (Object.keys(profile).length === 0) {
      throw AWS.util.error(
        new Error('Profile "' + this.profile + '" not found'),
        { code: 'SSOTokenProviderFailure' }
      );
    } else if (!profile['sso_session']) {
      throw AWS.util.error(
        new Error('Profile "' + profileName + '" is missing required property "sso_session".'),
        { code: 'SSOTokenProviderFailure' }
      );
    }

    var ssoSessionName = profile['sso_session'];
    var ssoSessions = iniLoader.loadSsoSessionsFrom();
    var ssoSession = ssoSessions[ssoSessionName];

    if (!ssoSession) {
      throw AWS.util.error(
        new Error('Sso session "' + ssoSessionName + '" not found'),
        { code: 'SSOTokenProviderFailure' }
      );
    } else if (!ssoSession['sso_start_url']) {
      throw AWS.util.error(
        new Error('Sso session "' + profileName + '" is missing required property "sso_start_url".'),
        { code: 'SSOTokenProviderFailure' }
      );
    } else if (!ssoSession['sso_region']) {
      throw AWS.util.error(
        new Error('Sso session "' + profileName + '" is missing required property "sso_region".'),
        { code: 'SSOTokenProviderFailure' }
      );
    }

    var hasher = crypto.createHash('sha1');
    var fileName = hasher.update(ssoSessionName).digest('hex') + '.json';
    var cachePath = path.join(iniLoader.getHomeDir(), '.aws', 'sso', 'cache', fileName);
    var tokenFromCache = JSON.parse(fs.readFileSync(cachePath));

    if (!tokenFromCache) {
      throw AWS.util.error(
        new Error('Cached token not found. Please log in using "aws sso login"'
          + ' for profile "' + this.profile + '".'),
        { code: 'SSOTokenProviderFailure' }
      );
    }

    validateTokenKey(tokenFromCache, 'accessToken');
    validateTokenKey(tokenFromCache, 'expiresAt');

    var currentTime = AWS.util.date.getDate().getTime();
    var adjustedTime = new Date(currentTime + this.expiryWindow * 1000);
    var tokenExpireTime = new Date(tokenFromCache['expiresAt']);

    if (tokenExpireTime > adjustedTime) {
      // Token is valid and not expired.
      self.token = tokenFromCache.accessToken;
      self.expireTime = tokenExpireTime;
      self.expired = false;
      callback(null);
      return;
    }

    // Skip new refresh, if last refresh was done within 30 seconds.
    if (currentTime - lastRefreshAttemptTime < 30 * 1000) {
      refreshUnsuccessful(currentTime, tokenExpireTime, callback);
      return;
    }

    // Token is in expiry window, refresh from SSOOIDC.createToken() call.
    validateTokenKey(tokenFromCache, 'clientId');
    validateTokenKey(tokenFromCache, 'clientSecret');
    validateTokenKey(tokenFromCache, 'refreshToken');

    if (!self.service || self.service.config.region !== ssoSession.sso_region) {
      self.service = new AWS.SSOOIDC({ region: ssoSession.sso_region });
    }

    var params = {
      clientId: tokenFromCache.clientId,
      clientSecret: tokenFromCache.clientSecret,
      refreshToken: tokenFromCache.refreshToken,
      grantType: 'refresh_token',
    };

    lastRefreshAttemptTime = AWS.util.date.getDate().getTime();
    self.service.createToken(params, function(err, data) {
      if (err || !data) {
        refreshUnsuccessful(currentTime, tokenExpireTime, callback);
      } else {
        try {
          validateTokenKey(data, 'accessToken');
          validateTokenKey(data, 'expiresIn');
          self.expired = false;
          self.token = data.accessToken;
          self.expireTime = new Date(Date.now() + data.expiresIn * 1000);
          callback(null);

          try {
            // Write updated token data to disk.
            tokenFromCache.accessToken = data.accessToken;
            tokenFromCache.expiresAt = self.expireTime.toISOString();
            tokenFromCache.refreshToken = data.refreshToken;
            fs.writeFileSync(cachePath, JSON.stringify(tokenFromCache, null, 2));
          } catch (error) {
            // Swallow error if unable to write token to file.
          }
        } catch (error) {
          refreshUnsuccessful(currentTime, tokenExpireTime, callback);
        }
      }
    });
  },

  /**
   * Loads the cached access token from disk.
   *
   * @callback callback function(err)
   *   Called after the AWS SSO process has been executed. When this
   *   callback is called with no error, it means that the token information
   *   has been loaded into the object (as the `token` property).
   *   @param err [Error] if an error occurred, this value will be filled.
   * @see get
   */
  refresh: function refresh(callback) {
    iniLoader.clearCachedFiles();
    this.coalesceRefresh(callback || AWS.util.fn.callback);
  },
});
