<?php

/**
 * Builder Content Support
 *
 * Provides read/write helpers for page-builder managed content.
 *
 * @package ProRank\SEO\Modules\Content\InternalLinking
 * @since   1.0.0
 */

declare(strict_types=1);

namespace ProRank\SEO\Modules\Content\InternalLinking;

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

class BuilderContentSupport {
    /**
     * Placeholder used when building link HTML from a matched anchor.
     */
    private const LINK_TEXT_PLACEHOLDER = '__PRORANK_LINK_TEXT__';
    /**
     * Known text settings keys used by builders.
     */
    private const TEXT_KEYS = [
        'editor',
        'text',
        'title',
        'description',
        'content',
        'caption',
        'sub_title',
        'subtitle',
        'heading',
        'heading_title',
        'tab_title',
        'tab_content',
        'message',
        'button_text',
        'html',
        'body',
        'blurb_content',
        'button_text',
        'subhead',
    ];

    /**
     * Known keys that often store link URLs in builder payloads.
     */
    private const LINK_KEYS = [
        'link',
        'url',
        'href',
        'button_link',
        'custom_link',
        'cta_link',
        'anchor_link',
        'permalink',
        'website',
        'button_url',
        'module_link',
        'background_link',
    ];

    /**
     * Key fragments that are URL-like but should not be treated as hyperlinks.
     */
    private const NON_LINK_KEY_PARTIALS = [
        'image',
        'icon',
        'video',
        'audio',
        'background',
        'thumbnail',
        'poster',
        'srcset',
        'attachment',
        'logo',
        'file',
    ];

    /**
     * Legacy Divi meta keys that can mirror content blobs.
     */
    private const DIVI_LEGACY_META_KEYS = [
        '_et_pb_old_content',
        '_et_pb_old_content_filtered',
    ];

    /**
     * Get combined post and builder content for analysis.
     *
     * @param int      $post_id Post ID.
     * @param \WP_Post $post    Optional post object.
     * @return string
     */
    public static function get_combined_content(int $post_id, ?\WP_Post $post = null): string {
        if ($post === null) {
            $post = get_post($post_id);
        }

        if (!$post instanceof \WP_Post) {
            return '';
        }

        $chunks = [];
        if (is_string($post->post_content) && $post->post_content !== '') {
            $chunks[] = $post->post_content;
        }

        $elementor_text = self::extract_elementor_content($post_id);
        if ($elementor_text !== '') {
            $chunks[] = $elementor_text;
        }

        $beaver_text = self::extract_beaver_content($post_id);
        if ($beaver_text !== '') {
            $chunks[] = $beaver_text;
        }

        $divi_text = self::extract_divi_content($post_id, $post);
        if ($divi_text !== '') {
            $chunks[] = $divi_text;
        }

        $chunks = array_values(array_filter(array_unique($chunks)));

        return implode("\n\n", $chunks);
    }

    /**
     * Extract all hyperlink URLs from native and supported builder content.
     *
     * @param int      $post_id Post ID.
     * @param \WP_Post $post    Optional post object.
     * @return array<int,string>
     */
    public static function extract_all_links(int $post_id, ?\WP_Post $post = null): array {
        if ($post === null) {
            $post = get_post($post_id);
        }

        if (!$post instanceof \WP_Post) {
            return [];
        }

        $links = [];

        if (is_string($post->post_content) && $post->post_content !== '') {
            self::collect_links_from_html($post->post_content, $links);
        }

        $elementor_raw = get_post_meta($post_id, '_elementor_data', true);
        if (is_string($elementor_raw) && $elementor_raw !== '') {
            $elementor_data = json_decode($elementor_raw, true);
            if (is_array($elementor_data)) {
                self::collect_links_from_tree($elementor_data, $links);
            }
        }

        $beaver_raw = get_post_meta($post_id, '_fl_builder_data', true);
        if (!empty($beaver_raw)) {
            $beaver_data = maybe_unserialize($beaver_raw);
            if (is_array($beaver_data)) {
                self::collect_links_from_tree($beaver_data, $links);
            }
        }

        if (self::is_divi_builder_content($post, $post_id)) {
            self::collect_links_from_shortcodes((string) $post->post_content, $links);

            foreach (self::DIVI_LEGACY_META_KEYS as $meta_key) {
                $legacy = get_post_meta($post_id, $meta_key, true);
                if (!is_string($legacy) || $legacy === '') {
                    continue;
                }

                if (str_contains($legacy, '<a')) {
                    self::collect_links_from_html($legacy, $links);
                }

                if (str_contains($legacy, '[et_pb_')) {
                    self::collect_links_from_shortcodes($legacy, $links);
                }
            }
        }

        return array_values(array_unique($links));
    }

    /**
     * Replace first anchor occurrence with a link in post or builder content.
     *
     * @param int    $post_id     Post ID.
     * @param string $anchor_text Anchor text.
     * @param string $target_url  Link URL.
     * @return bool
     */
    public static function replace_first_anchor_with_link(
        int $post_id,
        string $anchor_text,
        string $target_url,
        array $link_attributes = []
    ): bool {
        $anchor_text = trim($anchor_text);
        if ($post_id <= 0 || $anchor_text === '' || $target_url === '') {
            return false;
        }

        $attributes = [
            'href' => esc_url($target_url),
        ];

        $target = isset($link_attributes['target']) ? sanitize_key((string) $link_attributes['target']) : '';
        if ($target !== '' && in_array($target, ['_blank', '_self', '_parent', '_top'], true)) {
            $attributes['target'] = $target;
        }

        $rel_tokens = [];
        if (!empty($link_attributes['rel'])) {
            $raw_rel = preg_replace('/[^a-z0-9\\s_-]/i', ' ', (string) $link_attributes['rel']);
            $rel_tokens = array_values(array_unique(array_filter(
                preg_split('/\\s+/', strtolower(trim((string) $raw_rel))) ?: []
            )));
        }
        if (!empty($rel_tokens)) {
            $attributes['rel'] = implode(' ', $rel_tokens);
        }

        $title = isset($link_attributes['title']) ? sanitize_text_field((string) $link_attributes['title']) : '';
        if ($title !== '') {
            $attributes['title'] = $title;
        }

        $attribute_string = '';
        foreach ($attributes as $name => $value) {
            if ($value === '') {
                continue;
            }
            $attribute_string .= sprintf(' %s="%s"', esc_attr((string) $name), esc_attr((string) $value));
        }

        $link_template = '<a' . $attribute_string . '>' . self::LINK_TEXT_PLACEHOLDER . '</a>';

        // 1) Native post_content.
        $post = get_post($post_id);
        if ($post instanceof \WP_Post) {
            [$new_post_content, $count] = self::replace_in_text((string) $post->post_content, $anchor_text, $link_template);
            if ($count > 0 && $new_post_content !== $post->post_content) {
                if (!self::persist_post_content($post_id, $new_post_content)) {
                    return false;
                }

                // Keep Divi legacy mirrors aligned when we update shortcode-backed post_content.
                if (self::is_divi_builder_content($post, $post_id)) {
                    self::replace_in_divi_content($post_id, $anchor_text, $link_template);
                }
                return true;
            }
        }

        // 2) Elementor JSON data.
        if (self::replace_in_elementor_content($post_id, $anchor_text, $link_template)) {
            return true;
        }

        // 3) Beaver serialized builder data.
        if (self::replace_in_beaver_content($post_id, $anchor_text, $link_template)) {
            return true;
        }

        // 4) Divi legacy builder mirror field.
        if (self::replace_in_divi_content($post_id, $anchor_text, $link_template)) {
            return true;
        }

        return false;
    }

    /**
     * Append a small HTML block to supported content sources.
     *
     * This is used as a fallback when orphan fixing cannot find a safe anchor
     * to replace in the existing content.
     *
     * @param int    $post_id Post ID.
     * @param string $html    HTML block to append.
     * @return bool
     */
    public static function append_html_block(int $post_id, string $html): bool {
        $html = trim($html);
        if ($post_id <= 0 || $html === '') {
            return false;
        }

        if (self::append_to_elementor_content($post_id, $html)) {
            return true;
        }

        $post = get_post($post_id);
        if ($post instanceof \WP_Post) {
            $current_content = rtrim((string) $post->post_content);
            $separator = $current_content === '' ? '' : "\n\n";
            $updated_content = $current_content . $separator . $html;

            if (self::persist_post_content($post_id, $updated_content)) {
                if (self::is_divi_builder_content($post, $post_id)) {
                    self::append_to_divi_content($post_id, $html);
                }
                return true;
            }
        }

        return self::append_to_divi_content($post_id, $html);
    }

    /**
     * Persist updated post content.
     *
     * Uses the standard post update flow first, then falls back to a direct
     * database write when a site has broken save hooks that would otherwise
     * crash REST-based link insertion.
     *
     * @param int    $post_id      Post ID.
     * @param string $post_content Updated content.
     * @return bool
     */
    private static function persist_post_content(int $post_id, string $post_content): bool {
        global $wpdb;
        if (!($wpdb instanceof \wpdb)) {
            return false;
        }

        $updated = $wpdb->update(
            $wpdb->posts,
            [
                'post_content' => $post_content,
                'post_modified' => current_time('mysql'),
                'post_modified_gmt' => current_time('mysql', true),
            ],
            ['ID' => $post_id],
            ['%s', '%s', '%s'],
            ['%d']
        );

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

        clean_post_cache($post_id);
        return true;
    }

    /**
     * Append a text-editor widget to Elementor content.
     *
     * @param int    $post_id Post ID.
     * @param string $html    HTML block.
     * @return bool
     */
    private static function append_to_elementor_content(int $post_id, string $html): bool {
        $raw = get_post_meta($post_id, '_elementor_data', true);
        if (!is_string($raw) || $raw === '') {
            return false;
        }

        $data = json_decode($raw, true);
        if (!is_array($data)) {
            return false;
        }

        $widget = self::build_elementor_text_widget($html);
        if (!self::append_elementor_widget($data, $widget)) {
            $data[] = self::build_elementor_wrapper($widget, self::elementor_uses_containers($data));
        }

        update_post_meta($post_id, '_elementor_data', wp_slash(wp_json_encode($data)));
        delete_post_meta($post_id, '_elementor_css');
        clean_post_cache($post_id);

        return true;
    }

    /**
     * Append a simple HTML block to Divi mirror fields when present.
     *
     * @param int    $post_id Post ID.
     * @param string $html    HTML block.
     * @return bool
     */
    private static function append_to_divi_content(int $post_id, string $html): bool {
        $updated_any = false;

        foreach (self::DIVI_LEGACY_META_KEYS as $meta_key) {
            $legacy = get_post_meta($post_id, $meta_key, true);
            if (!is_string($legacy)) {
                continue;
            }

            $legacy = rtrim($legacy);
            $separator = $legacy === '' ? '' : "\n\n";
            update_post_meta($post_id, $meta_key, $legacy . $separator . $html);
            $updated_any = true;
        }

        if ($updated_any) {
            clean_post_cache($post_id);
        }

        return $updated_any;
    }

    /**
     * Append a new Elementor widget into the first suitable container/column.
     *
     * @param mixed $node   Mutable Elementor node or root array.
     * @param array $widget Widget payload.
     * @return bool
     */
    private static function append_elementor_widget(mixed &$node, array $widget): bool {
        if (!is_array($node)) {
            return false;
        }

        if (array_is_list($node)) {
            foreach ($node as &$child) {
                if (self::append_elementor_widget($child, $widget)) {
                    return true;
                }
            }
            unset($child);

            return false;
        }

        if (!isset($node['elements']) || !is_array($node['elements'])) {
            return false;
        }

        foreach ($node['elements'] as &$child) {
            if (self::append_elementor_widget($child, $widget)) {
                return true;
            }
        }
        unset($child);

        $element_type = (string) ($node['elType'] ?? '');
        if (in_array($element_type, ['container', 'column'], true)) {
            $node['elements'][] = $widget;
            return true;
        }

        return false;
    }

    /**
     * Determine whether Elementor content uses flexbox containers.
     *
     * @param array $data Root Elementor data array.
     * @return bool
     */
    private static function elementor_uses_containers(array $data): bool {
        foreach ($data as $node) {
            if (!is_array($node)) {
                continue;
            }

            if (($node['elType'] ?? '') === 'container') {
                return true;
            }

            if (!empty($node['elements']) && is_array($node['elements']) && self::elementor_uses_containers($node['elements'])) {
                return true;
            }
        }

        return false;
    }

    /**
     * Build a minimal Elementor widget payload.
     *
     * @param string $html HTML block.
     * @return array
     */
    private static function build_elementor_text_widget(string $html): array {
        return [
            'id' => self::generate_elementor_id(),
            'elType' => 'widget',
            'widgetType' => 'text-editor',
            'settings' => [
                'editor' => wp_kses_post($html),
            ],
            'elements' => [],
        ];
    }

    /**
     * Build a root Elementor wrapper for a newly appended widget.
     *
     * @param array $widget         Widget payload.
     * @param bool  $use_container  Whether the document uses container layout.
     * @return array
     */
    private static function build_elementor_wrapper(array $widget, bool $use_container): array {
        if ($use_container) {
            return [
                'id' => self::generate_elementor_id(),
                'elType' => 'container',
                'settings' => [],
                'elements' => [$widget],
                'isInner' => false,
            ];
        }

        return [
            'id' => self::generate_elementor_id(),
            'elType' => 'section',
            'settings' => [],
            'elements' => [
                [
                    'id' => self::generate_elementor_id(),
                    'elType' => 'column',
                    'settings' => [
                        '_column_size' => 100,
                    ],
                    'elements' => [$widget],
                ],
            ],
        ];
    }

    /**
     * Generate a short Elementor element identifier.
     *
     * @return string
     */
    private static function generate_elementor_id(): string {
        return substr(str_replace('-', '', wp_generate_uuid4()), 0, 8);
    }

    /**
     * Extract text from Elementor data.
     *
     * @param int $post_id Post ID.
     * @return string
     */
    private static function extract_elementor_content(int $post_id): string {
        $raw = get_post_meta($post_id, '_elementor_data', true);
        if (!is_string($raw) || $raw === '') {
            return '';
        }

        $data = json_decode($raw, true);
        if (!is_array($data)) {
            return '';
        }

        $chunks = self::extract_text_chunks($data);
        if (empty($chunks)) {
            return '';
        }

        return implode("\n\n", $chunks);
    }

    /**
     * Extract text from Beaver Builder data.
     *
     * @param int $post_id Post ID.
     * @return string
     */
    private static function extract_beaver_content(int $post_id): string {
        $raw = get_post_meta($post_id, '_fl_builder_data', true);
        if (empty($raw)) {
            return '';
        }

        $data = maybe_unserialize($raw);
        if (!is_array($data)) {
            return '';
        }

        $chunks = self::extract_text_chunks($data);
        if (empty($chunks)) {
            return '';
        }

        return implode("\n\n", $chunks);
    }

    /**
     * Extract text from Divi builder shortcodes.
     *
     * @param int      $post_id Post ID.
     * @param \WP_Post $post    Optional post object.
     * @return string
     */
    private static function extract_divi_content(int $post_id, ?\WP_Post $post = null): string {
        if ($post === null) {
            $post = get_post($post_id);
        }

        if (!$post instanceof \WP_Post || !self::is_divi_builder_content($post, $post_id)) {
            return '';
        }

        $chunks = self::extract_text_chunks_from_shortcodes((string) $post->post_content);

        foreach (self::DIVI_LEGACY_META_KEYS as $meta_key) {
            $legacy = get_post_meta($post_id, $meta_key, true);
            if (!is_string($legacy) || $legacy === '') {
                continue;
            }

            $legacy_text = trim(wp_strip_all_tags($legacy));
            if ($legacy_text !== '') {
                $chunks[] = $legacy_text;
            }

            if (str_contains($legacy, '[et_pb_')) {
                $chunks = array_merge($chunks, self::extract_text_chunks_from_shortcodes($legacy));
            }
        }

        $chunks = array_values(array_unique(array_filter(array_map('trim', $chunks))));
        if (empty($chunks)) {
            return '';
        }

        return implode("\n\n", $chunks);
    }

    /**
     * Replace first anchor in Elementor data.
     *
     * @param int    $post_id     Post ID.
     * @param string $anchor_text Anchor text.
     * @param string $link_html   Replacement link.
     * @return bool
     */
    private static function replace_in_elementor_content(int $post_id, string $anchor_text, string $link_html): bool {
        $raw = get_post_meta($post_id, '_elementor_data', true);
        if (!is_string($raw) || $raw === '') {
            return false;
        }

        $data = json_decode($raw, true);
        if (!is_array($data)) {
            return false;
        }

        $replaced = self::replace_in_tree($data, $anchor_text, $link_html);
        if (!$replaced) {
            return false;
        }

        update_post_meta($post_id, '_elementor_data', wp_slash(wp_json_encode($data)));
        delete_post_meta($post_id, '_elementor_css');
        clean_post_cache($post_id);

        return true;
    }

    /**
     * Replace first anchor in Beaver data.
     *
     * @param int    $post_id     Post ID.
     * @param string $anchor_text Anchor text.
     * @param string $link_html   Replacement link.
     * @return bool
     */
    private static function replace_in_beaver_content(int $post_id, string $anchor_text, string $link_html): bool {
        $raw = get_post_meta($post_id, '_fl_builder_data', true);
        if (empty($raw)) {
            return false;
        }

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

        $replaced = self::replace_in_tree($data, $anchor_text, $link_html);
        if (!$replaced) {
            return false;
        }

        update_post_meta($post_id, '_fl_builder_data', $data);
        clean_post_cache($post_id);

        return true;
    }

    /**
     * Replace first anchor in Divi legacy content mirror.
     *
     * Divi stores builder shortcodes in post_content, but some installs keep
     * mirrored blobs in legacy meta fields. Update mirrors as fallback.
     *
     * @param int    $post_id     Post ID.
     * @param string $anchor_text Anchor text.
     * @param string $link_html   Replacement link.
     * @return bool
     */
    private static function replace_in_divi_content(int $post_id, string $anchor_text, string $link_html): bool {
        $updated_any = false;

        foreach (self::DIVI_LEGACY_META_KEYS as $meta_key) {
            $legacy = get_post_meta($post_id, $meta_key, true);
            if (!is_string($legacy) || $legacy === '') {
                continue;
            }

            [$updated, $count] = self::replace_in_text($legacy, $anchor_text, $link_html);
            if ($count === 0 || $updated === $legacy) {
                continue;
            }

            update_post_meta($post_id, $meta_key, $updated);
            $updated_any = true;
        }

        if ($updated_any) {
            clean_post_cache($post_id);
        }

        return $updated_any;
    }

    /**
     * Recursively extract candidate text chunks.
     *
     * @param mixed $node Node data.
     * @return array
     */
    private static function extract_text_chunks(mixed $node): array {
        $chunks = [];

        if (!is_array($node)) {
            return $chunks;
        }

        foreach ($node as $key => $value) {
            if (is_array($value)) {
                $chunks = array_merge($chunks, self::extract_text_chunks($value));
                continue;
            }

            if (!is_string($value)) {
                continue;
            }

            if (!self::is_text_key((string) $key)) {
                continue;
            }

            $text = trim(wp_strip_all_tags($value));
            if ($text === '') {
                continue;
            }

            $chunks[] = $text;
        }

        return array_values(array_unique($chunks));
    }

    /**
     * Recursively replace the first matching anchor in builder tree.
     *
     * @param mixed  $node        Mutable node data.
     * @param string $anchor_text Anchor text.
     * @param string $link_html   Link replacement.
     * @return bool
     */
    private static function replace_in_tree(mixed &$node, string $anchor_text, string $link_html): bool {
        if (!is_array($node)) {
            return false;
        }

        foreach ($node as $key => &$value) {
            if (is_array($value)) {
                if (self::replace_in_tree($value, $anchor_text, $link_html)) {
                    return true;
                }
                continue;
            }

            if (!is_string($value) || !self::is_text_key((string) $key)) {
                continue;
            }

            [$updated, $count] = self::replace_in_text($value, $anchor_text, $link_html);
            if ($count > 0) {
                $value = $updated;
                return true;
            }
        }
        unset($value);

        return false;
    }

    /**
     * Replace first plain-text occurrence in a string.
     *
     * @param string $text        Source text.
     * @param string $anchor_text Anchor text.
     * @param string $link_html   Link replacement.
     * @return array{0:string,1:int}
     */
    private static function replace_in_text(string $text, string $anchor_text, string $link_template): array {
        if (self::contains_html_markup($text)) {
            return self::replace_in_html_text($text, $anchor_text, $link_template);
        }

        $pattern = self::build_anchor_pattern($anchor_text);
        if ($pattern === null || preg_match($pattern, $text, $matches) !== 1) {
            return [$text, 0];
        }

        $replacement = self::build_link_html($link_template, (string) ($matches[0] ?? $anchor_text));
        $result = preg_replace($pattern, $replacement, $text, 1, $count);

        if (!is_string($result)) {
            return [$text, 0];
        }

        return [$result, (int) $count];
    }

    /**
     * Determine if a text blob contains HTML markup.
     *
     * @param string $text Source text.
     * @return bool
     */
    private static function contains_html_markup(string $text): bool {
        return str_contains($text, '<') && preg_match('/<[^>]+>/', $text) === 1;
    }

    /**
     * Replace anchor text in HTML while avoiding risky contexts.
     *
     * @param string $html        HTML content.
     * @param string $anchor_text Anchor text.
     * @param string $link_html   Link replacement.
     * @return array{0:string,1:int}
     */
    private static function replace_in_html_text(string $html, string $anchor_text, string $link_template): array {
        $pattern = self::build_anchor_pattern($anchor_text);
        if ($pattern === null) {
            return [$html, 0];
        }

        $count = 0;

        $dom = new \DOMDocument();
        $wrapped = '<div id="__prorank_builder_wrap__">' . $html . '</div>';

        libxml_use_internal_errors(true);
        $loaded = $dom->loadHTML('<?xml encoding="utf-8" ?>' . $wrapped, LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD);
        libxml_clear_errors();

        if (!$loaded) {
            return [$html, 0];
        }

        $wrapper = $dom->getElementById('__prorank_builder_wrap__');
        if (!$wrapper instanceof \DOMElement) {
            return [$html, 0];
        }

        $xpath = new \DOMXPath($dom);
        $text_nodes = $xpath->query('//*[@id="__prorank_builder_wrap__"]//text()');
        if (!$text_nodes instanceof \DOMNodeList) {
            return [$html, 0];
        }

        foreach ($text_nodes as $text_node) {
            if ($count > 0) {
                break;
            }

            if (!($text_node instanceof \DOMText) || self::should_skip_text_node($text_node)) {
                continue;
            }

            $value = $text_node->nodeValue ?? '';
            if ($value === '' || preg_match($pattern, $value, $matches, PREG_OFFSET_CAPTURE) !== 1) {
                continue;
            }

            $matched = $matches[0][0] ?? '';
            $offset = (int) ($matches[0][1] ?? -1);
            if ($matched === '' || $offset < 0) {
                continue;
            }

            $before = substr($value, 0, $offset);
            $after = substr($value, $offset + strlen($matched));

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

            if ($before !== '') {
                $parent->insertBefore($dom->createTextNode($before), $text_node);
            }

            $link_html = self::build_link_html($link_template, $matched);
            $fragment = $dom->createDocumentFragment();
            if (!$fragment->appendXML($link_html)) {
                // Fallback to plain replacement for this node only.
                $replaced = preg_replace($pattern, $link_html, $value, 1, $node_count);
                if (is_string($replaced) && $node_count > 0) {
                    $fragment = $dom->createDocumentFragment();
                    if ($fragment->appendXML($replaced)) {
                        $parent->insertBefore($fragment, $text_node);
                        $parent->removeChild($text_node);
                        $count = 1;
                    }
                }
                continue;
            }

            $parent->insertBefore($fragment, $text_node);

            if ($after !== '') {
                $parent->insertBefore($dom->createTextNode($after), $text_node);
            }

            $parent->removeChild($text_node);
            $count = 1;
        }

        if ($count === 0) {
            return [$html, 0];
        }

        $result = '';
        foreach ($wrapper->childNodes as $child) {
            $result .= $dom->saveHTML($child);
        }

        return [$result, 1];
    }

    /**
     * Build a tolerant regex pattern for anchor matching.
     *
     * Allows whitespace and common punctuation variants between words so
     * suggestions such as "mobile first indexing" can still match
     * "mobile-first indexing" in the source content.
     *
     * @param string $anchor_text Anchor text to match.
     * @return string|null
     */
    private static function build_anchor_pattern(string $anchor_text): ?string {
        $normalized = html_entity_decode(trim($anchor_text), ENT_QUOTES | ENT_HTML5, 'UTF-8');
        if ($normalized === '') {
            return null;
        }

        $tokens = preg_split('/\s+/u', $normalized, -1, PREG_SPLIT_NO_EMPTY);
        if (empty($tokens)) {
            return null;
        }

        $escaped = array_map(
            static fn (string $token): string => preg_quote($token, '/'),
            $tokens
        );

        $joiner = '(?:[\s\x{00A0}]+|[\-–—\'"’“”.,;:!?()]+)+';

        return '/' . implode($joiner, $escaped) . '/iu';
    }

    /**
     * Insert the actual matched text into the prepared link template.
     *
     * @param string $link_template Template containing the placeholder token.
     * @param string $matched_text  Exact text matched in source content.
     * @return string
     */
    private static function build_link_html(string $link_template, string $matched_text): string {
        return str_replace(
            self::LINK_TEXT_PLACEHOLDER,
            esc_html($matched_text),
            $link_template
        );
    }

    /**
     * Skip nodes where auto-link replacement should never occur.
     *
     * @param \DOMNode $node Current text node.
     * @return bool
     */
    private static function should_skip_text_node(\DOMNode $node): bool {
        $skip_tags = ['a', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'nav', 'header', 'footer', 'button', 'script', 'style', 'code', 'pre'];
        $current = $node->parentNode;

        while ($current instanceof \DOMNode) {
            if ($current instanceof \DOMElement && in_array(strtolower($current->tagName), $skip_tags, true)) {
                return true;
            }
            $current = $current->parentNode;
        }

        return false;
    }

    /**
     * Collect href values from HTML content.
     *
     * @param string          $html  Raw HTML.
     * @param array<int,mixed> $links Mutable links buffer.
     * @return void
     */
    private static function collect_links_from_html(string $html, array &$links): void {
        if ($html === '') {
            return;
        }

        preg_match_all('/<a[^>]+href=[\'"]([^\'"]+)[\'"][^>]*>/i', $html, $matches);
        foreach (($matches[1] ?? []) as $href) {
            self::add_link_candidate($links, (string) $href);
        }
    }

    /**
     * Recursively collect URL candidates from builder payloads.
     *
     * @param mixed            $node       Current node.
     * @param array<int,mixed> $links      Mutable links buffer.
     * @param string|null      $parent_key Parent node key.
     * @return void
     */
    private static function collect_links_from_tree(mixed $node, array &$links, ?string $parent_key = null): void {
        if (!is_array($node)) {
            if (is_string($node) && $parent_key !== null) {
                if (str_contains($node, '<a')) {
                    self::collect_links_from_html($node, $links);
                } elseif (self::is_link_key($parent_key) && self::looks_like_link($node)) {
                    self::add_link_candidate($links, $node);
                }
            }
            return;
        }

        if (
            isset($node['url']) &&
            is_string($node['url']) &&
            $parent_key !== null &&
            self::is_link_key($parent_key)
        ) {
            self::add_link_candidate($links, $node['url']);
        }

        foreach ($node as $key => $value) {
            $key_name = is_string($key) ? $key : '';

            if (is_array($value)) {
                self::collect_links_from_tree($value, $links, $key_name);
                continue;
            }

            if (!is_string($value) || $value === '') {
                continue;
            }

            if (str_contains($value, '<a')) {
                self::collect_links_from_html($value, $links);
                continue;
            }

            if (self::is_link_key($key_name) && self::looks_like_link($value)) {
                self::add_link_candidate($links, $value);
            }
        }
    }

    /**
     * Recursively collect URL candidates from shortcode attributes.
     *
     * @param string           $content Shortcode-bearing content.
     * @param array<int,mixed> $links   Mutable links buffer.
     * @param int              $depth   Recursion depth limiter.
     * @return void
     */
    private static function collect_links_from_shortcodes(string $content, array &$links, int $depth = 0): void {
        if ($content === '' || $depth > 6) {
            return;
        }

        $shortcodes = self::match_shortcodes($content);
        if (empty($shortcodes)) {
            self::collect_links_from_divi_attributes($content, $links);
            return;
        }

        foreach ($shortcodes as $shortcode) {
            $atts = shortcode_parse_atts((string) ($shortcode[3] ?? ''));
            if (is_array($atts)) {
                foreach ($atts as $attr_key => $attr_value) {
                    if (!is_string($attr_key) || !is_string($attr_value) || $attr_value === '') {
                        continue;
                    }

                    if (self::is_link_key($attr_key) && self::looks_like_link($attr_value)) {
                        self::add_link_candidate($links, $attr_value);
                    }

                    if (str_contains($attr_value, '<a')) {
                        self::collect_links_from_html($attr_value, $links);
                    }
                }
            }

            $inner = (string) ($shortcode[5] ?? '');
            if ($inner !== '') {
                if (str_contains($inner, '<a')) {
                    self::collect_links_from_html($inner, $links);
                }
                self::collect_links_from_shortcodes($inner, $links, $depth + 1);
            }
        }
    }

    /**
     * Extract plain-text chunks from nested shortcodes.
     *
     * @param string $content Shortcode-bearing content.
     * @param int    $depth   Recursion depth limiter.
     * @return array<int,string>
     */
    private static function extract_text_chunks_from_shortcodes(string $content, int $depth = 0): array {
        $chunks = [];

        if ($content === '' || $depth > 6) {
            return $chunks;
        }

        $shortcodes = self::match_shortcodes($content);
        if (empty($shortcodes)) {
            return self::extract_text_chunks_from_divi_fallback($content);
        }

        foreach ($shortcodes as $shortcode) {
            $atts = shortcode_parse_atts((string) ($shortcode[3] ?? ''));
            if (is_array($atts)) {
                foreach ($atts as $attr_key => $attr_value) {
                    if (!is_string($attr_key) || !is_string($attr_value) || !self::is_text_key($attr_key)) {
                        continue;
                    }

                    $text = trim(wp_strip_all_tags($attr_value));
                    if ($text !== '') {
                        $chunks[] = $text;
                    }
                }
            }

            $inner = (string) ($shortcode[5] ?? '');
            if ($inner !== '') {
                $plain_inner = trim(wp_strip_all_tags(function_exists('strip_shortcodes') ? strip_shortcodes($inner) : $inner));
                if ($plain_inner !== '') {
                    $chunks[] = $plain_inner;
                }

                $chunks = array_merge($chunks, self::extract_text_chunks_from_shortcodes($inner, $depth + 1));
            }
        }

        return array_values(array_unique(array_filter(array_map('trim', $chunks))));
    }

    /**
     * Match nested shortcodes with WordPress regex and Divi fallback support.
     *
     * @param string $content Shortcode-bearing content.
     * @return array<int,array<int|string,mixed>>
     */
    private static function match_shortcodes(string $content): array {
        if ($content === '') {
            return [];
        }

        if (function_exists('get_shortcode_regex')) {
            $pattern = '/' . get_shortcode_regex() . '/';
            $matched = preg_match_all($pattern, $content, $shortcodes, PREG_SET_ORDER);
            if ($matched !== false && $matched > 0 && is_array($shortcodes)) {
                return $shortcodes;
            }
        }

        return [];
    }

    /**
     * Collect Divi shortcode attribute links when tags are not registered.
     *
     * @param string           $content Content containing Divi shortcode syntax.
     * @param array<int,mixed> $links   Mutable links buffer.
     * @return void
     */
    private static function collect_links_from_divi_attributes(string $content, array &$links): void {
        if ($content === '') {
            return;
        }

        preg_match_all('/\\[(?!\\/)(et_pb_[a-z0-9_-]+)([^\\]]*)\\]/i', $content, $modules, PREG_SET_ORDER);
        if (empty($modules)) {
            return;
        }

        foreach ($modules as $module) {
            $atts = shortcode_parse_atts((string) ($module[2] ?? ''));
            if (!is_array($atts)) {
                continue;
            }

            foreach ($atts as $attr_key => $attr_value) {
                if (!is_string($attr_key) || !is_string($attr_value) || $attr_value === '') {
                    continue;
                }

                if (self::is_link_key($attr_key) && self::looks_like_link($attr_value)) {
                    self::add_link_candidate($links, $attr_value);
                }

                if (str_contains($attr_value, '<a')) {
                    self::collect_links_from_html($attr_value, $links);
                }
            }
        }
    }

    /**
     * Extract text chunks from Divi shortcode content when shortcode tags are unregistered.
     *
     * @param string $content Content containing Divi shortcodes.
     * @return array<int,string>
     */
    private static function extract_text_chunks_from_divi_fallback(string $content): array {
        $chunks = [];

        preg_match_all('/\\[(?!\\/)(et_pb_[a-z0-9_-]+)([^\\]]*)\\]/i', $content, $modules, PREG_SET_ORDER);
        foreach ($modules as $module) {
            $atts = shortcode_parse_atts((string) ($module[2] ?? ''));
            if (!is_array($atts)) {
                continue;
            }

            foreach ($atts as $attr_key => $attr_value) {
                if (!is_string($attr_key) || !is_string($attr_value) || !self::is_text_key($attr_key)) {
                    continue;
                }

                $text = trim(wp_strip_all_tags($attr_value));
                if ($text !== '') {
                    $chunks[] = $text;
                }
            }
        }

        $without_tags = preg_replace('/\\[\\/?et_pb_[^\\]]+\\]/i', ' ', $content);
        if (!is_string($without_tags)) {
            $without_tags = $content;
        }

        $plain = trim(wp_strip_all_tags($without_tags));
        if ($plain !== '') {
            $plain = preg_replace('/\\s+/', ' ', $plain);
            if (is_string($plain) && trim($plain) !== '') {
                $chunks[] = trim($plain);
            }
        }

        return array_values(array_unique(array_filter(array_map('trim', $chunks))));
    }

    /**
     * Add a normalized URL candidate to link buffer.
     *
     * @param array<int,mixed> $links Mutable links buffer.
     * @param string           $value Candidate URL.
     * @return void
     */
    private static function add_link_candidate(array &$links, string $value): void {
        $normalized = self::normalize_link_candidate($value);
        if ($normalized !== null) {
            $links[] = $normalized;
        }
    }

    /**
     * Normalize and validate a URL-like string.
     *
     * @param string $url Raw candidate URL.
     * @return string|null
     */
    private static function normalize_link_candidate(string $url): ?string {
        $url = trim(html_entity_decode($url));
        if ($url === '') {
            return null;
        }

        if (
            str_starts_with($url, '#') ||
            str_starts_with($url, 'mailto:') ||
            str_starts_with($url, 'tel:') ||
            str_starts_with($url, 'javascript:') ||
            str_starts_with($url, 'data:')
        ) {
            return null;
        }

        return $url;
    }

    /**
     * Determine whether a key likely contains a hyperlink reference.
     *
     * @param string $key Node key.
     * @return bool
     */
    private static function is_link_key(string $key): bool {
        $key = strtolower(trim($key));
        if ($key === '') {
            return false;
        }

        foreach (self::NON_LINK_KEY_PARTIALS as $partial) {
            if (str_contains($key, $partial)) {
                return false;
            }
        }

        if (in_array($key, self::LINK_KEYS, true)) {
            return true;
        }

        if (
            str_contains($key, 'link') ||
            str_contains($key, 'url') ||
            str_contains($key, 'href')
        ) {
            return true;
        }

        return false;
    }

    /**
     * Basic URL-shape check for builder string values.
     *
     * @param string $value Candidate value.
     * @return bool
     */
    private static function looks_like_link(string $value): bool {
        $value = trim($value);
        if ($value === '') {
            return false;
        }

        if (preg_match('#^(https?:)?//#i', $value) === 1) {
            return true;
        }

        if (str_starts_with($value, '/')) {
            return true;
        }

        if (preg_match('~^[^\\s]+\\.[a-z]{2,}(?:[/?#].*)?$~i', $value) === 1) {
            return true;
        }

        return false;
    }

    /**
     * Determine whether a post appears to be managed by Divi.
     *
     * @param \WP_Post $post    Post object.
     * @param int      $post_id Post ID.
     * @return bool
     */
    private static function is_divi_builder_content(\WP_Post $post, int $post_id): bool {
        $use_builder = get_post_meta($post_id, '_et_pb_use_builder', true);
        if ($use_builder === 'on' || $use_builder === '1') {
            return true;
        }

        $content = (string) $post->post_content;
        return str_contains($content, '[et_pb_');
    }

    /**
     * Check if array key likely contains text content.
     *
     * @param string $key Node key.
     * @return bool
     */
    private static function is_text_key(string $key): bool {
        $key = strtolower($key);

        if (in_array($key, self::TEXT_KEYS, true)) {
            return true;
        }

        foreach (self::TEXT_KEYS as $text_key) {
            if (str_contains($key, $text_key)) {
                return true;
            }
        }

        return false;
    }
}
