<?php

namespace goodboyalex\php_components_pack\classes;

use DateTimeImmutable;
use DateTimeInterface;
use Exception;
use ReflectionClass;
use ReflectionException;
use stdClass;
use UnitEnum;

/**
 * Класс сопоставлений классов и объектов.
 *
 * @author Александр Бабаев
 * @package php_components_pack
 * @version 1.0
 * @since 1.0
 */
final class ClassMapper
{
    /**
     * @var array $DefaultOptions Настройки
     */
    public const array DefaultOptions = [
        'ignored' => [],
        'allowed' => []
    ];

    /**
     * Передаёт одинаковые параметры из класса $from в класс $to, учитывая игнорируемые ($ignoredProperties) и
     * разрешенные ($allowedProperties) свойства.
     *
     * @param object $from Класс-донор
     * @param object $to Класс-приемник
     * @param array $options Параметры привязки свойств (см атрибут Bind).
     *
     * @return void
     */
    public static function MapClass (object $from, object $to, array $options = self::DefaultOptions): void
    {
        // Если есть игнорируемые или разрешенные свойства
        if (!(count($options['ignored']) == 0 && count($options['allowed']) == 0))
            // -- то для каждого игнорируемого свойства
            foreach ($options['ignored'] as $ignoredProperty)
                // --- и если оно есть в массиве разрешенных
                if (in_array($ignoredProperty, $options['allowed']))
                    // ---- то исключаю его из массива разрешенных
                    unset($options['allowed'][array_search($ignoredProperty, $options['allowed'])]);

        // Получаю массив свойств
        $properties = get_class_vars(get_class($from));

        // Для каждого элемента массива
        foreach ($properties as $name => $value) {
            // - если свойство игнорируется
            if (in_array($name, $options['ignored']))
                // -- пропускаю
                continue;

            // - если свойство не разрешено
            if (count($options['allowed']) > 0 && !in_array($name, $options['allowed']))
                // -- пропускаю
                continue;

            // - если свойство есть в объекте
            if (property_exists($to, $name))
                // -- то присваиваю значение
                $to->$name = $from->$name;
        }
    }

    /**
     * Подготавливает значения свойств класса.
     *
     * @param array $params Данные запроса.
     * @param ReflectionClass $classReflector Анализатор класса.
     * @param array $options Массив свойств привязки.
     *
     * @return array Массив данных класса.
     */
    public static function PrepareClassProperties (array $params, ReflectionClass $classReflector,
        array $options = self::DefaultOptions): array
    {
        // Создаю массив данных класса
        $classData = [];

        // Для каждого свойства класса
        foreach ($classReflector->getProperties() as $property) {
            // - получаю имя свойства
            $propertyName = $property->getName();

            // - если это свойство задано в массиве параметров
            if (array_key_exists($propertyName, $params)) {
                // -- если задан массив разрешённых свойств
                if (!empty($options["allowed"]))
                    // --- если свойство не разрешено
                    if (!in_array($propertyName, $options["allowed"]))
                        // ---- то пропускаю
                        continue;

                // -- если задан массив запрещённых свойств
                if (!empty($options["ignored"]))
                    // --- если свойство должно игнорироваться
                    if (in_array($propertyName, $options["ignored"]))
                        // ---- то пропускаю
                        continue;

                // -- добавляю значение свойства в результат
                $classData[$propertyName] = $params[$propertyName];
            }
            else {
                // - в противном случае, пробегаю массив параметров
                foreach ($params as $key => $value) {
                    // -- если в имени параметра есть разделитель "_"
                    if (str_starts_with($key, $propertyName . "_")) {
                        // -- разбиваю имя параметра на части
                        $keyParts = explode("_", $key);

                        // -- добавляю значение свойства в результат
                        self::GetClassParametersArrayParser($classData, $keyParts, $value);
                    }
                }
            }
        }

        // Возвращаю массив данных класса
        return $classData;
    }

    /**
     * Парсит массив свойств класса.
     *
     * @param array $source Исходный массив (он же и результат парсинга).
     * @param array $parametersKeys Массив имен свойств. Например, Page_Meta_Id должен быть разбит на
     * ["Page", "Meta", "Id"].
     * @param mixed $value Значение свойства.
     * @param array $options Массив параметров привязки свойств.
     *
     * @return void
     */
    public static function GetClassParametersArrayParser (array &$source, array $parametersKeys, mixed $value,
        array $options = self::DefaultOptions): void
    {
        // Если массив имен свойств пустой
        if (empty($parametersKeys))
            // - то прерываю парсинг
            return;

        // Получаю имя текущего свойства
        $currentName = array_shift($parametersKeys);

        // Если текущего свойства нет в массиве
        if (!isset($source[$currentName]))
            // - то создаю его
            $source[$currentName] = [];

        // Если массив имен свойств содержит только одно свойство
        if (count($parametersKeys) === 0) {
            // - если задан массив разрешённых свойств
            if (!empty($options["allowed"]))
                // --- если свойство не разрешено
                if (!in_array($currentName, $options["allowed"]))
                    // ---- то пропускаю
                    return;

            // -- если задан массив запрещённых свойств
            if (!empty($options["ignored"]))
                // --- если свойство должно игнорироваться
                if (in_array($currentName, $options["ignored"]))
                    // ---- то пропускаю
                    return;

            // - добавляю значение свойства в результат
            $source[$currentName] = $value;
        }
        else
            // - иначе продолжаю парсинг
            self::GetClassParametersArrayParser($source[$currentName], $parametersKeys, $value, $options);
    }

    /**
     * Переводит данные из массива в объект класса.
     *
     * @param string $className Имя класса
     * @param array $properties Массив данных
     *
     * @return mixed Объект класса
     * @throws Exception
     */
    public static function MapToClassProperty (string $className, array $properties): mixed
    {
        // Создаю
        try {
            $classReflector = new ReflectionClass($className);

            // Создаю объект класса
            $classObj = new $className();

            // Для каждого свойства класса
            foreach ($properties as $key => $value) {
                // - проверяю наличие свойства
                if (!$classReflector->hasProperty($key))
                    // -- иду к следующему
                    continue;

                // - получаю данные про свойство
                // -- само свойство
                $property = $classReflector->getProperty($key);
                // -- тип свойства
                $propertyType = $property->getType();

                // - если значение является типом bool
                if ($propertyType->getName() === 'bool') {
                    // -- присваиваю дату
                    self::SetParameterToClass($classReflector, $key, $classObj, $value == "1");

                    // -- следующий элемент
                    continue;
                }

                // - если значение является классом
                if (!$propertyType->isBuiltin() && is_array($value)) {
                    // -- присваиваю объект
                    self::SetParameterToClass($classReflector, $key, $classObj,
                        self::MapToClassProperty($propertyType->getName(), $value));

                    // -- следующий элемент
                    continue;
                }

                // - если значение является датой
                if ($classObj->$key instanceof DateTimeInterface) {
                    // -- получаю дату
                    $dateValue = DateTimeImmutable::createFromFormat('Y-m-d H:i:s', $value . " 00:00:00");

                    // -- если не получилось
                    if ($dateValue === false)
                        // --- то добавляю дату по умолчанию (1970-01-01 00:00:00)
                        $dateValue = DateTimeImmutable::createFromFormat('Y-m-d H:i:s', "1970-01-01 00:00:00");

                    // -- присваиваю дату
                    self::SetParameterToClass($classReflector, $key, $classObj, $dateValue);

                    // -- следующий элемент
                    continue;
                }

                // - если значение является перечислением
                if ($classObj->$key instanceof UnitEnum) {
                    // -- если значение уже является перечислением
                    if ($value instanceof UnitEnum)
                        // --- присваиваю перечисление
                        self::SetParameterToClass($classReflector, $key, $classObj, $value);
                    else
                        // -- иначе
                        self::SetParameterToClass($classReflector, $key, $classObj,
                            is_numeric($value) ? $classObj->$key::FromInt($value) : $classObj->$key::FromName($value));

                    // -- следующий элемент
                    continue;
                }

                // - если значение является NULL
                if ($value == "null") {
                    // -- присваиваю NULL
                    self::SetParameterToClass($classReflector, $key, $classObj,
                        is_array($key) ? [] : null);

                    // -- следующий элемент
                    continue;
                }

                // - присваиваю значение
                self::SetParameterToClass($classReflector, $key, $classObj, $value);
            }

            // Возвращаю объект класса
            return $classObj;
        }
        catch (Exception $exception) {
            throw new Exception($exception->getMessage());
        }
    }

    /**
     * Присваивает значение параметра объекту класса.
     *
     * @param ReflectionClass $classReflector Рефлектор класса.
     * @param string $propertyName Имя свойства.
     * @param mixed $classObj Объект класса.
     * @param mixed $value Значение.
     *
     * @throws Exception
     */
    public static function SetParameterToClass (ReflectionClass $classReflector, string $propertyName,
        mixed $classObj, mixed $value): void
    {
        try {
            // Получаю свойство
            $property = $classReflector->getProperty($propertyName);

            /**
             * Устанавливаю доступ значения свойства.
             *
             * @noinspection PhpExpressionResultUnusedInspection
             */
            $property->setAccessible(true);

            // Если значение null
            if (!is_bool($value) && ($value == null || $value == "null"))
                // - то присваиваю значение по умолчанию
                $value = self::GetDefaults($property->getType()->getName());

            // Присваиваю значение
            $property->setValue($classObj, $value);
        }
        catch (ReflectionException $exception) {
            // Выбрасываю исключение
            throw new Exception($exception->getMessage());
        }
    }

    /**
     * Возвращает значение по умолчанию для типа $typeName.
     *
     * @param string $typeName Тип
     *
     * @return mixed Значение по умолчанию
     */
    public static function GetDefaults (string $typeName): mixed
    {
        return match (strtolower($typeName)) {
            'int', 'integer' => 0,
            'float', 'double' => 0.0,
            'bool', 'boolean' => false,
            'string' => '',
            'array' => [],
            'object' => new stdClass(),
            default => null,
        };
    }
}