<?php

// SPDX-FileCopyrightText: 2018 Icinga GmbH <https://icinga.com>
// SPDX-License-Identifier: GPL-3.0-or-later

namespace Icinga\Application;

use Icinga\Exception\NotWritableError;
use Iterator;
use Countable;
use LogicException;
use UnexpectedValueException;
use Icinga\Util\File;
use Icinga\Data\ConfigObject;
use Icinga\Data\Selectable;
use Icinga\Data\SimpleQuery;
use Icinga\File\Ini\IniWriter;
use Icinga\File\Ini\IniParser;
use Icinga\Exception\IcingaException;
use Icinga\Exception\NotReadableError;
use Icinga\Web\Navigation\Navigation;

/**
 * Container for INI like configuration and global registry of application and module related configuration.
 */
class Config implements Countable, Iterator, Selectable
{
    /**
     * Configuration directory where ALL (application and module) configuration is located
     *
     * @var string
     */
    public static $configDir;

    /**
     * Application config instances per file
     *
     * @var array
     */
    protected static $app = array();

    /**
     * Module config instances per file
     *
     * @var array
     */
    protected static $modules = array();

    /**
     * Navigation config instances per type
     *
     * @var array
     */
    protected static $navigation = array();

    /**
     * The internal ConfigObject
     *
     * @var ConfigObject
     */
    protected $config;

    /**
     * The INI file this config has been loaded from or should be written to
     *
     * @var string
     */
    protected $configFile;

    /**
     * Create a new config
     *
     * @param ?ConfigObject $config The config object to handle
     */
    public function __construct(?ConfigObject $config = null)
    {
        $this->config = $config !== null ? $config : new ConfigObject();
    }

    /**
     * Return this config's file path
     *
     * @return  string
     */
    public function getConfigFile()
    {
        return $this->configFile;
    }

    /**
     * Set this config's file path
     *
     * @param   string      $filepath   The path to the ini file
     *
     * @return  $this
     */
    public function setConfigFile($filepath)
    {
        $this->configFile = $filepath;
        return $this;
    }

    /**
     * Return the internal ConfigObject
     *
     * @return  ConfigObject
     */
    public function getConfigObject()
    {
        return $this->config;
    }

    /**
     * Provide a query for the internal config object
     *
     * @return  SimpleQuery
     */
    public function select()
    {
        return $this->config->select();
    }

    /**
     * Return the count of available sections
     *
     * @return  int
     */
    public function count(): int
    {
        return $this->select()->count();
    }

    /**
     * Reset the current position of the internal config object
     *
     * @return  void
     */
    public function rewind(): void
    {
        $this->config->rewind();
    }

    /**
     * Return the section of the current iteration
     *
     * @return  ConfigObject
     */
    public function current(): ConfigObject
    {
        return $this->config->current();
    }

    /**
     * Return whether the position of the current iteration is valid
     *
     * @return  bool
     */
    public function valid(): bool
    {
        return $this->config->valid();
    }

    /**
     * Return the section's name of the current iteration
     *
     * @return  string
     */
    public function key(): string
    {
        return $this->config->key();
    }

    /**
     * Advance the position of the current iteration and return the new section
     *
     * @return  void
     */
    public function next(): void
    {
        $this->config->next();
    }

    /**
     * Return whether this config has any sections
     *
     * @return  bool
     */
    public function isEmpty()
    {
        return $this->config->isEmpty();
    }

    /**
     * Return this config's section names
     *
     * @return  array
     */
    public function keys()
    {
        return $this->config->keys();
    }

    /**
     * Return this config's data as associative array
     *
     * @return  array
     */
    public function toArray()
    {
        return $this->config->toArray();
    }

    /**
     * Return the value from a section's property
     *
     * @param   string  $section    The section where the given property can be found
     * @param   string  $key        The section's property to fetch the value from
     * @param   mixed   $default    The value to return in case the section or the property is missing
     *
     * @return  mixed
     *
     * @throws  UnexpectedValueException    In case the given section does not hold any configuration
     */
    public function get($section, $key, $default = null)
    {
        $value = $this->config->$section;
        if ($value instanceof ConfigObject) {
            $value = $value->$key;
        } elseif ($value !== null) {
            throw new UnexpectedValueException(
                sprintf('Value "%s" is not of type "%s" or a sub-type of it', $value, get_class($this->config))
            );
        }

        if ($value === null && $default !== null) {
            $value = $default;
        }

        return $value;
    }

    /**
     * Return the given section
     *
     * @param   string  $name   The section's name
     *
     * @return  ConfigObject
     */
    public function getSection($name)
    {
        $section = $this->config->get($name);
        return $section !== null ? $section : new ConfigObject();
    }

    /**
     * Set or replace a section
     *
     * @param   string              $name
     * @param   array|ConfigObject  $config
     *
     * @return  $this
     */
    public function setSection($name, $config = null)
    {
        if ($config === null) {
            $config = new ConfigObject();
        } elseif (! $config instanceof ConfigObject) {
            $config = new ConfigObject($config);
        }

        $this->config->$name = $config;
        return $this;
    }

    /**
     * Remove a section
     *
     * @param   string  $name
     *
     * @return  $this
     */
    public function removeSection($name)
    {
        unset($this->config->$name);
        return $this;
    }

    /**
     * Return whether the given section exists
     *
     * @param   string  $name
     *
     * @return  bool
     */
    public function hasSection($name)
    {
        return isset($this->config->$name);
    }

    /**
     * Initialize a new config using the given array
     *
     * The returned config has no file associated to it.
     *
     * @param   array   $array      The array to initialize the config with
     *
     * @return  Config
     */
    public static function fromArray(array $array)
    {
        return new static(new ConfigObject($array));
    }

    /**
     * Load configuration from the given INI file
     *
     * @param   string      $file   The file to parse
     *
     * @throws  NotReadableError    When the file cannot be read
     */
    public static function fromIni($file)
    {
        $emptyConfig = new static();

        $filepath = realpath($file);
        if ($filepath === false) {
            $emptyConfig->setConfigFile($file);
        } elseif (is_readable($filepath)) {
            return IniParser::parseIniFile($filepath);
        } elseif (@file_exists($filepath)) {
            throw new NotReadableError(t('Cannot read config file "%s". Permission denied'), $filepath);
        }

        return $emptyConfig;
    }

    /**
     * Save configuration to the given INI file
     *
     * @param   string|null     $filePath   The path to the INI file or null in case this config's path should be used
     * @param   int             $fileMode   The file mode to store the file with
     *
     * @throws  LogicException              In case this config has no path and none is passed in either
     * @throws  NotWritableError            In case the INI file cannot be written
     *
     * @todo    create basepath and throw NotWritableError in case its not possible
     */
    public function saveIni($filePath = null, $fileMode = 0660)
    {
        if ($filePath === null && $this->configFile) {
            $filePath = $this->configFile;
        } elseif ($filePath === null) {
            throw new LogicException('You need to pass $filePath or set a path using Config::setConfigFile()');
        }

        if (! file_exists($filePath)) {
            File::create($filePath, $fileMode);
        }

        $this->getIniWriter($filePath, $fileMode)->write();
    }

    /**
     * Return a IniWriter for this config
     *
     * @param   string|null     $filePath
     * @param   int             $fileMode
     *
     * @return  IniWriter
     */
    protected function getIniWriter($filePath = null, $fileMode = null)
    {
        return new IniWriter($this, $filePath, $fileMode);
    }

    /**
     * Prepend configuration base dir to the given relative path
     *
     * @param   string  $path   A relative path
     *
     * @return  string
     */
    public static function resolvePath($path)
    {
        return self::$configDir . DIRECTORY_SEPARATOR . ltrim($path, DIRECTORY_SEPARATOR);
    }

    /**
     * Retrieve a application config
     *
     * @param   string  $configname     The configuration name (without ini suffix) to read and return
     * @param   bool    $fromDisk       When set true, the configuration will be read from disk, even
     *                                  if it already has been read
     *
     * @return  Config                  The requested configuration
     */
    public static function app($configname = 'config', $fromDisk = false)
    {
        if (! isset(self::$app[$configname]) || $fromDisk) {
            self::$app[$configname] = static::fromIni(static::resolvePath($configname . '.ini'));
        }

        return self::$app[$configname];
    }

    /**
     * Retrieve a module config
     *
     * @param   string  $modulename     The name of the module where to look for the requested configuration
     * @param   string  $configname     The configuration name (without ini suffix) to read and return
     * @param   bool    $fromDisk       When set true, the configuration will be read from disk, even
     *                                  if it already has been read
     *
     * @return  Config                  The requested configuration
     */
    public static function module($modulename, $configname = 'config', $fromDisk = false)
    {
        if (! isset(self::$modules[$modulename])) {
            self::$modules[$modulename] = array();
        }

        if (! isset(self::$modules[$modulename][$configname]) || $fromDisk) {
            self::$modules[$modulename][$configname] = static::fromIni(
                static::resolvePath('modules/' . $modulename . '/' . $configname . '.ini')
            );
        }
        return self::$modules[$modulename][$configname];
    }

    /**
     * Retrieve a navigation config
     *
     * @param   string  $type       The type identifier of the navigation item for which to return its config
     * @param   string  $username   A user's name or null if the shared config is desired
     * @param   bool    $fromDisk   If true, the configuration will be read from disk
     *
     * @return  Config              The requested configuration
     */
    public static function navigation($type, $username = null, $fromDisk = false)
    {
        if (! isset(self::$navigation[$type])) {
            self::$navigation[$type] = array();
        }

        $branch = $username ?: 'shared';
        $typeConfigs = self::$navigation[$type];
        if (! isset($typeConfigs[$branch]) || $fromDisk) {
            $typeConfigs[$branch] = static::fromIni(static::getNavigationConfigPath($type, $username));
        }

        return $typeConfigs[$branch];
    }

    /**
     * Return the path to the configuration file for the given navigation item type and user
     *
     * @param   string  $type
     * @param   string  $username
     *
     * @return  string
     *
     * @throws  IcingaException     In case the given type is unknown
     */
    protected static function getNavigationConfigPath($type, $username = null)
    {
        $itemTypeConfig = Navigation::getItemTypeConfiguration();
        if (! isset($itemTypeConfig[$type])) {
            throw new IcingaException('Invalid navigation item type %s provided', $type);
        }

        if (isset($itemTypeConfig[$type]['config'])) {
            $filename = $itemTypeConfig[$type]['config'] . '.ini';
        } else {
            $filename = $type . 's.ini';
        }

        if ($username) {
            $path = static::resolvePath(implode(DIRECTORY_SEPARATOR, array('preferences', $username, $filename)));
            if (realpath($path) === false) {
                $path = static::resolvePath(implode(
                    DIRECTORY_SEPARATOR,
                    array('preferences', strtolower($username), $filename)
                ));
            }
        } else {
            $path = static::resolvePath('navigation' . DIRECTORY_SEPARATOR . $filename);
        }
        return $path;
    }

    /**
     * Return this config rendered as a INI structured string
     *
     * @return  string
     */
    public function __toString()
    {
        return $this->getIniWriter()->render();
    }
}
