<?php
declare( strict_types = 1 );

namespace Wikimedia\Parsoid\Utils;

use Composer\Semver\Semver;
use stdClass;
use Wikimedia\Assert\Assert;
use Wikimedia\Assert\UnreachableException;
use Wikimedia\JsonCodec\Hint;
use Wikimedia\JsonCodec\JsonCodec;
use Wikimedia\Parsoid\Core\PageBundle;
use Wikimedia\Parsoid\DOM\Document;
use Wikimedia\Parsoid\DOM\Element;
use Wikimedia\Parsoid\DOM\Node;
use Wikimedia\Parsoid\NodeData\DataBag;
use Wikimedia\Parsoid\NodeData\DataMw;
use Wikimedia\Parsoid\NodeData\DataMwI18n;
use Wikimedia\Parsoid\NodeData\DataParsoid;
use Wikimedia\Parsoid\NodeData\I18nInfo;
use Wikimedia\Parsoid\NodeData\NodeData;
use Wikimedia\Parsoid\NodeData\TempData;

/**
 * These helpers pertain to HTML and data attributes of a node.
 */
class DOMDataUtils {
	public const DATA_OBJECT_ATTR_NAME = 'data-object-id';

	/**
	 * Return the dynamic "bag" property of a Document.
	 * @param Document $doc
	 * @return DataBag
	 */
	public static function getBag( Document $doc ): DataBag {
		// This is a dynamic property; it is not declared.
		// All references go through here so we can suppress phan's complaint.
		// @phan-suppress-next-line PhanUndeclaredProperty
		return $doc->bag;
	}

	/**
	 * Return the JsonCodec used for rich attributes in a Document.
	 * @param Document $doc
	 * @return JsonCodec
	 */
	public static function getCodec( Document $doc ): JsonCodec {
		// This is a dynamic property; it is not declared.
		// All references go through here so we can suppress phan's complaint.
		// @phan-suppress-next-line PhanUndeclaredProperty
		return $doc->codec;
	}

	public static function prepareDoc( Document $doc ): void {
		// `bag` is a deliberate dynamic property; see DOMDataUtils::getBag()
		// @phan-suppress-next-line PhanUndeclaredProperty dynamic property
		$doc->bag = new DataBag();
		// `codec` is a deliberate dynamic property; see DOMDataUtils::getCodec()
		// @phan-suppress-next-line PhanUndeclaredProperty dynamic property
		$doc->codec = new JsonCodec();

		// Cache the head and body.
		DOMCompat::getHead( $doc );
		DOMCompat::getBody( $doc );
	}

	/**
	 * @param Document $topLevelDoc
	 * @param Document $childDoc
	 */
	public static function prepareChildDoc( Document $topLevelDoc, Document $childDoc ) {
		// @phan-suppress-next-line PhanUndeclaredProperty dynamic property
		Assert::invariant( $topLevelDoc->bag instanceof DataBag, 'doc bag not set' );
		// @phan-suppress-next-line PhanUndeclaredProperty dynamic property
		$childDoc->bag = $topLevelDoc->bag;
		// @phan-suppress-next-line PhanUndeclaredProperty dynamic property
		$childDoc->codec = $topLevelDoc->codec;
	}

	/**
	 * Stash $obj in $doc and return an id for later retrieval
	 * @param Document $doc
	 * @param NodeData $obj
	 * @return int
	 */
	public static function stashObjectInDoc( Document $doc, NodeData $obj ): int {
		return self::getBag( $doc )->stashObject( $obj );
	}

	/**
	 * Does this node have any attributes?
	 * @param Element $node
	 * @return bool
	 */
	public static function noAttrs( Element $node ): bool {
		// The 'xmlns' attribute is "invisible" T235295
		if ( $node->hasAttribute( 'xmlns' ) ) {
			return false;
		}
		$numAttrs = count( $node->attributes );
		return $numAttrs === 0 ||
			( $numAttrs === 1 && $node->hasAttribute( self::DATA_OBJECT_ATTR_NAME ) );
	}

	/**
	 * Get data object from a node.
	 *
	 * @param Element $node node
	 * @return NodeData
	 */
	public static function getNodeData( Element $node ): NodeData {
		if ( !$node->hasAttribute( self::DATA_OBJECT_ATTR_NAME ) ) {
			// Initialized on first request
			$dataObject = new NodeData;
			self::setNodeData( $node, $dataObject );
			return $dataObject;
		}

		$nodeId = DOMCompat::getAttribute( $node, self::DATA_OBJECT_ATTR_NAME );
		if ( $nodeId !== null ) {
			$dataObject = self::getBag( $node->ownerDocument )->getObject( (int)$nodeId );
		} else {
			$dataObject = null; // Make phan happy
		}
		Assert::invariant( isset( $dataObject ), 'Bogus nodeId given!' );
		if ( isset( $dataObject->storedId ) ) {
			throw new UnreachableException(
				'Trying to fetch node data without loading!' .
				// If this node's data-object id is different from storedId,
				// it will indicate that the data-parsoid object was shared
				// between nodes without getting cloned. Useful for debugging.
				'Node id: ' . $nodeId .
				'Stored data: ' . PHPUtils::jsonEncode( $dataObject )
			);
		}
		return $dataObject;
	}

	/**
	 * Set node data.
	 *
	 * @param Element $node node
	 * @param NodeData $data data
	 */
	public static function setNodeData( Element $node, NodeData $data ): void {
		$nodeId = self::stashObjectInDoc( $node->ownerDocument, $data );
		$node->setAttribute( self::DATA_OBJECT_ATTR_NAME, (string)$nodeId );
	}

	/**
	 * Get data parsoid info from a node.
	 *
	 * @param Element $node node
	 * @return DataParsoid
	 */
	public static function getDataParsoid( Element $node ): DataParsoid {
		$data = self::getNodeData( $node );
		$data->parsoid ??= new DataParsoid;
		return $data->parsoid;
	}

	/**
	 * Set data parsoid info on a node.
	 *
	 * @param Element $node node
	 * @param DataParsoid $dp data-parsoid
	 */
	public static function setDataParsoid( Element $node, DataParsoid $dp ): void {
		$data = self::getNodeData( $node );
		$data->parsoid = $dp;
	}

	/**
	 * Returns the i18n information of a node. This is in private access because it shouldn't
	 * typically be used directly; instead getDataNodeI18n and getDataAttrI18n should be used.
	 * @param Element $node
	 * @return DataMwI18n|null
	 */
	private static function getDataMwI18n( Element $node ): ?DataMwI18n {
		$data = self::getNodeData( $node );
		// We won't set a default value for this property
		return $data->i18n ?? null;
	}

	/**
	 * Sets the i18n information of a node. This is in private access because it shouldn't
	 * typically be used directly; instead setDataNodeI18n and setDataAttrI18n should be used.
	 */
	private static function setDataMwI18n( Element $node, DataMwI18n $i18n ) {
		$data = self::getNodeData( $node );
		$data->i18n = $i18n;
	}

	/**
	 * Retrieves internationalization (i18n) information of a node (typically for localization)
	 * @param Element $node
	 * @return ?I18nInfo
	 */
	public static function getDataNodeI18n( Element $node ): ?I18nInfo {
		$data = self::getNodeData( $node );
		// We won't set a default value for this property
		if ( !isset( $data->i18n ) ) {
			return null;
		}
		return $data->i18n->getSpanInfo();
	}

	/**
	 * Sets internationalization (i18n) information of a node, used for later localization
	 */
	public static function setDataNodeI18n( Element $node, I18nInfo $i18n ) {
		$data = self::getNodeData( $node );
		$data->i18n ??= new DataMwI18n();
		$data->i18n->setSpanInfo( $i18n );
	}

	/**
	 * Retrieves internationalization (i18n) information of an attribute value (typically for
	 * localization)
	 * @param Element $node
	 * @param string $name
	 * @return ?I18nInfo
	 */
	public static function getDataAttrI18n( Element $node, string $name ): ?I18nInfo {
		$data = self::getNodeData( $node );
		// We won't set a default value for this property
		if ( !isset( $data->i18n ) ) {
			return null;
		}
		return $data->i18n->getAttributeInfo( $name );
	}

	/**
	 * Sets internationalization (i18n) information of a attribute value, used for later
	 * localization
	 * @param Element $node
	 * @param string $name
	 * @param I18nInfo $i18n
	 */
	public static function setDataAttrI18n( Element $node, string $name, I18nInfo $i18n ) {
		$data = self::getNodeData( $node );
		$data->i18n ??= new DataMwI18n();
		$data->i18n->setAttributeInfo( $name, $i18n );
	}

	/**
	 * @param Element $node
	 * @return array
	 */
	public static function getDataAttrI18nNames( Element $node ): array {
		$data = self::getNodeData( $node );
		// We won't set a default value for this property
		if ( !isset( $data->i18n ) ) {
			return [];
		}
		return $data->i18n->getAttributeNames();
	}

	/**
	 * Get data diff info from a node.
	 *
	 * @param Element $node node
	 * @return ?stdClass
	 */
	public static function getDataParsoidDiff( Element $node ): ?stdClass {
		$data = self::getNodeData( $node );
		// We won't set a default value for this property
		return $data->parsoid_diff ?? null;
	}

	/**
	 * Set data diff info on a node.
	 *
	 * @param Element $node node
	 * @param ?stdClass $diffObj data-parsoid-diff object
	 */
	public static function setDataParsoidDiff( Element $node, ?stdClass $diffObj ): void {
		$data = self::getNodeData( $node );
		$data->parsoid_diff = $diffObj;
	}

	/**
	 * Get data meta wiki info from a node.
	 *
	 * @param Element $node node
	 * @return DataMw
	 */
	public static function getDataMw( Element $node ): DataMw {
		$data = self::getNodeData( $node );
		$data->mw ??= new DataMw;
		return $data->mw;
	}

	/**
	 * Set data meta wiki info from a node.
	 *
	 * @param Element $node node
	 * @param ?DataMw $dmw data-mw
	 */
	public static function setDataMw( Element $node, ?DataMw $dmw ): void {
		$data = self::getNodeData( $node );
		$data->mw = $dmw;
	}

	/**
	 * Check if there is meta wiki info in a node.
	 *
	 * @param Element $node node
	 * @return bool
	 */
	public static function validDataMw( Element $node ): bool {
		return (array)self::getDataMw( $node ) !== [];
	}

	/**
	 * Check if there is i18n info on a node (for the node or its attributes)
	 * @param Element $node
	 * @return bool
	 */
	public static function validDataMwI18n( Element $node ): bool {
		return self::getDataMwI18n( $node ) !== null;
	}

	/**
	 * Get an object from a JSON-encoded XML attribute on a node.
	 *
	 * @param Element $node node
	 * @param string $name name
	 * @param mixed $defaultVal
	 * @return mixed
	 */
	public static function getJSONAttribute( Element $node, string $name, $defaultVal ) {
		$attVal = DOMCompat::getAttribute( $node, $name );
		if ( $attVal === null ) {
			return $defaultVal;
		}
		$decoded = PHPUtils::jsonDecode( $attVal, false );
		if ( $decoded !== null ) {
			return $decoded;
		} else {
			error_log( 'ERROR: Could not decode attribute-val ' . $attVal .
				' for ' . $name . ' on node ' . DOMCompat::nodeName( $node ) );
			return $defaultVal;
		}
	}

	/**
	 * Set a attribute on a node with a JSON-encoded object.
	 *
	 * @param Element $node node
	 * @param string $name Name of the attribute.
	 * @param mixed $obj value of the attribute to
	 */
	public static function setJSONAttribute( Element $node, string $name, $obj ): void {
		$val = $obj === [] ? '{}' : PHPUtils::jsonEncode( $obj );
		$node->setAttribute( $name, $val );
	}

	/**
	 * Set shadow info on a node; similar to the method on tokens.
	 * Records a key = value pair in data-parsoid['a'] property.
	 *
	 * This is effectively a call of 'setShadowInfoIfModified' except
	 * there is no original value, so by definition, $val is modified.
	 *
	 * @param Element $node node
	 * @param string $name Name of the attribute.
	 * @param mixed $val val
	 */
	public static function setShadowInfo( Element $node, string $name, $val ): void {
		$dp = self::getDataParsoid( $node );
		$dp->a ??= [];
		$dp->sa ??= [];
		$dp->a[$name] = $val;
	}

	/**
	 * Set shadow info on a node; similar to the method on tokens.
	 *
	 * If the new value ($val) for the key ($name) is different from the
	 * original value ($origVal):
	 * - the new value is recorded in data-parsoid->a and
	 * - the original value is recorded in data-parsoid->sa
	 *
	 * @param Element $node node
	 * @param string $name Name of the attribute.
	 * @param mixed $val val
	 * @param mixed $origVal original value (null is a valid value)
	 * @param bool $skipOrig
	 */
	public static function setShadowInfoIfModified(
		Element $node, string $name, $val, $origVal, bool $skipOrig = false
	): void {
		if ( !$skipOrig && ( $val === $origVal || $origVal === null ) ) {
			return;
		}
		$dp = self::getDataParsoid( $node );
		$dp->a ??= [];
		$dp->sa ??= [];
		// FIXME: This is a hack to not overwrite already shadowed info.
		// We should either fix the call site that depends on this
		// behaviour to do an explicit check, or double down on this
		// by porting it to the token method as well.
		if ( !$skipOrig && !array_key_exists( $name, $dp->a ) ) {
			$dp->sa[$name] = $origVal;
		}
		$dp->a[$name] = $val;
	}

	/**
	 * Set an attribute and shadow info to a node.
	 * Similar to the method on tokens
	 *
	 * @param Element $node node
	 * @param string $name Name of the attribute.
	 * @param mixed $val value
	 * @param mixed $origVal original value
	 * @param bool $skipOrig
	 */
	public static function addNormalizedAttribute(
		Element $node, string $name, $val, $origVal, bool $skipOrig = false
	): void {
		if ( $name === 'id' ) {
			DOMCompat::setIdAttribute( $node, $val );
		} else {
			$node->setAttribute( $name, $val );
		}
		self::setShadowInfoIfModified( $node, $name, $val, $origVal, $skipOrig );
	}

	/**
	 * Get this document's pagebundle object
	 * @param Document $doc
	 * @return PageBundle
	 */
	public static function getPageBundle( Document $doc ): PageBundle {
		return self::getBag( $doc )->getPageBundle();
	}

	/**
	 * Removes the `data-*` attribute from a node, and migrates the data to the
	 * document's JSON store. Generates a unique id with the following format:
	 * ```
	 * mw<base64-encoded counter>
	 * ```
	 * but attempts to keep user defined ids.
	 *
	 * TODO: Note that $data is effective a partial PageBundle containing
	 * only the 'parsoid' and 'mw' properties.
	 *
	 * @param Element $node node
	 * @param stdClass $data data
	 * @param array $idIndex Index of used id attributes in the DOM
	 */
	public static function storeInPageBundle(
		Element $node, stdClass $data, array $idIndex
	): void {
		$hints = self::getCodecHints();
		$uid = DOMCompat::getAttribute( $node, 'id' );
		$document = $node->ownerDocument;
		$pb = self::getPageBundle( $document );
		$codec = self::getCodec( $document );
		$docDp = &$pb->parsoid;
		$origId = $uid;
		if ( $uid !== null && array_key_exists( $uid, $docDp['ids'] ) ) {
			$uid = null;
		}
		if ( $uid === '' ) {
			$uid = null;
		}
		if ( $uid === null ) {
			do {
				$docDp['counter'] += 1;
				// PORT-FIXME: NOTE that we aren't updating the idIndex here because
				// we are generating unique ids that will not conflict. In any case,
				// the idIndex is a workaround for the PHP DOM's issues and we might
				// switch out of this in the future anyway.
				$uid = 'mw' . PHPUtils::counterToBase64( $docDp['counter'] );
			} while ( isset( $idIndex[$uid] ) );
			self::addNormalizedAttribute( $node, 'id', $uid, $origId );
		}
		// Convert from DataParsoid/DataMw objects to associative array
		$docDp['ids'][$uid] = $codec->toJsonArray( $data->parsoid, $hints['data-parsoid'] );
		if ( isset( $data->mw ) ) {
			$pb->mw['ids'][$uid] = $codec->toJsonArray( $data->mw, $hints['data-mw'] );
		}
	}

	/**
	 * Helper function to create static Hint objects for JsonCodec.
	 * @return array<Hint>
	 */
	public static function getCodecHints(): array {
		static $hints = null;
		if ( $hints === null ) {
			$hints = [
				'data-parsoid' => Hint::build( DataParsoid::class, Hint::ALLOW_OBJECT ),
				'data-mw' => Hint::build( DataMw::class, Hint::ALLOW_OBJECT ),
			];
		}
		return $hints;
	}

	/**
	 * @param Document $doc doc
	 * @param PageBundle $pb object
	 */
	public static function injectPageBundle( Document $doc, PageBundle $pb ): void {
		$script = DOMUtils::appendToHead( $doc, 'script', [
			'id' => 'mw-pagebundle',
			'type' => 'application/x-mw-pagebundle',
		] );
		$script->appendChild( $doc->createTextNode( $pb->encodeForHeadElement() ) );
	}

	/**
	 * @param Document $doc doc
	 * @return stdClass|null
	 */
	public static function extractPageBundle( Document $doc ): ?stdClass {
		$pb = null;
		$dpScriptElt = DOMCompat::getElementById( $doc, 'mw-pagebundle' );
		if ( $dpScriptElt ) {
			$dpScriptElt->parentNode->removeChild( $dpScriptElt );
			// we actually want arrays in the page bundle rather than stdClasses; but we still
			// want to access the object properties
			$pb = (object)PHPUtils::jsonDecode( $dpScriptElt->textContent );
		}
		return $pb;
	}

	/**
	 * Walk DOM from node downward calling loadDataAttribs
	 *
	 * @param Node $node node
	 * @param array $options options
	 */
	public static function visitAndLoadDataAttribs( Node $node, array $options = [] ): void {
		DOMUtils::visitDOM( $node, [ self::class, 'loadDataAttribs' ], $options );
	}

	/**
	 * These are intended be used on a document after post-processing, so that
	 * the underlying .dataobject is transparently applied (in the store case)
	 * and reloaded (in the load case), rather than worrying about keeping
	 * the attributes up-to-date throughout that phase.  For the most part,
	 * using this.ppTo* should be sufficient and using these directly should be
	 * avoided.
	 *
	 * @param Node $node node
	 * @param array $options options
	 */
	public static function loadDataAttribs( Node $node, array $options ): void {
		if ( !( $node instanceof Element ) ) {
			return;
		}
		// Reset the node data object's stored state, since we're reloading it
		self::setNodeData( $node, new NodeData );
		$codec = self::getCodec( $node->ownerDocument );
		$dataParsoidAttr = DOMCompat::getAttribute( $node, 'data-parsoid' );
		$dp = $codec->newFromJsonString(
			$dataParsoidAttr ?? '{}', self::getCodecHints()['data-parsoid']
		);
		if ( !empty( $options['markNew'] ) ) {
			$dp->setTempFlag( TempData::IS_NEW, $dataParsoidAttr === null );
		}
		self::setDataParsoid( $node, $dp );
		$node->removeAttribute( 'data-parsoid' );

		$dataMwAttr = DOMCompat::getAttribute( $node, 'data-mw' );
		$dmw = $dataMwAttr === null ? null :
			$codec->newFromJsonString( $dataMwAttr, self::getCodecHints()['data-mw'] );
		self::setDataMw( $node, $dmw );
		$node->removeAttribute( 'data-mw' );

		$dpd = self::getJSONAttribute( $node, 'data-parsoid-diff', null );
		self::setDataParsoidDiff( $node, $dpd );
		$node->removeAttribute( 'data-parsoid-diff' );
		$dataI18n = DOMCompat::getAttribute( $node, 'data-mw-i18n' );
		if ( $dataI18n !== null ) {
			$i18n = DataMwI18n::fromJson( PHPUtils::jsonDecode( $dataI18n, true ) );
			self::setDataMwI18n( $node, $i18n );
			$node->removeAttribute( 'data-mw-i18n' );
		}
	}

	/**
	 * Builds an index of id attributes seen in the DOM
	 * @param Node $node
	 * @return array
	 */
	public static function usedIdIndex( Node $node ): array {
		$index = [];
		DOMUtils::visitDOM( DOMCompat::getBody( $node->ownerDocument ),
			static function ( Node $n, ?array $options = null ) use ( &$index ) {
				if ( $n instanceof Element ) {
					$id = DOMCompat::getAttribute( $n, 'id' );
					if ( $id !== null ) {
						$index[$id] = true;
					}
				}
			},
			[]
		);
		return $index;
	}

	/**
	 * Walk DOM from node downward calling storeDataAttribs
	 *
	 * @param Node $node node
	 * @param array $options options
	 */
	public static function visitAndStoreDataAttribs( Node $node, array $options = [] ): void {
		// PORT-FIXME: storeDataAttribs calls storeInPageBundle which calls getElementById.
		// PHP's `getElementById` implementation is broken, and we work around that by
		// using Zest which uses XPath. So, getElementById call can be O(n) and calling it
		// on on every element of the DOM via vistDOM here makes it O(n^2) instead of O(n).
		// So, we work around that by building an index and avoiding getElementById entirely
		// in storeInPageBundle.
		if ( !empty( $options['storeInPageBundle'] ) ) {
			$options['idIndex'] = self::usedIdIndex( $node );
		}
		DOMUtils::visitDOM( $node, [ self::class, 'storeDataAttribs' ], $options );
	}

	/**
	 * Copy data attributes from the bag to either JSON-encoded attributes on
	 * each node, or the page bundle, erasing the data-object-id attributes.
	 *
	 * @param Node $node node
	 * @param ?array $options options
	 *   - discardDataParsoid: Discard DataParsoid objects instead of storing them
	 *   - keepTmp: Preserve DataParsoid::$tmp
	 *   - storeInPageBundle: If true, data will be stored in the page bundle
	 *     instead of data-parsoid and data-mw.
	 *   - env: The Env object required for various features
	 *   - idIndex: Array of used ID attributes
	 */
	public static function storeDataAttribs( Node $node, ?array $options = null ): void {
		$hints = self::getCodecHints();
		$options ??= [];
		if ( !( $node instanceof Element ) ) {
			return;
		}
		Assert::invariant( empty( $options['discardDataParsoid'] ) || empty( $options['keepTmp'] ),
			'Conflicting options: discardDataParsoid and keepTmp are both enabled.' );
		$codec = self::getCodec( $node->ownerDocument );
		$dp = self::getDataParsoid( $node );
		$discardDataParsoid = !empty( $options['discardDataParsoid'] );
		if ( $dp->getTempFlag( TempData::IS_NEW ) ) {
			// Only necessary to support the cite extension's getById,
			// that's already been loaded once.
			//
			// This is basically a hack to ensure that DOMUtils.isNewElt
			// continues to work since we effectively rely on the absence
			// of data-parsoid to identify new elements. But, loadDataAttribs
			// creates an empty {} if one doesn't exist. So, this hack
			// ensures that a loadDataAttribs + storeDataAttribs pair don't
			// dirty the node by introducing an empty data-parsoid attribute
			// where one didn't exist before.
			//
			// Ideally, we'll find a better solution for this edge case later.
			$discardDataParsoid = true;
		}
		$data = null;
		if ( !$discardDataParsoid ) {
			if ( empty( $options['keepTmp'] ) ) {
				// @phan-suppress-next-line PhanTypeObjectUnsetDeclaredProperty
				unset( $dp->tmp );
			}

			if ( !empty( $options['storeInPageBundle'] ) ) {
				$data ??= new stdClass;
				$data->parsoid = $dp;
			} else {
				$node->setAttribute(
					'data-parsoid',
					PHPUtils::jsonEncode(
						$codec->toJsonArray( $dp, $hints['data-parsoid'] )
					)
				);
			}
		}

		// Strip invalid data-mw attributes
		if ( self::validDataMw( $node ) ) {
			if (
				!empty( $options['storeInPageBundle'] ) && isset( $options['env'] ) &&
				// The pagebundle didn't have data-mw before 999.x
				Semver::satisfies( $options['env']->getOutputContentVersion(), '^999.0.0' )
			) {
				$data ??= new stdClass;
				$data->mw = self::getDataMw( $node );
			} else {
				$node->setAttribute(
					'data-mw',
					PHPUtils::jsonEncode(
						$codec->toJsonArray( self::getDataMw( $node ), $hints['data-mw'] )
					)
				);
			}
		}

		if ( self::validDataMwI18n( $node ) ) {
			self::setJSONAttribute( $node, 'data-mw-i18n', self::getDataMwI18n( $node ) );
		}

		// Store pagebundle
		if ( $data !== null ) {
			self::storeInPageBundle( $node, $data, $options['idIndex'] );
		}

		// Indicate that this node's data has been stored so that if we try
		// to access it after the fact we're aware and remove the attribute
		// since it's no longer needed.
		$nd = self::getNodeData( $node );
		$id = DOMCompat::getAttribute( $node, self::DATA_OBJECT_ATTR_NAME );
		$nd->storedId = $id !== null ? intval( $id ) : null;
		$node->removeAttribute( self::DATA_OBJECT_ATTR_NAME );
	}

	/**
	 * Clones a node and its data bag
	 * @param Element $elt
	 * @param bool $deep
	 * @return Element
	 */
	public static function cloneNode( Element $elt, bool $deep ): Element {
		$clone = $elt->cloneNode( $deep );
		'@phan-var Element $clone'; // @var Element $clone
		// We do not need to worry about $deep because a shallow clone does not have child nodes,
		// so it's always cloning data on the cloned tree (which may be empty).
		self::fixClonedData( $clone );
		return $clone;
	}

	/**
	 * Recursively fixes cloned data from $elt: to avoid conflicts of element IDs, we clone the
	 * data and set it in the node with a new element ID (which setNodeData does).
	 * @param Element $elt
	 */
	private static function fixClonedData( Element $elt ) {
		if ( $elt->hasAttribute( self::DATA_OBJECT_ATTR_NAME ) ) {
			self::setNodeData( $elt, self::getNodeData( $elt )->cloneNodeData() );
		}
		foreach ( $elt->childNodes as $child ) {
			if ( $child instanceof Element ) {
				self::fixClonedData( $child );
			}
		}
	}
}
