<?php

/**
 * Matomo - free/libre analytics platform
 *
 * @link    https://matomo.org
 * @license https://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later
 */

namespace Piwik;

use Piwik\Container\StaticContainer;
use Piwik\Intl\Data\Provider\RegionDataProvider;

/**
 * Contains less commonly needed URL helper methods.
 *
 */
class UrlHelper
{
    /**
     * @var string[]
     */
    private static $validLinkProtocols = [
        'http',
        'https',
        'tel',
        'sms',
        'mailto',
        'callto',
    ];

    /**
     * Checks if a string matches/is equal to one of the patterns/strings.
     *
     * @static
     * @param string $test String to test.
     * @param string[] $patterns Array of strings or regexs.
     *
     * @return bool true if $test matches or is equal to one of the regex/string in $patterns, false otherwise.
     */
    protected static function inArrayMatchesRegex($test, $patterns): bool
    {
        foreach ($patterns as $val) {
            if (@preg_match($val, '') === false) {
                if (strcasecmp($val, $test) === 0) {
                    return true;
                }
            } else {
                if (preg_match($val, $test) === 1) {
                    return true;
                }
            }
        }
        return false;
    }

    /**
     * Converts an array of query parameter name/value mappings into a query string.
     * Parameters that are in `$parametersToExclude` will not appear in the result.
     *
     * @static
     * @param array<string, string|false|array<string|false>> $queryParameters Array of query parameters, eg, `array('site' => '0', 'date' => '2012-01-01')`.
     * @param string[] $parametersToExclude Array of query parameter names that shouldn't be
     *                             in the result query string, eg, `array('date', 'period')`.
     * @return string A query string, eg, `"?site=0"`.
     * @api
     */
    public static function getQueryStringWithExcludedParameters($queryParameters, $parametersToExclude)
    {
        $validQuery = '';
        $separator = '&';
        foreach ($queryParameters as $name => $value) {
            // decode encoded square brackets
            $name = str_replace(array('%5B', '%5D'), array('[', ']'), $name);

            if (!self::inArrayMatchesRegex(strtolower($name), $parametersToExclude)) {
                if (is_array($value)) {
                    foreach ($value as $param) {
                        if ($param === false) {
                            $validQuery .= $name . '[]' . $separator;
                        } else {
                            $validQuery .= $name . '[]=' . $param . $separator;
                        }
                    }
                } elseif ($value === false) {
                    $validQuery .= $name . $separator;
                } else {
                    $validQuery .= $name . '=' . $value . $separator;
                }
            }
        }
        $validQuery = substr($validQuery, 0, -strlen($separator));
        return $validQuery;
    }

    /**
     * Reduce URL to more minimal form.  2 letter country codes are
     * replaced by '{}', while other parts are simply removed.
     *
     * Examples:
     *   www.example.com -> example.com
     *   search.example.com -> example.com
     *   m.example.com -> example.com
     *   de.example.com -> {}.example.com
     *   example.de -> example.{}
     *   example.co.uk -> example.{}
     *
     * @param string $url
     * @return string
     */
    public static function getLossyUrl($url)
    {
        static $countries;
        if (!isset($countries)) {
            /** @var RegionDataProvider $regionDataProvider */
            $regionDataProvider = StaticContainer::get('Piwik\Intl\Data\Provider\RegionDataProvider');
            $countries = implode('|', array_keys($regionDataProvider->getCountryList(true)));
        }

        return preg_replace(
            array(
                 '/^(w+[0-9]*|search)\./',
                 '/(^|\.)m\./',
                 '/(\.(com|org|net|co|it|edu))?\.(' . $countries . ')(\/|$)/',
                 '/(^|\.)(' . $countries . ')\./',
            ),
            array(
                 '',
                 '$1',
                 '.{}$4',
                 '$1{}.',
            ),
            $url
        );
    }

    /**
     * Returns true if the string passed may be a URL ie. it starts with protocol://.
     * We don't need a precise test here because the value comes from the website
     * tracked source code and the URLs may look very strange.
     *
     * @api
     * @param string $url
     * @return bool
     */
    public static function isLookLikeUrl($url)
    {
        return $url && preg_match('~^(([[:alpha:]][[:alnum:]+.-]*)?:)?//(.*)$~D', $url, $matches) !== 0
            && strlen($matches[3]) > 0
            && !preg_match('/^(javascript:|vbscript:|data:)/i', $matches[1])
            ;
    }

    /**
     * @param string $url
     * @return bool
     */
    public static function isLookLikeSafeUrl($url)
    {
        if (preg_match('/[\x00-\x1F\x7F]/', $url)) {
            return false;
        }

        if (strpos($url, ':') === false) {
            return true;
        }

        $protocol = explode(':', $url, 2)[0];
        return (bool)preg_match('/^(' . implode('|', self::$validLinkProtocols) . ')$/i', $protocol);
    }

    /**
     * Returns a URL created from the result of the [parse_url](https://php.net/manual/en/function.parse-url.php)
     * function.
     *
     * Copied from the PHP comments at [https://php.net/parse_url](https://php.net/parse_url).
     *
     * @param array $parsed Result of [parse_url](https://php.net/manual/en/function.parse-url.php).
     * @return false|string The URL or `false` if `$parsed` isn't an array.
     * @api
     */
    public static function getParseUrlReverse($parsed)
    {
        if (!is_array($parsed)) {
            return false;
        }

        // According to RFC 1738, the chars ':', '@' and '/' need to be encoded in username or password part of an url
        // We also encode '\' as a username or password containing that char, might be handled incorrectly by browsers
        $escapeSpecialChars = function ($value) {
            return str_replace([':', '@', '/', '\\'], [urlencode(':'), urlencode('@'), urlencode('/'), urlencode('\\')], $value);
        };

        $uri = !empty($parsed['scheme']) ? $parsed['scheme'] . ':' . (!strcasecmp($parsed['scheme'], 'mailto') ? '' : '//') : '';
        $uri .= !empty($parsed['user']) ? $escapeSpecialChars($parsed['user']) . (!empty($parsed['pass']) ? ':' . $escapeSpecialChars($parsed['pass']) : '') . '@' : '';
        $uri .= !empty($parsed['host']) ? $parsed['host'] : '';
        $uri .= !empty($parsed['port']) ? ':' . $parsed['port'] : '';

        if (!empty($parsed['path'])) {
            $uri .= (!strncmp($parsed['path'], '/', 1))
                ? $parsed['path']
                : ((!empty($uri) ? '/' : '') . $parsed['path']);
        }

        $uri .= !empty($parsed['query']) ? '?' . $parsed['query'] : '';
        $uri .= !empty($parsed['fragment']) ? '#' . $parsed['fragment'] : '';
        return $uri;
    }

    /**
     * Returns a URL query string as an array.
     *
     * @param string $urlQuery The query string, eg, `'?param1=value1&param2=value2'`.
     * @return array eg, `array('param1' => 'value1', 'param2' => 'value2')`
     * @api
     */
    public static function getArrayFromQueryString($urlQuery)
    {
        if (empty($urlQuery)) {
            return array();
        }

        // TODO: this method should not use a cache. callers should instead have their own cache, configured through DI.
        //       one undesirable side effect of using a cache here, is that this method can now init the StaticContainer, which makes setting
        //       test environment for RequestCommand more complicated.
        $cache    = Cache::getTransientCache();
        $cacheKey = 'arrayFromQuery' . $urlQuery;

        if ($cache->contains($cacheKey)) {
            return $cache->fetch($cacheKey);
        }

        if ($urlQuery[0] == '?') {
            $urlQuery = substr($urlQuery, 1);
        }
        $separator = '&';

        $urlQuery = $separator . $urlQuery;
        //        $urlQuery = str_replace(array('%20'), ' ', $urlQuery);
        $referrerQuery = trim($urlQuery);

        $values = explode($separator, $referrerQuery);

        $nameToValue = array();

        foreach ($values as $value) {
            $pos = strpos($value, '=');
            if ($pos !== false) {
                $name = substr($value, 0, $pos);
                $value = substr($value, $pos + 1);
                if ($value === false) {
                    $value = '';
                }
            } else {
                $name = $value;
                $value = false;
            }
            if (!empty($name)) {
                $name = Common::sanitizeInputValue($name);
            }
            if (!empty($value)) {
                $value = Common::sanitizeInputValue($value);
            }

            // if array without indexes
            $count = 0;
            $tmp = preg_replace('/(\[|%5b)(]|%5d)$/i', '', $name, -1, $count);
            if (!empty($tmp) && $count) {
                $name = $tmp;
                if (isset($nameToValue[$name]) == false || is_array($nameToValue[$name]) == false) {
                    $nameToValue[$name] = array();
                }
                array_push($nameToValue[$name], $value);
            } elseif (!empty($name)) {
                $nameToValue[$name] = $value;
            }
        }

        $cache->save($cacheKey, $nameToValue);

        return $nameToValue;
    }

    /**
     * Returns the value of a single query parameter from the supplied query string.
     *
     * @param string $urlQuery The query string.
     * @param string $parameter The query parameter name to return.
     * @return string|null Parameter value if found (can be the empty string!), null if not found.
     * @api
     */
    public static function getParameterFromQueryString($urlQuery, $parameter)
    {
        $nameToValue = self::getArrayFromQueryString($urlQuery);

        if (isset($nameToValue[$parameter])) {
            return $nameToValue[$parameter];
        }
        return null;
    }

    /**
     * Returns the path and query string of a URL.
     *
     * @param string    $url                    The URL.
     * @param array     $additionalParamsToAdd  If not empty the given parameters will be added to the query.
     * @param bool      $preserveAnchor         If true then do not remove any #anchor from the url, default false
     * @return string eg, `/test/index.php?module=CoreHome` if `$url` is `https://piwik.org/test/index.php?module=CoreHome`.
     * @api
     */
    public static function getPathAndQueryFromUrl($url, array $additionalParamsToAdd = [], bool $preserveAnchor = false)
    {
        $parsedUrl = parse_url($url);

        // If an anchor is included in the URL parse_url() will not split the anchor and query, so we do that there
        if (isset($parsedUrl['fragment']) && strpos($parsedUrl['fragment'], '?') !== false) {
            $parsedUrl['query'] = substr($parsedUrl['fragment'], strpos($parsedUrl['fragment'], '?') + 1);
            $parsedUrl['fragment'] = substr($parsedUrl['fragment'], 0, strpos($parsedUrl['fragment'], '?'));
        }

        $result = '';

        if (isset($parsedUrl['path'])) {
            if (substr($parsedUrl['path'], 0, 1) == '/') {
                $parsedUrl['path'] = substr($parsedUrl['path'], 1);
            }
            $result .= $parsedUrl['path'];
        }

        if ($preserveAnchor && isset($parsedUrl['fragment'])) {
            $result .= '#' . $parsedUrl['fragment'];
        }

        if (isset($parsedUrl['query']) || count($additionalParamsToAdd)) {
            $query = (isset($parsedUrl['query']) ? $parsedUrl['query'] : '');
            $query = self::addAdditionalParameters($query, $additionalParamsToAdd);
            $result .= '?' . $query;
        }
        return $result;
    }

    /**
     * Returns the query part from any valid url and adds additional parameters to the query part if needed.
     *
     * @param string $url    Any url eg `"https://example.com/piwik/?foo=bar"`
     * @param array $additionalParamsToAdd    If not empty the given parameters will be added to the query.
     *
     * @return string eg. `"foo=bar&foo2=bar2"`
     * @api
     */
    public static function getQueryFromUrl($url, array $additionalParamsToAdd = [])
    {
        $url = @parse_url($url);
        $query = '';

        if (!empty($url['query'])) {
            $query .= $url['query'];
        }

        $query = self::addAdditionalParameters($query, $additionalParamsToAdd);

        return $query;
    }

    /**
     * Add an array of additional parameters to a query string
     *
     * @param array  $additionalParamsToAdd
     */
    private static function addAdditionalParameters(string $query, array $additionalParamsToAdd): string
    {
        if (!empty($additionalParamsToAdd)) {
            if (!empty($query)) {
                $query .= '&';
            }
            $query .= Url::getQueryStringFromParameters($additionalParamsToAdd);
        }

        return $query;
    }

    /**
     * @param string $url
     * @return string|false|null
     */
    public static function getHostFromUrl($url)
    {
        if (!UrlHelper::isLookLikeUrl($url)) {
            $url = "http://" . $url;
        }
        return parse_url($url, PHP_URL_HOST);
    }
}
