<?php

namespace WordpressModels\Serialization;

use Symfony\Component\PropertyAccess\PropertyAccessorInterface;
use Symfony\Component\PropertyInfo\PropertyTypeExtractorInterface;
use Symfony\Component\Serializer\Mapping\ClassDiscriminatorResolverInterface;
use Symfony\Component\Serializer\Mapping\Factory\ClassMetadataFactoryInterface;
use Symfony\Component\Serializer\NameConverter\NameConverterInterface;
use Symfony\Component\Serializer\Normalizer\ObjectNormalizer;
use function get_class;

/**
 * Adds a MAX_TREE_DEPTH limitation to symfony's objectNormalizer.
 *
 * The usual objectNormalizer has a MAX_DEPTH limitation that can be used but it's
 * counting depth of objects of the same class (not global depth of the resulting tree).
 * See 4.3 implementation here:
 * https://github.com/symfony/symfony/blob/4.3/src/Symfony/Component/Serializer/Normalizer/AbstractObjectNormalizer.php#L527
 */
class TreeDepthObjectNormalizer extends ObjectNormalizer
{
    /**
     * How deep the resulting normalized tree can be.
     * The default value is 1.
     */
    public const TREE_DEPTH_LIMIT = 'tree_depth_limit';

    /**
     * Set to true to respect the max tree depth.
     */
    public const ENABLE_MAX_TREE_DEPTH = 'enable_max_tree_depth';

    /**
     * Handler to call when max tree depth is reached.
     *
     * If you specify no handler, a MaxTreeDepthException is thrown.
     *
     * The method will be called with ($object, $format, $context) and its
     * return value is returned as the result of the normalize call.
     */
    public const MAX_TREE_DEPTH_HANDLER = 'max_tree_depth_handler';

    /**
     * @internal
     */
    protected const TREE_DEPTH_LIMIT_COUNTERS = 'tree_depth_limit_counters';

    public function __construct(
        ClassMetadataFactoryInterface       $classMetadataFactory = null,
        NameConverterInterface              $nameConverter = null,
        PropertyAccessorInterface           $propertyAccessor = null,
        PropertyTypeExtractorInterface      $propertyTypeExtractor = null,
        ClassDiscriminatorResolverInterface $classDiscriminatorResolver = null,
        callable                            $objectClassResolver = null,
        array                               $defaultContext = []
    )
    {
        parent::__construct($classMetadataFactory, $nameConverter, $propertyAccessor, $propertyTypeExtractor, $classDiscriminatorResolver, $objectClassResolver, $defaultContext);
        $this->defaultContext[self::TREE_DEPTH_LIMIT] = 1;
    }

    /**
     * Detects if the configured max tree depth limit is reached.
     *
     * @param object $object
     * @param array $context
     *
     * @return bool
     *
     * @throws MaxTreeDepthException
     */
    protected function isMaxTreeDepth(object $object, array &$context): bool
    {
        $enableMaxTreeDepth = $context[self::ENABLE_MAX_TREE_DEPTH] ?? $this->defaultContext[self::ENABLE_MAX_TREE_DEPTH] ?? false;
        if (!$enableMaxTreeDepth) {
            return false;
        }
        $treeDepthLimit = $context[self::TREE_DEPTH_LIMIT] ?? $this->defaultContext[self::TREE_DEPTH_LIMIT] ?? 1;
        if (isset($context[self::TREE_DEPTH_LIMIT_COUNTERS])) {
            if ($context[self::TREE_DEPTH_LIMIT_COUNTERS] >= $treeDepthLimit) {
                unset($context[self::TREE_DEPTH_LIMIT_COUNTERS]);

                return true;
            }
            ++$context[self::TREE_DEPTH_LIMIT_COUNTERS];
        } else {
            $context[self::TREE_DEPTH_LIMIT_COUNTERS] = 1;
        }

        return false;
    }

    /**
     * Handles a max tree depth.
     *
     * If a max tree depth handler is set, it will be called. Otherwise, a
     * {@class MaxTreeDepthException} will be thrown.
     *
     * @param object $object
     * @param string|null $format
     * @param array $context
     *
     * @return mixed
     *
     * @throws MaxTreeDepthException
     */
    protected function handleMaxTreeDepth(object $object, string $format = null, array $context = []): mixed
    {
        $maxTreeDepthHandler = $context[self::MAX_TREE_DEPTH_HANDLER] ?? $this->defaultContext[self::MAX_TREE_DEPTH_HANDLER] ?? false;
        if ($maxTreeDepthHandler) {
            return $maxTreeDepthHandler($object, $format, $context);
        }
        throw new MaxTreeDepthException(sprintf('Max tree depth has been reached when serializing the object of class "%s" (configured limit: %d)', get_class($object), $this->treeDepthLimit));
    }

    /**
     * {@inheritdoc}
     */
    public function normalize($object, $format = null, array $context = [])
    {
        if ($this->isMaxTreeDepth($object, $context)) {
            return $this->handleMaxTreeDepth($object, $format, $context);
        }

        return parent::normalize($object, $format, $context);
    }
}
