<?php

/**
 * @file classes/plugins/ThemePlugin.inc.php
 *
 * Copyright (c) 2014-2021 Simon Fraser University
 * Copyright (c) 2003-2021 John Willinsky
 * Distributed under the GNU GPL v3. For full terms see the file docs/COPYING.
 *
 * @class ThemePlugin
 * @ingroup plugins
 *
 * @brief Abstract class for theme plugins
 */

import('lib.pkp.classes.plugins.LazyLoadPlugin');

define('LESS_FILENAME_SUFFIX', '.less');
define('THEME_OPTION_PREFIX', 'themeOption_');

abstract class ThemePlugin extends LazyLoadPlugin {
	/**
	 * Collection of styles
	 *
	 * @see self::_registerStyles
	 * @var $styles array
	 */
	public $styles = array();

	/**
	 * Collection of scripts
	 *
	 * @see self::_registerScripts
	 * @var $scripts array
	 */
	public $scripts = array();

	/**
	 * Theme-specific options
	 *
	 * @var $options array;
	 */
	public $options = array();

	/**
	 * Theme-specific navigation menu areas
	 *
	 * @var $menuAreas array;
	 */
	public $menuAreas = array();

	/**
	 * Parent theme (optional)
	 *
	 * @var $parent ThemePlugin
	 */
	public $parent;

	/**
	 * Stored reference to option values
	 *
	 * A null value indicates that no lookup has occured. If no options are set,
	 * the lookup will assign an empty array.
	 *
	 * @var $_optionValues null|array;
	 */
	protected $_optionValues = null;

	/**
	 * @copydoc Plugin::register
	 */
	function register($category, $path, $mainContextId = null) {
		if (!parent::register($category, $path, $mainContextId)) return false;

		// Don't perform any futher operations if theme is not currently active
		if (!$this->isActive()) {
			return true;
		}

		// Themes must initialize their functionality after all theme plugins
		// have been loaded in order to make use of parent/child theme
		// relationships
		HookRegistry::register('PluginRegistry::categoryLoaded::themes', array($this, 'themeRegistered'));
		HookRegistry::register('PluginRegistry::categoryLoaded::themes', array($this, 'initAfter'));

		// Allow themes to override plugin template files
		HookRegistry::register('TemplateResource::getFilename', array($this, '_overridePluginTemplates'));

		return true;
	}

	/**
	 * Fire the init() method when a theme is registered
	 *
	 * @param $themes array List of all loaded themes
	 * @return null
	 */
	public function themeRegistered($themes) {

		// Don't fully initialize the theme until OJS is installed, so that
		// there are no requests to the database before it exists
		if (defined('SESSION_DISABLE_INIT')) {
			return;
		}

		$this->init();
	}

	/**
	 * The primary method themes should use to add styles, scripts and fonts,
	 * or register hooks. This method is only fired for the currently active
	 * theme.
	 *
	 * @return null
	 */
	public abstract function init();

	/**
	 * Perform actions after the theme has been initialized
	 *
	 * Registers templates, styles and scripts that have been added by the
	 * theme or any parent themes
	 */
	public function initAfter() {
		$this->_registerTemplates();
		$this->_registerStyles();
		$this->_registerScripts();
	}

	/**
	 * Determine whether or not this plugin is currently active
	 *
	 * This only returns true if the theme is currently the selected theme
	 * in a given context. Use self::getEnabled() if you want to know if the
	 * theme is available for use on the site.
	 *
	 * @return boolean
	 */
	public function isActive() {
		if (defined('SESSION_DISABLE_INIT')) return false;
		$request = Application::get()->getRequest();
		$context = $request->getContext();
		if (is_a($context, 'Context')) {
			$activeTheme = $context->getData('themePluginPath');
		} else {
			$site = $request->getSite();
			$activeTheme = $site->getData('themePluginPath');
		}

		return $activeTheme == basename($this->getPluginPath());
	}

	/**
	 * Add a stylesheet to load with this theme
	 *
	 * Style paths with a .less extension will be compiled and redirected to
	 * the compiled file.
	 *
	 * @param $name string A name for this stylesheet
	 * @param $style string The stylesheet. Should be a path relative to the
	 *   theme directory or, if the `inline` argument is included, style data to
	 *   be output.
	 * @param $args array Optional arguments hash. Supported args:
	 *   'context': Whether to load this on the `frontend` or `backend`.
	 *      default: `frontend`
	 *   'priority': Controls order in which styles are printed
	 *   'addLess': Additional LESS files to process before compiling. Array
	 *   'addLessVariables': A string containing additional LESS variables to
	 *      parse before compiling. Example: "@bg:#000;"
	 *   `inline` bool Whether the $style value should be output directly as
	 *      style data.
	 */
	public function addStyle($name, $style, $args = array()) {

		// Pass a file path for LESS files
		if (substr($style, (strlen(LESS_FILENAME_SUFFIX) * -1)) === LESS_FILENAME_SUFFIX) {
			$args['style'] = $this->_getBaseDir($style);

		// Pass a URL for other files
		} elseif (empty($args['inline'])) {
			if (isset($args['baseUrl'])) {
				$args['style'] = $args['baseUrl'] . $style;
			} else {
				$args['style'] = $this->_getBaseUrl($style);
			}

		// Leave inlined styles alone
		} else {
			$args['style'] = $style;
		}

		// Generate file paths for any additional LESS files to compile with
		// this style
		if (isset($args['addLess'])) {
			foreach ($args['addLess'] as &$file) {
				$file = $this->_getBaseDir($file);
			}
		}

		$this->styles[$name] = $args;
	}

	/**
	 * Modify the params of an existing stylesheet
	 *
	 * @param $name string The name of the stylesheet to modify
	 * @param $args array Parameters to modify.
	 * @see self::addStyle()
	 * @return null
	 */
	public function modifyStyle($name, $args = array()) {

		$style = &$this->getStyle($name);

		if (empty($style)) {
			return;
		}

		if (isset($args['addLess'])) {
			foreach ($args['addLess'] as &$file) {
				$file = $this->_getBaseDir($file);
			}
		}

		if (isset($args['style']) && !isset($args['inline'])) {
			$args['style'] = substr($args['style'], (strlen(LESS_FILENAME_SUFFIX) * -1)) == LESS_FILENAME_SUFFIX ? $this->_getBaseDir($args['style']) : $this->_getBaseUrl($args['style']);
		}

		$style = array_merge_recursive($style, $args);
	}

	/**
	 * Remove a registered stylesheet
	 *
	 * @param $name string The name of the stylesheet to remove
	 * @return bool Whether or not the stylesheet was found and removed.
	 */
	public function removeStyle($name) {

		if (isset($this->styles[$name])) {
			unset($this->styles[$name]);
			return true;
		}

		return $this->parent ? $this->parent->removeStyle($name) : false;
	}

	/**
	 * Get a style from this theme or any parent theme
	 *
	 * @param $name string The name of the style to retrieve
	 * @return array|null Reference to the style or null if not found
	 */
	public function &getStyle($name) {

		// Search this theme
		if (isset($this->styles[$name])) {
			$style = &$this->styles[$name];
			return $style;
		}

		// If no parent theme, no style was found
		if (!isset($this->parent)) {
			$style = null;
			return $style;
		}

		return $this->parent->getStyle($name);
	}

	/**
	 * Add a script to load with this theme
	 *
	 * @param $name string A name for this script
	 * @param $script string The script to be included. Should be path relative
	 *   to the theme or, if the `inline` argument is included, script data to
	 *   be output.
	 * @param $args array Optional arguments hash. Supported args:
	 *   `context` string Whether to load this on the `frontend` or `backend`.
	 *      default: frontend
	 *   `priority` int Controls order in which scripts are printed
	 *      default: STYLE_SEQUENCE_NORMAL
	 *   `inline` bool Whether the $script value should be output directly as
	 *      script data. Used to pass backend data to the scripts.
	 */
	public function addScript($name, $script, $args = array()) {

		if (!empty($args['inline'])) {
			$args['script'] = $script;
		} elseif (isset($args['baseUrl'])) {
			$args['script'] = $args['baseUrl'] . $script;
		} else {
			$args['script'] = $this->_getBaseUrl($script);
		}

		$this->scripts[$name] = $args;
	}

	/**
	 * Modify the params of an existing script
	 *
	 * @param $name string The name of the script to modify
	 * @param $args array Parameters to modify.
	 * @see self::addScript()
	 * @return null
	 */
	public function modifyScript($name, $args = array()) {

		$script = &$this->getScript($name);

		if (empty($script)) {
			return;
		}

		if (isset($args['path'])) {
			$args['path'] = $this->_getBaseUrl($args['path']);
		}

		$script = array_merge( $script, $args );
	}

	/**
	 * Remove a registered script
	 *
	 * @param $name string The name of the script to remove
	 * @return bool Whether or not the stylesheet was found and removed.
	 */
	public function removeScript($name) {

		if (isset($this->scripts[$name])) {
			unset($this->scripts[$name]);
			return true;
		}

		return $this->parent ? $this->parent->removeScript($name) : false;
	}

	/**
	 * Get a script from this theme or any parent theme
	 *
	 * @param $name string The name of the script to retrieve
	 * @return array|null Reference to the script or null if not found
	 */
	public function &getScript($name) {

		// Search this theme
		if (isset($this->scripts[$name])) {
			$style = &$this->scripts[$name];
			return $style;
		}

		// If no parent theme, no script was found
		if (!isset($this->parent)) {
			return;
		}

		return $this->parent->getScript($name);
	}

	/**
	 * Add a theme option
	 *
	 * Theme options are added programmatically to the Settings > Website >
	 * Appearance form when this theme is activated. Common options are
	 * colour and typography selectors.
	 *
	 * @param $name string Unique name for this setting
	 * @param $type string One of the Field* class names
	 * @param $args array Optional parameters defining this setting. Some setting
	 *   types may accept or require additional arguments.
	 *  `label` string Locale key for a label for this field.
	 *  `description` string Locale key for a description for this field.
	 *  `default` mixed A default value. Default: ''
	 */
	public function addOption($name, $type, $args = array()) {

		if (!empty($this->options[$name])) {
			return;
		}

		// Convert theme option types from before v3.2
		if (in_array($type, ['text', 'colour', 'radio'])) {
			if (isset($args['label'])) {
				$args['label'] = __($args['label']);
			}
			if (isset($args['description'])) {
				$args['description'] = __($args['description']);
			}
			switch ($type) {
				case 'text':
					$type = 'FieldText';
					break;
				case 'colour':
					$type = 'FieldColor';
					break;
				case 'radio':
					$type = 'FieldOptions';
					$args['type'] = 'radio';
					if (!empty($args['options'])) {
						$options = [];
						foreach ($args['options'] as $optionValue => $optionLabel) {
							$options[] = ['value' => $optionValue, 'label' => __($optionLabel)];
						}
						$args['options'] = $options;
					}
					break;
			}
		}

		$class = 'PKP\components\forms\\' . $type;
		try {
			$this->options[$name] = new $class($name, $args);
		} catch (Exception $e) {
			$class = 'APP\components\forms\\' . $type;
			try {
				$this->options[$name] = new $class($name, $args);
			} catch (Exception $e) {
				throw new Exception(sprintf(
					'The %s class was not found for the theme option, %s,  defined by %s or one of its parent themes.',
					$type,
					$name,
					$this->getDisplayName()
				));
			}
		}
	}

	/**
	 * Get the value of an option or default if the option is not set
	 *
	 * @param $name string The name of the option value to retrieve
	 * @return mixed The value of the option. Will return a default if set in
	 *  the option config. False if no option exists. Null if no value or default
	 *  exists.
	 */
	public function getOption($name) {

		// Check if this is a valid option
		if (!isset($this->options[$name])) {
			return $this->parent ? $this->parent->getOption($name) : false;
		}

		// Retrieve option values if they haven't been loaded yet
		if (is_null($this->_optionValues)) {
			$context = Application::get()->getRequest()->getContext();
			$contextId = $context ? $context->getId() : CONTEXT_ID_NONE;
			$this->_optionValues = $this->getOptionValues($contextId);
		}

		if (isset($this->_optionValues[$name])) {
			return $this->_optionValues[$name];
		}

		// Return a default if no value is set
		if (isset($this->options[$name])) {
			$option = $this->options[$name];
		} elseif ($this->parent) {
			$option = $this->parent->getOption($name);
		}
		return isset($option->default) ? $option->default : null;
	}

	/**
	 * Get an option's configuration settings
	 *
	 * This retrives option settings for any option attached to this theme or
	 * any parent theme.
	 *
	 * @param $name The name of the option config to retrieve
	 * @return false|array The config array for this option. Or false if no
	 *  config is found.
	 */
	public function getOptionConfig($name) {

		if (isset($this->options[$name])) {
			return $this->options[$name];
		}

		return $this->parent ? $this->parent->getOptionConfig($name) : false;
	}

	/**
	 * Get all options' configuration settings.
	 *
	 * This retrieves a single array containing options settings for this
	 * theme and any parent themes.
	 *
	 * @return array
	 */
	public function getOptionsConfig() {

		if (!$this->parent) {
			return $this->options;
		}

		return array_merge(
			$this->parent->getOptionsConfig(),
			$this->options
		);
	}

	/**
	 * Modify option configuration settings
	 *
	 * @deprecated Unnecessary since 3.2 because options are stored as objects,
	 *  so changes can be made directly (via reference) and args don't need to be
	 *  manually merged
	 * @param $name The name of the option config to retrieve
	 * @param $args The new configuration settings for this option
	 * @return bool Whether the option was found and the config was updated.
	 */
	public function modifyOptionsConfig($name, $args = array()) {
		$option = $this->getOption($name);
		foreach ($args as $key => $value) {
			if (property_exists($option, $key)) {
				$option->{$key} = $value;
			}
		}
	}

	/**
	 * Remove an option
	 *
	 * @param $name The name of the option to remove
	 * @return bool Whether the option was found and removed
	 */
	public function removeOption($name) {

		if (isset($this->options[$name])) {
			unset($this->options[$name]);
			return true;
		}

		return $this->parent ? $this->parent->removeOption($name) : false;
	}

	/**
	 * Get all option values
	 *
	 * This retrieves a single array containing option values for this theme
	 * and any parent themes.
	 *
	 * @param $contextId int
	 * @return array
	 */
	public function getOptionValues($contextId) {

		$pluginSettingsDAO = DAORegistry::getDAO('PluginSettingsDAO');

		$return = [];
		$values = $pluginSettingsDAO->getPluginSettings($contextId, $this->getName());
		foreach ($this->options as $optionName => $optionConfig) {
			$value = isset($values[$optionName]) ? $values[$optionName] : null;
			// Convert values stored in the db to the type of the default value
			if (!is_null($optionConfig->default)) {
				switch (gettype($optionConfig->default)) {
					case 'boolean':
						$value = !$value || $value === 'false' ? false : true;
						break;
					case 'integer':
						$value = (int) $value;
						break;
					case 'array':
						$value = $value === null ? [] : unserialize($value);
						break;
				}
			}
			$return[$optionName] = $value;
		}

		if (!$this->parent) {
			return $return;
		}

		return array_merge(
			$this->parent->getOptionValues($contextId),
			$return
		);
	}

	/**
	 * Overwrite this function to perform any validation on options before they
	 * are saved
	 *
	 * If this is a child theme, you must call $this->parent->validateOptions() to
	 * perform any validation defined on the parent theme.
	 *
	 * @param $options array Key/value list of options to validate
	 * @param $themePluginPath string The theme these options are for
	 * @param $contextId int The context these theme options are for, or
	 *  CONTEXT_ID_NONE for the site-wide settings.
	 * @param $request Request
	 * @return array List of errors with option name as the key and the value as
	 *  an array of error messages. Example:
	 *  [
	 *    'color' => [
	 *      'This color is too dark for this area and some people will not be able to read it.',
	 *    ]
	 *  ]
	 */
	public function validateOptions($options, $themePluginPath, $contextId, $request) {
		return [];
	}

	/**
	 * Sanitize and save a theme option
	 *
	 * @param $name string A unique id for the option to save
	 * @param $value mixed The new value to save
	 * @param $contextId int Optional context id. Defaults to the current
	 *  context
	 */
	public function saveOption($name, $value, $contextId = null) {

		$option = !empty($this->options[$name]) ? $this->options[$name] : null;

		if (is_null($option)) {
			return $this->parent ? $this->parent->saveOption($name, $value, $contextId) : false;
		}

		if (is_null($contextId)) {
			$context = Application::get()->getRequest()->getContext();
			$contextId = $context->getId();
		}

		$pluginSettingsDao = DAORegistry::getDAO('PluginSettingsDAO'); /* @var $pluginSettingsDao PluginSettingsDAO */

		// Remove setting row for empty string values (but not all falsey values)
		if ($value === '') {
			$pluginSettingsDao->deleteSetting($contextId, $this->getName(), $name);
		} else {
			$type = $pluginSettingsDao->getType($value);
			$value = $pluginSettingsDao->convertToDb($value, $type);
			$this->updateSetting($contextId, $name, $value, $type);
		}

	}

	/**
	 * Register a navigation menu area for this theme
	 *
	 * @param $menuAreas string|array One or more menu area names
	 */
	public function addMenuArea($menuAreas) {

		if (!is_array($menuAreas)) {
			$menuAreas = array($menuAreas);
		}

		$this->menuAreas = array_merge($this->menuAreas, $menuAreas);
	}

	/**
	 * Remove a registered navigation menu area
	 *
	 * @param $menuArea string The menu area to remove
	 * @return bool Whether or not the menuArea was found and removed.
	 */
	public function removeMenuArea($menuArea) {

		$index = array_search($menuArea, $this->menuAreas);
		if ($index !== false) {
			array_splice($this->menuAreas, $index, 1);
			return true;
		}

		return $this->parent ? $this->parent->removeMenuArea($menuArea) : false;
	}

	/**
	 * Get all menu areas registered by this theme and any parents
	 *
	 * @param $existingAreas array Any existing menu areas from child themes
	 * @return array All menua reas
	 */
	public function getMenuAreas($existingAreas = array()) {

		$existingAreas = array_unique(array_merge($this->menuAreas, $existingAreas));

		return $this->parent ? $this->parent->getMenuAreas($existingAreas) : $existingAreas;
	}

	/**
	 * Set a parent theme for this theme
	 *
	 * @param $parent string Key in the plugin registry for the parent theme
	 * @return null
	 */
	public function setParent($parent) {
		$parent = PluginRegistry::getPlugin('themes', $parent);

		if (!is_a($parent, 'ThemePlugin')) {
			return;
		}

		$this->parent = $parent;
		$this->parent->init();
	}

	/**
	 * Register directories to search for template files
	 *
	 * @return null
	 */
	private function _registerTemplates() {

		// Register parent theme template directory
		if (isset($this->parent) && is_a($this->parent, 'ThemePlugin')) {
			$this->parent->_registerTemplates();
		}

		// Register this theme's template directory
		$request = Application::get()->getRequest();
		$templateManager = TemplateManager::getManager($request);
		$templateManager->addTemplateDir($this->_getBaseDir('templates'));
	}

	/**
	 * Register stylesheets and font assets
	 *
	 * Passes styles defined by the theme to the template manager for handling.
	 *
	 * @return null
	 */
	private function _registerStyles() {

		if (isset($this->parent)) {
			$this->parent->_registerStyles();
		}

		$request = Application::get()->getRequest();
		$dispatcher = $request->getDispatcher();
		$templateManager = TemplateManager::getManager($request);

		foreach($this->styles as $name => $data) {

			if (empty($data['style'])) {
				continue;
			}

			// Compile LESS files
			if ($dispatcher && substr($data['style'], (strlen(LESS_FILENAME_SUFFIX) * -1)) == LESS_FILENAME_SUFFIX) {
				$styles = $dispatcher->url(
					$request,
					ROUTE_COMPONENT,
					null,
					'page.PageHandler',
					'css',
					null,
					array(
						'name' => $name,
					)
				);
			} else {
				$styles = $data['style'];
			}

			unset($data['style']);

			$templateManager->addStylesheet($name, $styles, $data);
		}
	}

	/**
	 * Register script assets
	 *
	 * Passes scripts defined by the theme to the template manager for handling.
	 *
	 * @return null
	 */
	public function _registerScripts() {

		if (isset($this->parent)) {
			$this->parent->_registerScripts();
		}

		$request = Application::get()->getRequest();
		$templateManager = TemplateManager::getManager($request);

		foreach($this->scripts as $name => $data) {
			$script = $data['script'];
			unset($data['script']);
			$templateManager->addJavaScript($name, $script, $data);
		}
	}

	/**
	 * Get the base URL to be used for file paths
	 *
	 * A base URL for loading LESS/CSS/JS files in <link> elements. It will
	 * also be set to the @baseUrl variable before LESS files are compiloed so
	 * that images and fonts can be located.
	 *
	 * @param $path string An optional path to append to the base
	 * @return string
	 */
	public function _getBaseUrl($path = '') {
		$request = Application::get()->getRequest();
		$path = empty($path) ? '' : DIRECTORY_SEPARATOR . $path;
		return $request->getBaseUrl() . DIRECTORY_SEPARATOR . $this->getPluginPath() . $path;
	}

	/**
	 * Get the base path to be used for file references
	 *
	 * @param $path string An optional path to append to the base
	 * @return string
	 */
	public function _getBaseDir($path = '') {
		$path = empty($path) ? '' : DIRECTORY_SEPARATOR . $path;
		return Core::getBaseDir() . DIRECTORY_SEPARATOR . $this->getPluginPath() . $path;
	}

	/**
	 * Check if the passed colour is dark
	 *
	 * This is a utility function to determine the darkness of a hex colour. This
	 * is designed to be used in theme colour options, so that text can be
	 * adjusted to ensure it's readable on light or dark backgrounds. You can
	 * specify the brightness threshold by passing in a $limit value. Higher
	 * values are brighter.
	 *
	 * Based on: http://stackoverflow.com/a/8468448/1723499
	 *
	 * @since 0.1
	 */
	public function isColourDark($color, $limit = 130) {
		$color = str_replace('#', '', $color);
		$r = hexdec(substr($color, 0, 2));
		$g = hexdec(substr($color, 2, 2));
		$b = hexdec(substr($color, 4, 2));
		$contrast = sqrt(
			$r * $r * .241 +
			$g * $g * .691 +
			$b * $b * .068
		);
		return $contrast < $limit;
	}
}
