<?php

namespace PHPFUI\ConstantContact;

/**
 * The Client class needs to store authentication information between PHP sessions in order to function correctly.  The class defaults to normal PHP $_SESSION handling,
 * but you can specify a callback via **setSessionCallback** to provide a different persistence model. The callback function signature is:
 *
 * @param string $key used to store or retrieve $value
 * @param ?string $value if null, value should be returned and key deleted, if not null, value should be stored by key.
 * @return string $value from store or value passed in on set (ignored)
 */
class Client
{
    public $accessToken = '';

    public $refreshToken = '';
    public $PKCE = true;

    private $authorizeURL = 'https://authz.constantcontact.com/oauth2/default/v1/authorize';

    private $body = '';

    private $guzzleHandler;

    private $host = '';

    private $lastError = '';

    private $next = '';

    private $oauth2URL = 'https://authz.constantcontact.com/oauth2/default/v1/token';

    private $scopes = [];

    private $sessionCallback = null;

    private $statusCode = 200;

    private $validScopes = ['account_read', 'account_update', 'contact_data', 'campaign_data', 'offline_access',];
    private $clientAPIKey;
    private $clientSecret;
    private $redirectURI;

    /**
     * Construct a client.
     *
     * By default, all scopes are enabled.  You can remove any, or set new ones.
     */
    public function __construct(string $clientAPIKey, string $clientSecret, string $redirectURI, bool $PKCE = true)
    {
        $this->PKCE = $PKCE;
        $this->redirectURI = $redirectURI;
        $this->clientSecret = $clientSecret;
        $this->clientAPIKey = $clientAPIKey;
        // default to all scopes
        $this->scopes = \array_flip($this->validScopes);
        $this->host = $_SERVER['HTTP_HOST'] ?? '';
        $this->guzzleHandler = \GuzzleHttp\HandlerStack::create();
        $this->guzzleHandler->push(\Spatie\GuzzleRateLimiterMiddleware\RateLimiterMiddleware::perSecond(4));
    }

    /**
     * Exchange an authorization code for an access token.
     *
     * Make this call by passing in the code present when the account owner is redirected back to you.
     * The response will contain an 'access_token' and 'refresh_token'
     *
     * @param array $parameters passed to redirect URL
     */
    public function acquireAccessToken(array $parameters): bool
    {
        if (isset($parameters['error'])) {
            $this->statusCode = 0;
            $this->lastError = $parameters['error'].': '.($parameters['error_description'] ?? 'Undefined');

            return false;
        }

        $expectedState = $this->session('PHPFUI\ConstantContact\state', null);

        if (($parameters['state'] ?? 'undefined') != $expectedState) {
            $this->statusCode = 0;
            $this->lastError = 'state is not correct';

            return false;
        }

        // Use cURL to get access token and refresh token
        $ch = \curl_init();

        // Create full request URL
        $params = [
            'code' => $parameters['code'],
            'redirect_uri' => $this->redirectURI,
            'grant_type' => 'authorization_code',
        ];

        if ($this->PKCE) {
            $params['code_verifier'] = $this->session('PHPFUI\ConstantContact\code_verifier', null);
        }
        $url = $this->oauth2URL.'?'.\http_build_query($params);
        \curl_setopt($ch, CURLOPT_URL, $url);
        \curl_setopt(
            $ch,
            CURLOPT_POSTFIELDS,
            \json_encode(
                [
                    'client_id' => $this->clientAPIKey,
                    'client_secret' => $this->clientSecret,
                    'code' => $parameters['code'],
                ]
            )
        );

        $this->setAuthorization($ch);

        // Set method and to expect response
        \curl_setopt($ch, CURLOPT_POST, true);

        return $this->exec($ch);
    }

    public function addScope(string $scope): self
    {
        if (!\in_array($scope, $this->validScopes)) {
            throw new \PHPFUI\ConstantContact\Exception\InvalidParameter(
                "Scope {$scope} is not valid, must be one of (".\implode(',', $this->validScopes).')'
            );
        }
        $this->scopes[$scope] = true;

        return $this;
    }

    /**
     * Issue a delete request.  This is not normally called directly, but by the V3 namespace classes.
     */
    public function delete(string $url): bool
    {
        try {
            $guzzle = new \GuzzleHttp\Client(['headers' => $this->getHeaders(), 'handler' => $this->guzzleHandler,]);
            $response = $guzzle->request('DELETE', $url);

            $this->process($response);

            return $this->statusCode >= 200 && $this->statusCode < 300;
        } catch (\GuzzleHttp\Exception\RequestException $e) {
            $this->lastError = $e->getMessage();
            $this->statusCode = $e->getResponse()->getStatusCode();
        }

        return false;
    }

    /**
     * Issue a get request.  This is not normally called directly, but by the V3 namespace classes.
     */
    public function get(string $url, array $parameters): array
    {
        try {
            if ($parameters) {
                $paramString = \urldecode(\http_build_query($parameters));
                $paramString = \preg_replace('/\[[0-9]\]/', '', $paramString);

                if ($paramString) {
                    $url .= '?'.$paramString;
                }
            }

            $guzzle = new \GuzzleHttp\Client(['headers' => $this->getHeaders(), 'handler' => $this->guzzleHandler,]);
            $response = $guzzle->request('GET', $url);

            return $this->process($response);
        } catch (\GuzzleHttp\Exception\RequestException $e) {
            $this->lastError = $e->getMessage();
            $this->statusCode = $e->getResponse()->getStatusCode();
        }

        return [];
    }

    /**
     * Generate the URL an account owner would use to allow your app
     * to access their account.
     *
     * After visiting the URL, the account owner is prompted to log in and allow your app to access their account.
     * They are then redirected to your redirect URL with the authorization code appended as a query parameter. e.g.:
     * http://localhost:8888/?code={authorization_code}
     */
    public function getAuthorizationURL(): string
    {
        $scopes = \implode('+', \array_keys($this->scopes));

        $state = \bin2hex(\random_bytes(8));
        $this->session('PHPFUI\ConstantContact\state', $state);
        $params = [
            'response_type' => 'code',
            'client_id' => $this->clientAPIKey,
            'redirect_uri' => $this->redirectURI,
            'scope' => $scopes,
            'state' => $state,
        ];

        if ($this->PKCE) {
            list($code_verifier, $code_challenge) = $this->codeChallenge();

            // Store generated random state and code challenge based on RFC 7636
            // https://datatracker.ietf.org/doc/html/rfc7636#section-6.1
            $this->session('PHPFUI\ConstantContact\code_verifier', $code_verifier);
            $params['code_challenge'] = $code_challenge;
            $params['code_challenge_method'] = 'S256';
        }

        $url = $this->authorizeURL.'?'.\str_replace(
                '%2B',
                '+',
                \http_build_query($params)
            );    // hack %2B to + for stupid CC API bug

        return $url;
    }

    public function getBody(): string
    {
        return $this->body;
    }

    public function getLastError(): string
    {
        return $this->lastError;
    }

    public function getStatusCode(): int
    {
        return $this->statusCode;
    }

    /**
     * Get the next result set
     *
     * @return array of data, empty if no more results
     */
    public function next(): array
    {
        if (!$this->next) {
            return [];
        }

        $guzzle = new \GuzzleHttp\Client(['headers' => $this->getHeaders(), 'handler' => $this->guzzleHandler,]);
        $response = $guzzle->request('GET', 'https://api.cc.email'.$this->next);

        return $this->process($response);
    }

    /**
     * Issue a patch request.  This is not normally called directly, but by the V3 namespace classes.
     */
    public function patch(string $url, array $parameters): array
    {
        return $this->put($url, $parameters, 'PATCH');
    }

    /**
     * Issue a post request.  This is not normally called directly, but by the V3 namespace classes.
     */
    public function post(string $url, array $parameters): array
    {
        try {
            $json = \json_encode($parameters['body'], JSON_PRETTY_PRINT);
            $guzzle = new \GuzzleHttp\Client([
                'headers' => $this->getHeaders(),
                'handler' => $this->guzzleHandler,
                'body' => $json,
            ]);
            $response = $guzzle->request('POST', $url);

            return $this->process($response);
        } catch (\GuzzleHttp\Exception\RequestException $e) {
            $this->lastError = $e->getMessage();
            $this->statusCode = $e->getResponse()->getStatusCode();
        }

        return [];
    }

    /**
     * Issue a put request.  This is not normally called directly, but by the V3 namespace classes.
     */
    public function put(string $url, array $parameters, string $method = 'PUT'): array
    {
        try {
            $json = \json_encode($parameters['body'], JSON_PRETTY_PRINT);
            $guzzle = new \GuzzleHttp\Client([
                'headers' => $this->getHeaders(
                    [
                        'Connection' => 'keep-alive',
                        'Content-Length' => \strlen($json),
                        'Accept-Encoding' => 'gzip, deflate',
                        'Host' => $this->host,
                        'Accept' => '*/*',
                    ]
                ),
                'handler' => $this->guzzleHandler,
                'body' => $json,
            ]);

            $response = $guzzle->request($method, $url);

            return $this->process($response);
        } catch (\GuzzleHttp\Exception\RequestException $e) {
            $this->lastError = $e->getMessage();
            $this->statusCode = $e->getResponse()->getStatusCode();
        }

        return [];
    }

    /**
     * Refresh the access token.
     */
    public function refreshToken(): bool
    {
        // Use cURL to get a new access token and refresh token
        $ch = \curl_init();

        // Create full request URL
        $params = [
            'refresh_token' => $this->refreshToken,
            'grant_type' => 'refresh_token',
            'redirect_uri' => $this->redirectURI,
        ];

        $url = $this->oauth2URL.'?'.\http_build_query($params);
        \curl_setopt($ch, CURLOPT_URL, $url);

        $this->setAuthorization($ch);

        // Set method and to expect response
        \curl_setopt($ch, CURLOPT_POST, true);
        \curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'POST');
        \curl_setopt($ch, CURLOPT_POSTFIELDS, '');

        return $this->exec($ch);
    }

    public function removeScope(string $scope): self
    {
        unset($this->scopes[$scope]);

        return $this;
    }

    public function setHost(string $host): self
    {
        $this->host = $host;

        return $this;
    }

    public function setScopes(array $scopes): self
    {
        $this->scopes = [];

        foreach ($scopes as $scope) {
            $this->addScope($scope);
        }

        return $this;
    }

    /**
     * To avoid using built in PHP Sessions, set the callback to save values yourself
     *
     * Callback function signature:
     *
     * - function(string $key, string $value) : string // returns string $value from store or value passed in on set (ignored)
     */
    public function setSessionCallback(callable $callback): self
    {
        $this->sessionCallback = $callback;

        return $this;
    }

    private function base64url_encode(string $data): string
    {
        return \rtrim(\strtr(\base64_encode($data), '+/', '-_'), '=');
    }

    /**
     * Generate code_verifier and code_challenge for rfc7636 PKCE.
     * https://datatracker.ietf.org/doc/html/rfc7636#appendix-B
     *
     * @return array [code_verifier, code_challenge].
     */
    private function codeChallenge( $code_verifier = null): array
    {
        $gen = static function () {
            $strings = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~';
            $length = \random_int(43, 128);

            for ($i = 0; $i < $length; $i++) {
                yield $strings[\random_int(0, 65)];
            }
        };

        $code = $code_verifier ?? \implode('', \iterator_to_array($gen()));

        if (!\preg_match('/[A-Za-z0-9-._~]{43,128}/', $code)) {
            return ['', ''];
        }

        return [$code, $this->base64url_encode(\pack('H*', \hash('sha256', $code)))];
    }

    private function exec($ch): bool
    {
        \curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);

        // Make the call
        $result = \curl_exec($ch);
        $this->lastError = '';
        $this->statusCode = 0;

        if ($result) {
            $data = \json_decode($result, true);

            if (isset($data['error'])) {
                $this->lastError = $data['error'].': '.($data['error_description'] ?? 'Undefined');
            }
            $this->accessToken = $data['access_token'] ?? '';
            $this->refreshToken = $data['refresh_token'] ?? '';

            \curl_close($ch);

            return isset($data['access_token'], $data['refresh_token']);
        }

        $this->statusCode = \curl_errno($ch);
        $this->lastError = \curl_error($ch);
        \curl_close($ch);

        return false;
    }

    private function getHeaders(array $additional = []): array
    {
        return \array_merge([
            'Cache-Control' => 'no-cache',
            'Authorization' => 'Bearer '.$this->accessToken,
            'Content-Type' => 'application/json',
            'Accept' => 'application/json',
        ], $additional);
    }

    private function process(\Psr\Http\Message\ResponseInterface $response): array
    {
        $this->next = '';
        $this->lastError = $response->getReasonPhrase();
        $this->statusCode = $response->getStatusCode();
        $this->body = $response->getBody();
        $data = \json_decode($this->body, true);

        if (isset($data['_links']['next']['href'])) {
            $this->next = $data['_links']['next']['href'];
        }

        if (null !== $data) {
            return $data;
        }
        $this->lastError = \json_last_error_msg();
        $this->statusCode = \json_last_error();

        return [];
    }

    private function session(string $key, string $value = null): string
    {
        if ($this->sessionCallback) {
            return \call_user_func($this->sessionCallback, $key, $value);
        }

        if (PHP_SESSION_ACTIVE !== \session_status()) {
            throw new \PHPFUI\ConstantContact\Exception(
                'session not started. Call session_start() or use \PHPFUI\ConstantContact\Client->setSessionCallback'
            );
        }

        if (null === $value) {
            $value = $_SESSION[$key] ?? '';
            unset($_SESSION[$key]);

            return $value;
        }

        return $_SESSION[$key] = $value;
    }

    private function setAuthorization($ch)
    {
        // Set authorization header
        // Make string of "API_KEY:SECRET"
        $auth = $this->clientAPIKey.':'.$this->clientSecret;
        // Base64 encode it
        $credentials = \base64_encode($auth);
        // Create and set the Authorization header to use the encoded credentials
        $headers = ['Authorization: Basic '.$credentials, 'cache-control: no-cache',];
        \curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
    }
}
