<?php

class XmlHelper {

  /**
   * Convert a string of XML to an associative array.
   *
   * The converse of format_xml_elements().
   *
   * @param string|SimpleXmlElement $data
   *   The XML data to parse.
   * @param array $options
   *
   * @return array|bool
   *   An array representing the XML data, or FALSE if there was a failure.
   */
  public static function parseElements($data, array $options = array()) {
    $xml = static::normalizeDataToSimpleXml($data);

    $options += array(
      'simplify' => TRUE,
      'namespaces' => $xml->getNamespaces(TRUE),
    );

    $results = array(static::parseElement($xml, $options));
    if (!empty($options['simplify'])) {
      static::simplifyElements($results);
    }

    return $results;
  }

  public static function parseElement(SimpleXMLElement $element, array $options = array()) {
    $options += array(
      'simplify' => TRUE,
      'namespaces' => $element->getNamespaces(TRUE),
    );

    $result = array();
    $result['key'] = $element->getName();
    if (!empty($options['prefix'])) {
      $result['key'] = $options['prefix'] . ':' . $result['key'];
    }

    foreach ($element->attributes() as $attribute_key => $attribute_value) {
      $result['attributes'][$attribute_key] = (string) $attribute_value;
    }

    if (!empty($options['namespaces'])) {
      foreach (array_keys($options['namespaces']) as $namespace) {
        foreach ($element->attributes($namespace, TRUE) as $attribute_key => $attribute_value) {
          $result['attributes'][$namespace . ':' . $attribute_key] = (string) $attribute_value;
        }
      }
    }

    $children = array();
    foreach ($element->children() as $child) {
      $children[] = static::parseElement($child, $options);
    }
    if (!empty($options['namespaces'])) {
      foreach (array_keys($options['namespaces']) as $namespace) {
        foreach ($element->children($namespace, TRUE) as $child) {
          $children[] = static::parseElement($child, array('prefix' => $namespace) + $options);
        }
      }
    }

    if (!empty($children)) {
      if (!empty($options['simplify'])) {
        static::simplifyElements($children);
      }
      $result['value'] = $children;
    }
    else {
      $result['value'] = (string) $element;
      if (!empty($options['simplify'])) {
        $result['value'] = trim($result['value']);
      }
    }

    return $result;
  }

  public static function simplifyElements(array &$elements) {
    $key_indexes = array();
    foreach ($elements as $index => $element) {
      $key_indexes[$element['key']][] = $index;
    }

    foreach ($elements as $index => $element) {
      if (!is_numeric($index) || !is_array($element) || !isset($element['key'])) {
        continue;
      }
      if (count($key_indexes[$element['key']]) > 1) {
        continue;
      }

      if (!empty($element['attributes'])) {
        continue;
      }

      // Replace it in the array.
      $elements = ArrayHelper::spliceAssociativeValues($elements, array($element['key'] => $element['value']), $index);
      unset($elements[$index]);
    }
  }

  public static function convertToSimpleArray($data) {
    $xml = static::normalizeDataToSimpleXml($data);
    $array = json_decode(json_encode((array) $xml), 1);
    return array($xml->getName() => $array);
  }

  public static function isUrlSafe($url) {
    if (!is_string($url) || $url === '') {
      return FALSE;
    }

    $parts = @parse_url($url);
    if ($parts === FALSE || empty($parts['scheme']) || empty($parts['host'])) {
      return FALSE;
    }

    // Only http and https are permitted.
    if (!in_array(strtolower($parts['scheme']), array('http', 'https'), TRUE)) {
      return FALSE;
    }

    $host = strtolower($parts['host']);

    // Block well-known internal hostnames before DNS resolution.
    $blocked_hostnames = array('localhost', 'ip6-localhost', 'ip6-loopback', '0.0.0.0');
    if (in_array($host, $blocked_hostnames, TRUE)) {
      return FALSE;
    }

    // If the host is already a literal IP, validate it directly.
    if (filter_var($host, FILTER_VALIDATE_IP)) {
      return filter_var($host, FILTER_VALIDATE_IP,
        FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) !== FALSE;
    }

    // Resolve hostname to an IPv4 address.
    $ip = gethostbyname($host);
    // gethostbyname() returns the input unchanged when resolution fails.
    if ($ip === $host) {
      return FALSE;
    }

    // Block private (RFC 1918), loopback, link-local (169.254/16 — cloud
    // metadata endpoints), and other reserved ranges.
    return filter_var($ip, FILTER_VALIDATE_IP,
      FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) !== FALSE;
  }

  public static function normalizeDataToSimpleXml($data, $class_name = NULL, $options = LIBXML_NOCDATA) {
    if (is_object($data) && is_a($data, 'SimpleXMLElement')) {
      return $data;
    }
    elseif (valid_url($data, TRUE) || is_file($data)) {
      if (valid_url($data, TRUE) && !static::isUrlSafe($data)) {
        throw new Exception("URL not allowed: requests to private or reserved addresses are blocked.");
      }
      $prev = libxml_use_internal_errors(TRUE);
      $xml = simplexml_load_file($data, $class_name, $options);
      libxml_use_internal_errors($prev);
      if ($xml === FALSE) {
        throw new Exception("Unable to parse XML from $data");
      }
      else {
        return $xml;
      }
    }
    else {
      $xml = simplexml_load_string((string) render($data), $class_name, $options);
      if ($xml === FALSE) {
        throw new Exception("Unable to parse XML");
      }
      else {
        return $xml;
      }
    }
  }

}
