<?php

namespace Laminas\Feed\PubSubHubbub\Subscriber;

use Laminas\Feed\PubSubHubbub;
use Laminas\Feed\PubSubHubbub\Exception;
use Laminas\Feed\Uri;

use function array_key_exists;
use function explode;
use function hash;
use function is_array;
use function rawurldecode;
use function sprintf;
use function stripos;
use function strtolower;

class Callback extends PubSubHubbub\AbstractCallback
{
    /**
     * Contains the content of any feeds sent as updates to the Callback URL
     *
     * @var string
     */
    protected $feedUpdate;

    /**
     * Holds a manually set subscription key (i.e. identifies a unique
     * subscription) which is typical when it is not passed in the query string
     * but is part of the Callback URL path, requiring manual retrieval e.g.
     * using a route and the \Laminas\Mvc\Router\RouteMatch::getParam() method.
     *
     * @var null|string
     */
    protected $subscriptionKey;

    /**
     * After verification, this is set to the verified subscription's data.
     *
     * @var array
     */
    protected $currentSubscriptionData;

    /**
     * Set a subscription key to use for the current callback request manually.
     * Required if usePathParameter is enabled for the Subscriber.
     *
     * @param  string $key
     * @return $this
     */
    public function setSubscriptionKey($key)
    {
        $this->subscriptionKey = $key;
        return $this;
    }

    /**
     * Handle any callback from a Hub Server responding to a subscription or
     * unsubscription request. This should be the Hub Server confirming the
     * the request prior to taking action on it.
     *
     * @param  null|array $httpGetData     GET data if available and not in $_GET
     * @param  bool       $sendResponseNow Whether to send response now or when asked
     * @return void
     */
    public function handle(?array $httpGetData = null, $sendResponseNow = false)
    {
        if ($httpGetData === null) {
            $httpGetData = $_GET;
        }

        /**
         * Handle any feed updates (sorry for the mess :P)
         *
         * This DOES NOT attempt to process a feed update. Feed updates
         * SHOULD be validated/processed by an asynchronous process so as
         * to avoid holding up responses to the Hub.
         */
        $contentType = $this->_getHeader('Content-Type');
        if (
            strtolower($_SERVER['REQUEST_METHOD']) === 'post'
            && $this->_hasValidVerifyToken(null, false)
            && (stripos($contentType, 'application/atom+xml') === 0
                || stripos($contentType, 'application/rss+xml') === 0
                || stripos($contentType, 'application/xml') === 0
                || stripos($contentType, 'text/xml') === 0
                || stripos($contentType, 'application/rdf+xml') === 0)
        ) {
            $this->setFeedUpdate($this->_getRawBody());
            $this->getHttpResponse()->setHeader('X-Hub-On-Behalf-Of', $this->getSubscriberCount());
        /**
         * Handle any (un)subscribe confirmation requests
         */
        } elseif ($this->isValidHubVerification($httpGetData)) {
            $this->getHttpResponse()->setContent($httpGetData['hub_challenge']);

            switch (strtolower($httpGetData['hub_mode'])) {
                case 'subscribe':
                    $data                       = $this->currentSubscriptionData;
                    $data['subscription_state'] = PubSubHubbub\PubSubHubbub::SUBSCRIPTION_VERIFIED;
                    if (isset($httpGetData['hub_lease_seconds'])) {
                        $data['lease_seconds'] = $httpGetData['hub_lease_seconds'];
                    }
                    $this->getStorage()->setSubscription($data);
                    break;
                case 'unsubscribe':
                    $verifyTokenKey = $this->_detectVerifyTokenKey($httpGetData);
                    $this->getStorage()->deleteSubscription($verifyTokenKey);
                    break;
                default:
                    throw new Exception\RuntimeException(sprintf(
                        'Invalid hub_mode ("%s") provided',
                        $httpGetData['hub_mode']
                    ));
            }
        /**
         * Hey, C'mon! We tried everything else!
         */
        } else {
            $this->getHttpResponse()->setStatusCode(404);
        }

        if ($sendResponseNow) {
            $this->sendResponse();
        }
    }

    /**
     * Checks validity of the request simply by making a quick pass and
     * confirming the presence of all REQUIRED parameters.
     *
     * @return bool
     */
    public function isValidHubVerification(array $httpGetData)
    {
        /**
         * As per the specification, the hub.verify_token is OPTIONAL. This
         * implementation of Pubsubhubbub considers it REQUIRED and will
         * always send a hub.verify_token parameter to be echoed back
         * by the Hub Server. Therefore, its absence is considered invalid.
         */
        if (strtolower($_SERVER['REQUEST_METHOD']) !== 'get') {
            return false;
        }
        $required = [
            'hub_mode',
            'hub_topic',
            'hub_challenge',
            'hub_verify_token',
        ];
        foreach ($required as $key) {
            if (! array_key_exists($key, $httpGetData)) {
                return false;
            }
        }
        if (
            $httpGetData['hub_mode'] !== 'subscribe'
            && $httpGetData['hub_mode'] !== 'unsubscribe'
        ) {
            return false;
        }
        if (
            $httpGetData['hub_mode'] === 'subscribe'
            && ! array_key_exists('hub_lease_seconds', $httpGetData)
        ) {
            return false;
        }
        if (! Uri::factory($httpGetData['hub_topic'])->isValid()) {
            return false;
        }

        /**
         * Attempt to retrieve any Verification Token Key attached to Callback
         * URL's path by our Subscriber implementation
         */
        if (! $this->_hasValidVerifyToken($httpGetData)) {
            return false;
        }
        return true;
    }

    /**
     * Sets a newly received feed (Atom/RSS) sent by a Hub as an update to a
     * Topic we've subscribed to.
     *
     * @param  string $feed
     * @return $this
     */
    public function setFeedUpdate($feed)
    {
        $this->feedUpdate = $feed;
        return $this;
    }

    /**
     * Check if any newly received feed (Atom/RSS) update was received
     *
     * @return bool
     */
    public function hasFeedUpdate()
    {
        if ($this->feedUpdate === null) {
            return false;
        }
        return true;
    }

    /**
     * Gets a newly received feed (Atom/RSS) sent by a Hub as an update to a
     * Topic we've subscribed to.
     *
     * @return string
     */
    public function getFeedUpdate()
    {
        return $this->feedUpdate;
    }

    // phpcs:disable PSR2.Methods.MethodDeclaration.Underscore

    /**
     * Check for a valid verify_token. By default attempts to compare values
     * with that sent from Hub, otherwise merely ascertains its existence.
     *
     * @param  array $httpGetData
     * @param  bool $checkValue
     * @return bool
     */
    protected function _hasValidVerifyToken(?array $httpGetData = null, $checkValue = true)
    {
        $verifyTokenKey = $this->_detectVerifyTokenKey($httpGetData);
        if (empty($verifyTokenKey)) {
            return false;
        }
        $verifyTokenExists = $this->getStorage()->hasSubscription($verifyTokenKey);
        if (! $verifyTokenExists) {
            return false;
        }
        if ($checkValue) {
            $data        = $this->getStorage()->getSubscription($verifyTokenKey);
            $verifyToken = $data['verify_token'];
            if ($verifyToken !== hash('sha256', $httpGetData['hub_verify_token'])) {
                return false;
            }
            $this->currentSubscriptionData = $data;
            return true;
        }
        return true;
    }

    /**
     * Attempt to detect the verification token key. This would be passed in
     * the Callback URL (which we are handling with this class!) as a URI
     * path part (the last part by convention).
     *
     * @param  null|array $httpGetData
     * @return false|string
     */
    protected function _detectVerifyTokenKey(?array $httpGetData = null)
    {
        /**
         * Available when sub keys encoding in Callback URL path
         */
        if (isset($this->subscriptionKey)) {
            return $this->subscriptionKey;
        }

        /**
         * Available only if allowed by PuSH 0.2 Hubs
         */
        if (
            is_array($httpGetData)
            && isset($httpGetData['xhub_subscription'])
        ) {
            return $httpGetData['xhub_subscription'];
        }

        /**
         * Available (possibly) if corrupted in transit and not part of $_GET
         */
        $params = $this->_parseQueryString();
        if (isset($params['xhub.subscription'])) {
            return rawurldecode($params['xhub.subscription']);
        }

        return false;
    }

    /**
     * Build an array of Query String parameters.
     * This bypasses $_GET which munges parameter names and cannot accept
     * multiple parameters with the same key.
     *
     * @return array|void
     */
    protected function _parseQueryString()
    {
        $params      = [];
        $queryString = '';
        if (isset($_SERVER['QUERY_STRING'])) {
            $queryString = $_SERVER['QUERY_STRING'];
        }
        if (empty($queryString)) {
            return [];
        }
        $parts = explode('&', $queryString);
        foreach ($parts as $kvpair) {
            $pair  = explode('=', $kvpair);
            $key   = rawurldecode($pair[0]);
            $value = rawurldecode($pair[1]);
            if (isset($params[$key])) {
                if (is_array($params[$key])) {
                    $params[$key][] = $value;
                } else {
                    $params[$key] = [$params[$key], $value];
                }
            } else {
                $params[$key] = $value;
            }
        }
        return $params;
    }

    // phpcs:enable PSR2.Methods.MethodDeclaration.Underscore
}
