<?php
/*
 * Copyright (C) 2025 Xibo Signage Ltd
 *
 * Xibo - Digital Signage - https://xibosignage.com
 *
 * This file is part of Xibo.
 *
 * Xibo is free software: you can redistribute it and/or modify
 * it under the terms of the GNU Affero General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * any later version.
 *
 * Xibo 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 Affero General Public License for more details.
 *
 * You should have received a copy of the GNU Affero General Public License
 * along with Xibo.  If not, see <http://www.gnu.org/licenses/>.
 */
namespace Xibo\Service;

use Carbon\Carbon;
use Stash\Interfaces\PoolInterface;
use Xibo\Helper\Environment;
use Xibo\Helper\NatoAlphabet;
use Xibo\Storage\StorageServiceInterface;
use Xibo\Support\Exception\ConfigurationException;

/**
 * Class ConfigService
 * @package Xibo\Service
 */
class ConfigService implements ConfigServiceInterface
{
    /**
     * @var StorageServiceInterface
     */
    public $store;

    /**
     * @var PoolInterface
     */
    public $pool;

    /** @var string Setting Cache Key */
    private $settingCacheKey = 'settings';

    /** @var bool Has the settings cache been dropped this request? */
    private $settingsCacheDropped = false;

    /** @var array */
    private $settings = null;

    /**
     * @var string
     */
    public $rootUri;

    public $envTested = false;
    public $envFault = false;
    public $envWarning = false;

    /**
     * Database Config
     * @var array
     */
    public static $dbConfig = [];

    //
    // Extra Settings
    //
    public $middleware = null;
    public $logHandlers = null;
    public $logProcessors = null;
    public $authentication = null;
    public $samlSettings = null;
    public $casSettings = null;
    public $cacheDrivers = null;
    public $timeSeriesStore = null;
    public $cacheNamespace = 'Xibo';
    private $apiKeyPaths = null;
    private $connectorSettings = null;

    /**
     * Theme Specific Config
     * @var array
     */
    public $themeConfig = [];

    /** @var bool Has a theme been loaded? */
    private $themeLoaded = false;

    /**
     * @inheritdoc
     */
    public function setDependencies($store, $rootUri)
    {
        if ($store == null)
            throw new \RuntimeException('ConfigService setDependencies called with null store');

        if ($rootUri == null)
            throw new \RuntimeException('ConfigService setDependencies called with null rootUri');

        $this->store = $store;
        $this->rootUri = $rootUri;
    }

    /**
     * @inheritdoc
     */
    public function setPool($pool)
    {
        $this->pool = $pool;
    }

    /**
     * Get Cache Pool
     * @return \Stash\Interfaces\PoolInterface
     */
    private function getPool()
    {
        return $this->pool;
    }

    /**
     * Get Store
     * @return StorageServiceInterface
     */
    protected function getStore()
    {
        if ($this->store == null)
            throw new \RuntimeException('Config Service called before setDependencies');

        return $this->store;
    }

    /**
     * @inheritdoc
     */
    public function getDatabaseConfig()
    {
        return self::$dbConfig;
    }

    /**
     * Get App Root URI
     * @return string
     */
    public function rootUri()
    {
        if ($this->rootUri == null)
            throw new \RuntimeException('Config Service called before setDependencies');

        return $this->rootUri;
    }

    /**
     * @inheritdoc
     */
    public function getCacheDrivers()
    {
        return $this->cacheDrivers;
    }

    /**
     * @inheritdoc
     */
    public function getTimeSeriesStore()
    {
        return $this->timeSeriesStore;
    }

    /**
     * @inheritdoc
     */
    public function getCacheNamespace()
    {
        return $this->cacheNamespace;
    }

    /**
     * @inheritDoc
     */
    public function getConnectorSettings(string $connector): array
    {
        if ($this->connectorSettings !== null && array_key_exists($connector, $this->connectorSettings)) {
            return $this->connectorSettings[$connector];
        } else {
            return [];
        }
    }

    /**
     * Loads the settings from file.
     *  DO NOT CALL ANY STORE() METHODS IN HERE
     * @param \Psr\Container\ContainerInterface $container DI container which may be used in settings.php
     * @param string $settings Settings Path
     * @return ConfigServiceInterface
     */
    public static function Load($container, string $settings)
    {
        $config = new ConfigService();

        // Include the provided settings file.
        require ($settings);

        // Create a DB config
        self::$dbConfig = [
            'host' => $dbhost,
            'user' => $dbuser,
            'password' => $dbpass,
            'name' => $dbname,
            'ssl' => $dbssl ?? null,
            'sslVerify' => $dbsslverify ?? null
        ];

        // Pull in other settings

        // Log handlers
        if (isset($logHandlers))
            $config->logHandlers = $logHandlers;

        // Log Processors
        if (isset($logProcessors))
            $config->logProcessors = $logProcessors;

        // Middleware
        if (isset($middleware))
            $config->middleware = $middleware;

        // Authentication
        if (isset($authentication))
            $config->authentication = $authentication;

        // Saml settings
        if (isset($samlSettings))
            $config->samlSettings = $samlSettings;

        // CAS settings
        if (isset($casSettings))
            $config->casSettings = $casSettings;

        // Cache drivers
        if (isset($cacheDrivers))
            $config->cacheDrivers = $cacheDrivers;

        // Time series store settings
        if (isset($timeSeriesStore))
            $config->timeSeriesStore = $timeSeriesStore;

        if (isset($cacheNamespace))
            $config->cacheNamespace = $cacheNamespace;

        if (isset($apiKeyPaths))
            $config->apiKeyPaths = $apiKeyPaths;

        // Connector settings
        if (isset($connectorSettings)) {
            $config->connectorSettings = $connectorSettings;
        }

        // Set this as the global config
        return $config;
    }

    /**
     * Loads the theme
     * @param string|null $themeName
     * @throws ConfigurationException
     */
    public function loadTheme($themeName = null): void
    {
        global $config;

        // What is the currently selected theme?
        $globalTheme = ($themeName == null)
            ? basename($this->getSetting('GLOBAL_THEME_NAME', 'default'))
            : $themeName;

        // Is this theme valid?
        $systemTheme = (is_dir(PROJECT_ROOT . '/web/theme/' . $globalTheme)
            && file_exists(PROJECT_ROOT . '/web/theme/' . $globalTheme . '/config.php'));
        $customTheme = (is_dir(PROJECT_ROOT . '/web/theme/custom/' . $globalTheme)
            && file_exists(PROJECT_ROOT . '/web/theme/custom/' . $globalTheme . '/config.php'));

        if ($systemTheme) {
            require(PROJECT_ROOT . '/web/theme/' . $globalTheme . '/config.php');
            $themeFolder = 'theme/' . $globalTheme . '/';
        } elseif ($customTheme) {
            require(PROJECT_ROOT . '/web/theme/custom/' . $globalTheme . '/config.php');
            $themeFolder = 'theme/custom/' . $globalTheme . '/';
        } else {
            throw new ConfigurationException(__('The theme "%s" does not exist', $globalTheme));
        }

        $this->themeLoaded = true;
        $this->themeConfig = $config;
        $this->themeConfig['themeCode'] = $globalTheme;
        $this->themeConfig['themeFolder'] = $themeFolder;
    }

    /**
     * Get Theme Specific Settings
     * @param null $settingName
     * @param null $default
     * @return mixed|array|string
     */
    public function getThemeConfig($settingName = null, $default = null)
    {
        if ($settingName == null)
            return $this->themeConfig;

        if (isset($this->themeConfig[$settingName]))
            return $this->themeConfig[$settingName];
        else
            return $default;
    }

    /**
     * Get theme URI
     * @param string $uri
     * @param bool $local
     * @return string
     */
    public function uri($uri, $local = false)
    {
        $rootUri = ($local) ? PROJECT_ROOT . '/web/' : $this->rootUri();

        if (!$this->themeLoaded)
            return $rootUri . 'theme/default/' . $uri;

        // Serve the appropriate theme file
        if (is_dir(PROJECT_ROOT . '/web/' . $this->themeConfig['themeFolder'] . $uri)) {
            return $rootUri . $this->themeConfig['themeFolder'] . $uri;
        }
        else if (file_exists(PROJECT_ROOT . '/web/' . $this->themeConfig['themeFolder'] . $uri)) {
            return $rootUri . $this->themeConfig['themeFolder'] . $uri;
        }
        else {
            return $rootUri . 'theme/default/' . $uri;
        }
    }

    /**
     * Check a theme file exists
     * @param string $uri
     * @return string
     */
    public function fileExists($uri)
    {
        // Serve the appropriate file
        return file_exists(PROJECT_ROOT . '/web/' . $uri);
    }

    /**
     * Check a theme file exists
     * @param string $uri
     * @return string
     */
    public function themeFileExists($uri)
    {
        if (!$this->themeLoaded)
            return file_exists(PROJECT_ROOT . '/web/theme/default/' . $uri);

        // Serve the appropriate theme file
        if (file_exists(PROJECT_ROOT . '/web/' . $this->themeConfig['themeFolder'] . $uri)) {
            return true;
        } else {
            return file_exists(PROJECT_ROOT . '/web/theme/default/' . $uri);
        }
    }

    /**
     * @return array|mixed|null
     */
    private function loadSettings()
    {
        $item = null;

        if ($this->settings === null) {
            // We need to load in our settings
            if ($this->getPool() !== null) {
                // Try the cache
                $item = $this->getPool()->getItem($this->settingCacheKey);

                $data = $item->get();

                if ($item->isHit()) {
                    $this->settings = $data;
                }
            }

            // Are we still null?
            if ($this->settings === null) {
                // Load from the database
                $this->settings = $this->getStore()->select('SELECT `setting`, `value`, `userSee`, `userChange` FROM `setting`', []);
            }
        }

        // We should have our settings by now, so cache them if we can/need to
        if ($item !== null && $item->isMiss()) {
            // See about caching these settings - dependent on whether we're logging or not
            $cacheExpiry = 60 * 5;
            foreach ($this->settings as $setting) {
                if ($setting['setting'] == 'ELEVATE_LOG_UNTIL' && intval($setting['value']) > Carbon::now()->format('U')) {
                    $cacheExpiry = intval($setting['value']);
                    break;
                }
            }

            $item->set($this->settings);
            $item->expiresAfter($cacheExpiry);
            $this->getPool()->saveDeferred($item);
        }

        return $this->settings;
    }

    /** @inheritdoc */
    public function getSettings()
    {
        $settings = $this->loadSettings();
        $parsed = [];

        // Go through each setting and create a key/value pair
        foreach ($settings as $setting) {
            $parsed[$setting['setting']] = $setting['value'];
        }

        return $parsed;
    }

    /** @inheritdoc */
    public function getSetting($setting, $default = NULL, $full = false)
    {
        $settings = $this->loadSettings();

        if ($full) {
            foreach ($settings as $item) {
                if ($item['setting'] == $setting) {
                    return $item;
                }
            }

            return [
                'setting' => $setting,
                'value' => $default,
                'userSee' => 1,
                'userChange' => 1
            ];
        } else {
            $settings = $this->getSettings();
            return (isset($settings[$setting])) ? $settings[$setting] : $default;
        }
    }

    /** @inheritdoc */
    public function changeSetting($setting, $value, $userChange = 0)
    {
        $settings = $this->getSettings();

        // Update in memory cache
        foreach ($this->settings as $item) {
            if ($item['setting'] == $setting) {
                $item['value'] = $value;
                break;
            }
        }

        if (isset($settings[$setting])) {
            // We've already got this setting recorded, update it for
            // Update in database
            $this->getStore()->update('UPDATE `setting` SET `value` = :value WHERE `setting` = :setting', [
                'setting' => $setting,
                'value' => ($value === null) ? '' : $value
            ]);
        } else {
            // A new setting we've not seen before.
            // record it in the settings table.
            $this->getStore()->insert('
                INSERT INTO `setting` (`value`, setting, `userChange`) VALUES (:value, :setting, :userChange);', [
                    'setting' => $setting,
                    'value' => ($value === null) ? '' : $value,
                    'userChange' => $userChange
            ]);
        }

        // Drop the cache if we've not already done so this time around
        if (!$this->settingsCacheDropped && $this->getPool() !== null) {
            $this->getPool()->deleteItem($this->settingCacheKey);
            $this->settingsCacheDropped = true;
            $this->settings = null;
        }
    }

    /**
     * Is the provided setting visible
     * @param string $setting
     * @return bool
     */
    public function isSettingVisible($setting)
    {
        return $this->getSetting($setting, null, true)['userSee'] == 1;
    }

    /**
     * Is the provided setting editable
     * @param string $setting
     * @return bool
     */
    public function isSettingEditable($setting)
    {
        $item = $this->getSetting($setting, null, true);
        return $item['userSee'] == 1 && $item['userChange'] == 1;
    }

    /**
     * Should the host be considered a proxy exception
     * @param $host
     * @return bool
    */
    public function isProxyException($host)
    {
        $proxyExceptions = $this->getSetting('PROXY_EXCEPTIONS');

        // If empty, cannot be an exception
        if (empty($proxyExceptions))
            return false;

        // Simple test
        if (stripos($host, $proxyExceptions) !== false)
            return true;

        // Host test
        $parsedHost = parse_url($host, PHP_URL_HOST);

        // Kick out extremely malformed hosts
        if ($parsedHost === false)
            return false;

        // Go through each exception and test against the host
        foreach (explode(',', $proxyExceptions) as $proxyException) {
            if (stripos($parsedHost, $proxyException) !== false)
                return true;
        }

        // If we've got here without returning, then we aren't an exception
        return false;
    }

    /**
     * Get Proxy Configuration
     * @param array $httpOptions
     * @return array
     */
    public function getGuzzleProxy($httpOptions = [])
    {
        // Proxy support
        if ($this->getSetting('PROXY_HOST') != '') {

            $proxy = $this->getSetting('PROXY_HOST') . ':' . $this->getSetting('PROXY_PORT');

            if ($this->getSetting('PROXY_AUTH') != '') {
                $scheme = explode('://', $proxy);

                $proxy = $scheme[0] . '://' . $this->getSetting('PROXY_AUTH') . '@' . $scheme[1];
            }

            $httpOptions['proxy'] = [
                'http' => $proxy,
                'https' => $proxy
            ];

            if ($this->getSetting('PROXY_EXCEPTIONS') != '') {
                $httpOptions['proxy']['no'] = explode(',', $this->getSetting('PROXY_EXCEPTIONS'));
            }
        }

        // Global timeout
        // All outbound HTTP should have a timeout as they tie up a PHP process while the request completes (if
        // triggered from an incoming request)
        // https://github.com/xibosignage/xibo/issues/2631
        if (!array_key_exists('timeout', $httpOptions)) {
            $httpOptions['timeout'] = 20;
        }

        if (!array_key_exists('connect_timeout', $httpOptions)) {
            $httpOptions['connect_timeout'] = 5;
        }

        return $httpOptions;
    }

    /**
     * @inheritDoc
     */
    public function getApiKeyDetails()
    {
        if ($this->apiKeyPaths == null) {
            // We load the defaults
            $libraryLocation = $this->getSetting('LIBRARY_LOCATION');

            // We use the defaults
            $this->apiKeyPaths = [
                'publicKeyPath' => $libraryLocation . 'certs/public.key',
                'privateKeyPath' => $libraryLocation . 'certs/private.key',
                'encryptionKey' => file_get_contents($libraryLocation . 'certs/encryption.key')
            ];
        }

        return $this->apiKeyPaths;
    }

    private function testItem(&$results, $item, $result, $advice, $fault = true)
    {
        // 1=OK, 0=Failure, 2=Warning
        $status = ($result) ? 1 : (($fault) ? 0 : 2);

        // Set fault flag
        if (!$result && $fault)
            $this->envFault = true;

        // Set warning flag
        if (!$result && !$fault)
            $this->envWarning = true;

        $results[] = [
            'item' => $item,
            'status' => $status,
            'advice' => $advice
        ];
    }

    /**
     * Checks the Environment and Determines if it is suitable
     * @return array
     */
    public function checkEnvironment()
    {
        $rows = array();

        $this->testItem($rows, __('PHP Version'),
            Environment::checkPHP(),
            sprintf(__("PHP version %s or later required."), Environment::$VERSION_REQUIRED) . ' Detected ' . phpversion()
        );

        $this->testItem($rows, __('Cache File System Permissions'),
            Environment::checkCacheFileSystemPermissions(),
            __('Write permissions are required for cache/')
        );

        $this->testItem($rows, __('MySQL database (PDO MySql)'),
            Environment::checkPDO(),
            __('PDO support with MySQL drivers must be enabled in PHP.')
        );

        $this->testItem($rows, __('JSON Extension'),
            Environment::checkJson(),
            __('PHP JSON extension required to function.')
        );

        $this->testItem($rows, __('SOAP Extension'),
            Environment::checkSoap(),
            __('PHP SOAP extension required to function.')
        );

        $this->testItem($rows, __('GD Extension'),
            Environment::checkGd(),
            __('PHP GD extension required to function.')
        );

        $this->testItem($rows, __('Session'),
            Environment::checkGd(),
            __('PHP session support required to function.')
        );

        $this->testItem($rows, __('FileInfo'),
            Environment::checkFileInfo(),
            __('Requires PHP FileInfo support to function. If you are on Windows you need to enable the php_fileinfo.dll in your php.ini file.')
        );

        $this->testItem($rows, __('PCRE'),
            Environment::checkPCRE(),
            __('PHP PCRE support to function.')
        );

        $this->testItem($rows, __('Gettext'),
            Environment::checkPCRE(),
            __('PHP Gettext support to function.')
        );

        $this->testItem($rows, __('DOM Extension'),
            Environment::checkDom(),
            __('PHP DOM core functionality enabled.')
        );

        $this->testItem($rows, __('DOM XML Extension'),
            Environment::checkDomXml(),
            __('PHP DOM XML extension to function.')
        );

        $this->testItem($rows, __('Allow PHP to open external URLs'),
            (Environment::checkCurl() || Environment::checkAllowUrlFopen()),
            __('You must have the curl extension enabled or PHP configured with "allow_url_fopen = On" for the CMS to access external resources. We strongly recommend curl.'),
            false
        );

        $this->testItem($rows, __('DateTimeZone'),
            Environment::checkTimezoneIdentifiers(),
            __('This enables us to get a list of time zones supported by the hosting server.'),
            false
        );

        $this->testItem($rows, __('ZIP'),
            Environment::checkZip(),
            __('This enables import / export of layouts.')
        );

        $advice = __('Support for uploading large files is recommended.');
        $advice .= __('We suggest setting your PHP post_max_size and upload_max_filesize to at least 128M, and also increasing your max_execution_time to at least 120 seconds.');

        $this->testItem($rows, __('Large File Uploads'),
            Environment::checkPHPUploads(),
            $advice,
            false
        );

        $this->testItem($rows, __('cURL'),
            Environment::checkCurlInstalled(),
            __('cURL is used to fetch data from the Internet or Local Network')
        );

        $this->testItem($rows, __('OpenSSL'),
            Environment::checkOpenSsl(),
            __('OpenSSL is used to seal and verify messages sent to XMR'),
            false
        );

        $this->testItem($rows, __('SimpleXML'),
            Environment::checkSimpleXml(),
            __('SimpleXML is used to parse RSS feeds and other XML data sources')
        );

        $this->testItem($rows, __('GNUPG'),
            Environment::checkGnu(),
            __('checkGnu is used to verify the integrity of Player Software versions uploaded to the CMS'),
            false
        );

        $this->envTested = true;

        return $rows;
    }

    /**
     * Is there an environment fault
     * @return bool
     */
    public function environmentFault()
    {
        if (!$this->envTested) {
            $this->checkEnvironment();
        }

        return $this->envFault || !Environment::checkSettingsFileSystemPermissions();
    }

    /**
     * Is there an environment warning
     * @return bool
     */
    public function environmentWarning()
    {
        if (!$this->envTested) {
            $this->checkEnvironment();
        }

        return $this->envWarning;
    }

    /**
     * Check binlog format
     * @return bool
     */
    public function checkBinLogEnabled()
    {
        //TODO: move this into storage interface
        $results = $this->getStore()->select('show variables like \'log_bin\'', []);

        if (count($results) <= 0)
            return false;

        return ($results[0]['Value'] != 'OFF');
    }

    /**
     * Check binlog format
     * @return bool
     */
    public function checkBinLogFormat()
    {
        //TODO: move this into storage interface
        $results = $this->getStore()->select('show variables like \'binlog_format\'', []);

        if (count($results) <= 0)
            return false;

        return ($results[0]['Value'] != 'STATEMENT');
    }

    public function getPhoneticKey()
    {
        return NatoAlphabet::convertToNato($this->getSetting('SERVER_KEY'));
    }
}
