<?php

/**
 * Part of the Joomla Framework OAuth1 Package
 *
 * @copyright  Copyright (C) 2005 - 2021 Open Source Matters, Inc. All rights reserved.
 * @license    GNU General Public License version 2 or later; see LICENSE
 */

namespace Joomla\OAuth1;

use Joomla\Application\SessionAwareWebApplicationInterface;
use Joomla\Http\Http;
use Joomla\Http\HttpFactory;
use Joomla\Input\Input;
use Joomla\Uri\Uri;

/**
 * Joomla Framework class for interacting with an OAuth 1.0 and 1.0a server.
 *
 * @since  1.0
 */
abstract class Client
{
    /**
     * Options for the Client object.
     *
     * @var    array|\ArrayAccess
     * @since  1.0
     */
    protected $options;

    /**
     * Contains access token key, secret and verifier.
     *
     * @var    array
     * @since  1.0
     */
    protected $token = [];

    /**
     * The HTTP client object to use in sending HTTP requests.
     *
     * @var    Http
     * @since  1.0
     */
    protected $client;

    /**
     * The input object to use in retrieving GET/POST data.
     *
     * @var    Input
     * @since  1.0
     */
    protected $input;

    /**
     * The application object to send HTTP headers for redirects.
     *
     * @var    SessionAwareWebApplicationInterface
     * @since  1.0
     */
    protected $application;

    /**
     * Selects which version of OAuth to use: 1.0 or 1.0a.
     *
     * @var    string
     * @since  1.0
     */
    protected $version;

    /**
     * Constructor.
     *
     * @param   SessionAwareWebApplicationInterface  $application  The application object
     * @param   ?Http                                $client       The HTTP client object.
     * @param   ?Input                               $input        The input object
     * @param   array|\ArrayAccess                   $options      OAuth1 Client options.
     * @param   string                               $version      Specify the OAuth version. By default we are using 1.0a.
     *
     * @since   1.0
     */
    public function __construct(
        SessionAwareWebApplicationInterface $application,
        ?Http $client = null,
        ?Input $input = null,
        $options = [],
        $version = '1.0a'
    ) {
        if (!\is_array($options) && !($options instanceof \ArrayAccess)) {
            throw new \InvalidArgumentException(
                'The options param must be an array or implement the ArrayAccess interface.'
            );
        }

        $this->application = $application;
        $this->client      = $client ?: (new HttpFactory())->getHttp($options);
        $this->input       = $input ?: $application->getInput();
        $this->options     = $options;
        $this->version     = $version;
    }

    /**
     * Method to form the oauth flow.
     *
     * @return  array|null  The access token.
     *
     * @since   1.0
     * @throws  \DomainException
     */
    public function authenticate()
    {
        // Already got some credentials stored?
        if ($this->token) {
            $response = $this->verifyCredentials();

            if ($response) {
                return $this->token;
            }

            $this->token = null;
        }

        // Check for callback.
        if (strcmp($this->version, '1.0a') === 0) {
            $verifier = $this->input->get('oauth_verifier');
        } else {
            $verifier = $this->input->get('oauth_token');
        }

        if (!empty($verifier)) {
            $session = $this->application->getSession();

            // Get token form session.
            $this->token = [
                'key'    => $session->get('oauth_token.key'),
                'secret' => $session->get('oauth_token.secret'),
            ];

            // Verify the returned request token.
            if (strcmp($this->token['key'], $this->input->get('oauth_token')) !== 0) {
                throw new \DomainException('Bad session!');
            }

            // Set token verifier for 1.0a.
            if (strcmp($this->version, '1.0a') === 0) {
                $this->token['verifier'] = $this->input->get('oauth_verifier');
            }

            // Generate access token.
            $this->generateAccessToken();

            // Return the access token.
            return $this->token;
        }

        // Generate a request token.
        $this->generateRequestToken();

        // Authenticate the user and authorise the app.
        $this->authorise();
    }

    /**
     * Method used to get a request token.
     *
     * @return  void
     *
     * @since   1.1.2
     * @throws  \DomainException
     */
    private function generateRequestToken()
    {
        $parameters = [];

        // Set the callback URL.
        if ($this->getOption('callback')) {
            $parameters['oauth_callback'] = $this->getOption('callback');
        }

        // Make an OAuth request for the Request Token.
        $response = $this->oauthRequest($this->getOption('requestTokenURL'), 'POST', $parameters);

        parse_str($response->body, $params);

        if (strcmp($this->version, '1.0a') === 0 && strcmp($params['oauth_callback_confirmed'], 'true') !== 0) {
            throw new \DomainException('Bad request token!');
        }

        // Save the request token.
        $this->token = ['key' => $params['oauth_token'], 'secret' => $params['oauth_token_secret']];

        // Save the request token in session
        $session = $this->application->getSession();
        $session->set('oauth_token.key', $this->token['key']);
        $session->set('oauth_token.secret', $this->token['secret']);
    }

    /**
     * Method used to authorise the application.
     *
     * @return  void
     *
     * @since   1.1.2
     */
    private function authorise()
    {
        $url = $this->getOption('authoriseURL') . '?oauth_token=' . $this->token['key'];

        if ($this->getOption('scope')) {
            $scope = \is_array($this->getOption('scope')) ? implode(' ', $this->getOption('scope')) : $this->getOption('scope');
            $url .= '&scope=' . urlencode($scope);
        }

        if ($this->getOption('sendheaders')) {
            $this->application->redirect($url);
        }
    }

    /**
     * Method used to get an access token.
     *
     * @return  void
     *
     * @since   1.1.2
     */
    private function generateAccessToken()
    {
        // Set the parameters.
        $parameters = [
            'oauth_token' => $this->token['key'],
        ];

        if (strcmp($this->version, '1.0a') === 0) {
            $parameters = array_merge($parameters, ['oauth_verifier' => $this->token['verifier']]);
        }

        // Make an OAuth request for the Access Token.
        $response = $this->oauthRequest($this->getOption('accessTokenURL'), 'POST', $parameters);

        parse_str($response->body, $params);

        // Save the access token.
        $this->token = ['key' => $params['oauth_token'], 'secret' => $params['oauth_token_secret']];
    }

    /**
     * Method used to make an OAuth request.
     *
     * @param   string  $url         The request URL.
     * @param   string  $method      The request method.
     * @param   array   $parameters  Array containing request parameters.
     * @param   mixed   $data        The POST request data.
     * @param   array   $headers     An array of name-value pairs to include in the header of the request
     *
     * @return  \Joomla\Http\Response
     *
     * @since   1.0
     * @throws  \DomainException
     */
    public function oauthRequest($url, $method, $parameters, $data = [], $headers = [])
    {
        // Set the parameters.
        $defaults = [
            'oauth_consumer_key'     => $this->getOption('consumer_key'),
            'oauth_signature_method' => 'HMAC-SHA1',
            'oauth_version'          => '1.0',
            'oauth_nonce'            => $this->generateNonce(),
            'oauth_timestamp'        => time(),
        ];

        $parameters = array_merge($parameters, $defaults);

        // Do not encode multipart parameters. Do not include $data in the signature if $data is not array.
        if (isset($headers['Content-Type']) && strpos($headers['Content-Type'], 'multipart/form-data') !== false || !\is_array($data)) {
            $oauthHeaders = $parameters;
        } else {
            // Use all parameters for the signature.
            $oauthHeaders = array_merge($parameters, $data);
        }

        // Sign the request.
        $oauthHeaders = $this->signRequest($url, $method, $oauthHeaders);

        // Get parameters for the Authorisation header.
        if (\is_array($data)) {
            $oauthHeaders = array_diff_key($oauthHeaders, $data);
        }

        // Send the request.
        switch ($method) {
            case 'GET':
                $url      = $this->toUrl($url, $data);
                $response = $this->client->get($url, ['Authorization' => $this->createHeader($oauthHeaders)]);

                break;

            case 'POST':
                $headers  = array_merge($headers, ['Authorization' => $this->createHeader($oauthHeaders)]);
                $response = $this->client->post($url, $data, $headers);

                break;

            case 'PUT':
                $headers  = array_merge($headers, ['Authorization' => $this->createHeader($oauthHeaders)]);
                $response = $this->client->put($url, $data, $headers);

                break;

            case 'DELETE':
                $headers  = array_merge($headers, ['Authorization' => $this->createHeader($oauthHeaders)]);
                $response = $this->client->delete($url, $headers);

                break;
        }

        // Validate the response code.
        $this->validateResponse($url, $response);

        return $response;
    }

    /**
     * Method to validate a response.
     *
     * @param   string    $url       The request URL.
     * @param   Response  $response  The response to validate.
     *
     * @return  void
     *
     * @since   1.0
     * @throws  \DomainException
     */
    abstract public function validateResponse($url, $response);

    /**
     * Method used to create the header for the POST request.
     *
     * @param   array  $parameters  Array containing request parameters.
     *
     * @return  string  The header.
     *
     * @since   1.1.2
     */
    private function createHeader(array $parameters): string
    {
        $header = 'OAuth ';

        foreach ($parameters as $key => $value) {
            if (!strcmp($header, 'OAuth ')) {
                $header .= $key . '="' . $this->safeEncode($value) . '"';
            } else {
                $header .= ', ' . $key . '="' . $value . '"';
            }
        }

        return $header;
    }

    /**
     * Method to create the URL formed string with the parameters.
     *
     * @param   string  $url         The request URL.
     * @param   array   $parameters  Array containing request parameters.
     *
     * @return  string  The formed URL.
     *
     * @since   1.0
     */
    public function toUrl($url, $parameters)
    {
        $uri = new Uri($url);
        $uri->setQuery($parameters);

        return (string) $uri;
    }

    /**
     * Method used to sign requests.
     *
     * @param   string  $url         The URL to sign.
     * @param   string  $method      The request method.
     * @param   array   $parameters  Array containing request parameters.
     *
     * @return  array  The array containing the request parameters, including signature.
     *
     * @since   1.1.2
     */
    private function signRequest(string $url, string $method, array $parameters): array
    {
        // Create the signature base string.
        $base = $this->baseString($url, $method, $parameters);

        $parameters['oauth_signature'] = $this->safeEncode(
            base64_encode(
                hash_hmac('sha1', $base, $this->prepareSigningKey(), true)
            )
        );

        return $parameters;
    }

    /**
     * Prepare the signature base string.
     *
     * @param   string  $url         The URL to sign.
     * @param   string  $method      The request method.
     * @param   array   $parameters  Array containing request parameters.
     *
     * @return  string  The base string.
     *
     * @since   1.1.2
     */
    private function baseString(string $url, string $method, array $parameters): string
    {
        // Sort the parameters alphabetically
        uksort($parameters, 'strcmp');
        $kv = [];

        // Encode parameters.
        foreach ($parameters as $key => $value) {
            $key = $this->safeEncode($key);

            if (\is_array($value)) {
                foreach ($value as $k => $v) {
                    $v    = $this->safeEncode($v);
                    $kv[] = "{$key}={$v}";
                }
            } else {
                $value = $this->safeEncode($value);
                $kv[]  = "{$key}={$value}";
            }
        }

        // Form the parameter string.
        $params = implode('&', $kv);

        // Signature base string elements.
        $base = [
            $method,
            $url,
            $params,
        ];

        // Return the base string.
        return implode('&', $this->safeEncode($base));
    }

    /**
     * Encodes the string or array passed in a way compatible with OAuth.
     * If an array is passed each array value will will be encoded.
     *
     * @param   string|string[]  $data  The scalar or array to encode.
     *
     * @return  string|string[]  $data encoded in a way compatible with OAuth.
     *
     * @since   1.0
     */
    public function safeEncode($data)
    {
        if (\is_array($data)) {
            return array_map([$this, 'safeEncode'], $data);
        }

        if (is_scalar($data)) {
            return str_ireplace(
                ['+', '%7E'],
                [' ', '~'],
                rawurlencode($data)
            );
        }

        return '';
    }

    /**
     * Method used to generate the current nonce.
     *
     * @return  string  The current nonce.
     *
     * @since   1.0
     */
    public static function generateNonce()
    {
        // The md5s look nicer than numbers.
        return md5(microtime() . random_bytes(16));
    }

    /**
     * Prepares the OAuth signing key.
     *
     * @return  string  The prepared signing key.
     *
     * @since   1.1.2
     */
    private function prepareSigningKey(): string
    {
        return $this->safeEncode($this->getOption('consumer_secret')) . '&' . $this->safeEncode(($this->token) ? $this->token['secret'] : '');
    }

    /**
     * Returns an HTTP 200 OK response code and a representation of the requesting user if authentication was successful;
     * returns a 401 status code and an error message if not.
     *
     * @return  array  The decoded JSON response
     *
     * @since   1.0
     */
    abstract public function verifyCredentials();

    /**
     * Get an option from the OAuth1 Client instance.
     *
     * @param   string  $key      The name of the option to get
     * @param   mixed   $default  Optional default value if the option does not exist
     *
     * @return  mixed  The option value
     *
     * @since   1.0
     */
    public function getOption($key, $default = null)
    {
        return $this->options[$key] ?? $default;
    }

    /**
     * Set an option for the OAuth1 Client instance.
     *
     * @param   string  $key    The name of the option to set
     * @param   mixed   $value  The option value to set
     *
     * @return  $this
     *
     * @since   1.0
     */
    public function setOption($key, $value)
    {
        $this->options[$key] = $value;

        return $this;
    }

    /**
     * Get the oauth token key or secret.
     *
     * @return  array  The oauth token key and secret.
     *
     * @since   1.0
     */
    public function getToken()
    {
        return $this->token;
    }

    /**
     * Set the oauth token.
     *
     * @param   array  $token  The access token key and secret.
     *
     * @return  $this
     *
     * @since   1.0
     */
    public function setToken($token)
    {
        $this->token = $token;

        return $this;
    }
}
