<?php

/**
 * Proxy future that implements OAuth1 request signing. For references, see:
 *
 *  RFC 5849: http://tools.ietf.org/html/rfc5849
 *  Guzzle: https://github.com/guzzle/guzzle/blob/master/src/Guzzle/Plugin/Oauth/OauthPlugin.php
 *
 */
final class PhutilOAuth1Future extends FutureProxy {

  private $uri;
  private $data;
  private $consumerKey;
  private $consumerSecret;
  private $signatureMethod;
  private $privateKey;
  private $method = 'POST';
  private $token;
  private $tokenSecret;
  private $nonce;
  private $timestamp;
  private $hasConstructedFuture;
  private $callbackURI;
  private $headers = array();

  public function setCallbackURI($callback_uri) {
    $this->callbackURI = $callback_uri;
    return $this;
  }

  public function setTimestamp($timestamp) {
    $this->timestamp = $timestamp;
    return $this;
  }

  public function setNonce($nonce) {
    $this->nonce = $nonce;
    return $this;
  }

  public function setTokenSecret($token_secret) {
    $this->tokenSecret = $token_secret;
    return $this;
  }

  public function setToken($token) {
    $this->token = $token;
    return $this;
  }

  public function setPrivateKey(PhutilOpaqueEnvelope $private_key) {
    $this->privateKey = $private_key;
    return $this;
  }

  public function setSignatureMethod($signature_method) {
    $this->signatureMethod = $signature_method;
    return $this;
  }

  public function setConsumerKey($consumer_key) {
    $this->consumerKey = $consumer_key;
    return $this;
  }

  public function setConsumerSecret(PhutilOpaqueEnvelope $consumer_secret) {
    $this->consumerSecret = $consumer_secret;
    return $this;
  }

  public function setMethod($method) {
    $this->method = $method;
    return $this;
  }

  public function __construct($uri, $data = array()) {
    $this->uri = new PhutilURI((string)$uri);
    $this->data = $data;
    $this->setProxiedFuture(new HTTPSFuture($uri, $data));
  }

  public function getSignature() {
    $params = array();

    // NOTE: The JIRA API uses JSON-encoded request bodies which are not
    // signed, and OAuth1 provides no real way to sign a nonparameterized
    // request body. Possibly we should split this apart into flags which
    // control which data is signed, but for now this rule seems to cover
    // all the use cases.

    if (is_array($this->data)) {
      $params = $this->data;
    }

    $params = $params
            + $this->uri->getQueryParams()
            + $this->getOAuth1Headers();

    return $this->sign($params);
  }

  public function addHeader($name, $value) {
    // If we haven't built the future yet, hold on to the header until after
    // we do, since there might be more changes coming which will affect the
    // signature process.

    if (!$this->hasConstructedFuture) {
      $this->headers[] = array($name, $value);
    } else {
      $this->getProxiedFuture()->addHeader($name, $value);
    }
    return $this;
  }

  protected function getProxiedFuture() {
    $future = parent::getProxiedFuture();

    if (!$this->hasConstructedFuture) {
      $future->setMethod($this->method);

      $oauth_headers = $this->getOAuth1Headers();
      $oauth_headers['oauth_signature'] = $this->getSignature();

      $full_oauth_header = array();
      foreach ($oauth_headers as $header => $value) {
        $full_oauth_header[] = $header.'="'.urlencode($value).'"';
      }
      $full_oauth_header = 'OAuth '.implode(', ', $full_oauth_header);

      $future->addHeader('Authorization', $full_oauth_header);

      foreach ($this->headers as $header) {
        $future->addHeader($header[0], $header[1]);
      }
      $this->headers = array();

      $this->hasConstructedFuture = true;
    }

    return $future;
  }

  protected function didReceiveResult($result) {
    return $result;
  }

  private function getOAuth1Headers() {
    if (!$this->nonce) {
      $this->nonce = Filesystem::readRandomCharacters(32);
    }
    if (!$this->timestamp) {
      $this->timestamp = time();
    }

    $oauth_headers = array(
      'oauth_consumer_key' => $this->consumerKey,
      'oauth_signature_method' => $this->signatureMethod,
      'oauth_timestamp' => $this->timestamp,
      'oauth_nonce' => $this->nonce,
      'oauth_version' => '1.0',
    );

    if ($this->callbackURI) {
      $oauth_headers['oauth_callback'] = (string)$this->callbackURI;
    }

    if ($this->token) {
      $oauth_headers['oauth_token'] = $this->token;
    }

    return $oauth_headers;
  }

  private function sign(array $params) {
    ksort($params);

    $pstr = array();
    foreach ($params as $key => $value) {
      $pstr[] = rawurlencode($key).'='.rawurlencode($value);
    }
    $pstr = implode('&', $pstr);

    $sign_uri = clone $this->uri;
    $sign_uri->setFragment('');
    $sign_uri->setQueryParams(array());

    $sign_uri->setProtocol(phutil_utf8_strtolower($sign_uri->getProtocol()));
    $protocol = $sign_uri->getProtocol();
    switch ($protocol) {
      case 'http':
        if ($sign_uri->getPort() == 80) {
          $sign_uri->setPort(null);
        }
        break;
      case 'https':
        if ($sign_uri->getPort() == 443) {
          $sign_uri->setPort(null);
        }
        break;
    }

    $method = rawurlencode(phutil_utf8_strtoupper($this->method));
    $sign_uri = rawurlencode((string)$sign_uri);
    $pstr = rawurlencode($pstr);

    $sign_input = "{$method}&{$sign_uri}&{$pstr}";
    return $this->signString($sign_input);
  }

  private function signString($string) {
    $consumer_secret = null;
    if ($this->consumerSecret) {
      $consumer_secret = $this->consumerSecret->openEnvelope();
    }

    $key = urlencode($consumer_secret).'&'.urlencode($this->tokenSecret);

    switch ($this->signatureMethod) {
      case 'HMAC-SHA1':
        if (!$this->consumerSecret) {
          throw new Exception(
            pht(
              "Signature method '%s' requires %s!",
              'HMAC-SHA1',
              'setConsumerSecret()'));
        }

        $hash = hash_hmac('sha1', $string, $key, true);
        return base64_encode($hash);
      case 'RSA-SHA1':
        if (!$this->privateKey) {
          throw new Exception(
            pht(
              "Signature method '%s' requires %s!",
              'RSA-SHA1',
              'setPrivateKey()'));
        }

        $cert = @openssl_pkey_get_private($this->privateKey->openEnvelope());
        if (!$cert) {
          throw new Exception(pht('%s failed!', 'openssl_pkey_get_private()'));
        }

        $pkey = @openssl_get_privatekey($cert);
        if (!$pkey) {
          throw new Exception(pht('%s failed!', 'openssl_get_privatekey()'));
        }

        $signature = null;
        $ok = openssl_sign($string, $signature, $pkey, OPENSSL_ALGO_SHA1);
        if (!$ok) {
          throw new Exception(pht('%s failed!', 'openssl_sign()'));
        }

        openssl_free_key($pkey);

        return base64_encode($signature);
      case 'PLAINTEXT':
        if (!$this->consumerSecret) {
          throw new Exception(
            pht(
              "Signature method '%s' requires %s!",
              'PLAINTEXT',
              'setConsumerSecret()'));
        }
        return $key;
      default:
        throw new Exception(pht("Unknown signature method '%s'!", $string));
    }
  }

  public function resolvex() {
    $result = $this->getProxiedFuture()->resolvex();
    return $this->didReceiveResult($result);
  }

  public function resolveJSON() {
    $result = $this->getProxiedFuture()->resolvex();
    $result = $this->didReceiveResult($result);
    list($body) = $result;

    try {
      return phutil_json_decode($body);
    } catch (PhutilJSONParserException $ex) {
      throw new PhutilProxyException(pht('Expected JSON.'), $ex);
    }
  }


}
