<?php
/**
 * Blog Agent AJAX handlers.
 *
 * @package    SiteStaffr
 * @subpackage SiteStaffr/admin/traits
 * @since      1.21.0
 */

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

trait SiteStaffr_Admin_Blog_Ajax_Trait {

    /**
     * Generate a blog post via middleware.
     *
     * @since 1.21.0
     */
    public function ajax_blog_generate_post() {
        if ( ! check_ajax_referer( 'sitestaffr_blog_nonce', 'nonce', false ) ) {
            wp_send_json_error( array( 'message' => __( 'Security check failed.', 'sitestaffr' ) ) );
            return;
        }

        if ( ! current_user_can( 'manage_options' ) ) {
            wp_send_json_error( array( 'message' => __( 'Unauthorized.', 'sitestaffr' ) ) );
            return;
        }

        $topic = isset( $_POST['topic'] ) ? sanitize_text_field( wp_unslash( $_POST['topic'] ) ) : '';
        $keyword = isset( $_POST['keyword'] ) ? sanitize_text_field( wp_unslash( $_POST['keyword'] ) ) : '';

        // Generate-from-topic: an Upcoming Topics row supplies topic/keyword
        // and is marked used once the job is accepted.
        $topic_id   = isset( $_POST['topic_id'] ) ? sanitize_text_field( wp_unslash( $_POST['topic_id'] ) ) : '';
        $from_queue = null;
        if ( '' !== $topic_id ) {
            foreach ( SiteStaffr_Blog_Manager::get_topic_queue() as $queued ) {
                if ( isset( $queued['id'], $queued['status'] ) && $queued['id'] === $topic_id
                    && in_array( $queued['status'], array( 'approved', 'queued' ), true ) ) {
                    $from_queue = $queued;
                    break;
                }
            }
            if ( ! $from_queue ) {
                wp_send_json_error( array( 'message' => __( 'Topic not found.', 'sitestaffr' ) ) );
                return;
            }
            $topic   = isset( $from_queue['topic'] ) ? $from_queue['topic'] : '';
            $keyword = isset( $from_queue['keyword'] ) ? $from_queue['keyword'] : '';
        }

        if ( '' === $topic ) {
            wp_send_json_error( array( 'message' => __( 'Topic is required.', 'sitestaffr' ) ) );
            return;
        }

        $allowed_tones     = array( 'professional', 'friendly', 'authoritative' );
        $allowed_languages = array( 'en', 'es', 'fr', 'pt' );
        $allowed_wc        = array( 800, 1200, 1500, 2000, 2500 );

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

        // Check post budget.
        $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_used >= $post_budget && $post_budget > 0 ) {
            $reset_raw = isset( $blog_settings['budget_reset_date'] ) ? (string) $blog_settings['budget_reset_date'] : '';
            $reset_ts  = '' !== $reset_raw ? strtotime( $reset_raw ) : false;
            if ( false !== $reset_ts ) {
                $message = sprintf(
                    /* translators: %s: localized reset date */
                    __( 'Post budget reached. Credits reset on %s.', 'sitestaffr' ),
                    wp_date( (string) get_option( 'date_format', 'F j, Y' ), $reset_ts, new DateTimeZone( 'America/New_York' ) )
                );
            } else {
                $message = __( 'Monthly post budget reached. Try again next month.', 'sitestaffr' );
            }
            wp_send_json_error( array( 'message' => $message ) );
            return;
        }

        $saved_tone = isset( $blog_settings['tone'] ) ? sanitize_key( $blog_settings['tone'] ) : 'professional';
        if ( ! in_array( $saved_tone, $allowed_tones, true ) ) {
            $saved_tone = 'professional';
        }

        $saved_wc_target = isset( $blog_settings['word_count_target'] ) ? absint( $blog_settings['word_count_target'] ) : 1500;
        if ( ! in_array( $saved_wc_target, $allowed_wc, true ) ) {
            $saved_wc_target = 1500;
        }

        $saved_language = isset( $blog_settings['language'] ) ? sanitize_key( substr( (string) $blog_settings['language'], 0, 2 ) ) : 'en';
        if ( ! in_array( $saved_language, $allowed_languages, true ) ) {
            $saved_language = 'en';
        }

        $tone = isset( $_POST['tone'] ) ? sanitize_key( wp_unslash( $_POST['tone'] ) ) : $saved_tone;
        if ( ! in_array( $tone, $allowed_tones, true ) ) {
            $tone = $saved_tone;
        }

        $wc_target = isset( $_POST['word_count_target'] ) ? absint( $_POST['word_count_target'] ) : $saved_wc_target;
        if ( ! in_array( $wc_target, $allowed_wc, true ) ) {
            $wc_target = $saved_wc_target;
        }

        $language = isset( $_POST['language'] ) ? sanitize_key( wp_unslash( $_POST['language'] ) ) : $saved_language;
        if ( ! in_array( $language, $allowed_languages, true ) ) {
            $language = $saved_language;
        }

        // Manual posts: the form toggle is the sole authority (absent/0 → no
        // image; never fall back to the saved default). Generate-from-topic
        // has no form, so it follows the saved setting like autopilot does.
        $image_generation = $from_queue ? ! empty( $blog_settings['image_generation'] ) : ! empty( $_POST['image_generation'] );

        $preferences = array(
            'tone'              => $tone,
            'word_count_target' => (int) $wc_target,
            'language'          => $language,
            'image_generation'  => $image_generation,
        );

        // Shared payload builder — identical context for manual and autopilot posts.
        $payload = SiteStaffr_Blog_Manager::build_generation_payload( $topic, $keyword, $preferences );

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

        if ( is_wp_error( $result ) ) {
            wp_send_json_error( array( 'message' => $result->get_error_message() ) );
            return;
        }

        $job_id = isset( $result['job_id'] ) ? sanitize_text_field( $result['job_id'] ) : '';
        if ( '' === $job_id ) {
            wp_send_json_error( array( 'message' => __( 'No job ID returned from middleware.', 'sitestaffr' ) ) );
            return;
        }

        $row_id = SiteStaffr_Blog_Manager::create_job( $job_id, $topic, $keyword, array(
            'topic'       => $topic,
            'keyword'     => $keyword,
            'preferences' => $preferences,
            'autopilot'   => false,
        ) );

        if ( ! $row_id ) {
            wp_send_json_error( array( 'message' => __( 'Failed to save job record.', 'sitestaffr' ) ) );
            return;
        }

        if ( $from_queue ) {
            SiteStaffr_Blog_Manager::update_topic_status( $topic_id, 'used' );
            // The topic became a post — replace it one-for-one (same as a skip),
            // up to the cycle's topic budget.
            SiteStaffr_Blog_Manager::replace_consumed_topic();
        }

        wp_send_json_success( array(
            'job_id'            => $job_id,
            'row_id'            => $row_id,
            'status'            => 'generating',
            'image_generation'  => $image_generation,
            'estimated_seconds' => isset( $result['estimated_seconds'] ) ? (int) $result['estimated_seconds'] : 45,
        ) );
    }

    /**
     * Check blog post generation status.
     *
     * @since 1.21.0
     */
    public function ajax_blog_check_status() {
        if ( ! check_ajax_referer( 'sitestaffr_blog_nonce', 'nonce', false ) ) {
            wp_send_json_error( array( 'message' => __( 'Security check failed.', 'sitestaffr' ) ) );
            return;
        }

        if ( ! current_user_can( 'manage_options' ) ) {
            wp_send_json_error( array( 'message' => __( 'Unauthorized.', 'sitestaffr' ) ) );
            return;
        }

        $job_id = isset( $_POST['job_id'] ) ? sanitize_text_field( wp_unslash( $_POST['job_id'] ) ) : '';
        $row_id = isset( $_POST['row_id'] ) ? absint( $_POST['row_id'] ) : 0;

        if ( '' === $job_id || 0 === $row_id ) {
            wp_send_json_error( array( 'message' => __( 'Missing job or row ID.', 'sitestaffr' ) ) );
            return;
        }

        $ai_client = new SiteStaffr_AI_Client();
        $result = $ai_client->get_blog_status( $job_id );

        if ( is_wp_error( $result ) ) {
            wp_send_json_error( array( 'message' => $result->get_error_message() ) );
            return;
        }

        $status = isset( $result['status'] ) ? $result['status'] : 'generating';

        if ( 'complete' === $status ) {
            // The signed push (or the pull poller, when the host blocks the push)
            // is the content channel: the draft is created there, never by this
            // poll. The status response carries no post or image content —
            // completion is detected via the local row's wp_post_id.
            $row        = SiteStaffr_Blog_Manager::get_job( $row_id );
            $wp_post_id = ( $row && ! empty( $row->wp_post_id ) ) ? (int) $row->wp_post_id : 0;

            // Push failed but the post is parked middleware-side (pull-fallback):
            // pick it up NOW instead of waiting for the 5-minute cron, so the
            // in-browser flow (progress → preview lightbox) works identically on
            // hosts whose bot protection blocks the push.
            if ( ! $wp_post_id && empty( $result['delivered'] ) ) {
                SiteStaffr_Blog_Manager::pickup_poll();
                $row        = SiteStaffr_Blog_Manager::get_job( $row_id );
                $wp_post_id = ( $row && ! empty( $row->wp_post_id ) ) ? (int) $row->wp_post_id : 0;
            }

            if ( $wp_post_id ) {
                // Title/word count/image state come from the delivered payload stored
                // locally by the webhook — never from the (content-free) poll response.
                $delivered_row = ( $row && ! empty( $row->response_payload ) ) ? json_decode( $row->response_payload, true ) : null;
                $delivered_post = isset( $delivered_row['post'] ) ? $delivered_row['post'] : array();

                // The body HTML is retained in the stored payload (only the raw
                // image bytes are stripped), so the preview can be rebuilt here
                // without re-fetching from the middleware. The image is pulled
                // from the media library it was just attached to.
                $featured_image = get_the_post_thumbnail_url( $wp_post_id, 'large' );

                wp_send_json_success( array(
                    'status'                  => 'complete',
                    'wp_post_id'              => $wp_post_id,
                    'edit_url'                => get_edit_post_link( $wp_post_id, 'raw' ),
                    'title'                   => isset( $delivered_post['title'] ) ? $delivered_post['title'] : '',
                    'word_count'              => isset( $delivered_post['word_count'] ) ? (int) $delivered_post['word_count'] : 0,
                    'image_generation_failed' => ! empty( $delivered_post['featured_image_error'] ),
                    'preview_html'            => isset( $delivered_post['body_html'] ) ? wp_kses_post( $delivered_post['body_html'] ) : '',
                    'featured_image'          => $featured_image ? $featured_image : '',
                ) );
                return;
            }

            // No local draft yet. A failed push is NOT necessarily terminal: since
            // 1.32.0 the middleware parks the finished post and the 5-minute pickup
            // cron pulls it in. When the middleware reports a pickup is pending, keep
            // the row generating and keep polling — sweep_stuck_jobs() fails it at 30
            // minutes if the pickup never lands, so it cannot loop forever.
            //
            // Before this branch existed the row was marked failed on any delivery
            // error and the raw error was shown to the user, while the poller imported
            // the post ~3 min later regardless (live case 2026-07-24, job
            // blog_1784931140_07ac47: host bot-protection answered the push with a
            // "Checking your browser…" interstitial).
            $delivery_error  = isset( $result['delivery_error'] ) ? (string) $result['delivery_error'] : '';
            $awaiting_pickup = ! empty( $result['awaiting_pickup'] );

            if ( '' !== $delivery_error && $awaiting_pickup ) {
                wp_send_json_success( array(
                    'status'   => 'generating',
                    'step'     => __( 'Finishing delivery…', 'sitestaffr' ),
                    'progress' => 95,
                    'notice'   => __( 'Your site did not accept the handoff on the first try, so the post is being retried. This usually completes within 5 minutes and it will appear in your drafts. You can safely leave this page.', 'sitestaffr' ),
                ) );
                return;
            }

            if ( '' !== $delivery_error ) {
                SiteStaffr_Blog_Manager::update_job( $row_id, 'failed', array(
                    'response_payload' => wp_json_encode( $result ),
                    'completed_at'     => current_time( 'mysql' ),
                ) );
                wp_send_json_error( array(
                    'status'  => 'failed',
                    'message' => sprintf(
                        /* translators: %s: delivery error detail from the middleware */
                        __( 'Delivery failed: %s', 'sitestaffr' ),
                        $delivery_error
                    ),
                ) );
                return;
            }

            // Generation is done but the push has not landed yet — keep polling so the
            // "finalizing" state shows until the webhook creates the draft.
            wp_send_json_success( array(
                'status'   => 'generating',
                'step'     => __( 'Finalizing…', 'sitestaffr' ),
                'progress' => 95,
            ) );
            return;
        }

        if ( 'failed' === $status ) {
            SiteStaffr_Blog_Manager::update_job( $row_id, 'failed', array(
                'response_payload' => wp_json_encode( $result ),
                'completed_at'     => current_time( 'mysql' ),
            ) );

            wp_send_json_error( array(
                'status'  => 'failed',
                'message' => isset( $result['error'] ) ? $result['error'] : __( 'Generation failed.', 'sitestaffr' ),
            ) );
            return;
        }

        wp_send_json_success( array(
            'status'   => 'generating',
            'step'     => isset( $result['step'] ) ? $result['step'] : '',
            'progress' => isset( $result['progress'] ) ? (int) $result['progress'] : 0,
        ) );
    }

    /**
     * Get blog drafts list.
     *
     * @since 1.21.0
     */
    public function ajax_blog_get_drafts() {
        if ( ! check_ajax_referer( 'sitestaffr_blog_nonce', 'nonce', false ) ) {
            wp_send_json_error( array( 'message' => __( 'Security check failed.', 'sitestaffr' ) ) );
            return;
        }

        if ( ! current_user_can( 'manage_options' ) ) {
            wp_send_json_error( array( 'message' => __( 'Unauthorized.', 'sitestaffr' ) ) );
            return;
        }

        $jobs = SiteStaffr_Blog_Manager::get_all_jobs();
        $drafts = array();

        // Warm the post cache once for the whole list (title/status/permalink).
        $job_post_ids = array_filter( array_map( function ( $j ) {
            return (int) $j->wp_post_id;
        }, $jobs ) );
        if ( ! empty( $job_post_ids ) ) {
            _prime_post_caches( $job_post_ids, false, false );
        }

        foreach ( $jobs as $job ) {
            $wp_post_id  = $job->wp_post_id ? (int) $job->wp_post_id : 0;
            $post_status = $wp_post_id ? (string) get_post_status( $wp_post_id ) : '';

            // Once a post exists for this job, drop it from the list if it has
            // since been trashed or hard-deleted (from WP admin, the no-login
            // review page's Discard, anywhere) so deleted posts don't linger in
            // the Ready/Drafts list. Still-generating jobs (no post yet) stay.
            if ( $wp_post_id && ( '' === $post_status || 'trash' === $post_status ) ) {
                continue;
            }

            $draft = array(
                'id'          => (int) $job->id,
                'job_id'      => $job->job_id,
                'topic'       => $job->topic,
                'keyword'     => $job->keyword,
                'status'      => $job->status,
                'created_at'  => SiteStaffr_Blog_Manager::format_job_created_at( $job->created_at ),
                'wp_post_id'  => $wp_post_id ? $wp_post_id : null,
                'post_status' => $post_status,
                'view_url'    => ( 'publish' === $post_status ) ? get_permalink( $wp_post_id ) : '',
                'edit_url'    => $wp_post_id ? get_edit_post_link( $wp_post_id, 'raw' ) : '',
                'fail_reason' => ( 'failed' === $job->status ) ? $this->extract_blog_fail_reason( $job ) : '',
            );
            $drafts[] = $draft;
        }

        wp_send_json_success( array( 'drafts' => $drafts ) );
    }

    /**
     * Human-readable failure reason for a failed generation job.
     *
     * Prefers an error/message field from the stored middleware payload;
     * falls back to the stuck-job-sweep explanation.
     *
     * @param object $job Job row.
     * @return string
     */
    private function extract_blog_fail_reason( $job ) {
        if ( ! empty( $job->response_payload ) ) {
            $payload = json_decode( (string) $job->response_payload, true );
            if ( is_array( $payload ) ) {
                foreach ( array( 'error', 'message', 'reason' ) as $reason_key ) {
                    if ( ! empty( $payload[ $reason_key ] ) && is_string( $payload[ $reason_key ] ) ) {
                        return $payload[ $reason_key ];
                    }
                }
            }
        }
        return __( 'Generation didn\'t finish — it timed out or the service was interrupted. Retry to try again.', 'sitestaffr' );
    }

    /**
     * Delete a blog post job and its WordPress draft.
     *
     * @since 1.21.0
     */
    public function ajax_blog_delete_post() {
        if ( ! check_ajax_referer( 'sitestaffr_blog_nonce', 'nonce', false ) ) {
            wp_send_json_error( array( 'message' => __( 'Security check failed.', 'sitestaffr' ) ) );
            return;
        }

        if ( ! current_user_can( 'manage_options' ) ) {
            wp_send_json_error( array( 'message' => __( 'Unauthorized.', 'sitestaffr' ) ) );
            return;
        }

        $id = isset( $_POST['id'] ) ? absint( $_POST['id'] ) : 0;
        if ( 0 === $id ) {
            wp_send_json_error( array( 'message' => __( 'Missing post ID.', 'sitestaffr' ) ) );
            return;
        }

        $job = SiteStaffr_Blog_Manager::get_job( $id );
        if ( ! $job ) {
            wp_send_json_error( array( 'message' => __( 'Post not found.', 'sitestaffr' ) ) );
            return;
        }

        if ( ! empty( $job->wp_post_id ) ) {
            // Trash, never force-delete: published posts carry live URLs and
            // SEO equity; WordPress's 30-day trash window is the safety net.
            wp_trash_post( (int) $job->wp_post_id );
        }

        SiteStaffr_Blog_Manager::delete_job( $id );

        wp_send_json_success();
    }

    /**
     * Publish a generated Blog Agent draft.
     *
     * @since 1.23.7
     */
    public function ajax_blog_publish_post() {
        if ( ! check_ajax_referer( 'sitestaffr_blog_nonce', 'nonce', false ) ) {
            wp_send_json_error( array( 'message' => __( 'Security check failed.', 'sitestaffr' ) ) );
            return;
        }

        if ( ! current_user_can( 'publish_posts' ) ) {
            wp_send_json_error( array( 'message' => __( 'Unauthorized.', 'sitestaffr' ) ) );
            return;
        }

        $wp_post_id = isset( $_POST['wp_post_id'] ) ? absint( $_POST['wp_post_id'] ) : 0;
        if ( 0 === $wp_post_id ) {
            wp_send_json_error( array( 'message' => __( 'Missing post ID.', 'sitestaffr' ) ) );
            return;
        }

        $post = get_post( $wp_post_id );
        if ( ! $post || 'post' !== $post->post_type ) {
            wp_send_json_error( array( 'message' => __( 'Post not found.', 'sitestaffr' ) ) );
            return;
        }

        if ( ! current_user_can( 'edit_post', $wp_post_id ) ) {
            wp_send_json_error( array( 'message' => __( 'Unauthorized.', 'sitestaffr' ) ) );
            return;
        }

        $job = SiteStaffr_Blog_Manager::get_job_by_wp_post_id( $wp_post_id );
        if ( ! $job ) {
            wp_send_json_error( array( 'message' => __( 'Only Blog Agent-generated posts can be published here.', 'sitestaffr' ) ) );
            return;
        }

        if ( 'publish' === $post->post_status ) {
            wp_send_json_success( array(
                'view_url' => get_permalink( $wp_post_id ),
            ) );
            return;
        }

        if ( 'draft' !== $post->post_status ) {
            wp_send_json_error( array( 'message' => __( 'Only draft Blog Agent posts can be published here.', 'sitestaffr' ) ) );
            return;
        }

        $updated = wp_update_post(
            array(
                'ID'          => $wp_post_id,
                'post_status' => 'publish',
            ),
            true
        );

        if ( is_wp_error( $updated ) ) {
            wp_send_json_error( array( 'message' => $updated->get_error_message() ) );
            return;
        }

        wp_send_json_success( array(
            'view_url' => get_permalink( $wp_post_id ),
        ) );
    }

    /**
     * Save Blog Agent settings (including autopilot config).
     *
     * @since 1.21.0
     */
    public function ajax_blog_save_settings() {
        if ( ! check_ajax_referer( 'sitestaffr_blog_nonce', 'nonce', false ) ) {
            wp_send_json_error( array( 'message' => __( 'Security check failed.', 'sitestaffr' ) ) );
            return;
        }

        if ( ! current_user_can( 'manage_options' ) ) {
            wp_send_json_error( array( 'message' => __( 'Unauthorized.', 'sitestaffr' ) ) );
            return;
        }

        $allowed_tones     = array( 'professional', 'friendly', 'authoritative' );
        $allowed_languages = array( 'en', 'es', 'fr', 'pt' );
        $allowed_wc        = array( 800, 1200, 1500, 2000, 2500 );

        $tone = isset( $_POST['tone'] ) ? sanitize_key( wp_unslash( $_POST['tone'] ) ) : 'professional';
        if ( ! in_array( $tone, $allowed_tones, true ) ) {
            $tone = 'professional';
        }

        $wc_target = isset( $_POST['word_count_target'] ) ? absint( $_POST['word_count_target'] ) : 1500;
        if ( ! in_array( $wc_target, $allowed_wc, true ) ) {
            $wc_target = 1500;
        }

        $language = isset( $_POST['language'] ) ? sanitize_key( wp_unslash( $_POST['language'] ) ) : 'en';
        if ( ! in_array( $language, $allowed_languages, true ) ) {
            $language = 'en';
        }

        $blog_settings = SiteStaffr_Blog_Manager::get_settings();
        $blog_settings['tone']              = $tone;
        $blog_settings['word_count_target'] = $wc_target;
        $blog_settings['language']          = $language;

        // Autopilot settings. (Topic approval mode removed 1.23.14 — every
        // suggested topic is actionable; Skip is the rejection mechanism.)
        // Autopilot follows the custom-greeting pattern: the section simply is
        // not rendered for plans without it, and the field is only honored
        // when the plan includes it — never shown locked or upsold.
        if ( SiteStaffr_Blog_Manager::is_autopilot_allowed() ) {
            $blog_settings['autopilot_enabled'] = ! empty( $_POST['autopilot_enabled'] );
        } else {
            $blog_settings['autopilot_enabled'] = false;
        }
        // Autopilot delivery is a single mutually-exclusive choice (Autopilot-only):
        //  - 'publish' → auto-publish + a "post published" preview email.
        //  - 'review'  → save as draft + a no-login review/edit/publish email.
        // Manual posts never trigger an automatic email regardless of this setting.
        $delivery = isset( $_POST['autopilot_delivery'] ) ? sanitize_key( wp_unslash( $_POST['autopilot_delivery'] ) ) : 'publish';
        if ( 'review' === $delivery ) {
            $blog_settings['review_by_email'] = true;
            $blog_settings['auto_publish']    = false;
        } else {
            $blog_settings['review_by_email'] = false;
            $blog_settings['auto_publish']    = true;
        }
        $blog_settings['image_generation']   = ! empty( $_POST['image_generation'] );

        // Schedule.
        if ( isset( $_POST['schedule_days'] ) && is_array( $_POST['schedule_days'] ) ) {
            $valid_days = array( 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday' );
            $blog_settings['schedule_days'] = array_values( array_intersect(
                array_map( 'sanitize_key', wp_unslash( $_POST['schedule_days'] ) ),
                $valid_days
            ) );
        }

        $blog_settings['schedule_time'] = isset( $_POST['schedule_time'] )
            ? sanitize_text_field( wp_unslash( $_POST['schedule_time'] ) )
            : '09:00';

        // Blog Agent scheduling is fixed to Eastern time for Autopilot.
        $blog_settings['schedule_timezone'] = 'America/New_York';

        // Autopilot is plugin-owned: budgets initialize locally and the schedule
        // is a WP-Cron event — nothing about this site is stored server-side.
        if ( empty( $blog_settings['budget_reset_date'] ) ) {
            $blog_settings['budget_reset_date'] = gmdate( 'Y-m-d\TH:i:s\Z', strtotime( 'first day of next month midnight' ) );
        }

        SiteStaffr_Blog_Manager::save_settings( $blog_settings );
        SiteStaffr_Blog_Manager::reschedule_autopilot();

        $next = wp_next_scheduled( 'sitestaffr_blog_autopilot_tick' );
        if ( $blog_settings['autopilot_enabled'] && $next ) {
            $blog_settings['next_due_at'] = gmdate( 'Y-m-d\TH:i:s\Z', $next );
            SiteStaffr_Blog_Manager::save_settings( $blog_settings );
        }

        wp_send_json_success( array( 'message' => __( 'Settings saved.', 'sitestaffr' ) ) );
    }

    /**
     * Resolve the shared secret used to verify middleware delivery pushes.
     *
     * Mirrors SiteStaffr_AI_Client::get_api_secret(): prefer the per-site secret
     * issued at registration, then the locally generated fallback. The middleware
     * signs delivery with the matching secret (per-site, else PHONEEASE_API_SECRET).
     *
     * @since 1.22.15
     * @return string Shared HMAC secret, or '' if none is configured.
     */
    private function get_delivery_secret() {
        $secret = get_option( 'sitestaffr_site_api_secret', '' );
        if ( ! empty( $secret ) ) {
            return $secret;
        }
        $secret = get_option( 'sitestaffr_api_secret', '' );
        return is_string( $secret ) ? $secret : '';
    }

    /**
     * Verify an HMAC signature over a raw delivery body.
     *
     * Pure (no WordPress dependencies) so it can be unit-tested directly. Mirrors
     * the middleware signer: base64( HMAC-SHA256( "<timestamp>.<raw_body>", secret ) ),
     * with a 5-minute replay window matching authenticate.js.
     *
     * @since 1.22.15
     * @param string      $raw_body         Exact request body bytes that were signed.
     * @param string      $timestamp_header X-SiteStaffr-Timestamp header value.
     * @param string      $signature_header X-SiteStaffr-Signature header value (base64).
     * @param string      $secret           Shared HMAC secret.
     * @param int|null    $now              Current unix time (injectable for tests).
     * @return string One of: 'valid', 'missing', 'no_secret', 'stale', 'invalid'.
     */
    public static function check_delivery_signature( $raw_body, $timestamp_header, $signature_header, $secret, $now = null ) {
        if ( '' === (string) $signature_header || '' === (string) $timestamp_header ) {
            return 'missing';
        }
        if ( '' === (string) $secret ) {
            return 'no_secret';
        }

        $timestamp = (int) $timestamp_header;
        $now       = ( null === $now ) ? time() : (int) $now;
        if ( abs( $now - $timestamp ) > 300 ) {
            return 'stale';
        }

        $expected = base64_encode( hash_hmac( 'sha256', (string) $timestamp_header . '.' . (string) $raw_body, $secret, true ) );
        if ( ! hash_equals( $expected, (string) $signature_header ) ) {
            return 'invalid';
        }

        return 'valid';
    }

    /**
     * Receive a completed blog post pushed from the middleware (server-to-server).
     *
     * Sole creator of the WordPress draft for BOTH manual and autopilot posts, so
     * completion never depends on the admin browser staying open. The featured
     * image arrives inline in this push and is sideloaded into the media library;
     * it is never stored on SiteStaffr infrastructure. Idempotent per job_id.
     *
     * @since 1.22.0
     */
    public function ajax_blog_receive_post() {
        // Authenticate the server-to-server push via HMAC over the EXACT raw body,
        // mirroring the plugin's own outbound signing in class-sitestaffr-ai-client.php
        // and the middleware's inbound verification in authenticate.js. The old
        // plaintext site_token comparison has been removed — the signature is the
        // sole credential.
        $raw_body         = file_get_contents( 'php://input' );
        $signature_header = isset( $_SERVER['HTTP_X_SITESTAFFR_SIGNATURE'] ) ? wp_unslash( $_SERVER['HTTP_X_SITESTAFFR_SIGNATURE'] ) : '';
        $timestamp_header = isset( $_SERVER['HTTP_X_SITESTAFFR_TIMESTAMP'] ) ? wp_unslash( $_SERVER['HTTP_X_SITESTAFFR_TIMESTAMP'] ) : '';

        $verdict = self::check_delivery_signature( $raw_body, $timestamp_header, $signature_header, $this->get_delivery_secret() );
        if ( 'valid' !== $verdict ) {
            wp_send_json_error( array( 'message' => 'Invalid signature.' ) );
            return;
        }

        $data = json_decode( $raw_body, true );

        // The import core lives in SiteStaffr_Blog_Manager::import_delivered_post()
        // — shared with the pull-delivery poller. This handler only authenticates
        // (above) and translates the result to a JSON response.
        $result = SiteStaffr_Blog_Manager::import_delivered_post( is_array( $data ) ? $data : array() );

        if ( is_wp_error( $result ) ) {
            wp_send_json_error( array( 'message' => $result->get_error_message() ) );
            return;
        }

        wp_send_json_success( $result );
    }

    /**
     * Get the local upcoming-topics queue (with projected publish slots).
     *
     * @since 1.22.0
     */
    public function ajax_blog_get_topics() {
        if ( ! check_ajax_referer( 'sitestaffr_blog_nonce', 'nonce', false ) ) {
            wp_send_json_error( array( 'message' => __( 'Security check failed.', 'sitestaffr' ) ) );
            return;
        }

        if ( ! current_user_can( 'manage_options' ) ) {
            wp_send_json_error( array( 'message' => __( 'Unauthorized.', 'sitestaffr' ) ) );
            return;
        }

        // Local queue is the source of truth; top it up when few actionable
        // topics remain (replenish self-limits on the topic budget). Runs with
        // or without autopilot — topics also feed one-click Generate.
        $blog_settings = SiteStaffr_Blog_Manager::get_settings();
        SiteStaffr_Blog_Manager::replenish_topics();

        $topics = array();
        foreach ( SiteStaffr_Blog_Manager::get_topic_queue() as $topic ) {
            if ( isset( $topic['status'] ) && in_array( $topic['status'], array( 'queued', 'approved' ), true ) ) {
                $topics[] = $topic;
            }
        }

        // Project each approved topic onto its future publish slot so owners
        // can see when each one will go live. The first approved topic takes
        // the live cron slot; the rest take consecutive schedule slots.
        if ( ! empty( $blog_settings['autopilot_enabled'] ) ) {
            $slot = wp_next_scheduled( 'sitestaffr_blog_autopilot_tick' );
            if ( ! $slot ) {
                $slot = SiteStaffr_Blog_Manager::get_next_due_timestamp( $blog_settings );
            }
            $et = new DateTimeZone( 'America/New_York' );
            foreach ( $topics as &$topic_row ) {
                if ( ! $slot ) {
                    continue;
                }
                $topic_row['scheduled_for'] = wp_date( 'M j, g:i a', $slot, $et );
                $slot                       = SiteStaffr_Blog_Manager::get_next_due_timestamp( $blog_settings, $slot );
            }
            unset( $topic_row );
        }

        wp_send_json_success( array( 'topics' => $topics ) );
    }

    /**
     * Suggest one topic for the New Post form (free — drawn from the reservoir
     * when available).
     *
     * @since 1.24.0
     */
    public function ajax_blog_suggest_topic() {
        if ( ! check_ajax_referer( 'sitestaffr_blog_nonce', 'nonce', false ) ) {
            wp_send_json_error( array( 'message' => __( 'Security check failed.', 'sitestaffr' ) ) );
            return;
        }

        if ( ! current_user_can( 'manage_options' ) ) {
            wp_send_json_error( array( 'message' => __( 'Unauthorized.', 'sitestaffr' ) ) );
            return;
        }

        // A re-roll passes the suggestion currently in the form so the AI
        // doesn't hand the same one back.
        $extra_previous = array();
        $current_topic  = isset( $_POST['current_topic'] ) ? sanitize_text_field( wp_unslash( $_POST['current_topic'] ) ) : '';
        if ( '' !== $current_topic ) {
            $extra_previous[] = array(
                'topic'   => $current_topic,
                'keyword' => isset( $_POST['current_keyword'] ) ? sanitize_text_field( wp_unslash( $_POST['current_keyword'] ) ) : '',
            );
        }

        $suggestion = SiteStaffr_Blog_Manager::suggest_single_topic( $extra_previous );
        if ( is_wp_error( $suggestion ) ) {
            wp_send_json_error( array( 'message' => $suggestion->get_error_message() ) );
            return;
        }

        wp_send_json_success( array(
            'topic'     => $suggestion['topic'],
            'keyword'   => $suggestion['keyword'],
            'rationale' => $suggestion['rationale'],
        ) );
    }

    /**
     * Skip/delete an upcoming topic.
     *
     * @since 1.22.0
     */
    public function ajax_blog_skip_topic() {
        if ( ! check_ajax_referer( 'sitestaffr_blog_nonce', 'nonce', false ) ) {
            wp_send_json_error( array( 'message' => __( 'Security check failed.', 'sitestaffr' ) ) );
            return;
        }

        if ( ! current_user_can( 'manage_options' ) ) {
            wp_send_json_error( array( 'message' => __( 'Unauthorized.', 'sitestaffr' ) ) );
            return;
        }

        $topic_id = isset( $_POST['topic_id'] ) ? sanitize_text_field( wp_unslash( $_POST['topic_id'] ) ) : '';
        if ( '' === $topic_id ) {
            wp_send_json_error( array( 'message' => __( 'Missing topic ID.', 'sitestaffr' ) ) );
            return;
        }

        if ( ! SiteStaffr_Blog_Manager::update_topic_status( $topic_id, 'skipped' ) ) {
            wp_send_json_error( array( 'message' => __( 'Topic not found.', 'sitestaffr' ) ) );
            return;
        }

        // Skips are free: the replacement promotes instantly from the local
        // reservoir (no API call); a background batch replan is scheduled when
        // the reservoir runs low.
        SiteStaffr_Blog_Manager::replace_consumed_topic();

        wp_send_json_success();
    }

    /**
     * Edit an upcoming topic's title and/or keyword in place.
     *
     * Local-only edit: no middleware call, so it costs no topic credit. Lets
     * owners fix wording (title length, keyword phrasing) without skipping and
     * spending a credit on a replacement.
     *
     * @since 1.25.0
     */
    public function ajax_blog_update_topic() {
        if ( ! check_ajax_referer( 'sitestaffr_blog_nonce', 'nonce', false ) ) {
            wp_send_json_error( array( 'message' => __( 'Security check failed.', 'sitestaffr' ) ) );
            return;
        }

        if ( ! current_user_can( 'manage_options' ) ) {
            wp_send_json_error( array( 'message' => __( 'Unauthorized.', 'sitestaffr' ) ) );
            return;
        }

        $topic_id = isset( $_POST['topic_id'] ) ? sanitize_text_field( wp_unslash( $_POST['topic_id'] ) ) : '';
        $topic    = isset( $_POST['topic'] ) ? sanitize_text_field( wp_unslash( $_POST['topic'] ) ) : '';
        $keyword  = isset( $_POST['keyword'] ) ? sanitize_text_field( wp_unslash( $_POST['keyword'] ) ) : '';

        if ( '' === $topic_id ) {
            wp_send_json_error( array( 'message' => __( 'Missing topic ID.', 'sitestaffr' ) ) );
            return;
        }
        if ( '' === $topic || '' === $keyword ) {
            wp_send_json_error( array( 'message' => __( 'Enter both a title and a keyword.', 'sitestaffr' ) ) );
            return;
        }

        if ( ! SiteStaffr_Blog_Manager::update_topic( $topic_id, $topic, $keyword ) ) {
            wp_send_json_error( array( 'message' => __( 'Topic not found.', 'sitestaffr' ) ) );
            return;
        }

        wp_send_json_success( array(
            'topic'   => $topic,
            'keyword' => $keyword,
        ) );
    }

}
