<?php

namespace LimeSurvey\PluginManager;

use LSActiveRecord;

/**
 * Base class for plugins.
 */
abstract class PluginBase implements iPlugin
{
    /**
     * @var LimesurveyApi
     */
    protected $api = null;

    /**
     * @var PluginEvent
     */
    protected $event = null;

    /**
     * @var int
     */
    protected $id = null;

    /**
     * @var string
     */
    protected $storage = 'DummyStorage';

    /**
     * @var string
     */
    protected static $description = 'Base plugin object';

    /**
     * @var string
     */
    protected static $name = 'PluginBase';

    /**
     * @var ?
     */
    private $store = null;

    /**
     * Global settings of plugin
     * @var array[]
     */
    protected $settings = [];

    /**
     * This holds the pluginmanager that instantiated the plugin
     * @var PluginManager
     */
    protected $pluginManager;

    /**
     * config.xml
     * @todo Use ExtensionConfig
     * @var \SimpleXMLElement|null
     */
    public $config = null;

    /**
     * List of allowed public method, null mean all method are allowed.
     * Else method must be in the list.
     * Used in public controller :
     * - PluginHelper::ajax (admin/pluginhelper&sa=ajax)
     * - PluginHelper::fullpagewrapper (admin/pluginhelper&sa=fullpagewrapper)
     * - PluginHelper::sidebody (admin/pluginhelper&sa=sidebody)
     * @var string[]|null
     */
    public $allowedPublicMethods = null;

    /**
     * List of settings that should be encrypted before saving.
     * @var string[]
     */
    protected $encryptedSettings = [];

    /**
     * Constructor for the plugin
     * @todo Add proper type hint in 3.0
     * @param PluginManager $manager    The plugin manager instantiating the object
     * @param int           $id         The id for storage
     */
    public function __construct(PluginManager $manager, $id)
    {
        $this->pluginManager = $manager;
        $this->id = $id;
        $this->api = $manager->getAPI();

        $this->setLocaleComponent();
    }

    /**
     * We need a component for each plugin to load correct
     * locale file.
     *
     * @return void
     */
    protected function setLocaleComponent()
    {
        $dir = $this->getDir();
        if ($dir) {
            $basePath = $dir . DIRECTORY_SEPARATOR . 'locale';
        } else {
            throw new \Exception('Found no dir for locale component');
        }

        // No need to load a component if there is no locale files
        if (!file_exists($basePath)) {
            return;
        }

        // Set plugin specific locale file to locale/<lang>/<lang>.mo, and DB replacement
        \Yii::app()->setComponent(
            get_class($this) . 'Messages',
            [
                'class'            => 'LSMessageSource',
                'cachingDuration'  => 3600,
                'forceTranslation' => true,
                'useMoFile'        => true,
                'basePath'         => $basePath
            ]
        );
    }

    /**
     * This function retrieves plugin data. Do not cache this data; the plugin storage
     * engine will handling caching. After the first call to this function, subsequent
     * calls will only consist of a few function calls and array lookups.
     *
     * @param string $key
     * @param string $model
     * @param int $id
     * @param mixed $default The default value to use when not was set
     * @return boolean
     */
    protected function get($key = null, $model = null, $id = null, $default = null)
    {
        $data = $this->getStore()->get($this, $key, $model, $id, $default);
        // Decrypt the attribute if needed
        // TODO: Handle decryption in storage class, as that would allow each storage to handle
        // it on it's own way. Currently there is no good way of telling the storage which
        // attributes should be encrypted. Adding a method to the storage interface would break
        // backward compatibility. See https://bugs.limesurvey.org/view.php?id=18375#c72133
        if (!empty($data) && in_array($key, $this->encryptedSettings)) {
            try {
                $json = LSActiveRecord::decryptSingle($data);
                $data = !empty($json) ? json_decode((string) $json, true) : $json;
            } catch (\Throwable $e) {
                // If decryption fails, just leave the value untouched (it was probably saved as plain text)
            }
        }
        return $data;
    }

    /**
     * Return the description for this plugin
     */
    public static function getDescription()
    {
        return static::$description;
    }

    /**
     * Get the current event this plugin is responding to
     *
     * @return PluginEvent
     */
    public function getEvent()
    {
        return $this->event;
    }

    /**
     * Returns the id of the plugin
     *
     * Used by storage model to find settings specific to this plugin
     *
     * @return int
     */
    public function getId()
    {
        return $this->id;
    }

    /**
     * Provides meta data on the plugin settings that are available for this plugin.
     * This does not include enable / disable; a disabled plugin is never loaded.
     *
     */
    public function getPluginSettings($getValues = true)
    {
        $settings = $this->settings;
        foreach ($settings as $name => &$setting) {
            if ($getValues) {
                $setting['current'] = $this->get($name, null, null, $setting['default'] ?? null);
            }
            if ($setting['type'] == 'logo') {
                $setting['path'] = $this->publish($setting['path']);
            }
        }
        return $settings;
    }

    public static function getName()
    {
        return static::$name;
    }
    /**
     * Returns the plugin storage and takes care of
     * instantiating it
     *
     * @return iPluginStorage
     */
    public function getStore()
    {
        if (is_null($this->store)) {
            $this->store = $this->pluginManager->getStore($this->storage);
        }

        return $this->store;
    }

    /**
     * Publishes plugin assets.
     * @return string
     */
    public function publish($fileName)
    {
        // Check if filename is relative.
        if (strpos('//', (string) $fileName) === false) {
            // This is a limesurvey relative path.
            if (strpos('/', (string) $fileName) === 0) {
                $url = \Yii::getPathOfAlias('webroot') . $fileName;
            } else {
                // This is a plugin relative path.
                $path = \Yii::getPathOfAlias('webroot.plugins.' . get_class($this)) . DIRECTORY_SEPARATOR . $fileName;
                /*
                 * By using the asset manager the assets are moved to a publicly accessible path.
                 * This approach allows a locked down plugin directory that is not publicly accessible.
                 */
                $url = \Yii::app()->assetManager->publish($path);
            }
        } else {
            $url = $fileName;
        }
        return $url;
    }

    /**
     *
     * @param string $name Name of the setting.
     * The type of the setting is either a basic type or choice.
     * The choice type is either a single or a multiple choice setting.
     * @param array $options
     * Contains parameters for the setting. The 'type' key contains the parameter type.
     * The type is one of: string, int, float, choice.
     * Supported keys per type:
     * String: max-length(int), min-length(int), regex(string).
     * Int: max(int), min(int).
     * Float: max(float), min(float).
     * Choice: choices(array containing values as keys and names as values), multiple(bool)
     * Note that the values for choice will be translated.
     */
    protected function registerSetting($name, $options = array('type' => 'string'))
    {
        $this->settings[$name] = $options;
    }

    /**
     * @param string[]|mixed[] $settings
     * @return void
     */
    public function saveSettings($settings)
    {
        foreach ($settings as $name => $setting) {
            $this->set($name, $setting);
        }
    }


    /**
     * This function stores plugin data.
     *
     * @param string $key
     * @param mixed $data
     * @param string $model
     * @param int $id
     * @return boolean
     */
    protected function set($key, $data, $model = null, $id = null)
    {
        /* Date time settings format */
        if (isset($this->settings[$key]['type']) && $this->settings[$key]['type'] == 'date' && !empty($this->settings[$key]['saveformat'])) {
            $data = LimesurveyApi::getFormattedDateTime($data, $this->settings[$key]['saveformat']);
        }
        // Encrypt the attribute if needed
        // TODO: Handle encryption in storage class, as that would allow each storage to handle
        // it on it's own way. Currently there is no good way of telling the storage which
        // attributes should be encrypted. Adding a method to the storage interface would break
        // backward compatibility. See https://bugs.limesurvey.org/view.php?id=18375#c72133
        if (!empty($data) && in_array($key, $this->encryptedSettings)) {
            // Data is json encoded before encryption because it might be an array or object.
            $data = LSActiveRecord::encryptSingle(json_encode($data));
        }
        return $this->getStore()->set($this, $key, $data, $model, $id);
    }

    /**
     * Set the event to the plugin, this method is executed by the PluginManager
     * just before dispatching the event.
     *
     * @param PluginEvent $event
     * @return PluginBase
     */
    public function setEvent(PluginEvent $event)
    {
        $this->event = $event;
        return $this;
    }

    /**
     * Here you should handle subscribing to the events your plugin will handle
     */
    //abstract public function registerEvents();

    /**
     * This function subscribes the plugin to receive an event.
     *
     * @param string $event
     * @param string $function
     */
    protected function subscribe($event, $function = null)
    {
        return $this->pluginManager->subscribe($this, $event, $function);
    }

    /**
     * This function unsubscribes the plugin from an event.
     * @param string $event
     */
    protected function unsubscribe($event)
    {
        return $this->pluginManager->unsubscribe($this, $event);
    }

    /**
     * To find the plugin locale file, we need late runtime result of __DIR__.
     * Solution copied from http://stackoverflow.com/questions/18100689/php-dir-evaluated-runtime-late-binding
     *
     * @return string|null
     */
    protected function getDir()
    {
        $reflObj = new \ReflectionObject($this);
        $fileName = $reflObj->getFileName();
        if ($fileName) {
            return dirname($fileName);
        } else {
            null;
        }
    }

    /**
     * Look for views in plugin views/ folder and render it
     *
     * @param string $viewfile Filename of view in views/ folder
     * @param array $data
     * @param boolean $return
     * @param boolean $processOutput
     * @return string;
     */
    public function renderPartial($viewfile, $data, $return = false, $processOutput = false)
    {
        $alias = 'plugin_views_folder' . $this->id;
        \Yii::setPathOfAlias($alias, $this->getDir());
        $fullAlias = $alias . '.views.' . $viewfile;

        if (isset($data['plugin'])) {
            throw new \InvalidArgumentException("Key 'plugin' in data variable is for plugin base only. Please use another key name.");
        }

        // Provide this so we can use $plugin->gT() in plugin views
        $data['plugin'] = $this;

        return \Yii::app()->controller->renderPartial($fullAlias, $data, $return, $processOutput);
    }

    /**
     * Translation for plugin
     *
     * @param string $sToTranslate The message that are being translated
     * @param string $sEscapeMode
     * @param string $sLanguage
     * @return string
     */
    public function gT($sToTranslate, $sEscapeMode = 'html', $sLanguage = null)
    {
        $translation = \quoteText(
            \Yii::t(
                '',
                $sToTranslate,
                array(),
                get_class($this) . 'Messages',
                $sLanguage
            ),
            $sEscapeMode
        );

        // If we don't have a translation from the plugin, check core translations
        if ($translation == $sToTranslate) {
            $translationFromCore = \quoteText(
                \Yii::t(
                    '',
                    $sToTranslate,
                    array(),
                    null,
                    $sLanguage
                ),
                $sEscapeMode
            );

            return $translationFromCore;
        }

        return $translation;
    }

    /**
     * Call the Yii::log function to log into tmp/runtime/plugin.log
     * The plugin name is the category.
     *
     * @param string $message
     * @param string $level From CLogger, defaults to CLogger::LEVEL_TRACE
     * @return void
     */
    public function log($message, $level = \CLogger::LEVEL_TRACE)
    {
        $category = $this->getName();
        \Yii::log($message, $level, 'plugin.' . $category);
    }

    /**
     * Read XML config file and store it in $this->config
     * Assumes config file is config.xml and in plugin root folder.
     * @todo Could this be moved to plugin model?
     * @return boolean
     */
    public function readConfigFile()
    {
        $file = $this->getDir() . DIRECTORY_SEPARATOR . 'config.xml';
        if (file_exists($file)) {
            if (\PHP_VERSION_ID < 80000) {
                libxml_disable_entity_loader(false);
            }
            $this->config = simplexml_load_file(realpath($file));
            if (\PHP_VERSION_ID < 80000) {
                libxml_disable_entity_loader(true);
            }

            if ($this->config === null) {
                // Failed. Popup error message.
                $this->showConfigErrorNotification();
                return false;
            } elseif ($this->configIsNewVersion()) {
                // Do everything related to reading config fields
                // TODO: Create a config object for this? One object for each config field? Then loop through those fields.
                if ($this->id !== null) {
                    $pluginModel = \Plugin::model()->findByPk($this->id);
                    // "Impossible"
                    if (empty($pluginModel)) {
                        throw new \Exception('Internal error: Found no database entry for plugin id ' . $this->id);
                    }
                    $this->checkActive($pluginModel);
                    $this->saveNewVersion($pluginModel);
                }
            }
            return true;
        } else {
            $this->log('Found no config file');
            return false;
        }
    }

    /**
     * Check if config field active is 1. If yes, activate the plugin.
     * This is the 'active-by-default' feature.
     * @param \Plugin $pluginModel
     * @return void
     */
    protected function checkActive($pluginModel)
    {
        // TODO: Do we want to support automatically installed plugins?
        return;

        if ($this->config->active == 1) {
            // Activate plugin
            $result = \Yii::app()->getPluginManager()->dispatchEvent(
                new PluginEvent('beforeActivate', \Yii::app()->getController()),
                $this->getName()
            );

            if ($result->get('success') !== false) {
                $pluginModel->active = 1;
                $pluginModel->update();
            } else {
                // Failed. Popup error message.
                $not = new \Notification(
                    [
                        'user_id' => \Yii::app()->user->id,
                        'title'   => gT('Plugin error'),
                        'message' =>
                            '<span class="ri-error-warning-fill"></span>&nbsp;' .
                            gT('Could not activate plugin ' . $this->getName()) . '. ' .
                            gT('Reason:') . ' ' . $result->get('message'),
                        'importance' => \Notification::HIGH_IMPORTANCE
                    ]
                );
                $not->save();
            }
        }
    }

    /**
     * Show an error message about malformed config.json file.
     * @return void
     */
    protected function showConfigErrorNotification()
    {
        $not = new \Notification(
            [
            'user_id' => \Yii::app()->user->id,
            'title'   => gT('Plugin error'),
            'message' =>
                '<span class="ri-error-warning-fill"></span>&nbsp;' .
                gT('Could not read config file for plugin ' . $this->getName()) . '. ' .
                gT('Config file is malformed or null.'),
            'importance' => \Notification::HIGH_IMPORTANCE
            ]
        );
        $not->save();
    }

    /**
     * Returns true if config file has a higher version than database.
     * Assumes $this->config is set.
     * @return boolean
     */
    protected function configIsNewVersion()
    {
        if (empty($this->config)) {
            throw new \InvalidArgumentException('config is not set');
        }

        $pluginModel = \Plugin::model()->findByPk($this->id);

        return empty($pluginModel->version) ||
            version_compare($pluginModel->version, (string) $this->config->metadata->version) === -1;
    }

    /**
     * Saves the new version from config into database
     * @return void
     */
    protected function saveNewVersion()
    {
        \Yii::app()->db->createCommand()->update(
            '{{plugins}}',
            ['version' => (string)$this->config->metadata->version],
            'id=:id',
            [
                ':id' => $this->id
            ]
        );
    }

    /**
     * @param string $relativePathToScript
     * @param string $parentPlugin
     * @return void
     */
    protected function registerScript($relativePathToScript, $parentPlugin = null)
    {
        $parentPlugin = $parentPlugin ?? get_class($this);

        $scriptToRegister = null;
        if (file_exists(\Yii::getPathOfAlias('userdir') . '/plugins/' . $parentPlugin . '/' . $relativePathToScript)) {
            $scriptToRegister = \Yii::app()->getAssetManager()->publish(
                \Yii::getPathOfAlias('userdir') . '/plugins/' . $parentPlugin . '/' . $relativePathToScript
            );
        } elseif (file_exists(\Yii::app()->getBasePath() . '/plugins/' . $parentPlugin . '/' . $relativePathToScript)) {
            $scriptToRegister = \Yii::app()->getAssetManager()->publish(
                \Yii::app()->getBasePath() . '/plugins/' . $parentPlugin . '/' . $relativePathToScript
            );
        } elseif (file_exists(\Yii::app()->getBasePath() . '/application/core/plugins/' . $parentPlugin . '/' . $relativePathToScript)) {
            $scriptToRegister = \Yii::app()->getAssetManager()->publish(
                \Yii::app()->getBasePath() . '/application/core/plugins/' . $parentPlugin . '/' . $relativePathToScript
            );
        }
        \Yii::app()->getClientScript()->registerScriptFile($scriptToRegister);
    }

    /**
     * @param string $relativePathToCss
     * @param string $parentPlugin
     * @return void
     */
    protected function registerCss($relativePathToCss, $parentPlugin = null)
    {
        $parentPlugin = $parentPlugin ?? get_class($this);

        $cssToRegister = null;
        if (file_exists(\Yii::getPathOfAlias('userdir') . '/plugins/' . $parentPlugin . '/' . $relativePathToCss)) {
            $cssToRegister = \Yii::app()->getAssetManager()->publish(
                \Yii::getPathOfAlias('userdir') . '/plugins/' . $parentPlugin . '/' . $relativePathToCss
            );
        } elseif (file_exists(\Yii::getPathOfAlias('webroot') . '/plugins/' . $parentPlugin . '/' . $relativePathToCss)) {
            $cssToRegister = \Yii::app()->getAssetManager()->publish(
                \Yii::getPathOfAlias('webroot') . '/plugins/' . $parentPlugin . '/' . $relativePathToCss
            );
        } elseif (file_exists(\Yii::app()->getBasePath() . '/application/core/plugins/' . $parentPlugin . '/' . $relativePathToCss)) {
            $cssToRegister = \Yii::app()->getAssetManager()->publish(
                \Yii::app()->getBasePath() . '/application/core/plugins/' . $parentPlugin . '/' . $relativePathToCss
            );
        }
        \Yii::app()->getClientScript()->registerCssFile($cssToRegister);
    }

    /**
     * Returns a health status text to show in plugin overview.
     * For example, the plugin might be active but not properly configured.
     * @return string|null
     */
    public function getHealthStatusText()
    {
        return null;
    }
}
