<?php
/**
 * Brand Card Endpoint (Week 27.6).
 *
 * Admin-only endpoints for managing the global BrandCard and generating About summaries.
 *
 * Routes:
 * - GET  /wp-json/boostbot/v1/brand-card
 * - POST /wp-json/boostbot/v1/brand-card
 * - GET  /wp-json/boostbot/v1/brand-card/about-candidates
 * - POST /wp-json/boostbot/v1/brand-card/regenerate-preview
 * - GET  /wp-json/boostbot/v1/brand-card/page-candidates
 * - POST /wp-json/boostbot/v1/brand-card/autofill-preview
 * - POST /wp-json/boostbot/v1/brand-card/autofill-apply
 *
 * @package BoostBot
 * @since   1.9.0
 */

// phpcs:disable WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
if ( ! defined( 'ABSPATH' ) ) {
    exit;
}

class BoostBot_Brand_Card_Endpoint extends BoostBot_Base_Endpoint {

    protected $rest_base = 'brand-card';

    public function register() {
        $this->register_route( '', 'GET', array( $this, 'get_brand_card' ), array( $this, 'admin_access' ) );
        $this->register_route( '', 'POST', array( $this, 'update_brand_card' ), array( $this, 'admin_access' ) );
        $this->register_route( '/about-candidates', 'GET', array( $this, 'get_about_candidates' ), array( $this, 'admin_access' ) );
        $this->register_route( '/regenerate-preview', 'POST', array( $this, 'regenerate_preview' ), array( $this, 'admin_access' ) );
        $this->register_route( '/page-candidates', 'GET', array( $this, 'get_page_candidates' ), array( $this, 'admin_access' ) );
        $this->register_route( '/autofill-preview', 'POST', array( $this, 'autofill_preview' ), array( $this, 'admin_access' ) );
        $this->register_route( '/autofill-apply', 'POST', array( $this, 'autofill_apply' ), array( $this, 'admin_access' ) );
        $this->register_route( '/services-autofill-preview', 'POST', array( $this, 'services_autofill_preview' ), array( $this, 'admin_access' ) );
        $this->register_route( '/table-autofill-preview', 'POST', array( $this, 'table_autofill_preview' ), array( $this, 'admin_access' ) );
    }

    /**
     * GET global BrandCard.
     */
    public function get_brand_card( $request ) {
        $language = $this->get_request_language( $request );

        $json = (string) BoostBot_Settings::get_for_language( 'brand_card_global_json', $language, '' );
        $ver  = absint( BoostBot_Settings::get_for_language( 'brand_card_global_version', $language, 1 ) ) ?: 1;

        $parsed = $this->decode_json_object( $json );
        if ( null === $parsed ) {
            $parsed = array();
        }

        return $this->success(
            array(
                'brand_card' => $parsed,
                'version'    => $ver,
            )
        );
    }

    /**
     * POST update global BrandCard (stores JSON string and bumps version).
     */
    public function update_brand_card( $request ) {
        $payload = $request->get_json_params();
        if ( ! is_array( $payload ) ) {
            return $this->error( __( 'Request body is not valid JSON.', 'boostbot' ), 400 );
        }

        $brand_card = isset( $payload['brand_card'] ) && is_array( $payload['brand_card'] ) ? $payload['brand_card'] : null;
        if ( ! is_array( $brand_card ) ) {
            return $this->error( __( 'Missing brand_card object in request.', 'boostbot' ), 400 );
        }

        $language = $this->get_request_language( $request );

        $existing = $this->decode_json_object(
            (string) BoostBot_Settings::get_for_language( 'brand_card_global_json', $language, '' )
        );
        if ( ! is_array( $existing ) ) {
            $existing = array();
        }

        $out = array(
            'company_name'      => isset( $brand_card['company_name'] ) ? sanitize_text_field( (string) $brand_card['company_name'] ) : ( $existing['company_name'] ?? '' ),
            'one_liner'         => isset( $brand_card['one_liner'] ) ? sanitize_text_field( (string) $brand_card['one_liner'] ) : ( $existing['one_liner'] ?? '' ),
            'about_summary'     => isset( $brand_card['about_summary'] ) ? sanitize_textarea_field( (string) $brand_card['about_summary'] ) : ( $existing['about_summary'] ?? '' ),
            'phone'             => isset( $brand_card['phone'] ) ? sanitize_text_field( (string) $brand_card['phone'] ) : ( $existing['phone'] ?? '' ),
            'whatsapp_number'   => isset( $brand_card['whatsapp_number'] ) ? sanitize_text_field( (string) $brand_card['whatsapp_number'] ) : ( $existing['whatsapp_number'] ?? '' ),
            'primary_cta_text'  => isset( $brand_card['primary_cta_text'] ) ? sanitize_text_field( (string) $brand_card['primary_cta_text'] ) : ( $existing['primary_cta_text'] ?? '' ),
            'primary_cta_url'   => isset( $brand_card['primary_cta_url'] ) ? esc_url_raw( (string) $brand_card['primary_cta_url'] ) : ( $existing['primary_cta_url'] ?? '' ),
            'about_source_mode' => isset( $brand_card['about_source_mode'] ) ? sanitize_text_field( (string) $brand_card['about_source_mode'] ) : ( $existing['about_source_mode'] ?? 'auto' ),
            'about_source_post_id' => isset( $brand_card['about_source_post_id'] ) ? absint( $brand_card['about_source_post_id'] ) : ( isset( $existing['about_source_post_id'] ) ? absint( $existing['about_source_post_id'] ) : 0 ),
            'services'          => $this->sanitize_services_list( isset( $brand_card['services'] ) ? $brand_card['services'] : ( $existing['services'] ?? null ) ),
            'tables'            => $this->sanitize_tables( isset( $brand_card['tables'] ) ? $brand_card['tables'] : ( $existing['tables'] ?? null ) ),
        );

        // Clamp sizes.
        if ( mb_strlen( $out['one_liner'] ) > 160 ) {
            $out['one_liner'] = mb_substr( $out['one_liner'], 0, 160 );
        }
        if ( mb_strlen( $out['about_summary'] ) > 900 ) {
            $out['about_summary'] = mb_substr( $out['about_summary'], 0, 900 );
        }
        if ( mb_strlen( $out['phone'] ) > 60 ) {
            $out['phone'] = mb_substr( $out['phone'], 0, 60 );
        }
        if ( mb_strlen( $out['whatsapp_number'] ) > 60 ) {
            $out['whatsapp_number'] = mb_substr( $out['whatsapp_number'], 0, 60 );
        }

        $json = wp_json_encode( $out, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES );
        if ( false === $json ) {
            return $this->error( __( 'Internal error: unable to save brand card (JSON).', 'boostbot' ), 500 );
        }

        BoostBot_Settings::set_for_language( 'brand_card_global_json', $language, (string) $json );
        $ver = absint( BoostBot_Settings::get_for_language( 'brand_card_global_version', $language, 1 ) ) ?: 1;
        BoostBot_Settings::set_for_language( 'brand_card_global_version', $language, (string) ( $ver + 1 ) );

        return $this->success(
            array(
                'saved'   => true,
                'version' => $ver + 1,
            )
        );
    }

    /**
     * GET page candidates for the "page selector" in Services List.
     *
     * Hebrew-first heuristic: skip obvious English URLs (/en/ or lang=en).
     */
    public function get_page_candidates( $request ) {
        $language = $this->get_request_language( $request );

        $posts = get_posts(
            array(
                'post_type'      => array( 'page' ),
                'post_status'    => 'publish',
                'posts_per_page' => 200,
                'orderby'        => 'title',
                'order'          => 'ASC',
                's'              => '',
            )
        );

        $items = array();
        foreach ( $posts as $p ) {
            if ( ! ( $p instanceof WP_Post ) ) {
                continue;
            }
            $url = urldecode( (string) get_permalink( $p ) );

            if ( 'he' === $language ) {
                if ( false !== strpos( $url, '/en/' ) || false !== strpos( $url, 'lang=en' ) ) {
                    continue;
                }
            } elseif ( 'en' === $language ) {
                if ( false !== strpos( $url, '/he/' ) || false !== strpos( $url, 'lang=he' ) ) {
                    continue;
                }
            }

            $items[] = array(
                'post_id' => (int) $p->ID,
                'title'   => (string) get_the_title( $p ),
                'url'     => $url,
            );
        }

        return $this->success(
            array(
                'items' => $items,
            )
        );
    }

    /**
     * POST BrandCard+Tables autofill preview — two-phase Gemini flow.
     *
     * Phase 1 (Plan): Gemini receives page catalog (titles + excerpts) and picks
     *                  which pages to read in full.
     * Phase 2 (Generate): Gemini receives full page content for selected pages
     *                      and produces a comprehensive Brand Card with tables.
     *
     * Data sources (priority):
     *  1) Knowledge Index (if site has been indexed) — gives richer excerpts + full_content.
     *  2) WP nav-menus + page text extraction (fallback for non-indexed sites).
     *
     * Input:
     * - max_pages (optional, default 25, cap 40)
     * - about_post_id (optional)
     */
    public function autofill_preview( $request ) {
        $payload = $request->get_json_params();
        if ( ! is_array( $payload ) ) {
            $payload = array();
        }

        $max_pages   = isset( $payload['max_pages'] ) ? absint( $payload['max_pages'] ) : 25;
        $max_pages   = max( 1, min( 40, $max_pages ) );
        $about_post  = isset( $payload['about_post_id'] ) ? absint( $payload['about_post_id'] ) : 0;
        $language    = $this->get_request_language( $request );

        $existing_json = (string) BoostBot_Settings::get_for_language( 'brand_card_global_json', $language, '' );
        $existing_obj  = $this->decode_json_object( $existing_json );
        if ( ! is_array( $existing_obj ) ) {
            $existing_obj = array();
        }

        if ( ! class_exists( 'BoostBot_Indexing_Credits_Backend_Client' ) || ! class_exists( 'BoostBot_Credits_Backend_Client' ) ) {
            return $this->error( __( 'Internal error: backend communication components are not available.', 'boostbot' ), 500 );
        }

        $idx_client = new BoostBot_Indexing_Credits_Backend_Client();
        $site_id    = $idx_client->ensure_site_id();
        if ( is_wp_error( $site_id ) ) {
            return $this->error( $site_id->get_error_message(), 500 );
        }
        $client = new BoostBot_Credits_Backend_Client();

        // -----------------------------------------------------------
        // Step 1: Build page catalog (title + excerpt + post_id)
        // -----------------------------------------------------------
        $has_index  = false;
        $catalog    = array();
        $index_data = array();

        if ( class_exists( 'BoostBot_Knowledge_Index' ) ) {
            $ki    = new BoostBot_Knowledge_Index();
            $stats = $ki->get_stats();
            if ( isset( $stats['total'] ) && (int) $stats['total'] > 0 ) {
                $has_index = true;
                global $wpdb;
                $table_name = self::sanitize_identifier( $wpdb->prefix . 'boostbot_knowledge_index' );
                // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table identifier sanitized via sanitize_identifier(); values bind via prepare().
                $indexed_rows = $wpdb->get_results(
                    "SELECT id, content_type, content_id AS post_id, title, url, summary, full_content, is_enabled
                     FROM {$table_name}
                     WHERE is_enabled = 1
                     ORDER BY relevance_score DESC, updated_at DESC
                     LIMIT 200",
                    ARRAY_A
                );
                if ( is_array( $indexed_rows ) ) {
                    foreach ( $indexed_rows as $row ) {
                        $pid = absint( $row['post_id'] ?? 0 );
                        if ( 0 === $pid ) {
                            continue;
                        }
                        $url = isset( $row['url'] ) ? (string) $row['url'] : '';
                        if ( false !== strpos( $url, '/en/' ) || false !== strpos( $url, 'lang=en' ) ) {
                            continue;
                        }
                        $excerpt = '';
                        if ( ! empty( $row['summary'] ) ) {
                            $excerpt = mb_substr( (string) $row['summary'], 0, 500 );
                        } elseif ( ! empty( $row['full_content'] ) ) {
                            $excerpt = mb_substr( (string) $row['full_content'], 0, 500 );
                        }
                        $catalog[] = array(
                            'post_id'      => $pid,
                            'title'        => (string) ( $row['title'] ?? '' ),
                            'url'          => $url,
                            'excerpt'      => $excerpt,
                            'content_type' => (string) ( $row['content_type'] ?? 'page' ),
                        );
                        $index_data[ $pid ] = $row;
                    }
                }
            }
        }

        if ( empty( $catalog ) ) {
            $page_ids = $this->get_service_candidate_page_ids( $max_pages );
            $front_id = absint( get_option( 'page_on_front' ) );
            if ( $front_id > 0 && ! in_array( $front_id, $page_ids, true ) ) {
                array_unshift( $page_ids, $front_id );
            }
            $contact_post = $this->find_contact_page_id();
            if ( $contact_post > 0 && ! in_array( $contact_post, $page_ids, true ) ) {
                array_unshift( $page_ids, $contact_post );
            }
            $page_ids = array_slice( array_values( array_unique( array_filter( $page_ids ) ) ), 0, $max_pages );

            foreach ( $page_ids as $pid ) {
                $p = get_post( $pid );
                if ( ! $p ) {
                    continue;
                }
                $url = urldecode( (string) get_permalink( $p ) );
                if ( false !== strpos( $url, '/en/' ) || false !== strpos( $url, 'lang=en' ) ) {
                    continue;
                }
                $catalog[] = array(
                    'post_id'      => (int) $pid,
                    'title'        => (string) get_the_title( $p ),
                    'url'          => $url,
                    'excerpt'      => mb_substr( $this->extract_post_text_capped( (int) $pid, 500 ), 0, 500 ),
                    'content_type' => (string) $p->post_type,
                );
            }
        }

        if ( empty( $catalog ) ) {
            return $this->error( __( 'No pages found on the site for auto-fill. Please index the site first.', 'boostbot' ), 400 );
        }

        $idempotency_key = 'brandcard_autofill:' . wp_generate_uuid4();
        $delta_chunks    = min( 50, max( 1, count( $catalog ) + 1 ) );

        $consumed = $idx_client->consume(
            (string) $site_id,
            array(
                'job_id'           => 'brandcard_autofill',
                'input_tokens'     => 0,
                'output_tokens'    => 0,
                'provider'         => 'google',
                'model'            => 'gemini-2.5-flash',
                'delta_chunks'     => $delta_chunks,
                'idempotency_key'  => $idempotency_key,
            )
        );
        if ( is_wp_error( $consumed ) ) {
            return $this->error( $consumed->get_error_message(), 402 );
        }

        // -----------------------------------------------------------
        // Phase 1: Send catalog to Gemini — get selected page IDs
        // -----------------------------------------------------------
        $plan_resp = $client->rag_brand_card_autofill_plan(
            (string) $site_id,
            array(
                'pages'         => array_values( array_slice( $catalog, 0, 150 ) ),
                'language_hint' => $language,
            )
        );

        $selected_ids = array();
        if ( ! is_wp_error( $plan_resp ) && isset( $plan_resp['selected_page_ids'] ) && is_array( $plan_resp['selected_page_ids'] ) ) {
            $selected_ids = array_map( 'absint', $plan_resp['selected_page_ids'] );
        }

        if ( empty( $selected_ids ) ) {
            $selected_ids = array_map( function( $c ) { return (int) $c['post_id']; }, array_slice( $catalog, 0, $max_pages ) );
        }

        $front_id = absint( get_option( 'page_on_front' ) );
        if ( $front_id > 0 && ! in_array( $front_id, $selected_ids, true ) ) {
            array_unshift( $selected_ids, $front_id );
        }
        $contact_id = $this->find_contact_page_id();
        if ( $contact_id > 0 && ! in_array( $contact_id, $selected_ids, true ) ) {
            $selected_ids[] = $contact_id;
        }

        $selected_ids = array_values( array_unique( array_filter( $selected_ids ) ) );
        $selected_ids = array_slice( $selected_ids, 0, $max_pages );

        // -----------------------------------------------------------
        // Phase 2: Gather FULL content for selected pages
        // -----------------------------------------------------------
        $pages       = array();
        $about_text  = '';
        $about_title = '';
        $about_url   = '';
        $max_text_per_page = $has_index ? 6000 : 3000;

        foreach ( $selected_ids as $pid ) {
            $title = '';
            $url   = '';
            $text  = '';

            if ( isset( $index_data[ $pid ] ) ) {
                $row   = $index_data[ $pid ];
                $title = (string) ( $row['title'] ?? '' );
                $url   = (string) ( $row['url'] ?? '' );
                $text  = (string) ( $row['full_content'] ?? '' );
                if ( '' === $text && ! empty( $row['summary'] ) ) {
                    $text = (string) $row['summary'];
                }
            }

            if ( mb_strlen( $text ) < 200 ) {
                $p = get_post( $pid );
                if ( $p ) {
                    if ( '' === $title ) {
                        $title = (string) get_the_title( $p );
                    }
                    if ( '' === $url ) {
                        $url = urldecode( (string) get_permalink( $p ) );
                    }
                    $extracted = $this->extract_post_text_capped( $pid, $max_text_per_page );
                    if ( mb_strlen( $extracted ) > mb_strlen( $text ) ) {
                        $text = $extracted;
                    }
                }
            }

            if ( '' === trim( $text ) || '' === $title ) {
                continue;
            }
            if ( false !== strpos( $url, '/en/' ) || false !== strpos( $url, 'lang=en' ) ) {
                continue;
            }

            $text = mb_substr( $text, 0, $max_text_per_page );

            $pages[] = array(
                'title'   => $title,
                'url'     => $url,
                'text'    => $text,
                'post_id' => (int) $pid,
            );
        }

        if ( empty( $pages ) ) {
            return $this->error( __( 'Unable to extract content from the selected pages. Please ensure the site is indexed and try again.', 'boostbot' ), 400 );
        }

        if ( 0 === $about_post ) {
            $about_detection = $this->detect_about_source_auto( $max_pages );
            $about_post = isset( $about_detection['post_id'] ) ? absint( $about_detection['post_id'] ) : 0;
            if ( 0 === $about_post && isset( $existing_obj['about_source_post_id'] ) ) {
                $about_post = absint( $existing_obj['about_source_post_id'] );
            }
        }

        if ( $about_post > 0 ) {
            foreach ( $pages as $pg ) {
                if ( (int) $pg['post_id'] === $about_post ) {
                    $about_title = $pg['title'];
                    $about_url   = $pg['url'];
                    $about_text  = $pg['text'];
                    break;
                }
            }
            if ( '' === $about_text ) {
                $about_text  = $this->extract_post_text_capped( $about_post, 12000 );
                $p = get_post( $about_post );
                if ( $p ) {
                    $about_title = (string) get_the_title( $p );
                    $about_url   = urldecode( (string) get_permalink( $p ) );
                }
            }
        }
        if ( '' === trim( $about_text ) ) {
            $about_title = $pages[0]['title'];
            $about_url   = $pages[0]['url'];
            $about_text  = $pages[0]['text'];
        }

        // -----------------------------------------------------------
        // Phase 2: Call backend Gemini with full content
        // -----------------------------------------------------------
        $req_body = array(
            'about_source'  => array(
                'title' => $about_title,
                'url'   => $about_url,
                'text'  => mb_substr( $about_text, 0, 12000 ),
            ),
            'pages'         => $pages,
            'language_hint' => $language,
        );
        if ( ! empty( $existing_obj ) ) {
            $req_body['existing'] = $existing_obj;
        }

        $resp = $client->rag_brand_card_autofill( (string) $site_id, $req_body );
        if ( is_wp_error( $resp ) ) {
            return $this->error( $resp->get_error_message(), 500 );
        }

        $draft = array(
            'company_name'        => isset( $resp['company_name'] ) ? (string) $resp['company_name'] : '',
            'one_liner'           => isset( $resp['one_liner'] ) ? (string) $resp['one_liner'] : '',
            'about_summary'       => isset( $resp['about_summary'] ) ? (string) $resp['about_summary'] : '',
            'phone'               => isset( $resp['phone'] ) ? (string) $resp['phone'] : '',
            'whatsapp_number'     => isset( $resp['whatsapp_number'] ) ? (string) $resp['whatsapp_number'] : '',
            'primary_cta_text'    => $resp['primary_cta_text'] ?? null,
            'primary_cta_url'     => $resp['primary_cta_url'] ?? null,
            'about_source_mode'   => 'manual',
            'about_source_post_id'=> $about_post,
            'services'            => isset( $resp['services'] ) && is_array( $resp['services'] ) ? $this->resolve_service_links( $resp['services'] ) : array(),
        );

        if ( isset( $resp['tables'] ) && is_array( $resp['tables'] ) && ! empty( $resp['tables'] ) ) {
            $draft['tables'] = $this->resolve_table_page_links( $resp['tables'], $pages );
        }

        return $this->success(
            array(
                'draft'           => $draft,
                'fallback_used'   => isset( $resp['fallback_used'] ) ? (bool) $resp['fallback_used'] : false,
                'provider'        => isset( $resp['provider'] ) ? (string) $resp['provider'] : null,
                'model'           => isset( $resp['model'] ) ? (string) $resp['model'] : null,
                'usage'           => isset( $resp['usage'] ) ? $resp['usage'] : null,
                'idempotency_key' => $idempotency_key,
                'delta_chunks'    => $delta_chunks,
                'pages_used'      => count( $pages ),
                'pages_selected'  => count( $selected_ids ),
                'catalog_size'    => count( $catalog ),
                'used_index'      => $has_index,
            )
        );
    }

    /**
     * POST apply an autofill draft into the stored global BrandCard (no extra credits charged).
     *
     * Input: { brand_card: {...} }
     */
    public function autofill_apply( $request ) {
        // Reuse the same storage/sanitization as update_brand_card.
        return $this->update_brand_card( $request );
    }

    /**
     * POST services-only autofill preview (Gemini), without requiring About source.
     *
     * This generates surface-level service descriptions (no pricing, no deep details) using:
     * - menus (primary/footer)
     * - indexed "service pages" when available (best-effort)
     *
     * Input: { max_pages?: number }
     */
    public function services_autofill_preview( $request ) {
        $payload = $request->get_json_params();
        if ( ! is_array( $payload ) ) {
            $payload = array();
        }

        $max_pages = isset( $payload['max_pages'] ) ? absint( $payload['max_pages'] ) : 20;
        $max_pages = max( 5, min( 30, $max_pages ) );

        $language = $this->get_request_language( $request );

        $existing_json = (string) BoostBot_Settings::get_for_language( 'brand_card_global_json', $language, '' );
        $existing_obj  = $this->decode_json_object( $existing_json );
        if ( ! is_array( $existing_obj ) ) {
            $existing_obj = array();
        }

        $page_ids = $this->get_service_candidate_page_ids( $max_pages );
        $pages    = array();
        foreach ( $page_ids as $pid ) {
            $p = get_post( $pid );
            if ( ! $p ) {
                continue;
            }
            $url = urldecode( (string) get_permalink( $p ) );
            if ( false !== strpos( $url, '/en/' ) || false !== strpos( $url, 'lang=en' ) ) {
                continue;
            }
            $pages[] = array(
                'title' => (string) get_the_title( $p ),
                'url'   => $url,
                'text'  => $this->extract_post_text_capped( (int) $pid, 1200 ),
            );
        }
        if ( empty( $pages ) ) {
            return $this->error( __( 'No suitable pages found for generating a services list.', 'boostbot' ), 400 );
        }

        if ( ! class_exists( 'BoostBot_Indexing_Credits_Backend_Client' ) || ! class_exists( 'BoostBot_Credits_Backend_Client' ) ) {
            return $this->error( __( 'Internal error: backend communication components are not available.', 'boostbot' ), 500 );
        }

        $idx_client = new BoostBot_Indexing_Credits_Backend_Client();
        $site_id    = $idx_client->ensure_site_id();
        if ( is_wp_error( $site_id ) ) {
            return $this->error( $site_id->get_error_message(), 500 );
        }

        $idempotency_key = 'services_autofill:' . wp_generate_uuid4();
        $delta_chunks    = min( 60, max( 1, count( $pages ) ) );

        $consumed = $idx_client->consume(
            (string) $site_id,
            array(
                'job_id'           => 'services_autofill',
                'input_tokens'     => 0,
                'output_tokens'    => 0,
                'provider'         => 'google',
                'model'            => 'gemini-2.0-flash',
                'delta_chunks'     => $delta_chunks,
                'idempotency_key'  => $idempotency_key,
            )
        );
        if ( is_wp_error( $consumed ) ) {
            return $this->error( $consumed->get_error_message(), 402 );
        }

        // Reuse the backend BrandCard autofill endpoint, but ask it to focus on services only
        // by providing minimal about_source.
        $client = new BoostBot_Credits_Backend_Client();
        $language_hint = $language ?: 'he';
        $prompt_lang   = 'he' === $language_hint ? 'Hebrew' : 'English';
        $req_body = array(
                'about_source'  => array(
                    'title' => 'Services list generation (no About)',
                    'url'   => home_url( '/' ),
                    'text'  => "Generate ONLY a general services list in {$prompt_lang}. Do NOT include pricing. Keep it surface-level.",
                ),
                'pages'         => $pages,
                'language_hint' => $language_hint,
        );
        // IMPORTANT: if existing is empty, DO NOT send the key at all (some validators reject null).
        if ( ! empty( $existing_obj ) ) {
            $req_body['existing'] = $existing_obj;
        }

        $resp = $client->rag_brand_card_autofill( (string) $site_id, $req_body );
        if ( is_wp_error( $resp ) ) {
            return $this->error( $resp->get_error_message(), 500 );
        }

        $draft_services = isset( $resp['services'] ) && is_array( $resp['services'] ) ? $this->resolve_service_links( $resp['services'] ) : array();

        return $this->success(
            array(
                'services'        => $draft_services,
                'provider'        => isset( $resp['provider'] ) ? (string) $resp['provider'] : null,
                'model'           => isset( $resp['model'] ) ? (string) $resp['model'] : null,
                'usage'           => isset( $resp['usage'] ) ? $resp['usage'] : null,
                'idempotency_key' => $idempotency_key,
                'delta_chunks'    => $delta_chunks,
                'pages_used'      => count( $pages ),
            )
        );
    }

    /**
     * POST table-autofill-preview
     *
     * Generalized auto-fill for custom tables. Accepts table_title, columns (headers + types),
     * and existing rows. Uses Gemini to fill only empty cells using website content.
     */
    public function table_autofill_preview( $request ) {
        $payload = $request->get_json_params();
        if ( ! is_array( $payload ) ) {
            $payload = array();
        }

        $table_title = isset( $payload['table_title'] ) ? sanitize_text_field( (string) $payload['table_title'] ) : '';
        $columns     = isset( $payload['columns'] ) && is_array( $payload['columns'] ) ? $payload['columns'] : array();
        $rows        = isset( $payload['rows'] ) && is_array( $payload['rows'] ) ? $payload['rows'] : array();
        $max_pages   = isset( $payload['max_pages'] ) ? absint( $payload['max_pages'] ) : 20;
        $max_pages   = max( 5, min( 30, $max_pages ) );

        if ( empty( $columns ) ) {
            return $this->error( __( 'Table columns are missing.', 'boostbot' ), 400 );
        }

        // Build column headers description for Gemini
        $column_descriptions = array();
        foreach ( $columns as $col ) {
            if ( ! is_array( $col ) ) {
                continue;
            }
            $cid    = isset( $col['id'] ) ? (string) $col['id'] : '';
            $header = isset( $col['header'] ) ? (string) $col['header'] : '';
            $type   = isset( $col['type'] ) ? (string) $col['type'] : 'text';
            if ( '' === $cid ) {
                continue;
            }
            $column_descriptions[] = array(
                'id'     => $cid,
                'header' => $header,
                'type'   => $type,
            );
        }

        if ( empty( $column_descriptions ) ) {
            return $this->error( __( 'No valid columns.', 'boostbot' ), 400 );
        }

        // Gather website pages for context
        $page_ids = $this->get_service_candidate_page_ids( $max_pages );
        $pages    = array();
        foreach ( $page_ids as $pid ) {
            $p = get_post( $pid );
            if ( ! $p ) {
                continue;
            }
            $url = urldecode( (string) get_permalink( $p ) );
            if ( false !== strpos( $url, '/en/' ) || false !== strpos( $url, 'lang=en' ) ) {
                continue;
            }
            $pages[] = array(
                'title' => (string) get_the_title( $p ),
                'url'   => $url,
                'text'  => $this->extract_post_text_capped( (int) $pid, 1200 ),
            );
        }

        if ( empty( $pages ) ) {
            return $this->error( __( 'No pages found on the site for auto-fill.', 'boostbot' ), 400 );
        }

        // Credits check
        if ( ! class_exists( 'BoostBot_Indexing_Credits_Backend_Client' ) || ! class_exists( 'BoostBot_Credits_Backend_Client' ) ) {
            return $this->error( __( 'Internal error: backend communication components are not available.', 'boostbot' ), 500 );
        }

        $idx_client = new BoostBot_Indexing_Credits_Backend_Client();
        $site_id    = $idx_client->ensure_site_id();
        if ( is_wp_error( $site_id ) ) {
            return $this->error( $site_id->get_error_message(), 500 );
        }

        $idempotency_key = 'table_autofill:' . wp_generate_uuid4();
        $delta_chunks    = min( 60, max( 1, count( $pages ) ) );

        $consumed = $idx_client->consume(
            (string) $site_id,
            array(
                'job_id'           => 'table_autofill',
                'input_tokens'     => 0,
                'output_tokens'    => 0,
                'provider'         => 'google',
                'model'            => 'gemini-2.0-flash',
                'delta_chunks'     => $delta_chunks,
                'idempotency_key'  => $idempotency_key,
            )
        );
        if ( is_wp_error( $consumed ) ) {
            return $this->error( $consumed->get_error_message(), 402 );
        }

        // Build Gemini prompt for table autofill
        $headers_str = array();
        foreach ( $column_descriptions as $cd ) {
            $headers_str[] = $cd['header'] ?: $cd['id'];
        }

        $existing_rows_text = '';
        if ( ! empty( $rows ) ) {
            foreach ( $rows as $ri => $row ) {
                $vals = array();
                foreach ( $column_descriptions as $cd ) {
                    $v = isset( $row[ $cd['id'] ] ) ? trim( (string) $row[ $cd['id'] ] ) : '';
                    $vals[] = '' === $v ? '[EMPTY]' : $v;
                }
                $existing_rows_text .= 'Row ' . ( $ri + 1 ) . ': ' . implode( ' | ', $vals ) . "\n";
            }
        }

        $pages_context = '';
        foreach ( $pages as $pg ) {
            $pages_context .= "--- {$pg['title']} ({$pg['url']}) ---\n{$pg['text']}\n\n";
        }

        $prompt = "You are helping auto-fill a table titled \"{$table_title}\" for a Hebrew business website.\n\n"
                . "Column headers: " . implode( ' | ', $headers_str ) . "\n\n"
                . "Existing rows (cells marked [EMPTY] need filling):\n{$existing_rows_text}\n\n"
                . "Website content for reference:\n{$pages_context}\n\n"
                . "RULES:\n"
                . "1. ONLY fill cells marked [EMPTY]. Keep all existing values unchanged.\n"
                . "2. ONLY use data that actually exists in the website content above.\n"
                . "3. If you cannot find relevant data for a cell, leave it as an empty string.\n"
                . "4. If the table needs more rows based on the website data, add them.\n"
                . "5. Respond in Hebrew.\n"
                . "6. For page_select type columns, return the page URL if possible.\n\n"
                . "Respond ONLY with a valid JSON array of row objects. Each object maps column IDs to values.\n"
                . "Column IDs: " . wp_json_encode( array_column( $column_descriptions, 'id' ) ) . "\n"
                . "Respond with JSON only, no markdown or explanation.";

        // Call Gemini via the quick_llm_call endpoint
        $client = new BoostBot_Credits_Backend_Client();
        $resp = $client->quick_llm_call(
            (string) $site_id,
            array(
                'prompt'     => $this->truncate_quick_llm_prompt( $prompt ),
                'model'      => 'gemini-2.0-flash',
                'max_tokens' => 500,
                'purpose'    => 'table_autofill',
            )
        );

        if ( is_wp_error( $resp ) ) {
            return $this->error( $resp->get_error_message(), 500 );
        }

        // Parse the response — expect JSON array of row objects
        $generated_text = '';
        if ( is_string( $resp ) ) {
            $generated_text = $resp;
        } elseif ( is_array( $resp ) && isset( $resp['response'] ) ) {
            $generated_text = (string) $resp['response'];
        } elseif ( is_array( $resp ) && isset( $resp['text'] ) ) {
            $generated_text = (string) $resp['text'];
        } elseif ( is_array( $resp ) && isset( $resp['content'] ) ) {
            $generated_text = (string) $resp['content'];
        }

        // Strip markdown code fences if present
        $generated_text = trim( $generated_text );
        $generated_text = preg_replace( '/^```(?:json)?\s*/i', '', $generated_text );
        $generated_text = preg_replace( '/\s*```$/', '', $generated_text );

        $filled_rows = json_decode( $generated_text, true );
        if ( ! is_array( $filled_rows ) ) {
            // Try to extract JSON array from the response
            if ( preg_match( '/\[[\s\S]*\]/', $generated_text, $m ) ) {
                $filled_rows = json_decode( $m[0], true );
            }
        }

        if ( ! is_array( $filled_rows ) ) {
            return $this->error( __( 'Unable to parse auto-fill results.', 'boostbot' ), 500 );
        }

        // Sanitize filled rows — only keep valid column IDs, resolve page URLs to IDs
        $valid_col_ids = array_column( $column_descriptions, 'id' );
        $col_types     = array();
        foreach ( $column_descriptions as $cd ) {
            $col_types[ $cd['id'] ] = $cd['type'];
        }

        $sanitized_rows = array();
        foreach ( $filled_rows as $row ) {
            if ( ! is_array( $row ) ) {
                continue;
            }
            $srow = array();
            foreach ( $valid_col_ids as $cid ) {
                $val = isset( $row[ $cid ] ) ? $row[ $cid ] : '';
                if ( 'page_select' === ( $col_types[ $cid ] ?? 'text' ) ) {
                    // If a URL was returned, try to resolve to post ID
                    if ( is_string( $val ) && '' !== $val && ! is_numeric( $val ) ) {
                        $pid = absint( url_to_postid( $val ) );
                        if ( 0 === $pid ) {
                            $pid = absint( url_to_postid( untrailingslashit( $val ) ) );
                        }
                        $srow[ $cid ] = $pid;
                    } else {
                        $srow[ $cid ] = absint( $val );
                    }
                } else {
                    $srow[ $cid ] = sanitize_textarea_field( (string) $val );
                }
            }
            $sanitized_rows[] = $srow;
        }

        return $this->success(
            array(
                'rows'            => $sanitized_rows,
                'idempotency_key' => $idempotency_key,
                'delta_chunks'    => $delta_chunks,
                'pages_used'      => count( $pages ),
            )
        );
    }

    /**
     * GET About-page candidates (heuristics).
     */
    public function get_about_candidates( $request ) {
        // Optional query param: allow admins to search pages/posts by title/slug.
        $q = '';
        if ( is_object( $request ) && method_exists( $request, 'get_param' ) ) {
            $q = (string) $request->get_param( 'q' );
        }
        $q = trim( (string) $q );

        // If a query is provided, return a direct search list (no heuristics) so admins can find "מי אנחנו" etc.
        if ( '' !== $q ) {
            $posts = get_posts(
                array(
                    'post_type'      => array( 'page', 'post' ),
                    'post_status'    => 'publish',
                    'posts_per_page' => 50,
                    's'              => $q,
                )
            );

            $items = array();
            foreach ( $posts as $p ) {
                if ( ! ( $p instanceof WP_Post ) ) {
                    continue;
                }
                $url = urldecode( (string) get_permalink( $p ) );
                if ( false !== strpos( $url, '/en/' ) || false !== strpos( $url, 'lang=en' ) ) {
                    continue;
                }
                $items[] = array(
                    'post_id' => (int) $p->ID,
                    'title'   => (string) get_the_title( $p ),
                    'url'     => $url,
                    'reason'  => 'search',
                );
            }

            return $this->success(
                array(
                    'candidates' => $items,
                )
            );
        }

        $posts = get_posts(
            array(
                'post_type'      => array( 'page', 'post' ),
                'post_status'    => 'publish',
                'posts_per_page' => 500,
                's'              => '',
            )
        );

        $candidates = array();
        $needles = array( 'about', 'about-us', 'אודות', 'אודותינו', 'מי אנחנו', 'עלינו', 'הסיפור שלנו', 'אודות החברה' );
        foreach ( $posts as $p ) {
            if ( ! ( $p instanceof WP_Post ) ) {
                continue;
            }
            $title = (string) get_the_title( $p );
            $slug  = (string) $p->post_name;
            $hay   = mb_strtolower( $title . ' ' . $slug );

            $reason = '';
            foreach ( $needles as $n ) {
                if ( false !== mb_strpos( $hay, mb_strtolower( $n ) ) ) {
                    $reason = 'match:' . $n;
                    break;
                }
            }
            if ( '' === $reason ) {
                continue;
            }

            $candidates[] = array(
                'post_id' => (int) $p->ID,
                'title'   => $title,
                'url'     => urldecode( (string) get_permalink( $p ) ),
                'reason'  => $reason,
            );
        }

        // Sort stable: shorter titles first (usually the real About page), then ID.
        usort(
            $candidates,
            function( $a, $b ) {
                $la = mb_strlen( (string) ( $a['title'] ?? '' ) );
                $lb = mb_strlen( (string) ( $b['title'] ?? '' ) );
                if ( $la === $lb ) {
                    return (int) ( $a['post_id'] ?? 0 ) <=> (int) ( $b['post_id'] ?? 0 );
                }
                return $la <=> $lb;
            }
        );

        // If no matches found, fall back to a safe list of pages so the dropdown is never empty.
        if ( empty( $candidates ) ) {
            $fallback = get_posts(
                array(
                    'post_type'      => array( 'page' ),
                    'post_status'    => 'publish',
                    'posts_per_page' => 50,
                    'orderby'        => 'modified',
                    'order'          => 'DESC',
                )
            );
            foreach ( $fallback as $p ) {
                if ( ! ( $p instanceof WP_Post ) ) {
                    continue;
                }
                $candidates[] = array(
                    'post_id' => (int) $p->ID,
                    'title'   => (string) get_the_title( $p ),
                    'url'     => urldecode( (string) get_permalink( $p ) ),
                    'reason'  => 'fallback:page',
                );
                if ( count( $candidates ) >= 20 ) {
                    break;
                }
            }
        }

        return $this->success(
            array(
                'candidates' => array_slice( $candidates, 0, 50 ),
            )
        );
    }

    /**
     * POST regenerate preview (calls backend Gemini helper).
     *
     * Input:
     * - post_id (optional) OR text (optional). At least one must be provided.
     */
    public function regenerate_preview( $request ) {
        $payload = $request->get_json_params();
        if ( ! is_array( $payload ) ) {
            return $this->error( 'Invalid JSON body.', 400 );
        }

        $post_id = isset( $payload['post_id'] ) ? absint( $payload['post_id'] ) : 0;
        $text    = isset( $payload['text'] ) ? (string) $payload['text'] : '';
        $lang    = isset( $payload['language_hint'] ) ? sanitize_text_field( (string) $payload['language_hint'] ) : '';

        if ( $post_id > 0 && '' === trim( $text ) ) {
            if ( class_exists( 'BoostBot_Content_Extractor' ) ) {
                $extractor = new BoostBot_Content_Extractor();
                $text = (string) $extractor->extract( $post_id );
            } else {
                $p = get_post( $post_id );
                $text = $p ? wp_strip_all_tags( (string) $p->post_content ) : '';
            }
        }

        $text = trim( (string) $text );
        if ( '' === $text ) {
            return $this->error( 'Missing source text (provide post_id or text).', 400 );
        }
        if ( mb_strlen( $text ) > 12000 ) {
            $text = mb_substr( $text, 0, 12000 );
        }

        $title = '';
        $url   = '';
        if ( $post_id > 0 ) {
            $p = get_post( $post_id );
            if ( $p ) {
                $title = (string) get_the_title( $p );
                $url   = urldecode( (string) get_permalink( $p ) );
            }
        }

        $backend_base_url = trim( (string) BoostBot_Settings::get( 'backend_base_url', '' ) );
        if ( empty( $backend_base_url ) || ! class_exists( 'BoostBot_Credits_Backend_Client' ) ) {
            // Fail soft: return a preview based on raw text.
            return $this->success(
                array(
                    'draft' => array(
                        'company_name'     => '',
                        'one_liner'        => '',
                        'about_summary'    => mb_substr( $text, 0, 600 ),
                        'primary_cta_text' => null,
                        'primary_cta_url'  => null,
                    ),
                    'fallback_used' => true,
                    'provider'      => null,
                    'model'         => null,
                    'usage'         => null,
                )
            );
        }

        $client  = new BoostBot_Credits_Backend_Client();
        $site_id = $client->ensure_site_id();
        if ( is_wp_error( $site_id ) ) {
            return $this->error( $site_id->get_error_message(), 500 );
        }

        $body = array(
            'about_source' => array(
                'title' => $title,
                'url'   => $url,
                'text'  => $text,
            ),
        );
        if ( 'he' === $lang || 'en' === $lang ) {
            $body['language_hint'] = $lang;
        }

        $resp = $client->rag_brand_card_generate( (string) $site_id, $body );
        if ( is_wp_error( $resp ) ) {
            return $this->success(
                array(
                    'draft' => array(
                        'company_name'     => '',
                        'one_liner'        => '',
                        'about_summary'    => mb_substr( $text, 0, 600 ),
                        'primary_cta_text' => null,
                        'primary_cta_url'  => null,
                    ),
                    'fallback_used' => true,
                    'provider'      => null,
                    'model'         => null,
                    'usage'         => null,
                    'error'         => $resp->get_error_message(),
                )
            );
        }

        // Normalize output shape for admin UI.
        $draft = array(
            'company_name'     => isset( $resp['company_name'] ) ? (string) $resp['company_name'] : '',
            'one_liner'        => isset( $resp['one_liner'] ) ? (string) $resp['one_liner'] : '',
            'about_summary'    => isset( $resp['about_summary'] ) ? (string) $resp['about_summary'] : '',
            'primary_cta_text' => $resp['primary_cta_text'] ?? null,
            'primary_cta_url'  => $resp['primary_cta_url'] ?? null,
        );

        return $this->success(
            array(
                'draft'        => $draft,
                'fallback_used'=> isset( $resp['fallback_used'] ) ? (bool) $resp['fallback_used'] : false,
                'provider'     => isset( $resp['provider'] ) ? (string) $resp['provider'] : null,
                'model'        => isset( $resp['model'] ) ? (string) $resp['model'] : null,
                'usage'        => isset( $resp['usage'] ) ? $resp['usage'] : null,
            )
        );
    }

    /**
     * Auto-detect best About source page.
     *
     * Strategy:
     * 1) Prefer active-menu pages.
     * 2) Score by Hebrew/English About keywords.
     * 3) If still ambiguous, ask quick LLM to choose from candidate titles/URLs.
     *
     * @param int $max_pages
     * @return array{post_id:int}
     */
    private function detect_about_source_auto( int $max_pages ): array {
        $candidate_ids = $this->get_service_candidate_page_ids( max( 20, $max_pages ) );

        // Expand pool with additional recent pages/posts (in case About is not in active menu).
        $extra = get_posts(
            array(
                'post_type'      => array( 'page', 'post' ),
                'post_status'    => 'publish',
                'posts_per_page' => 100,
                'orderby'        => 'modified',
                'order'          => 'DESC',
            )
        );
        foreach ( $extra as $p ) {
            if ( ! ( $p instanceof WP_Post ) ) {
                continue;
            }
            $candidate_ids[] = (int) $p->ID;
        }

        $candidate_ids = array_values( array_unique( array_filter( array_map( 'absint', $candidate_ids ) ) ) );
        if ( empty( $candidate_ids ) ) {
            return array( 'post_id' => 0 );
        }

        $needles = array( 'about', 'about-us', 'aboutus', 'אודות', 'אודותינו', 'מי אנחנו', 'עלינו', 'הסיפור שלנו', 'אודות החברה' );
        $scored = array();
        foreach ( $candidate_ids as $pid ) {
            $p = get_post( $pid );
            if ( ! ( $p instanceof WP_Post ) ) {
                continue;
            }
            $title = (string) get_the_title( $p );
            $slug  = (string) $p->post_name;
            $url   = urldecode( (string) get_permalink( $p ) );
            if ( false !== strpos( $url, '/en/' ) || false !== strpos( $url, 'lang=en' ) ) {
                continue;
            }

            $hay   = mb_strtolower( trim( $title . ' ' . $slug . ' ' . $url ) );
            $score = 0;
            foreach ( $needles as $needle ) {
                if ( false !== mb_strpos( $hay, mb_strtolower( $needle ) ) ) {
                    // Boost direct title matches higher than URL-only matches.
                    if ( false !== mb_strpos( mb_strtolower( $title . ' ' . $slug ), mb_strtolower( $needle ) ) ) {
                        $score += 6;
                    } else {
                        $score += 3;
                    }
                }
            }

            if ( $score > 0 ) {
                $scored[] = array(
                    'post_id' => $pid,
                    'title'   => $title,
                    'slug'    => $slug,
                    'url'     => $url,
                    'score'   => $score,
                );
            }
        }

        if ( ! empty( $scored ) ) {
            usort(
                $scored,
                function( $a, $b ) {
                    if ( (int) $a['score'] === (int) $b['score'] ) {
                        return mb_strlen( (string) $a['title'] ) <=> mb_strlen( (string) $b['title'] );
                    }
                    return (int) $b['score'] <=> (int) $a['score'];
                }
            );
            return array( 'post_id' => (int) $scored[0]['post_id'] );
        }

        // LLM-assisted fallback using candidate titles from menu/recent pages.
        if ( class_exists( 'BoostBot_Credits_Backend_Client' ) ) {
            try {
                $client = new BoostBot_Credits_Backend_Client();
                $site_id = $client->ensure_site_id();
                if ( ! is_wp_error( $site_id ) && ! empty( $site_id ) ) {
                    $lines = array();
                    foreach ( array_slice( $candidate_ids, 0, 40 ) as $pid ) {
                        $p = get_post( $pid );
                        if ( ! ( $p instanceof WP_Post ) ) {
                            continue;
                        }
                        $url = urldecode( (string) get_permalink( $p ) );
                        if ( false !== strpos( $url, '/en/' ) || false !== strpos( $url, 'lang=en' ) ) {
                            continue;
                        }
                        $lines[] = $pid . ' | ' . get_the_title( $p ) . ' | ' . (string) $p->post_name . ' | ' . $url;
                    }

                    if ( ! empty( $lines ) ) {
                        $prompt = "Choose the best ABOUT page id for this website from the candidates below.\n"
                                . "Return only one integer id. If none is suitable return 0.\n\n"
                                . "Candidates:\n"
                                . implode( "\n", $lines );

                        $resp = $client->quick_llm_call(
                            (string) $site_id,
                            array(
                                'model'      => 'gemini-2.0-flash',
                                'prompt'     => $this->truncate_quick_llm_prompt( $prompt ),
                                'max_tokens' => 20,
                                'purpose'    => 'brandcard_about_discovery',
                            )
                        );
                        if ( ! is_wp_error( $resp ) ) {
                            $text = '';
                            if ( is_array( $resp ) && isset( $resp['response'] ) ) {
                                $text = (string) $resp['response'];
                            } elseif ( is_array( $resp ) && isset( $resp['content'] ) ) {
                                $text = (string) $resp['content'];
                            } elseif ( is_string( $resp ) ) {
                                $text = $resp;
                            }
                            if ( preg_match( '/\b(\d+)\b/', $text, $m ) ) {
                                $picked = absint( $m[1] );
                                if ( $picked > 0 && in_array( $picked, $candidate_ids, true ) ) {
                                    return array( 'post_id' => $picked );
                                }
                            }
                        }
                    }
                }
            } catch ( Exception $e ) {
                // Keep silent fallback behavior; no hard failure for autofill.
            }
        }

        return array( 'post_id' => 0 );
    }

    /**
     * Keep /llm/quick prompts under backend validation limit.
     *
     * @param string $prompt
     * @return string
     */
    private function truncate_quick_llm_prompt( string $prompt ): string {
        $max_chars = 7600;
        if ( function_exists( 'mb_strlen' ) && function_exists( 'mb_substr' ) ) {
            if ( mb_strlen( $prompt, 'UTF-8' ) <= $max_chars ) {
                return $prompt;
            }
            return mb_substr( $prompt, 0, $max_chars, 'UTF-8' );
        }

        if ( strlen( $prompt ) <= $max_chars ) {
            return $prompt;
        }
        return substr( $prompt, 0, $max_chars );
    }

    private function decode_json_object( string $json ): ?array {
        $json = trim( (string) $json );
        if ( '' === $json ) {
            return null;
        }
        $decoded = json_decode( $json, true );
        return is_array( $decoded ) ? $decoded : null;
    }

    /**
     * Sanitize services list stored under BrandCard.
     *
     * @param mixed $services
     * @return array
     */
    private function sanitize_services_list( $services ): array {
        if ( ! is_array( $services ) ) {
            return array();
        }

        $out = array();
        foreach ( $services as $row ) {
            if ( ! is_array( $row ) ) {
                continue;
            }
            $name = isset( $row['name'] ) ? sanitize_text_field( (string) $row['name'] ) : '';
            $desc = isset( $row['description'] ) ? sanitize_textarea_field( (string) $row['description'] ) : '';
            $pid  = isset( $row['page_post_id'] ) ? absint( $row['page_post_id'] ) : 0;
            $url  = isset( $row['page_url'] ) ? esc_url_raw( (string) $row['page_url'] ) : '';

            $name = trim( (string) $name );
            $desc = trim( (string) $desc );
            if ( '' === $name && '' === $desc && 0 === $pid && '' === $url ) {
                continue;
            }

            if ( mb_strlen( $name ) > 120 ) {
                $name = mb_substr( $name, 0, 120 );
            }
            if ( mb_strlen( $desc ) > 360 ) {
                $desc = mb_substr( $desc, 0, 360 );
            }

            $out[] = array(
                'name'         => $name,
                'description'  => $desc,
                'page_post_id' => $pid,
                'page_url'     => $url,
            );

            if ( count( $out ) >= 60 ) {
                break;
            }
        }

        return $out;
    }

    /**
     * Sanitize custom tables array.
     *
     * Each table has: id, title, columns (array of {id, header, type}), rows (array of objects).
     *
     * @param mixed $tables
     * @return array
     */
    private function sanitize_tables( $tables ): array {
        if ( ! is_array( $tables ) ) {
            return array();
        }

        $out = array();
        foreach ( $tables as $tbl ) {
            if ( ! is_array( $tbl ) ) {
                continue;
            }

            $tbl_id    = isset( $tbl['id'] ) ? sanitize_text_field( (string) $tbl['id'] ) : '';
            $tbl_title = isset( $tbl['title'] ) ? sanitize_text_field( (string) $tbl['title'] ) : '';

            // Sanitize columns.
            $columns = array();
            if ( isset( $tbl['columns'] ) && is_array( $tbl['columns'] ) ) {
                foreach ( $tbl['columns'] as $col ) {
                    if ( ! is_array( $col ) ) {
                        continue;
                    }
                    $col_id   = isset( $col['id'] ) ? sanitize_text_field( (string) $col['id'] ) : '';
                    $col_head = isset( $col['header'] ) ? sanitize_text_field( (string) $col['header'] ) : '';
                    $col_type = isset( $col['type'] ) && 'page_select' === $col['type'] ? 'page_select' : 'text';
                    if ( '' === $col_id ) {
                        continue;
                    }
                    $columns[] = array(
                        'id'     => $col_id,
                        'header' => $col_head,
                        'type'   => $col_type,
                    );
                }
            }

            if ( empty( $columns ) ) {
                continue; // A table without columns is invalid.
            }

            // Sanitize rows.
            $rows = array();
            if ( isset( $tbl['rows'] ) && is_array( $tbl['rows'] ) ) {
                $valid_col_ids = array_column( $columns, 'id' );
                $col_types     = array();
                foreach ( $columns as $c ) {
                    $col_types[ $c['id'] ] = $c['type'];
                }

                foreach ( $tbl['rows'] as $row ) {
                    if ( ! is_array( $row ) ) {
                        continue;
                    }
                    $sanitized_row = array();
                    foreach ( $valid_col_ids as $cid ) {
                        $val = isset( $row[ $cid ] ) ? $row[ $cid ] : '';
                        if ( 'page_select' === ( $col_types[ $cid ] ?? 'text' ) ) {
                            $sanitized_row[ $cid ] = absint( $val );
                        } else {
                            $sanitized_row[ $cid ] = sanitize_textarea_field( (string) $val );
                        }
                    }
                    $rows[] = $sanitized_row;
                }
            }

            $out[] = array(
                'id'      => $tbl_id,
                'title'   => $tbl_title,
                'columns' => $columns,
                'rows'    => $rows,
            );

            if ( count( $out ) >= 20 ) {
                break; // Hard cap on number of tables.
            }
        }

        return $out;
    }

    /**
     * Best-effort link resolver for services generated by Gemini.
     *
     * Adds `page_post_id` when possible, based on:
     * - `page_url` (preferred; uses url_to_postid)
     * - service name -> page title fallback (WP_Query by title)
     *
     * @param array $services
     * @return array
     */
    private function resolve_service_links( array $services ): array {
        $out = array();
        foreach ( $services as $s ) {
            if ( ! is_array( $s ) ) {
                continue;
            }

            $name = isset( $s['name'] ) ? sanitize_text_field( (string) $s['name'] ) : '';
            $desc = isset( $s['description'] ) ? sanitize_textarea_field( (string) $s['description'] ) : '';
            $url  = isset( $s['page_url'] ) ? esc_url_raw( (string) $s['page_url'] ) : '';

            $pid = 0;
            if ( '' !== $url ) {
                $pid = absint( url_to_postid( $url ) );
                if ( 0 === $pid ) {
                    // Try trimming trailing slash.
                    $pid = absint( url_to_postid( untrailingslashit( $url ) ) );
                }
            }
            if ( 0 === $pid && '' !== $name ) {
                $title_query = new WP_Query( array(
                    'post_type'      => 'page',
                    'title'          => $name,
                    'posts_per_page' => 1,
                    'no_found_rows'  => true,
                    'fields'         => 'ids',
                ) );
                $p = $title_query->have_posts() ? get_post( $title_query->posts[0] ) : null;
                if ( $p instanceof WP_Post ) {
                    $pid = (int) $p->ID;
                    if ( '' === $url ) {
                        $url = urldecode( (string) get_permalink( $p ) );
                    }
                }
            }

            $out[] = array(
                'name'         => $name,
                'description'  => $desc,
                'page_url'     => $url,
                'page_post_id' => $pid,
            );

            if ( count( $out ) >= 60 ) {
                break;
            }
        }
        return $out;
    }

    /**
     * Convert backend V2 tables (with page_url column type) into the BrandCard tables format
     * with page_select columns and resolved post IDs.
     *
     * @param array $backend_tables  Tables from backend response (title, columns, rows as arrays).
     * @param array $known_pages     Pages array with post_id, url, title for fast lookup.
     * @return array Tables in BrandCard format (id, title, columns with IDs, rows as keyed objects).
     */
    private function resolve_table_page_links( array $backend_tables, array $known_pages ): array {
        $url_to_pid = array();
        foreach ( $known_pages as $pg ) {
            if ( ! empty( $pg['url'] ) && ! empty( $pg['post_id'] ) ) {
                $url_to_pid[ untrailingslashit( $pg['url'] ) ] = (int) $pg['post_id'];
                $url_to_pid[ trailingslashit( $pg['url'] ) ]   = (int) $pg['post_id'];
            }
        }

        $result = array();
        $tbl_counter = 0;
        foreach ( $backend_tables as $tbl ) {
            if ( ! is_array( $tbl ) ) {
                continue;
            }
            $title   = isset( $tbl['title'] ) ? sanitize_text_field( (string) $tbl['title'] ) : '';
            $columns = isset( $tbl['columns'] ) && is_array( $tbl['columns'] ) ? $tbl['columns'] : array();
            $rows    = isset( $tbl['rows'] ) && is_array( $tbl['rows'] ) ? $tbl['rows'] : array();

            if ( empty( $columns ) || empty( $rows ) ) {
                continue;
            }

            $tbl_counter++;
            $tbl_id = 'tbl_af_' . $tbl_counter . '_' . time();

            $col_defs   = array();
            $col_ids    = array();
            $col_types  = array();
            $col_counter = 0;
            foreach ( $columns as $col ) {
                if ( ! is_array( $col ) ) {
                    continue;
                }
                $col_counter++;
                $col_id   = 'col_af_' . $tbl_counter . '_' . $col_counter;
                $header   = isset( $col['header'] ) ? sanitize_text_field( (string) $col['header'] ) : '';
                $raw_type = isset( $col['type'] ) ? (string) $col['type'] : 'text';
                $type     = 'page_url' === $raw_type ? 'page_select' : 'text';

                $col_defs[] = array(
                    'id'     => $col_id,
                    'header' => $header,
                    'type'   => $type,
                );
                $col_ids[]  = $col_id;
                $col_types[] = $type;
            }

            if ( empty( $col_defs ) ) {
                continue;
            }

            $converted_rows = array();
            foreach ( $rows as $row ) {
                if ( ! is_array( $row ) ) {
                    continue;
                }
                $keyed_row = array();
                foreach ( $col_ids as $ci => $cid ) {
                    $val = isset( $row[ $ci ] ) ? $row[ $ci ] : '';

                    if ( 'page_select' === $col_types[ $ci ] ) {
                        $url_val = is_string( $val ) ? trim( $val ) : '';
                        $pid     = 0;
                        if ( '' !== $url_val ) {
                            $normalized = untrailingslashit( $url_val );
                            if ( isset( $url_to_pid[ $normalized ] ) ) {
                                $pid = $url_to_pid[ $normalized ];
                            } else {
                                $pid = absint( url_to_postid( $url_val ) );
                                if ( 0 === $pid ) {
                                    $pid = absint( url_to_postid( untrailingslashit( $url_val ) ) );
                                }
                            }
                        }
                        $keyed_row[ $cid ] = $pid;
                    } else {
                        $keyed_row[ $cid ] = is_string( $val ) ? sanitize_textarea_field( $val ) : (string) $val;
                    }
                }
                $converted_rows[] = $keyed_row;

                if ( count( $converted_rows ) >= 30 ) {
                    break;
                }
            }

            if ( ! empty( $converted_rows ) ) {
                $result[] = array(
                    'id'      => $tbl_id,
                    'title'   => $title,
                    'columns' => $col_defs,
                    'rows'    => $converted_rows,
                );
            }

            if ( count( $result ) >= 10 ) {
                break;
            }
        }

        return $result;
    }

    /**
     * Extract a capped plain-text excerpt from a post (best-effort).
     */
    private function extract_post_text_capped( int $post_id, int $max_chars ): string {
        $text = '';
        if ( class_exists( 'BoostBot_Content_Extractor' ) ) {
            $extractor = new BoostBot_Content_Extractor();
            $text = (string) $extractor->extract( $post_id );
        } else {
            $p = get_post( $post_id );
            $text = $p ? (string) $p->post_content : '';
            $text = strip_shortcodes( $text );
            $text = wp_strip_all_tags( $text );
        }

        $text = html_entity_decode( (string) $text, ENT_QUOTES | ENT_HTML5, 'UTF-8' );
        $text = preg_replace( '/\s+/u', ' ', $text );
        $text = trim( (string) $text );
        if ( $max_chars > 0 && mb_strlen( $text ) > $max_chars ) {
            $text = mb_substr( $text, 0, $max_chars );
        }
        return $text;
    }

    /**
     * Find the "Contact Us" page ID by title/slug heuristics.
     *
     * @return int Post ID or 0 if not found.
     */
    private function find_contact_page_id(): int {
        $posts = get_posts(
            array(
                'post_type'      => array( 'page', 'post' ),
                'post_status'    => 'publish',
                'posts_per_page' => 120,
                's'              => '',
            )
        );
        $needles = array( 'contact', 'contact-us', 'צור קשר', 'צור-קשר', 'יצירת קשר', 'ליצירת קשר' );
        foreach ( $posts as $p ) {
            if ( ! ( $p instanceof WP_Post ) ) {
                continue;
            }
            $title = (string) get_the_title( $p );
            $slug  = (string) $p->post_name;
            $hay   = mb_strtolower( $title . ' ' . $slug );
            foreach ( $needles as $n ) {
                if ( false !== mb_strpos( $hay, mb_strtolower( $n ) ) ) {
                    return (int) $p->ID;
                }
            }
        }
        return 0;
    }

    /**
     * Heuristic service candidate pages: use menu pages when possible, else fall back to recent pages.
     *
     * @return int[]
     */
    private function get_service_candidate_page_ids( int $max_pages ): array {
        $ids = array();

        // Prefer menu pages (most likely represent services).
        $locations = function_exists( 'get_nav_menu_locations' ) ? (array) get_nav_menu_locations() : array();
        foreach ( $locations as $menu_id ) {
            if ( empty( $menu_id ) ) {
                continue;
            }
            $items = wp_get_nav_menu_items( (int) $menu_id );
            if ( ! is_array( $items ) ) {
                continue;
            }
            foreach ( $items as $it ) {
                if ( ! isset( $it->object_id ) ) {
                    continue;
                }
                $pid = absint( $it->object_id );
                if ( $pid > 0 ) {
                    $ids[] = $pid;
                }
                if ( count( $ids ) >= $max_pages ) {
                    break 2;
                }
            }
        }

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

        if ( count( $ids ) < $max_pages ) {
            $more = get_posts(
                array(
                    'post_type'      => array( 'page' ),
                    'post_status'    => 'publish',
                    'posts_per_page' => $max_pages,
                    'orderby'        => 'modified',
                    'order'          => 'DESC',
                )
            );
            foreach ( $more as $p ) {
                if ( ! ( $p instanceof WP_Post ) ) {
                    continue;
                }
                $ids[] = (int) $p->ID;
                if ( count( $ids ) >= $max_pages ) {
                    break;
                }
            }
        }

        $ids = array_values( array_unique( array_filter( $ids ) ) );
        return array_slice( $ids, 0, $max_pages );
    }

    /**
     * Restrict a SQL identifier to [A-Za-z0-9_] characters.
     *
     * DDL cannot use $wpdb->prepare() for identifiers, so any identifier
     * that ends up interpolated into a query first passes through this
     * allow-list.
     *
     * @param string $identifier Raw identifier.
     * @return string Identifier with disallowed characters removed.
     */
    private static function sanitize_identifier( $identifier ) {
        return preg_replace( '/[^A-Za-z0-9_]/', '', (string) $identifier );
    }
}
