<?php
/**
 * Link Inserter.
 *
 * Given an HTML fragment + phrase + target URL, wraps the FIRST
 * contextually-valid occurrence of the phrase in an `<a>` tag.
 *
 * "Contextually valid" means the occurrence is NOT inside any of:
 *   - existing <a> tag (avoid nested anchors)
 *   - h1–h6 (headings — bad anchor location for SEO)
 *   - <script>, <style>, <code>, <pre>
 *   - <button> (button labels shouldn't link)
 *
 * Works on plain HTML strings AND on Elementor JSON. For Elementor,
 * we walk the JSON tree, run the HTML inserter on every "editor" /
 * "text" / "html" widget setting, and stop at the first successful
 * insertion (per source post — controlled by the BatchProcessor).
 *
 * @package Platform\Helm\Modules\Link_Architect
 */

declare( strict_types = 1 );

namespace Platform\Helm\Modules\Link_Architect;

if ( ! defined( 'ABSPATH' ) ) {
	exit;
}

final class LinkInserter {

	/** Tags that block insertion when an occurrence falls inside them. */
	private const FORBIDDEN_ANCESTORS = [
		'a', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
		'script', 'style', 'code', 'pre', 'button',
	];

	/**
	 * Attempt to insert a link into an HTML fragment.
	 *
	 * @return array{html:string,modified:bool}
	 */
	public function insert_in_html( string $html, string $phrase, string $url ): array {
		$phrase = trim( $phrase );
		if ( '' === $phrase || '' === trim( $html ) ) {
			return [ 'html' => $html, 'modified' => false ];
		}

		// Quick reject — phrase not even present.
		if ( false === stripos( $html, $phrase ) ) {
			return [ 'html' => $html, 'modified' => false ];
		}

		$wrapped   = $this->wrap_for_parsing( $html );
		$prev_libxml = libxml_use_internal_errors( true );

		$dom = new \DOMDocument( '1.0', 'UTF-8' );
		$loaded = $dom->loadHTML( $wrapped, LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD );
		libxml_clear_errors();
		libxml_use_internal_errors( $prev_libxml );

		if ( ! $loaded ) {
			return [ 'html' => $html, 'modified' => false ];
		}

		$xpath = new \DOMXPath( $dom );
		$text_nodes = $xpath->query( '//text()' );
		if ( ! $text_nodes instanceof \DOMNodeList ) {
			return [ 'html' => $html, 'modified' => false ];
		}

		$modified = false;
		foreach ( $text_nodes as $node ) {
			if ( ! $node instanceof \DOMText ) {
				continue;
			}
			if ( $this->has_forbidden_ancestor( $node ) ) {
				continue;
			}
			$pos = $this->case_insensitive_strpos( $node->nodeValue ?? '', $phrase );
			if ( false === $pos ) {
				continue;
			}
			$this->wrap_text_node( $dom, $node, $pos, $phrase, $url );
			$modified = true;
			break; // First valid occurrence only.
		}

		if ( ! $modified ) {
			return [ 'html' => $html, 'modified' => false ];
		}

		$new_html = $this->unwrap_after_parsing( $dom );
		return [ 'html' => $new_html, 'modified' => true ];
	}

	/**
	 * Attempt to insert a link into a JSON-encoded Elementor data array.
	 * Walks every widget setting that holds HTML text and applies the
	 * same insertion logic. Stops at the first successful insertion.
	 *
	 * @return array{json:string,modified:bool}
	 */
	public function insert_in_elementor( string $em_json, string $phrase, string $url ): array {
		if ( '' === trim( $em_json ) ) {
			return [ 'json' => $em_json, 'modified' => false ];
		}
		$data = json_decode( $em_json, true );
		if ( ! is_array( $data ) ) {
			return [ 'json' => $em_json, 'modified' => false ];
		}

		$modified = false;
		$this->walk_elementor( $data, $phrase, $url, $modified );

		if ( ! $modified ) {
			return [ 'json' => $em_json, 'modified' => false ];
		}
		$encoded = wp_json_encode( $data, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE );
		if ( false === $encoded ) {
			return [ 'json' => $em_json, 'modified' => false ];
		}
		return [ 'json' => $encoded, 'modified' => true ];
	}

	/* =========================================================================
	 * DOM HELPERS
	 * ========================================================================= */

	/**
	 * loadHTML wants a document — but our input is a fragment. Wrap and tag
	 * so we can extract just the body content afterward.
	 */
	private function wrap_for_parsing( string $html ): string {
		// Mark with sentinels — easy to strip back out after parsing.
		return '<!DOCTYPE html><html><head><meta charset="UTF-8"></head><body data-phelm-root>'
			. '<!--PHELM_START-->' . $html . '<!--PHELM_END-->'
			. '</body></html>';
	}

	private function unwrap_after_parsing( \DOMDocument $dom ): string {
		$out = '';
		$xpath = new \DOMXPath( $dom );
		$body  = $xpath->query( '//body' );
		if ( $body && $body->length > 0 ) {
			$body_node = $body->item( 0 );
			if ( $body_node instanceof \DOMNode ) {
				foreach ( $body_node->childNodes as $child ) {
					$out .= $dom->saveHTML( $child );
				}
			}
		}
		// Strip our sentinels.
		$out = str_replace( [ '<!--PHELM_START-->', '<!--PHELM_END-->' ], '', $out );
		return $out;
	}

	private function has_forbidden_ancestor( \DOMNode $node ): bool {
		$cur = $node->parentNode;
		while ( $cur instanceof \DOMNode ) {
			$name = strtolower( (string) $cur->nodeName );
			if ( in_array( $name, self::FORBIDDEN_ANCESTORS, true ) ) {
				return true;
			}
			if ( 'body' === $name || 'html' === $name || '#document' === $name ) {
				return false;
			}
			$cur = $cur->parentNode;
		}
		return false;
	}

	/**
	 * Replace a text node with [text before] + <a>[phrase]</a> + [text after].
	 * Preserves the original casing of the matched substring.
	 */
	private function wrap_text_node( \DOMDocument $dom, \DOMText $node, int $pos, string $phrase, string $url ): void {
		$original = (string) $node->nodeValue;
		$len      = strlen( $phrase );

		$before = substr( $original, 0, $pos );
		$match  = substr( $original, $pos, $len );
		$after  = substr( $original, $pos + $len );

		$parent = $node->parentNode;
		if ( ! $parent instanceof \DOMNode ) {
			return;
		}

		$anchor = $dom->createElement( 'a' );
		$anchor->setAttribute( 'href', $url );
		$anchor->setAttribute( 'data-phelm-injected', '1' );
		$anchor->appendChild( $dom->createTextNode( $match ) );

		$fragment = $dom->createDocumentFragment();
		if ( '' !== $before ) {
			$fragment->appendChild( $dom->createTextNode( $before ) );
		}
		$fragment->appendChild( $anchor );
		if ( '' !== $after ) {
			$fragment->appendChild( $dom->createTextNode( $after ) );
		}

		$parent->replaceChild( $fragment, $node );
	}

	private function case_insensitive_strpos( string $haystack, string $needle ): int|false {
		return stripos( $haystack, $needle );
	}

	/* =========================================================================
	 * ELEMENTOR WALKER
	 * ========================================================================= */

	/** Setting keys we'll try to inject into (Elementor stores HTML in these). */
	private const ELEMENTOR_TEXT_KEYS = [ 'editor', 'text', 'html', 'description', 'content', 'description_text' ];

	private function walk_elementor( array &$node, string $phrase, string $url, bool &$modified ): void {
		if ( $modified ) {
			return;
		}
		// Element nodes have settings + elements.
		if ( isset( $node['settings'] ) && is_array( $node['settings'] ) ) {
			foreach ( self::ELEMENTOR_TEXT_KEYS as $key ) {
				if ( $modified ) {
					return;
				}
				if ( ! isset( $node['settings'][ $key ] ) || ! is_string( $node['settings'][ $key ] ) ) {
					continue;
				}
				$html = $node['settings'][ $key ];
				$res  = $this->insert_in_html( $html, $phrase, $url );
				if ( $res['modified'] ) {
					$node['settings'][ $key ] = $res['html'];
					$modified                  = true;
					return;
				}
			}
		}
		if ( isset( $node['elements'] ) && is_array( $node['elements'] ) ) {
			foreach ( $node['elements'] as &$child ) {
				if ( is_array( $child ) ) {
					$this->walk_elementor( $child, $phrase, $url, $modified );
					if ( $modified ) {
						return;
					}
				}
			}
			unset( $child );
		}
		// Top-level Elementor data is an array of sections, no settings at root.
		// Iterate numeric keys.
		foreach ( $node as $k => &$child ) {
			if ( $modified ) {
				return;
			}
			if ( ! is_int( $k ) ) {
				continue;
			}
			if ( is_array( $child ) ) {
				$this->walk_elementor( $child, $phrase, $url, $modified );
			}
		}
		unset( $child );
	}
}
