<?php

declare(strict_types=1);

namespace Core\Utils;

use DOMDocument;
use Exception;

class XmlDeserializer
{
    /**
     * @var DOMDocument
     */
    private $dom;

    /**
     * @int
     */
    private $loadOptions;

    /**
     * @param int|null $loadOptions  A bit field of LIBXML_* constants
     */
    public function __construct(?int $loadOptions = null)
    {
        $this->dom = new DOMDocument();
        $this->loadOptions = $loadOptions ?? (LIBXML_NONET | LIBXML_NOBLANKS);
    }

    public function deserialize(string $xml, string $rootName, string $clazz)
    {
        $this->dom->loadXML($xml, $this->loadOptions);
        return $this->fromElement($this->dom, $rootName, $clazz);
    }

    public function deserializeToArray(
        string $xml,
        string $rootName,
        string $itemName,
        string $clazz
    ) {
        $this->dom->loadXML($xml, $this->loadOptions);
        return $this->fromElementToArray($this->dom, $itemName, $clazz, $rootName);
    }

    public function deserializeToMap(
        string $xml,
        string $rootName,
        string $clazz
    ) {
        $this->dom->loadXML($xml, $this->loadOptions);
        return $this->fromElementToMap($this->dom, $rootName, $clazz);
    }

    public function fromAttribute(\DOMElement $parent, string $name, string $clazz)
    {
        if (!$parent->hasAttribute($name)) {
            static::assertNullable($parent, '@' . $name, $clazz);
            return null;
        }

        $attributeValue = $parent->getAttribute($name);

        return $this->convertSimple($parent->getAttributeNode($name), $attributeValue, $clazz);
    }

    public function fromElement(\DOMNode $parent, string $name, string $clazz)
    {
        $element = static::getChildNodeByTagName($parent, $name);

        if ($element === null) {
            static::assertNullable($parent, $name . '[1]', $clazz);
            return null;
        }

        return $this->convert($element, $clazz);
    }

    public function fromElementToArray(
        \DOMNode $parent,
        string $itemName,
        string $clazz,
        ?string $wrappingElementName = null
    ) {
        if ($wrappingElementName === null) {
            $elements = static::getChildNodesByTagName($parent, $itemName);
        } else {
            $wrappingElement = static::getChildNodeByTagName($parent, $wrappingElementName);

            if ($wrappingElement === null) {
                static::assertNullable($parent, $wrappingElementName . '[1]', $clazz);
                return null;
            }

            $elements = static::getChildNodesByTagName($wrappingElement, $itemName);
        }

        return \array_map(
            function ($element) use ($clazz) {
                return $this->convert($element, $clazz);
            },
            $elements
        );
    }

    public function fromElementToMap(
        \DOMNode $parent,
        string $name,
        string $clazz
    ) {
        $wrapper = static::getChildNodeByTagName($parent, $name);

        if ($wrapper === null) {
            static::assertNullable($parent, $name . '[1]', $clazz);
            return null;
        }

        $map = [];
        foreach ($wrapper->childNodes as $element) {
            if ($element->nodeType === XML_ELEMENT_NODE && $element->hasAttribute('key') === true) {
                $map[$element->getAttribute('key')] = $this->convert($element, $clazz);
            }
        }

        return $map;
    }

    private function convert(\DOMElement $node, string $clazz)
    {
        $type = static::withoutNullQualifier($clazz);

        if (\class_exists($type) && \method_exists($type, 'fromXmlElement')) {
            return \call_user_func([$type, 'fromXmlElement'], $this, $node);
        }

        return $this->convertSimple($node, $node->textContent, $clazz);
    }

    private function convertSimple(\DOMNode $node, string $value, string $clazz)
    {
        $type = static::withoutNullQualifier($clazz);

        if ($type === 'float') {
            return \is_numeric($value) ? \floatval($value) : static::throwTypeException($node, $value, $clazz);
        } elseif ($type === 'int') {
            return \is_numeric($value) ? \intval($value) :  static::throwTypeException($node, $value, $clazz);
        } elseif ($type === 'bool') {
            return \strcasecmp($value, 'true') === 0 ?:
                (\strcasecmp($value, 'false') === 0 ? false :  static::throwTypeException($node, $value, $clazz));
        }

        return $value;
    }

    private static function assertNullable(\DOMNode $parentNode, string $nodeSubPath, string $clazz): void
    {
        if ($clazz[0] === '?') {
            return;
        }

        $sourceNodePath = static::makeNodePath($parentNode, $nodeSubPath);

        throw new Exception(
            'Required value not found at XML path "' . $sourceNodePath . '" during deserialization.'
        );
    }

    private static function throwTypeException(\DOMNode $sourceNode, $value, string $clazz): void
    {
        throw new Exception(
            'Expected value of type "' . $clazz . '" but got value "' . $value . '" at XML path "' .
            $sourceNode->getNodePath() . '" during deserialization.'
        );
    }

    private static function withoutNullQualifier(string $type): string
    {
        if (\strlen($type) > 1 && $type[0] === '?') {
            return \substr($type, 1);
        }

        return $type;
    }

    private static function makeNodePath(\DOMNode $node, $subpath)
    {
        $parentPath = $node->getNodePath();
        if ($parentPath === '/') {
            $parentPath = '';
        }

        return $parentPath . '/' . $subpath;
    }

    private static function getChildNodeByTagName(\DOMNode $node, string $name): ?\DOMElement
    {
        if ($node->hasChildNodes()) {
            foreach ($node->childNodes as $childNode) {
                if ($childNode->nodeType === XML_ELEMENT_NODE && $childNode->tagName === $name) {
                    return $childNode;
                }
            }
        }

        return null;
    }

    private static function getChildNodesByTagName(\DOMNode $node, string $name): array
    {
        $arr = [];
        if ($node->hasChildNodes()) {
            foreach ($node->childNodes as $childNode) {
                if ($childNode->nodeType === XML_ELEMENT_NODE && $childNode->tagName === $name) {
                    $arr[] = $childNode;
                }
            }
        }

        return $arr;
    }
}
