<?php
/**
 * For the full copyright and license information, please view the
 * docs/licenses/LICENSE.txt file that was distributed with this source code.
 */

namespace PrestaShop\PrestaShop\Adapter\Presenter;

use ArrayAccess;
use ArrayIterator;
use ArrayObject;
use Closure;
use Context;
use Countable;
use Iterator;
use JsonSerializable;
use ObjectModelCore;
use PrestaShop\PrestaShop\Adapter\ContainerFinder;
use PrestaShop\PrestaShop\Core\Exception\ContainerNotFoundException;
use PrestaShop\PrestaShop\Core\ExtraProperty\Value\ExtraPropertiesBag;
use PrestaShop\PrestaShop\Core\Util\Inflector;
use ReflectionClass;
use ReflectionException;
use ReflectionMethod;
use ReturnTypeWillChange;
use RuntimeException;

/**
 * This class is useful to provide the same behaviour than an array, but which load the result of each key on demand
 * (LazyLoading).
 *
 * Example:
 *
 * If your want to define the ['addresses'] array access in your lazyArray object, just define the public method
 * getAddresses() and add the annotation arrayAccess to it. e.g:
 *
 *     @arrayAccess
 *
 * @return array
 *
 *     public function getAddresses()
 *
 * The method name should always be the index name converted to camelCase and prefixed with get. e.g:
 *
 * ['add_to_cart'] => getAddToCart()
 *
 * You can also add an array with already defined key to the lazyArray be calling the appendArray function.
 * e.g.: you have a $product array containing $product['id'] = 10; $product['url'] = 'foo';
 *       If you call ->appendArray($product) on the lazyArray, it will define the key ['id'] and ['url'] as well
 *       for the lazyArray.
 * Note if the key already exists as a method, it will be skip. In our example, if getUrl() is defined with the
 * annotation @arrayAccess, the $product['url'] = 'foo'; will be ignored
 */
abstract class AbstractLazyArray implements Iterator, ArrayAccess, Countable, JsonSerializable
{
    /**
     * When set by a concrete LazyArray, exposes front-office extra fields under the `extra_properties` key.
     *
     * @var ExtraPropertiesBag|null
     */
    protected $extraPropertiesBag = null;

    /**
     * @var ArrayObject
     */
    private $arrayAccessList;

    /**
     * @var ArrayIterator
     */
    private $arrayAccessIterator;

    /**
     * @var array
     */
    private $methodCacheResults = [];

    /**
     * AbstractLazyArray constructor.
     *
     * @throws ReflectionException
     */
    public function __construct()
    {
        $this->arrayAccessList = new ArrayObject();
        $reflectionClass = new ReflectionClass(static::class);
        $methods = $reflectionClass->getMethods(ReflectionMethod::IS_PUBLIC);
        foreach ($methods as $method) {
            $attributeInstance = $this->getAttributeInstanceFromMethod($method);
            if ($this->isArrayAccessMethod($attributeInstance, $method)) {
                $this->arrayAccessList->offsetSet(
                    $this->getIndexNameFromMethod($attributeInstance, $method),
                    [
                        'type' => 'method',
                        'value' => $method->getName(),
                        'isRewritable' => $this->isResultRewritable($reflectionClass, $attributeInstance),
                    ]
                );
            }
        }
        // The extra_properties index is opt-in: it is only exposed when the concrete LazyArray
        // initialized the bag (presenters call initExtraPropertiesBag() before this constructor),
        // so lazy arrays without extra properties never leak a bag in loops/serialization.
        if (null !== $this->extraPropertiesBag) {
            $this->registerExtraPropertiesIndex();
        }
        $this->arrayAccessIterator = $this->arrayAccessList->getIterator();
    }

    /**
     * Initializes the extra properties bag for the wrapped entity.
     *
     * Concrete LazyArrays call this in their constructor. Legacy resolution
     * (Context / ContainerFinder) is done here, in the Adapter layer, and the
     * resolved container is passed to the Core factory.
     *
     * displayFront filtering is derived from the running context like in ObjectModel:
     * filtered on front-office requests, unfiltered elsewhere (BO, CLI, API).
     *
     * @param class-string<ObjectModelCore> $objectModelClass
     * @param int $entityId Entity row id (<= 0 results in an empty bag)
     * @param int|null $langId Language id carried by the entity; null falls back to context language
     */
    protected function initExtraPropertiesBag(string $objectModelClass, int $entityId, ?int $langId = null): void
    {
        $context = Context::getContext();
        try {
            $container = (new ContainerFinder($context))->getContainer();
        } catch (ContainerNotFoundException) {
            return; // bag stays null → empty extra properties
        }
        $this->extraPropertiesBag = ExtraPropertiesBag::createForEntity(
            $container,
            $objectModelClass,
            $entityId,
            $langId ?? (int) $context->language->id,
            $context->getShopConstraint(),
            forFrontOffice: Context::isFrontOfficeContext(),
        );

        // Covers the init-after-construct ordering; presenters init before parent::__construct(),
        // in which case the constructor performs the registration.
        if (null !== $this->arrayAccessList) {
            $this->registerExtraPropertiesIndex();
        }
    }

    /**
     * Exposes the `extra_properties` index on this lazy array.
     *
     * Not registered through LazyArrayAttribute on purpose: the index must only exist on
     * lazy arrays that initialized the bag, so generic templates iterating all entries
     * (e.g. order subtotals) never meet a non-printable ExtraPropertiesBag value.
     */
    private function registerExtraPropertiesIndex(): void
    {
        $this->arrayAccessList->offsetSet('extra_properties', [
            'type' => 'method',
            'value' => 'getExtraProperties',
            'isRewritable' => false,
        ]);
    }

    /**
     * Returns the extra properties bag for front-office templates (`extra_properties` in Smarty
     * and array access alike: $product['extra_properties']['module']['field']).
     *
     * Same API as ObjectModel::$extra_properties — both bag levels implement
     * ArrayAccess and JsonSerializable, so Smarty chained access and json_encode work unchanged.
     *
     * The `extra_properties` array index only exists on lazy arrays that called
     * initExtraPropertiesBag() (see registerExtraPropertiesIndex()); this method stays callable
     * directly on any instance and falls back to an empty bag.
     */
    public function getExtraProperties(): ExtraPropertiesBag
    {
        return $this->extraPropertiesBag ??= new ExtraPropertiesBag(static fn (): array => []);
    }

    /**
     * Make the lazyArray serializable like an array.
     *
     * @return array
     *
     * @throws RuntimeException
     */
    #[ReturnTypeWillChange]
    public function jsonSerialize()
    {
        $arrayResult = [];
        foreach ($this->arrayAccessList as $key => $value) {
            $arrayResult[$key] = $this->offsetGet($key);
        }

        return $arrayResult;
    }

    /**
     * Set array key and values from $array into the LazyArray.
     *
     * @param array $array
     */
    public function appendArray($array)
    {
        foreach ($array as $key => $value) {
            // do not override any existing method
            if (!$this->arrayAccessList->offsetExists($key)) {
                $this->arrayAccessList->offsetSet(
                    $key,
                    [
                        'type' => 'variable',
                        'value' => $value,
                    ]
                );
            }
        }
    }

    /**
     * @param mixed $key
     * @param Closure $closure
     */
    public function appendClosure($key, Closure $closure)
    {
        $this->arrayAccessList->offsetSet(
            $key,
            [
                'type' => 'closure',
                'value' => $closure,
            ]
        );
    }

    /**
     * The number of keys defined into the lazyArray.
     *
     * @return int
     */
    public function count(): int
    {
        return $this->arrayAccessList->count();
    }

    /**
     * The properties are provided as an array. But callers checking the type of this class (is_object === true)
     * think they must use the object syntax.
     *
     * Check if the index exists inside the lazyArray.
     *
     * @param string $index
     *
     * @return bool
     */
    public function __isset($index)
    {
        return $this->offsetExists($index);
    }

    /**
     * The properties are provided as an array. But callers checking the type of this class (is_object === true)
     * think they must use the object syntax.
     *
     * Get the value associated with the $index from the lazyArray.
     *
     * @param mixed $index
     *
     * @return mixed
     *
     * @throws RuntimeException
     */
    public function __get($index)
    {
        return $this->offsetGet($index);
    }

    /**
     * The properties are provided as an array. But callers checking the type of this class (is_object === true)
     * think they must use the object syntax.
     *
     * @param mixed $name
     * @param mixed $value
     *
     * @throws RuntimeException
     */
    public function __set($name, $value)
    {
        $this->offsetSet($name, $value);
    }

    /**
     * The properties are provided as an array. But callers checking the type of this class (is_object === true)
     * think they must use the object syntax.
     *
     * @param mixed $name
     *
     * @throws RuntimeException
     */
    public function __unset($name)
    {
        $this->offsetUnset($name);
    }

    /**
     * Needed to ensure that any changes to this object won't bleed to other instances
     */
    public function __clone()
    {
        $this->arrayAccessList = clone $this->arrayAccessList;
        $this->arrayAccessIterator = clone $this->arrayAccessIterator;
    }

    /**
     * Get the value associated with the $index from the lazyArray.
     *
     * @param mixed $index
     *
     * @return mixed
     *
     * @throws RuntimeException
     */
    #[ReturnTypeWillChange]
    public function offsetGet($index)
    {
        if (isset($this->arrayAccessList[$index])) {
            $type = $this->arrayAccessList[$index]['type'];
            switch ($type) {
                case 'method':
                    $isResultAvailableInCache = (isset($this->methodCacheResults[$index]));

                    if (!$isResultAvailableInCache) {
                        $methodName = $this->arrayAccessList[$index]['value'];
                        $this->methodCacheResults[$index] = $this->{$methodName}();
                    }
                    $result = $this->methodCacheResults[$index];

                    break;

                case 'closure':
                    $isResultAvailableInCache = (isset($this->methodCacheResults[$index]));

                    if (!$isResultAvailableInCache) {
                        $methodName = $this->arrayAccessList[$index]['value'];
                        $this->methodCacheResults[$index] = $methodName();
                    }
                    $result = $this->methodCacheResults[$index];

                    break;

                default:
                    $result = $this->arrayAccessList[$index]['value'];
                    break;
            }

            return $result;
        }

        return [];
    }

    public function clearMethodCacheResults()
    {
        $this->methodCacheResults = [];
    }

    /**
     * Check if the index exists inside the lazyArray.
     *
     * @param mixed $index
     *
     * @return bool
     */
    public function offsetExists($index): bool
    {
        return isset($this->arrayAccessList[$index]);
    }

    /**
     * Copy the lazyArray.
     *
     * @return AbstractLazyArray
     */
    public function getArrayCopy()
    {
        return clone $this;
    }

    /**
     * Get the result associated with the current index.
     *
     * @return mixed
     *
     * @throws RuntimeException
     */
    #[ReturnTypeWillChange]
    public function current()
    {
        $key = $this->arrayAccessIterator->key();

        return $this->offsetGet($key);
    }

    /**
     * Go to the next result inside the lazyArray.
     */
    public function next(): void
    {
        $this->arrayAccessIterator->next();
    }

    /**
     * Get the key associated with the current index.
     *
     * @return mixed|string
     */
    #[ReturnTypeWillChange]
    public function key()
    {
        return $this->arrayAccessIterator->key();
    }

    /**
     * Check if we are at the end of the lazyArray.
     *
     * @return bool
     */
    public function valid(): bool
    {
        return $this->arrayAccessIterator->valid();
    }

    /**
     * Go back to the first element of the lazyArray.
     */
    public function rewind(): void
    {
        $this->arrayAccessIterator->rewind();
    }

    /**
     * Set the keys not present in the given $array to null.
     *
     * @param array $array
     *
     * @throws RuntimeException
     */
    public function intersectKey($array)
    {
        $arrayCopy = $this->arrayAccessList->getArrayCopy();
        foreach ($arrayCopy as $key => $value) {
            if (!array_key_exists($key, $array)) {
                $this->offsetUnset($key, true);
            }
        }
    }

    /**
     * @param mixed $offset
     * @param mixed $value
     * @param bool $force if set, allow override of an existing method
     *
     * @throws RuntimeException
     */
    public function offsetSet($offset, $value, $force = false): void
    {
        // verify if the offset exists and is not rewritable, unless forced
        if ($this->arrayAccessList->offsetExists($offset)) {
            $offsetData = $this->arrayAccessList->offsetGet($offset);

            if (!$force && $offsetData['type'] !== 'variable' && !$offsetData['isRewritable']) {
                $errorMessage = sprintf(
                    'Trying to set the index %s of the LazyArray %s already defined by a method is not allowed.',
                    print_r($offset, true),
                    static::class
                );
                throw new RuntimeException($errorMessage);
            }
        }

        $this->arrayAccessList->offsetSet($offset, [
            'type' => 'variable',
            'value' => $value,
            'isRewritable' => $offsetData['isRewritable'] ?? false,
        ]);
    }

    /**
     * @param mixed $offset
     * @param bool $force if set, allow unset of an existing method
     *
     * @throws RuntimeException
     */
    public function offsetUnset($offset, $force = false): void
    {
        $result = $this->arrayAccessList->offsetGet($offset);
        if ($force || $result['type'] === 'variable') {
            $this->arrayAccessList->offsetUnset($offset);
        } else {
            throw new RuntimeException('Trying to unset the index ' . print_r($offset, true) . ' of the LazyArray ' . static::class . ' already defined by a method is not allowed');
        }
    }

    /**
     * @param string $methodName
     *
     * @return string
     */
    private function convertMethodNameToIndex($methodName)
    {
        // remove "get" prefix from the function name
        $strippedMethodName = substr($methodName, 3);

        return Inflector::getInflector()->tableize($strippedMethodName);
    }

    private function isResultRewritable(ReflectionClass $reflexionClass, ?LazyArrayAttribute $methodAttributeInstance): bool
    {
        if (!is_null($methodAttributeInstance) && !is_null($methodAttributeInstance->isRewritable)) {
            return $methodAttributeInstance->isRewritable;
        }

        // no attribute found at method level, let's check at class level
        $classAttributeInstance = null;
        $classAttributes = $reflexionClass->getAttributes();

        if (!empty($classAttributes)) {
            $classAttributeInstance = $classAttributes[0]->newInstance();
            if (isset($classAttributeInstance->isRewritable)) {
                return $classAttributeInstance->isRewritable;
            }
        }

        return false;
    }

    private function getAttributeInstanceFromMethod(ReflectionMethod $method): ?LazyArrayAttribute
    {
        $attributeInstance = null;
        $methodAttributes = $method->getAttributes(LazyArrayAttribute::class);

        if (!empty($methodAttributes)) {
            $attributeInstance = $methodAttributes[0]->newInstance();
        }

        return $attributeInstance;
    }

    private function getIndexNameFromMethod(?LazyArrayAttribute $attributeInstance, ReflectionMethod $method): string
    {
        if (!is_null($attributeInstance) && !empty($attributeInstance->indexName)) {
            return $attributeInstance->indexName;
        }

        return $this->convertMethodNameToIndex($method->getName());
    }

    private function isArrayAccessMethod($attributeInstance, $method): bool
    {
        if (!is_null($attributeInstance)) {
            return $attributeInstance->arrayAccess;
        }

        @trigger_error(
            'Configuring a method as arrayAccess through annotations is deprecated since version 9.0.0, use php attributes instead, using the LazyArrayAttribute class.',
            E_USER_DEPRECATED
        );

        $methodDoc = $method->getDocComment();

        return str_contains($methodDoc, '@arrayAccess');
    }
}
