'use strict';

Object.defineProperty(exports, "__esModule", {
  value: true
});
exports.BLOCKSTACK_GAIA_HUB_LABEL = exports.uploadToGaiaHub = exports.connectToGaiaHub = undefined;

var _slicedToArray = function () { function sliceIterator(arr, i) { var _arr = []; var _n = true; var _d = false; var _e = undefined; try { for (var _i = arr[Symbol.iterator](), _s; !(_n = (_s = _i.next()).done); _n = true) { _arr.push(_s.value); if (i && _arr.length === i) break; } } catch (err) { _d = true; _e = err; } finally { try { if (!_n && _i["return"]) _i["return"](); } finally { if (_d) throw _e; } } return _arr; } return function (arr, i) { if (Array.isArray(arr)) { return arr; } else if (Symbol.iterator in Object(arr)) { return sliceIterator(arr, i); } else { throw new TypeError("Invalid attempt to destructure non-iterable instance"); } }; }();
// export { type GaiaHubConfig } from './hub'

exports.getUserAppFileUrl = getUserAppFileUrl;
exports.encryptContentImpl = encryptContentImpl;
exports.decryptContentImpl = decryptContentImpl;
exports.getFileImpl = getFileImpl;
exports.putFileImpl = putFileImpl;
exports.getAppBucketUrl = getAppBucketUrl;
exports.listFilesImpl = listFilesImpl;

var _hub = require('./hub');

var _encryption = require('../encryption');

var _keys = require('../keys');

var _profiles = require('../profiles');

var _errors = require('../errors');

var _logger = require('../logger');

var SIGNATURE_FILE_SUFFIX = '.sig';

/**
 * Fetch the public read URL of a user file for the specified app.
 * @param {String} path - the path to the file to read
 * @param {String} username - The Blockstack ID of the user to look up
 * @param {String} appOrigin - The app origin
 * @param {String} [zoneFileLookupURL=null] - The URL
 * to use for zonefile lookup. If falsey, this will use the
 * blockstack.js's getNameInfo function instead.
 * @return {Promise} that resolves to the public read URL of the file
 * or rejects with an error
 */
function getUserAppFileUrl(path, username, appOrigin) {
  var zoneFileLookupURL = arguments.length > 3 && arguments[3] !== undefined ? arguments[3] : null;

  return (0, _profiles.lookupProfile)(username, zoneFileLookupURL).then(function (profile) {
    if (profile.hasOwnProperty('apps')) {
      if (profile.apps.hasOwnProperty(appOrigin)) {
        return profile.apps[appOrigin];
      } else {
        return null;
      }
    } else {
      return null;
    }
  }).then(function (bucketUrl) {
    if (bucketUrl) {
      var bucket = bucketUrl.replace(/\/?(\?|#|$)/, '/$1');
      return '' + bucket + path;
    } else {
      return null;
    }
  });
}

/**
 * Encrypts the data provided with the app public key.
 * @param {UserSession} caller - the instance calling this method
 * @param {String|Buffer} content - data to encrypt
 * @param {Object} [options=null] - options object
 * @param {String} options.publicKey - the hex string of the ECDSA public
 * key to use for encryption. If not provided, will use user's appPrivateKey.
 * @return {String} Stringified ciphertext object
 * @private
 */
function encryptContentImpl(caller, content, options) {
  var defaults = { publicKey: null };
  var opt = Object.assign({}, defaults, options);
  if (!opt.publicKey) {
    var userData = caller.loadUserData();
    var privateKey = userData.appPrivateKey;
    opt.publicKey = (0, _keys.getPublicKeyFromPrivate)(privateKey);
  }

  var cipherObject = (0, _encryption.encryptECIES)(opt.publicKey, content);
  return JSON.stringify(cipherObject);
}

/**
 * Decrypts data encrypted with `encryptContent` with the
 * transit private key.
 * @param {UserSession} caller - the instance calling this method
 * @param {String|Buffer} content - encrypted content.
 * @param {Object} [options=null] - options object
 * @param {String} options.privateKey - the hex string of the ECDSA private
 * key to use for decryption. If not provided, will use user's appPrivateKey.
 * @return {String|Buffer} decrypted content.
 * @private
 */
function decryptContentImpl(caller, content, options) {
  var defaults = { privateKey: null };
  var opt = Object.assign({}, defaults, options);
  var privateKey = opt.privateKey;
  if (!privateKey) {
    privateKey = caller.loadUserData().appPrivateKey;
  }

  try {
    var cipherObject = JSON.parse(content);
    return (0, _encryption.decryptECIES)(privateKey, cipherObject);
  } catch (err) {
    if (err instanceof SyntaxError) {
      throw new Error('Failed to parse encrypted content JSON. The content may not ' + 'be encrypted. If using getFile, try passing { decrypt: false }.');
    } else {
      throw err;
    }
  }
}

/* Get the gaia address used for servicing multiplayer reads for the given
 * (username, app) pair.
 * @private
 */
function getGaiaAddress(caller, app, username, zoneFileLookupURL) {
  return Promise.resolve().then(function () {
    if (username) {
      return getUserAppFileUrl('/', username, app, zoneFileLookupURL);
    } else {
      return (0, _hub.getOrSetLocalGaiaHubConnection)(caller).then(function (gaiaHubConfig) {
        return (0, _hub.getFullReadUrl)('/', gaiaHubConfig);
      });
    }
  }).then(function (fileUrl) {
    var matches = fileUrl.match(/([13][a-km-zA-HJ-NP-Z0-9]{26,35})/);
    if (!matches) {
      throw new Error('Failed to parse gaia address');
    }
    return matches[matches.length - 1];
  });
}

/* Handle fetching the contents from a given path. Handles both
 *  multi-player reads and reads from own storage.
 * @private
 */
function getFileContents(caller, path, app, username, zoneFileLookupURL, forceText) {
  return Promise.resolve().then(function () {
    if (username) {
      return getUserAppFileUrl(path, username, app, zoneFileLookupURL);
    } else {
      return (0, _hub.getOrSetLocalGaiaHubConnection)(caller).then(function (gaiaHubConfig) {
        return (0, _hub.getFullReadUrl)(path, gaiaHubConfig);
      });
    }
  }).then(function (readUrl) {
    return new Promise(function (resolve, reject) {
      if (!readUrl) {
        reject(null);
      } else {
        resolve(readUrl);
      }
    });
  }).then(function (readUrl) {
    return fetch(readUrl);
  }).then(function (response) {
    if (response.status !== 200) {
      if (response.status === 404) {
        _logger.Logger.debug('getFile ' + path + ' returned 404, returning null');
        return null;
      } else {
        throw new Error('getFile ' + path + ' failed with HTTP status ' + response.status);
      }
    }
    var contentType = response.headers.get('Content-Type');
    if (forceText || contentType === null || contentType.startsWith('text') || contentType === 'application/json') {
      return response.text();
    } else {
      return response.arrayBuffer();
    }
  });
}

/* Handle fetching an unencrypted file, its associated signature
 *  and then validate it. Handles both multi-player reads and reads
 *  from own storage.
 * @private
 */
function getFileSignedUnencrypted(caller, path, opt) {
  // future optimization note:
  //    in the case of _multi-player_ reads, this does a lot of excess
  //    profile lookups to figure out where to read files
  //    do browsers cache all these requests if Content-Cache is set?
  return Promise.all([getFileContents(caller, path, opt.app, opt.username, opt.zoneFileLookupURL, false), getFileContents(caller, '' + path + SIGNATURE_FILE_SUFFIX, opt.app, opt.username, opt.zoneFileLookupURL, true), getGaiaAddress(caller, opt.app, opt.username, opt.zoneFileLookupURL)]).then(function (_ref) {
    var _ref2 = _slicedToArray(_ref, 3),
        fileContents = _ref2[0],
        signatureContents = _ref2[1],
        gaiaAddress = _ref2[2];

    if (!fileContents) {
      return fileContents;
    }
    if (!gaiaAddress) {
      throw new _errors.SignatureVerificationError('Failed to get gaia address for verification of: ' + ('' + path));
    }
    if (!signatureContents || typeof signatureContents !== 'string') {
      throw new _errors.SignatureVerificationError('Failed to obtain signature for file: ' + (path + ' -- looked in ' + path + SIGNATURE_FILE_SUFFIX));
    }
    var signature = void 0;
    var publicKey = void 0;
    try {
      var sigObject = JSON.parse(signatureContents);
      signature = sigObject.signature;
      publicKey = sigObject.publicKey;
    } catch (err) {
      if (err instanceof SyntaxError) {
        throw new Error('Failed to parse signature content JSON ' + ('(path: ' + path + SIGNATURE_FILE_SUFFIX + ')') + ' The content may be corrupted.');
      } else {
        throw err;
      }
    }
    var signerAddress = (0, _keys.publicKeyToAddress)(publicKey);
    if (gaiaAddress !== signerAddress) {
      throw new _errors.SignatureVerificationError('Signer pubkey address (' + signerAddress + ') doesn\'t' + (' match gaia address (' + gaiaAddress + ')'));
    } else if (!(0, _encryption.verifyECDSA)(Buffer.from(fileContents), publicKey, signature)) {
      throw new _errors.SignatureVerificationError('Contents do not match ECDSA signature: ' + ('path: ' + path + ', signature: ' + path + SIGNATURE_FILE_SUFFIX));
    } else {
      return fileContents;
    }
  });
}

/* Handle signature verification and decryption for contents which are
 *  expected to be signed and encrypted. This works for single and
 *  multiplayer reads. In the case of multiplayer reads, it uses the
 *  gaia address for verification of the claimed public key.
 * @private
 */
function handleSignedEncryptedContents(caller, path, storedContents, app, username, zoneFileLookupURL) {
  var appPrivateKey = caller.loadUserData().appPrivateKey;
  var appPublicKey = (0, _keys.getPublicKeyFromPrivate)(appPrivateKey);

  var addressPromise = void 0;
  if (username) {
    addressPromise = getGaiaAddress(caller, app, username, zoneFileLookupURL);
  } else {
    var address = (0, _keys.publicKeyToAddress)(appPublicKey);
    addressPromise = Promise.resolve(address);
  }

  return addressPromise.then(function (address) {
    if (!address) {
      throw new _errors.SignatureVerificationError('Failed to get gaia address for verification of: ' + ('' + path));
    }
    var sigObject = void 0;
    try {
      sigObject = JSON.parse(storedContents);
    } catch (err) {
      if (err instanceof SyntaxError) {
        throw new Error('Failed to parse encrypted, signed content JSON. The content may not ' + 'be encrypted. If using getFile, try passing' + ' { verify: false, decrypt: false }.');
      } else {
        throw err;
      }
    }
    var signature = sigObject.signature;
    var signerPublicKey = sigObject.publicKey;
    var cipherText = sigObject.cipherText;
    var signerAddress = (0, _keys.publicKeyToAddress)(signerPublicKey);

    if (!signerPublicKey || !cipherText || !signature) {
      throw new _errors.SignatureVerificationError('Failed to get signature verification data from file:' + (' ' + path));
    } else if (signerAddress !== address) {
      throw new _errors.SignatureVerificationError('Signer pubkey address (' + signerAddress + ') doesn\'t' + (' match gaia address (' + address + ')'));
    } else if (!(0, _encryption.verifyECDSA)(cipherText, signerPublicKey, signature)) {
      throw new _errors.SignatureVerificationError('Contents do not match ECDSA signature in file:' + (' ' + path));
    } else {
      return caller.decryptContent(cipherText);
    }
  });
}

/**
 * Retrieves the specified file from the app's data store.
 * @param {UserSession} caller - instance calling this method
 * @param {String} path - the path to the file to read
 * @param {Object} [options=null] - options object
 * @param {Boolean} [options.decrypt=true] - try to decrypt the data with the app private key
 * @param {String} options.username - the Blockstack ID to lookup for multi-player storage
 * @param {Boolean} options.verify - Whether the content should be verified, only to be used
 * when `putFile` was set to `sign = true`
 * @param {String} options.app - the app to lookup for multi-player storage -
 * defaults to current origin
 * @param {String} [options.zoneFileLookupURL=null] - The URL
 * to use for zonefile lookup. If falsey, this will use the
 * blockstack.js's getNameInfo function instead.
 * @returns {Promise} that resolves to the raw data in the file
 * or rejects with an error
 * @private
 */
function getFileImpl(caller, path, options) {
  var appConfig = caller.appConfig;
  if (!appConfig) {
    throw new _errors.InvalidStateError('Missing AppConfig');
  }
  var defaults = {
    decrypt: true,
    verify: false,
    username: null,
    app: appConfig.appDomain,
    zoneFileLookupURL: null
  };

  var opt = Object.assign({}, defaults, options);

  // in the case of signature verification, but no
  //  encryption expected, need to fetch _two_ files.
  if (opt.verify && !opt.decrypt) {
    return getFileSignedUnencrypted(caller, path, opt);
  }

  return getFileContents(caller, path, opt.app, opt.username, opt.zoneFileLookupURL, !!opt.decrypt).then(function (storedContents) {
    if (storedContents === null) {
      return storedContents;
    } else if (opt.decrypt && !opt.verify) {
      if (typeof storedContents !== 'string') {
        throw new Error('Expected to get back a string for the cipherText');
      }
      return decryptContentImpl(caller, storedContents);
    } else if (opt.decrypt && opt.verify) {
      if (typeof storedContents !== 'string') {
        throw new Error('Expected to get back a string for the cipherText');
      }
      return handleSignedEncryptedContents(caller, path, storedContents, opt.app, opt.username, opt.zoneFileLookupURL);
    } else if (!opt.verify && !opt.decrypt) {
      return storedContents;
    } else {
      throw new Error('Should be unreachable.');
    }
  });
}

/**
 * Stores the data provided in the app's data store to to the file specified.
 * @param {UserSession} caller - instance calling this method
 * @param {String} path - the path to store the data in
 * @param {String|Buffer} content - the data to store in the file
 * @param {Object} [options=null] - options object
 * @param {Boolean|String} [options.encrypt=true] - encrypt the data with the app private key
 *                                                  or the provided public key
 * @param {Boolean} [options.sign=false] - sign the data using ECDSA on SHA256 hashes with
 *                                         the app private key
 * @return {Promise} that resolves if the operation succeed and rejects
 * if it failed
 * @private
 */
function putFileImpl(caller, path, content, options) {
  var defaults = {
    encrypt: true,
    sign: false
  };

  var opt = Object.assign({}, defaults, options);

  var contentType = 'text/plain';
  if (typeof content !== 'string') {
    contentType = 'application/octet-stream';
  }

  // First, let's figure out if we need to get public/private keys,
  //  or if they were passed in

  var privateKey = '';
  var publicKey = '';
  if (opt.sign) {
    if (typeof opt.sign === 'string') {
      privateKey = opt.sign;
    } else {
      privateKey = caller.loadUserData().appPrivateKey;
    }
  }
  if (opt.encrypt) {
    if (typeof opt.encrypt === 'string') {
      publicKey = opt.encrypt;
    } else {
      if (!privateKey) {
        privateKey = caller.loadUserData().appPrivateKey;
      }
      publicKey = (0, _keys.getPublicKeyFromPrivate)(privateKey);
    }
  }

  // In the case of signing, but *not* encrypting,
  //   we perform two uploads. So the control-flow
  //   here will return there.
  if (!opt.encrypt && opt.sign) {
    var signatureObject = (0, _encryption.signECDSA)(privateKey, content);
    var signatureContent = JSON.stringify(signatureObject);
    return (0, _hub.getOrSetLocalGaiaHubConnection)(caller).then(function (gaiaHubConfig) {
      return Promise.all([(0, _hub.uploadToGaiaHub)(path, content, gaiaHubConfig, contentType), (0, _hub.uploadToGaiaHub)('' + path + SIGNATURE_FILE_SUFFIX, signatureContent, gaiaHubConfig, 'application/json')]);
    }).then(function (fileUrls) {
      return fileUrls[0];
    });
  }

  // In all other cases, we only need one upload.
  if (opt.encrypt && !opt.sign) {
    content = encryptContentImpl(caller, content, { publicKey: publicKey });
    contentType = 'application/json';
  } else if (opt.encrypt && opt.sign) {
    var cipherText = encryptContentImpl(caller, content, { publicKey: publicKey });
    var _signatureObject = (0, _encryption.signECDSA)(privateKey, cipherText);
    var signedCipherObject = {
      signature: _signatureObject.signature,
      publicKey: _signatureObject.publicKey,
      cipherText: cipherText
    };
    content = JSON.stringify(signedCipherObject);
    contentType = 'application/json';
  }
  return (0, _hub.getOrSetLocalGaiaHubConnection)(caller).then(function (gaiaHubConfig) {
    return (0, _hub.uploadToGaiaHub)(path, content, gaiaHubConfig, contentType);
  });
}

/**
 * Get the app storage bucket URL
 * @param {String} gaiaHubUrl - the gaia hub URL
 * @param {String} appPrivateKey - the app private key used to generate the app address
 * @returns {Promise} That resolves to the URL of the app index file
 * or rejects if it fails
 */
function getAppBucketUrl(gaiaHubUrl, appPrivateKey) {
  return (0, _hub.getBucketUrl)(gaiaHubUrl, appPrivateKey);
}

/**
 * Loop over the list of files in a Gaia hub, and run a callback on each entry.
 * Not meant to be called by external clients.
 * @param {GaiaHubConfig} hubConfig - the Gaia hub config
 * @param {String | null} page - the page ID
 * @param {number} callCount - the loop count
 * @param {number} fileCount - the number of files listed so far
 * @param {function} callback - the callback to invoke on each file.  If it returns a falsey
 *  value, then the loop stops.  If it returns a truthy value, the loop continues.
 * @returns {Promise} that resolves to the number of files listed.
 * @private
 */
function listFilesLoop(hubConfig, page, callCount, fileCount, callback) {
  if (callCount > 65536) {
    // this is ridiculously huge, and probably indicates
    // a faulty Gaia hub anyway (e.g. on that serves endless data)
    throw new Error('Too many entries to list');
  }

  var httpStatus = void 0;
  var pageRequest = JSON.stringify({ page: page });

  var fetchOptions = {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'Content-Length': '' + pageRequest.length,
      Authorization: 'bearer ' + hubConfig.token
    },
    body: pageRequest
  };

  return fetch(hubConfig.server + '/list-files/' + hubConfig.address, fetchOptions).then(function (response) {
    httpStatus = response.status;
    if (httpStatus >= 400) {
      throw new Error('listFiles failed with HTTP status ' + httpStatus);
    }
    return response.text();
  }).then(function (responseText) {
    return JSON.parse(responseText);
  }).then(function (responseJSON) {
    var entries = responseJSON.entries;
    var nextPage = responseJSON.page;
    if (entries === null || entries === undefined) {
      // indicates a misbehaving Gaia hub or a misbehaving driver
      // (i.e. the data is malformed)
      throw new Error('Bad listFiles response: no entries');
    }
    for (var i = 0; i < entries.length; i++) {
      var rc = callback(entries[i]);
      if (!rc) {
        // callback indicates that we're done
        return Promise.resolve(fileCount + i);
      }
    }
    if (nextPage && entries.length > 0) {
      // keep going -- have more entries
      return listFilesLoop(hubConfig, nextPage, callCount + 1, fileCount + entries.length, callback);
    } else {
      // no more entries -- end of data
      return Promise.resolve(fileCount);
    }
  });
}

/**
 * List the set of files in this application's Gaia storage bucket.
 * @param {UserSession} caller - instance calling this method
 * @param {function} callback - a callback to invoke on each named file that
 * returns `true` to continue the listing operation or `false` to end it
 * @return {Promise} that resolves to the number of files listed
 * @private
 */
function listFilesImpl(caller, callback) {
  return (0, _hub.getOrSetLocalGaiaHubConnection)(caller).then(function (gaiaHubConfig) {
    return listFilesLoop(gaiaHubConfig, null, 0, 0, callback);
  });
}

exports.connectToGaiaHub = _hub.connectToGaiaHub;
exports.uploadToGaiaHub = _hub.uploadToGaiaHub;
exports.BLOCKSTACK_GAIA_HUB_LABEL = _hub.BLOCKSTACK_GAIA_HUB_LABEL;