<?php

// TODO: Tests (ensure the tests include items with combinations of attributes,
// multiple identical children, plain values)
// TODO: More documentation
/**
 * To store SimpleXML-like data we have to ensure that tags with colliding
 * names do not interfere with each other. To allow for quick lookup based on
 * tag-name we use an array of key -> list<child-nodes> instead of key->child-node.
 *
 * ```xml
 * <config>
 *   <foo>
 *     <aNode>data</aNode>
 *   </foo>
 *   <foo>another</foo>
 * </config>
 * ```
 *
 * ```json
 * {
 *   foo: [
 *     {
 *       aNode: [
 *         "data"
 *       ],
 *     },
 *     "another",
 *   ],
 * }
 * ```
 *
 * @psalm-type Varien_Simplexml_AttributesMap Array<string, string>
 * @psalm-type Varien_Simplexml_Node Array<string, list<Array>>
 * @psalm-type Varien_Simplexml_LeafNode array{"@"?:Varien_Simplexml_AttributesMap,"#":string}
 */
class Varien_Simplexml_Util {
    final const VALUE_KEY = Varien_Simplexml_Element::VALUE_KEY;
    final const ATTRIBUTES_KEY = Varien_Simplexml_Element::ATTRIBUTES_KEY;

    /**
     * Converts a SimpleXMLElement into a nested array structure of
     * key -> list<child-node>.
     */
    // TODO: Optimize?
    public static function simpleXmlToArray(SimpleXMLElement $xml): array {
        $data = [];
        $attributes = [];

        foreach($xml->attributes() as $k => $v) {
            $attributes[$k] = trim((string)$v, " \r\n\t") ?: null;
        }

        /**
         * @var string $name
         */
        foreach($xml->children() as $name => $value) {
            if( ! array_key_exists($name, $data)) {
                $data[$name] = [];
            }

            $data[$name][] = self::simpleXmlToArray($value);
        }

        if(count($data) === 0) {
            $data[self::VALUE_KEY] = trim((string)$xml, " \r\n\t");
        }

        if(count($attributes) > 0) {
            $data[self::ATTRIBUTES_KEY] = $attributes;
        }

        return $data;
    }

    public static function parseXmlString(string $string): SimpleXMLElement|false {
        // Modified to produce error-messages in dev-mode
        // We want the errors as an array, hopefully the XML-file is not too large
        libxml_use_internal_errors(true);
        $xml = simplexml_load_string(
            $string,
            // Compact internal representation + noblanks for performance,
            // and do not load external entities
            options: LIBXML_COMPACT | LIBXML_NOBLANKS | LIBXML_NONET
        );
        $errors = libxml_get_errors();

        // Reset error configuration, this will clear the libxml errors array
        libxml_use_internal_errors(false);

        if($xml === false) {
            if(Mage::getIsDeveloperMode()) {
                $errors = array_map(function($e) {
                    return sprintf("%s on line %d", trim($e->message, "\n\r\t "), $e->line);
                }, $errors);

                throw new RuntimeException(sprintf("simplexml_load_string: Failed to parse XML-string: %s", implode(", ", $errors)));
            }

            return false;
        }

        return $xml;
    }

    public static function parseXmlFile(string $path): SimpleXMLElement|false {
        if (!is_readable($path)) {
            return false;
        }

        try {
            return self::parseXmlString(file_get_contents($path));
        }
        catch(Exception $e) {
            // Modified to add some extra info if something goes wrong while parsing XML, so we know which file it is
            throw new Exception(
                sprintf("Failed to parse XML file '%s': %s.", $path, $e->getMessage()),
                previous: $e
            );
        }
    }

    /**
     * Extends $old with keys from $new, recursively, where keys from $new
     * overwrite if non null and $overwrite.
     *
     * @param Varien_Simplexml_LeafNode|Varien_Simplexml_Node $old
     * @param Varien_Simplexml_LeafNode|Varien_Simplexml_Node $new
     * @return Varien_Simplexml_LeafNode|Varien_Simplexml_Node
     */
    // TODO: Optimize?
    /*
        [
         "foo" => [
            0 => [
                "#" => "bar"
                "@" => [
                    "baz",
                ],
            ],
        ],
        "@" => [
            "baz",
        ],
     */
    public static function extendArray(array $old, array $new, bool $overwrite = true): array {
        // TODO: Can we simplify this logic dealing with string vs nodes?
        // Ensure we only replace with actual values
        if(
            array_key_exists(self::VALUE_KEY, $new) &&
            (
                ! empty($new[self::VALUE_KEY]) ||
                // Zero is a valid value
                $new[self::VALUE_KEY] === "0"
            )
        ) {
            return ($overwrite || empty($old[self::VALUE_KEY]) && ($old[self::VALUE_KEY] ?? "") !== "0") ? $new : $old;
        }

        // Ensure we do not propagate any empty value nodes
        if(array_key_exists(self::VALUE_KEY, $new)) {
            unset($new[self::VALUE_KEY]);
        }

        if(empty($new)) {
            return $old;
        }

        // We have a new node, delete any string data
        if(array_key_exists(self::VALUE_KEY, $old)) {
            unset($old[self::VALUE_KEY]);
        }

        /**
         * We removed the value keys from both $old and $new, so it is
         * attributes + node-lists left.
         *
         * @var Varien_Simplexml_Node $new
         * @var Varien_Simplexml_Node $old
         */
        foreach($new as $k => $group) {
            if( ! array_key_exists($k, $old)) {
                $old[$k] = $group;
            }
            else if($k === self::ATTRIBUTES_KEY) {
                $old[$k] = $overwrite ? array_merge($old[$k], $group) : array_merge($group, $old[$k]);
            }
            else {
                foreach($group as $gk => $gv) {
                    if(array_key_exists($gk, $old[$k])) {
                        $old[$k][$gk] = self::extendArray($old[$k][$gk], $gv, $overwrite);
                    }
                    else {
                        $old[$k][$gk] = $gv;
                    }
                }
            }
        }

        return $old;
    }

    public static function extendArrayOld(array $old, array $new, bool $overwrite = true): array {
        foreach($new as $k => $v) {
            if($v instanceof Varien_Simplexml_Element) {
                $v = $v->getInnerData();
            }

            if(is_array($v) && array_key_exists($k, $old) && is_array($old[$k])) {
                $old[$k] = self::extendArrayOld($old[$k], $v, $overwrite);
            }
            // We need to allow "0" and 0 to allow unsetting settings
            // TODO: Can we simplify these expressions, maybe with isset?
            // TODO: Technically slightly wrong, we allow overwriting "0" and 0 here due to empty
            else if(( ! empty($v) || $v === "0" || $v === 0) && ($overwrite || empty($old[$k]))) {
                $old[$k] = $v;
            }
        }

        return $old;
    }

    // TODO: Technically the type is a bit wrong since we can have attributes on a list
    /**
     * Converts a nested structure of key -> node into the internal XML-like
     * nested structure of key -> list<node>.
     *
     * @param Array<string, string|array> $arr
     * @return Array<string, list<Array>>
     */
    public static function wrapKeys(array $arr) {
        $new = [];

        foreach($arr as $k => $v) {
            $new[$k] = [
                is_array($v) ? self::wrapKeys($v) : [ self::VALUE_KEY => $v ],
            ];
        }

        return $new;
    }

    /**
     * Flattens the internal XML-like nested structure of key -> list<node>
     * into a nested key -> node structure, skipping conflicting nodes.
     * Attributes will be included in the key "@" if $includeAttributes is set.
     *
     * @param Varien_Simplexml_Node|Varien_Simplexml_LeafNode $node
     * @return Array<string, array|string>|string
     */
    public static function flattenKeys(array $node, bool $includeAttributes = true) {
        $new = [];

        foreach($node as $k => $v) {
            if($k === self::VALUE_KEY) {
                /**
                 * We cannot preserve attributes while flattening
                 *
                 * @var string
                 */
                return $v;
            }

            if($k === self::ATTRIBUTES_KEY) {
                if($includeAttributes) {
                    $new[$k] = $v;
                }

                continue;
            }

            $new[$k] = self::flattenKeys($v[0], $includeAttributes);
            // We discard any other children since we have no way of
            // representing lists in this kind of structure
        }

        return $new;
    }

    final const XPATH_TOKEN_REGEX =
        '/\\$?(?:(?![0-9-\\.])(?:\\*|[\\w\\-\\.]+):)?(?![0-9-\\.])' .
        '(?:\\*|[\\w\\-\\.]+)' .
            // Nodename or wildcard[*] (possibly with namespace or wildcard[*])
            // or variable.
        '|\\/\\/' . // Double slash.
        '|\\.\\.' . // Double dot.
        '|::' . // Double colon.
        '|\\d+(?:\\.\\d*)?' . // Number starting with digit.
        '|\\.\\d+' . // Number starting with decimal point.
        '|"[^"]*"' . // Double quoted string.
        '|\'[^\']*\'' . // Single quoted string.
        '|[!<>]=' . // Operators
        '|\\s+' . // Whitespaces.
        '|./'; // Any single character.

    final const XPATH_DESCEND_ANY = "//";
    final const XPATH_DESCEND_ONE = "/";
    final const XPATH_ANY = "*";
    final const XPATH_TEST = "[";
    final const XPATH_NODE_NAME = "n";

    // Examples:
    //
    // app/code/core/Mage/Adminhtml/Model/Email/Template.php
    // 57:        $defaultCfgNodes = Mage::getConfig()->getXpath('default/*/*[*="' . $templateCode . '"]');
    //
    // app/code/core/Mage/Widget/Model/Widget.php
    // 70:        $elements = $this->getXmlConfig()->getXpath('*[@type="' . $type . '"]');
    // app/code/core/Mage/Core/Helper/Js.php
    // 145:            $messages = $this->_getXmlConfig()->getXpath('*/message');
    /**
     * Runs an XPath node-expression on the given node, returning a list of tuples containing [nodeName, nodeData].
     *
     * @return list<array{0:string, 1:array}>
     */
    public static function xpath(string $expression, array $node): array {
        preg_match_all(self::XPATH_TOKEN_REGEX, $expression, $matches);

        $tokens = $matches[0];
        $i = 0;
        $count = count($tokens);
        $steps = self::tokenizeXpath($tokens, $i);
        $data = self::walkXpath($steps, $node, null);

        if($data !== false) {
            return $data;
        }

        // FIXME: Implement
        throw new Exception(sprintf("%s: Not implemented for path '%s'", __METHOD__, $expression));
    }

    private static function tokenizeXpath(array $tokens, int &$i = 0): array {
        $steps = [];
        $c = count($tokens);

        // Path Expression parsing
        for(; $i < $c; $i++) {
            $op = $tokens[$i];

            switch($op) {
            case "//":
                $steps[] = [self::XPATH_DESCEND_ANY, $tokens[++$i]];
                break;
            case "/":
                $steps[] = [self::XPATH_DESCEND_ONE, $tokens[++$i]];
                break;
            case "*":
                $steps[] = [self::XPATH_ANY, null];
                break;
            case "[":
                $i++;
                // TODO: How to differentiate some of the tests with the
                // attribute selector? since they both are ("[" "foobar") vs
                // ("[" "@" "foobar"), the former selects a node having a
                // foobar node as a child, the latter one which has it as an
                // element
                $steps[] = [self::XPATH_TEST, self::tokenizeXpath($tokens, $i)];
                break;
            case "]":
                // TODO: We really need a better parser
                return $steps;
            default:
                $steps[] = [self::XPATH_NODE_NAME, $op];
            }
        }

        return $steps;
    }

    private static function walkXpath(array $steps, array|string $node, ?string $nodeName): array|false {
        if(empty($steps)) {
            // We have found a node
            assert($nodeName !== null);

            return [
                [ $nodeName, $node ],
            ];
        }

        [$op, $arg] = array_shift($steps);

        switch($op) {
        case self::XPATH_DESCEND_ANY:
            if( ! is_array($node)) {
                return [];
            }

            if($arg !== "*") {
                return self::walkUntilTagName($arg, $steps, $node);
            }
        case self::XPATH_ANY:
            if( ! is_array($node)) {
                return [];
            }
            return self::walkAllNodes($steps, $node);

        case self::XPATH_DESCEND_ONE:
            $matches = [];

            if( ! is_array($node)) {
                return [];
            }

            if($arg === "*") {
                foreach($node as $groupName => $group) {
                    foreach($group as $child) {
                        $res = self::walkXpath($steps, $child, $groupName);

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

                        $matches = array_merge($matches, $res);
                    }
                }
            }
            else {
                foreach($node[$arg] ?? [] as $child) {
                    $res = self::walkXpath($steps, $child, $arg);

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

                    $matches = array_merge($matches, $res);
                }
            }

            return $matches;

        case self::XPATH_TEST:
            // Test for attributes somewhere in the test
            if(in_array(self::ATTRIBUTES_KEY, $arg, true)) {
                return false;
            }

            if(is_array($node)) {
                foreach($node as $name => $child) {
                    if( ! empty(self::walkXpath($arg, $child, $name))) {
                        return [
                            [ $nodeName, $node ],
                        ];
                    }
                }
            }

            return [];
        case self::XPATH_NODE_NAME:
            if($nodeName === $arg) {
                return [
                    [ $nodeName, $node ]
                ];
            }

        default:
            return false;
        }
    }

    private static function walkUntilTagName(string $name, array $steps, array $node): array|false {
        $matches = [];

        if(isset($node[$name])) {
            foreach($node[$name] as $child) {
                $res = self::walkXpath($steps, $child, $name);

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

                $matches = array_merge($matches, $res);
            }
        }

        foreach($node as $groupName => $group) {
            if($groupName === self::VALUE_KEY || $groupName === self::ATTRIBUTES_KEY) {
                continue;
            }

            foreach($group as $child) {
                if(is_array($child)) {
                    $res = self::walkUntilTagName($name, $steps, $child);

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

                    $matches = array_merge($matches, $res);
                }
            }
        }

        return $matches;
    }

    private static function walkAllNodes(array $steps, array $node): array|false {
        $matches = [];

        foreach($node as $groupName => $group) {
            if($groupName === self::VALUE_KEY || $groupName === self::ATTRIBUTES_KEY) {
                continue;
            }

            foreach($group as $child) {
                $res = self::walkXpath($steps, $child, $groupName);

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

                $matches = array_merge($matches, $res);

                if(is_array($child)) {
                    $res = self::walkAllNodes($steps, $child);

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

                    $matches = array_merge($matches, $res);
                }
            }
        }

        return $matches;
    }
}

