<?php

namespace KupShop\FeedGeneratorBundle;

use KupShop\FeedGeneratorBundle\Exception\XMLFeedReader\UnreadableXMLException;
use KupShop\FeedGeneratorBundle\Exception\XMLFeedReader\XMLFeedReaderException;
use KupShop\FeedGeneratorBundle\XSD\Parser;

class XMLFeedReader
{
    private const INDEX_ATTRIBUTE_NAME = 'position';
    private const SEARCH_ATTRIBUTE_NAME = 'search';

    private array $basePath = ['feed', 'item'];
    private array $typeCorrectionMap = [];

    private \XMLReader $reader;

    /**
     * @throws UnreadableXMLException
     */
    public function open(string $xmlContent): void
    {
        $reader = new \XMLReader();
        if (!$reader->XML($xmlContent)) {
            throw new UnreadableXMLException('Failed to read XML from string.');
        } else {
            $this->reader = $reader;
        }
    }

    /**
     * @throws UnreadableXMLException
     */
    public function openFile(string $url): void
    {
        $reader = @\XMLReader::open($url);
        if (!$reader || @filesize($url) === 0) {
            throw new UnreadableXMLException('Failed to read XML.');
        } else {
            $this->reader = $reader;
        }
    }

    private function goToStart(): bool
    {
        if (empty($this->basePath)) {
            return false;
        }

        foreach ($this->basePath as $pathPart) {
            while ($this->reader->name !== $pathPart) {
                $res = $this->reader->read();
                if (!$res) {
                    return false;
                }
            }
        }

        return true;
    }

    private function goToNext(): bool
    {
        return $this->reader->next(end($this->basePath));
    }

    /**
     * Supplies a \Generator which yields XML elements converted to associative arrays.
     *
     * @throws XMLFeedReaderException
     */
    public function read(): \Generator
    {
        $hasNext = $this->goToStart();
        if (!$hasNext) {
            throw new XMLFeedReaderException('Could not find feed root element.');
        }

        while ($hasNext) {
            $search = $this->reader->getAttribute(self::SEARCH_ATTRIBUTE_NAME);
            $doc = $this->xmlToAssoc(new \SimpleXMLElement($this->reader->readOuterXml()));

            $doc['__search'] = $search ?? '';

            $hasNext = $this->goToNext();
            yield $doc;
        }
    }

    /**
     * @throws XMLFeedReaderException
     */
    public function findElementBySearchAttribute(string $value, string $searchAttributeName = self::SEARCH_ATTRIBUTE_NAME): array
    {
        $hasNext = $this->goToStart();
        if (!$hasNext) {
            throw new XMLFeedReaderException('Could not find feed root element.');
        }

        while ($hasNext) {
            if ($this->reader->getAttribute($searchAttributeName) === $value) {
                return $this->xmlToAssoc(new \SimpleXMLElement($this->reader->readOuterXml()));
            }

            $hasNext = $this->goToNext();
        }

        return [];
    }

    private function xmlToAssoc(\SimpleXMLElement $xml, string $currentBaseFieldName = ''): array
    {
        $res = [];

        foreach ($xml->children() as $child) {
            $childName = $child->getName();
            $fieldName = empty($currentBaseFieldName) ? $childName : "{$currentBaseFieldName}.{$childName}";

            // if the current node is a value without children
            if ($child->count() === 0) {
                $corrected = $this->correctValueType($child, $fieldName);
                $index = self::getElementAttribute($child, self::INDEX_ATTRIBUTE_NAME);
                $isAlreadyDefined = isset($res[$childName]);

                if ($isAlreadyDefined || $index !== null) {
                    $current = $isAlreadyDefined ? (array) $res[$childName] : [];
                    $index ??= $isAlreadyDefined ? count((array) $res[$childName]) : 0;

                    $res[$childName] = $current + [$index => $corrected];
                    continue;
                }

                $res[$childName] = $corrected;
                continue;
            }

            // array in XML (to not override existing entry, but merge into one array)
            if (isset($res[$childName])) {
                $res[$childName] = array_merge(
                    (array) $res[$childName],
                    [$this->xmlToAssoc($child, $fieldName)],
                );
                continue;
            }

            // only one occurrence in XML, but has maxOccurs=unbounded in XSD
            if ($this->typeCorrectionMap[$fieldName] ?? '' === Parser::TYPE_ARRAY) {
                $res[$childName] = [$this->xmlToAssoc($child, $fieldName)];
                continue;
            }

            $res[$childName] = $this->xmlToAssoc($child, $fieldName);
        }

        return $res;
    }

    private function correctValueType(\SimpleXMLElement $element, string $fieldName)
    {
        switch ($this->typeCorrectionMap[$fieldName] ?? '') {
            case Parser::TYPE_INT:
                return (int) $element;
            case Parser::TYPE_FLOAT:
                return (float) $element;
            case Parser::TYPE_BOOL:
                // bool cast checks if $element is nullish -> has to be checked manually
                // https://www.w3.org/TR/xmlschema-2/#boolean
                $val = (string) $element;

                return $val === 'true' || $val === '1';
            default:
                return (string) $element;
        }
    }

    private static function getElementAttribute(\SimpleXMLElement $self, string $name, $default = null)
    {
        $attributes = $self->attributes();

        if ($attributes === null) {
            return $default;
        }

        foreach ($attributes as $attr) {
            if ($attr->getName() === $name) {
                return (string) $attr;
            }
        }

        return $default;
    }

    public function setBasePath(array $basePath): void
    {
        $this->basePath = $basePath;
    }

    public function setTypeCorrectionMap(array $typeCorrectionMap): void
    {
        $this->typeCorrectionMap = $typeCorrectionMap;
    }
}
