<?php
/**
 * Business description generation AJAX handlers for SiteStaffr admin.
 *
 * @package    SiteStaffr
 * @subpackage SiteStaffr/admin/traits
 * @since      0.14.262
 */
if ( ! defined( 'WPINC' ) ) {
    die;
}
/**
 * Encapsulates website-to-description AJAX flows.
 */
trait SiteStaffr_Admin_Description_Generation_Ajax_Trait {

    /**
     * Save the Business Identity summary and stamp when it last changed.
     *
     * `sitestaffr_business_description_updated` used to be written only when the
     * profile was *generated*, so a hand-edited profile read as never-updated —
     * and the staleness nudge below would have called it permanently stale. Every
     * write site goes through here instead.
     *
     * Stamped only when the text actually changes, so saving an unrelated setting
     * on the same form does not silently reset the signal. The epoch is UTC
     * (`time()`, not `current_time()`) to match the browser-supplied `since` the
     * generation recovery poll compares it against.
     *
     * @since 1.37.1
     * @param string $description Sanitized business description.
     * @return void
     */
    public static function save_business_description_option( $description ) {
        $description = (string) $description;
        $previous    = (string) get_option( 'sitestaffr_business_description', '' );

        update_option( 'sitestaffr_business_description', $description );

        if ( $description !== $previous ) {
            update_option( 'sitestaffr_business_description_updated', time() );
        }
    }

    /**
     * Count published pages and public CPT entries created since the Business
     * Identity was last written.
     *
     * A new page or service entry is what changes what a business *is* — a new
     * location, a new service line, a new product. That is the signal worth
     * nudging on, and it is one query with no threshold to tune.
     *
     * `post` is deliberately excluded: the Blog Agent publishes on autopilot, so
     * a blog-triggered nudge would fire every week forever and get trained into
     * background noise.
     *
     * Returns 0 when the timestamp option is missing. An identity that was never
     * generated is a different state — not a stale one — and must never be
     * reported as out of date.
     *
     * @since 1.37.1
     * @return int Number of qualifying entries, capped at IDENTITY_STALENESS_COUNT_CAP.
     */
    public static function count_content_published_since_identity() {
        $updated_at = (int) get_option( 'sitestaffr_business_description_updated', 0 );
        if ( $updated_at <= 0 ) {
            return 0;
        }

        $types = get_post_types( array( 'public' => true ), 'names' );
        $types = array_diff(
            array_values( $types ),
            array( 'post', 'attachment', 'revision', 'nav_menu_item', 'wp_block', 'wp_template', 'wp_template_part', 'wp_global_styles', 'wp_navigation', 'wp_font_family', 'wp_font_face' )
        );

        if ( empty( $types ) ) {
            return 0;
        }

        // Compare against post_date_gmt: the stored timestamp is UTC, and the
        // default date_query column (post_date) is site-local.
        $ids = get_posts(
            array(
                'post_type'              => array_values( $types ),
                'post_status'            => 'publish',
                'posts_per_page'         => self::IDENTITY_STALENESS_COUNT_CAP,
                'fields'                 => 'ids',
                'no_found_rows'          => true,
                'update_post_meta_cache' => false,
                'update_post_term_cache' => false,
                'date_query'             => array(
                    array(
                        'column'    => 'post_date_gmt',
                        'after'     => gmdate( 'Y-m-d H:i:s', $updated_at ),
                        'inclusive' => false,
                    ),
                ),
            )
        );

        return count( $ids );
    }

    /**
     * AJAX handler for saving business description.
     *
     * @since    1.4.8
     */
    public function ajax_save_business_description() {
        // Verify nonce
        $nonce = isset( $_POST['_wpnonce'] ) ? sanitize_text_field( wp_unslash( $_POST['_wpnonce'] ) ) : '';
        if ( empty( $nonce ) || ! wp_verify_nonce( $nonce, 'sitestaffr_save_description' ) ) {
            wp_send_json_error( __( 'Security check failed.', 'sitestaffr' ) );
            return;
        }

        // Check user capability
        if ( ! current_user_can( 'manage_options' ) ) {
            wp_send_json_error( __( 'Insufficient permissions', 'sitestaffr' ) );
            return;
        }

        // Get and sanitize description
        $description = isset( $_POST['description'] ) ? sanitize_textarea_field( wp_unslash( $_POST['description'] ) ) : '';

        if ( strlen( $description ) > self::BUSINESS_DESCRIPTION_MAX_LENGTH ) {
            wp_send_json_error( array(
                'message' => sprintf(
                    /* translators: %d is the max business description length */
                    __( 'Business description is too long. Maximum %d characters.', 'sitestaffr' ),
                    self::BUSINESS_DESCRIPTION_MAX_LENGTH
                ),
            ) );
            return;
        }

        // Save to database
        self::save_business_description_option( $description );

        // Return success
        wp_send_json_success( array(
            'message' => __( 'Description saved successfully', 'sitestaffr' ),
            'timestamp' => (int) get_option( 'sitestaffr_business_description_updated', 0 ),
        ) );
    }

    /**
     * AJAX handler: check if a description was freshly generated.
     *
     * Lightweight endpoint polled by JS after a connection timeout to recover
     * descriptions that were saved server-side despite the browser losing the
     * HTTP connection (common when the two-pass AI pipeline takes > 60s).
     *
     * @since 1.11.4
     */
    public function ajax_check_fresh_description() {
        $nonce = isset( $_POST['nonce'] ) ? sanitize_text_field( wp_unslash( $_POST['nonce'] ) ) : '';
        if ( empty( $nonce ) || ! wp_verify_nonce( $nonce, 'sitestaffr_generate_business_description' ) ) {
            wp_send_json_error();
            return;
        }

        if ( ! current_user_can( 'manage_options' ) ) {
            wp_send_json_error();
            return;
        }

        $since = isset( $_POST['since'] ) ? (int) wp_unslash( $_POST['since'] ) : 0; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- integer cast is sufficient sanitization.
        if ( $since <= 0 ) {
            wp_send_json_error();
            return;
        }

        $updated_at = (int) get_option( 'sitestaffr_business_description_updated', 0 );
        if ( $updated_at <= 0 || $updated_at < $since ) {
            wp_send_json_error( array( 'pending' => true ) );
            return;
        }

        $description = get_option( 'sitestaffr_business_description', '' );
        if ( empty( $description ) ) {
            wp_send_json_error();
            return;
        }

        wp_send_json_success( array(
            'description' => $description,
            'remaining'   => $this->get_remaining_description_generations(),
            'updated_at'  => $updated_at,
        ) );
    }

    /**
     * AJAX handler: generate business description from synced knowledge chunks.
     *
     * Collects all synced knowledge chunks and sends them to middleware for
     * AI synthesis into a structured business description.
     *
     * @since 1.10.0
     */
    public function ajax_generate_business_description_from_website() {
        $nonce = isset( $_POST['nonce'] ) ? sanitize_text_field( wp_unslash( $_POST['nonce'] ) ) : '';
        if ( empty( $nonce ) || ! wp_verify_nonce( $nonce, 'sitestaffr_generate_business_description' ) ) {
            wp_send_json_error( array( 'message' => __( 'Security check failed. Please refresh and try again.', 'sitestaffr' ) ) );
            return;
        }

        if ( ! current_user_can( 'manage_options' ) ) {
            wp_send_json_error( array( 'message' => __( 'You do not have permission to do this.', 'sitestaffr' ) ), 403 );
            return;
        }

        // RAG synthesis can take 30-90s — extend limits for this request only.
        ignore_user_abort( true ); // phpcs:ignore Generic.PHP.NoSilencedErrors.Discouraged
        if ( function_exists( 'set_time_limit' ) ) {
            set_time_limit( 120 ); // phpcs:ignore Squiz.PHP.DiscouragedFunctions.Discouraged
        }

        $result = $this->generate_and_store_business_identity();

        if ( is_wp_error( $result ) ) {
            $status = ( 'scan_rate_limited' === $result->get_error_code() ) ? 429 : 200;
            wp_send_json_error(
                array(
                    'message'    => $result->get_error_message(),
                    'error_code' => $result->get_error_code(),
                ),
                $status
            );
            return;
        }

        wp_send_json_success( $result );
    }

    /**
     * Start the background identity job (learn the site + write the identity).
     *
     * Returns immediately; the work runs on cron so it completes whether or not the
     * owner stays on the page.
     *
     * @return void
     */
    public function ajax_start_identity_job() {
        check_ajax_referer( 'sitestaffr_identity_job', 'nonce' );

        if ( ! current_user_can( 'manage_options' ) ) {
            wp_send_json_error( array( 'message' => __( 'You do not have permission to do this.', 'sitestaffr' ) ), 403 );
            return;
        }

        wp_send_json_success( self::identity_job_payload( SiteStaffr_Identity_Job::start() ) );
    }

    /**
     * Poll the background identity job.
     *
     * @return void
     */
    public function ajax_identity_job_status() {
        check_ajax_referer( 'sitestaffr_identity_job', 'nonce' );

        if ( ! current_user_can( 'manage_options' ) ) {
            wp_send_json_error( array( 'message' => __( 'You do not have permission to do this.', 'sitestaffr' ) ), 403 );
            return;
        }

        wp_send_json_success( self::identity_job_payload( SiteStaffr_Identity_Job::get_state() ) );
    }

    /**
     * Shape job state for the browser: progress, and the description once written.
     *
     * @param array $state Job state.
     * @return array
     */
    private static function identity_job_payload( array $state ) {
        $status     = isset( $state['status'] ) ? (string) $state['status'] : 'idle';
        $total      = isset( $state['total_posts'] ) ? (int) $state['total_posts'] : 0;
        $done       = isset( $state['posts_done'] ) ? (int) $state['posts_done'] : 0;

        // Sync is the bulk of the wait; generation is the tail.
        $percent = 5;
        if ( $total > 0 ) {
            $percent = 10 + (int) round( ( $done / $total ) * 60 );
        }
        if ( 'generating' === $status ) {
            $percent = 80;
        }
        if ( 'done' === $status ) {
            $percent = 100;
        }

        return array(
            'status'      => $status,
            'percent'     => min( 100, max( 0, $percent ) ),
            'total'       => $total,
            'done'        => $done,
            'synced'      => isset( $state['synced'] ) ? (int) $state['synced'] : 0,
            'current'     => isset( $state['current'] ) ? (string) $state['current'] : '',
            'error'       => isset( $state['error'] ) ? (string) $state['error'] : '',
            'error_code'  => isset( $state['error_code'] ) ? (string) $state['error_code'] : '',
            'description' => ( 'done' === $status ) ? (string) get_option( 'sitestaffr_business_description', '' ) : '',
        );
    }

    /**
     * Generate the business identity from synced knowledge and store it.
     *
     * The rate limit, sanitizing and the option writes live here so the AJAX
     * handler and the background identity job (SiteStaffr_Identity_Job, which runs
     * on cron and must survive the owner closing the tab) share one path.
     *
     * @return array|WP_Error Success payload, or WP_Error on failure.
     */
    public function generate_and_store_business_identity() {
        $rate_limit_result = $this->check_description_gen_rate_limit();
        if ( is_wp_error( $rate_limit_result ) ) {
            return $rate_limit_result;
        }

        $settings      = get_option( 'sitestaffr_settings', array() );
        $business_name = is_array( $settings ) && isset( $settings['business_name'] ) ? $settings['business_name'] : '';

        $description_result = $this->generate_business_description_from_knowledge( $business_name );
        if ( is_wp_error( $description_result ) ) {
            return $description_result;
        }

        $description_raw       = isset( $description_result['description'] ) ? $description_result['description'] : '';
        $description_sanitized = $this->sanitize_business_description( $description_raw );
        $description           = $description_sanitized['value'];

        if ( empty( $description ) ) {
            return new WP_Error(
                'empty_description',
                __( 'Unable to generate a business description from this website.', 'sitestaffr' )
            );
        }

        // Increment rate limit only on successful generation.
        $increment_result = $this->increment_description_gen_rate_limit();
        if ( is_wp_error( $increment_result ) ) {
            return $increment_result;
        }

        // Save generated description. The timestamp is written unconditionally
        // here — not via save_business_description_option(), which only stamps on
        // a real change — because ajax_check_fresh_description() polls it to tell
        // "generation finished" from "still running". A regeneration that lands on
        // the same text still has to resolve that poll.
        update_option( 'sitestaffr_business_description', $description );
        update_option( 'sitestaffr_business_description_updated', time() );
        $save_settings = get_option( 'sitestaffr_settings', array() );
        if ( ! is_array( $save_settings ) ) {
            $save_settings = array();
        }
        $save_settings['business_context'] = $description;
        // Detailed Business Profile is stored BODY-ONLY (summary lives in business_context);
        // strip the SUMMARY section so it is never duplicated. Blog Agent combines both at read-time.
        $profile_body = ! empty( $description_result['business_profile'] )
            ? self::strip_summary_section( (string) $description_result['business_profile'] )
            : '';
        if ( '' !== $profile_body ) {
            $save_settings['business_profile'] = $profile_body;
        }
        update_option( 'sitestaffr_settings', $save_settings );

        $success_data = array(
            'message'      => __( 'Business identity generated successfully.', 'sitestaffr' ),
            'description'  => $description,
            'remaining'    => $this->get_remaining_description_generations(),
            'last_updated' => current_time( 'mysql' ),
        );
        if ( '' !== $profile_body ) {
            $success_data['business_profile'] = $profile_body;
        }
        if ( isset( $description_result['extraction_chars'] ) ) {
            $success_data['extraction_chars'] = (int) $description_result['extraction_chars'];
        }

        return $success_data;
    }

    /**
     * Generate business description from synced knowledge chunks.
     *
     * Collects all chunk text from the knowledge DB and sends it to
     * middleware for AI synthesis. No web_search — content is pre-extracted.
     *
     * @since 1.14.0
     * @param string $business_name Business name for prompt context.
     * @return array|WP_Error Array with 'description' key on success.
     */
    private function generate_business_description_from_knowledge( $business_name ) {
        $knowledge_text = SiteStaffr_Knowledge_Manager::get_all_synced_chunks_as_text();

        if ( empty( $knowledge_text ) ) {
            return new WP_Error(
                'no_knowledge',
                __( 'No knowledge content is synced. Please sync your website content on the Knowledge page first.', 'sitestaffr' )
            );
        }

        $api_secrets = $this->get_business_description_api_secret_candidates();
        if ( empty( $api_secrets ) ) {
            return new WP_Error( 'missing_api_secret', __( 'AI generation is not configured for this site.', 'sitestaffr' ) );
        }

        $site_token = get_option( 'sitestaffr_site_token' );
        if ( empty( $site_token ) ) {
            $site_token = wp_generate_password( 32, false );
            update_option( 'sitestaffr_site_token', $site_token );
        }

        $settings = get_option( 'sitestaffr_settings', array() );
        if ( ! is_array( $settings ) ) {
            $settings = array();
        }

        $business_info = array(
            'business_name'        => $business_name,
            'business_hours'       => isset( $settings['business_hours'] ) ? $settings['business_hours'] : '',
            'business_description' => isset( $settings['business_context'] ) ? $settings['business_context'] : '',
            'business_phone'       => isset( $settings['business_phone'] ) ? $settings['business_phone'] : '',
        );

        $timestamp    = (string) time();
        $request_body = array(
            'site_token'       => $site_token,
            'site_url'         => get_site_url(),
            'business_info'    => $business_info,
            'timestamp'        => $timestamp,
            'knowledge_chunks' => $knowledge_text,
        );

        $last_error = new WP_Error( 'summary_empty', __( 'AI could not generate a usable business description.', 'sitestaffr' ) );

        foreach ( $api_secrets as $api_secret ) {
            $summary_result = $this->request_business_description_summary( $request_body, $api_secret, $timestamp );
            if ( is_wp_error( $summary_result ) ) {
                $last_error = $summary_result;
                continue;
            }

            if ( $summary_result['status'] < 200 || $summary_result['status'] >= 300 ) {
                $last_error = $this->build_business_description_summary_http_error( $summary_result );
                continue;
            }

            if ( ! is_array( $summary_result['data'] ) ) {
                $last_error = new WP_Error( 'summary_parse_error', __( 'Invalid AI response received while generating description.', 'sitestaffr' ) );
                continue;
            }

            if ( isset( $summary_result['data']['success'] ) && $summary_result['data']['success'] && ! empty( $summary_result['data']['ai_response'] ) ) {
                $description = $this->sanitize_generated_business_description_output( (string) $summary_result['data']['ai_response'] );
                if ( 'INSUFFICIENT_BUSINESS_CONTEXT' === strtoupper( $description ) ) {
                    return new WP_Error(
                        'scan_insufficient_context',
                        __( 'Website content does not contain enough business details. Please add more content to your site or write the description manually.', 'sitestaffr' )
                    );
                }
                if ( ! empty( $description ) ) {
                    // Rich Business Profile (Phase 1) — detailed source of truth for content
                    // features. Sanitized with a higher cap than the lean identity.
                    $profile_raw = isset( $summary_result['data']['business_profile'] ) ? (string) $summary_result['data']['business_profile'] : '';
                    $profile     = '' !== trim( $profile_raw ) ? $this->sanitize_generated_business_description_output( $profile_raw, 20000 ) : '';
                    return array( 'description' => $description, 'business_profile' => $profile );
                }
            }

            $last_error = new WP_Error( 'summary_empty', __( 'AI could not generate a usable business description.', 'sitestaffr' ) );
        }

        return $last_error;
    }

    /**
     * Resolve API secret candidates for business description summary requests.
     *
     * Tries site-scoped secret first, then global secret as fallback.
     *
     * @since 0.14.264
     * @return array
     */
    private function get_business_description_api_secret_candidates() {
        $candidates = array();

        $site_api_secret = get_option( 'sitestaffr_site_api_secret' );
        if ( ! empty( $site_api_secret ) && is_string( $site_api_secret ) ) {
            $candidates[] = $site_api_secret;
        }

        $global_api_secret = get_option( 'sitestaffr_api_secret' );
        if ( ! empty( $global_api_secret ) && is_string( $global_api_secret ) ) {
            $candidates[] = $global_api_secret;
        }

        return array_values( array_unique( array_filter( $candidates ) ) );
    }

    /**
     * Call middleware summary endpoint for business description generation.
     *
     * @since 0.14.264
     * @param array  $request_body Request body payload.
     * @param string $api_secret   HMAC signing secret.
     * @param string $timestamp    Request timestamp.
     * @return array|WP_Error
     */
    private function request_business_description_summary( $request_body, $api_secret, $timestamp ) {
        $payload_json = wp_json_encode( $request_body );
        if ( false === $payload_json ) {
            return new WP_Error( 'encode_failed', __( 'Failed to prepare AI request payload.', 'sitestaffr' ) );
        }

		$payload_to_sign = $timestamp . '.' . $payload_json;
		$signature = base64_encode( hash_hmac( 'sha256', $payload_to_sign, $api_secret, true ) );
        $response = wp_remote_post(
            rtrim( SITESTAFFR_MIDDLEWARE_URL, '/' ) . '/api/v1/summary',
            array(
                'timeout' => 600,
                'headers' => array(
                    'Content-Type'          => 'application/json',
                    'X-SiteStaffr-Signature' => $signature,
                    'X-SiteStaffr-Timestamp' => $timestamp,
                    'X-SiteStaffr-Site-URL'  => esc_url_raw( get_site_url() ),
                ),
                'body'    => $payload_json,
            )
        );

        if ( is_wp_error( $response ) ) {
            return new WP_Error( 'summary_request_failed', __( 'Website scan completed, but AI description generation failed. Please try again.', 'sitestaffr' ) );
        }

        $status = wp_remote_retrieve_response_code( $response );
        $body = wp_remote_retrieve_body( $response );
        $data = json_decode( $body, true );

        return array(
            'status' => $status,
            'data'   => $data,
            'body'   => $body,
        );
    }

    /**
     * Build descriptive WP_Error for non-2xx summary endpoint responses.
     *
     * @since 0.14.264
     * @param array $summary_result Summary request result payload.
     * @return WP_Error
     */
    private function build_business_description_summary_http_error( $summary_result ) {
        $status = isset( $summary_result['status'] ) ? (int) $summary_result['status'] : 0;
        $data = isset( $summary_result['data'] ) ? $summary_result['data'] : null;
        $body = isset( $summary_result['body'] ) ? (string) $summary_result['body'] : '';

        $remote_message = '';
        if ( is_array( $data ) ) {
            if ( isset( $data['error'] ) && is_string( $data['error'] ) ) {
                $remote_message = sanitize_text_field( $data['error'] );
            } elseif ( isset( $data['message'] ) && is_string( $data['message'] ) ) {
                $remote_message = sanitize_text_field( $data['message'] );
            } elseif ( isset( $data['data']['error'] ) && is_string( $data['data']['error'] ) ) {
                $remote_message = sanitize_text_field( $data['data']['error'] );
            } elseif ( isset( $data['data']['message'] ) && is_string( $data['data']['message'] ) ) {
                $remote_message = sanitize_text_field( $data['data']['message'] );
            }
        }

        if ( empty( $remote_message ) && ! empty( $body ) ) {
            $trimmed_body = trim( wp_strip_all_tags( $body ) );
            if ( ! empty( $trimmed_body ) && strlen( $trimmed_body ) <= 180 ) {
                $remote_message = sanitize_text_field( $trimmed_body );
            }
        }

        if ( $status >= 500 ) {
            $message = __( 'AI description service is temporarily unavailable. Please try again shortly.', 'sitestaffr' );
            $code = 'summary_http_server';
        } elseif ( 429 === $status ) {
            $message = __( 'AI description generation is temporarily rate-limited. Please try again in a minute.', 'sitestaffr' );
            $code = 'summary_http_rate_limited';
        } elseif ( 413 === $status ) {
            $message = __( 'Website content is too large to summarize right now. Please try manual editing for now.', 'sitestaffr' );
            $code = 'summary_http_payload_too_large';
        } elseif ( 401 === $status || 403 === $status ) {
            $message = __( 'AI description authentication failed. Please reconnect site integration and try again.', 'sitestaffr' );
            $code = 'summary_http_auth';
        } elseif ( $status >= 400 && $status < 500 ) {
            $message = __( 'AI description request was rejected. Please try again.', 'sitestaffr' );
            $code = 'summary_http_client_error';
        } else {
            $message = __( 'AI description service is currently unavailable. Please try again.', 'sitestaffr' );
            $code = 'summary_http_error';
        }

        if ( ! empty( $remote_message ) ) {
            $message .= ' ' . $remote_message;
        }

        SiteStaffr_Logger::legacy(
            sprintf(
                'SiteStaffr Business Description: summary endpoint non-200 (status=%d, code=%s, remote_message=%s)',
                $status,
                $code,
                ! empty( $remote_message ) ? $remote_message : 'n/a'
            )
        );

        return new WP_Error( $code, $message );
    }

    /**
     * Post-process generated business description text.
     *
     * Removes summary artifacts and enforces strict max length for UI + storage.
     *
     * @since 0.14.198
     * @param string $raw_text Raw model output.
     * @return string
     */
    private function sanitize_generated_business_description_output( $raw_text, $max_length = 0 ) {
        $text = wp_strip_all_tags( (string) $raw_text );
        $text = html_entity_decode( $text, ENT_QUOTES, 'UTF-8' );

        // Strip markdown bold/italic markers (won't render in a textarea).
        $text = preg_replace( '/\*{1,3}([^*]+)\*{1,3}/', '$1', $text );

        // Normalize line endings and collapse runs of 3+ blank lines to 2.
        $text = str_replace( "\r\n", "\n", $text );
        $text = str_replace( "\r", "\n", $text );
        $text = preg_replace( '/\n{3,}/', "\n\n", $text );

        // Collapse horizontal whitespace (spaces/tabs) within each line, but preserve newlines.
        $text = preg_replace( '/[^\S\n]+/', ' ', $text );

        // Trim each line individually.
        $text = implode( "\n", array_map( 'trim', explode( "\n", $text ) ) );

        // Remove recap-style tail content from summary-oriented prompts.
        $text = preg_replace( '/\bSuggested\s*follow[- ]?up\s*:\s*.*/iu', '', $text );
        $text = preg_replace( '/\bNext\s*steps?\s*:\s*.*/iu', '', $text );

        // Strip trailing source/citation sections from web search output.
        $text = preg_replace( '/\n(?:Sources?|Pages?\s+used|References?|Citations?)\s*:?\s*\n.*$/is', '', $text );
        $text = preg_replace( '/\n---+\s*\n(?:\s*(?:https?:\/\/|-).*\n?)+$/i', '', $text );

        $text = trim( $text );

        $limit = $max_length > 0 ? (int) $max_length : self::BUSINESS_DESCRIPTION_MAX_LENGTH;
        if ( strlen( $text ) > $limit ) {
            $text = substr( $text, 0, $limit );
            // Avoid ending mid-word when possible.
            $text = preg_replace( '/\s+\S*$/', '', $text );
            $text = rtrim( $text, " \t\n\r\0\x0B,;:-" );
        }

        return trim( $text );
    }

    /**
     * Resolve current daily cycle key for description generation limits.
     *
     * @since 1.1.0
     * @return string UTC datetime key (Y-m-d H:i:s).
     */
    private function get_description_generation_cycle_key() {
        return current_time( 'Y-m-d' );
    }

    /**
     * Read current description generation count for this billing cycle.
     *
     * Resets automatically when a new plan billing cycle begins.
     *
     * @since 1.1.0
     * @return array { settings: array, count: int }
     */
    private function get_description_gen_cycle_state() {
        $settings = get_option( 'sitestaffr_settings', array() );
        if ( ! is_array( $settings ) ) {
            $settings = array();
        }

        $today       = $this->get_description_generation_cycle_key();
        $cycle_start = isset( $settings['description_gen_cycle_start'] ) ? sanitize_text_field( (string) $settings['description_gen_cycle_start'] ) : '';
        $count       = isset( $settings['description_gen_count'] ) ? (int) $settings['description_gen_count'] : 0;
        $count       = max( 0, min( SITESTAFFR_DAILY_GEN_LIMIT, $count ) );

        if ( $cycle_start !== $today ) {
            $count       = 0;
            $cycle_start = $today;
        }

        $settings['description_gen_cycle_start'] = $cycle_start;
        $settings['description_gen_count']       = $count;

        return array(
            'settings' => $settings,
            'count'    => $count,
        );
    }

    /**
     * Rate limit description generation per billing cycle.
     *
     * @since 1.1.0
     * @return true|WP_Error
     */
    private function check_description_gen_rate_limit() {
        $state = $this->get_description_gen_cycle_state();

        if ( $state['count'] >= SITESTAFFR_DAILY_GEN_LIMIT ) {
            return new WP_Error(
                'scan_rate_limited',
                __( 'You\'ve reached the daily limit for description generations. Please try again tomorrow.', 'sitestaffr' )
            );
        }

        return true;
    }

    /**
     * Increment description generation count for current billing cycle.
     *
     * @since 1.1.0
     * @return true|WP_Error
     */
    private function increment_description_gen_rate_limit() {
        $state = $this->get_description_gen_cycle_state();

        if ( $state['count'] >= SITESTAFFR_DAILY_GEN_LIMIT ) {
            return new WP_Error(
                'scan_rate_limited',
                __( 'You\'ve reached the daily limit for description generations. Please try again tomorrow.', 'sitestaffr' )
            );
        }

        $settings                          = $state['settings'];
        $settings['description_gen_count'] = $state['count'] + 1;
        update_option( 'sitestaffr_settings', $settings );

        return true;
    }

    /**
     * Get remaining description generations for today.
     *
     * @since 1.1.0
     * @return int
     */
    public function get_remaining_description_generations() {
        $state = $this->get_description_gen_cycle_state();
        return max( 0, SITESTAFFR_DAILY_GEN_LIMIT - $state['count'] );
    }

    /**
     * Public wrapper for auto-sync candidates, formatted for REST API.
     *
     * @since 1.14.0
     * @return array Array of { id, title, post_type } arrays.
     */
    public function get_auto_sync_candidates_for_rest() {
        $posts = $this->get_auto_sync_candidates();
        $result = array();
        foreach ( $posts as $post ) {
            $result[] = array(
                'id'        => $post->ID,
                'title'     => $post->post_title,
                'post_type' => $post->post_type,
            );
        }
        return $result;
    }

    /**
     * Get top pages for auto-sync during wizard description generation.
     *
     * Queries all published pages/posts/CPTs, filters by minimum text content,
     * ranks by priority (homepage > title keywords > top-level > CPTs > posts),
     * and caps at 20.
     *
     * @since 1.14.0
     * @return array Array of WP_Post objects ranked by priority.
     */
    private function get_auto_sync_candidates() {
        $public_types    = get_post_types( array( 'public' => true ), 'names' );
        $queryable_types = get_post_types( array( 'publicly_queryable' => true ), 'names' );
        $ui_types        = get_post_types( array( 'show_ui' => true ), 'names' );
        $all_types       = array_unique( array_merge( array_values( $public_types ), array_values( $queryable_types ), array_values( $ui_types ) ) );
        $all_types       = array_diff( $all_types, array( 'attachment', 'revision', 'nav_menu_item', 'custom_css', 'customize_changeset', 'oembed_cache', 'user_request', 'wp_block', 'wp_template', 'wp_template_part', 'wp_global_styles', 'wp_navigation', 'wp_font_family', 'wp_font_face' ) );

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

        $posts = get_posts( array(
            'post_type'              => array_values( $all_types ),
            'post_status'            => 'publish',
            'posts_per_page'         => -1,
            'orderby'                => 'title',
            'order'                  => 'ASC',
            'no_found_rows'          => true,
            'update_post_meta_cache' => false,
            'update_post_term_cache' => false,
        ) );

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

        $min_chars = 500;
        $cap       = 20;

        $exclude_title_keywords = array( 'blog', 'news', 'press', 'archive', 'tag', 'category', 'privacy', 'terms', 'cookie', 'legal', 'disclaimer', 'login', 'cart', 'checkout', 'manage', 'account', 'register', 'sign up', 'sign in', 'reset password', 'my account', 'dashboard', 'maintenance', 'testimonials', 'reviews', 'success stories', 'case studies', 'before and after', 'results', 'transformations' );
        $priority_title_keywords = array( 'about', 'services', 'pricing', 'fees', 'faq', 'contact', 'team', 'staff', 'hours', 'procedures', 'products', 'plans', 'how it works', 'getting started', 'locations' );

        $homepage_id = (int) get_option( 'page_on_front' );
        $candidates  = array();

        foreach ( $posts as $post ) {
            $raw_content = $post->post_content;

            // If post_content is non-empty, check minimum text length.
            // If post_content is empty AND the page has a PHP template
            // (either explicitly assigned or via page-{slug}.php hierarchy),
            // let it through — the sync pipeline's rendered-page fallback
            // will extract real content from the template file.
            // Empty pages with no template are true placeholders and skipped.
            if ( ! empty( $raw_content ) ) {
                $text_length = strlen( wp_strip_all_tags( $raw_content ) );
                if ( $text_length < $min_chars ) {
                    continue;
                }
            } else {
                $has_template = false;
                // Check explicit template assignment (Page Attributes metabox).
                $template_slug = get_page_template_slug( $post->ID );
                if ( ! empty( $template_slug ) && 'default' !== $template_slug ) {
                    $has_template = true;
                }
                // Check automatic page-{slug}.php template hierarchy.
                if ( ! $has_template && 'page' === $post->post_type ) {
                    $slug_template = get_stylesheet_directory() . '/page-' . $post->post_name . '.php';
                    if ( file_exists( $slug_template ) ) {
                        $has_template = true;
                    }
                }
                if ( ! $has_template ) {
                    continue; // No template and no content — skip.
                }
                $text_length = 0;
            }

            $title_lower = strtolower( $post->post_title );

            // Exclude by title keywords.
            $excluded = false;
            foreach ( $exclude_title_keywords as $kw ) {
                if ( false !== strpos( $title_lower, $kw ) ) {
                    $excluded = true;
                    break;
                }
            }
            if ( $excluded ) {
                continue;
            }

            // Compute priority score (lower = higher priority).
            $score = 500; // default

            if ( $post->ID === $homepage_id ) {
                $score = 0; // homepage always first
            } else {
                // Title keyword match.
                foreach ( $priority_title_keywords as $kw ) {
                    if ( false !== strpos( $title_lower, $kw ) ) {
                        $score = 100;
                        break;
                    }
                }

                // Top-level page (no parent) gets priority bump.
                if ( 'page' === $post->post_type && 0 === (int) $post->post_parent && $score > 100 ) {
                    $score = 200;
                }

                // Pages over CPTs over posts.
                if ( 'page' === $post->post_type && $score > 200 ) {
                    $score = 300;
                } elseif ( 'post' !== $post->post_type && 'page' !== $post->post_type && $score > 300 ) {
                    $score = 350; // CPTs
                }
                // Posts stay at 500.
            }

            $candidates[] = array(
                'post'         => $post,
                'score'        => $score,
                'text_length'  => $text_length,
            );
        }

        // Sort by score ascending, then text_length descending as tiebreaker.
        usort( $candidates, function ( $a, $b ) {
            if ( $a['score'] !== $b['score'] ) {
                return $a['score'] - $b['score'];
            }
            return $b['text_length'] - $a['text_length'];
        } );

        $candidates = array_slice( $candidates, 0, $cap );

        return array_map( function ( $c ) {
            return $c['post'];
        }, $candidates );
    }

}
