<?php

declare (strict_types=1);
namespace SlimStat\Dependencies\BrowscapPHP;

use SlimStat\Dependencies\BrowscapPHP\Cache\BrowscapCache;
use SlimStat\Dependencies\BrowscapPHP\Cache\BrowscapCacheInterface;
use SlimStat\Dependencies\BrowscapPHP\Exception\ErrorCachedVersionException;
use SlimStat\Dependencies\BrowscapPHP\Exception\ErrorReadingFileException;
use SlimStat\Dependencies\BrowscapPHP\Exception\FetcherException;
use SlimStat\Dependencies\BrowscapPHP\Exception\FileNameMissingException;
use SlimStat\Dependencies\BrowscapPHP\Exception\FileNotFoundException;
use SlimStat\Dependencies\BrowscapPHP\Exception\NoCachedVersionException;
use SlimStat\Dependencies\BrowscapPHP\Exception\NoNewVersionException;
use SlimStat\Dependencies\BrowscapPHP\Helper\Converter;
use SlimStat\Dependencies\BrowscapPHP\Helper\ConverterInterface;
use SlimStat\Dependencies\BrowscapPHP\Helper\Exception;
use SlimStat\Dependencies\BrowscapPHP\Helper\Filesystem;
use SlimStat\Dependencies\BrowscapPHP\Helper\IniLoader;
use SlimStat\Dependencies\BrowscapPHP\Helper\IniLoaderInterface;
use SlimStat\Dependencies\GuzzleHttp\Client;
use SlimStat\Dependencies\GuzzleHttp\ClientInterface;
use SlimStat\Dependencies\GuzzleHttp\Exception\GuzzleException;
use SlimStat\Dependencies\Psr\Http\Message\ResponseInterface;
use SlimStat\Dependencies\Psr\Log\LoggerInterface;
use SlimStat\Dependencies\Psr\SimpleCache\CacheInterface;
use SlimStat\Dependencies\Psr\SimpleCache\InvalidArgumentException;
use SlimStat\Dependencies\Symfony\Component\Filesystem\Exception\IOException;
use Throwable;
use function assert;
use function error_get_last;
use function file_get_contents;
use function is_array;
use function is_int;
use function is_readable;
use function preg_replace;
use function sprintf;
use function str_replace;
/**
 * Browscap.ini parsing class with caching and update capabilities
 */
final class BrowscapUpdater implements BrowscapUpdaterInterface
{
    public const DEFAULT_TIMEOUT = 5;
    /**
     * The cache instance
     */
    private BrowscapCacheInterface $cache;
    private LoggerInterface $logger;
    private ClientInterface $client;
    /**
     * Curl connect timeout in seconds
     */
    private int $connectTimeout;
    /**
     * @throws void
     */
    public function __construct(CacheInterface $cache, LoggerInterface $logger, ?ClientInterface $client = null, int $connectTimeout = self::DEFAULT_TIMEOUT)
    {
        $this->cache = new BrowscapCache($cache, $logger);
        $this->logger = $logger;
        if ($client === null) {
            $client = new Client();
        }
        $this->client = $client;
        $this->connectTimeout = $connectTimeout;
    }
    /**
     * reads and parses an ini file and writes the results into the cache
     *
     * @throws FileNameMissingException
     * @throws FileNotFoundException
     * @throws ErrorReadingFileException
     */
    public function convertFile(string $iniFile): void
    {
        if (empty($iniFile)) {
            throw new FileNameMissingException('the file name can not be empty');
        }
        if (!is_readable($iniFile)) {
            throw new FileNotFoundException(sprintf('it was not possible to read the local file %s', $iniFile));
        }
        $iniString = file_get_contents($iniFile);
        if ($iniString === false) {
            throw new ErrorReadingFileException('an error occured while converting the local file into the cache');
        }
        $this->convertString($iniString);
    }
    /**
     * reads and parses an ini string and writes the results into the cache
     *
     * @throws void
     */
    public function convertString(string $iniString): void
    {
        try {
            $cachedVersion = $this->cache->getItem('browscap.version', false, $success);
        } catch (InvalidArgumentException $e) {
            $this->logger->error(new \InvalidArgumentException('an error occured while reading the data version from the cache', 0, $e));
            return;
        }
        assert($cachedVersion === null || is_int($cachedVersion));
        $converter = new Converter($this->logger, $this->cache);
        $this->storeContent($converter, $iniString, $cachedVersion);
    }
    /**
     * fetches a remote file and stores it into a local folder
     *
     * @param string $file       The name of the file where to store the remote content
     * @param string $remoteFile The code for the remote file to load
     *
     * @throws FetcherException
     * @throws Exception
     * @throws ErrorCachedVersionException
     */
    public function fetch(string $file, string $remoteFile = IniLoaderInterface::PHP_INI): void
    {
        try {
            $cachedVersion = $this->checkUpdate();
        } catch (NoNewVersionException $e) {
            return;
        } catch (NoCachedVersionException $e) {
            $cachedVersion = 0;
        }
        $this->logger->debug('started fetching remote file');
        $loader = new IniLoader();
        $loader->setRemoteFilename($remoteFile);
        $uri = $loader->getRemoteIniUrl();
        try {
            $response = $this->client->request('get', $uri, ['connect_timeout' => $this->connectTimeout]);
            assert($response instanceof ResponseInterface);
        } catch (GuzzleException $e) {
            throw new FetcherException(sprintf('an error occured while fetching remote data from URI %s', $uri), 0, $e);
        }
        if ($response->getStatusCode() !== 200) {
            throw new FetcherException(sprintf('an error occured while fetching remote data from URI %s: StatusCode was %d', $uri, $response->getStatusCode()));
        }
        try {
            $content = $response->getBody()->getContents();
        } catch (Throwable $e) {
            throw new FetcherException('an error occured while fetching remote data', 0, $e);
        }
        if (empty($content)) {
            $error = error_get_last();
            if (is_array($error)) {
                throw FetcherException::httpError($uri, $error['message']);
            }
            throw FetcherException::httpError($uri, 'an error occured while fetching remote data, but no error was raised');
        }
        $this->logger->debug('finished fetching remote file');
        $this->logger->debug('started storing remote file into local file');
        $content = $this->sanitizeContent($content);
        $converter = new Converter($this->logger, $this->cache);
        $iniVersion = $converter->getIniVersion($content);
        if ($iniVersion > $cachedVersion) {
            $fs = new Filesystem();
            try {
                $fs->dumpFile($file, $content);
            } catch (IOException $exception) {
                throw new FetcherException('an error occured while writing fetched data to local file', 0, $exception);
            }
        }
        $this->logger->debug('finished storing remote file into local file');
    }
    /**
     * fetches a remote file, parses it and writes the result into the cache
     * if the local stored information are in the same version as the remote data no actions are
     * taken
     *
     * @param string $remoteFile The code for the remote file to load
     *
     * @throws FetcherException
     * @throws Exception
     * @throws ErrorCachedVersionException
     */
    public function update(string $remoteFile = IniLoaderInterface::PHP_INI): void
    {
        $this->logger->debug('started fetching remote file');
        try {
            $cachedVersion = $this->checkUpdate();
        } catch (NoNewVersionException $e) {
            return;
        } catch (NoCachedVersionException $e) {
            $cachedVersion = 0;
        }
        $loader = new IniLoader();
        $loader->setRemoteFilename($remoteFile);
        $uri = $loader->getRemoteIniUrl();
        try {
            $response = $this->client->request('get', $uri, ['connect_timeout' => $this->connectTimeout]);
            assert($response instanceof ResponseInterface);
        } catch (GuzzleException $e) {
            throw new FetcherException(sprintf('an error occured while fetching remote data from URI %s', $uri), 0, $e);
        }
        if ($response->getStatusCode() !== 200) {
            throw new FetcherException(sprintf('an error occured while fetching remote data from URI %s: StatusCode was %d', $uri, $response->getStatusCode()));
        }
        try {
            $content = $response->getBody()->getContents();
        } catch (Throwable $e) {
            throw new FetcherException('an error occured while fetching remote data', 0, $e);
        }
        if (empty($content)) {
            $error = error_get_last();
            throw FetcherException::httpError($uri, $error['message'] ?? '');
        }
        $this->logger->debug('finished fetching remote file');
        $this->logger->debug('started updating cache from remote file');
        $converter = new Converter($this->logger, $this->cache);
        $this->storeContent($converter, $content, $cachedVersion);
        $this->logger->debug('finished updating cache from remote file');
    }
    /**
     * checks if an update on a remote location for the local file or the cache
     *
     * @return int|null The actual cached version if a newer version is available, null otherwise
     *
     * @throws FetcherException
     * @throws NoCachedVersionException
     * @throws ErrorCachedVersionException
     * @throws NoNewVersionException
     */
    public function checkUpdate(): ?int
    {
        $success = null;
        try {
            $cachedVersion = $this->cache->getItem('browscap.version', false, $success);
        } catch (InvalidArgumentException $e) {
            throw new ErrorCachedVersionException('an error occured while reading the data version from the cache', 0, $e);
        }
        assert($cachedVersion === null || is_int($cachedVersion));
        if (!$cachedVersion) {
            // could not load version from cache
            throw new NoCachedVersionException('there is no cached version available, please update from remote');
        }
        $uri = (new IniLoader())->getRemoteVersionUrl();
        try {
            $response = $this->client->request('get', $uri, ['connect_timeout' => $this->connectTimeout]);
            assert($response instanceof ResponseInterface);
        } catch (GuzzleException $e) {
            throw new FetcherException(sprintf('an error occured while fetching version data from URI %s', $uri), 0, $e);
        }
        if ($response->getStatusCode() !== 200) {
            throw new FetcherException(sprintf('an error occured while fetching version data from URI %s: StatusCode was %d', $uri, $response->getStatusCode()));
        }
        try {
            $remoteVersion = $response->getBody()->getContents();
        } catch (Throwable $e) {
            throw new FetcherException(sprintf('an error occured while fetching version data from URI %s: StatusCode was %d', $uri, $response->getStatusCode()), 0, $e);
        }
        if (!$remoteVersion) {
            // could not load remote version
            throw new FetcherException('could not load version from remote location');
        }
        if ($remoteVersion <= $cachedVersion) {
            throw new NoNewVersionException('there is no newer version available');
        }
        $this->logger->info(sprintf('a newer version is available, local version: %s, remote version: %s', $cachedVersion, $remoteVersion));
        return (int) $cachedVersion;
    }
    /**
     * @throws void
     */
    private function sanitizeContent(string $content): string
    {
        // replace everything between opening and closing php and asp tags
        $content = preg_replace('/<[?%].*[?%]>/', '', $content);
        // replace opening and closing php and asp tags
        return str_replace(['<?', '<%', '?>', '%>'], '', (string) $content);
    }
    /**
     * reads and parses an ini string and writes the results into the cache
     *
     * @throws void
     */
    private function storeContent(ConverterInterface $converter, string $content, ?int $cachedVersion): void
    {
        $iniString = $this->sanitizeContent($content);
        $iniVersion = $converter->getIniVersion($iniString);
        if ($cachedVersion && $iniVersion <= $cachedVersion) {
            return;
        }
        $converter->storeVersion();
        $converter->convertString($iniString);
    }
}