<?php

/**
 * ---------------------------------------------------------------------
 *
 * GLPI - Gestionnaire Libre de Parc Informatique
 *
 * http://glpi-project.org
 *
 * @copyright 2015-2024 Teclib' and contributors.
 * @copyright 2003-2014 by the INDEPNET Development Team.
 * @licence   https://www.gnu.org/licenses/gpl-3.0.html
 *
 * ---------------------------------------------------------------------
 *
 * LICENSE
 *
 * This file is part of GLPI.
 *
 * This program is free software: you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with this program.  If not, see <https://www.gnu.org/licenses/>.
 *
 * ---------------------------------------------------------------------
 */

namespace Glpi\Cache;

use DirectoryIterator;
use Psr\Cache\CacheItemPoolInterface;
use Psr\SimpleCache\CacheInterface;
use Symfony\Component\Cache\Adapter\FilesystemAdapter;
use Symfony\Component\Cache\Adapter\MemcachedAdapter;
use Symfony\Component\Cache\Adapter\RedisAdapter;
use Symfony\Component\Cache\CacheItem;
use Toolbox;

class CacheManager
{
    /**
     * GLPI core cache context.
     * @var string
     */
    public const CONTEXT_CORE = 'core';

    /**
     * GLPI translations cache context.
     * @var string
     */
    public const CONTEXT_TRANSLATIONS = 'translations';

    /**
     * GLPI installer cache context.
     * @var string
     */
    public const CONTEXT_INSTALLER = 'installer';

    /**
     * Memcached scheme.
     * @var string
     */
    public const SCHEME_MEMCACHED  = 'memcached';

    /**
     * Redis scheme (TCP connection).
     * @var string
     */
    public const SCHEME_REDIS      = 'redis';

    /**
     * Redis scheme (TLS connection).
     * @var string
     */
    public const SCHEME_REDISS     = 'rediss';

    /**
     * Core cache configuration filename.
     * @var string
     */
    public const CONFIG_FILENAME = 'cache.php';

    /**
     * Configuration directory.
     *
     * @var string
     */
    private $config_dir;

    /**
     * Cache directory.
     *
     * @var string
     */
    private $cache_dir;

    public function __construct(string $config_dir = GLPI_CONFIG_DIR, string $cache_dir = GLPI_CACHE_DIR)
    {
        $this->config_dir = $config_dir;
        $this->cache_dir = $cache_dir;
    }

    /**
     * Defines cache namespace prefix.
     *
     * @param string $namespace_prefix
     *
     * @return bool
     */
    public function setNamespacePrefix(string $namespace_prefix): bool
    {
        $config = $this->getRawConfig();
        $config['namespace_prefix'] = $namespace_prefix ?: null;

        return $this->writeConfig($config);
    }

    /**
     * Defines cache configuration for given context.
     *
     * @param string          $context
     * @param string|string[] $dsn
     * @param array           $options
     *
     * @return bool
     */
    public function setConfiguration(string $context, $dsn, array $options = []): bool
    {
        if (!$this->isContextValid($context, true)) {
            throw new \InvalidArgumentException(sprintf('Invalid or non configurable context: "%s".', $context));
        }
        if (!$this->isDsnValid($dsn)) {
            throw new \InvalidArgumentException(sprintf('Invalid DSN: %s.', json_encode($dsn, JSON_UNESCAPED_SLASHES)));
        }

        $config = $this->getRawConfig();
        $config['contexts'][$context] = [
            'dsn'       => $dsn,
            'options'   => $options,
        ];

        return $this->writeConfig($config);
    }

    /**
     * Unset cache configuration for given context.
     *
     * @param string $context
     *
     * @return bool
     */
    public function unsetConfiguration(string $context): bool
    {
        if (!$this->isContextValid($context, true)) {
            throw new \InvalidArgumentException(sprintf('Invalid or non configurable context: "%s".', $context));
        }

        $config = $this->getRawConfig();
        unset($config['contexts'][$context]);

        return $this->writeConfig($config);
    }

    /**
     * Test connection to given DSN. Conection failure will trigger an exception.
     *
     * @param string|string[] $dsn
     * @param array           $options
     *
     * @return array
     */
    public function testConnection($dsn, array $options = []): void
    {
        switch ($this->extractScheme($dsn)) {
            case self::SCHEME_MEMCACHED:
                // Init Memcached connection to find potential connection errors.
                $client = MemcachedAdapter::createConnection($dsn, $options);
                $stats = $client->getStats();
                if ($stats === false) {
                   // Memcached::getStats() will return false if server cannot be reached.
                    throw new \RuntimeException('Unable to connect to Memcached server.');
                }
                break;
            case self::SCHEME_REDIS:
            case self::SCHEME_REDISS:
               // Init Redis connection to find potential connection errors.
                $options['lazy'] = false; //force instant connection
                RedisAdapter::createConnection($dsn, $options);
                break;
            default:
                break;
        }
    }

    /**
     * Get cache instance for given context.
     *
     * @param string $context
     *
     * @return CacheInterface
     */
    public function getCacheInstance(string $context): CacheInterface
    {
        return new SimpleCache($this->getCacheStorageAdapter($context));
    }

    /**
     * Get cache storage adapter for given context.
     *
     * @return \Psr\Cache\CacheItemPoolInterface
     */
    public function getCacheStorageAdapter(string $context): CacheItemPoolInterface
    {
        /** @var \Psr\Log\LoggerInterface $PHPLOGGER */
        global $PHPLOGGER;

        if (!$this->isContextValid($context)) {
            throw new \InvalidArgumentException(sprintf('Invalid context: "%s".', $context));
        }

        $raw_config = $this->getRawConfig();

        $namespace_prefix = $raw_config['namespace_prefix'] ?? '';
        if (!empty($namespace_prefix)) {
            $namespace_prefix .= '-';
        }

        if ($context === self::CONTEXT_TRANSLATIONS || $context === self::CONTEXT_INSTALLER) {
            // 'translations' and 'installer' contexts are not supposed to be configured
            // and should always use a filesystem adapter.
            // Append GLPI version to namespace to ensure that these caches are not containing data
            // from a previous version.
            $namespace = $this->normalizeNamespace($namespace_prefix . $context . '-' . GLPI_VERSION);
            $adapter = new FilesystemAdapter($namespace, 0, $this->cache_dir);
        } elseif (!array_key_exists($context, $raw_config['contexts'])) {
            // Default to filesystem, inside GLPI_CACHE_DIR/$context.
            $adapter = new FilesystemAdapter($this->normalizeNamespace($namespace_prefix . $context), 0, $this->cache_dir);
        } else {
            $context_config = $raw_config['contexts'][$context];

            $dsn       = $context_config['dsn'];
            $options   = $context_config['options'] ?? [];
            $scheme    = $this->extractScheme($dsn);
            $namespace = $this->normalizeNamespace($namespace_prefix .  $context);

            switch ($scheme) {
                case self::SCHEME_MEMCACHED:
                    $adapter = new MemcachedAdapter(
                        MemcachedAdapter::createConnection($dsn, $options),
                        $namespace
                    );
                    break;

                case self::SCHEME_REDIS:
                case self::SCHEME_REDISS:
                    $adapter = new RedisAdapter(
                        RedisAdapter::createConnection($dsn, $options),
                        $namespace
                    );
                    break;

                default:
                    throw new \RuntimeException(sprintf('Invalid cache DSN %s.', var_export($dsn, true)));
                    break;
            }
        }

        $adapter->setLogger($PHPLOGGER);

        return $adapter;
    }

    /**
     * Get core cache instance.
     *
     * @return CacheInterface
     */
    public function getCoreCacheInstance(): CacheInterface
    {

        return $this->getCacheInstance(self::CONTEXT_CORE);
    }

    /**
     * Get translations cache instance.
     *
     * @return CacheInterface
     */
    public function getTranslationsCacheInstance(): CacheInterface
    {
        return $this->getCacheInstance(self::CONTEXT_TRANSLATIONS);
    }

    /**
     * Get installer cache instance.
     *
     * @return CacheInterface
     */
    public function getInstallerCacheInstance(): CacheInterface
    {
        return $this->getCacheInstance(self::CONTEXT_INSTALLER);
    }

    /**
     * Reset all caches.
     *
     * @return bool
     */
    public function resetAllCaches(): bool
    {

        $success = true;

       // Clear all cache contexts
        $known_contexts = $this->getKnownContexts();
        foreach ($known_contexts as $context) {
            $success = $this->getCacheInstance($context)->clear() && $success;
        }

       // Clear compiled templates
        $tpl_cache_dir = $this->cache_dir . '/templates';
        if (file_exists($tpl_cache_dir)) {
            $tpl_files = glob($tpl_cache_dir . '/**/*.php');
            foreach ($tpl_files as $tpl_file) {
                $success = unlink($tpl_file) && $success;
            }

            $tpl_dirs = glob($tpl_cache_dir . '/*', GLOB_ONLYDIR);
            foreach ($tpl_dirs as $tpl_dir) {
                $success = rmdir($tpl_dir) && $success;
            }
        }

        return $success;
    }

    /**
     * Return list of all know cache contexts.
     *
     * @return string[]
     */
    public function getKnownContexts(): array
    {
       // Core contexts
        $contexts = [
            'core',
            'installer',
            'translations',
        ];

       // Contexts defined in configuration.
       // These may not be find in directories if they are configured to use a remote service.
        $config = $this->getRawConfig();
        array_push($contexts, ...array_keys($config['contexts']));

       // Context found from cache directories.
       // These may not be find in configuration if they are using default configuration.
        $directory_iterator = new DirectoryIterator($this->cache_dir);
        foreach ($directory_iterator as $file) {
            if ($file->isDot() || !$file->isDir() || !preg_match('/^plugin_/', $file->getFilename())) {
                continue;
            }

            $context = preg_replace('/^plugin_([a-zA-Z]+)$/', 'plugin:$1', $file->getFilename());
            if ($this->isContextValid($context)) {
                $contexts[] = $context;
            }
        }

        return array_unique($contexts);
    }

    /**
     * Extract scheme from DSN.
     *
     * @param string|string[] $dsn
     *
     * @return string|null
     */
    public function extractScheme($dsn): ?string
    {
        if (is_array($dsn)) {
            if (count($dsn) === 0) {
                return null;
            }

            $schemes = [];
            foreach ($dsn as $entry) {
                $schemes[] = $this->extractScheme($entry);
            }
            $schemes = array_unique($schemes);

            if (count($schemes) !== 1) {
                return null; // Mixed schemes are not allowed
            }
            $scheme = reset($schemes);
           // Only Memcached system accept multiple DSN.
            return $scheme === self::SCHEME_MEMCACHED ? $scheme : null;
        }

        if (!is_string($dsn)) {
            return null;
        }

        $matches = [];
        if (preg_match('/^(?<scheme>[a-z]+):\/\//', $dsn, $matches) !== 1) {
            return null;
        }
        $scheme = $matches['scheme'];

        return in_array($scheme, array_keys($this->getAvailableAdapters())) ? $scheme : null;
    }

    /**
     * Returns raw configuration from configuration file.
     *
     * @return array
     */
    private function getRawConfig(): array
    {
        $config_file = $this->config_dir . DIRECTORY_SEPARATOR . self::CONFIG_FILENAME;

        $config = [];
        if (file_exists($config_file)) {
            $config  = include($config_file);
            $contexts = $config['contexts'] ?? [];
            foreach ($contexts as $context => $context_config) {
                if (!$this->isContextValid($context, true)) {
                    trigger_error(sprintf('Invalid or non configurable context: "%s".', $context), E_USER_NOTICE);
                    unset($config['contexts'][$context]);
                    continue;
                }
                if (
                    !$this->isContextValid($context, true)
                    || !is_array($context_config)
                    || !array_key_exists('dsn', $context_config)
                    || !$this->isDsnValid($context_config['dsn'])
                    || (array_key_exists('options', $context_config) && !is_array($context_config['options']))
                ) {
                    trigger_error(sprintf('Invalid configuration for cache context "%s".', $context), E_USER_WARNING);
                    unset($config['contexts'][$context]);
                    continue;
                }
            }
        }

        if (!array_key_exists('contexts', $config)) {
            $config['contexts'] = [];
        }

        return $config;
    }

    /**
     * Write cache configuration to disk.
     *
     * @param array $config
     *
     * @return bool
     */
    private function writeConfig(array $config): bool
    {
        $config_export = var_export($config, true);

        $config_file_contents = <<<PHP
<?php
return {$config_export};
PHP;

        return Toolbox::writeConfig(self::CONFIG_FILENAME, $config_file_contents, $this->config_dir);
    }

    /**
     * Check if context key is valid.
     *
     * @param string $context
     * @param bool $only_configurable
     *
     * @return bool
     */
    public function isContextValid(string $context, bool $only_configurable = false): bool
    {
        $core_contexts = ['core'];

        if (!$only_configurable) {
           // 'installer' and 'translations' cache storages cannot not be configured (they always use the filesystem storage)
            $core_contexts[] = self::CONTEXT_INSTALLER;
            $core_contexts[] = self::CONTEXT_TRANSLATIONS;
        }

        return in_array($context, $core_contexts, true) || preg_match('/^plugin:\w+$/', $context) === 1;
    }

    /**
     * Check if DSN is valid.
     *
     * @param string|string[] $dsn
     *
     * @return bool
     */
    public function isDsnValid($dsn): bool
    {
        if (is_array($dsn)) {
            if (count($dsn) === 0) {
                return false;
            }

            $schemes = [];
            foreach ($dsn as $entry) {
                $schemes[] = $this->extractScheme($entry);
            }
            $schemes = array_unique($schemes);

            if (count($schemes) !== 1) {
                return false; // Mixed schemes are not allowed
            }

           // Only Memcached system accept multiple DSN.
            return reset($schemes) === self::SCHEME_MEMCACHED;
        }

        return in_array($this->extractScheme($dsn), array_keys($this->getAvailableAdapters()));
    }

    /**
     * Normalize namespace to prevent usage of reserved chars.
     *
     * @param string $namespace
     *
     * @return string
     */
    private function normalizeNamespace(string $namespace): string
    {
        return preg_replace(
            '/[' . preg_quote(CacheItem::RESERVED_CHARACTERS, '/') . ']/',
            '_',
            $namespace
        );
    }

    /**
     * Returns a list of available adapters.
     * Keys are adapter schemes (see self::SCHEME_*).
     * Values are translated names.
     *
     * @return array
     */
    public static function getAvailableAdapters(): array
    {
        return [
            self::SCHEME_MEMCACHED  => __('Memcached'),
            self::SCHEME_REDIS      => __('Redis (TCP)'),
            self::SCHEME_REDISS     => __('Redis (TLS)'),
        ];
    }
}
