<?php
/**
 * Copyright since 2007 PrestaShop SA and Contributors
 * PrestaShop is an International Registered Trademark & Property of PrestaShop SA
 *
 * NOTICE OF LICENSE
 *
 * This source file is subject to the Open Software License (OSL 3.0)
 * that is bundled with this package in the file LICENSE.md.
 * It is also available through the world-wide-web at this URL:
 * https://opensource.org/licenses/OSL-3.0
 * If you did not receive a copy of the license and are unable to
 * obtain it through the world-wide-web, please send an email
 * to license@prestashop.com so we can send you a copy immediately.
 *
 * DISCLAIMER
 *
 * Do not edit or add to this file if you wish to upgrade PrestaShop to newer
 * versions in the future. If you wish to customize PrestaShop for your
 * needs please refer to https://devdocs.prestashop.com/ for more information.
 *
 * @author    PrestaShop SA and Contributors <contact@prestashop.com>
 * @copyright Since 2007 PrestaShop SA and Contributors
 * @license   https://opensource.org/licenses/OSL-3.0 Open Software License (OSL 3.0)
 */

declare(strict_types=1);

namespace PrestaShop\PrestaShop\Core\Form\IdentifiableObject\CommandBuilder;

use InvalidArgumentException;
use PrestaShop\PrestaShop\Core\Util\DateTime\DateTime;
use Symfony\Component\PropertyAccess\Exception\NoSuchIndexException;
use Symfony\Component\PropertyAccess\PropertyAccess;
use Symfony\Component\PropertyAccess\PropertyAccessorInterface;

/**
 * This class is inspired from the PropertyAccessor component from Symfony, but it's centered around preparing CQRS
 * command objects based on a config and the data available in the flattened form data array. The component is based on
 * a configuration which allows it to prefill a command, this reduces the size of code used since all array checking and
 * setters calling are performed by the component itself.
 *
 * For example instead of doing:
 *
 *     $modificationDetected = false;
 *     $command = new CQRSCommand();
 *     if (isset($data['number'])) {
 *         $modificationDetected = true;
 *         $command->setNumber((int) $data['number']);
 *     }
 *     if (!empty($data['author']['name'])) {
 *         $modificationDetected = true;
 *         $command->setAuthorName($data['author']['name']);
 *     }
 *     if (!empty($data['isValid'])) {
 *         $modificationDetected = true;
 *         $command->setRedirectOption(
 *             $data['seo']['redirect_option']['type'],
 *             $data['seo']['redirect_option']['target']['id'] ?? 0
 *         );
 *     }
 *
 *     return $modificationDetected ? [$command] : [];
 *
 * You can do instead:
 *
 *     $config = new CommandBuilderConfig();
 *     $config
 *         ->addField('[number]', 'setNumber', DataField::TYPE_INT)
 *         ->addField('[author][name]', 'setAuthorName', DataField::TYPE_STRING)
 *         ->addCompoundField('setRedirectOption', [
 *             '[seo][redirect_option][type]' => DataField::TYPE_STRING,
 *             '[seo][redirect_option][target][id]' => [
 *                 'type' => DataField::TYPE_INT,
 *                 'default' => 0,
 *             ],
 *         ])
 *     ;
 *     $builder = new CommandBuilder($config);
 *
 *     return $builder->buildCommands($data, new CQRSCommand());
 *
 * The code is cleaner, and you can focus on your data format instead of some trivial checking. When dealing with multi
 * shop commands it becomes even more interesting.
 */
class CommandBuilder
{
    /**
     * @var CommandBuilderConfig
     */
    private $config;

    /**
     * @var PropertyAccessorInterface
     */
    private $propertyAccessor;

    /**
     * @param CommandBuilderConfig $config contains all the configuration of the fields that need to be handled by the
     *                                     builder along with the multishop prefix, if needed
     */
    public function __construct(CommandBuilderConfig $config)
    {
        $this->config = $config;
        $this->propertyAccessor = PropertyAccess::createPropertyAccessorBuilder()
            ->enableExceptionOnInvalidIndex()
            ->enableExceptionOnInvalidPropertyPath()
            ->disableMagicCall()
            ->getPropertyAccessor()
        ;
    }

    /**
     * This method prepares CQRS commands, based on the configuration (set in the constructor) and the provided data
     * (which is likely coming from a form or any other input).
     *
     * It can be used for two use cases:
     *
     * - Fill a single command: this is the most common use case, in this case only the single shop command parameter
     * is required, the builder will search for matching values in the flattened array input data, and fill them using
     * the appropriate setters of the command. The command is fully filled and returned via an array (which will contain
     * only one command naturally), only when a modification has been detected though.
     *
     * - Fill a single command AND an all shops command: in this particular use case you need to specify two input
     * commands as parameters. The builder performs the same research of command fields in the input array. However, in
     * this case it will check if the data was set to be modified for ALL the shops (not just a single one). To do this
     * it checks the data for a boolean value which key matches the command field:
     *
     *      ex:
     *          The price is modified for all shops
     *          ['price' => 15.45, 'modify_all_price' => true]
     *
     *          The price is modified for all shops, but the tax for a single shop only
     *          ['price' => 15.45, 'tax_rate' => 0.2, 'modify_all_price' => true]
     *
     * This indicates the target of this modification (the prefix can be configured). Depending on the target, the
     * accessor will choose to fill either the single shop command OR the all shops command. Finally, it returns an
     * array of either one, two or zero commands depending on the modifications that have been detected or not (the
     * single shop command is always returned before the all shops one though).
     *
     * @param array $data
     * @param object $singleShopCommand
     * @param object|null $allShopsCommand
     *
     * @return array Returns prepared commands (if no updated field was detected an empty array is returned)
     */
    public function buildCommands(
        array $data,
        object $singleShopCommand,
        object $allShopsCommand = null
    ): array {
        $updatedCommands = [];

        foreach ($this->config->getFields() as $commandField) {
            $command = $this->getAppropriateCommand(
                $commandField,
                $data,
                $singleShopCommand,
                $allShopsCommand
            );
            if ($this->updateCommand($command, $commandField, $data)) {
                $updatedCommands[] = $command;
            }
        }
        // Make sure the order of returned commands is always consistent (single shop comes first)
        $commands = [];
        if (in_array($singleShopCommand, $updatedCommands, true)) {
            $commands[] = $singleShopCommand;
        }
        if (in_array($allShopsCommand, $updatedCommands, true)) {
            $commands[] = $allShopsCommand;
        }

        return $commands;
    }

    /**
     * Updates the provided command with data selected by field
     *
     * @param object $command
     * @param CommandField $commandField
     * @param array $data
     *
     * @return bool Returns true if command has been updated, or false otherwise
     */
    private function updateCommand(object $command, CommandField $commandField, array $data): bool
    {
        try {
            $setterArguments = $this->fetchDataValues($commandField, $data);
        } catch (NoSuchIndexException $exception) {
            // Data has no value for this field, this is acceptable since partial data can be submitted
            return false;
        }
        $setterMethod = $commandField->getCommandSetter();

        if (!method_exists($command, $setterMethod)) {
            throw new InvalidArgumentException(
                sprintf('Setter method "%s" not found in command "%s"', $setterMethod, get_class($command))
            );
        }
        $command->$setterMethod(...$setterArguments);

        return true;
    }

    /**
     * Check if the data has a mapping checkbox to modify all shops for the tested field, if so use the allShopsCommand
     *
     * @param CommandField $commandField
     * @param array $data
     * @param object $singleShopCommand
     * @param object|null $allShopsCommand
     *
     * @return object
     */
    private function getAppropriateCommand(
        CommandField $commandField,
        array $data,
        object $singleShopCommand,
        ?object $allShopsCommand
    ): object {
        if (null === $allShopsCommand || !$commandField->isMultiShopField()) {
            return $singleShopCommand;
        }
        // Search multi-shop checkbox in data fields
        foreach ($commandField->getDataFields() as $dataField) {
            $propertyPath = $dataField->getPropertyPath();
            $lastElement = $propertyPath->getElement($propertyPath->getLength() - 1);
            $modifyAllElement = $this->config->getModifyAllNamePrefix() . $lastElement;
            $stringPath = (string) $propertyPath;
            // Replace last element of property path to guess path of multi-shop checkbox
            $stringPath = substr_replace(
                $stringPath,
                $modifyAllElement,
                strrpos($stringPath, $lastElement),
                strlen($lastElement)
            );
            // Return multi-shop command if any of the fields is enabled by checkbox
            try {
                if ($this->propertyAccessor->getValue($data, $stringPath)) {
                    return $allShopsCommand;
                }
            } catch (NoSuchIndexException $exception) {
                // No checkbox value found in data
            }
        }

        return $singleShopCommand;
    }

    /**
     * Extracts field values from data
     *
     * @param array $data
     * @param CommandField $commandField
     *
     * @return array<int, mixed>
     *
     * @throws DataFieldException
     * @throws NoSuchIndexException
     */
    private function fetchDataValues(CommandField $commandField, array $data): array
    {
        $dataValues = [];

        foreach ($commandField->getDataFields() as $dataField) {
            try {
                $value = $this->castValue(
                    $this->propertyAccessor->getValue($data, $dataField->getPropertyPath()),
                    $dataField->getType()
                );
            } catch (NoSuchIndexException $exception) {
                if ($dataField->hasDefaultValue()) {
                    $value = $dataField->getDefaultValue();
                } else {
                    throw $exception;
                }
            }
            $dataValues[] = $value;
        }

        return $dataValues;
    }

    /**
     * Casts the provided value
     *
     * @param mixed $value
     * @param string $type
     *
     * @return mixed
     */
    private function castValue($value, string $type)
    {
        switch ($type) {
            case DataField::TYPE_STRING:
                return (string) $value;
            case DataField::TYPE_BOOL:
                return (bool) $value;
            case DataField::TYPE_INT:
                return (int) $value;
            case DataField::TYPE_ARRAY:
                return (array) $value;
            case DataField::TYPE_DATETIME:
                return DateTime::buildNullableDateTime($value);
            default:
                return $value;
        }
    }
}
