<?php
/**
 * Syntax-agnostic DOM facade.
 *
 * @package Harbor_EU_EInvoicing_For_WooCommerce
 */

defined( 'ABSPATH' ) || exit;

class HEIN_XML_Writer {

	private DOMDocument $doc;
	private DOMElement $root;
	private array $namespaces;

	/**
	 * A prefixed qname must be created with createElementNS: createElement( 'cbc:ID' ) does not
	 * bind the prefix and fails the XSD on every element
	 */
	public function __construct( string $root_qname, string $root_namespace, array $namespaces = [] ) {
		$this->namespaces = $namespaces;

		$this->doc               = new DOMDocument( '1.0', 'UTF-8' );
		$this->doc->formatOutput = true;

		$this->root = $this->doc->createElementNS( $root_namespace, $root_qname );

		foreach ( $namespaces as $prefix => $uri ) {
			$this->root->setAttributeNS( 'http://www.w3.org/2000/xmlns/', 'xmlns:' . $prefix, $uri );
		}

		$this->doc->appendChild( $this->root );
	}

	public function root(): DOMElement {
		return $this->root;
	}

	public function node( DOMElement $parent, string $qname ): DOMElement {
		$el = $this->create( $qname );
		$parent->appendChild( $el );

		return $el;
	}

	public function el( DOMElement $parent, string $qname, ?string $value = null, array $attributes = [] ): ?DOMElement {
		$value = self::xml_safe( $value );

		if ( '' === trim( (string) $value ) ) {
			return null;
		}

		$el = $this->create( $qname );
		$el->appendChild( $this->doc->createTextNode( (string) $value ) );

		foreach ( $attributes as $name => $attribute ) {
			$attribute = self::xml_safe( $attribute );

			if ( '' !== trim( (string) $attribute ) ) {
				$el->setAttribute( $name, (string) $attribute );
			}
		}

		$parent->appendChild( $el );

		return $el;
	}

	/**
	 * Order data reaches the document unfiltered (sanitize_text_field keeps C0 control characters,
	 * and supplier CSV imports carry them routinely). A single one makes the whole invoice
	 * non-well-formed, which an access point rejects at parse time before any schematron runs
	 */
	private static function xml_safe( ?string $value ): ?string {
		if ( null === $value || '' === $value ) {
			return $value;
		}

		$stripped = preg_replace(
			'/[^\x{9}\x{A}\x{D}\x{20}-\x{D7FF}\x{E000}-\x{FFFD}\x{10000}-\x{10FFFF}]/u',
			'',
			$value
		);

		// The /u pass returns null on malformed UTF-8, so fall back to a byte-wise strip of the
		// control characters rather than letting the original through unfiltered
		return null === $stripped
			? preg_replace( '/[\x00-\x08\x0B\x0C\x0E-\x1F]/', '', $value )
			: $stripped;
	}

	public function amount( DOMElement $parent, string $qname, ?string $value, string $currency ): ?DOMElement {
		return $this->el( $parent, $qname, $value, [ 'currencyID' => $currency ] );
	}

	public function qty( DOMElement $parent, string $qname, ?string $value, string $unit_code ): ?DOMElement {
		return $this->el( $parent, $qname, $value, [ 'unitCode' => $unit_code ] );
	}

	/**
	 * PEPPOL-EN16931-R008 fails the document for ANY childless, textless element. Pruning until
	 * the pass matches nothing also catches a container whose only child was itself pruned, which
	 * is what makes the rule structurally unreachable without a guard in every builder
	 */
	public function save(): string {
		$xpath = new DOMXPath( $this->doc );

		do {
			$removed = 0;

			foreach ( $xpath->query( '//*[not(*) and not(normalize-space())]' ) as $node ) {
				if ( $node !== $this->root ) {
					$node->remove();
					++$removed;
				}
			}
		} while ( $removed > 0 );

		return (string) $this->doc->saveXML();
	}

	private function create( string $qname ): DOMElement {
		$separator = strpos( $qname, ':' );

		if ( false === $separator ) {
			return $this->doc->createElement( $qname );
		}

		$prefix = substr( $qname, 0, $separator );

		return $this->doc->createElementNS( $this->namespaces[ $prefix ] ?? '', $qname );
	}
}
