<?php

/*
 * This file is part of the Symfony package.
 *
 * (c) Fabien Potencier <fabien@symfony.com>
 *
 * For the full copyright and license information, please view the LICENSE
 * file that was distributed with this source code.
 */

namespace Symfony\Component\VarExporter;

use Symfony\Component\VarExporter\Exception\ClassNotFoundException;
use Symfony\Component\VarExporter\Exception\LogicException;
use Symfony\Component\VarExporter\Exception\NotInstantiableTypeException;

/**
 * Deep-clones PHP values while preserving copy-on-write benefits for strings and arrays.
 *
 * Unlike unserialize(serialize()), this approach does not reallocate strings and scalar-only
 * arrays, allowing PHP's copy-on-write mechanism to share memory for these values.
 *
 * DeepCloner instances are serializable: the serialized form is a pure array; it contains
 * only scalars and nested arrays, no objects. This makes it suitable for encoding with
 * json_encode(), var_export(), or any serializer that handles plain PHP arrays.
 * The format uses a compact representation that deduplicates class and property names,
 * typically producing a payload smaller than serialize($value) itself.
 *
 * The heavy lifting is delegated to deepclone_to_array() / deepclone_from_array(), which
 * come from either the native `deepclone` PHP extension (for a 4-5x speedup) or the
 * `symfony/polyfill-deepclone` package when the extension is not loaded.
 *
 * Security:
 * A DeepCloner instance is itself safe to serialize and unserialize (its serialized form is
 * a pure array of scalars and nested arrays, no objects), so it can be round-tripped via
 * unserialize($data, ['allowed_classes' => [DeepCloner::class]]) without instantiating any
 * other class at unserialize() time. However, the $allowedClasses discipline that applies
 * to PHP's native unserialize() extends to clone(), cloneAs(), deepClone() and to the cloner
 * returned by fromArray(): these are what actually instantiate the inner objects (running
 * __wakeup() / __unserialize() on them). $allowedClasses = null (the default) lets the cloner
 * instantiate any class loaded in the process, including classes with side effects in
 * __wakeup() / __unserialize(), which reproduces the full unserialize-gadget surface.
 * Callers that obtain a DeepCloner (or its array payload) from an untrusted source MUST pass
 * an explicit allow-list to the clone methods; pass [] to forbid all classes. Allow-list
 * violations surface as \ValueError (matching PHP's unserialize() contract), not as
 * {@see Exception\ExceptionInterface}; callers that want to catch every DeepCloner failure
 * must catch both.
 *
 * @template T
 *
 * @author Nicolas Grekas <p@tchwork.com>
 */
final class DeepCloner
{
    private array $payload;

    /**
     * @param T                 $value
     * @param list<string>|null $allowedClasses Classes that may be serialized.
     *                                          null (default) allows all classes. An empty array allows none.
     *
     * @throws NotInstantiableTypeException When $value (or a nested value) cannot be serialized
     * @throws \ValueError                  When a class in the graph is not in $allowedClasses
     */
    public function __construct(mixed $value, ?array $allowedClasses = null)
    {
        try {
            $this->payload = deepclone_to_array($value, $allowedClasses);
        } catch (\DeepClone\NotInstantiableException $e) {
            throw new NotInstantiableTypeException($e);
        }
    }

    /**
     * Deep-clones a PHP value.
     *
     * When the input may come from an untrusted source, pass an explicit allow-list:
     * $allowedClasses = null (default) allows every loaded class to be instantiated,
     * which runs __wakeup() / __unserialize() on them. Pass [] to forbid all classes.
     *
     * @template U
     *
     * @param U                 $value
     * @param list<string>|null $allowedClasses classes that may be serialized/deserialized
     *
     * @return U
     *
     * @throws ClassNotFoundException       When a class in the graph cannot be loaded
     * @throws NotInstantiableTypeException When $value (or a nested value) cannot be serialized/instantiated
     * @throws \ValueError                  When a class in the graph is not in $allowedClasses
     */
    public static function deepClone(mixed $value, ?array $allowedClasses = null): mixed
    {
        return (new self($value, $allowedClasses))->clone($allowedClasses);
    }

    /**
     * Returns true when the value doesn't need cloning (scalars, null, enums, scalar-only arrays).
     */
    public function isStaticValue(): bool
    {
        return \array_key_exists('value', $this->payload);
    }

    /**
     * Creates a deep clone of the value.
     *
     * When this DeepCloner was obtained from an untrusted source (e.g. unserialize() of
     * attacker-controlled data, or fromArray() on an attacker-controlled payload), the
     * $allowedClasses argument is the security boundary: $allowedClasses = null (default)
     * lets any loaded class be instantiated and runs __wakeup() / __unserialize() on it,
     * reproducing the unserialize-gadget surface. Pass an explicit list (or [] for none).
     *
     * @param list<string>|null $allowedClasses Classes that may be instantiated.
     *                                          null (default) allows all classes. An empty array allows none.
     *
     * @return T
     *
     * @throws ClassNotFoundException       When a class in the graph cannot be loaded
     * @throws NotInstantiableTypeException When a class in the graph cannot be instantiated
     * @throws \ValueError                  When a class in the graph is not in $allowedClasses
     */
    public function clone(?array $allowedClasses = null): mixed
    {
        if (\array_key_exists('value', $this->payload)) {
            return $this->payload['value'];
        }

        try {
            return deepclone_from_array($this->payload, $allowedClasses);
        } catch (\DeepClone\ClassNotFoundException $e) {
            throw new ClassNotFoundException($e);
        } catch (\DeepClone\NotInstantiableException $e) {
            throw new NotInstantiableTypeException($e);
        }
    }

    /**
     * Creates a deep clone of the root object using a different class.
     *
     * The target class must be compatible with the original (typically in the same hierarchy).
     *
     * When this DeepCloner was obtained from an untrusted source, the $allowedClasses argument
     * is the security boundary: $allowedClasses = null (default) lets any loaded class be
     * instantiated and runs __wakeup() / __unserialize() on it. Pass an explicit list (or []
     * for none). $class itself is not implicitly allow-listed; include it in $allowedClasses
     * when restricting.
     *
     * @template U of object
     *
     * @param class-string<U>   $class
     * @param list<string>|null $allowedClasses Classes that may be instantiated.
     *                                          null (default) allows all classes. An empty array allows none.
     *
     * @return U
     *
     * @throws LogicException               When the cloned value is not an object
     * @throws ClassNotFoundException       When $class or another class in the graph cannot be loaded
     * @throws NotInstantiableTypeException When $class or another class in the graph cannot be instantiated
     * @throws \ValueError                  When a class in the graph is not in $allowedClasses
     */
    public function cloneAs(string $class, ?array $allowedClasses = null): object
    {
        if (\array_key_exists('value', $this->payload) || !\is_int($this->payload['prepared'] ?? null) || $this->payload['prepared'] < 0) {
            throw new LogicException('DeepCloner::cloneAs() requires the value to be an object.');
        }

        $payload = $this->payload;
        $rootId = $payload['prepared'];

        // Add the new class to the dedup'd list and remember its index
        $classes = $payload['classes'];
        if (!\is_array($classes)) {
            $classes = '' !== $classes ? [$classes] : [];
        }
        $newCidx = \count($classes);
        $classes[] = $class;
        $payload['classes'] = $classes;

        // Expand objectMeta to its array form so we can address the root entry
        $meta = $payload['objectMeta'];
        if (\is_int($meta)) {
            $meta = $meta > 0 ? array_fill(0, $meta, 0) : [];
        }
        $entry = $meta[$rootId] ?? null;
        if (\is_array($entry)) {
            $meta[$rootId] = [$newCidx, $entry[1]];
        } else {
            $meta[$rootId] = $newCidx;
        }
        $payload['objectMeta'] = $meta;

        try {
            return deepclone_from_array($payload, $allowedClasses);
        } catch (\DeepClone\ClassNotFoundException $e) {
            throw new ClassNotFoundException($e);
        } catch (\DeepClone\NotInstantiableException $e) {
            throw new NotInstantiableTypeException($e);
        }
    }

    /**
     * Exports the cloner state as a pure array (no objects, only scalars and arrays).
     *
     * The returned array can be passed to {@see fromArray()} to restore the cloner.
     */
    public function toArray(): array
    {
        return $this->payload;
    }

    /**
     * Restores a DeepCloner from an array previously created by {@see toArray()}.
     *
     * The payload is stored as-is and validated lazily on the first clone() / cloneAs() call.
     * When $data comes from an untrusted source, the returned cloner must be used with an
     * explicit $allowedClasses allow-list on clone() / cloneAs(); otherwise any loaded class
     * referenced in the payload can be instantiated, running its __wakeup() / __unserialize().
     *
     * @return self<mixed>
     */
    public static function fromArray(array $data): self
    {
        $cloner = new self(null);
        $cloner->__unserialize($data);

        return $cloner;
    }

    public function __serialize(): array
    {
        return $this->payload;
    }

    public function __unserialize(array $data): void
    {
        // No upfront validation: deepclone_from_array() does it on first clone()
        // and throws \ValueError on malformed input. This avoids paying for the
        // validation twice on the happy path.
        $this->payload = $data;
    }
}
