<?php
declare(strict_types=1);
namespace Aen\SerializerWorkshop;

use Symfony\Component\PropertyInfo\Extractor\PhpDocExtractor;
use Symfony\Component\PropertyInfo\Extractor\ReflectionExtractor;
use Symfony\Component\PropertyInfo\PropertyInfoExtractor;
use Symfony\Component\Serializer\Encoder\JsonEncoder;
use Symfony\Component\Serializer\Exception\ExtraAttributesException;
use Symfony\Component\Serializer\Exception\MissingConstructorArgumentsException;
use Symfony\Component\Serializer\Exception\NotEncodableValueException;
use Symfony\Component\Serializer\Exception\NotNormalizableValueException;
use Symfony\Component\Serializer\Exception\PartialDenormalizationException;
use Symfony\Component\Serializer\Mapping\Factory\ClassMetadataFactory;
use Symfony\Component\Serializer\Mapping\Loader\AttributeLoader;
use Symfony\Component\Serializer\NameConverter\CamelCaseToSnakeCaseNameConverter;
use Symfony\Component\Serializer\Normalizer\AbstractNormalizer;
use Symfony\Component\Serializer\Normalizer\AbstractObjectNormalizer;
use Symfony\Component\Serializer\Normalizer\ArrayDenormalizer;
use Symfony\Component\Serializer\Normalizer\DateTimeNormalizer;
use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
use Symfony\Component\Serializer\Normalizer\ObjectNormalizer;
use Symfony\Component\Serializer\Serializer;
use Symfony\Component\Validator\Validation;

require_once __DIR__.'/Models.php';

final class Workshop
{
    public const VERSION = '7.4.23';
    public const IDS = ['roundtrip','names','groups','dates','nested','presence','strict','relations','validate'];

    public static function serializer(bool $snake = false): Serializer
    {
        $metadata = new ClassMetadataFactory(new AttributeLoader());
        $types = new PropertyInfoExtractor([], [new PhpDocExtractor(), new ReflectionExtractor()]);
        return new Serializer([
            new DateTimeNormalizer(), new ArrayDenormalizer(),
            new ObjectNormalizer($metadata, $snake ? new CamelCaseToSnakeCaseNameConverter() : null, null, $types),
        ], [new JsonEncoder()]);
    }

    public static function run(string $id, string $json, array $options = []): array
    {
        if (!in_array($id, self::IDS, true)) throw new \InvalidArgumentException('UNKNOWN_SCENARIO');
        if (strlen($json) > 16384) throw new \InvalidArgumentException('INPUT_TOO_LARGE');
        if (array_diff(array_keys($options), ['group','timezone','collectErrors','skipNull','relationMode'])) throw new \InvalidArgumentException('UNKNOWN_OPTION');
        foreach (['collectErrors','skipNull'] as $key) if (isset($options[$key]) && !is_bool($options[$key])) throw new \InvalidArgumentException('INVALID_OPTION');
        if (isset($options['group']) && !in_array($options['group'], ['public','admin'], true)) throw new \InvalidArgumentException('INVALID_OPTION');
        if (isset($options['timezone']) && !in_array($options['timezone'], ['UTC','Europe/Moscow'], true)) throw new \InvalidArgumentException('INVALID_OPTION');
        if (isset($options['relationMode']) && !in_array($options['relationMode'], ['reference','depth'], true)) throw new \InvalidArgumentException('INVALID_OPTION');
        $envelope = ['engine'=>'Symfony Serializer PHP','version'=>self::VERSION,'scenario'=>$id,'ok'=>false];
        try {
            $decoded = json_decode($json, false, 16, JSON_THROW_ON_ERROR);
            if (!$decoded instanceof \stdClass) throw new \InvalidArgumentException('ROOT_MUST_BE_OBJECT');
            $nodes = 0; self::bound($decoded, 0, $nodes);
            // The scheduling example requires an exact calendar timestamp, not
            // DateTime's permissive rollover or relative-date parser.
            if ($id === 'dates' && isset($decoded->startsAt) && is_string($decoded->startsAt) && !self::validTimestamp($decoded->startsAt)) {
                return array_merge($envelope, ['stage'=>'denormalize','errors'=>[['code'=>'INVALID_DATE','path'=>'startsAt','message'=>'Нужна существующая дата в формате YYYY-MM-DDTHH:MM:SS±HH:MM или с Z. Доли секунд и относительные даты в этом примере не принимаются.']]]);
            }
            $serializer = self::serializer($id === 'names');
            $context = [AbstractNormalizer::ALLOW_EXTRA_ATTRIBUTES=>false, AbstractNormalizer::REQUIRE_ALL_PROPERTIES=>true];
            if ($options['collectErrors'] ?? true) $context[DenormalizerInterface::COLLECT_DENORMALIZATION_ERRORS] = true;
            $classes = ['roundtrip'=>Customer::class,'names'=>Contact::class,'groups'=>Profile::class,'dates'=>Meeting::class,'nested'=>Order::class,'presence'=>Preferences::class,'strict'=>Customer::class,'validate'=>Signup::class];
            if ($id === 'relations') return array_merge($envelope, self::relations($decoded, $options));
            $object = $serializer->deserialize($json, $classes[$id], 'json', $context);
            $outContext = [];
            if ($id === 'groups') $outContext[AbstractNormalizer::GROUPS] = [$options['group'] ?? 'public'];
            if ($id === 'dates') $outContext = [DateTimeNormalizer::FORMAT_KEY=>'Y-m-d\TH:i:sP',DateTimeNormalizer::TIMEZONE_KEY=>$options['timezone'] ?? 'UTC'];
            if ($id === 'presence') $outContext[AbstractObjectNormalizer::SKIP_NULL_VALUES] = $options['skipNull'] ?? false;
            $normalized = $serializer->normalize($object, 'json', $outContext);
            $resultJson = $serializer->serialize($object, 'json', $outContext + ['json_encode_options'=>JSON_PRETTY_PRINT|JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES]);
            $violations = [];
            if ($id === 'validate') foreach (Validation::createValidatorBuilder()->enableAttributeMapping()->getValidator()->validate($object) as $violation) $violations[] = ['path'=>$violation->getPropertyPath(),'message'=>$violation->getMessage()];
            return array_merge($envelope, ['ok'=>!$violations, 'stage'=>$violations ? 'validation' : 'serialized', 'class'=>(new \ReflectionClass($object))->getShortName(), 'normalized'=>$normalized,'json'=>$resultJson,'violations'=>$violations,'details'=>self::details($id,$object,$decoded)]);
        } catch (\JsonException|NotEncodableValueException $e) {
            return array_merge($envelope,['stage'=>'decode','errors'=>[['code'=>'INVALID_JSON','path'=>'','message'=>'Нужен корректный JSON-объект глубиной не более 16.']]]);
        } catch (PartialDenormalizationException $e) {
            return array_merge($envelope,['stage'=>'denormalize','errors'=>array_map(self::typeError(...),$e->getErrors())]);
        } catch (NotNormalizableValueException $e) {
            return array_merge($envelope,['stage'=>'denormalize','errors'=>[self::typeError($e)]]);
        } catch (ExtraAttributesException $e) {
            return array_merge($envelope,['stage'=>'denormalize','errors'=>[['code'=>'EXTRA_FIELDS','path'=>'','message'=>'В DTO нет этих полей.','fields'=>$e->getExtraAttributes()]]]);
        } catch (MissingConstructorArgumentsException $e) {
            return array_merge($envelope,['stage'=>'denormalize','errors'=>[['code'=>'MISSING_FIELDS','path'=>'','message'=>'Отсутствуют обязательные параметры конструктора.','fields'=>$e->getMissingConstructorArguments()]]]);
        } catch (\InvalidArgumentException $e) {
            return array_merge($envelope,['stage'=>'input','errors'=>[['code'=>$e->getMessage(),'path'=>'','message'=>'Вход не соответствует ограниченному контракту этого примера.']]]);
        }
    }
    private static function typeError(NotNormalizableValueException $e): array
    {
        if(str_starts_with($e->getMessage(),'Failed to create object because the class misses the '))return ['code'=>'MISSING_FIELDS','path'=>$e->getPath()??'','message'=>'Обязательный параметр конструктора отсутствует. Явный null и отсутствие поля — разные случаи.'];
        return ['code'=>'TYPE_MISMATCH','path'=>$e->getPath() ?? '', 'expected'=>$e->getExpectedTypes(),'actual'=>$e->getCurrentType(),'message'=>'Значение не соответствует типу свойства. Исправьте поле; частичный объект не используется.'];
    }
    private static function validTimestamp(string $value): bool
    {
        if (!preg_match('/^\d{4}-\d{2}-\d{2}T(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:Z|[+-](?:[01]\d|2[0-3]):[0-5]\d)$/D', $value)) return false;
        $date = \DateTimeImmutable::createFromFormat('!Y-m-d\TH:i:sP', $value);
        $errors = \DateTimeImmutable::getLastErrors();
        return $date !== false && ($errors === false || ($errors['warning_count'] === 0 && $errors['error_count'] === 0));
    }
    private static function bound(mixed $value, int $depth, int &$nodes): void
    {
        if (++$nodes > 250 || $depth > 8) throw new \InvalidArgumentException('STRUCTURE_LIMIT');
        if (is_string($value) && strlen($value)>2000) throw new \InvalidArgumentException('STRING_LIMIT');
        if (is_object($value) || is_array($value)) {
            $items=(array)$value;
            if(count($items)>30)throw new \InvalidArgumentException('COLLECTION_LIMIT');
            foreach($items as $k=>$v) {if(strlen((string)$k)>100)throw new \InvalidArgumentException('KEY_LIMIT');self::bound($v,$depth+1,$nodes);}
        }
    }
    private static function details(string $id,object $object,object $input): array
    {
        if($id==='nested')return ['addressClass'=>get_class($object->address),'itemClasses'=>array_map(get_class(...),$object->items)];
        if($id==='presence')return ['inputKeys'=>array_keys((array)$input),'noteIsNull'=>$object->note===null,'active'=>$object->active];
        if($id==='dates')return ['timestamp'=>$object->startsAt->getTimestamp(),'originalTimezone'=>$object->startsAt->getTimezone()->getName()];
        return ['immutableInput'=>'DTO is created for this request only; nothing is stored.'];
    }
    private static function relations(object $input,array $options): array
    {
        if(array_diff(array_keys((array)$input),['team','member']))throw new \InvalidArgumentException('EXTRA_FIELDS');
        if(!isset($input->team,$input->member)||!is_string($input->team)||!is_string($input->member))throw new \InvalidArgumentException('RELATION_LABELS_REQUIRED');
        $serializer=self::serializer();
        if(($options['relationMode']??'reference')==='depth') {
            $root=new TreeNode($input->team);$root->child=new TreeNode($input->member);$root->child->child=new TreeNode('Уровень 3');
            $ctx=[AbstractObjectNormalizer::ENABLE_MAX_DEPTH=>true];$object=$root;
        } else {
            $team=new Team(1,$input->team);$member=new Member(2,$input->member);$team->members=[$member];$member->team=$team;$object=$team;
            $ctx=[AbstractNormalizer::CIRCULAR_REFERENCE_HANDLER=>static fn(object $o):array=>['refType'=>(new \ReflectionClass($o))->getShortName(),'id'=>$o->id]];
        }
        return ['ok'=>true,'stage'=>'serialized','class'=>(new \ReflectionClass($object))->getShortName(),'normalized'=>$serializer->normalize($object,'json',$ctx),'json'=>$serializer->serialize($object,'json',$ctx+['json_encode_options'=>JSON_PRETTY_PRINT|JSON_UNESCAPED_UNICODE]),'details'=>['mode'=>$options['relationMode']??'reference']];
    }
}
