<?php

namespace League\OAuth1\Client\Server;

use GuzzleHttp\Client as GuzzleHttpClient;
use GuzzleHttp\Exception\BadResponseException;
use League\OAuth1\Client\Credentials\ClientCredentials;
use League\OAuth1\Client\Credentials\ClientCredentialsInterface;
use League\OAuth1\Client\Credentials\CredentialsException;
use League\OAuth1\Client\Credentials\CredentialsInterface;
use League\OAuth1\Client\Credentials\RsaClientCredentials;
use League\OAuth1\Client\Credentials\TemporaryCredentials;
use League\OAuth1\Client\Credentials\TokenCredentials;
use League\OAuth1\Client\Signature\HmacSha1Signature;
use League\OAuth1\Client\Signature\RsaSha1Signature;
use League\OAuth1\Client\Signature\SignatureInterface;
use SimpleXMLElement;
use Throwable;

abstract class Server
{
    /**
     * Client credentials.
     *
     * @var ClientCredentialsInterface
     */
    protected $clientCredentials;

    /**
     * Signature.
     *
     * @var SignatureInterface
     */
    protected $signature;

    /**
     * The response type for data returned from API calls.
     *
     * @var string
     */
    protected $responseType = 'json';

    /**
     * Cached user details response.
     *
     * @var array|SimpleXMLElement
     */
    protected $cachedUserDetailsResponse;

    /**
     * Optional user agent.
     *
     * @var string
     */
    protected $userAgent;

    /**
     * Create a new server instance.
     *
     * @param ClientCredentialsInterface|array $clientCredentials
     * @param SignatureInterface               $signature
     */
    public function __construct($clientCredentials, ?SignatureInterface $signature = null)
    {
        // Pass through an array or client credentials, we don't care
        if (is_array($clientCredentials)) {
            $clientCredentials = $this->createClientCredentials($clientCredentials);
        } elseif ( ! $clientCredentials instanceof ClientCredentialsInterface) {
            throw new \InvalidArgumentException('Client credentials must be an array or valid object.');
        }

        $this->clientCredentials = $clientCredentials;

        if ( ! $signature && $clientCredentials instanceof RsaClientCredentials) {
            $signature = new RsaSha1Signature($clientCredentials);
        }
        $this->signature = $signature ?: new HmacSha1Signature($clientCredentials);
    }

    /**
     * Gets temporary credentials by performing a request to
     * the server.
     *
     * @return TemporaryCredentials
     *
     * @throws CredentialsException
     */
    public function getTemporaryCredentials()
    {
        $uri = $this->urlTemporaryCredentials();

        $client = $this->createHttpClient();

        $header = $this->temporaryCredentialsProtocolHeader($uri);
        $authorizationHeader = ['Authorization' => $header];
        $headers = $this->buildHttpClientHeaders($authorizationHeader);

        try {
            $response = $client->post($uri, [
                'headers' => $headers,
            ]);

            return $this->createTemporaryCredentials((string) $response->getBody());
        } catch (BadResponseException $e) {
            $this->handleTemporaryCredentialsBadResponse($e);
        }

        throw new CredentialsException('Failed to get temporary credentials');
    }

    /**
     * Get the authorization URL by passing in the temporary credentials
     * identifier or an object instance.
     *
     * @param TemporaryCredentials|string $temporaryIdentifier
     * @param array                       $options
     *
     * @return string
     */
    public function getAuthorizationUrl($temporaryIdentifier, array $options = [])
    {
        // Somebody can pass through an instance of temporary
        // credentials and we'll extract the identifier from there.
        if ($temporaryIdentifier instanceof TemporaryCredentials) {
            $temporaryIdentifier = $temporaryIdentifier->getIdentifier();
        }

        $parameters = array_merge($options, ['oauth_token' => $temporaryIdentifier]);

        $url = $this->urlAuthorization();
        $queryString = http_build_query($parameters);

        return $this->buildUrl($url, $queryString);
    }

    /**
     * Redirect the client to the authorization URL.
     *
     * @param TemporaryCredentials|string $temporaryIdentifier
     *
     * @return void
     */
    public function authorize($temporaryIdentifier)
    {
        $url = $this->getAuthorizationUrl($temporaryIdentifier);

        header('Location: ' . $url);
    }

    /**
     * Retrieves token credentials by passing in the temporary credentials,
     * the temporary credentials identifier as passed back by the server
     * and finally the verifier code.
     *
     * @param TemporaryCredentials $temporaryCredentials
     * @param string               $temporaryIdentifier
     * @param string               $verifier
     *
     * @return TokenCredentials
     *
     * @throws CredentialsException
     */
    public function getTokenCredentials(TemporaryCredentials $temporaryCredentials, $temporaryIdentifier, $verifier)
    {
        if ($temporaryIdentifier !== $temporaryCredentials->getIdentifier()) {
            throw new \InvalidArgumentException(
                'Temporary identifier passed back by server does not match that of stored temporary credentials.
                Potential man-in-the-middle.'
            );
        }

        $uri = $this->urlTokenCredentials();
        $bodyParameters = ['oauth_verifier' => $verifier];

        $client = $this->createHttpClient();

        $headers = $this->getHeaders($temporaryCredentials, 'POST', $uri, $bodyParameters);

        try {
            $response = $client->post($uri, [
                'headers' => $headers,
                'form_params' => $bodyParameters,
            ]);

            return $this->createTokenCredentials((string) $response->getBody());
        } catch (BadResponseException $e) {
            $this->handleTokenCredentialsBadResponse($e);
        }

        throw new CredentialsException('Failed to get token credentials.');
    }

    /**
     * Get user details by providing valid token credentials.
     *
     * @param TokenCredentials $tokenCredentials
     * @param bool             $force
     *
     * @return \League\OAuth1\Client\Server\User
     */
    public function getUserDetails(TokenCredentials $tokenCredentials, $force = false)
    {
        $data = $this->fetchUserDetails($tokenCredentials, $force);

        return $this->userDetails($data, $tokenCredentials);
    }

    /**
     * Get the user's unique identifier (primary key).
     *
     * @param TokenCredentials $tokenCredentials
     * @param bool             $force
     *
     * @return string|int
     */
    public function getUserUid(TokenCredentials $tokenCredentials, $force = false)
    {
        $data = $this->fetchUserDetails($tokenCredentials, $force);

        return $this->userUid($data, $tokenCredentials);
    }

    /**
     * Get the user's email, if available.
     *
     * @param TokenCredentials $tokenCredentials
     * @param bool             $force
     *
     * @return string|null
     */
    public function getUserEmail(TokenCredentials $tokenCredentials, $force = false)
    {
        $data = $this->fetchUserDetails($tokenCredentials, $force);

        return $this->userEmail($data, $tokenCredentials);
    }

    /**
     * Get the user's screen name (username), if available.
     *
     * @param TokenCredentials $tokenCredentials
     * @param bool             $force
     *
     * @return string
     */
    public function getUserScreenName(TokenCredentials $tokenCredentials, $force = false)
    {
        $data = $this->fetchUserDetails($tokenCredentials, $force);

        return $this->userScreenName($data, $tokenCredentials);
    }

    /**
     * Fetch user details from the remote service.
     *
     * @param TokenCredentials $tokenCredentials
     * @param bool             $force
     *
     * @return array HTTP client response
     */
    protected function fetchUserDetails(TokenCredentials $tokenCredentials, $force = true)
    {
        if ( ! $this->cachedUserDetailsResponse || $force) {
            $url = $this->urlUserDetails();

            $client = $this->createHttpClient();

            $headers = $this->getHeaders($tokenCredentials, 'GET', $url);

            try {
                $response = $client->get($url, [
                    'headers' => $headers,
                ]);
            } catch (BadResponseException $e) {
                $response = $e->getResponse();
                $body = $response->getBody();
                $statusCode = $response->getStatusCode();

                throw new \Exception(
                    "Received error [$body] with status code [$statusCode] when retrieving token credentials."
                );
            }
            switch ($this->responseType) {
                case 'json':
                    $this->cachedUserDetailsResponse = json_decode((string) $response->getBody(), true);

                    break;

                case 'xml':
                    $this->cachedUserDetailsResponse = simplexml_load_string((string) $response->getBody());

                    break;

                case 'string':
                    parse_str((string) $response->getBody(), $this->cachedUserDetailsResponse);

                    break;

                default:
                    throw new \InvalidArgumentException("Invalid response type [{$this->responseType}].");
            }
        }

        return $this->cachedUserDetailsResponse;
    }

    /**
     * Get the client credentials associated with the server.
     *
     * @return ClientCredentialsInterface
     */
    public function getClientCredentials()
    {
        return $this->clientCredentials;
    }

    /**
     * Get the signature associated with the server.
     *
     * @return SignatureInterface
     */
    public function getSignature()
    {
        return $this->signature;
    }

    /**
     * Creates a Guzzle HTTP client for the given URL.
     *
     * @return GuzzleHttpClient
     */
    public function createHttpClient()
    {
        return new GuzzleHttpClient();
    }

    /**
     * Set the user agent value.
     *
     * @param string $userAgent
     *
     * @return Server
     */
    public function setUserAgent($userAgent = null)
    {
        $this->userAgent = $userAgent;

        return $this;
    }

    /**
     * Get all headers required to created an authenticated request.
     *
     * @param CredentialsInterface $credentials
     * @param string               $method
     * @param string               $url
     * @param array                $bodyParameters
     *
     * @return array
     */
    public function getHeaders(CredentialsInterface $credentials, $method, $url, array $bodyParameters = [])
    {
        $header = $this->protocolHeader(strtoupper($method), $url, $credentials, $bodyParameters);
        $authorizationHeader = ['Authorization' => $header];
        $headers = $this->buildHttpClientHeaders($authorizationHeader);

        return $headers;
    }

    /**
     * Get Guzzle HTTP client default headers.
     *
     * @return array
     */
    protected function getHttpClientDefaultHeaders()
    {
        $defaultHeaders = [];
        if ( ! empty($this->userAgent)) {
            $defaultHeaders['User-Agent'] = $this->userAgent;
        }

        return $defaultHeaders;
    }

    /**
     * Build Guzzle HTTP client headers.
     *
     * @param array $headers
     *
     * @return array
     */
    protected function buildHttpClientHeaders($headers = [])
    {
        $defaultHeaders = $this->getHttpClientDefaultHeaders();

        return array_merge($headers, $defaultHeaders);
    }

    /**
     * Creates a client credentials instance from an array of credentials.
     *
     * @param array $clientCredentials
     *
     * @return ClientCredentials
     */
    protected function createClientCredentials(array $clientCredentials)
    {
        $keys = ['identifier', 'secret'];

        foreach ($keys as $key) {
            if ( ! isset($clientCredentials[$key])) {
                throw new \InvalidArgumentException("Missing client credentials key [$key] from options.");
            }
        }

        if (isset($clientCredentials['rsa_private_key']) && isset($clientCredentials['rsa_public_key'])) {
            $_clientCredentials = new RsaClientCredentials();
            $_clientCredentials->setRsaPrivateKey($clientCredentials['rsa_private_key']);
            $_clientCredentials->setRsaPublicKey($clientCredentials['rsa_public_key']);
        } else {
            $_clientCredentials = new ClientCredentials();
        }

        $_clientCredentials->setIdentifier($clientCredentials['identifier']);
        $_clientCredentials->setSecret($clientCredentials['secret']);

        if (isset($clientCredentials['callback_uri'])) {
            $_clientCredentials->setCallbackUri($clientCredentials['callback_uri']);
        }

        return $_clientCredentials;
    }

    /**
     * Handle a bad response coming back when getting temporary credentials.
     *
     * @param BadResponseException $e
     *
     * @return void
     *
     * @throws CredentialsException
     */
    protected function handleTemporaryCredentialsBadResponse(BadResponseException $e)
    {
        $response = $e->getResponse();
        $body = $response->getBody();
        $statusCode = $response->getStatusCode();

        throw new CredentialsException(
            "Received HTTP status code [$statusCode] with message \"$body\" when getting temporary credentials."
        );
    }

    /**
     * Creates temporary credentials from the body response.
     *
     * @param string $body
     *
     * @return TemporaryCredentials
     */
    protected function createTemporaryCredentials($body)
    {
        parse_str($body, $data);

        if ( ! $data || ! is_array($data)) {
            throw new CredentialsException('Unable to parse temporary credentials response.');
        }

        if ( ! isset($data['oauth_callback_confirmed']) || $data['oauth_callback_confirmed'] != 'true') {
            throw new CredentialsException('Error in retrieving temporary credentials.');
        }

        $temporaryCredentials = new TemporaryCredentials();
        $temporaryCredentials->setIdentifier($data['oauth_token']);
        $temporaryCredentials->setSecret($data['oauth_token_secret']);

        return $temporaryCredentials;
    }

    /**
     * Handle a bad response coming back when getting token credentials.
     *
     * @param BadResponseException $e
     *
     * @return void
     *
     * @throws CredentialsException
     */
    protected function handleTokenCredentialsBadResponse(BadResponseException $e)
    {
        $response = $e->getResponse();
        $body = $response->getBody();
        $statusCode = $response->getStatusCode();

        throw new CredentialsException(
            "Received HTTP status code [$statusCode] with message \"$body\" when getting token credentials."
        );
    }

    /**
     * Creates token credentials from the body response.
     *
     * @param string $body
     *
     * @return TokenCredentials
     */
    protected function createTokenCredentials($body)
    {
        parse_str($body, $data);

        if ( ! $data || ! is_array($data)) {
            throw new CredentialsException('Unable to parse token credentials response.');
        }

        if (isset($data['error'])) {
            throw new CredentialsException("Error [{$data['error']}] in retrieving token credentials.");
        }

        $tokenCredentials = new TokenCredentials();
        $tokenCredentials->setIdentifier($data['oauth_token']);
        $tokenCredentials->setSecret($data['oauth_token_secret']);

        return $tokenCredentials;
    }

    /**
     * Get the base protocol parameters for an OAuth request.
     * Each request builds on these parameters.
     *
     * @return array
     *
     * @see    OAuth 1.0 RFC 5849 Section 3.1
     */
    protected function baseProtocolParameters()
    {
        $dateTime = new \DateTime();

        return [
            'oauth_consumer_key' => $this->clientCredentials->getIdentifier(),
            'oauth_nonce' => $this->nonce(),
            'oauth_signature_method' => $this->signature->method(),
            'oauth_timestamp' => $dateTime->format('U'),
            'oauth_version' => '1.0',
        ];
    }

    /**
     * Any additional required protocol parameters for an
     * OAuth request.
     *
     * @return array
     */
    protected function additionalProtocolParameters()
    {
        return [];
    }

    /**
     * Generate the OAuth protocol header for a temporary credentials
     * request, based on the URI.
     *
     * @param string $uri
     *
     * @return string
     */
    protected function temporaryCredentialsProtocolHeader($uri)
    {
        $parameters = array_merge($this->baseProtocolParameters(), [
            'oauth_callback' => $this->clientCredentials->getCallbackUri(),
        ]);

        $parameters['oauth_signature'] = $this->signature->sign($uri, $parameters, 'POST');

        return $this->normalizeProtocolParameters($parameters);
    }

    /**
     * Generate the OAuth protocol header for requests other than temporary
     * credentials, based on the URI, method, given credentials & body query
     * string.
     *
     * @param string               $method
     * @param string               $uri
     * @param CredentialsInterface $credentials
     * @param array                $bodyParameters
     *
     * @return string
     */
    protected function protocolHeader($method, $uri, CredentialsInterface $credentials, array $bodyParameters = [])
    {
        $parameters = array_merge(
            $this->baseProtocolParameters(),
            $this->additionalProtocolParameters(),
            [
                'oauth_token' => $credentials->getIdentifier(),
            ]
        );

        $this->signature->setCredentials($credentials);

        $parameters['oauth_signature'] = $this->signature->sign(
            $uri,
            array_merge($parameters, $bodyParameters),
            $method
        );

        return $this->normalizeProtocolParameters($parameters);
    }

    /**
     * Takes an array of protocol parameters and normalizes them
     * to be used as a HTTP header.
     *
     * @param array $parameters
     *
     * @return string
     */
    protected function normalizeProtocolParameters(array $parameters)
    {
        array_walk($parameters, function (&$value, $key) {
            $value = rawurlencode($key) . '="' . rawurlencode($value) . '"';
        });

        return 'OAuth ' . implode(', ', $parameters);
    }

    /**
     * Generate a random string.
     *
     * @param int $length
     *
     * @return string
     *
     * @see    OAuth 1.0 RFC 5849 Section 3.3
     */
    protected function nonce($length = 32)
    {
        $pool = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';

        return substr(str_shuffle(str_repeat($pool, 5)), 0, $length);
    }

    /**
     * Build a url by combining hostname and query string after checking for
     * exisiting '?' character in host.
     *
     * @param string $host
     * @param string $queryString
     *
     * @return string
     */
    protected function buildUrl($host, $queryString)
    {
        return $host . (strpos($host, '?') !== false ? '&' : '?') . $queryString;
    }

    /**
     * Get the URL for retrieving temporary credentials.
     *
     * @return string
     */
    abstract public function urlTemporaryCredentials();

    /**
     * Get the URL for redirecting the resource owner to authorize the client.
     *
     * @return string
     */
    abstract public function urlAuthorization();

    /**
     * Get the URL retrieving token credentials.
     *
     * @return string
     */
    abstract public function urlTokenCredentials();

    /**
     * Get the URL for retrieving user details.
     *
     * @return string
     */
    abstract public function urlUserDetails();

    /**
     * Take the decoded data from the user details URL and convert
     * it to a User object.
     *
     * @param mixed            $data
     * @param TokenCredentials $tokenCredentials
     *
     * @return User
     */
    abstract public function userDetails($data, TokenCredentials $tokenCredentials);

    /**
     * Take the decoded data from the user details URL and extract
     * the user's UID.
     *
     * @param mixed            $data
     * @param TokenCredentials $tokenCredentials
     *
     * @return string|int
     */
    abstract public function userUid($data, TokenCredentials $tokenCredentials);

    /**
     * Take the decoded data from the user details URL and extract
     * the user's email.
     *
     * @param mixed            $data
     * @param TokenCredentials $tokenCredentials
     *
     * @return string|null
     */
    abstract public function userEmail($data, TokenCredentials $tokenCredentials);

    /**
     * Take the decoded data from the user details URL and extract
     * the user's screen name.
     *
     * @param mixed            $data
     * @param TokenCredentials $tokenCredentials
     *
     * @return string|null
     */
    abstract public function userScreenName($data, TokenCredentials $tokenCredentials);
}
