text: // URL parsing and validation
  // RPC to server (endpoint, arguments)
  // see if RPC requires password
  // prompt for password
  // send RPC with or without password as required
  
  var qs = require('querystring');
  var path = require('path');
  var files = require('./files.js');
  var httpHelpers = require('./http-helpers.js');
  var buildmessage = require('./buildmessage.js');
  var config = require('./config.js');
  var auth = require('./auth.js');
  var utils = require('./utils.js');
  var _ = require('underscore');
  var Future = require('fibers/future');
  var stats = require('./stats.js');
  
  // Make a synchronous RPC to the "classic" MDG deploy API. The deploy
  // API has the following contract:
  //
  // - Parameters are always sent in the query string.
  // - A tarball can be sent in the body (when deploying an app).
  // - On success, all calls return HTTP 200. Those that return a value
  //   either return a JSON payload or a plaintext payload and the
  //   Content-Type header is set appropriately.
  // - On failure, calls return some non-200 HTTP status code and
  //   provide a human-readable error message in the body.
  // - URLs are of the form "/[operation]/[site]".
  // - Body encodings are always utf8.
  // - Meteor Accounts auth is possible using first-party MDG cookies
  //   (rather than OAuth).
  //
  // Options include:
  // - method: GET, POST, or DELETE. default GET
  // - operation: "info", "logs", "mongo", "deploy", "authorized-apps"
  // - site: site name
  // - expectPayload: an array of key names. if present, then we expect
  //   the server to return JSON content on success and to return an
  //   object with all of these key names.
  // - expectMessage: if true, then we expect the server to return text
  //   content on success.
  // - bodyStream: if provided, a stream to use as the request body
  // - any other parameters accepted by the node 'request' module, for example
  //   'qs' to set query string parameters
  //
  // Waits until server responds, then returns an object with the
  // following keys:
  //
  // - statusCode: HTTP status code, or null if the server couldn't be
  //   contacted
  // - payload: if successful, and the server returned a JSON body, the
  //   parsed JSON body
  // - message: if successful, and the server returned a text body, the
  //   body as a string
  // - errorMessage: if unsuccessful, a human-readable error message,
  //   derived from either a transport-level exception, the response
  //   body, or a generic 'try again later' message, as appropriate
  
  var deployRpc = function (options) {
    var genericError = "Server error (please try again later)";
  
    options = _.clone(options);
    options.headers = _.clone(options.headers || {});
    if (options.headers.cookie)
      throw new Error("sorry, can't combine cookie headers yet");
  
    try {
      var result = httpHelpers.request(_.extend(options, {
        url: config.getDeployUrl() + '/' + options.operation +
          (options.site ? ('/' + options.site) : ''),
        method: options.method || 'GET',
        bodyStream: options.bodyStream,
        useAuthHeader: true,
        encoding: 'utf8' // Hack, but good enough for the deploy server..
      }));
    } catch (e) {
      return {
        statusCode: null,
        errorMessage: "Connection error (" + e.message + ")"
      };
    }
  
    var response = result.response;
    var body = result.body;
    var ret = { statusCode: response.statusCode };
  
    if (response.statusCode !== 200) {
      ret.errorMessage = body.length > 0 ? body : genericError;
      return ret;
    }
  
    var contentType = response.headers["content-type"] || '';
    if (contentType === "application/json; charset=utf-8") {
      try {
        ret.payload = JSON.parse(body);
      } catch (e) {
        ret.errorMessage = genericError;
        return ret;
      }
    } else if (contentType === "text/plain; charset=utf-8") {
      ret.message = body;
    }
  
    var hasAllExpectedKeys = _.all(_.map(
      options.expectPayload || [], function (key) {
        return ret.payload && _.has(ret.payload, key);
      }));
  
    if ((options.expectPayload && ! _.has(ret, 'payload')) ||
        (options.expectMessage && ! _.has(ret, 'message')) ||
        ! hasAllExpectedKeys) {
      delete ret.payload;
      delete ret.message;
  
      ret.errorMessage = genericError;
    }
  
    return ret;
  };
  
  // Just like deployRpc, but also presents authentication. It will
  // prompt the user for a password, or use a Meteor Accounts
  // credential, as necessary.
  //
  // Additional options (beyond deployRpc):
  //
  // - preflight: if true, do everything but the actual RPC. The only
  //   other necessary option is 'site'. On failure, returns an object
  //   with errorMessage (just like deployRpc). On success, returns an
  //   object without an errorMessage key and with possible keys
  //   'protection' (value either 'password' or 'account') and
  //   'authorized' (true if the current user is an authorized user on
  //   this app).
  // - promptIfAuthFails: if true, then we think we are logged in with the
  //   accounts server but our authentication actually fails, then prompt
  //   the user to log in with a username and password and then resend the
  //   RPC.
  var authedRpc = function (options) {
    var rpcOptions = _.clone(options);
    var preflight = rpcOptions.preflight;
    delete rpcOptions.preflight;
  
    // Fetch auth info
    var infoResult = deployRpc({
      operation: 'info',
      site: rpcOptions.site,
      expectPayload: []
    });
  
    if (infoResult.statusCode === 401 && rpcOptions.promptIfAuthFails) {
      // Our authentication didn't validate, so prompt the user to log in
      // again, and resend the RPC if the login succeeds.
      var username = utils.readLine({
        prompt: "Username: ",
        stream: process.stderr
      });
      var loginOptions = {
        username: username,
        suppressErrorMessage: true
      };
      if (auth.doInteractivePasswordLogin(loginOptions)) {
        return authedRpc(options);
      } else {
        return {
          statusCode: 403,
          errorMessage: "login failed."
        };
      }
    }
  
    if (infoResult.statusCode === 404) {
      // Doesn't exist, therefore not protected.
      return preflight ? { } : deployRpc(rpcOptions);
    }
  
    if (infoResult.errorMessage)
      return infoResult;
    var info = infoResult.payload;
  
    if (! _.has(info, 'protection')) {
      // Not protected.
      //
      // XXX should prompt the user to claim the app (only if deploying?)
      return preflight ? { } : deployRpc(rpcOptions);
    }
  
    if (info.protection === "password") {
      if (preflight) {
        return { protection: info.protection };
      }
      // Password protected. Read a password, hash it, and include the
      // hashed password as a query parameter when doing the RPC.
      var password;
      password = utils.readLine({
        echo: false,
        prompt: "Password: ",
        stream: process.stderr
      });
  
      // Hash the password so we never send plaintext over the
      // wire. Doesn't actually make us more secure, but it means we
      // won't leak a user's password, which they might use on other
      // sites too.
      var crypto = require('crypto');
      var hash = crypto.createHash('sha1');
      hash.update('S3krit Salt!');
      hash.update(password);
      password = hash.digest('hex');
  
      rpcOptions = _.clone(rpcOptions);
      rpcOptions.qs = _.clone(rpcOptions.qs || {});
      rpcOptions.qs.password = password;
  
      return deployRpc(rpcOptions);
    }
  
    if (info.protection === "account") {
      if (! _.has(info, 'authorized')) {
        // Absence of this implies that we are not an authorized user on
        // this app
        if (preflight) {
          return { protection: info.protection };
        } else {
          return {
            statusCode: null,
            errorMessage: auth.isLoggedIn() ?
              // XXX better error message (probably need to break out of
              // the 'errorMessage printed with brief prefix' pattern)
              "Not an authorized user on this site" :
              "Not logged in"
          };
        }
      }
  
      // Sweet, we're an authorized user.
      if (preflight) {
        return {
          protection: info.protection,
          authorized: info.authorized
        };
      } else {
        return deployRpc(rpcOptions);
      }
    }
  
    return {
      statusCode: null,
      errorMessage: "You need a newer version of Meteor to work with this site"
    };
  };
  
  // When the user is trying to do something with a legacy
  // password-protected app, instruct them to claim it with 'meteor
  // claim'.
  var printLegacyPasswordMessage = function (site) {
      process.stderr.write(
  "\nThis site was deployed with an old version of Meteor that used\n" +
  "site passwords instead of user accounts. Now we have a much better\n" +
  "system, Meteor developer accounts.\n\n" +
  "If this is your site, please claim it into your account with\n" +
  "  meteor claim " + site + "\n");
  };
  
  // When the user is trying to do something with an app that they are not
  // authorized for, instruct them to get added via 'meteor authorized
  // --add' or switch accounts.
  var printUnauthorizedMessage = function () {
    var username = auth.loggedInUsername();
    process.stderr.write(
  "Sorry, that site belongs to a different user.\n" +
  (username ? "You are currently logged in as " + username  + ".\n" : "") +
  "\nEither have the site owner use 'meteor authorized --add' to add you\n" +
  "as an authorized developer for the site, or switch to an authorized\n" +
  "account with 'meteor login'.\n");
  };
  
  // Take a proposed sitename for deploying to. If it looks
  // syntactically good, canonicalize it (this essentially means
  // stripping 'http://' or a trailing '/' if present) and return it. If
  // not, print an error message to stderr and return null.
  var canonicalizeSite = function (site) {
    // There are actually two different bugs here. One is that the meteor deploy
    // server does not support apps whose total site length is greater than 63
    // (because of how it generates Mongo database names); that can be fixed on
    // the server. After that, this check will be too strong, but we still will
    // want to check that each *component* of the hostname is at most 63
    // characters (url.parse will do something very strange if a component is
    // larger than 63, which is the maximum legal length).
    if (site.length > 63) {
      process.stdout.write(
  "The maximum hostname length currently supported is 63 characters.\n" +
  site + " is too long.\n" +
  "Please try again with a shorter URL for your site.\n");
      return false;
    }
  
    var url = site;
    if (!url.match(':\/\/'))
      url = 'http://' + url;
  
    var parsed = require('url').parse(url);
  
    if (! parsed.hostname) {
      process.stdout.write(
  "Please specify a domain to connect to, such as www.example.com or\n" +
  "http://www.example.com/\n");
      return false;
    }
  
    if (parsed.pathname != '/' || parsed.hash || parsed.query) {
      process.stdout.write(
  "Sorry, Meteor does not yet support specific path URLs, such as\n" +
  "http://www.example.com/blog .  Please specify the root of a domain.\n");
      return false;
    }
  
    return parsed.hostname;
  };
  
  // Run the bundler and deploy the result. Print progress
  // messages. Return a command exit code.
  //
  // Options:
  // - appDir: root directory of app to bundle up
  // - site: site to deploy as
  // - settingsFile: file from which to read deploy settings (undefined
  //   to leave unchanged from previous deploy of the app, if any)
  // - recordPackageUsage: (defaults to true) if set to false, don't
  //   send information about packages used by this app to the package
  //   stats server.
  // - buildOptions: the 'buildOptions' argument to the bundler
  var bundleAndDeploy = function (options) {
    if (options.recordPackageUsage === undefined)
      options.recordPackageUsage = true;
  
    var site = canonicalizeSite(options.site);
    if (! site)
      return 1;
  
    // We should give a username/password prompt if the user was logged in
    // but the credentials are expired, unless the user is logged in but
    // doesn't have a username (in which case they should hit the email
    // prompt -- a user without a username shouldn't be given a username
    // prompt). There's an edge case where things happen in the following
    // order: user creates account, user sets username, credential expires
    // or is revoked, user comes back to deploy again. In that case,
    // they'll get an email prompt instead of a username prompt because
    // the command-line tool didn't have time to learn about their
    // username before the credential was expired.
    auth.pollForRegistrationCompletion({
      noLogout: true
    });
    var promptIfAuthFails = (auth.loggedInUsername() !== null);
  
    // Check auth up front, rather than after the (potentially lengthy)
    // bundling process.
    var preflight = authedRpc({
      site: site,
      preflight: true,
      promptIfAuthFails: promptIfAuthFails
    });
  
    if (preflight.errorMessage) {
      process.stderr.write("\nError deploying application: " +
                           preflight.errorMessage + "\n");
      return 1;
    }
  
    if (preflight.protection === "password") {
      printLegacyPasswordMessage(site);
      process.stderr.write("If it's not your site, please try a different name!\n");
      return 1;
  
    } else if (preflight.protection === "account" &&
               ! preflight.authorized) {
      printUnauthorizedMessage();
      return 1;
    }
  
    var buildDir = path.join(options.appDir, '.meteor', 'local', 'build_tar');
    var bundlePath = path.join(buildDir, 'bundle');
  
    process.stdout.write('Deploying to ' + site + '. Bundling...\n');
  
    var settings = null;
    var messages = buildmessage.capture({
      title: "preparing to deploy",
      rootPath: process.cwd()
    }, function () {
      if (options.settingsFile)
        settings = files.getSettings(options.settingsFile);
    });
  
    if (! messages.hasMessages()) {
      var bundler = require('./bundler.js');
  
      if (options.recordPackageUsage) {
        var statsMessages = buildmessage.capture(function () {
          stats.recordPackages("sdk.deploy", site);
        });
        if (statsMessages.hasMessages()) {
          process.stdout.write("Error recording package list:\n" +
                               statsMessages.formatMessages());
          // ... but continue;
        }
      }
  
      var bundleResult = bundler.bundle({
        outputPath: bundlePath,
        buildOptions: options.buildOptions
      });
  
      if (bundleResult.errors)
        messages.merge(bundleResult.errors);
    }
  
    if (messages.hasMessages()) {
      process.stdout.write("\nErrors prevented deploying:\n");
      process.stdout.write(messages.formatMessages());
      return 1;
    }
  
    process.stdout.write('Uploading...\n');
  
    var result = authedRpc({
      method: 'POST',
      operation: 'deploy',
      site: site,
      qs: settings !== null ? { settings: settings } : {},
      bodyStream: files.createTarGzStream(path.join(buildDir, 'bundle')),
      expectPayload: ['url'],
      preflightPassword: preflight.preflightPassword
    });
  
    if (result.errorMessage) {
      process.stderr.write("\nError deploying application: " +
                           result.errorMessage + "\n");
      return 1;
    }
  
    var deployedAt = require('url').parse(result.payload.url);
    var hostname = deployedAt.hostname;
  
    process.stdout.write('Now serving at ' + hostname + '\n');
    files.rm_recursive(buildDir);
  
    if (! hostname.match(/meteor\.com$/)) {
      var dns = require('dns');
      dns.resolve(hostname, 'CNAME', function (err, cnames) {
        if (err || cnames[0] !== 'origin.meteor.com') {
          dns.resolve(hostname, 'A', function (err, addresses) {
            if (err || addresses[0] !== '107.22.210.133') {
              process.stdout.write('-------------\n');
              process.stdout.write("You've deployed to a custom domain.\n");
              process.stdout.write("Please be sure to CNAME your hostname to origin.meteor.com,\n");
              process.stdout.write("or set an A record to 107.22.210.133.\n");
              process.stdout.write('-------------\n');
            }
          });
          }
      });
    }
  
    return 0;
  };
  
  var deleteApp = function (site) {
    site = canonicalizeSite(site);
    if (! site)
      return 1;
  
    var result = authedRpc({
      method: 'DELETE',
      operation: 'deploy',
      site: site,
      promptIfAuthFails: true
    });
  
    if (result.errorMessage) {
      process.stderr.write("Couldn't delete application: " +
                           result.errorMessage + "\n");
      return 1;
    }
  
    process.stdout.write("Deleted.\n");
    return 0;
  };
  
  // Helper that does a preflight request to check auth, and prints the
  // appropriate error message if auth fails or if this is a legacy
  // password-protected app. If auth succeeds, then it runs the actual
  // RPC. 'site' and 'operation' are the site and operation for the
  // RPC. 'what' is a string describing the operation, for use in error
  // messages.  Returns the result of the RPC if successful, or null
  // otherwise (including if auth failed or if the user is not authorized
  // for this site).
  var checkAuthThenSendRpc = function (site, operation, what) {
    var preflight = authedRpc({
      operation: operation,
      site: site,
      preflight: true,
      promptIfAuthFails: true
    });
  
    if (preflight.errorMessage) {
      process.stderr.write("Couldn't " + what + ": " +
                           preflight.errorMessage + "\n");
      return null;
    }
  
    if (preflight.protection === "password") {
      printLegacyPasswordMessage(site);
      return null;
    } else if (preflight.protection === "account" &&
               ! preflight.authorized) {
      if (! auth.isLoggedIn()) {
        // Maybe the user is authorized for this app but not logged in
        // yet, so give them a login prompt.
        var loginResult = auth.doUsernamePasswordLogin({ retry: true });
        if (loginResult) {
          // Once we've logged in, retry the whole operation. We need to
          // do the preflight request again instead of immediately moving
          // on to the real RPC because we don't yet know if the newly
          // logged-in user is authorized for this app, and if they
          // aren't, then we want to print the nice unauthorized error
          // message.
          return checkAuthThenSendRpc(site, operation, what);
        } else {
          // Shouldn't ever get here because we set the retry flag on the
          // login, but just in case.
          process.stderr.write(
  "\nYou must be logged in to " + what + " for this app. Use 'meteor login'\n" +
  "to log in.\n\n" +
  "If you don't have a Meteor developer account yet, you can quickly\n" +
  "create one at www.meteor.com.\n");
          return null;
        }
      } else { // User is logged in but not authorized for this app
        process.stderr.write("\n");
        printUnauthorizedMessage();
        return null;
      }
    }
  
    // User is authorized for the app; go ahead and do the actual RPC.
  
    var result = authedRpc({
      operation: operation,
      site: site,
      expectMessage: true,
      promptIfAuthFails: true
    });
  
    if (result.errorMessage) {
      process.stderr.write("Couldn't " + what + ": " +
                           result.errorMessage + "\n");
      return null;
    }
  
    return result;
  };
  
  // On failure, prints a message to stderr and returns null. Otherwise,
  // returns a temporary authenticated Mongo URL allowing access to this
  // site's database.
  var temporaryMongoUrl = function (site) {
    site = canonicalizeSite(site);
    if (! site)
      // canonicalizeSite printed an error
      return null;
  
    var result = checkAuthThenSendRpc(site, 'mongo', 'open a mongo connection');
  
    if (result !== null) {
      return result.message;
    } else {
      return null;
    }
  };
  
  var logs = function (site) {
    site = canonicalizeSite(site);
    if (! site)
      return 1;
  
    var result = checkAuthThenSendRpc(site, 'logs', 'view logs');
  
    if (result === null) {
      return 1;
    } else {
      process.stdout.write(result.message);
      auth.maybePrintRegistrationLink({ leadingNewline: true });
      return 0;
    }
  };
  
  var listAuthorized = function (site) {
    site = canonicalizeSite(site);
    if (! site)
      return 1;
  
    var result = deployRpc({
      operation: 'info',
      site: site,
      expectPayload: []
    });
    if (result.errorMessage) {
      process.stderr.write("Couldn't get authorized users list: " +
                           result.errorMessage + "\n");
      return 1;
    }
    var info = result.payload;
  
    if (! _.has(info, 'protection')) {
      process.stdout.write("<anyone>\n");
      return 0;
    }
  
    if (info.protection === "password") {
      process.stdout.write("<password>\n");
      return 0;
    }
  
    if (info.protection === "account") {
      if (! _.has(info, 'authorized')) {
        process.stderr.write("Couldn't get authorized users list: " +
                             "You are not authorized\n");
        return 1;
      }
  
      process.stdout.write((auth.loggedInUsername() || "<you>") + "\n");
      _.each(info.authorized, function (username) {
        if (username)
          process.stdout.write(username + "\n");
      });
      return 0;
    }
  };
  
  // action is "add" or "remove"
  var changeAuthorized = function (site, action, username) {
    site = canonicalizeSite(site);
    if (! site)
      // canonicalizeSite will have already printed an error
      return 1;
  
    var result = authedRpc({
      method: 'POST',
      operation: 'authorized',
      site: site,
      qs: action === "add" ? { add: username } : { remove: username },
      promptIfAuthFails: true
    });
  
    if (result.errorMessage) {
      process.stderr.write("Couldn't change authorized users: " +
                           result.errorMessage + "\n");
      return 1;
    }
  
    process.stdout.write(site + ": " +
                         (action === "add" ? "added " : "removed ")
                         + username + "\n");
    return 0;
  };
  
  var claim = function (site) {
    site = canonicalizeSite(site);
    if (! site)
      // canonicalizeSite will have already printed an error
      return 1;
  
    // Check to see if it's even a claimable site, so that we can print
    // a more appropriate message than we'd get if we called authedRpc
    // straight away (at a cost of an extra REST call)
    var infoResult = deployRpc({
      operation: 'info',
      site: site
    });
  
    if (infoResult.statusCode === 404) {
      process.stderr.write(
  "There isn't a site deployed at that address. Use 'meteor deploy' if\n" +
  "you'd like to deploy your app here.\n");
      return 1;
    }
  
    if (infoResult.payload && infoResult.payload.protection === "account") {
      if (infoResult.payload.authorized)
        process.stderr.write("That site already belongs to you.\n");
      else
        process.stderr.write("Sorry, that site belongs to someone else.\n");
      return 1;
    }
  
    if (infoResult.payload &&
        infoResult.payload.protection === "password") {
      process.stdout.write(
  "To claim this site and transfer it to your account, enter the\n" +
  "site password one last time.\n\n");
    }
  
    var result = authedRpc({
      method: 'POST',
      operation: 'claim',
      site: site,
      promptIfAuthFails: true
    });
  
    if (result.errorMessage) {
      auth.pollForRegistrationCompletion();
      if (! auth.loggedInUsername() &&
          auth.registrationUrl()) {
        process.stderr.write(
  "You need to set a password on your Meteor developer account before\n" +
  "you can claim sites. You can do that here in under a minute:\n\n" +
  auth.registrationUrl() + "\n\n");
      } else {
        process.stderr.write("Couldn't claim site: " +
                             result.errorMessage + "\n");
      }
      return 1;
    }
  
    process.stdout.write(
  site + ": " + "successfully transferred to your account.\n" +
  "\n" +
  "Show authorized users with:\n" +
  "  meteor authorized " + site + "\n" +
  "\n" +
  "Add authorized users with:\n" +
  "  meteor authorized " + site + " --add <username>\n" +
  "\n" +
  "Remove authorized users with:\n" +
  "  meteor authorized " + site + " --remove <user>\n" +
  "\n");
    return 0;
  };
  
  var listSites = function () {
    var result = deployRpc({
      method: "GET",
      operation: "authorized-apps",
      promptIfAuthFails: true,
      expectPayload: ["sites"]
    });
  
    if (result.errorMessage) {
      process.stderr.write("Couldn't list sites: " +
                           result.errorMessage + "\n");
      return 1;
    }
  
    if (! result.payload ||
        ! result.payload.sites ||
        ! result.payload.sites.length) {
      process.stdout.write("You don't have any sites yet.\n");
    } else {
      result.payload.sites.sort();
      _.each(result.payload.sites, function (site) {
        process.stdout.write(site + "\n");
      });
    }
    return 0;
  };
  
  
  exports.bundleAndDeploy = bundleAndDeploy;
  exports.deleteApp = deleteApp;
  exports.temporaryMongoUrl = temporaryMongoUrl;
  exports.logs = logs;
  exports.listAuthorized = listAuthorized;
  exports.changeAuthorized = changeAuthorized;
  exports.claim = claim;
  exports.listSites = listSites;
  