<?php

declare(strict_types=1);

// TODO: Strict types and fix inheriting classes
/* From original Varien_Simplexml_Config:
 *
 * ```
 * // Unimplemented because they are unused:
 * updateCacheChecksum(string): void
 * fetchCacheChecksum(): bool
 * loadDom(DOMNode $dom): bool
 *
 * // Needs implementation:
 * getXpath(string): array|false
 * getXmlString(): string   // not sure?
 * ```
 */
/**
 * @psalm-consistent-constructor
 * @psalm-import-type Varien_Simplexml_Node from Varien_Simplexml_Util
 * @psalm-import-type Varien_Simplexml_LeafNode from Varien_Simplexml_Util
 */
class Varien_Simplexml_Config {
    final const CHECKSUM_ID_SUFFIX = "__CHECKSUM";

    private ?Zend_Cache_Core $cache = null;
    /**
     * Cache checksum, null if needs to be calculated, false if cache-saving is
     * disabled.
     */
    private string|null|false $cacheChecksum = null;
    private ?string $cacheId = null;
    private ?int $cacheLifetime = null;
    private bool $cacheSaved = false;
    /**
     * @var list<string>
     */
    private array $cacheTags = [];
    private string $rootName = "root";
    /**
     * @var Varien_Simplexml_Node|Varien_Simplexml_LeafNode
     */
    private array $data = [];

    public static function fromSerialized(string $serializedData): ?static {
        $obj = new static();

        if( ! $obj->loadSerializedString($serializedData)) {
            return null;
        }

        return $obj;
    }

    /**
     * @param Array<string, string|array> $data
     */
    public static function fromArray(array $data): static {
        $obj = new static();

        $obj->setInnerData(Varien_Simplexml_Util::wrapKeys($data));

        return $obj;
    }

    public function __construct(
        string|SimpleXMLElement|Varien_Simplexml_Config|Varien_Simplexml_Element $sourceData = null
    ) {
        if($sourceData === null) {
            return;
        }

        if($sourceData instanceof SimpleXMLElement ||
            $sourceData instanceof Varien_Simplexml_Config ||
            $sourceData instanceof Varien_Simplexml_Element) {
            $this->setXml($sourceData);
        }
        else {
            $this->loadString($sourceData);
        }
    }

    /**
     * @param list<Varien_Simplexml_Node|Varien_Simplexml_LeafNode> $data
     * @param Varien_Simplexml_Element::ITER_* $iteratorType
     */
    protected function createElement(
        string $name,
        array $data,
        string $iteratorType
    ): Varien_Simplexml_Element {
        return new Varien_Simplexml_Element($name, $data, $iteratorType);
    }

    /**
     * @internal Varien_Simplexml_*
     * @return Varien_Simplexml_Node|Varien_Simplexml_LeafNode
     */
    public function getInnerData(): array {
        return $this->data;
    }

    /**
     * @internal Varien_Simplexml_*
     * @param Varien_Simplexml_Node $data
     */
    public function setInnerData(array $data): void {
        $this->data = $data;
    }

    public function getCache(): Zend_Cache_Core {
        assert($this->cache !== null);

        return $this->cache;
    }

    public function setCache(Zend_Cache_Core $cache): void {
        $this->cache = $cache;
    }

    public function getCacheId(): ?string {
        return $this->cacheId;
    }

    public function setCacheId(string $id): void {
        $this->cacheId = $id;
    }

    public function getCacheChecksum(): string|null|false {
        return $this->cacheChecksum;
    }

    public function setCacheChecksum(string|null|false $data): void {
        $this->cacheChecksum = is_string($data) ? md5($data) : $data;
    }

    public function validateCacheChecksum(): bool {
        $newChecksum = $this->getCacheChecksum();

        if ($newChecksum === null) {
            return true;
        }

        $cacheId = $this->getCacheId();

        if( ! $cacheId || ! $newChecksum) {
            return false;
        }

        /**
         * @var string|false
         */
        $cachedChecksum = $this->getCache()->load($cacheId.self::CHECKSUM_ID_SUFFIX);

        return $cachedChecksum === false || $newChecksum === $cachedChecksum;
    }

    public function getCacheLifetime(): ?int {
        return $this->cacheLifetime;
    }

    public function setCacheLifetime(int $lifetime): void {
        $this->cacheLifetime = $lifetime;
    }

    public function getCacheSaved(): bool {
        return $this->cacheSaved;
    }

    public function setCacheSaved(bool $flag): void {
        $this->cacheSaved = $flag;
    }

    /**
     * @return list<string>
     */
    public function getCacheTags(): array {
        return $this->cacheTags;
    }

    /**
     * @param list<string> $tags
     */
    public function setCacheTags(array $tags): void {
        $this->cacheTags = $tags;
    }

    public function getName(): string {
        return $this->rootName;
    }

    // We have to pretend to be "XML" for this, since we are emulating the xml
    // objects adn setXml needs to work with them
    public function setXml(
        SimpleXMLElement|Varien_Simplexml_Config|Varien_Simplexml_Element $xml
    ): void {
        if(
            $xml instanceof Varien_Simplexml_Config ||
            $xml instanceof Varien_Simplexml_Element
        ) {
            $this->rootName = $xml->getName();
            $this->data = $xml->getInnerData();
        }
        else {
            $this->rootName = $xml->getName();
            $this->data = Varien_Simplexml_Util::simpleXmlToArray($xml);
        }
    }

    private function loadSerializedString(string $string): bool {
        /**
         * @var array{name:string, data:Varien_Simplexml_Node}|false
         */
        $data = unserialize($string, ["allowed_classes" => false]);
        // $data = json_decode($string, true);

        if( ! $data) {
            // TODO: Throw error in dev mode if we fail to load?
            return false;
        }

        $this->rootName = $data["name"];
        $this->data = $data["data"];

        return true;
    }

    /**
     * Loads a JSON or XML string into the configuration object, if the string
     * starts with "{" then it is considered a JSON string.
     */
    public function loadString(string $string): bool {
        $xml = Varien_Simplexml_Util::parseXmlString($string);

        if($xml === false) {
            return $xml;
        }

        $this->setXml($xml);

        return true;
    }

    public function loadFile(string $path): bool {
        $xml = Varien_Simplexml_Util::parseXmlFile($path);

        if($xml !== false) {
            $this->data = Varien_Simplexml_Util::simpleXmlToArray($xml);
        }

        return true;
    }

    // TODO: Types
    /**
     * @param string|list<string|Stringable> $path
     * @return ?Varien_Simplexml_Element
     */
    public function getNode($path = []) {
        $root = $this->createElement(
            $this->rootName,
            [$this->data],
            Varien_Simplexml_Element::ITER_CHILDREN,
        );

        if(empty($path)) {
            return $root;
        }

        return $root->descend($path);
    }

    // TODO: Test
    // TODO: Break out?
    // TODO: Performance?
    // TODO: Accept a Varien_Simplexml_Element as value?
    /**
     * Sets the given path node to the supplied value, false or null removes
     * the node from the tree.
     * @param string|list<string|Stringable> $path
     */
    // Overwrite parameter is never used by anything, and if it is used it is
    // set to true which is default.
    public function setNode(
        string|array $path,
        Varien_Simplexml_Config|Varien_Simplexml_Element|string|false|null $value
    ): void {
        $parts = is_array($path) ? array_map("strval", $path) : explode("/", $path);
        /**
         * Stack of items to modify when we have reached the target node.
         *
         * @var Array<array{0:string, 1: Array<string, list<array|string>>}>
         */
        $stack = [];
        /**
         * @var Array<string, list<array|string>>
         */
        $current = $this->data;

        // This assertion is required to uphold the invariant that the root
        // element is a Node and not a LeafNode:
        assert(count($parts) > 0, "Attempting to set root node is not allowed");

        // Traverse down one key -> [0] step at a time
        foreach($parts as $k) {
            assert($k !== "#", "Attempting to modify a string node is not allowed");

            if(array_key_exists($k, $current)) {
                $stack[] = [$k, $current];
                /**
                 * @var Array<string, list<array|string>>
                 */
                $current = $current[$k][0];
            }
            else {
                $stack[] = [$k, $current];
                $current = [];
            }
        }

        if(
            $value instanceof Varien_Simplexml_Element ||
            $value instanceof Varien_Simplexml_Config
        ) {
            $current = $value->getInnerData();
        }
        else if(!empty($value) || $value === "0") {
            $current = [
                Varien_Simplexml_Element::VALUE_KEY => $value,
            ];
        }
        else {
            // False or null => remove the node
            $current = [];
        }

        /**
         * @var Varien_Simplexml_Node|Varien_Simplexml_LeafNode $current
         */

        // Recursively merge the differing nodes
        for($i = count($stack) - 1; $i >= 0; $i--) {
            [$k, $v] = $stack[$i];

            if( ! empty($current)) {
                $v[$k] = [$current];
            }
            else {
                unset($v[$k]);
            }

            /**
             * @var Varien_Simplexml_Node
             */
            $current = $v;
        }

        $this->data = $current;
    }

    /**
     * @return Traversable<Varien_Simplexml_Element>
     */
    public function getXPath(string $expression): Traversable {
        return $this->createElement(
            $this->rootName,
            [$this->data],
            Varien_Simplexml_Element::ITER_CHILDREN,
        )->xpath($expression);
    }

    /**
     * @param bool $overwrite
     * @return $this
     */
    public function extend(Varien_Simplexml_Config $other, $overwrite = true): static {
        $this->data = Varien_Simplexml_Util::extendArray($this->data, $other->getInnerData(), $overwrite);

        return $this;
    }

    /**
     * @param string|list<string|Stringable> $path
     */
    public function extendNode(
        string|array $path,
        Varien_Simplexml_Config|Varien_Simplexml_Element $node,
        bool $overwrite = true
    ): void {
        $parts = is_array($path) ? array_map("strval", $path) : explode("/", $path);
        $current = $node->getInnerData();

        for($i = count($parts) - 1; $i >= 0; $i--) {
            $current = [
                $parts[$i] => [$current],
            ];
        }

        $this->data = Varien_Simplexml_Util::extendArray($this->data, $current, $overwrite);
    }

    public function applyExtends(): void {
        // Do nothing, only paypal-module uses @extends attribute
    }

    // TODO: Types
    /**
     * @param ?list<string> $tags
     * @return void
     */
    public function saveCache($tags = null)
    {
        // TODO: Can we simplify this logic?
        $checksum = $this->getCacheChecksum();
        if ($this->getCacheSaved() || $checksum === false) {
            return;
        }

        $cacheId = $this->getCacheId();

        if( ! $cacheId) {
            return;
        }

        if($tags === null) {
            $tags = $this->cacheTags;
        }

        if (!is_null($checksum)) {
            $this->_saveCache(
                $checksum,
                $cacheId.self::CHECKSUM_ID_SUFFIX,
                $tags,
                $this->getCacheLifetime()
            );
        }

        $this->_saveCache($this->serializeData(), $cacheId, $tags, $this->getCacheLifetime());

        $this->setCacheSaved(true);
    }

    public function loadCache(): bool {
        $id = $this->getCacheId();

        if (!$this->validateCacheChecksum() || !$id) {
            return false;
        }

        $xmlString = $this->_loadCache($id);

        if(!$xmlString) {
            return false;
        }

        if(!$this->loadSerializedString($xmlString)) {
            return false;
        }

        $this->setCacheSaved(true);

        return true;
    }

    // TODO: Types
    /**
     * @return void
     */
    public function removeCache() {
        $cacheId = $this->getCacheId();

        if( ! $cacheId) {
            return;
        }

        $this->_removeCache($cacheId);
        $this->_removeCache($cacheId.self::CHECKSUM_ID_SUFFIX);
    }

    // TODO: Types
    /**
     * @param string $id
     * @return string|null|false
     */
    protected function _loadCache($id) {
        return $this->getCache()->load($id);
    }

    // TODO: Types
    /**
     * @param string $data
     * @param string $id
     * @param list<string> $tags
     * @param int|false|null $lifetime
     * @return bool
     */
    protected function _saveCache($data, $id, $tags = array(), $lifetime = false)
    {
        return $this->getCache()->save($data, $id, $tags, $lifetime);
    }

    // TODO: Types
    /**
     * @param string $id
     * @return bool
     */
    protected function _removeCache($id)
    {
        return $this->getCache()->remove($id);
    }

    public function serializeData(): string {
        return serialize([
            "name" => $this->rootName,
            "data" => $this->data,
        ]);
        /*
        return json_encode([
            "name" => $this->rootname,
            "data" => $this->data,
        ], JSON_PRESERVE_ZERO_FRACTION |
            JSON_THROW_ON_ERROR |
            JSON_UNESCAPED_SLASHES |
            JSON_UNESCAPED_UNICODE);
        */
    }
}
