<?php
/**
 * Rank Booster Heading IO
 *
 * Builder-aware helpers for locating, replacing, inserting and promoting
 * heading tags. Shared by all heading-family content fixes
 * (long_h1_h2_tag, missing_h1_tag).
 *
 * Only the forward (apply) operations live here. Reverting is handled by the
 * handler via a whole-field snapshot taken before the edit (see
 * Rank_Booster_Heading_Fix), which keeps this class simple and side-effect free
 * for the post_content path.
 */

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

class Rank_Booster_Heading_IO {

    /* ───────────────── post_content builders ───────────────── */

    /**
     * Replace the inner text of the first heading of $tag_type whose visible
     * text matches $current_text.
     *
     * @return string Modified content (unchanged if no match found).
     */
    public function replace_heading_in_content( $content, $tag_type, $current_text, $new_text ) {
        if ( empty( $current_text ) || empty( $content ) ) {
            return $content;
        }

        $tag_type = $this->sanitize_tag( $tag_type );

        // Match by normalized visible text so inline markup inside the heading
        // (e.g. <span>) doesn't defeat the match.
        $pattern = "/(<{$tag_type}\b[^>]*>)(.*?)(<\/{$tag_type}>)/is";
        $done    = false;

        $result = preg_replace_callback( $pattern, function ( $m ) use ( $current_text, $new_text, &$done ) {
            if ( $done ) {
                return $m[0];
            }
            if ( $this->text_matches( $m[2], $current_text ) ) {
                $done = true;
                return $m[1] . esc_html( $new_text ) . $m[3];
            }
            return $m[0];
        }, $content );

        return $result !== null ? $result : $content;
    }

    /**
     * Insert a new heading. If a first paragraph exists we place it right after
     * it (a natural sub-heading position); otherwise we prepend it.
     *
     * @return string Modified content.
     */
    public function insert_heading( $content, $tag_type, $text ) {
        $tag_type = $this->sanitize_tag( $tag_type );
        $heading  = '<' . $tag_type . '>' . esc_html( $text ) . '</' . $tag_type . '>';

        if ( trim( (string) $content ) === '' ) {
            return $heading;
        }

        // Prefer inserting after the first closing paragraph.
        if ( preg_match( '/<\/p>/i', $content ) ) {
            return preg_replace( '/(<\/p>)/i', '$1' . "\n" . $heading, $content, 1 );
        }

        return $heading . "\n" . $content;
    }

    /**
     * Promote the first <h2> to an <h1>, replacing its text with $new_text.
     * Falls back to inserting a new H1 at the top when no <h2> exists.
     *
     * @return string Modified content.
     */
    public function promote_or_insert_h1( $content, $new_text ) {
        $pattern = '/<h2\b[^>]*>.*?<\/h2>/is';
        if ( preg_match( $pattern, (string) $content ) ) {
            $replacement = '<h1>' . esc_html( $new_text ) . '</h1>';
            return preg_replace( $pattern, $replacement, $content, 1 );
        }
        return $this->insert_heading( $content, 'h1', $new_text );
    }

    /* ───────────────── Elementor ───────────────── */

    /**
     * Replace the first Elementor slot whose text matches $current_text.
     *
     * We match ANY text slot (not only heading '.title' slots) because headings
     * are sometimes authored in text-editor / other widgets.
     *
     * @return bool True when a slot was updated.
     */
    public function replace_elementor_heading( $post_id, $tag_type, $current_text, $new_text ) {
        if ( ! class_exists( 'Rank_Booster_Elementor' ) ) {
            return false;
        }

        $slots = Rank_Booster_Elementor::extract_text_slots( $post_id );
        // Prefer heading '.title' slots, then fall back to any matching slot.
        foreach ( array( true, false ) as $title_only ) {
            foreach ( $slots as $key => $text ) {
                if ( $title_only && substr( $key, -6 ) !== '.title' ) {
                    continue;
                }
                if ( $this->text_matches( $text, $current_text ) ) {
                    Rank_Booster_Elementor::apply_text_slots( $post_id, array( $key => $new_text ) );
                    return true;
                }
            }
        }
        return false;
    }

    /**
     * Insert a heading into an Elementor layout by prepending an HTML widget.
     *
     * @return bool
     */
    public function insert_elementor_heading( $post_id, $tag_type, $text ) {
        if ( ! class_exists( 'Rank_Booster_Elementor' ) ) {
            return false;
        }
        $tag_type = $this->sanitize_tag( $tag_type );
        $html     = '<' . $tag_type . '>' . esc_html( $text ) . '</' . $tag_type . '>';
        return (bool) Rank_Booster_Elementor::inject_html_widget( $post_id, $html, 'prepend' );
    }

    /* ───────────────── helpers ───────────────── */

    /**
     * Public: whether two heading strings are equivalent after normalization.
     * Used by the handler to detect when a heading is really the post title.
     */
    public function text_equals( $a, $b ) {
        return $this->text_matches( $a, $b );
    }

    /**
     * Return a compact view of stored heading markup for gated debug logging.
     *
     * This intentionally exposes text only, never the surrounding post content,
     * so a failed match can be diagnosed without dumping the whole page.
     */
    public function get_heading_candidates( $content, $limit = 20 ) {
        $candidates = array();
        $limit      = max( 1, (int) $limit );

        if ( ! preg_match_all( '/<(h[1-6])\b[^>]*>(.*?)<\/\1>/is', (string) $content, $matches, PREG_SET_ORDER ) ) {
            return $candidates;
        }

        foreach ( $matches as $match ) {
            $text = wp_strip_all_tags( $match[2] );
            $candidates[] = array(
                'type'       => strtolower( $match[1] ),
                'text'       => $this->truncate_debug_text( $text ),
                'normalized' => $this->truncate_debug_text( $this->normalize( $text ) ),
            );

            if ( count( $candidates ) >= $limit ) {
                break;
            }
        }

        return $candidates;
    }

    /**
     * Normalize text for debug comparisons without exposing the matcher itself.
     */
    public function get_normalized_text( $text ) {
        return $this->normalize( $text );
    }

    /**
     * Whether two heading strings match once markup, entities, punctuation
     * variants (dashes/quotes), whitespace and case are normalized. Crawlers and
     * editors frequently differ on exactly these, so we fold them all away.
     */
    private function text_matches( $a, $b ) {
        $na = $this->normalize( $a );
        $nb = $this->normalize( $b );
        if ( $na === '' || $nb === '' ) {
            return false;
        }
        return $na === $nb;
    }

    private function normalize( $text ) {
        $text = wp_strip_all_tags( (string) $text );
        $text = html_entity_decode( $text, ENT_QUOTES );

        // Fold Unicode dashes to a plain hyphen (– — ‒ − → -).
        $text = str_replace(
            array( "\xE2\x80\x93", "\xE2\x80\x94", "\xE2\x80\x92", "\xE2\x88\x92" ),
            '-',
            $text
        );
        // Fold double-quote variants (“ ” „ ‟ « » → ").
        $text = str_replace(
            array( "\xE2\x80\x9C", "\xE2\x80\x9D", "\xE2\x80\x9E", "\xE2\x80\x9F", "\xC2\xAB", "\xC2\xBB" ),
            '"',
            $text
        );
        // Fold curly single quotes / primes (‘ ’ ‚ ‛ ´ ` → ').
        $text = str_replace(
            array( "\xE2\x80\x98", "\xE2\x80\x99", "\xE2\x80\x9A", "\xE2\x80\x9B", "\xC2\xB4", "\x60" ),
            "'",
            $text
        );
        // Non-breaking space → normal space.
        $text = str_replace( "\xC2\xA0", ' ', $text );

        // Collapse all whitespace runs to a single space.
        $text = preg_replace( '/\s+/u', ' ', $text );
        $text = trim( $text );

        // Case-insensitive: crawlers sometimes report an uppercased variant.
        if ( function_exists( 'mb_strtolower' ) ) {
            $text = mb_strtolower( $text, 'UTF-8' );
        } else {
            $text = strtolower( $text );
        }
        return $text;
    }

    private function truncate_debug_text( $text, $length = 300 ) {
        $text = (string) $text;
        if ( function_exists( 'mb_substr' ) ) {
            return mb_substr( $text, 0, $length, 'UTF-8' );
        }
        return substr( $text, 0, $length );
    }

    /**
     * Only ever allow real heading tags; default to h2.
     */
    private function sanitize_tag( $tag_type ) {
        $tag_type = strtolower( trim( (string) $tag_type ) );
        return in_array( $tag_type, array( 'h1', 'h2', 'h3', 'h4', 'h5', 'h6' ), true ) ? $tag_type : 'h2';
    }
}
