<?php
namespace intec\core\handling;

use intec\core\base\Exception;
use intec\core\base\BaseObject;
use intec\core\helpers\ArrayHelper;
use intec\core\helpers\FileHelper;
use intec\core\helpers\StringHelper;
use intec\core\helpers\Type;

/**
 * Class Handler
 * @property string $directory
 * @property string $namespace
 * @property string $separator
 * @property string $extension
 * @property string $prefix
 * @package intec\core\handling
 */
class Handler extends BaseObject
{
    /**
     * Жёсткий белый список компонентов для components.get (нижний регистр, без пробелов).
     * Расширение только правкой кода модуля, либо наследованным классом.
     *
     * @var string[]
     */
    protected static $allowedComponents = [
        'bitrix:catalog.element',
        'bitrix:main.register',
        'bitrix:system.auth.authorize',
        'bitrix:system.auth.form',
        'intec.universe:catalog.delivery',
        'intec.universe:main.widget',
        'intec.universe:product.timer',
        'intec.universe:reviews',
        'intec.universe:sale.basket.icons',
        'intec.universe:sale.basket.small',
        'intec.universe:sale.order.fast',
        'intec.universe:main.stories',
    ];

    /**
     * @var string
     */
    protected $_directory;
    /**
     * @var string
     */
    protected $_namespace;
    /**
     * @var string
     */
    protected $_separator = '.';
    /**
     * @var string
     */
    protected $_extension = 'php';
    /**
     * @var string
     */
    protected $_prefix = 'Actions';

    /**
     * Handler constructor.
     * @param string $directory
     * @param string $namespace
     */
    public function __construct($directory, $namespace)
    {
        parent::__construct([]);

        $this->_directory = FileHelper::normalizePath($directory);
        $this->_namespace = Type::toString($namespace);
    }

    /**
     * @param string $name
     * @return string
     */
    protected static function normalizeComponentName($name)
    {
        $name = mb_strtolower(trim(Type::toString($name)));

        return str_replace(' ', '', $name);
    }

    /**
     * Данные о компоненте в запросе components.get (для проверки белого списка).
     *
     * @param mixed $data
     * @return array{present:bool,scalar:bool,normalized:?string,raw_display:?string}|null null если $data не массив
     */
    protected static function describeComponentsGetComponent($data)
    {
        if (!Type::isArray($data))
            return null;

        $componentRaw = null;
        if (array_key_exists('component', $data))
            $componentRaw = $data['component'];

        if (($componentRaw === null || $componentRaw === '')
            && isset($data['parameters']) && Type::isArray($data['parameters'])
            && array_key_exists('component', $data['parameters'])
        ) {
            $componentRaw = $data['parameters']['component'];
        }

        if ($componentRaw === null || $componentRaw === '')
            return [
                'present' => false,
                'scalar' => true,
                'normalized' => null,
                'raw_display' => null
            ];

        if (!is_scalar($componentRaw)) {
            return [
                'present' => true,
                'scalar' => false,
                'normalized' => null,
                'raw_display' => gettype($componentRaw)
            ];
        }

        $raw = Type::toString($componentRaw);

        return [
            'present' => true,
            'scalar' => true,
            'normalized' => static::normalizeComponentName($raw),
            'raw_display' => $raw
        ];
    }

    /**
     * Возвращает директорию, из которой происходит подключение.
     * @return string
     */
    public function getDirectory()
    {
        return $this->_directory;
    }

    /**
     * Возвращает пространство имен, из которго происходит подключение.
     * @return string
     */
    public function getNamespace()
    {
        return $this->_namespace;
    }

    /**
     * Устанавливает разделитель для действия.
     * @param string $separator
     * @return $this
     */
    public function setSeparator($separator)
    {
        $this->_separator = $separator;
        return $this;
    }

    /**
     * Возвращает разделитель для действия.
     * @return string
     */
    public function getSeparator()
    {
        return $this->_separator;
    }

    /**
     * Устанавливает расширение подключаемого файла.
     * @param string $extension
     * @return $this
     */
    public function setExtension($extension)
    {
        $this->_extension = $extension;
        return $this;
    }

    /**
     * Возвращает расширение подключаемого файла.
     * @return string
     */
    public function getExtension()
    {
        return $this->_extension;
    }

    /**
     * Устанавливает префикс подключаемого файла.
     * @param string $prefix
     * @return $this
     */
    public function setPrefix($prefix)
    {
        $this->_prefix = $prefix;
        return $this;
    }

    /**
     * Возвращает префикс подключаемого файла.
     * @return string
     */
    public function getPrefix()
    {
        return $this->_prefix;
    }

    /**
     * Возвращает действие.
     * @param string $action
     * @return string
     */
    public function getAction($action)
    {
        $action = Type::toString($action);

        if (empty($action))
            return null;

        $action = explode($this->_separator, $action);
        $actionLength = count($action);

        if ($actionLength > 1) {
            return end($action);
        } else {
            return $action[0];
        }
    }

    /**
     * Возвращает расположение относительно данного обработчика
     * в виде массива с частями пути.
     * @param string $action
     * @return array|null
     */
    public function getPathParts($action)
    {
        $action = Type::toString($action);

        if (empty($action))
            return null;

        $action = explode($this->_separator, $action);
        $actionLength = count($action);

        if ($actionLength > 1) {
            $pathParts = ArrayHelper::slice($action, 0, $actionLength - 1);
            $pathLength = count($pathParts);
            $pathParts[$pathLength - 1] = StringHelper::toUpperCharacter($pathParts[$pathLength - 1]);
        } else {
            $pathParts = [StringHelper::toUpperCharacter($action[0])];
        }

        return $pathParts;
    }

    /**
     * Возвращает класс с действиями по его имени и пути.
     * @param array $pathParts
     * @return Actions|null
     * @throws Exception
     */
    public function getActions($pathParts)
    {
        if (!Type::isArrayable($pathParts))
            return null;

        if (empty($pathParts))
            return null;

        $name = $this->_namespace.'\\'.implode('\\', $pathParts).$this->_prefix;
        $path = $this->_directory.DIRECTORY_SEPARATOR.implode(DIRECTORY_SEPARATOR, $pathParts).
            $this->_prefix.'.'.$this->_extension;

        $getClass = function ($name) {
            try {
                return new \ReflectionClass($name);
            } catch (\Exception $exception) {
                return null;
            }
        };

        $class = $getClass($name);

        if ($class === null)
            include($path);

        /** @var \ReflectionClass $class */
        $class = $getClass($name);

        if ($class === null)
            throw new Exception('Cannot find actions class "'.$name.'"');

        if (!$class->isSubclassOf(Actions::className()))
            throw new Exception('Class "'.$class->getName().'" is not "'.Actions::className().'"');

        /** @var Actions $actions */
        $actions = $class->newInstance();

        return $actions;
    }

    /**
     * Приводит имя виртуальной страницы / AJAX-действия к строке (в т.ч. из page[page]=… в query).
     *
     * @param mixed $action
     * @return string
     */
    protected function normalizePageAction($action)
    {
        if (Type::isArray($action)) {
            if (!isset($action['page']))
                return '';

            $page = $action['page'];

            if (Type::isArray($page))
                return '';

            return trim(Type::toString($page));
        }

        if ($action === null || $action === false)
            return '';

        return trim(Type::toString($action));
    }

    /**
     * Допустимые символы в имени действия (components.get, basket.update и т.п.).
     *
     * @param string $action
     * @return bool
     */
    protected function isValidPageAction($action)
    {
        return $action !== '' && preg_match('/^[a-zA-Z0-9_.]+$/', $action) === 1;
    }

    /**
     * Обрабатывает действие.
     * @param $action
     * @param mixed $data
     * @return mixed|null
     * @throws Exception
     */
    public function handle($action, $data = null)
    {
        $action = $this->normalizePageAction($action);

        if ($action === '')
            return null;

        if (!$this->isValidPageAction($action))
            throw new Exception('Invalid action');

        $data = $this->mergeComponentsGetDataFromRequest($action, $data);

        if ($this->isForbiddenComponentRequest($action, $data))
            throw new Exception('Forbidden component request');

        $pathParts = $this->getPathParts($action);
        $runAction = $this->getAction($action);
        $actions = $this->getActions($pathParts);
        $actions->data = $data;

        return $actions->run($this->getAction($runAction));
    }

    /**
     * Для components.get подмешивает параметры из текущего HTTP-запроса, если во второй
     * аргумент handle() не передали данные (старые шаблоны / ошибочный вызов).
     * Усиливает блокировку опасных компонентов и восстанавливает работу легитимных вызовов.
     *
     * @param string $action
     * @param mixed $data
     * @return array|null
     */
    protected function mergeComponentsGetDataFromRequest($action, $data)
    {
        if ($action !== 'components.get')
            return $data;

        $payload = $this->getComponentsGetRequestPayload();
        if (empty($payload))
            return $data;

        if (!Type::isArray($data))
            return $payload;

        return ArrayHelper::merge($payload, $data);
    }

    /**
     * Копия публичных параметров виртуальной страницы components.get (без служебных ключей шаблона).
     *
     * @return array
     */
    protected function getComponentsGetRequestPayload()
    {
        if (!isset($_REQUEST) || !is_array($_REQUEST))
            return [];

        $bag = $_REQUEST;
        unset($bag['siteId'], $bag['templateId'], $bag['page']);

        return $bag;
    }

    /**
     * Блокирует вызовы компонентов вне жёсткого белого списка ($allowedComponents) для components.get.
     *
     * @param string $action
     * @param mixed $data
     * @return bool
     */
    protected function isForbiddenComponentRequest($action, $data)
    {
        if ($action !== 'components.get')
            return false;

        $info = static::describeComponentsGetComponent($data);
        if ($info === null)
            return false;

        if (!$info['present'])
            return false;

        if (!$info['scalar'])
            return true;

        return !in_array($info['normalized'], static::$allowedComponents, true);
    }
}