<?php
/**
 * Blog Agent manager.
 *
 * Handles blog post job CRUD and WordPress draft creation.
 *
 * @package    SiteStaffr
 * @subpackage SiteStaffr/includes
 * @since      1.21.0
 */

if ( ! defined( 'WPINC' ) ) {
    die;
}

// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
class SiteStaffr_Blog_Manager {

    /**
     * Get the blog posts table name.
     *
     * @return string
     */
    private static function table() {
        global $wpdb;
        return $wpdb->prefix . 'sitestaffr_blog_posts';
    }

    /**
     * Create a new blog post job.
     *
     * @param string $job_id  Middleware job identifier.
     * @param string $topic   Blog post topic.
     * @param string $keyword Target keyword.
     * @param array  $request_payload Full request sent to middleware.
     * @return int|false Insert ID on success, false on failure.
     */
    public static function create_job( $job_id, $topic, $keyword, $request_payload = array() ) {
        global $wpdb;

        $result = $wpdb->insert(
            self::table(),
            array(
                'job_id'          => $job_id,
                'topic'           => $topic,
                'keyword'         => $keyword,
                'status'          => 'generating',
                'request_payload' => wp_json_encode( $request_payload ),
                'created_at'      => current_time( 'mysql' ),
            ),
            array( '%s', '%s', '%s', '%s', '%s', '%s' )
        );

        return $result ? $wpdb->insert_id : false;
    }

    /**
     * Update a job's status and response.
     *
     * @param int    $id     Row ID.
     * @param string $status New status (generating|complete|failed).
     * @param array  $data   Optional additional columns to update.
     * @return bool
     */
    public static function update_job( $id, $status, $data = array() ) {
        global $wpdb;

        $update = array_merge( $data, array( 'status' => $status ) );
        $format = array();

        foreach ( $update as $key => $value ) {
            if ( in_array( $key, array( 'wp_post_id', 'id' ), true ) ) {
                $format[] = '%d';
            } else {
                $format[] = '%s';
            }
        }

        $result = $wpdb->update(
            self::table(),
            $update,
            array( 'id' => $id ),
            $format,
            array( '%d' )
        );

        return $result !== false;
    }

    /**
     * Get a job by row ID.
     *
     * @param int $id Row ID.
     * @return object|null
     */
    public static function get_job( $id ) {
        global $wpdb;

        return $wpdb->get_row(
            $wpdb->prepare(
                'SELECT * FROM %i WHERE id = %d',
                self::table(),
                $id
            )
        );
    }

    /**
     * Get a job by middleware job_id.
     *
     * @param string $job_id Middleware job identifier.
     * @return object|null
     */
    public static function get_job_by_job_id( $job_id ) {
        global $wpdb;

        return $wpdb->get_row(
            $wpdb->prepare(
                'SELECT * FROM %i WHERE job_id = %s',
                self::table(),
                $job_id
            )
        );
    }

    /**
     * Get a job by WordPress post ID.
     *
     * @param int $wp_post_id WordPress post ID.
     * @return object|null
     */
    public static function get_job_by_wp_post_id( $wp_post_id ) {
        global $wpdb;

        return $wpdb->get_row(
            $wpdb->prepare(
                'SELECT * FROM %i WHERE wp_post_id = %d',
                self::table(),
                $wp_post_id
            )
        );
    }

    /**
     * Get all blog post jobs, newest first.
     *
     * @param int $limit Max rows to return.
     * @return array
     */
    public static function get_all_jobs( $limit = 50 ) {
        global $wpdb;

        return $wpdb->get_results(
            $wpdb->prepare(
                'SELECT * FROM %i ORDER BY created_at DESC LIMIT %d',
                self::table(),
                $limit
            )
        );
    }

    /**
     * Format a job's created_at for display in Eastern Time.
     *
     * created_at is written via current_time('mysql') — a site-local
     * wall-clock string — so parse in the site timezone first.
     *
     * @param string $created_at MySQL datetime string.
     * @return string
     */
    public static function format_job_created_at( $created_at ) {
        if ( '' === (string) $created_at ) {
            return ''; // DateTime('') would silently mean "now".
        }
        try {
            $date = new DateTime( (string) $created_at, wp_timezone() );
        } catch ( Exception $e ) {
            return '';
        }

        return wp_date( 'M j, g:i a', $date->getTimestamp(), new DateTimeZone( 'America/New_York' ) );
    }

    /**
     * Delete a blog post job row.
     *
     * @param int $id Row ID.
     * @return bool
     */
    public static function delete_job( $id ) {
        global $wpdb;

        $result = $wpdb->delete(
            self::table(),
            array( 'id' => $id ),
            array( '%d' )
        );

        return $result !== false;
    }

    /**
     * Import a delivered post payload into WordPress.
     *
     * Single import path shared by the middleware's signed push
     * (ajax_blog_receive_post) and the pull-delivery poller (pickup_poll) —
     * the caller authenticates; this method only imports. Idempotent per
     * job_id via the local jobs row: a job that already produced a draft
     * returns it without duplicating or draining budget twice.
     *
     * @since 1.32.0
     * @param  array $data Decoded delivery payload (job_id, post, autopilot?).
     * @return array|WP_Error array( wp_post_id, status, duplicate ) or error.
     */
    public static function import_delivered_post( $data ) {
        if ( empty( $data ) || empty( $data['post'] ) || empty( $data['post']['title'] ) ) {
            return new WP_Error( 'missing_post_data', 'Missing post data.' );
        }

        $job_id = isset( $data['job_id'] ) ? sanitize_text_field( $data['job_id'] ) : '';
        $post   = $data['post'];

        $existing = '' !== $job_id ? self::get_job_by_job_id( $job_id ) : null;

        // The autopilot flag comes from OUR job row, recorded when the cron tick
        // requested generation — never from the middleware payload, which carries
        // no authority over publish behavior. (Payload flag kept as fallback for
        // in-flight jobs from older versions.)
        $is_autopilot = ! empty( $data['autopilot'] );
        if ( $existing && ! empty( $existing->request_payload ) ) {
            $request_payload = json_decode( $existing->request_payload, true );
            if ( is_array( $request_payload ) && array_key_exists( 'autopilot', $request_payload ) ) {
                $is_autopilot = ! empty( $request_payload['autopilot'] );
            }
        }
        if ( $existing && ! empty( $existing->wp_post_id ) ) {
            return array(
                'wp_post_id' => (int) $existing->wp_post_id,
                'status'     => get_post_status( (int) $existing->wp_post_id ) ?: 'draft',
                'duplicate'  => true,
            );
        }

        if ( $existing ) {
            $row_id = (int) $existing->id;
        } else {
            $topic   = isset( $post['title'] ) ? sanitize_text_field( $post['title'] ) : '';
            $keyword = isset( $post['target_keyword'] ) ? sanitize_text_field( $post['target_keyword'] ) : '';
            $row_id  = self::create_job( $job_id, $topic, $keyword, $data );
        }
        if ( ! $row_id ) {
            return new WP_Error( 'job_record_failed', 'Failed to save job record.' );
        }

        // Determine post status. Autopilot posts publish immediately at receive
        // time when auto_publish is on (the cron tick already fired at the
        // scheduled slot); otherwise everything lands as a draft. Autopilot
        // delivery is the only path that publishes-on-arrival or emails.
        $blog_settings = self::get_settings();
        $auto_publish  = isset( $blog_settings['auto_publish'] ) ? (bool) $blog_settings['auto_publish'] : true;
        $review_mode   = ! empty( $blog_settings['review_by_email'] );

        if ( $is_autopilot && $review_mode ) {
            $post_status = 'draft';
        } elseif ( $is_autopilot && $auto_publish ) {
            $post_status = 'publish';
        } else {
            $post_status = 'draft';
        }

        $wp_post_id = self::create_wp_draft( $row_id, $post, $post_status );

        if ( is_wp_error( $wp_post_id ) ) {
            self::update_job( $row_id, 'failed' );
            return $wp_post_id;
        }

        // Post budget is NOT drained here. The middleware is the source of truth
        // for the post count (effective limit = plan + grant, enforced via
        // countPostsSince); the plugin's post_budget_used is synced from each
        // blog response in sync_budget_from_middleware(). Incrementing locally
        // here is what double-counted. Topic budget is drained when suggestions
        // are fetched (replenish), not here.

        // Persist the job record WITHOUT the raw image bytes — the image is
        // already in the media library, so keeping the multi-MB data URL in the
        // database would just bloat it.
        $stored = $data;
        if ( isset( $stored['post']['featured_image_url'] ) ) {
            $stored['post']['featured_image_url'] = '';
        }
        self::update_job( $row_id, 'complete', array(
            'response_payload' => wp_json_encode( $stored ),
            'completed_at'     => current_time( 'mysql' ),
        ) );

        // Autopilot-only emails (manual posts never auto-email). Each sender holds
        // its own send-once guard.
        if ( $is_autopilot && class_exists( 'SiteStaffr_Email_Notifications' ) ) {
            $notifier = new SiteStaffr_Email_Notifications();
            if ( $review_mode && 'draft' === $post_status ) {
                $notifier->send_blog_ready_notification( $wp_post_id );
            } elseif ( 'publish' === $post_status ) {
                $notifier->send_blog_published_notification( $wp_post_id );
            }
        }

        return array(
            'wp_post_id' => $wp_post_id,
            'status'     => $post_status,
            'duplicate'  => false,
        );
    }

    /**
     * Create a WordPress draft post from middleware response.
     *
     * @param int   $job_id_row Row ID in blog_posts table.
     * @param array $post_data  Post data from middleware response['post'].
     * @return int|WP_Error WordPress post ID on success, WP_Error on failure.
     */
    public static function create_wp_draft( $job_id_row, $post_data, $post_status = 'draft', $publish_at = null ) {
        if ( empty( $post_data['title'] ) || empty( $post_data['body_html'] ) ) {
            return new WP_Error( 'missing_post_data', 'Post title and body are required.' );
        }

        $author_id = get_current_user_id();
        if ( ! $author_id ) {
            $admins    = get_users( array( 'role' => 'administrator', 'number' => 1, 'fields' => 'ids' ) );
            $author_id = ! empty( $admins ) ? (int) $admins[0] : 0;
        }

        $wp_post = array(
            'post_title'   => sanitize_text_field( $post_data['title'] ),
            'post_content' => wp_kses_post( $post_data['body_html'] ),
            'post_excerpt' => sanitize_textarea_field( isset( $post_data['excerpt'] ) ? $post_data['excerpt'] : '' ),
            'post_status'  => $post_status,
            'post_type'    => 'post',
            'post_name'    => sanitize_title( isset( $post_data['slug'] ) ? $post_data['slug'] : $post_data['title'] ),
        );

        if ( $author_id ) {
            $wp_post['post_author'] = $author_id;
        }

        if ( 'publish' === $post_status && $publish_at ) {
            $wp_post['post_date']     = get_date_from_gmt( $publish_at );
            $wp_post['post_date_gmt'] = $publish_at;
        }

        $wp_post_id = wp_insert_post( $wp_post, true );

        if ( is_wp_error( $wp_post_id ) ) {
            return $wp_post_id;
        }

        // Set categories. Snap returned terms to existing categories first so
        // near-duplicates reuse the site's taxonomy instead of bloating it.
        $cat_ids = array();
        if ( ! empty( $post_data['categories'] ) && is_array( $post_data['categories'] ) ) {
            $existing_cats = get_terms( array( 'taxonomy' => 'category', 'hide_empty' => false, 'number' => 200 ) );
            $existing_cats = is_wp_error( $existing_cats ) ? array() : $existing_cats;
            foreach ( $post_data['categories'] as $cat_name ) {
                $cat_name = is_string( $cat_name ) ? sanitize_text_field( $cat_name ) : '';
                if ( '' === $cat_name ) {
                    continue;
                }
                // An explicit "Uncategorized" from the middleware is a non-category —
                // skipping it leaves WP's default in place rather than legitimizing it.
                if ( preg_match( '/^uncategorized$/i', $cat_name ) ) {
                    continue;
                }
                $cat_name = self::snapTaxonomy( $cat_name, $existing_cats );
                $term     = term_exists( $cat_name, 'category' );
                if ( ! $term ) {
                    $term = wp_insert_term( $cat_name, 'category' );
                }
                if ( ! is_wp_error( $term ) ) {
                    $cat_ids[] = is_array( $term ) ? (int) $term['term_id'] : (int) $term;
                } else {
                    SiteStaffr_Logger::legacy( 'Blog: category term failed for "' . $cat_name . '": ' . $term->get_error_message() );
                }
            }
            if ( ! empty( $cat_ids ) ) {
                wp_set_post_categories( $wp_post_id, $cat_ids );
            }
        }
        // One-time cleanup of the temporary category diagnostic (bug closed 2026-06-12).
        delete_option( 'sitestaffr_blog_category_diag' );

        // Set tags. Snap returned terms to existing tags first for the same
        // reuse-don't-invent behavior.
        if ( ! empty( $post_data['tags'] ) && is_array( $post_data['tags'] ) ) {
            $existing_tags = get_terms( array( 'taxonomy' => 'post_tag', 'hide_empty' => false, 'number' => 200 ) );
            $existing_tags = is_wp_error( $existing_tags ) ? array() : $existing_tags;
            $snapped_tags  = array();
            foreach ( $post_data['tags'] as $tag_name ) {
                $snapped_tags[] = sanitize_text_field( self::snapTaxonomy( $tag_name, $existing_tags ) );
            }
            wp_set_post_tags( $wp_post_id, $snapped_tags );
        }

        // Apply SEO metadata via detection chain.
        $meta_title = ! empty( $post_data['meta_title'] ) ? sanitize_text_field( $post_data['meta_title'] ) : '';
        $meta_desc  = ! empty( $post_data['meta_description'] ) ? sanitize_text_field( $post_data['meta_description'] ) : '';
        $focus_kw   = ! empty( $post_data['target_keyword'] ) ? sanitize_text_field( $post_data['target_keyword'] ) : '';

        // Meta description goes through the shared resolver (also used by the
        // no-login review handler) so the Yoast/RankMath/fallback key mapping
        // lives in exactly one place. Title and focus keyword stay inline — they
        // are written only here.
        if ( $meta_desc ) {
            self::set_seo_meta_description( $wp_post_id, $meta_desc );
        }
        if ( defined( 'WPSEO_VERSION' ) ) {
            // Yoast SEO.
            if ( $meta_title ) {
                update_post_meta( $wp_post_id, '_yoast_wpseo_title', $meta_title );
            }
            if ( $focus_kw ) {
                update_post_meta( $wp_post_id, '_yoast_wpseo_focuskw', $focus_kw );
            }
        } elseif ( defined( 'RANK_MATH_VERSION' ) ) {
            // RankMath.
            if ( $meta_title ) {
                update_post_meta( $wp_post_id, 'rank_math_title', $meta_title );
            }
            if ( $focus_kw ) {
                update_post_meta( $wp_post_id, 'rank_math_focus_keyword', $focus_kw );
            }
        } else {
            // No SEO plugin — store in custom meta as fallback.
            if ( $meta_title ) {
                update_post_meta( $wp_post_id, '_sitestaffr_meta_title', $meta_title );
            }
            if ( $focus_kw ) {
                update_post_meta( $wp_post_id, '_sitestaffr_meta_keyword', $focus_kw );
            }
        }

        // Inject schema JSON-LD into post content.
        if ( ! empty( $post_data['schema_json_ld'] ) ) {
            $schema_data = $post_data['schema_json_ld'];
            if ( is_string( $schema_data ) ) {
                $decoded = json_decode( $schema_data, true );
                if ( json_last_error() === JSON_ERROR_NONE ) {
                    $schema_data = $decoded;
                }
            }

            $has_seo_schema = defined( 'WPSEO_VERSION' ) || defined( 'RANK_MATH_VERSION' );
            if ( ! $has_seo_schema && is_array( $schema_data ) ) {
                $schema_html = "\n" . '<script type="application/ld+json">' . wp_json_encode( $schema_data, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ) . '</script>';

                // The pull-delivery poller imports via cron (no user), where kses
                // strips <script> and leaves the schema as visible raw JSON in the
                // post body (sandbox draft 75, 2026-07-07). The push path imports
                // as an admin with unfiltered_html and never filtered this — so
                // bypass kses here for parity. Safe: server-generated JSON we just
                // encoded ourselves on an HMAC-authenticated import path.
                kses_remove_filters();
                wp_update_post( array(
                    'ID'           => $wp_post_id,
                    'post_content' => get_post_field( 'post_content', $wp_post_id ) . $schema_html,
                ) );
                kses_init_filters();
            }
        }

        // Handle featured image.
        if ( ! empty( $post_data['featured_image_url'] ) ) {
            $image_url  = $post_data['featured_image_url'];
            $image_alt  = isset( $post_data['featured_image_alt'] ) ? $post_data['featured_image_alt'] : '';

            // Keyword-rich filename (image SEO signal) derived from the post slug.
            $image_basename = sanitize_title( ! empty( $post_data['slug'] ) ? $post_data['slug'] : $post_data['title'] );

            if ( 0 === strpos( $image_url, 'data:image/' ) ) {
                // gpt-image models return the PNG inline as a base64 data URL. Decode
                // and attach the bytes directly — there is no remote URL to retry, so
                // no pending meta / cron fallback is needed.
                $image_result = self::attach_featured_image_from_data_url( $wp_post_id, $image_url, $image_alt, $image_basename );
                if ( is_wp_error( $image_result ) ) {
                    error_log( 'SiteStaffr Blog: Featured image decode/attach failed: ' . $image_result->get_error_message() );
                }
            } else {
                // Remote HTTP(S) URL (legacy DALL-E path). Persist the URL before the
                // download attempt so a cron retry can recover if PHP times out
                // mid-download during an AJAX request.
                update_post_meta( $wp_post_id, '_sitestaffr_pending_image_url', esc_url_raw( $image_url ) );
                update_post_meta( $wp_post_id, '_sitestaffr_pending_image_alt', sanitize_text_field( $image_alt ) );

                $image_result = self::attach_featured_image( $wp_post_id, $image_url, $image_alt, $image_basename );
                if ( is_wp_error( $image_result ) ) {
                    error_log( 'SiteStaffr Blog: Featured image sideload failed: ' . $image_result->get_error_message() );
                    // Pending meta is already stored; schedule cron to retry.
                    wp_schedule_single_event( time() + 30, 'sitestaffr_attach_blog_image', array( $wp_post_id ) );
                } else {
                    // Inline succeeded — remove the pending meta.
                    delete_post_meta( $wp_post_id, '_sitestaffr_pending_image_url' );
                    delete_post_meta( $wp_post_id, '_sitestaffr_pending_image_alt' );
                }
            }
        } elseif ( ! empty( $post_data['featured_image_prompt'] ) ) {
            // Fallback: store prompt for manual image generation later.
            update_post_meta( $wp_post_id, '_sitestaffr_image_prompt', sanitize_textarea_field( $post_data['featured_image_prompt'] ) );
        }
        if ( ! empty( $post_data['featured_image_alt'] ) ) {
            update_post_meta( $wp_post_id, '_sitestaffr_image_alt', sanitize_text_field( $post_data['featured_image_alt'] ) );
        }

        // Update the blog_posts row with the WP post ID.
        self::update_job( $job_id_row, 'complete', array(
            'wp_post_id'   => $wp_post_id,
            'completed_at' => current_time( 'mysql' ),
        ) );

        return $wp_post_id;
    }

    /**
     * Resolve the post-meta key that holds the SEO meta description for the
     * active SEO plugin (Yoast → RankMath → SiteStaffr fallback).
     *
     * Single source for the detection chain, shared by create_wp_draft() and the
     * no-login review handler so the mapping never drifts.
     *
     * @since 1.28.0
     * @return string Meta key.
     */
    public static function seo_meta_description_key() {
        if ( defined( 'WPSEO_VERSION' ) ) {
            return '_yoast_wpseo_metadesc';
        }
        if ( defined( 'RANK_MATH_VERSION' ) ) {
            return 'rank_math_description';
        }
        return '_sitestaffr_meta_description';
    }

    /**
     * Read the SEO meta description from the active SEO plugin's key.
     *
     * @since 1.28.0
     * @param int $post_id WordPress post ID.
     * @return string
     */
    public static function get_seo_meta_description( $post_id ) {
        return (string) get_post_meta( (int) $post_id, self::seo_meta_description_key(), true );
    }

    /**
     * Resolve the post-meta key that holds the SEO meta TITLE for the active SEO
     * plugin (Yoast → RankMath → SiteStaffr fallback).
     *
     * @since 1.28.0
     * @return string
     */
    public static function seo_meta_title_key() {
        if ( defined( 'WPSEO_VERSION' ) ) {
            return '_yoast_wpseo_title';
        }
        if ( defined( 'RANK_MATH_VERSION' ) ) {
            return 'rank_math_title';
        }
        return '_sitestaffr_meta_title';
    }

    /**
     * Read the SEO meta title from the active SEO plugin's key.
     *
     * @since 1.28.0
     * @param int $post_id WordPress post ID.
     * @return string
     */
    public static function get_seo_meta_title( $post_id ) {
        return (string) get_post_meta( (int) $post_id, self::seo_meta_title_key(), true );
    }

    /**
     * Write the SEO meta title to the active SEO plugin's key.
     *
     * @since 1.28.0
     * @param int    $post_id WordPress post ID.
     * @param string $title   Meta title.
     * @return void
     */
    public static function set_seo_meta_title( $post_id, $title ) {
        update_post_meta( (int) $post_id, self::seo_meta_title_key(), sanitize_text_field( $title ) );
    }

    /**
     * Write the SEO meta description to the active SEO plugin's key.
     *
     * @since 1.28.0
     * @param int    $post_id WordPress post ID.
     * @param string $desc    Meta description.
     * @return void
     */
    public static function set_seo_meta_description( $post_id, $desc ) {
        update_post_meta( (int) $post_id, self::seo_meta_description_key(), sanitize_text_field( $desc ) );
    }

    /**
     * Conservatively snap a returned taxonomy term to an existing site term.
     *
     * Reuses an existing term only when the returned term is slug/stem/alias
     * equivalent. Matching is deliberately strict so genuinely distinct terms are
     * not merged (for example, Marketing must not collapse into Market).
     *
     * @since 1.23.0
     * @param string           $term_name      Returned category/tag name.
     * @param array<int,mixed> $existing_terms Existing terms.
     * @return string Existing term name to reuse, or the original term unchanged.
     */
    public static function snapTaxonomy( $term_name, $existing_terms ) {
        $term_name = is_string( $term_name ) ? trim( $term_name ) : '';
        if ( '' === $term_name || empty( $existing_terms ) || ! is_array( $existing_terms ) ) {
            return $term_name;
        }

        $candidate_keys = self::taxonomy_match_keys( $term_name );
        if ( empty( $candidate_keys ) ) {
            return $term_name;
        }

        foreach ( $existing_terms as $existing ) {
            $existing_name = '';
            $existing_slug = '';
            if ( is_object( $existing ) ) {
                $existing_name = isset( $existing->name ) ? $existing->name : '';
                $existing_slug = isset( $existing->slug ) ? $existing->slug : '';
            } elseif ( is_array( $existing ) ) {
                $existing_name = isset( $existing['name'] ) ? $existing['name'] : '';
                $existing_slug = isset( $existing['slug'] ) ? $existing['slug'] : '';
            } else {
                $existing_name = (string) $existing;
            }

            $existing_keys = array_merge(
                self::taxonomy_match_keys( $existing_name ),
                self::taxonomy_match_keys( $existing_slug )
            );
            if ( ! empty( array_intersect( $candidate_keys, $existing_keys ) ) ) {
                return (string) $existing_name;
            }
        }

        return $term_name;
    }

    /**
     * Build conservative match keys for a taxonomy term.
     *
     * @since 1.23.0
     * @param string $name Term name or slug.
     * @return array<int,string> Normalized match keys.
     */
    private static function taxonomy_match_keys( $name ) {
        $slug = strtolower( trim( (string) $name ) );
        $slug = preg_replace( '/[^a-z0-9]+/', '-', $slug );
        $slug = trim( $slug, '-' );
        if ( '' === $slug ) {
            return array();
        }

        $keys = array( $slug );
        if ( 's' === substr( $slug, -1 ) && strlen( $slug ) >= 5 ) {
            $keys[] = substr( $slug, 0, -1 );
        }

        // Conservative stem for common "chatbot(s)" near-duplicates: ai-chatbot
        // can snap to an existing ai-chat term, but generic chatbot does not become
        // ai-chat unless that exact/stem key exists independently.
        if ( substr( $slug, -4 ) === 'bots' && strlen( $slug ) > 4 ) {
            $keys[] = substr( $slug, 0, -4 );
        } elseif ( substr( $slug, -3 ) === 'bot' && strlen( $slug ) > 3 ) {
            $keys[] = substr( $slug, 0, -3 );
        }

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

    /**
     * Download an image URL and attach as featured image.
     *
     * @param int    $post_id  WordPress post ID.
     * @param string $image_url Remote image URL.
     * @param string $alt_text  Alt text for the image.
     * @return int|WP_Error Attachment ID on success, WP_Error on failure.
     */
    public static function attach_featured_image( $post_id, $image_url, $alt_text = '', $basename = '' ) {
        self::load_media_dependencies();

        $tmp_file = download_url( $image_url, 60 );
        if ( is_wp_error( $tmp_file ) ) {
            return $tmp_file;
        }

        $filename = ( $basename ? $basename : 'blog-featured-' . $post_id ) . '.png';

        return self::sideload_tmp_file( $post_id, $tmp_file, $filename, $alt_text );
    }

    /**
     * Decode a base64 data URL and attach the bytes as the featured image.
     *
     * Used for gpt-image model output, which the middleware forwards inline as a
     * `data:image/<type>;base64,<data>` URL instead of a remote HTTP URL.
     *
     * @since 1.22.3
     * @param int    $post_id  WordPress post ID.
     * @param string $data_url data:image/<type>;base64,<data> URL.
     * @param string $alt_text Alt text for the image.
     * @return int|WP_Error Attachment ID on success, WP_Error on failure.
     */
    public static function attach_featured_image_from_data_url( $post_id, $data_url, $alt_text = '', $basename = '' ) {
        if ( ! preg_match( '#^data:image/([a-z0-9.+-]+);base64,(.+)$#is', $data_url, $matches ) ) {
            return new WP_Error( 'invalid_data_url', 'Featured image is not a valid base64 image data URL.' );
        }

        $binary = base64_decode( $matches[2], true );
        if ( false === $binary || '' === $binary ) {
            return new WP_Error( 'decode_failed', 'Failed to decode base64 featured image.' );
        }

        self::load_media_dependencies();

        $subtype  = strtolower( $matches[1] );
        $ext      = 'jpeg' === $subtype ? 'jpg' : preg_replace( '/[^a-z0-9]/', '', $subtype );
        $filename = ( $basename ? $basename : 'blog-featured-' . $post_id ) . '.' . ( $ext ? $ext : 'png' );

        $tmp_file = wp_tempnam( $filename );
        if ( ! $tmp_file ) {
            return new WP_Error( 'tmp_failed', 'Could not create temp file for featured image.' );
        }

        // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents -- writing decoded bytes to a WP temp file before sideload.
        if ( false === file_put_contents( $tmp_file, $binary ) ) {
            @unlink( $tmp_file );
            return new WP_Error( 'write_failed', 'Could not write decoded featured image to disk.' );
        }

        return self::sideload_tmp_file( $post_id, $tmp_file, $filename, $alt_text );
    }

    /**
     * Sideload a local temp file as a post's featured image.
     *
     * Shared by attach_featured_image() (remote download) and
     * attach_featured_image_from_data_url() (decoded inline bytes). Deletes the
     * temp file on failure and sets the thumbnail + alt text on success.
     *
     * @since 1.22.3
     * @param int    $post_id  WordPress post ID.
     * @param string $tmp_file Absolute path to the temp file holding the image.
     * @param string $filename Desired attachment filename (with extension).
     * @param string $alt_text Alt text for the image.
     * @return int|WP_Error Attachment ID on success, WP_Error on failure.
     */
    private static function sideload_tmp_file( $post_id, $tmp_file, $filename, $alt_text = '' ) {
        $file_array = array(
            'name'     => $filename,
            'tmp_name' => $tmp_file,
        );

        // Third arg becomes the attachment title (media-library SEO signal) —
        // the alt text reads better than a raw filename.
        $attachment_id = media_handle_sideload( $file_array, $post_id, $alt_text ? sanitize_text_field( $alt_text ) : null );
        if ( is_wp_error( $attachment_id ) ) {
            @unlink( $tmp_file );
            return $attachment_id;
        }

        set_post_thumbnail( $post_id, $attachment_id );

        if ( $alt_text ) {
            update_post_meta( $attachment_id, '_wp_attachment_image_alt', sanitize_text_field( $alt_text ) );
        }

        return $attachment_id;
    }

    /**
     * Load the WordPress admin media/file/image helpers required for sideloading.
     *
     * @since 1.22.3
     */
    private static function load_media_dependencies() {
        require_once ABSPATH . 'wp-admin/includes/media.php';
        require_once ABSPATH . 'wp-admin/includes/file.php';
        require_once ABSPATH . 'wp-admin/includes/image.php';
    }

    /**
     * WP-Cron callback: retry attaching a pending featured image.
     *
     * Scheduled by create_wp_draft() when the inline sideload fails so that a
     * transient network or timeout issue during the AJAX request is recovered
     * automatically on the next cron tick.
     *
     * @since 1.22.2
     * @param int $post_id WordPress post ID.
     */
    public static function process_pending_blog_image( $post_id ) {
        $url = get_post_meta( $post_id, '_sitestaffr_pending_image_url', true );
        $alt = get_post_meta( $post_id, '_sitestaffr_pending_image_alt', true );
        if ( ! $url ) {
            return;
        }
        $result = self::attach_featured_image( $post_id, $url, (string) $alt );
        if ( ! is_wp_error( $result ) ) {
            delete_post_meta( $post_id, '_sitestaffr_pending_image_url' );
            delete_post_meta( $post_id, '_sitestaffr_pending_image_alt' );
        } else {
            error_log( 'SiteStaffr Blog: Cron image attach failed for post ' . $post_id . ': ' . $result->get_error_message() );
        }
    }

    /**
     * Assemble the full generation payload from the site's own database.
     *
     * Single source for BOTH manual generation and the autopilot cron tick, so
     * scheduled posts get exactly the same business context, link candidates
     * (with excerpts), and taxonomy as manual ones. Nothing here is persisted
     * outside WordPress.
     *
     * @since 1.24.0
     * @param string $topic       Blog post topic.
     * @param string $keyword     Target keyword.
     * @param array  $preferences tone / word_count_target / language / image_generation.
     * @return array Args for SiteStaffr_AI_Client::generate_blog_post().
     */
    public static function build_generation_payload( $topic, $keyword, $preferences ) {
        $settings = get_option( 'sitestaffr_settings', array() );

        // Brand accent for featured-image style consistency: reuse the widget's
        // configured color so generated images echo the site's palette.
        $widget_settings = get_option( 'sitestaffr_widget_settings', array() );
        $brand_color     = '';
        if ( is_array( $widget_settings ) && ! empty( $widget_settings['fixed_background_color'] ) ) {
            $brand_color = sanitize_hex_color( $widget_settings['fixed_background_color'] );
        }

        $business_context = array(
            'name'        => isset( $settings['business_name'] ) ? $settings['business_name'] : '',
            'description' => self::get_content_business_description(),
            'brand_color' => (string) $brand_color,
        );

        $existing_content = self::get_existing_content_candidates();

        $existing_categories = array();
        $existing_tags       = array();

        $category_terms = get_terms( array( 'taxonomy' => 'category', 'hide_empty' => false, 'number' => 200 ) );
        if ( ! is_wp_error( $category_terms ) ) {
            foreach ( $category_terms as $cat_term ) {
                // Never advertise the default category as reusable — the AI takes
                // the reuse instruction literally and returns "Uncategorized"
                // verbatim (the June 12 uncategorized-post root cause).
                if ( 'uncategorized' === $cat_term->slug ) {
                    continue;
                }
                $existing_categories[] = array(
                    'name'  => $cat_term->name,
                    'slug'  => $cat_term->slug,
                    'count' => (int) $cat_term->count,
                );
            }
        }

        $tag_terms = get_terms( array( 'taxonomy' => 'post_tag', 'hide_empty' => false, 'number' => 200 ) );
        if ( ! is_wp_error( $tag_terms ) ) {
            foreach ( $tag_terms as $tag_term ) {
                $existing_tags[] = array(
                    'name'  => $tag_term->name,
                    'slug'  => $tag_term->slug,
                    'count' => (int) $tag_term->count,
                );
            }
        }

        return array(
            'topic'            => $topic,
            'keyword'          => $keyword,
            'business_context' => $business_context,
            'existing_content' => $existing_content,
            'recently_linked'  => self::get_recently_linked_urls(),
            'categories'       => $existing_categories,
            'tags'             => $existing_tags,
            'preferences'      => $preferences,
        );
    }

    /**
     * Internal URLs the most recent posts already link to, so the middleware
     * can rotate link candidates instead of reusing the same conversion pages
     * in every generated post. Drafts count: with review-by-email (or any
     * manual review) recent generations sit unpublished — rotation must see
     * their links too or consecutive drafts all pick the same pages.
     *
     * @since 1.33.0
     * @return string[] Up to 30 own-site URLs.
     */
    public static function get_recently_linked_urls() {
        $recent = get_posts( array(
            'post_status'    => array( 'publish', 'draft' ),
            'post_type'      => 'post',
            'posts_per_page' => 10,
            'orderby'        => 'date',
            'order'          => 'DESC',
        ) );

        $home_host = wp_parse_url( home_url(), PHP_URL_HOST );
        $urls      = array();
        foreach ( $recent as $post ) {
            if ( ! preg_match_all( '/href=["\']([^"\']+)["\']/i', (string) $post->post_content, $matches ) ) {
                continue;
            }
            foreach ( $matches[1] as $href ) {
                $host = wp_parse_url( $href, PHP_URL_HOST );
                // Own-site links only: relative hrefs or absolute ones on this host.
                if ( null !== $host && $host !== $home_host ) {
                    continue;
                }
                $path = wp_parse_url( $href, PHP_URL_PATH );
                if ( empty( $path ) || '/' === $path ) {
                    continue;
                }
                $urls[ untrailingslashit( strtolower( $path ) ) ] = true;
            }
        }

        return array_slice( array_keys( $urls ), 0, 30 );
    }

    /**
     * Option key for the cached site-content index.
     *
     * @since 1.29.4
     * @var string
     */
    const CONTENT_INDEX_OPTION = 'sitestaffr_blog_content_index';

    /**
     * Cron hook for the daily content-index rebuild backstop.
     *
     * @since 1.29.4
     * @var string
     */
    const CONTENT_INDEX_CRON = 'sitestaffr_blog_index_rebuild';

    /**
     * Post types that make up the site-content index.
     *
     * Every PUBLIC post type — so the planner can de-dup against (and link to)
     * custom post types like a clinic's procedures, not just core post/page.
     * The exclusion list drops WordPress noise (attachments, menu items,
     * block/template internals) that isn't real published content.
     *
     * @since 1.29.4
     * @return array<int,string> Post-type slugs.
     */
    public static function get_indexable_post_types() {
        $public_types = get_post_types( array( 'public' => true ), 'names' );

        $excluded = 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',
        );

        $types = array_values( array_diff( array_values( $public_types ), $excluded ) );

        /**
         * Filter the post types included in the Blog Agent content index.
         *
         * @since 1.29.4
         * @param array $types Post-type slugs.
         */
        return apply_filters( 'sitestaffr_blog_indexable_post_types', $types );
    }

    /**
     * Comprehensive, cached index of ALL published content on the site.
     *
     * One lightweight source of truth ({ title, type, url }, titles-only — no
     * excerpt) covering every public post type, so topic de-dup sees the whole
     * site including CPTs. Cached in a WP option; invalidated on
     * save_post/transition_post_status and rebuilt on a daily cron backstop.
     * Titles never leave the WP DB beyond the title string (content-privacy).
     *
     * @since 1.29.4
     * @return array<int,array{title:string,type:string,url:string}>
     */
    public static function get_published_content_index() {
        $cached = get_option( self::CONTENT_INDEX_OPTION, false );
        if ( is_array( $cached ) ) {
            return $cached;
        }
        return self::rebuild_content_index();
    }

    /**
     * Rebuild and cache the site-content index.
     *
     * @since 1.29.4
     * @param int $limit Max entries (newest first).
     * @return array<int,array{title:string,type:string,url:string}>
     */
    public static function rebuild_content_index( $limit = 500 ) {
        $limit = max( 1, (int) $limit );
        $posts = get_posts(
            array(
                'post_type'      => self::get_indexable_post_types(),
                'post_status'    => 'publish',
                'posts_per_page' => $limit,
                'orderby'        => 'date',
                'order'          => 'DESC',
            )
        );

        $index = array();
        foreach ( $posts as $post_obj ) {
            $title = trim( wp_strip_all_tags( get_the_title( $post_obj ) ) );
            if ( '' === $title ) {
                continue;
            }
            $index[] = array(
                'title' => $title,
                'type'  => $post_obj->post_type,
                'url'   => wp_make_link_relative( get_permalink( $post_obj ) ),
            );
        }

        update_option( self::CONTENT_INDEX_OPTION, $index, false );
        return $index;
    }

    /**
     * Invalidate the cached content index.
     *
     * Hooked to save_post / transition_post_status so a publish, edit, or
     * status change re-warms the index on the next read (and the daily cron
     * keeps it fresh as a backstop).
     *
     * @since 1.29.4
     */
    public static function clear_content_index_cache() {
        delete_option( self::CONTENT_INDEX_OPTION );
    }

    /**
     * How many recent blog posts the internal-link candidate list carries.
     *
     * @since 1.37.1
     */
    const LINK_CANDIDATE_POST_LIMIT = 50;

    /**
     * How many recent pages / CPT entries the candidate list carries, queried
     * separately from posts so a site that publishes daily cannot push its
     * marketing pages out of the window.
     *
     * @since 1.37.1
     */
    const LINK_CANDIDATE_PAGE_LIMIT = 25;

    /**
     * Build the internal-link candidate list (published content + signals).
     *
     * Posts and pages/CPTs are queried separately and merged. A single window of
     * the 50 most recent items across all types meant that on a site publishing
     * daily — which is exactly what the Blog Agent does — the evergreen marketing
     * pages aged out within weeks and stopped being linkable at all.
     *
     * @since 1.24.0
     * @return array<int,array>
     */
    public static function get_existing_content_candidates() {
        $existing_content = array();
        $indexable        = self::get_indexable_post_types();
        $page_types       = array_values( array_diff( $indexable, array( 'post' ) ) );

        $query_recent = function ( $types, $limit ) {
            if ( empty( $types ) ) {
                return array();
            }
            return get_posts(
                array(
                    'post_type'      => array_values( (array) $types ),
                    'post_status'    => 'publish',
                    'posts_per_page' => $limit,
                    'orderby'        => 'date',
                    'order'          => 'DESC',
                )
            );
        };

        $posts_pages = array();
        if ( in_array( 'post', $indexable, true ) ) {
            $posts_pages = $query_recent( array( 'post' ), self::LINK_CANDIDATE_POST_LIMIT );
        }
        foreach ( $query_recent( $page_types, self::LINK_CANDIDATE_PAGE_LIMIT ) as $page_obj ) {
            $posts_pages[] = $page_obj;
        }

        if ( empty( $posts_pages ) ) {
            return $existing_content;
        }

        $page_ids = wp_list_pluck( $posts_pages, 'ID' );

        // Prime post-meta cache for all IDs in one query so subsequent
        // get_post_meta() calls hit the cache instead of the database.
        update_postmeta_cache( $page_ids );

        // Find which of these pages are parents of published children (any
        // public type — hierarchical CPTs and pages alike).
        global $wpdb;
        $placeholders   = implode( ',', array_fill( 0, count( $page_ids ), '%d' ) );
        // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
        $raw_parents    = (array) $wpdb->get_col( $wpdb->prepare( "SELECT DISTINCT post_parent FROM {$wpdb->posts} WHERE post_status = 'publish' AND post_parent != 0 AND post_parent IN ({$placeholders})", $page_ids ) );
        $parent_ids_map = array_flip( array_map( 'intval', $raw_parents ) );

        // Pages whose content lives in a PHP template report an empty
        // post_content, so one batched lookup pulls their real text from the
        // knowledge rows the sync pipeline already wrote for them.
        $synced_excerpts = self::get_synced_excerpts_for_empty_posts( $posts_pages );

        foreach ( $posts_pages as $post_obj ) {
            $pid = (int) $post_obj->ID;

            // Check noindex via Yoast SEO and Rank Math (uses primed meta cache).
            $yoast_noindex = (int) get_post_meta( $pid, '_yoast_wpseo_meta-robots-noindex', true );
            $rm_robots     = get_post_meta( $pid, 'rank_math_robots', true );
            $is_noindex    = 1 === $yoast_noindex || ( is_array( $rm_robots ) && in_array( 'noindex', $rm_robots, true ) );

            $stripped_content = wp_strip_all_tags( $post_obj->post_content );
            $word_count       = (int) str_word_count( $stripped_content );

            // Short excerpt so the middleware can rank candidates by topical
            // relevance and the AI can judge section fit (additive payload).
            $excerpt = $post_obj->post_excerpt
                ? wp_strip_all_tags( $post_obj->post_excerpt )
                : $stripped_content;

            // A page rendered entirely by a PHP template (SiteStaffr's own
            // /salesforce/ and /blog-agent/, a theme's landing pages) stores no
            // post_content, so it reported 0 words and the middleware dropped it
            // as a sub-50-word stub — these pages could never be internal-link
            // targets. Send word_count as null instead: the middleware only
            // filters on `typeof word_count === 'number'`, so null reads as
            // "unknown" and keeps the page eligible, with no middleware change.
            //
            // A genuinely empty page — no template, nothing ever synced — is a
            // real stub and keeps reporting 0.
            if ( '' === trim( $stripped_content ) ) {
                $recovered = isset( $synced_excerpts[ $pid ] ) ? $synced_excerpts[ $pid ] : '';
                if ( '' === $recovered ) {
                    $recovered = self::get_meta_description( $pid );
                }
                if ( '' !== $recovered || self::has_rendering_template( $post_obj ) ) {
                    $word_count = null;
                    if ( '' === trim( (string) $excerpt ) ) {
                        $excerpt = $recovered;
                    }
                }
            }

            $excerpt = wp_trim_words( $excerpt, 30, '' );

            $existing_content[] = array(
                'title'        => get_the_title( $post_obj ),
                'url'          => wp_make_link_relative( get_permalink( $post_obj ) ),
                'type'         => $post_obj->post_type,
                'noindex'      => $is_noindex,
                'has_children' => isset( $parent_ids_map[ $pid ] ),
                'word_count'   => $word_count,
                'excerpt'      => $excerpt,
            );
        }

        return $existing_content;
    }

    /**
     * First synced knowledge chunk for every candidate whose post_content is empty.
     *
     * The sync pipeline already solves template-rendered pages — it fetches the
     * permalink and extracts the rendered text into `chunk_text`. Reading that
     * back is one indexed query, so the candidate payload (rebuilt on every
     * generation) gains a real excerpt without a single HTTP fetch.
     *
     * @since 1.37.1
     * @param array<int,WP_Post> $posts Candidate posts.
     * @return array<int,string> post_id => excerpt text.
     */
    private static function get_synced_excerpts_for_empty_posts( $posts ) {
        $empty_ids = array();
        foreach ( $posts as $post_obj ) {
            if ( '' === trim( wp_strip_all_tags( $post_obj->post_content ) ) ) {
                $empty_ids[] = (int) $post_obj->ID;
            }
        }

        $empty_ids = array_values( array_unique( array_filter( $empty_ids ) ) );
        if ( empty( $empty_ids ) || ! class_exists( 'SiteStaffr_Database' ) ) {
            return array();
        }

        global $wpdb;
        $table        = SiteStaffr_Database::get_knowledge_table();
        $placeholders = implode( ',', array_fill( 0, count( $empty_ids ), '%d' ) );

        // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
        $rows = $wpdb->get_results(
            $wpdb->prepare(
                "SELECT post_id, chunk_text FROM %i WHERE post_id IN ({$placeholders}) AND chunk_text IS NOT NULL ORDER BY post_id ASC, chunk_index ASC",
                array_merge( array( $table ), $empty_ids )
            )
        );

        $excerpts = array();
        foreach ( (array) $rows as $row ) {
            $pid = (int) $row->post_id;
            if ( isset( $excerpts[ $pid ] ) ) {
                continue; // First chunk only — ordered by chunk_index.
            }
            $text = trim( wp_strip_all_tags( (string) $row->chunk_text ) );
            if ( '' !== $text ) {
                $excerpts[ $pid ] = $text;
            }
        }

        return $excerpts;
    }

    /**
     * Yoast / Rank Math meta description for a post, when there is one.
     *
     * Last-resort excerpt for a template-rendered page that has not been synced
     * to the knowledge base yet — an owner-written summary of the page is still
     * far better ranking material than an empty string.
     *
     * @since 1.37.1
     * @param int $post_id Post ID.
     * @return string
     */
    private static function get_meta_description( $post_id ) {
        foreach ( array( '_yoast_wpseo_metadesc', 'rank_math_description' ) as $meta_key ) {
            $value = get_post_meta( $post_id, $meta_key, true );
            if ( is_string( $value ) && '' !== trim( $value ) ) {
                return trim( wp_strip_all_tags( $value ) );
            }
        }
        return '';
    }

    /**
     * Whether a post's content is rendered by a PHP template rather than stored
     * in post_content.
     *
     * Mirrors the check the knowledge sync already makes when it decides an
     * empty-post_content page is worth fetching: an explicitly assigned page
     * template, or an automatic `page-{slug}.php` from the template hierarchy.
     *
     * @since 1.37.1
     * @param WP_Post $post_obj Post object.
     * @return bool
     */
    private static function has_rendering_template( $post_obj ) {
        $template_slug = get_page_template_slug( $post_obj->ID );
        if ( ! empty( $template_slug ) && 'default' !== $template_slug ) {
            return true;
        }

        if ( 'page' === $post_obj->post_type && ! empty( $post_obj->post_name ) ) {
            if ( file_exists( get_stylesheet_directory() . '/page-' . $post_obj->post_name . '.php' ) ) {
                return true;
            }
        }

        return false;
    }

    /**
     * Lightweight list of ALL published titles across the site, for topic de-dup.
     *
     * Derived from the comprehensive content index, so it spans EVERY public post
     * type — including custom post types (e.g. a clinic's procedures), which the
     * old post+page-only query missed. get_existing_content_candidates() is capped
     * at the most recent 50 posts + 25 pages (it carries excerpts for internal-link
     * ranking), so on established sites older content falls outside its window and
     * the planner could re-suggest a topic the site already covers. Titles only;
     * no content leaves the site beyond the title string (content-privacy).
     *
     * @since 1.29.4
     * @param int $limit Max titles to return.
     * @return array<int,string>
     */
    public static function get_all_published_titles( $limit = 200 ) {
        $limit = max( 1, (int) $limit );
        $clean = array();
        foreach ( self::get_published_content_index() as $entry ) {
            $title = isset( $entry['title'] ) ? trim( (string) $entry['title'] ) : '';
            if ( '' !== $title ) {
                $clean[] = $title;
            }
        }

        $clean = array_values( array_unique( $clean ) );
        return array_slice( $clean, 0, $limit );
    }

    /**
     * Get the local topic queue (all statuses, newest last).
     *
     * The queue lives entirely in the site's own database — topics are
     * AI-suggested but stored locally so SiteStaffr infrastructure holds no
     * per-site content.
     *
     * @since 1.24.0
     * @return array<int,array>
     */
    public static function get_topic_queue() {
        $blog_settings = self::get_settings();
        $queue         = isset( $blog_settings['topic_queue'] ) && is_array( $blog_settings['topic_queue'] )
            ? $blog_settings['topic_queue']
            : array();
        return array_values( $queue );
    }

    /**
     * Persist the topic queue. Actionable and reserve rows are never trimmed
     * (they ARE the reservoir); consumed history (used/skipped/superseded) is
     * capped at the newest 40 so previous-topic dedup context stays bounded.
     *
     * @since 1.24.0
     * @param array $queue Topic entries.
     */
    private static function save_topic_queue( $queue ) {
        $queue   = array_values( $queue );
        $live    = array();
        $history = array();
        foreach ( $queue as $topic ) {
            $status = isset( $topic['status'] ) ? $topic['status'] : '';
            if ( in_array( $status, array( 'queued', 'approved', 'reserve' ), true ) ) {
                $live[] = $topic;
            } else {
                $history[] = $topic;
            }
        }
        if ( count( $history ) > 40 ) {
            $history = array_slice( $history, -40 );
        }
        $blog_settings                = self::get_settings();
        $blog_settings['topic_queue'] = array_merge( $history, $live );
        self::save_settings( $blog_settings );
    }

    /**
     * Visible slate size — 8 for every plan (display only; the post budget is
     * what actually gates generation). Reservoir rows sit behind it as
     * status 'reserve' and are promoted instantly on Skip/Generate.
     *
     * @since 1.32.0
     */
    const VISIBLE_SLATE = 8;

    /**
     * Count queue rows by status group.
     *
     * @since 1.32.0
     * @return array { actionable: int, reserve: int }
     */
    public static function count_topic_pool() {
        $counts = array(
            'actionable' => 0,
            'reserve'    => 0,
        );
        foreach ( self::get_topic_queue() as $topic ) {
            $status = isset( $topic['status'] ) ? $topic['status'] : '';
            if ( in_array( $status, array( 'queued', 'approved' ), true ) ) {
                $counts['actionable']++;
            } elseif ( 'reserve' === $status ) {
                $counts['reserve']++;
            }
        }
        return $counts;
    }

    /**
     * Promote reserve topics into the visible slate — the instant, free path
     * that replaced the per-skip middleware call. Each candidate passes
     * draw-time dedup against the site's CURRENT published titles (content
     * published after the batch was planned must not be re-covered); colliding
     * rows are marked superseded and the next reserve is tried.
     *
     * @since 1.32.0
     * @param int $count How many to promote.
     * @return int Topics actually promoted.
     */
    public static function promote_from_reserve( $count = 1 ) {
        $count = max( 1, (int) $count );
        $queue = self::get_topic_queue();

        $published = array();
        foreach ( self::get_all_published_titles( 300 ) as $title ) {
            $published[ strtolower( trim( (string) $title ) ) ] = true;
        }

        // Promoted topics are MOVED to the end of the queue so replacements
        // always render at the bottom of the Upcoming Topics list (the list
        // displays in storage order) — a new topic never jumps ahead of topics
        // the owner has already seen.
        $promoted_rows = array();
        $promoted      = 0;
        $changed       = false;
        foreach ( $queue as $i => $topic ) {
            if ( $promoted >= $count ) {
                break;
            }
            if ( ! isset( $topic['status'] ) || 'reserve' !== $topic['status'] ) {
                continue;
            }
            $title_key = strtolower( trim( isset( $topic['topic'] ) ? (string) $topic['topic'] : '' ) );
            if ( '' === $title_key || isset( $published[ $title_key ] ) ) {
                $queue[ $i ]['status'] = 'superseded';
                $changed               = true;
                continue;
            }
            $topic['status'] = 'queued';
            $promoted_rows[] = $topic;
            unset( $queue[ $i ] );
            $promoted++;
            $changed = true;
        }

        if ( $changed ) {
            self::save_topic_queue( array_merge( array_values( $queue ), $promoted_rows ) );
        }
        return $promoted;
    }

    /**
     * Update one topic's status in the local queue.
     *
     * @since 1.24.0
     * @param string $topic_id Topic UUID.
     * @param string $status   queued|approved|skipped|used.
     * @return bool Whether the topic was found.
     */
    public static function update_topic_status( $topic_id, $status ) {
        $queue = self::get_topic_queue();
        $found = false;
        foreach ( $queue as &$topic ) {
            if ( isset( $topic['id'] ) && $topic['id'] === $topic_id ) {
                $topic['status'] = $status;
                if ( 'used' === $status ) {
                    $topic['used_at'] = current_time( 'mysql' );
                }
                $found = true;
                break;
            }
        }
        unset( $topic );
        if ( $found ) {
            self::save_topic_queue( $queue );
        }
        return $found;
    }

    /**
     * Edit one topic's title and/or keyword in the local queue.
     *
     * Pure local mutation — never calls the middleware, so it costs no topic
     * credit. Status is preserved, keeping the entry actionable. Lets owners
     * fix wording (title length, keyword phrasing) instead of skipping and
     * burning a credit on a replacement.
     *
     * @since 1.25.0
     * @param string $topic_id Topic UUID.
     * @param string $topic    New title.
     * @param string $keyword  New target keyword.
     * @return bool Whether the topic was found.
     */
    public static function update_topic( $topic_id, $topic, $keyword ) {
        $queue = self::get_topic_queue();
        $found = false;
        foreach ( $queue as &$entry ) {
            if ( isset( $entry['id'] ) && $entry['id'] === $topic_id ) {
                $entry['topic']   = $topic;
                $entry['keyword'] = $keyword;
                $found            = true;
                break;
            }
        }
        unset( $entry );
        if ( $found ) {
            self::save_topic_queue( $queue );
        }
        return $found;
    }

    /**
     * Pop the oldest actionable topic, marking it used.
     *
     * Accepts legacy 'queued' entries (from the removed manual-approval mode)
     * as actionable — Skip is the rejection mechanism.
     *
     * @since 1.24.0
     * @return array|null Topic entry or null when none actionable.
     */
    public static function pop_next_approved_topic() {
        $queue = self::get_topic_queue();
        foreach ( $queue as &$topic ) {
            if ( isset( $topic['status'] ) && in_array( $topic['status'], array( 'approved', 'queued' ), true ) ) {
                $topic['status']  = 'used';
                $topic['used_at'] = current_time( 'mysql' );
                $popped           = $topic;
                unset( $topic );
                self::save_topic_queue( $queue );
                return $popped;
            }
        }
        unset( $topic );
        return null;
    }

    /**
     * Keep the upcoming-topics queue topped up to the plan's slate size.
     *
     * Mario's model (2026-06-24): show a slate of plan_post_budget topics, where
     * each Skip or post-generation replaces exactly one (the snappy path is
     * replace_consumed_topic). This is the safety net: it fetches only the
     * shortfall, so steady-state it stays one-for-one, but it ALSO self-heals if the
     * queue ever falls below the slate (a failed replacement, an earlier race) —
     * which a one-time seed could not (it would leave the list stuck short for the
     * rest of the cycle). The plan's topic budget (3x posts) is the hard cap; the
     * middleware clamps each fetch to the remaining budget and we stop at the cap.
     *
     * @since 1.24.0
     * @return bool Whether new topics were added.
     */
    public static function replenish_topics() {
        self::maybe_reset_budgets();

        // Instant path first: fill the visible slate from the reserve.
        $pool      = self::count_topic_pool();
        $shortfall = self::VISIBLE_SLATE - $pool['actionable'];
        if ( $shortfall > 0 && $pool['reserve'] > 0 ) {
            self::promote_from_reserve( $shortfall );
            $pool = self::count_topic_pool();
        }

        // Reservoir low → plan a fresh batch in the background (a batch run
        // takes ~30-60s; page loads and skips must never wait on it).
        if ( $pool['actionable'] + $pool['reserve'] < self::VISIBLE_SLATE ) {
            self::schedule_reservoir_refill();
        }

        return $pool['actionable'] >= self::VISIBLE_SLATE;
    }

    /**
     * Replace a consumed topic instantly from the reserve — free, no API call.
     * A topic is "consumed" when it's skipped OR used to generate a post.
     * Tops up the whole visible slate (self-healing) and schedules a background
     * batch replan when the reservoir is running low.
     *
     * @since 1.29.1
     * @return int Topics promoted.
     */
    public static function replace_consumed_topic() {
        $pool      = self::count_topic_pool();
        $promoted  = 0;
        $shortfall = self::VISIBLE_SLATE - $pool['actionable'];
        if ( $shortfall > 0 ) {
            $promoted = self::promote_from_reserve( $shortfall );
        }
        $pool = self::count_topic_pool();
        if ( $pool['actionable'] + $pool['reserve'] < self::VISIBLE_SLATE ) {
            self::schedule_reservoir_refill();
        }
        return $promoted;
    }

    /**
     * Queue a background reservoir refill (single cron event, deduped).
     *
     * @since 1.32.0
     */
    public static function schedule_reservoir_refill() {
        if ( ! wp_next_scheduled( 'sitestaffr_blog_reservoir_refill' ) ) {
            wp_schedule_single_event( time() + 10, 'sitestaffr_blog_reservoir_refill' );
        }
    }

    /**
     * Fetch a strategically planned reservoir batch (up to 40 topics with
     * pillar quotas + set-shape validation, planned server-side in ONE session)
     * and store it: the visible slate fills first, the rest lands as reserve.
     * Old un-promoted reserve rows are superseded — a fresh plan replaces a
     * stale one, it doesn't stack on top.
     *
     * Topic credits are retired: the middleware rate-limits batch replans
     * (~20h apart, waived when the reservoir is empty) instead of metering
     * per-topic draws.
     *
     * @since 1.32.0
     * @return int Topics actually added.
     */
    public static function fetch_reservoir_batch() {
        $blog_settings = self::get_settings();
        $queue         = self::get_topic_queue();
        $pool          = self::count_topic_pool();

        $args                    = self::build_topic_request_args( $blog_settings, $queue );
        $args['count']           = 40;
        $args['batch']           = true;
        // reservoir_low waives the server's 20h replan limit (30min floor still
        // applies) whenever the pool can't fill the visible slate — a draining
        // queue must never sit starved for hours (live 2026-07-08).
        $args['reservoir_low']   = ( $pool['actionable'] + $pool['reserve'] < self::VISIBLE_SLATE );
        $args['reservoir_empty'] = ( 0 === $pool['actionable'] + $pool['reserve'] );

        $ai_client = new SiteStaffr_AI_Client();
        $topics    = $ai_client->suggest_blog_topics( $args );
        if ( is_wp_error( $topics ) || empty( $topics ) ) {
            return 0;
        }

        // The new plan supersedes any stale reserve rows (visible topics stay).
        foreach ( $queue as &$stale ) {
            if ( isset( $stale['status'] ) && 'reserve' === $stale['status'] ) {
                $stale['status'] = 'superseded';
            }
        }
        unset( $stale );

        $slate_room = max( 0, self::VISIBLE_SLATE - $pool['actionable'] );

        $added = 0;
        foreach ( $topics as $suggestion ) {
            if ( empty( $suggestion['topic'] ) || empty( $suggestion['keyword'] ) ) {
                continue;
            }
            $queue[] = array(
                'id'         => wp_generate_uuid4(),
                'topic'      => sanitize_text_field( $suggestion['topic'] ),
                'keyword'    => sanitize_text_field( $suggestion['keyword'] ),
                'rationale'  => isset( $suggestion['rationale'] ) ? sanitize_text_field( $suggestion['rationale'] ) : '',
                'pillar'     => isset( $suggestion['pillar'] ) ? sanitize_text_field( $suggestion['pillar'] ) : '',
                'shape'      => isset( $suggestion['shape'] ) ? sanitize_text_field( $suggestion['shape'] ) : '',
                'status'     => $added < $slate_room ? 'approved' : 'reserve',
                'created_at' => current_time( 'mysql' ),
                'used_at'    => null,
            );
            $added++;
        }
        if ( $added > 0 ) {
            self::save_topic_queue( $queue );
        }

        return $added;
    }

    /**
     * The business description CONTENT features (blog topics, writing) should use:
     * the rich Business Profile when present, falling back to the lean identity.
     * The lean identity stays reserved for the live receptionist prompt.
     *
     * @since 1.29.0
     * @return string
     */
    private static function get_content_business_description() {
        $settings = get_option( 'sitestaffr_settings', array() );
        if ( ! is_array( $settings ) ) {
            $settings = array();
        }
        // Storage is split: business_context = SUMMARY, business_profile = BODY only. Recombine the
        // full profile for content features. (Defensively strip a legacy SUMMARY from the body.)
        $summary = isset( $settings['business_context'] ) ? trim( (string) $settings['business_context'] ) : '';
        $body    = isset( $settings['business_profile'] ) ? trim( (string) $settings['business_profile'] ) : '';
        if ( '' !== $body && class_exists( 'SiteStaffr_Admin' ) ) {
            $body = SiteStaffr_Admin::strip_summary_section( $body );
        }
        if ( '' !== $summary && '' !== $body ) {
            return "SUMMARY\n\n" . $summary . "\n\n" . $body;
        }
        return '' !== $body ? $body : $summary;
    }

    /**
     * Shared request context for the middleware's stateless topic suggester.
     *
     * @since 1.24.0
     * @param array $blog_settings Blog settings (tone, language).
     * @param array $queue         Topic queue entries used as previous topics.
     * @return array { business_context, existing_content, previous_topics, preferences }.
     */
    private static function build_topic_request_args( $blog_settings, $queue ) {
        $settings = get_option( 'sitestaffr_settings', array() );

        $existing = array();
        foreach ( self::get_existing_content_candidates() as $item ) {
            $existing[] = array( 'title' => $item['title'], 'url' => $item['url'] );
        }

        $previous = array();
        foreach ( $queue as $topic ) {
            // SUPERSEDED rows are excluded: the owner never saw them (they were
            // internal replacement casualties), so blocking new suggestions on
            // them starves every subsequent batch — live 2026-07-08, keep-rates
            // collapsed 15→2 as superseded rows piled into the dedup context.
            // Everything the owner actually saw (visible, skipped, used) and
            // the live reserve still dedup normally.
            if ( isset( $topic['status'] ) && 'superseded' === $topic['status'] ) {
                continue;
            }
            $previous[] = array(
                'topic'   => isset( $topic['topic'] ) ? $topic['topic'] : '',
                'keyword' => isset( $topic['keyword'] ) ? $topic['keyword'] : '',
            );
        }
        // The middleware rejects previous_topics above 50 entries; with the
        // reservoir the full queue (reserve + history) can exceed that — send
        // the newest 50, which covers everything still relevant for dedup.
        if ( count( $previous ) > 50 ) {
            $previous = array_slice( $previous, -50 );
        }

        return array(
            'business_context' => array(
                'name'        => isset( $settings['business_name'] ) ? $settings['business_name'] : '',
                'description' => self::get_content_business_description(),
            ),
            'existing_content' => $existing,
            'existing_titles'  => self::get_all_published_titles( 200 ),
            'previous_topics'  => $previous,
            'preferences'      => array(
                'tone'     => isset( $blog_settings['tone'] ) ? $blog_settings['tone'] : 'professional',
                'language' => isset( $blog_settings['language'] ) ? $blog_settings['language'] : 'en',
            ),
        );
    }

    /**
     * One on-demand topic suggestion for the New Post form (free).
     *
     * Draws instantly from the reservoir when a reserve topic is available
     * (consuming it), falling back to a single middleware suggestion for
     * re-rolls or an empty reservoir. Never touches the visible slate.
     *
     * @since 1.24.0
     * @param array $extra_previous Topics already shown in the form, so a
     *                              re-roll doesn't repeat them.
     * @return array|WP_Error { topic, keyword, rationale } or error.
     */
    public static function suggest_single_topic( $extra_previous = array() ) {
        self::maybe_reset_budgets();
        $blog_settings = self::get_settings();

        // Instant path: hand out the next reserve topic (skipping any the
        // caller has already seen this session).
        $seen = array();
        foreach ( (array) $extra_previous as $prev ) {
            if ( ! empty( $prev['topic'] ) ) {
                $seen[ strtolower( trim( (string) $prev['topic'] ) ) ] = true;
            }
        }
        $queue = self::get_topic_queue();
        foreach ( $queue as &$topic ) {
            if ( isset( $topic['status'] ) && 'reserve' === $topic['status']
                && ! isset( $seen[ strtolower( trim( (string) $topic['topic'] ) ) ] ) ) {
                $topic['status']  = 'used';
                $topic['used_at'] = current_time( 'mysql' );
                $drawn            = $topic;
                unset( $topic );
                self::save_topic_queue( $queue );
                return array(
                    'topic'     => sanitize_text_field( $drawn['topic'] ),
                    'keyword'   => isset( $drawn['keyword'] ) ? sanitize_text_field( $drawn['keyword'] ) : '',
                    'rationale' => isset( $drawn['rationale'] ) ? sanitize_text_field( $drawn['rationale'] ) : '',
                );
            }
        }
        unset( $topic );

        $args          = self::build_topic_request_args( $blog_settings, self::get_topic_queue() );
        $args['count'] = 1;
        if ( ! empty( $extra_previous ) ) {
            $args['previous_topics'] = array_merge( $args['previous_topics'], $extra_previous );
        }

        $ai_client = new SiteStaffr_AI_Client();
        $topics    = $ai_client->suggest_blog_topics( $args );
        if ( is_wp_error( $topics ) ) {
            return $topics;
        }
        if ( empty( $topics[0]['topic'] ) ) {
            return new WP_Error( 'topic_suggest_empty', __( 'The AI did not return a topic. Please try again.', 'sitestaffr' ) );
        }

        // topic_budget_used is written by sync_topic_budget_from_middleware() from
        // the suggest response (server-authoritative) — no local increment here.

        return array(
            'topic'     => sanitize_text_field( $topics[0]['topic'] ),
            'keyword'   => isset( $topics[0]['keyword'] ) ? sanitize_text_field( $topics[0]['keyword'] ) : '',
            'rationale' => isset( $topics[0]['rationale'] ) ? sanitize_text_field( $topics[0]['rationale'] ) : '',
        );
    }

    /**
     * Sync the post counter and billing-cycle reset date from a middleware
     * budget snapshot.
     *
     * The middleware is the single source of truth for the blog post count: it
     * enforces the effective limit (plan + manual grant) via countPostsSince and
     * returns the authoritative `used` on every blog response. This is the ONLY
     * writer of `post_budget_used` — the post is no longer metered locally in
     * ajax_blog_receive_post(), so there is no double-count. `resets_at`
     * replaces the cold-start "first of next month" fallback with the customer's
     * real billing renewal. This counter is display only; the cap is enforced
     * server-side regardless.
     *
     * @since 1.24.1
     * @param array $budget { limit, used, resets_at } from a blog API response.
     */
    public static function sync_budget_from_middleware( $budget ) {
        if ( ! is_array( $budget ) ) {
            return;
        }

        $blog_settings = self::get_settings();
        $dirty         = false;

        if ( isset( $budget['used'] ) && is_numeric( $budget['used'] ) ) {
            $used = max( 0, (int) $budget['used'] );
            if ( $used !== (int) $blog_settings['post_budget_used'] ) {
                $blog_settings['post_budget_used'] = $used;
                $dirty                             = true;
            }
        }

        // Effective limit (plan + manual grant) from the middleware. Stored so
        // get_settings() can surface the granted total in the display AND the
        // soft pre-check gate, instead of the bare plan limit.
        if ( isset( $budget['limit'] ) && is_numeric( $budget['limit'] ) ) {
            $synced_limit = max( 0, (int) $budget['limit'] );
            if ( $synced_limit !== (int) $blog_settings['post_budget_synced'] ) {
                $blog_settings['post_budget_synced'] = $synced_limit;
                $dirty                               = true;
            }
        }

        if ( isset( $budget['resets_at'] ) && is_string( $budget['resets_at'] ) ) {
            $resets_ts = strtotime( $budget['resets_at'] );
            if ( false !== $resets_ts && $resets_ts > time() ) {
                $resets_iso = gmdate( 'Y-m-d\TH:i:s\Z', $resets_ts );
                if ( $resets_iso !== $blog_settings['budget_reset_date'] ) {
                    $blog_settings['budget_reset_date'] = $resets_iso;
                    $dirty                              = true;
                }
            }
        }

        if ( $dirty ) {
            self::save_settings( $blog_settings );
        }
    }

    /**
     * Sync the topic counter + effective limit from a middleware budget snapshot.
     *
     * Mirrors sync_budget_from_middleware for topics (server-authoritative since
     * the topic-metering migration). The ONLY writer of topic_budget_used. The
     * middleware sends `topics_budget` on each /topics/suggest response:
     *   - null  → the site is exempt: unlimited topics (no cap, no block).
     *   - array → { limit, used, resets_at } metered values.
     *
     * @since 1.29.0
     * @param array|null $topics The `topics_budget` value from the response.
     */
    public static function sync_topic_budget_from_middleware( $topics ) {
        $blog_settings = self::get_settings();
        $dirty         = false;

        if ( null === $topics ) {
            // Exempt site → unlimited.
            if ( empty( $blog_settings['topic_unlimited'] ) ) {
                $blog_settings['topic_unlimited'] = true;
                $dirty                            = true;
            }
        } elseif ( is_array( $topics ) ) {
            if ( ! empty( $blog_settings['topic_unlimited'] ) ) {
                $blog_settings['topic_unlimited'] = false;
                $dirty                            = true;
            }
            if ( isset( $topics['used'] ) && is_numeric( $topics['used'] ) ) {
                $used = max( 0, (int) $topics['used'] );
                if ( $used !== (int) $blog_settings['topic_budget_used'] ) {
                    $blog_settings['topic_budget_used'] = $used;
                    $dirty                              = true;
                }
            }
            if ( isset( $topics['limit'] ) && is_numeric( $topics['limit'] ) ) {
                $limit = max( 0, (int) $topics['limit'] );
                if ( $limit !== (int) $blog_settings['topic_budget_synced'] ) {
                    $blog_settings['topic_budget_synced'] = $limit;
                    $dirty                                = true;
                }
            }
            if ( isset( $topics['resets_at'] ) && is_string( $topics['resets_at'] ) ) {
                $resets_ts = strtotime( $topics['resets_at'] );
                if ( false !== $resets_ts && $resets_ts > time() ) {
                    $resets_iso = gmdate( 'Y-m-d\TH:i:s\Z', $resets_ts );
                    if ( $resets_iso !== $blog_settings['budget_reset_date'] ) {
                        $blog_settings['budget_reset_date'] = $resets_iso;
                        $dirty                              = true;
                    }
                }
            }
        } else {
            return;
        }

        if ( $dirty ) {
            self::save_settings( $blog_settings );
        }
    }

    /**
     * Reset monthly budgets when the stored reset date has passed.
     *
     * The reset date is the customer's billing renewal once middleware has
     * supplied one (see sync_budget_from_middleware); "first day of next month"
     * is only the cold-start fallback before any response has been seen.
     *
     * @since 1.24.0
     */
    public static function maybe_reset_budgets() {
        $blog_settings = self::get_settings();
        $reset_date    = isset( $blog_settings['budget_reset_date'] ) ? $blog_settings['budget_reset_date'] : '';

        if ( '' === $reset_date || strtotime( $reset_date ) <= time() ) {
            $blog_settings['post_budget_used']  = ( '' === $reset_date ) ? (int) ( isset( $blog_settings['post_budget_used'] ) ? $blog_settings['post_budget_used'] : 0 ) : 0;
            $blog_settings['topic_budget_used'] = ( '' === $reset_date ) ? (int) ( isset( $blog_settings['topic_budget_used'] ) ? $blog_settings['topic_budget_used'] : 0 ) : 0;
            $blog_settings['budget_reset_date'] = gmdate( 'Y-m-d\TH:i:s\Z', strtotime( 'first day of next month midnight' ) );
            self::save_settings( $blog_settings );
        }
    }

    /**
     * Compute the next autopilot due timestamp (UTC) from the saved schedule.
     *
     * Schedule days/time are interpreted in America/New_York per product rule.
     *
     * @since 1.24.0
     * @param array    $blog_settings Blog settings (schedule_days, schedule_time).
     * @param int|null $after_ts      Find the first slot strictly after this UTC timestamp (default now).
     * @return int|null UTC timestamp or null when no valid schedule.
     */
    public static function get_next_due_timestamp( $blog_settings, $after_ts = null ) {
        $days = isset( $blog_settings['schedule_days'] ) && is_array( $blog_settings['schedule_days'] ) && ! empty( $blog_settings['schedule_days'] )
            ? $blog_settings['schedule_days']
            : array( 'tuesday', 'thursday' );
        $time = isset( $blog_settings['schedule_time'] ) && preg_match( '/^\d{2}:\d{2}$/', (string) $blog_settings['schedule_time'] )
            ? $blog_settings['schedule_time']
            : '09:00';

        $after_ts = null === $after_ts ? time() : (int) $after_ts;

        try {
            $tz = new DateTimeZone( 'America/New_York' );
        } catch ( Exception $e ) {
            return null;
        }

        $valid_days = array( 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday' );
        $days       = array_values( array_intersect( array_map( 'strtolower', $days ), $valid_days ) );
        if ( empty( $days ) ) {
            $days = array( 'tuesday', 'thursday' );
        }

        $cursor = new DateTimeImmutable( '@' . $after_ts );
        $cursor = $cursor->setTimezone( $tz );

        for ( $offset = 0; $offset <= 7; $offset++ ) {
            $candidate = $cursor->add( new DateInterval( 'P' . $offset . 'D' ) );
            $candidate = $candidate->setTime( (int) substr( $time, 0, 2 ), (int) substr( $time, 3, 2 ), 0 );
            if ( $candidate->getTimestamp() <= $after_ts ) {
                continue;
            }
            if ( in_array( strtolower( $candidate->format( 'l' ) ), $days, true ) ) {
                return $candidate->getTimestamp();
            }
        }

        return null;
    }

    /**
     * (Re)schedule the autopilot cron event to the next due slot.
     *
     * @since 1.24.0
     */
    public static function reschedule_autopilot() {
        wp_clear_scheduled_hook( 'sitestaffr_blog_autopilot_tick' );

        $blog_settings = self::get_settings();
        if ( empty( $blog_settings['autopilot_enabled'] ) || ! self::is_autopilot_allowed() ) {
            return;
        }

        $next = self::get_next_due_timestamp( $blog_settings );
        if ( $next ) {
            wp_schedule_single_event( $next, 'sitestaffr_blog_autopilot_tick' );
        }
    }

    /**
     * Autopilot cron tick: generate the next scheduled post.
     *
     * Owns the whole scheduled flow locally: budget reset/check, topic queue
     * replenish + pop, payload assembly, and the generation request. The
     * finished post arrives via the existing signed webhook, which publishes
     * or drafts based on this job's locally stored autopilot flag.
     *
     * @since 1.24.0
     */
    /**
     * Pull-delivery poller: import any posts whose middleware push could not
     * land (host bot-protection). Runs every 5 minutes via WP-Cron; a silent
     * no-op when nothing is parked or the site token is missing (fail-closed
     * until the middleware endpoint exists). ACK deletes the parked payload
     * middleware-side.
     *
     * @since 1.32.0
     */
    public static function pickup_poll() {
        // Re-entrancy guard, same pattern as the autopilot tick.
        if ( get_transient( 'sitestaffr_pickup_poll_lock' ) ) {
            return;
        }
        set_transient( 'sitestaffr_pickup_poll_lock', 1, 4 * MINUTE_IN_SECONDS );

        try {
            $client  = new SiteStaffr_AI_Client();
            $pending = $client->get_pending_deliveries();
            if ( is_wp_error( $pending ) || empty( $pending ) ) {
                return;
            }

            foreach ( $pending as $job ) {
                if ( empty( $job['payload'] ) || ! is_array( $job['payload'] ) || empty( $job['job_id'] ) ) {
                    continue;
                }

                $result = self::import_delivered_post( $job['payload'] );
                if ( is_wp_error( $result ) ) {
                    SiteStaffr_Logger::legacy( 'SiteStaffr Blog: pickup import failed for ' . sanitize_text_field( $job['job_id'] ) . ' — ' . $result->get_error_message() );
                    continue;
                }

                $client->ack_delivery( sanitize_text_field( $job['job_id'] ) );
            }
        } finally {
            delete_transient( 'sitestaffr_pickup_poll_lock' );
        }
    }

    /**
     * Fail job rows stuck 'generating' past the timeout.
     *
     * The middleware pipeline runs as a background promise in its serving
     * instance; a Cloud Run deploy (or crash) mid-generation kills it silently
     * and the row would sit 'generating' forever (live case 2026-07-09). Runs
     * on the 5-minute pickup cron. Safe with late deliveries: the import path
     * finds the row by job_id and completes it, so a payload that survives
     * after all still self-heals a swept row.
     *
     * @since 1.33.0
     * @return int Rows marked failed.
     */
    public static function sweep_stuck_jobs() {
        global $wpdb;

        $cutoff = gmdate( 'Y-m-d H:i:s', strtotime( current_time( 'mysql' ) ) - 30 * MINUTE_IN_SECONDS );

        // Read the doomed rows BEFORE failing them: an autopilot row's topic was
        // popped off the queue when generation started, and a row that never
        // produced a post must give that topic back. A row stuck this long is
        // an interruption by definition — the documented cause is a middleware
        // restart killing the background pipeline.
        $stuck = $wpdb->get_results(
            $wpdb->prepare(
                "SELECT * FROM {$wpdb->prefix}sitestaffr_blog_posts WHERE status = 'generating' AND created_at < %s",
                $cutoff
            )
        );

        $swept = $wpdb->query(
            $wpdb->prepare(
                "UPDATE {$wpdb->prefix}sitestaffr_blog_posts SET status = 'failed' WHERE status = 'generating' AND created_at < %s",
                $cutoff
            )
        );

        if ( $swept ) {
            SiteStaffr_Logger::legacy( 'SiteStaffr Blog: stuck-job sweep failed ' . (int) $swept . ' generation(s) older than 30 minutes.' );
        }

        if ( is_array( $stuck ) ) {
            foreach ( $stuck as $row ) {
                self::restore_topic_for_job( $row );
            }
        }

        return (int) $swept;
    }

    /**
     * Recover autopilot generations the middleware reports as interrupted.
     *
     * The 30-minute sweep above is the backstop. This is the fast path: since
     * the middleware grew a SIGTERM handler it marks in-flight jobs
     * failed + retryable within seconds of a deploy, so a row can be recovered
     * on the next 5-minute tick instead of half an hour later.
     *
     * Only `retryable` failures give the topic back. A genuine generation
     * failure (a topic the pipeline cannot write) must NOT return to the queue,
     * or autopilot would retry it forever.
     *
     * @since 1.40.1
     * @return int Rows recovered.
     */
    public static function recover_interrupted_jobs() {
        global $wpdb;

        $generating = $wpdb->get_results(
            "SELECT * FROM {$wpdb->prefix}sitestaffr_blog_posts WHERE status = 'generating' LIMIT 20"
        );

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

        $client    = new SiteStaffr_AI_Client();
        $recovered = 0;

        foreach ( $generating as $row ) {
            if ( empty( $row->job_id ) ) {
                continue;
            }

            $status = $client->get_blog_status( $row->job_id );
            // A transport error is not evidence of anything — leave the row for
            // the 30-minute sweep rather than failing it on a flaky call.
            if ( is_wp_error( $status ) || ! is_array( $status ) ) {
                continue;
            }
            if ( ! isset( $status['status'] ) || 'failed' !== $status['status'] ) {
                continue;
            }

            self::update_job( (int) $row->id, 'failed', array(
                'response_payload' => wp_json_encode( $status ),
                'completed_at'     => current_time( 'mysql' ),
            ) );

            if ( ! empty( $status['retryable'] ) && self::restore_topic_for_job( $row ) ) {
                $recovered++;
            }
        }

        if ( $recovered ) {
            SiteStaffr_Logger::legacy( 'SiteStaffr Blog: returned ' . (int) $recovered . ' interrupted topic(s) to the queue for retry.' );
        }

        return $recovered;
    }

    /**
     * Put an interrupted autopilot job's topic back on the queue.
     *
     * Autopilot consumes a topic the moment generation starts
     * (pop_next_approved_topic marks it 'used', replace_consumed_topic pulls a
     * fresh one). If the generation then dies, that topic is gone and the post
     * is lost — which is exactly the failure this recovery exists for. The
     * request path already does this when the initial call fails; this covers
     * the job dying afterwards.
     *
     * Capped at one retry: a topic that keeps getting interrupted stops being
     * worth the credit, and an uncapped return could loop.
     *
     * @since 1.40.1
     * @param object $row Job row.
     * @return bool Whether a topic was returned to the queue.
     */
    private static function restore_topic_for_job( $row ) {
        if ( empty( $row->request_payload ) ) {
            return false;
        }

        $payload = json_decode( $row->request_payload, true );
        if ( ! is_array( $payload ) || empty( $payload['autopilot'] ) ) {
            // Manual generations don't consume a queue topic — nothing to give back.
            return false;
        }

        $topic_id    = isset( $payload['topic_id'] ) ? (string) $payload['topic_id'] : '';
        $topic_title = isset( $payload['topic'] ) ? (string) $payload['topic'] : '';

        $queue    = self::get_topic_queue();
        $restored = false;

        foreach ( $queue as &$entry ) {
            // Prefer the recorded id; fall back to the title for rows created
            // before topic_id was stored.
            $matches = ( '' !== $topic_id && isset( $entry['id'] ) && $entry['id'] === $topic_id )
                || ( '' === $topic_id && '' !== $topic_title && isset( $entry['topic'] ) && $entry['topic'] === $topic_title );

            if ( ! $matches ) {
                continue;
            }
            // Only a consumed topic can be given back; if it is already
            // actionable again something else restored it.
            if ( ! isset( $entry['status'] ) || 'used' !== $entry['status'] ) {
                break;
            }

            $retries = isset( $entry['retry_count'] ) ? (int) $entry['retry_count'] : 0;
            if ( $retries >= 1 ) {
                SiteStaffr_Logger::legacy( 'SiteStaffr Blog: topic already retried once after an interruption, leaving it consumed.' );
                break;
            }

            $entry['status']      = 'approved';
            $entry['retry_count'] = $retries + 1;
            unset( $entry['used_at'] );
            $restored = true;
            break;
        }
        unset( $entry );

        if ( $restored ) {
            self::save_topic_queue( $queue );
        }

        return $restored;
    }

    public static function run_autopilot_tick() {
        // Re-entrancy guard: WP-Cron can fire concurrently under traffic.
        if ( get_transient( 'sitestaffr_autopilot_lock' ) ) {
            return;
        }
        set_transient( 'sitestaffr_autopilot_lock', 1, 10 * MINUTE_IN_SECONDS );

        try {
            $blog_settings = self::get_settings();
            if ( empty( $blog_settings['autopilot_enabled'] ) ) {
                return;
            }

            // Plan changed since autopilot was enabled (e.g. downgrade): stop
            // quietly; the section disappears from Settings and the next save
            // clears the stored flag.
            if ( ! self::is_autopilot_allowed() ) {
                return;
            }

            self::maybe_reset_budgets();
            $blog_settings = self::get_settings();

            $post_budget = isset( $blog_settings['post_budget'] ) ? (int) $blog_settings['post_budget'] : 8;
            $post_used   = isset( $blog_settings['post_budget_used'] ) ? (int) $blog_settings['post_budget_used'] : 0;
            if ( $post_budget > 0 && $post_used >= $post_budget ) {
                SiteStaffr_Logger::legacy( 'Blog Autopilot: monthly post budget exhausted (' . $post_used . '/' . $post_budget . '), skipping slot.' );
                return;
            }

            self::replenish_topics();

            $topic = self::pop_next_approved_topic();
            if ( ! $topic ) {
                SiteStaffr_Logger::legacy( 'Blog Autopilot: no approved topics available, skipping slot.' );
                return;
            }

            $preferences = array(
                'tone'              => isset( $blog_settings['tone'] ) ? $blog_settings['tone'] : 'professional',
                'word_count_target' => isset( $blog_settings['word_count_target'] ) ? (int) $blog_settings['word_count_target'] : 1500,
                'language'          => isset( $blog_settings['language'] ) ? $blog_settings['language'] : 'en',
                'image_generation'  => ! empty( $blog_settings['image_generation'] ),
            );

            $payload = self::build_generation_payload( $topic['topic'], $topic['keyword'], $preferences );

            $ai_client = new SiteStaffr_AI_Client();
            $result    = $ai_client->generate_blog_post( $payload );

            if ( is_wp_error( $result ) || empty( $result['job_id'] ) ) {
                // Re-approve the topic so the next slot retries it.
                self::update_topic_status( $topic['id'], 'approved' );
                SiteStaffr_Logger::legacy( 'Blog Autopilot: generation request failed: ' . ( is_wp_error( $result ) ? $result->get_error_message() : 'no job ID' ) );
                return;
            }

            // The autopilot flag lives in OUR row, not the middleware's payload —
            // the webhook reads it from here to decide draft vs publish.
            // topic_id is what lets an interrupted generation put THIS topic back
            // on the queue rather than matching on title text.
            self::create_job( sanitize_text_field( $result['job_id'] ), $topic['topic'], $topic['keyword'], array(
                'topic'       => $topic['topic'],
                'topic_id'    => isset( $topic['id'] ) ? $topic['id'] : '',
                'keyword'     => $topic['keyword'],
                'preferences' => $preferences,
                'autopilot'   => true,
            ) );

            // The topic became a post — it left the queue, so pull a fresh one
            // one-for-one (same as a skip), up to the cycle's topic budget.
            self::replace_consumed_topic();
        } finally {
            delete_transient( 'sitestaffr_autopilot_lock' );
            self::reschedule_autopilot();
        }
    }

    /**
     * Enqueue spacing styles for generated FAQ sections on the front end.
     *
     * Generated posts mark their FAQ list with .sitestaffr-blog-faq; most themes
     * render dl/dt/dd with no margins, so the questions and answers compress
     * into a single block. Inline style on a dummy handle — no extra HTTP
     * request — and only on singular views whose content contains the class.
     *
     * @since 1.23.11
     */
    public static function enqueue_faq_styles() {
        if ( ! is_singular( 'post' ) ) {
            return;
        }

        $post = get_post();
        if ( ! $post || false === strpos( (string) $post->post_content, 'sitestaffr-blog-faq' ) ) {
            return;
        }

        wp_register_style( 'sitestaffr-blog-faq', false, array(), SITESTAFFR_VERSION );
        wp_enqueue_style( 'sitestaffr-blog-faq' );
        wp_add_inline_style( 'sitestaffr-blog-faq', self::faq_styles() );
    }

    /**
     * Shared FAQ spacing rules (front end and admin preview).
     *
     * @since 1.23.11
     * @param string $scope Optional selector prefix to scope the rules.
     * @return string CSS rules.
     */
    public static function faq_styles( $scope = '' ) {
        $s = $scope ? rtrim( $scope ) . ' ' : '';
        return "
{$s}dl.sitestaffr-blog-faq { margin: 1.5em 0; }
{$s}dl.sitestaffr-blog-faq dt { font-weight: 600; font-size: 1.05em; line-height: 1.4; margin: 1.6em 0 0.5em; }
{$s}dl.sitestaffr-blog-faq dt:first-child { margin-top: 0; }
{$s}dl.sitestaffr-blog-faq dd { margin: 0 0 1.4em; padding: 0 0 1.4em; border-bottom: 1px solid rgba(0, 0, 0, 0.08); }
{$s}dl.sitestaffr-blog-faq dd:last-child { margin-bottom: 0; padding-bottom: 0; border-bottom: none; }
{$s}dl.sitestaffr-blog-faq dd p { margin: 0 0 0.75em; }
{$s}dl.sitestaffr-blog-faq dd p:last-child { margin-bottom: 0; }
";
    }

    /**
     * Monthly post budget per plan. DISPLAY values only — the middleware
     * enforces the same limits server-side (Firestore is entitlement truth;
     * WordPress is a display cache). Topic credits are 2x posts.
     *
     * @since 1.24.0
     * @return int Monthly post budget for the cached plan.
     */
    public static function get_plan_post_budget() {
        $limits = array(
            'trial'    => 1,
            'starter'  => 2,
            'business' => 4,
            'pro'      => 8,
        );

        $plan = self::get_plan_name();
        return isset( $limits[ $plan ] ) ? $limits[ $plan ] : $limits['starter'];
    }

    /**
     * Cached plan name from billing state.
     *
     * @since 1.24.0
     * @return string trial|starter|business|pro.
     */
    public static function get_plan_name() {
        if ( ! class_exists( 'SiteStaffr_Billing_State' ) ) {
            return 'starter';
        }
        $cache = SiteStaffr_Billing_State::get_cache();
        $plan  = isset( $cache['plan_name'] ) ? sanitize_key( $cache['plan_name'] ) : 'starter';
        return in_array( $plan, array( 'trial', 'starter', 'business', 'pro' ), true ) ? $plan : 'starter';
    }

    /**
     * Whether the cached plan includes Autopilot (Business and Pro).
     *
     * Mirrors the custom-greeting pattern: the feature is simply not rendered
     * for other plans — never shown locked, grayed out, or with upgrade copy
     * (WP.org guideline 5).
     *
     * @since 1.24.0
     * @return bool
     */
    public static function is_autopilot_allowed() {
        return in_array( self::get_plan_name(), array( 'business', 'pro' ), true );
    }

    /**
     * Get Blog Agent settings.
     *
     * Budgets are derived from the cached plan on every read, so plan changes
     * reflect immediately; only the used counters persist.
     *
     * @return array
     */
    public static function get_settings() {
        $settings = get_option( 'sitestaffr_settings', array() );
        if ( ! is_array( $settings ) ) {
            $settings = array();
        }
        $defaults = array(
            'tone'               => 'professional',
            'word_count_target'  => 1500,
            'language'           => get_locale(),
            'autopilot_enabled'  => false,
            'auto_publish'       => true,
            'review_by_email'    => false,
            'image_generation'   => false,
            'schedule_days'      => array( 'tuesday', 'thursday' ),
            'schedule_time'      => '09:00',
            'schedule_timezone'  => 'America/New_York',
            'post_budget_used'    => 0,
            'topic_budget_used'   => 0,
            'post_budget_synced'  => 0,
            'topic_budget_synced' => 0,
            'topic_unlimited'     => false,
            'budget_reset_date'   => '',
            'next_due_at'         => '',
        );

        $blog_settings = wp_parse_args(
            isset( $settings['blog_agent'] ) ? $settings['blog_agent'] : array(),
            $defaults
        );

        // post_budget is the EFFECTIVE limit (plan + any manual grant). The
        // middleware is the source of truth: it returns the effective limit as
        // budget.limit on each blog response, synced into post_budget_synced.
        // Fall back to the plan limit before the first sync. Topics are now
        // server-authoritative too: the middleware returns the effective topic
        // limit (3x posts + grant) synced into topic_budget_synced; before the
        // first sync, fall back to the plan-based 3x. topic_unlimited (exempt
        // sites) means no cap at all.
        $plan_budget                    = self::get_plan_post_budget();
        $synced_budget                  = (int) $blog_settings['post_budget_synced'];
        $blog_settings['post_budget']   = $synced_budget > 0 ? $synced_budget : $plan_budget;
        $synced_topic                   = (int) $blog_settings['topic_budget_synced'];
        $blog_settings['topic_budget']  = $synced_topic > 0 ? $synced_topic : 3 * $plan_budget;

        return $blog_settings;
    }

    /**
     * Save Blog Agent settings.
     *
     * @param array $blog_settings Settings to save.
     * @return bool
     */
    public static function save_settings( $blog_settings ) {
        $settings = get_option( 'sitestaffr_settings', array() );
        if ( ! is_array( $settings ) ) {
            $settings = array();
        }
        $settings['blog_agent'] = $blog_settings;
        return update_option( 'sitestaffr_settings', $settings );
    }
}
// phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
