<?php
/**
 * Text Chunker
 *
 * Splits long text into smaller, overlapping chunks suitable for embeddings.
 *
 * @package BoostBot
 * @since 1.7.0
 */

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

/**
 * Class BoostBot_Text_Chunker
 *
 * @since 1.7.0
 */
class BoostBot_Text_Chunker {

    /**
     * Chunk a text into overlapping pieces.
     *
     * This is a character-based chunker with some basic paragraph/sentence awareness.
     * It's intentionally simple and deterministic for stable re-indexing.
     *
     * @param string $text Raw text.
     * @param int    $chunk_size Target chunk size (chars).
     * @param int    $overlap    Overlap between chunks (chars).
     * @return array<string> Chunks.
     */
    public static function chunk( string $text, int $chunk_size = 2500, int $overlap = 250 ): array {
        $text = trim( preg_replace( "/[ \t]+/", ' ', $text ) );
        if ( '' === $text ) {
            return array();
        }

        // Safety: truncate extremely long text to prevent OOM.
        // 500KB of text is more than enough for meaningful semantic search.
        $max_text_chars = (int) apply_filters( 'boostbot_chunk_max_text_chars', 500000 );
        if ( mb_strlen( $text ) > $max_text_chars ) {
            $text = mb_substr( $text, 0, $max_text_chars );
            BoostBot_Logger::warning( 'Text truncated to ' . $max_text_chars . ' chars for chunking (OOM prevention).' );
        }

        // Normalize newlines and collapse excessive blank lines.
        $text = str_replace( array( "\r\n", "\r" ), "\n", $text );
        $text = preg_replace( "/\n{3,}/", "\n\n", $text );

        $len = mb_strlen( $text );
        if ( $len <= $chunk_size ) {
            return array( $text );
        }

        $chunks = array();
        $start  = 0;

        while ( $start < $len ) {
            $prev_start = $start;
            $end        = min( $start + $chunk_size, $len );

            // Try to end on a boundary near the end (paragraph, sentence).
            $window = mb_substr( $text, $start, $end - $start );

            $cut = self::find_best_cut( $window );
            if ( $cut > 0 && $cut < mb_strlen( $window ) ) {
                $end = $start + $cut;
            }

            $chunk = trim( mb_substr( $text, $start, $end - $start ) );
            if ( '' !== $chunk ) {
                $chunks[] = $chunk;
            }

            if ( $end >= $len ) {
                break;
            }

            // Move start with overlap — but guarantee forward progress. On a
            // short final window the boundary cut can land BEFORE the overlap,
            // making ($end - $overlap) <= $start; without this guard the loop
            // re-processes the same window forever (100% CPU, unbounded memory).
            $start = max( 0, $end - $overlap );
            if ( $start <= $prev_start ) {
                $start = $end;
            }
        }

        // Deduplicate accidental repeats (can happen on boundary adjustments).
        $chunks = array_values( array_filter( array_unique( $chunks ) ) );

        return $chunks;
    }

    /**
     * Find a natural cut point inside a window, close to the end.
     *
     * @param string $window Window text.
     * @return int Cut position in chars (relative to window start), or 0 to keep full window.
     */
    private static function find_best_cut( string $window ): int {
        $wlen = mb_strlen( $window );
        if ( $wlen < 200 ) {
            return 0;
        }

        // Search last ~500 chars for a boundary.
        $tail_len = min( 500, $wlen );
        $tail     = mb_substr( $window, $wlen - $tail_len, $tail_len );

        // Prefer paragraph breaks.
        $pos = mb_strrpos( $tail, "\n\n" );
        if ( false !== $pos && $pos > 50 ) {
            return ( $wlen - $tail_len ) + $pos;
        }

        // Then single newline.
        $pos = mb_strrpos( $tail, "\n" );
        if ( false !== $pos && $pos > 50 ) {
            return ( $wlen - $tail_len ) + $pos;
        }

        // Then sentence punctuation (supports Hebrew/English).
        foreach ( array( '. ', '! ', '? ', "। " ) as $punc ) {
            $pos = mb_strrpos( $tail, $punc );
            if ( false !== $pos && $pos > 50 ) {
                return ( $wlen - $tail_len ) + $pos + 1;
            }
        }

        return 0;
    }
}


