<?php
/**
 * Extracts and chunks WordPress post/page content for RAG embedding.
 *
 * Uses recursive character splitting with a 4-level separator hierarchy:
 * headings (h2/h3) -> paragraphs -> sentences -> words.
 * Produces 400-token target chunks with 15% overlap.
 *
 * @since 1.7.0
 */
class SiteStaffr_Content_Chunker {

    const TARGET_CHUNK_TOKENS = 400;
    const MAX_CHUNK_TOKENS    = 500;
    const MIN_CHUNK_TOKENS    = 100;
    const OVERLAP_TOKENS      = 60;
    const CHARS_PER_TOKEN     = 4;

    /**
     * Extract clean text from a WordPress post.
     *
     * @param int $post_id WordPress post ID.
     * @return string Clean text content.
     */
    public static function extract_content( $post_id ) {
        $post = get_post( $post_id );
        if ( ! $post || 'publish' !== $post->post_status ) {
            return '';
        }

        $raw = self::get_rendered_post_content( $post );
        if ( empty( $raw ) ) {
            return '';
        }

        return self::strip_to_text( $raw );
    }

    /**
     * Get post content with page builder shortcodes and blocks rendered.
     *
     * Page builders (Divi, Elementor, Beaver Builder, Bricks) store content as
     * shortcodes in post_content. Block themes use block markup. Without rendering,
     * the chunker sees raw shortcode tags or block comments instead of actual text.
     *
     * Applies the_content filter inside a global $post setup so page builders that
     * hook in to render their cached HTML produce the same output visitors see.
     *
     * @since 1.14.17
     * @param WP_Post $post Post object.
     * @return string Rendered HTML content.
     */
    private static function get_rendered_post_content( $target_post ) {
        global $post;

        $previous_post = $post;
        $post          = $target_post; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
        setup_postdata( $target_post );

        $rendered = apply_filters( 'the_content', $target_post->post_content ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- applying core filter

        $post = $previous_post; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
        if ( $previous_post ) {
            setup_postdata( $previous_post );
        } else {
            wp_reset_postdata();
        }

        return is_string( $rendered ) ? $rendered : '';
    }

    /**
     * Strip HTML, scripts, styles, and normalize whitespace.
     *
     * @param string $html HTML content.
     * @return string Plain text.
     */
    private static function strip_to_text( $html ) {
        $text = preg_replace( '/<script\b[^>]*>.*?<\/script>/is', '', $html );
        $text = preg_replace( '/<style\b[^>]*>.*?<\/style>/is', '', $text );
        $text = wp_strip_all_tags( $text );
        $text = html_entity_decode( $text, ENT_QUOTES, 'UTF-8' );
        $text = preg_replace( '/\s+/', ' ', $text );
        return trim( $text );
    }

    /**
     * Clean an HTML fragment to plain text.
     *
     * @param string $fragment HTML fragment.
     * @return string Plain text.
     */
    private static function clean_fragment( $fragment ) {
        $text = wp_strip_all_tags( $fragment );
        $text = html_entity_decode( $text, ENT_QUOTES, 'UTF-8' );
        $text = preg_replace( '/\s+/', ' ', $text );
        return trim( $text );
    }

    /**
     * Build a context prefix for a chunk.
     *
     * @param string $title   Page/post title.
     * @param string $heading Section heading (may be empty).
     * @return string Prefix string like "Page: Title | Section: Heading\n".
     */
    private static function build_prefix( $title, $heading ) {
        $prefix = 'Page: ' . $title;
        if ( ! empty( $heading ) ) {
            $prefix .= ' | Section: ' . $heading;
        }
        return $prefix . "\n";
    }

    /**
     * Extract and chunk a post in one call.
     *
     * Uses recursive character splitting with a 4-level separator hierarchy:
     * headings -> paragraphs -> sentences -> words.
     *
     * @param int $post_id WordPress post ID.
     * @return array Array of chunk arrays with keys: post_id, post_title, post_type, chunk_index, text.
     */
    public static function process_post( $post_id ) {
        $post = get_post( $post_id );
        if ( ! $post ) {
            return array();
        }

        // Render content through the_content filter so page builders (Divi, Elementor,
        // Beaver Builder, Bricks) and dynamic blocks expand to actual HTML instead of
        // leaving raw shortcodes/block markup.
        $html = self::get_rendered_post_content( $post );
        if ( empty( $html ) ) {
            return array();
        }

        // Phase 1: Recursive split.
        $raw_chunks = self::recursive_split( $html, 0 );

        // Filter out empty chunks.
        $raw_chunks = array_filter( $raw_chunks, function ( $c ) {
            return ! empty( trim( $c['text'] ) );
        } );
        $raw_chunks = array_values( $raw_chunks );

        if ( empty( $raw_chunks ) ) {
            return array();
        }

        // Phase 2: Merge small chunks.
        $min_chars = self::MIN_CHUNK_TOKENS * self::CHARS_PER_TOKEN;
        $raw_chunks = self::merge_small_chunks( $raw_chunks, $min_chars );

        // Phase 3: Apply overlap between sibling chunks.
        $overlap_chars = self::OVERLAP_TOKENS * self::CHARS_PER_TOKEN;
        $raw_chunks = self::apply_overlap( $raw_chunks, $overlap_chars );

        // Phase 4: Build final chunk array with metadata and prefixes.
        $chunks = array();
        foreach ( $raw_chunks as $index => $rc ) {
            $prefix = self::build_prefix( $post->post_title, $rc['heading'] );
            $chunks[] = array(
                'post_id'     => $post_id,
                'post_title'  => $post->post_title,
                'post_type'   => $post->post_type,
                'chunk_index' => $index,
                'text'        => $prefix . $rc['text'],
            );
        }

        return $chunks;
    }

    /**
     * Recursively split text using a separator hierarchy.
     *
     * Level 0: h2-h6 headings (HTML input, stripped to text after split).
     * Level 1: Paragraph breaks (\n\n).
     * Level 2: Sentence boundaries (. ? !).
     * Level 3: Word boundaries (space).
     *
     * @param string $text  Text to split (HTML at depth 0, plain text at depth 1+).
     * @param int    $depth Current depth in separator hierarchy (0-3).
     * @return array Array of ['text' => string, 'heading' => string, 'from_heading' => bool].
     */
    private static function recursive_split( $text, $depth = 0 ) {
        $max_chars = self::MAX_CHUNK_TOKENS * self::CHARS_PER_TOKEN;

        // Base case: text fits within max — return as single chunk.
        $check_text = ( 0 === $depth ) ? self::html_to_paragraphed_text( $text ) : $text;
        if ( strlen( $check_text ) <= $max_chars ) {
            return array(
                array(
                    'text'         => $check_text,
                    'heading'      => '',
                    'from_heading' => false,
                ),
            );
        }

        // Level 0: Split by h2/h3 headings.
        if ( 0 === $depth ) {
            return self::split_by_headings( $text, $max_chars );
        }

        // Levels 1-3: Split by separator patterns.
        // At depth 1+, text is already plain text (stripped by split_by_headings or strip_to_text).
        // Split by double-newlines (paragraph), then sentence endings, then spaces.
        $separators = array(
            1 => '/\n\s*\n/',
            2 => '/(?<=[.?!])\s+/',
            3 => '/\s+/',
        );

        if ( ! isset( $separators[ $depth ] ) ) {
            // Exhausted all separators — return as-is (hard limit, won't cut mid-word).
            return array(
                array(
                    'text'         => $text,
                    'heading'      => '',
                    'from_heading' => false,
                ),
            );
        }

        $pieces = preg_split( $separators[ $depth ], $text, -1, PREG_SPLIT_NO_EMPTY );

        // If splitting didn't help (single piece or failed), try next level.
        if ( empty( $pieces ) || count( $pieces ) <= 1 ) {
            return self::recursive_split( $text, $depth + 1 );
        }

        // Greedily merge consecutive pieces into chunks up to max size.
        $chunks = array();
        $buffer = '';

        $min_boundary_chars = self::MIN_CHUNK_TOKENS * self::CHARS_PER_TOKEN;

        foreach ( $pieces as $piece ) {
            $piece = trim( $piece );
            if ( empty( $piece ) ) {
                continue;
            }

            // At paragraph level, force a chunk boundary when a new
            // option/package/tier section starts so each pricing option
            // stays in its own chunk instead of straddling boundaries.
            if ( 1 === $depth && ! empty( $buffer ) && strlen( $buffer ) >= $min_boundary_chars && self::is_option_boundary( $piece ) ) {
                $chunks[] = array(
                    'text'         => $buffer,
                    'heading'      => '',
                    'from_heading' => false,
                );
                $buffer = $piece;
                continue;
            }

            $separator = ( 1 === $depth ) ? "\n\n" : ' ';
            $combined  = empty( $buffer ) ? $piece : $buffer . $separator . $piece;

            if ( strlen( $combined ) <= $max_chars ) {
                $buffer = $combined;
                continue;
            }

            // Buffer is full — emit it.
            if ( ! empty( $buffer ) ) {
                $chunks[] = array(
                    'text'         => $buffer,
                    'heading'      => '',
                    'from_heading' => false,
                );
            }

            // Check if this single piece exceeds max — recurse deeper.
            if ( strlen( $piece ) > $max_chars ) {
                $sub_chunks = self::recursive_split( $piece, $depth + 1 );
                $chunks     = array_merge( $chunks, $sub_chunks );
                $buffer     = '';
            } else {
                $buffer = $piece;
            }
        }

        // Emit remaining buffer.
        if ( ! empty( $buffer ) ) {
            $chunks[] = array(
                'text'         => $buffer,
                'heading'      => '',
                'from_heading' => false,
            );
        }

        return $chunks;
    }

    /**
     * Check if a paragraph starts with a common option/package/tier pattern.
     *
     * Businesses often list pricing options as "Option #1", "Package A",
     * "Plan 1", etc. These should start a new chunk so each option's
     * header, description, and price stay together instead of straddling
     * chunk boundaries.
     *
     * @param string $text Paragraph text to check.
     * @return bool True if the paragraph looks like a new option/section start.
     */
    private static function is_option_boundary( $text ) {
        $trimmed = trim( $text );
        if ( empty( $trimmed ) ) {
            return false;
        }
        return (bool) preg_match(
            '/^(?:option|package|plan|tier|bundle|level|step|choice)\b\s*#?\s*[\dA-Za-z]/i',
            $trimmed
        );
    }

    /**
     * Convert HTML to plain text, preserving paragraph breaks as \n\n.
     *
     * Unlike strip_to_text() which collapses all whitespace, this method
     * converts </p> and <br><br> to double-newlines so the paragraph
     * splitter (depth 1) can find them.
     *
     * @param string $html HTML content.
     * @return string Plain text with paragraph breaks preserved.
     */
    private static function html_to_paragraphed_text( $html ) {
        $text = preg_replace( '/<script\b[^>]*>.*?<\/script>/is', '', $html );
        $text = preg_replace( '/<style\b[^>]*>.*?<\/style>/is', '', $text );

        // Convert block-level closers to double-newlines.
        $text = preg_replace( '/<\/(?:p|div|li|tr|blockquote|article|section)>/i', "\n\n", $text );
        $text = preg_replace( '/<br\s*\/?>\s*<br\s*\/?>/i', "\n\n", $text );

        $text = wp_strip_all_tags( $text );
        $text = html_entity_decode( $text, ENT_QUOTES, 'UTF-8' );

        // Collapse non-breaking spaces and other invisible whitespace first so
        // the line-blank check below recognizes them as empty.
        $text = str_replace( array( "\xC2\xA0", "\xE2\x80\x8B", "\xE2\x80\xA8", "\xE2\x80\xA9" ), ' ', $text );

        // Normalize runs of whitespace within lines, but preserve \n.
        $text = preg_replace( '/[^\S\n]+/', ' ', $text );

        // Collapse any run of blank-ish lines (\n with only whitespace between)
        // into a single paragraph break. Catches <p>&nbsp;</p><p>&nbsp;</p>...
        // patterns that page builders emit as vertical padding.
        $text = preg_replace( '/(?:\n[ \t]*){2,}/', "\n\n", $text );

        return trim( $text );
    }

    /**
     * Split HTML content by h2/h3 headings and recurse on oversized sections.
     *
     * @param string $html      HTML content.
     * @param int    $max_chars Max characters per chunk.
     * @return array Array of ['text' => string, 'heading' => string, 'from_heading' => bool].
     */
    private static function split_by_headings( $html, $max_chars ) {
        // Strip scripts and styles first.
        $html = preg_replace( '/<script\b[^>]*>.*?<\/script>/is', '', $html );
        $html = preg_replace( '/<style\b[^>]*>.*?<\/style>/is', '', $html );

        $parts  = preg_split( '/<h[2-6][^>]*>(.*?)<\/h[2-6]>/is', $html, -1, PREG_SPLIT_DELIM_CAPTURE );
        $chunks = array();

        for ( $i = 0; $i < count( $parts ); $i++ ) {
            if ( 0 === $i ) {
                // Content before any heading.
                $text = self::html_to_paragraphed_text( $parts[0] );
                if ( empty( $text ) ) {
                    continue;
                }
                if ( strlen( $text ) > $max_chars ) {
                    $sub    = self::recursive_split( $text, 1 );
                    $chunks = array_merge( $chunks, $sub );
                } else {
                    $chunks[] = array(
                        'text'         => $text,
                        'heading'      => '',
                        'from_heading' => false,
                    );
                }
            } elseif ( 1 === $i % 2 ) {
                // Odd index = captured heading text.
                $heading = self::clean_fragment( $parts[ $i ] );
                $body    = isset( $parts[ $i + 1 ] ) ? self::html_to_paragraphed_text( $parts[ $i + 1 ] ) : '';
                ++$i; // Skip the body part (already consumed).

                if ( empty( $heading ) && empty( $body ) ) {
                    continue;
                }

                $labeled = ! empty( $heading ) && ! empty( $body )
                    ? $heading . ': ' . $body
                    : ( ! empty( $body ) ? $body : $heading );

                if ( strlen( $labeled ) > $max_chars ) {
                    $sub = self::recursive_split( $labeled, 1 );
                    // Preserve heading on all sub-chunks from this section.
                    foreach ( $sub as &$sc ) {
                        $sc['heading']      = $heading;
                        $sc['from_heading'] = true;
                    }
                    unset( $sc );
                    $chunks = array_merge( $chunks, $sub );
                } else {
                    $chunks[] = array(
                        'text'         => $labeled,
                        'heading'      => $heading,
                        'from_heading' => true,
                    );
                }
            }
        }

        // No headings found — fall through to paragraph splitting.
        if ( empty( $chunks ) ) {
            $text = self::html_to_paragraphed_text( $html );
            if ( ! empty( $text ) ) {
                return self::recursive_split( $text, 1 );
            }
        }

        return $chunks;
    }

    /**
     * Merge chunks smaller than MIN_CHUNK_TOKENS with their neighbors.
     *
     * Prefers merging with the previous chunk. If this is the first chunk,
     * merges forward. Respects two hard constraints:
     *   1. Never merge past MAX_CHUNK_TOKENS (prevents bloated chunks).
     *   2. Never merge a chunk that starts with an option/package boundary
     *      into its neighbor (keeps distinct pricing items separate).
     *
     * @param array $chunks    Array of chunk arrays with 'text' key.
     * @param int   $min_chars Minimum characters per chunk.
     * @return array Merged chunks.
     */
    private static function merge_small_chunks( $chunks, $min_chars ) {
        if ( count( $chunks ) <= 1 ) {
            return $chunks;
        }

        $max_chars = self::MAX_CHUNK_TOKENS * self::CHARS_PER_TOKEN;
        $merged    = array();

        foreach ( $chunks as $chunk ) {
            if ( empty( $merged ) ) {
                $merged[] = $chunk;
                continue;
            }

            $last_idx  = count( $merged ) - 1;
            $separator = "\n\n";

            // Never merge a chunk that starts a new option/package section.
            $is_boundary = self::is_option_boundary( $chunk['text'] );

            // Current chunk is too small — merge with previous.
            if ( strlen( $chunk['text'] ) < $min_chars && ! $is_boundary ) {
                $combined_len = strlen( $merged[ $last_idx ]['text'] ) + strlen( $separator ) + strlen( $chunk['text'] );
                if ( $combined_len <= $max_chars ) {
                    $merged[ $last_idx ]['text'] .= $separator . $chunk['text'];
                    continue;
                }
            }

            // Previous chunk is too small — merge current into it.
            if ( strlen( $merged[ $last_idx ]['text'] ) < $min_chars && ! $is_boundary ) {
                $combined_len = strlen( $merged[ $last_idx ]['text'] ) + strlen( $separator ) + strlen( $chunk['text'] );
                if ( $combined_len <= $max_chars ) {
                    $merged[ $last_idx ]['text'] .= $separator . $chunk['text'];
                    // Inherit heading from whichever chunk had one.
                    if ( empty( $merged[ $last_idx ]['heading'] ) && ! empty( $chunk['heading'] ) ) {
                        $merged[ $last_idx ]['heading']      = $chunk['heading'];
                        $merged[ $last_idx ]['from_heading'] = $chunk['from_heading'];
                    }
                    continue;
                }
            }

            $merged[] = $chunk;
        }

        return $merged;
    }

    /**
     * Apply overlap between consecutive chunks from the same section.
     *
     * Prepends the last ~overlap_chars of the previous chunk to the start
     * of the next chunk. Snaps to sentence boundary when possible, word
     * boundary otherwise.
     *
     * @param array $chunks       Array of chunk arrays.
     * @param int   $overlap_chars Target overlap in characters.
     * @return array Chunks with overlap applied.
     */
    private static function apply_overlap( $chunks, $overlap_chars ) {
        if ( count( $chunks ) <= 1 || $overlap_chars <= 0 ) {
            return $chunks;
        }

        for ( $i = 1; $i < count( $chunks ); $i++ ) {
            $prev = $chunks[ $i - 1 ];
            $curr = $chunks[ $i ];

            // Only overlap chunks from the same heading section.
            // Different headings = different topics = no overlap.
            if ( $prev['heading'] !== $curr['heading'] ) {
                continue;
            }

            $prev_text = $prev['text'];
            if ( strlen( $prev_text ) <= $overlap_chars ) {
                continue; // Previous chunk too short to take overlap from.
            }

            // Extract the tail of the previous chunk.
            $tail = substr( $prev_text, -$overlap_chars );

            // Snap to sentence boundary (look for ". " or "? " or "! ").
            $sentence_pos = false;
            foreach ( array( '. ', '? ', '! ' ) as $delim ) {
                $pos = strpos( $tail, $delim );
                if ( false !== $pos ) {
                    if ( false === $sentence_pos || $pos < $sentence_pos ) {
                        $sentence_pos = $pos;
                    }
                }
            }

            if ( false !== $sentence_pos ) {
                $tail = substr( $tail, $sentence_pos + 2 );
            } else {
                // Snap to word boundary.
                $space_pos = strpos( $tail, ' ' );
                if ( false !== $space_pos ) {
                    $tail = substr( $tail, $space_pos + 1 );
                }
            }

            $tail = trim( $tail );
            if ( empty( $tail ) ) {
                continue;
            }

            // Prepend overlap to current chunk.
            $chunks[ $i ]['text'] = $tail . ' ' . $curr['text'];
        }

        return $chunks;
    }
}
