<?php

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

class Niwa_AI_Index_Worker {
    const OPTION_KEY = Niwa_AI_Constants::OPT_INDEX_DOCS;
	const SEARCH_DOCUMENT_VERSION = '5';
	const SEARCH_DOCUMENT_OPTION  = 'niwa_ai_product_search_document_version';
	const EMBEDDING_BINARY_BACKFILL_HOOK   = 'niwa_ai_embedding_binary_backfill';
	const EMBEDDING_BINARY_RESTART_HOOK    = 'niwa_ai_restart_embedding_binary_backfill';
	const EMBEDDING_BINARY_BACKFILL_STATUS = 'niwa_ai_embedding_binary_backfill_status';
	const EMBEDDING_BINARY_BACKFILL_LOCK   = 'niwa_ai_embedding_binary_backfill_lock';
	const EMBEDDING_BINARY_BACKFILL_BATCH  = 100;
	const EMBEDDING_BINARY_MAX_RETRY_ROUNDS = 3;
	const EMBEDDING_BINARY_LOCK_TTL         = 300;
	const EMBEDDING_BINARY_RECOVERY_BASE    = 300;
	const EMBEDDING_BINARY_RECOVERY_MAX     = 21600;
    /** @var Niwa_AI_Content_Store_Interface|null */
    private $content_store;
    private $site_knowledge_extractor;
    private $html_cleaner;

    public function __construct( ?Niwa_AI_Content_Store_Interface $content_store = null ) {
        $this->html_cleaner = class_exists( 'Niwa_AI_HTML_Content_Cleaner' )
            ? new Niwa_AI_HTML_Content_Cleaner()
            : null;

        if ( $content_store ) {
            $this->content_store = $content_store;
            $this->site_knowledge_extractor = class_exists( 'Niwa_AI_Site_Knowledge_Extractor' )
                ? new Niwa_AI_Site_Knowledge_Extractor()
                : null;
            return;
        }

        $this->content_store = class_exists( 'Niwa_AI_Content_Store' ) ? new Niwa_AI_Content_Store() : null;
        $this->site_knowledge_extractor = class_exists( 'Niwa_AI_Site_Knowledge_Extractor' )
            ? new Niwa_AI_Site_Knowledge_Extractor()
            : null;
    }

    public function register_hooks() {
        add_action( 'niwa_ai_index_upsert', array( $this, 'handle_upsert' ), 10, 3 );
        add_action( 'niwa_ai_index_delete', array( $this, 'handle_delete' ), 10, 2 );
        add_action( 'niwa_ai_index_rebuild_full', array( $this, 'handle_rebuild_full' ) );
		add_action( 'init', array( $this, 'maybe_schedule_search_document_rebuild' ), 30 );
		add_action( 'init', array( $this, 'maybe_schedule_embedding_binary_backfill' ), 31 );
		add_action( self::EMBEDDING_BINARY_BACKFILL_HOOK, array( $this, 'handle_embedding_binary_backfill' ) );
		add_action( self::EMBEDDING_BINARY_RESTART_HOOK, array( $this, 'restart_embedding_binary_backfill' ), 10, 1 );
    }

	public function maybe_schedule_search_document_rebuild() {
		if ( self::SEARCH_DOCUMENT_VERSION === (string) get_option( self::SEARCH_DOCUMENT_OPTION, '' ) ) {
			return;
		}
		if ( ! wp_next_scheduled( 'niwa_ai_index_rebuild_full' ) ) {
			wp_schedule_single_event( time() + 10, 'niwa_ai_index_rebuild_full' );
		}
	}

	/**
	 * Queue the lossless JSON-to-float32 migration without blocking a request.
	 */
	public function maybe_schedule_embedding_binary_backfill() {
		if ( ! $this->content_store || ! method_exists( $this->content_store, 'backfill_embedding_binary' ) ) {
			return;
		}

		$status = get_option( self::EMBEDDING_BINARY_BACKFILL_STATUS, array() );
		$status = is_array( $status ) ? $status : array();
		$format = defined( 'Niwa_AI_Content_Store::EMBEDDING_BINARY_FORMAT' )
			? (string) Niwa_AI_Content_Store::EMBEDDING_BINARY_FORMAT
			: 'f32le-v1';

		if (
			! empty( $status['done'] )
			&& empty( $status['completed_with_errors'] )
			&& $format === (string) ( $status['format'] ?? '' )
		) {
			return;
		}

		$next_retry_at = max( 0, intval( $status['next_retry_at'] ?? 0 ) );
		$delay         = $next_retry_at > time() ? $next_retry_at - time() : 10;
		$this->schedule_embedding_binary_backfill_event( $delay );
	}

	/**
	 * Explicit operational re-entry for WP-CLI/admin tooling.
	 *
	 * Usage: do_action( 'niwa_ai_restart_embedding_binary_backfill', 'manual' ).
	 *
	 * @param string $reason Audit-friendly restart reason.
	 * @return array Fresh observable status.
	 */
	public function restart_embedding_binary_backfill( $reason = 'manual' ) {
		$previous = get_option( self::EMBEDDING_BINARY_BACKFILL_STATUS, array() );
		$previous = is_array( $previous ) ? $previous : array();
		$status   = $this->new_embedding_binary_backfill_status( $this->embedding_binary_format() );
		$status['manual_reentry_at']    = time();
		$status['manual_reentry_reason'] = sanitize_key( (string) $reason );
		$status['manual_reentry_count']  = max( 0, intval( $previous['manual_reentry_count'] ?? 0 ) ) + 1;
		$status['updated_at']            = time();

		update_option( self::EMBEDDING_BINARY_BACKFILL_STATUS, $status, false );
		if ( function_exists( 'wp_clear_scheduled_hook' ) ) {
			wp_clear_scheduled_hook( self::EMBEDDING_BINARY_BACKFILL_HOOK );
		}
		$this->schedule_embedding_binary_backfill_event( 1 );
		return $status;
	}

	/**
	 * Read the migration state without exposing the option name to callers.
	 */
	public function get_embedding_binary_backfill_status() {
		$status = get_option( self::EMBEDDING_BINARY_BACKFILL_STATUS, array() );
		return is_array( $status ) ? $status : array();
	}

	/**
	 * Process one bounded, resumable migration batch.
	 */
	public function handle_embedding_binary_backfill() {
		if ( ! $this->content_store || ! method_exists( $this->content_store, 'backfill_embedding_binary' ) ) {
			return;
		}

		$preflight = $this->get_embedding_binary_backfill_status();
		$format    = $this->embedding_binary_format();
		if (
			$format === (string) ( $preflight['format'] ?? '' )
			&& ! empty( $preflight['completed_with_errors'] )
			&& intval( $preflight['next_retry_at'] ?? 0 ) > time()
		) {
			$this->schedule_embedding_binary_backfill_event( intval( $preflight['next_retry_at'] ) - time() );
			return;
		}

		$lock_owner = $this->acquire_embedding_binary_backfill_lock();
		if ( '' === $lock_owner ) {
			// The active worker owns this batch. Always leave a wake-up behind so
			// a killed PHP process cannot strand the migration after lock expiry.
			$this->schedule_embedding_binary_backfill_event( 30 );
			return;
		}

		try {
			$status = get_option( self::EMBEDDING_BINARY_BACKFILL_STATUS, array() );
			$status = is_array( $status ) ? $status : array();
			$format = $this->embedding_binary_format();

			if ( $format !== (string) ( $status['format'] ?? '' ) ) {
				$status = $this->new_embedding_binary_backfill_status( $format );
			} elseif ( ! empty( $status['done'] ) && empty( $status['completed_with_errors'] ) ) {
				return;
			} elseif ( ! empty( $status['completed_with_errors'] ) ) {
				$next_retry_at = max( 0, intval( $status['next_retry_at'] ?? 0 ) );
				if ( $next_retry_at > time() ) {
					$this->schedule_embedding_binary_backfill_event( $next_retry_at - time() );
					return;
				}
				$status['done']               = false;
				$status['cursor']             = 0;
				$status['retry_round']        = 0;
				$status['failed_ids']         = array();
				$status['consecutive_errors'] = 0;
				$status['recovery_active']    = true;
				$status['next_retry_at']      = 0;
			}

			$schema_ready = true === $this->content_store->ensure_tables();
			$result = $schema_ready
				? $this->content_store->backfill_embedding_binary(
					self::EMBEDDING_BINARY_BACKFILL_BATCH,
					max( 0, intval( $status['cursor'] ?? 0 ) )
				)
				: array(
					'migrated' => 0, 'failed' => 0, 'stale' => 0, 'processed' => 0,
					'next_cursor' => max( 0, intval( $status['cursor'] ?? 0 ) ),
					'done' => true, 'error' => 'binary_schema_not_ready', 'failed_ids' => array(),
				);
			$result = is_array( $result ) ? $result : array();

			$failed_ids = array_merge(
				isset( $status['failed_ids'] ) && is_array( $status['failed_ids'] ) ? $status['failed_ids'] : array(),
				isset( $result['failed_ids'] ) && is_array( $result['failed_ids'] ) ? $result['failed_ids'] : array()
			);
			$failed_ids = array_slice( array_values( array_unique( array_map( 'intval', $failed_ids ) ) ), -500 );

			$status['format']             = $format;
			$status['cursor']             = max( 0, intval( $result['next_cursor'] ?? ( $status['cursor'] ?? 0 ) ) );
			$status['migrated']           = max( 0, intval( $status['migrated'] ?? 0 ) ) + max( 0, intval( $result['migrated'] ?? 0 ) );
			$status['processed']          = max( 0, intval( $status['processed'] ?? 0 ) ) + max( 0, intval( $result['processed'] ?? 0 ) );
			$status['failed_ids']         = $failed_ids;
			$status['last_error']         = sanitize_key( (string) ( $result['error'] ?? '' ) );
			$status['updated_at']         = time();
			$status['done']               = false;
			$status['consecutive_errors'] = '' !== $status['last_error']
				? max( 0, intval( $status['consecutive_errors'] ?? 0 ) ) + 1
				: 0;

			$batch_done  = ! empty( $result['done'] );
			$retry_round = max( 0, intval( $status['retry_round'] ?? 0 ) );
			$has_errors  = ! empty( $failed_ids ) || ! empty( $result['stale'] ) || '' !== $status['last_error'];

			if ( intval( $status['consecutive_errors'] ) >= self::EMBEDDING_BINARY_MAX_RETRY_ROUNDS ) {
				$status = $this->defer_embedding_binary_recovery( $status );
			} elseif ( $batch_done && $has_errors && $retry_round < self::EMBEDDING_BINARY_MAX_RETRY_ROUNDS ) {
				$status['cursor']      = 0;
				$status['retry_round'] = $retry_round + 1;
				$status['failed_ids']  = array();
			} elseif ( $batch_done && $has_errors ) {
				$status = $this->defer_embedding_binary_recovery( $status );
			} elseif ( $batch_done ) {
				$status['done']                  = true;
				$status['completed_with_errors'] = false;
				$status['recoverable']           = false;
				$status['recovery_active']       = false;
				$status['next_retry_at']         = 0;
			}

			// Atomically renew the exact owner value before committing status.
			// A worker that lost an expired lock cannot overwrite a newer cursor.
			$refreshed_owner = $this->refresh_embedding_binary_backfill_lock( $lock_owner );
			if ( '' === $refreshed_owner ) {
				$this->schedule_embedding_binary_backfill_event( 5 );
				return;
			}
			$lock_owner = $refreshed_owner;
			update_option( self::EMBEDDING_BINARY_BACKFILL_STATUS, $status, false );

			if ( ! empty( $status['completed_with_errors'] ) ) {
				$this->schedule_embedding_binary_backfill_event(
					max( 1, intval( $status['next_retry_at'] ?? 0 ) - time() )
				);
			} elseif ( empty( $status['done'] ) ) {
				$this->schedule_embedding_binary_backfill_event( '' !== $status['last_error'] ? 60 : 5 );
			}
		} finally {
			$this->release_embedding_binary_backfill_lock( $lock_owner );
		}
	}

	private function acquire_embedding_binary_backfill_lock() {
		try {
			$token = bin2hex( random_bytes( 16 ) );
		} catch ( Throwable $e ) {
			$token = function_exists( 'wp_generate_uuid4' ) ? wp_generate_uuid4() : uniqid( 'niwa_', true );
		}
		$lock_value = $token . '|' . ( time() + self::EMBEDDING_BINARY_LOCK_TTL );

		if ( add_option( self::EMBEDDING_BINARY_BACKFILL_LOCK, $lock_value, '', false ) ) {
			return $lock_value;
		}

		$current     = (string) get_option( self::EMBEDDING_BINARY_BACKFILL_LOCK, '' );
		$separator   = strrpos( $current, '|' );
		$expires_at  = false !== $separator ? intval( substr( $current, $separator + 1 ) ) : 0;
		if ( $expires_at >= time() || ! $this->compare_delete_embedding_binary_lock( $current ) ) {
			return '';
		}

		return add_option( self::EMBEDDING_BINARY_BACKFILL_LOCK, $lock_value, '', false ) ? $lock_value : '';
	}

	private function release_embedding_binary_backfill_lock( $lock_owner ) {
		$lock_owner = (string) $lock_owner;
		if ( '' !== $lock_owner ) {
			$this->compare_delete_embedding_binary_lock( $lock_owner );
		}
	}

	/**
	 * CAS-renew the exact owner before it commits migration status.
	 *
	 * @return string Refreshed owner value, or an empty string when ownership
	 *                was lost to another worker.
	 */
	private function refresh_embedding_binary_backfill_lock( $expected_value ) {
		global $wpdb;

		$expected_value = (string) $expected_value;
		$separator      = strrpos( $expected_value, '|' );
		if ( '' === $expected_value || false === $separator || ! isset( $wpdb->options ) ) {
			return '';
		}

		$token          = substr( $expected_value, 0, $separator );
		$current_expiry = intval( substr( $expected_value, $separator + 1 ) );
		$new_expiry     = max( $current_expiry + 1, time() + self::EMBEDDING_BINARY_LOCK_TTL );
		$new_value      = $token . '|' . $new_expiry;

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
		$updated = $wpdb->query(
			$wpdb->prepare(
				"UPDATE {$wpdb->options} SET option_value = %s WHERE option_name = %s AND option_value = %s",
				$new_value,
				self::EMBEDDING_BINARY_BACKFILL_LOCK,
				$expected_value
			)
		);
		if ( 1 !== intval( $updated ) ) {
			return '';
		}

		wp_cache_delete( self::EMBEDDING_BINARY_BACKFILL_LOCK, 'options' );
		return $new_value;
	}

	/**
	 * Atomically delete only the exact lock value observed by this worker.
	 */
	private function compare_delete_embedding_binary_lock( $expected_value ) {
		global $wpdb;

		$expected_value = (string) $expected_value;
		if ( '' === $expected_value || ! isset( $wpdb->options ) ) {
			return false;
		}

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
		$deleted = $wpdb->query(
			$wpdb->prepare(
				"DELETE FROM {$wpdb->options} WHERE option_name = %s AND option_value = %s",
				self::EMBEDDING_BINARY_BACKFILL_LOCK,
				$expected_value
			)
		);
		if ( 1 === intval( $deleted ) ) {
			wp_cache_delete( self::EMBEDDING_BINARY_BACKFILL_LOCK, 'options' );
			return true;
		}

		return false;
	}

	private function schedule_embedding_binary_backfill_event( $delay_seconds ) {
		if ( ! function_exists( 'wp_schedule_single_event' ) ) {
			return;
		}
		if ( function_exists( 'wp_next_scheduled' ) && wp_next_scheduled( self::EMBEDDING_BINARY_BACKFILL_HOOK ) ) {
			return;
		}
		wp_schedule_single_event( time() + max( 1, intval( $delay_seconds ) ), self::EMBEDDING_BINARY_BACKFILL_HOOK );
	}

	private function embedding_binary_format() {
		return defined( 'Niwa_AI_Content_Store::EMBEDDING_BINARY_FORMAT' )
			? (string) Niwa_AI_Content_Store::EMBEDDING_BINARY_FORMAT
			: 'f32le-v1';
	}

	private function new_embedding_binary_backfill_status( $format ) {
		return array(
			'format'                => (string) $format,
			'cursor'                => 0,
			'retry_round'           => 0,
			'recovery_round'        => 0,
			'failed_ids'            => array(),
			'migrated'              => 0,
			'processed'             => 0,
			'last_error'            => '',
			'consecutive_errors'    => 0,
			'done'                  => false,
			'completed_with_errors' => false,
			'recoverable'           => false,
			'recovery_active'       => false,
			'next_retry_at'         => 0,
		);
	}

	private function defer_embedding_binary_recovery( $status ) {
		$round    = max( 0, intval( $status['recovery_round'] ?? 0 ) ) + 1;
		$exponent = min( 8, max( 0, $round - 1 ) );
		$delay    = min(
			self::EMBEDDING_BINARY_RECOVERY_MAX,
			self::EMBEDDING_BINARY_RECOVERY_BASE * ( 2 ** $exponent )
		);

		$status['done']                  = true;
		$status['completed_with_errors'] = true;
		$status['recoverable']           = true;
		$status['recovery_active']       = false;
		$status['recovery_round']        = $round;
		$status['retry_round']           = 0;
		$status['cursor']                = 0;
		$status['next_retry_at']         = time() + intval( $delay );
		return $status;
	}

    public function handle_upsert( $post_id, $post_type, $update ) {
        if ( ! $this->content_store ) {
            return;
        }

        if ( 'site_knowledge' === $post_type ) {
            $docs = $this->build_site_knowledge_docs();
            if ( ! empty( $docs ) ) {
                $rows = array();
                foreach ( $docs as $doc ) {
                    $chunk_rows = $this->build_chunked_rows( $doc );
                    foreach ( $chunk_rows as $row ) {
                        if ( ! empty( $row ) ) {
                            $rows[] = $row;
                        }
                    }
                }
                if ( ! empty( $rows ) ) {
                    $this->content_store->delete_by_post_type( 'site_knowledge' );
                    $this->content_store->upsert_batch( $rows );
                    // Invalidate cached blobs so the system prompt gets fresh data.
                    delete_transient( 'niwa_ai_site_knowledge_blob' );
                    delete_transient( 'niwa_ai_store_overview' );
                }
            }
            return;
        }

        $doc = $this->build_document( $post_id, $post_type );
        if ( empty( $doc ) ) {
            return;
        }

        $rows = $this->build_chunked_rows( $doc );
        if ( ! empty( $rows ) ) {
            // Delete all existing chunks for this post before inserting new ones.
            $this->content_store->delete_by_doc_key_prefix( $post_type . ':' . intval( $post_id ) );
            $this->content_store->upsert_batch( $rows );
        }
    }

    public function handle_delete( $post_id, $post_type ) {
        if ( ! $this->content_store ) {
            return;
        }

        if ( 'site_knowledge' === $post_type ) {
            $this->content_store->delete_by_post_type( 'site_knowledge' );
            return;
        }

        // Delete all chunks for this post (base doc_key + :c0, :c1, etc.).
        $this->content_store->delete_by_doc_key_prefix( $post_type . ':' . intval( $post_id ) );
    }

    public function handle_rebuild_full() {
        if ( ! $this->content_store ) {
            return;
        }

        $this->content_store->ensure_tables();

        $types = $this->get_indexable_post_types();
        foreach ( $types as $type ) {
            $this->content_store->delete_by_post_type( $type );
        }
        $this->content_store->delete_by_post_type( 'site_knowledge' );

        $batch = array();

        foreach ( $types as $type ) {
            $ids = get_posts(
                array(
                    'post_type'      => $type,
                    'post_status'    => $this->get_post_statuses_for_type( $type ),
                    'posts_per_page' => -1,
                    'fields'         => 'ids',
                    'no_found_rows'  => true,
                )
            );

            foreach ( $ids as $post_id ) {
                $doc = $this->build_document( $post_id, $type );
                if ( ! empty( $doc ) ) {
                    $chunk_rows = $this->build_chunked_rows( $doc );
                    foreach ( $chunk_rows as $row ) {
                        if ( ! empty( $row ) ) {
                            $batch[] = $row;
                        }
                    }
                    if ( count( $batch ) >= 100 ) {
                        $this->content_store->upsert_batch( $batch );
                        $batch = array();
                    }
                }
            }
        }

        if ( ! empty( $batch ) ) {
            $this->content_store->upsert_batch( $batch );
        }

        $docs = $this->build_site_knowledge_docs();
        if ( ! empty( $docs ) ) {
            $rows = array();
            foreach ( $docs as $doc ) {
                $chunk_rows = $this->build_chunked_rows( $doc );
                foreach ( $chunk_rows as $row ) {
                    if ( ! empty( $row ) ) {
                        $rows[] = $row;
                    }
                }
            }
            if ( ! empty( $rows ) ) {
                $this->content_store->delete_by_post_type( 'site_knowledge' );
                $this->content_store->upsert_batch( $rows );
            }
        }

		update_option( self::SEARCH_DOCUMENT_OPTION, self::SEARCH_DOCUMENT_VERSION, false );

		// Rebuilding search documents invalidates embeddings whose content hash
		// changed. Always hand pending rows back to the normal embedding worker;
		// otherwise a killed or unattended cron rebuild could leave semantic
		// product search incomplete indefinitely.
		$embedding_stats = method_exists( $this->content_store, 'get_embedding_stats' )
			? $this->content_store->get_embedding_stats()
			: array();
		if (
			intval( isset( $embedding_stats['pending'] ) ? $embedding_stats['pending'] : 0 ) > 0
			&& ! wp_next_scheduled( 'niwa_ai_process_memory_embeddings' )
		) {
			wp_schedule_single_event( time() + Niwa_AI_Constants::EMBEDDING_QUEUE_DELAY, 'niwa_ai_process_memory_embeddings' );
		}
    }

    private function build_document( $post_id, $post_type ) {
        $post = get_post( $post_id );
        if ( ! $post ) {
            return array();
        }

        if ( ! $this->is_indexable_post_status( $post_type, (string) $post->post_status ) ) {
            return array();
        }

        if ( $this->html_cleaner && $this->html_cleaner->should_skip_post( $post ) ) {
            return array();
        }

        $meta = get_post_meta( $post_id );
        $meta_flat = array();
        foreach ( $meta as $k => $v ) {
            if ( '_product_attributes' === $k ) {
                $meta_flat[ $k ] = ( is_array( $v ) && isset( $v[0] ) ) ? $v[0] : $v;
                continue;
            }
            $meta_flat[ $k ] = is_array( $v ) ? array_map( 'strval', $v ) : (string) $v;
        }

        $image_data = $this->get_image_data( $post_id );
        $tax_data   = $this->get_taxonomy_data( $post_id, $post_type );

		$meta_text = $this->extract_meta_text( $meta_flat );
		$product_search_evidence = 'product' === $post_type && class_exists( 'Niwa_AI_Product_Search_Evidence' )
			? Niwa_AI_Product_Search_Evidence::build( $post_id )
			: array();
		$rlm_text = $this->build_rlm_text( $post, $meta_flat, $tax_data, $image_data );
		if ( ! empty( $product_search_evidence['attributes_text'] ) ) {
			$rlm_text .= "\n" . (string) $product_search_evidence['attributes_text'];
		}

		$doc = array(
            'doc_key'        => $post_type . ':' . intval( $post_id ),
            'post_id'        => intval( $post_id ),
            'post_type'      => $post_type,
            'url'            => get_permalink( $post_id ),
            'slug'           => $post->post_name,
            'title'          => get_the_title( $post_id ),
            'content'        => wp_strip_all_tags( (string) $post->post_content ),
            'content_rendered'=> $this->get_rendered_text( $post ),
            'excerpt'        => wp_strip_all_tags( (string) $post->post_excerpt ),
            'status'         => $post->post_status,
            'meta'           => $meta_flat,
            'meta_text'      => $meta_text,
            'taxonomy'       => $tax_data,
            'image'          => $image_data,
			'custom_attributes' => $this->extract_custom_attributes( $post_id, $meta_flat ),
			'product_search_evidence' => $product_search_evidence,
			'indexed_at'     => time(),
			'rlm_text'       => $rlm_text,
		);

        return $doc;
    }

    private function build_site_knowledge_docs() {
        if ( ! $this->site_knowledge_extractor ) {
            return array();
        }

        $sections = $this->site_knowledge_extractor->extract_full_site_content();
        if ( empty( $sections ) || ! is_array( $sections ) ) {
            return array();
        }

        $docs = array();
        foreach ( $sections as $i => $section ) {
            $title = isset( $section['title'] ) ? (string) $section['title'] : 'Site Knowledge';
            $text  = isset( $section['text'] ) ? (string) $section['text'] : '';
            if ( '' === trim( $text ) ) {
                continue;
            }
            $source = isset( $section['source_type'] ) ? (string) $section['source_type'] : 'site';
            $doc_key = 'site:' . $source . ':' . intval( $i );
            $site_post_id = $this->site_doc_post_id( $doc_key );

            $docs[] = array(
                'doc_key'        => $doc_key,
                'post_id'        => $site_post_id,
                'post_type'      => 'site_knowledge',
                'url'            => isset( $section['url'] ) ? (string) $section['url'] : home_url( '/' ),
                'slug'           => sanitize_title( $title ),
                'title'          => $title,
                'content'        => $text,
                'content_rendered'=> $text,
                'excerpt'        => '',
                'status'         => 'publish',
                'meta'           => array(),
                'meta_text'      => '',
                'taxonomy'       => array(),
                'image'          => array(),
                'custom_attributes' => array(),
                'indexed_at'     => time(),
                'rlm_text'       => $text,
            );
        }

        return $docs;
    }

    private function site_doc_post_id( $doc_key ) {
        $hash = crc32( (string) $doc_key );
        if ( $hash < 0 ) {
            $hash = $hash * -1;
        }
        // Keep synthetic IDs in a high range to avoid collisions with real post IDs.
        return 2000000000 + intval( $hash % 1000000000 );
    }

    /**
     * Split long text into chunks of approximately $max_chunk characters.
     *
     * Splits on paragraph boundaries (\n\n) first, then on line boundaries (\n).
     * Each chunk is at most $max_chunk chars (unless a single paragraph exceeds it).
     *
     * @param string $text      The text to chunk.
     * @param int    $max_chunk Target maximum characters per chunk.
     * @return array Array of text chunks (at least 1 element).
     */
    private function chunk_text( $text, $max_chunk = null ) {
        $text = trim( (string) $text );
        if ( '' === $text ) {
            return array( '' );
        }

        if ( null === $max_chunk ) {
            $max_chunk = Niwa_AI_Constants::CHUNK_SIZE_CHARS;
        }
        $max_chunk = max( 500, intval( $max_chunk ) );

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

        // Split into paragraphs.
        $paragraphs = preg_split( '/\n\s*\n/', $text );
        if ( ! is_array( $paragraphs ) || empty( $paragraphs ) ) {
            return array( $text );
        }

        $chunks  = array();
        $current = '';

        foreach ( $paragraphs as $para ) {
            $para = trim( $para );
            if ( '' === $para ) {
                continue;
            }

            // If a single paragraph exceeds the limit, split it by lines.
            if ( mb_strlen( $para ) > $max_chunk ) {
                // Flush current buffer first.
                if ( '' !== $current ) {
                    $chunks[] = $current;
                    $current  = '';
                }
                $lines = preg_split( '/\n/', $para );
                $lines = is_array( $lines ) ? $lines : array( $para );
                foreach ( $lines as $line ) {
                    $line = trim( $line );
                    if ( '' === $line ) {
                        continue;
                    }
                    if ( '' === $current ) {
                        $current = $line;
                    } elseif ( mb_strlen( $current ) + mb_strlen( $line ) + 1 <= $max_chunk ) {
                        $current .= "\n" . $line;
                    } else {
                        $chunks[] = $current;
                        $current  = $line;
                    }
                }
                continue;
            }

            if ( '' === $current ) {
                $current = $para;
            } elseif ( mb_strlen( $current ) + mb_strlen( $para ) + 2 <= $max_chunk ) {
                $current .= "\n\n" . $para;
            } else {
                $chunks[] = $current;
                $current  = $para;
            }
        }

        if ( '' !== $current ) {
            $chunks[] = $current;
        }

        return ! empty( $chunks ) ? $chunks : array( $text );
    }

    /**
     * Build one or more table rows from a document, chunking long content.
     *
     * If the document's content is within the text threshold, returns one row.
     * If longer, splits into configured chunks, each becoming its own row with
     * doc_key suffixed as :c0, :c1, etc. All metadata (price, brand, etc.) is
     * preserved across chunks.
     *
     * @param array $doc       Document array from build_document() or build_site_knowledge_docs().
     * @param int   $max_chunk Target chunk size in characters.
     * @return array Array of row arrays suitable for upsert_batch().
     */
    private function build_chunked_rows( $doc, $max_chunk = null ) {
        if ( empty( $doc ) ) {
            return array();
        }
        if ( null === $max_chunk ) {
            $max_chunk = Niwa_AI_Constants::CHUNK_SIZE_CHARS;
        }

        // Determine the chunkable text.
        $chunkable = '';
        if ( ! empty( $doc['content_rendered'] ) ) {
            $chunkable = (string) $doc['content_rendered'];
        } elseif ( ! empty( $doc['content'] ) ) {
            $chunkable = (string) $doc['content'];
        }

        // Short content — no chunking needed.
        if ( mb_strlen( $chunkable ) <= Niwa_AI_Constants::TEXT_THRESHOLD_CHARS ) {
            $row = $this->build_table_row( $doc );
            return ! empty( $row ) ? array( $row ) : array();
        }

        // Chunk the text.
        $text_chunks = $this->chunk_text( $chunkable, $max_chunk );

        $rows         = array();
        $original_key = isset( $doc['doc_key'] ) ? (string) $doc['doc_key'] : '';

        foreach ( $text_chunks as $i => $chunk ) {
            $chunk_doc                     = $doc;
            $chunk_doc['doc_key']          = $original_key . ':c' . intval( $i );
            $chunk_doc['content']          = $chunk;
            $chunk_doc['content_rendered'] = $chunk;
            $chunk_doc['rlm_text']         = $chunk;
			if ( $i > 0 ) {
				$chunk_doc['meta_text'] = '';
			}

            $row = $this->build_table_row( $chunk_doc );
            if ( ! empty( $row ) ) {
                $rows[] = $row;
            }
        }

        return $rows;
    }

    private function build_table_row( $doc ) {
        if ( ! $this->content_store || empty( $doc ) ) {
            return array();
        }

        return $this->content_store->build_row_from_doc( $doc );
    }

    private function get_rendered_text( $post ) {
        $raw = isset( $post->post_content ) ? (string) $post->post_content : '';
        $rendered = apply_filters( 'the_content', $raw );
        $rendered = do_shortcode( $rendered );

        if ( $this->html_cleaner ) {
            $rendered = $this->html_cleaner->clean( $rendered );
        } else {
            $rendered = wp_strip_all_tags( $rendered );
        }

        $block_text = $this->extract_block_text( $raw );

        $combined = trim( $rendered . "\n" . $block_text );
        $combined = preg_replace( '/\s{2,}/', ' ', $combined );

        return trim( (string) $combined );
    }

    private function extract_block_text( $raw_content ) {
        if ( ! function_exists( 'parse_blocks' ) ) {
            return '';
        }

        $blocks = parse_blocks( (string) $raw_content );
        if ( empty( $blocks ) || ! is_array( $blocks ) ) {
            return '';
        }

        $parts = array();
        $this->collect_block_text_recursive( $blocks, $parts );

        $text = implode( "\n", array_filter( $parts ) );
        $text = wp_strip_all_tags( $text );
        $text = preg_replace( '/\s{2,}/', ' ', $text );

        return trim( (string) $text );
    }

    private function collect_block_text_recursive( $blocks, &$parts ) {
        foreach ( (array) $blocks as $block ) {
            if ( ! is_array( $block ) ) {
                continue;
            }

            $inner_html = isset( $block['innerHTML'] ) ? (string) $block['innerHTML'] : '';
            $inner_text = trim( wp_strip_all_tags( $inner_html ) );
            if ( '' !== $inner_text ) {
                $parts[] = $inner_text;
            }

            if ( ! empty( $block['attrs'] ) && is_array( $block['attrs'] ) ) {
                foreach ( $block['attrs'] as $k => $v ) {
                    if ( is_scalar( $v ) ) {
                        $val = trim( (string) $v );
                        if ( '' !== $val ) {
                            $parts[] = strtoupper( (string) $k ) . ': ' . $val;
                        }
                    }
                }
            }

            if ( ! empty( $block['innerBlocks'] ) && is_array( $block['innerBlocks'] ) ) {
                $this->collect_block_text_recursive( $block['innerBlocks'], $parts );
            }
        }
    }

    private function get_image_data( $post_id ) {
        $image_id = get_post_thumbnail_id( $post_id );
        if ( ! $image_id ) {
            return array();
        }

        return array(
            'id'          => intval( $image_id ),
            'alt'         => (string) get_post_meta( $image_id, '_wp_attachment_image_alt', true ),
            'caption'     => wp_get_attachment_caption( $image_id ),
            'description' => (string) get_post_field( 'post_content', $image_id ),
        );
    }

    private function get_taxonomy_data( $post_id, $post_type ) {
        $tax_data = array();

        $taxes = get_object_taxonomies( $post_type, 'names' );
        if ( empty( $taxes ) || ! is_array( $taxes ) ) {
            return $tax_data;
        }

        foreach ( $taxes as $tax ) {
            $terms = get_the_terms( $post_id, $tax );
            if ( is_wp_error( $terms ) || empty( $terms ) ) {
                continue;
            }

			if ( 'product_cat' !== (string) $tax ) {
				$tax_data[ $tax ] = array_map(
					function( $t ) {
						return array(
							'id'   => intval( $t->term_id ),
							'name' => (string) $t->name,
							'slug' => (string) $t->slug,
						);
					},
					$terms
				);
				continue;
			}

			/*
			 * Preserve both the directly assigned category and its hierarchy.
			 * Ancestors are explicit taxonomy relationships, not inferred product
			 * properties. Including them as relationship-marked entries lets the
			 * existing category/memory text consumers find broader concepts while
			 * retaining the exact assigned-vs-ancestor distinction in taxonomy_json.
			 */
			$category_entries = array();
			foreach ( $terms as $term ) {
				$ancestor_entries = array();
				$ancestor_ids     = function_exists( 'get_ancestors' )
					? array_reverse( (array) get_ancestors( intval( $term->term_id ), 'product_cat', 'taxonomy' ) )
					: array();
				foreach ( $ancestor_ids as $ancestor_id ) {
					$ancestor = get_term( intval( $ancestor_id ), 'product_cat' );
					if ( ! $ancestor || is_wp_error( $ancestor ) ) {
						continue;
					}
					$ancestor_entries[] = array(
						'id'   => intval( $ancestor->term_id ),
						'name' => (string) $ancestor->name,
						'slug' => (string) $ancestor->slug,
					);
				}

				$path = array_merge(
					$ancestor_entries,
					array(
						array(
							'id'   => intval( $term->term_id ),
							'name' => (string) $term->name,
							'slug' => (string) $term->slug,
						),
					)
				);

				foreach ( $ancestor_entries as $ancestor_entry ) {
					$ancestor_id = intval( $ancestor_entry['id'] );
					if ( ! isset( $category_entries[ $ancestor_id ] ) ) {
						$category_entries[ $ancestor_id ] = array_merge(
							$ancestor_entry,
							array(
								'assigned'     => false,
								'relationship' => 'ancestor',
							)
						);
					}
				}

				$category_entries[ intval( $term->term_id ) ] = array(
					'id'           => intval( $term->term_id ),
					'name'         => (string) $term->name,
					'slug'         => (string) $term->slug,
					'assigned'     => true,
					'relationship' => 'assigned',
					'ancestors'    => $ancestor_entries,
					'path'         => $path,
					'path_text'    => implode( ' > ', array_column( $path, 'name' ) ),
				);
			}

			$tax_data[ $tax ] = array_values( $category_entries );
        }

        return $tax_data;
    }

    private function build_rlm_text( $post, $meta, $tax, $image ) {
        $parts = array();
        $parts[] = 'URL: ' . get_permalink( $post->ID );
        $parts[] = 'Slug: ' . $post->post_name;
        $parts[] = 'Title: ' . get_the_title( $post->ID );
        $parts[] = 'Content: ' . wp_strip_all_tags( (string) $post->post_content );
        $parts[] = 'Rendered Content: ' . $this->get_rendered_text( $post );
        $parts[] = 'Excerpt: ' . wp_strip_all_tags( (string) $post->post_excerpt );

        if ( ! empty( $image ) ) {
            $parts[] = 'Image alt: ' . ( isset( $image['alt'] ) ? $image['alt'] : '' );
            $parts[] = 'Image caption: ' . ( isset( $image['caption'] ) ? $image['caption'] : '' );
            $parts[] = 'Image description: ' . ( isset( $image['description'] ) ? $image['description'] : '' );
        }

        if ( ! empty( $tax ) ) {
            foreach ( $tax as $tax_name => $terms ) {
                $names = array();
                foreach ( $terms as $term ) {
                    if ( ! empty( $term['name'] ) ) {
                        $names[] = $term['name'];
                    }
                }
                if ( ! empty( $names ) ) {
                    $parts[] = strtoupper( $tax_name ) . ': ' . implode( ', ', $names );
                }
            }
        }

        if ( ! empty( $meta ) ) {
            $meta_lines = array();
            foreach ( $meta as $k => $v ) {
                if ( is_array( $v ) ) {
                    $meta_lines[] = $k . '=' . implode( '|', array_map( 'strval', $v ) );
                } else {
                    $meta_lines[] = $k . '=' . (string) $v;
                }
            }
            $parts[] = 'META: ' . implode( '; ', $meta_lines );
        }

        $text = trim( implode( "\n", $parts ) );
        return $this->filter_index_noise( $text );
    }

    public function get_indexable_post_types() {
        $types = array( 'page', 'post', 'product', 'elementor_library', 'wp_template', 'wp_template_part', 'wp_block', 'wp_navigation' );
        return apply_filters( 'niwa_ai_index_post_types', $types );
    }

    private function get_post_statuses_for_type( $post_type ) {
        $template_like = array( 'elementor_library', 'wp_template', 'wp_template_part', 'wp_block', 'wp_navigation' );
        if ( in_array( (string) $post_type, $template_like, true ) ) {
            return array( 'publish', 'private', 'inherit', 'draft' );
        }
        return 'publish';
    }

    private function is_indexable_post_status( $post_type, $post_status ) {
        $allowed = $this->get_post_statuses_for_type( $post_type );
        if ( is_array( $allowed ) ) {
            return in_array( (string) $post_status, $allowed, true );
        }
        return (string) $allowed === (string) $post_status;
    }

    private function extract_custom_attributes( $post_id, $meta_flat ) {
        $out = array();

        if ( function_exists( 'wc_get_product' ) ) {
            $product = wc_get_product( $post_id );
            if ( $product && method_exists( $product, 'get_attributes' ) ) {
                foreach ( (array) $product->get_attributes() as $attr ) {
                    if ( ! is_object( $attr ) || ! method_exists( $attr, 'is_taxonomy' ) ) {
                        continue;
                    }
                    if ( $attr->is_taxonomy() ) {
                        continue;
                    }
                    $name = method_exists( $attr, 'get_name' ) ? (string) $attr->get_name() : '';
                    $vals = method_exists( $attr, 'get_options' ) ? (array) $attr->get_options() : array();
                    $vals = array_values( array_filter( array_map( 'strval', $vals ) ) );
                    if ( '' !== $name && ! empty( $vals ) ) {
                        $out[ $name ] = $vals;
                    }
                }
            }
        }

        if ( empty( $out ) && ! empty( $meta_flat['_product_attributes'] ) && is_array( $meta_flat['_product_attributes'] ) ) {
            foreach ( $meta_flat['_product_attributes'] as $key => $attr ) {
                if ( empty( $attr ) || ! is_array( $attr ) ) {
                    continue;
                }
                if ( ! empty( $attr['is_taxonomy'] ) ) {
                    continue;
                }
                $name = isset( $attr['name'] ) ? (string) $attr['name'] : (string) $key;
                $value = isset( $attr['value'] ) ? (string) $attr['value'] : '';
                if ( '' === $name || '' === $value ) {
                    continue;
                }
                $parts = preg_split( '/\s*\|\s*|\s*,\s*/', $value );
                $parts = is_array( $parts ) ? array_values( array_filter( array_map( 'trim', $parts ) ) ) : array();
                if ( ! empty( $parts ) ) {
                    $out[ $name ] = $parts;
                }
            }
        }

        return $out;
    }

    private function extract_meta_text( $meta_flat ) {
        if ( empty( $meta_flat ) || ! is_array( $meta_flat ) ) {
            return '';
        }

        $parts = array();
        foreach ( $meta_flat as $key => $value ) {
			$key = (string) $key;
			if ( 0 === strpos( $key, '_' ) ) {
				continue;
			}
            $values = array();
            $this->flatten_meta_value( $value, $values );
            if ( empty( $values ) ) {
                continue;
            }
            foreach ( $values as $val ) {
                $text = $this->sanitize_meta_text( $val );
                if ( '' === $text ) {
                    continue;
                }
                if ( ! preg_match( '/\p{L}/u', $text ) ) {
                    continue;
                }
                $parts[] = (string) $key . ': ' . $text;
            }
        }

        if ( empty( $parts ) ) {
            return '';
        }

        $text = implode( "\n", $parts );
        $text = preg_replace( '/\s{2,}/', ' ', $text );
        return trim( (string) $text );
    }

    private function flatten_meta_value( $value, &$out ) {
        if ( is_string( $value ) ) {
            $maybe = maybe_unserialize( $value );
            if ( $maybe !== $value ) {
                $this->flatten_meta_value( $maybe, $out );
                return;
            }
            $trimmed = trim( $value );
            if ( '' !== $trimmed && ( '{' === $trimmed[0] || '[' === $trimmed[0] ) ) {
                $decoded = json_decode( $trimmed, true );
                if ( is_array( $decoded ) ) {
                    $this->flatten_meta_value( $decoded, $out );
                    return;
                }
            }
            $out[] = $value;
            return;
        }

        if ( is_numeric( $value ) ) {
            $out[] = (string) $value;
            return;
        }

        if ( is_array( $value ) ) {
            foreach ( $value as $item ) {
                $this->flatten_meta_value( $item, $out );
            }
            return;
        }

        if ( is_object( $value ) ) {
            $this->flatten_meta_value( (array) $value, $out );
        }
    }

    private function sanitize_meta_text( $value ) {
        $text = is_string( $value ) ? $value : ( is_numeric( $value ) ? (string) $value : '' );
        if ( '' === $text ) {
            return '';
        }

        $text = preg_replace( '/<script\\b[^>]*>(.*?)<\\/script>/is', ' ', $text );
        $text = preg_replace( '/<style\\b[^>]*>(.*?)<\\/style>/is', ' ', $text );
        $text = preg_replace( '/<code\\b[^>]*>(.*?)<\\/code>/is', ' ', $text );
        $text = preg_replace( '/```.*?```/s', ' ', $text );
        $text = preg_replace( '/<\\?php.*?\\?>/s', ' ', $text );
        if ( function_exists( 'strip_shortcodes' ) ) {
            $text = strip_shortcodes( $text );
        }
        $text = wp_strip_all_tags( $text );
        $text = html_entity_decode( $text, ENT_QUOTES | ENT_HTML5 );
        $text = preg_replace( '/\\s{2,}/', ' ', $text );
        return trim( (string) $text );
    }

    private function filter_index_noise( $text ) {
        $text = (string) $text;
        $text = preg_replace( '/\s{2,}/', ' ', $text );
        return trim( $text );
    }
}
