<?php
/**
 * DBA NewsBot Generator — PE/Capital Markets news article pipeline
 *
 * Completely isolated from the core DBA_Post_Creator pipeline.
 * Uses its own:
 *   - Progress transient key   (dba_pe_generation_progress)
 *   - Post meta keys           (_dba_pe_auto_created, _dba_pe_geography, etc.)
 *   - OpenAI prompts           (system + guardrails + user — PE-specific)
 *   - Validator                (DBA_NewsBot_Validator)
 *   - Dedupe                   (DBA_NewsBot_Dedupe)
 *
 * ═══════════════════════════════════════════════════════════════════════
 *  PUBLISH TIME: 10:00 AM New York (America/New_York) for ALL USA posts
 *  Individual posts staggered: 10:00, 10:02, 10:04, …
 *  Roundup hub: 10:15 AM New York
 * ═══════════════════════════════════════════════════════════════════════
 *
 * Pipeline per post:
 *   1. research_candidate   — Find PE/CM news via OpenAI web search
 *   2. dedupe_check         — Validate company + geography uniqueness
 *   3. generate_article     — Generate 800-1200 word article (configurable from dashboard)
 *   4. validate_article     — Run 6-check validator gate
 *   5. create_wp_post       — Insert into WordPress (post_date = 10:00 AM NY)
 *   6. publish              — Set final status
 *
 * After individual posts:
 *   7. generate_roundup     — Create daily hub post linking to individuals
 *
 * @since 2.6.0
 * @since 2.6.1 — Added 10:00 AM NY publish time enforcement
 */

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

class DBA_NewsBot_Generator {

    private static $instance = null;

    const PROGRESS_KEY  = 'dba_pe_generation_progress';  // ISOLATED from core
    const META_KEY      = '_dba_pe_auto_created';         // ISOLATED from core
    const META_GEO      = '_dba_pe_geography';
    const META_COMPANY  = '_dba_pe_company';
    const META_CATEGORY = '_dba_pe_category';
    const LOG_PREFIX    = 'DBA PE';

    private $max_retries = 1;

    // Geographies are now configurable from the dashboard
    // Use DBA_NewsBot_Dedupe::get_active_geo_names() everywhere

    public static function get_instance() {
        if ( null === self::$instance ) self::$instance = new self();
        return self::$instance;
    }

    private function __construct() {
        add_action( 'wp_ajax_dba_pe_generate_batch', array( $this, 'ajax_generate_batch' ) );
        add_action( 'wp_ajax_dba_pe_get_progress',   array( $this, 'ajax_get_progress' ) );
        add_action( 'wp_ajax_dba_pe_init_batch',      array( $this, 'ajax_init_batch' ) );
        add_action( 'wp_ajax_dba_pe_single_post',     array( $this, 'ajax_single_post' ) );
        add_action( 'wp_ajax_dba_pe_finish_roundup',  array( $this, 'ajax_finish_roundup' ) );
    }

    /* =================================================================
     *  PROGRESS — Isolated from core plugin
     * ================================================================= */

    private function set_progress( array $data ) {
        set_transient( self::PROGRESS_KEY, $data, 3600 );
    }

    public function get_progress() {
        return get_transient( self::PROGRESS_KEY ) ?: array();
    }

    private function log_step( int $post_index, int $step, string $name, string $status, string $detail = '' ) {
        $progress = $this->get_progress();
        $progress['current_post']      = $post_index;
        $progress['current_step']      = $step;
        $progress['current_step_name'] = $name;
        $progress['current_status']    = $status;
        $progress['current_detail']    = $detail;
        $progress['updated']           = time();
        $this->set_progress( $progress );
        error_log( self::LOG_PREFIX . " [{$post_index}] Step {$step} ({$name}): {$status} — {$detail}" );
    }

    /* =================================================================
     *  AJAX HANDLERS
     * ================================================================= */

    public function ajax_generate_batch() {
        check_ajax_referer( 'dba_admin_nonce', 'nonce' );
        if ( ! current_user_can( 'manage_options' ) ) wp_die( 'Unauthorized' );

        // ─── GUARD: Check if today already has enough posts ───
        $force = isset( $_POST['force'] ) && $_POST['force'] === '1';
        if ( ! $force ) {
            $existing = DBA_NewsBot_Scheduler::count_todays_pe_posts();
            $pe_settings = get_option( 'dba_pe_settings', array() );
            $target = max( 1, intval( $pe_settings['posts_per_day'] ?? 6 ) );

            if ( $existing >= $target ) {
                wp_send_json_error( array(
                    'message' => "Already have {$existing}/{$target} PE posts today. Batch skipped to prevent duplicates. Use force=1 to override.",
                    'existing' => $existing,
                    'target'   => $target,
                ) );
                return;
            }
        }

        @set_time_limit( 900 );
        @ini_set( 'memory_limit', '512M' );

        $results = $this->run_daily_batch();

        wp_send_json_success( $results );
    }

    public function ajax_get_progress() {
        check_ajax_referer( 'dba_admin_nonce', 'nonce' );
        wp_send_json_success( $this->get_progress() );
    }


    /* =================================================================
     *  PER-POST AJAX HANDLERS (timeout-safe manual run)
     *
     *  Instead of running all 6 posts in one HTTP request that exceeds
     *  most hosts' 60s timeout, the admin JS now calls:
     *    1. init_batch    -> setup, returns target count
     *    2. single_post   -> called N times (one per post)
     *    3. finish_roundup -> creates roundup from today's posts
     * ================================================================= */

    public function ajax_init_batch() {
        check_ajax_referer( 'dba_admin_nonce', 'nonce' );
        if ( ! current_user_can( 'manage_options' ) ) wp_die( 'Unauthorized' );

        $pe_settings = get_option( 'dba_pe_settings', array() );
        $active_geos = DBA_NewsBot_Dedupe::get_active_geo_names();
        $max_posts   = count( $active_geos );
        $base_target = max( 1, min( $max_posts, intval( $pe_settings['posts_per_day'] ?? 6 ) ) );

        $existing_today = DBA_NewsBot_Scheduler::count_todays_pe_posts();

        $force = isset( $_POST['force'] ) && $_POST['force'] === '1';
        if ( ! $force && $existing_today >= $base_target ) {
            wp_send_json_error( array(
                'message' => "Already have {$existing_today}/{$base_target} PE posts today.",
                'existing' => $existing_today,
                'target'   => $base_target,
            ) );
            return;
        }

        $today_key = current_time( 'Y-m-d' );
        $hist = get_option( 'damalion_newsbot_history', array() );
        if ( ! is_array( $hist ) ) $hist = array();
        if ( empty( $hist['days'] ) ) $hist['days'] = array();

        // ═══════════════════════════════════════════════════════════════
        //  FULL REBUILD: Reconstruct dedupe history from actual DB posts
        //  for the last 14 days. This ensures cross-day dedupe works even
        //  if the option was cleared, corrupted, or history was lost.
        // ═══════════════════════════════════════════════════════════════
        $rebuild_since = date( 'Y-m-d', strtotime( '-14 days' ) );
        $all_pe_posts = get_posts( array(
            'post_type'      => 'post',
            'post_status'    => array( 'publish', 'draft', 'pending' ),
            'meta_key'       => self::META_KEY,
            'meta_value'     => '1',
            'date_query'     => array( array(
                'after'     => $rebuild_since . ' 00:00:00',
                'inclusive' => true,
            ) ),
            'posts_per_page' => 200,
        ) );

        // Clear history for rebuild period and reconstruct from DB
        foreach ( array_keys( $hist['days'] ) as $d ) {
            if ( $d >= $rebuild_since ) {
                unset( $hist['days'][ $d ] );
            }
        }

        foreach ( $all_pe_posts as $rp ) {
            // Skip roundup posts
            if ( get_post_meta( $rp->ID, '_dba_pe_is_roundup', true ) === '1' ) continue;

            $post_date = get_the_date( 'Y-m-d', $rp );
            if ( ! isset( $hist['days'][ $post_date ] ) ) {
                $hist['days'][ $post_date ] = array(
                    'companies'    => array(),
                    'company_keys' => array(),
                    'domains'      => array(),
                    'geos'         => array(),
                    'created_at'   => time(),
                );
            }
            $day = &$hist['days'][ $post_date ];

            $g = get_post_meta( $rp->ID, self::META_GEO, true );
            $c = get_post_meta( $rp->ID, self::META_COMPANY, true );

            if ( $g ) {
                $s = DBA_NewsBot_Dedupe::normalize_geo( $g );
                if ( $s && ! in_array( $s, $day['geos'], true ) ) {
                    $day['geos'][] = $s;
                }
            }
            if ( $c ) {
                if ( ! in_array( $c, $day['companies'], true ) ) {
                    $day['companies'][] = $c;
                }
                $k = DBA_NewsBot_Dedupe::normalize_company( $c );
                if ( $k && ! in_array( $k, $day['company_keys'], true ) ) {
                    $day['company_keys'][] = $k;
                }
            }

            unset( $day );
        }
        update_option( 'damalion_newsbot_history', $hist, false );

        error_log( self::LOG_PREFIX . ': init_batch rebuilt dedupe from ' . count( $all_pe_posts ) . ' posts since ' . $rebuild_since );

        if ( class_exists( 'DBA_Kaizen' ) ) {
            DBA_Kaizen::flush_cache();
        }

        $tz = DBA_NewsBot_Scheduler::PUBLISH_TZ;
        try {
            $now   = new DateTime( 'now', new DateTimeZone( $tz ) );
            $today = strtolower( $now->format( 'l' ) );
        } catch ( Exception $e ) {
            $today = strtolower( date( 'l' ) );
        }
        $posts_target = ( $today === 'friday' ) ? max( 1, $base_target - 1 ) : $base_target;

        $this->set_progress( array(
            'total_posts'   => $posts_target + 1,
            'completed'     => 0,
            'failed'        => 0,
            'current_post'  => 0,
            'current_step'  => 0,
            'started'       => time(),
        ) );

        wp_send_json_success( array(
            'posts_target' => $posts_target,
            'existing'     => $existing_today,
            'day'          => $today,
        ) );
    }

    public function ajax_single_post() {
        check_ajax_referer( 'dba_admin_nonce', 'nonce' );
        if ( ! current_user_can( 'manage_options' ) ) wp_die( 'Unauthorized' );

        @set_time_limit( 120 );
        @ini_set( 'memory_limit', '512M' );

        $post_index = max( 1, intval( $_POST['post_index'] ?? 1 ) );

        $result = $this->create_single_post( $post_index );

        $progress = $this->get_progress();
        if ( is_wp_error( $result ) ) {
            $progress['failed'] = ( $progress['failed'] ?? 0 ) + 1;
            $this->set_progress( $progress );

            update_option( 'dba_pe_last_error', array(
                'time'    => current_time( 'mysql' ),
                'code'    => $result->get_error_code(),
                'message' => "Post {$post_index} failed: " . $result->get_error_message(),
            ), false );

            wp_send_json_success( array(
                'status'     => 'failed',
                'error'      => $result->get_error_message(),
                'post_index' => $post_index,
            ) );
            return;
        }

        $progress['completed'] = ( $progress['completed'] ?? 0 ) + 1;
        $this->set_progress( $progress );

        wp_send_json_success( array_merge( $result, array( 'post_index' => $post_index ) ) );
    }

    public function ajax_finish_roundup() {
        check_ajax_referer( 'dba_admin_nonce', 'nonce' );
        if ( ! current_user_can( 'manage_options' ) ) wp_die( 'Unauthorized' );

        @set_time_limit( 120 );

        $today_key = current_time( 'Y-m-d' );
        $today_posts = get_posts( array(
            'post_type'      => 'post',
            'post_status'    => array( 'publish', 'draft', 'pending', 'future' ),
            'meta_key'       => self::META_KEY,
            'meta_value'     => '1',
            'date_query'     => array( array(
                'after'     => $today_key . ' 00:00:00',
                'before'    => $today_key . ' 23:59:59',
                'inclusive'  => true,
            ) ),
            'posts_per_page' => 20,
            'meta_query'     => array(
                array( 'key' => '_dba_pe_is_roundup', 'compare' => 'NOT EXISTS' ),
            ),
        ) );

        if ( empty( $today_posts ) ) {
            wp_send_json_error( array( 'message' => 'No posts to include in roundup.' ) );
            return;
        }

        $existing_roundup = get_posts( array(
            'post_type'      => 'post',
            'post_status'    => array( 'publish', 'draft', 'pending', 'future' ),
            'meta_query'     => array(
                array( 'key' => '_dba_pe_is_roundup', 'value' => '1' ),
            ),
            'date_query'     => array( array(
                'after'     => $today_key . ' 00:00:00',
                'before'    => $today_key . ' 23:59:59',
                'inclusive'  => true,
            ) ),
            'posts_per_page' => 1,
            'fields'         => 'ids',
        ) );

        if ( ! empty( $existing_roundup ) ) {
            wp_send_json_success( array(
                'roundup_id' => $existing_roundup[0],
                'skipped'    => true,
                'message'    => 'Roundup already exists.',
            ) );
            return;
        }

        $published_posts = array();
        foreach ( $today_posts as $p ) {
            $published_posts[] = array(
                'post_id'   => $p->ID,
                'title'     => $p->post_title,
                'geo'       => get_post_meta( $p->ID, self::META_GEO, true ),
                'company'   => get_post_meta( $p->ID, self::META_COMPANY, true ),
                'category'  => get_post_meta( $p->ID, self::META_CATEGORY, true ),
                'permalink' => get_permalink( $p->ID ),
                'excerpt'   => $p->post_excerpt,
            );
        }

        $posts_target = count( $today_posts );
        $this->log_step( $posts_target + 1, 1, 'roundup', 'running', 'Generating daily roundup hub...' );

        $roundup_id = $this->create_roundup_post( $published_posts );

        if ( is_wp_error( $roundup_id ) ) {
            $this->log_step( $posts_target + 1, 6, 'roundup', 'failed', $roundup_id->get_error_message() );
            wp_send_json_error( array( 'message' => $roundup_id->get_error_message() ) );
            return;
        }

        $progress = $this->get_progress();
        $progress['current_status'] = 'complete';
        $progress['current_detail'] = "Batch done: {$progress['completed']} created, {$progress['failed']} failed";
        $this->set_progress( $progress );

        update_option( 'dba_pe_last_run', array(
            'time'    => current_time( 'mysql' ),
            'results' => array(
                'created'    => $progress['completed'] ?? 0,
                'failed'     => $progress['failed'] ?? 0,
                'roundup_id' => $roundup_id,
            ),
        ), false );

        $this->log_step( $posts_target + 1, 6, 'roundup', 'done', "Roundup published: post #{$roundup_id}" );

        wp_send_json_success( array(
            'roundup_id'  => $roundup_id,
            'total_posts' => count( $today_posts ),
        ) );
    }

    /* =================================================================
     *  MAIN BATCH RUNNER
     * ================================================================= */

    /**
     * Run the full daily batch: individual posts + roundup hub.
     *
     * @return array { created, failed, roundup_id, details[] }
     */
    public function run_daily_batch() {

        // ═══ SAFETY GUARD: Prevent double batch runs ═══
        $existing_today = DBA_NewsBot_Scheduler::count_todays_pe_posts();
        $pe_settings = get_option( 'dba_pe_settings', array() );
        $active_geos = DBA_NewsBot_Dedupe::get_active_geo_names();
        $max_posts   = count( $active_geos );
        $base_target = max( 1, min( $max_posts, intval( $pe_settings['posts_per_day'] ?? 6 ) ) );

        if ( $existing_today >= $base_target ) {
            error_log( self::LOG_PREFIX . ": BLOCKED — Already have {$existing_today}/{$base_target} posts today. Skipping entire batch." );
            return array(
                'created'    => 0,
                'failed'     => 0,
                'roundup_id' => 0,
                'details'    => array(),
                'skipped'    => true,
                'reason'     => "Already have {$existing_today}/{$base_target} posts today",
            );
        }

        // FIX: Rebuild dedupe from actual WP posts (full 14-day rebuild)
        $today_key = current_time( 'Y-m-d' );
        $hist = get_option( 'damalion_newsbot_history', array() );
        if ( ! is_array( $hist ) ) $hist = array();
        if ( empty( $hist['days'] ) ) $hist['days'] = array();

        $rebuild_since = date( 'Y-m-d', strtotime( '-14 days' ) );
        $real = get_posts( array(
            'post_type'      => 'post',
            'post_status'    => array( 'publish', 'draft', 'pending' ),
            'meta_key'       => self::META_KEY,
            'meta_value'     => '1',
            'date_query'     => array( array( 'after' => $rebuild_since . ' 00:00:00', 'inclusive' => true ) ),
            'posts_per_page' => 200,
        ) );
        foreach ( array_keys( $hist['days'] ) as $d ) {
            if ( $d >= $rebuild_since ) unset( $hist['days'][ $d ] );
        }
        foreach ( $real as $rp ) {
            if ( get_post_meta( $rp->ID, '_dba_pe_is_roundup', true ) === '1' ) continue;
            $post_date = get_the_date( 'Y-m-d', $rp );
            if ( ! isset( $hist['days'][ $post_date ] ) ) {
                $hist['days'][ $post_date ] = array( 'companies' => array(), 'company_keys' => array(), 'domains' => array(), 'geos' => array(), 'created_at' => time() );
            }
            $day = &$hist['days'][ $post_date ];
            $g = get_post_meta( $rp->ID, self::META_GEO, true );
            $c = get_post_meta( $rp->ID, self::META_COMPANY, true );
            if ( $g ) { $s = DBA_NewsBot_Dedupe::normalize_geo( $g ); if ( $s && ! in_array( $s, $day['geos'], true ) ) $day['geos'][] = $s; }
            if ( $c ) {
                if ( ! in_array( $c, $day['companies'], true ) ) $day['companies'][] = $c;
                $k = DBA_NewsBot_Dedupe::normalize_company( $c );
                if ( $k && ! in_array( $k, $day['company_keys'], true ) ) $day['company_keys'][] = $k;
            }
            unset( $day );
        }
        update_option( 'damalion_newsbot_history', $hist, false );
        error_log( self::LOG_PREFIX . ': Dedupe rebuilt — ' . count( $real ) . ' posts since ' . $rebuild_since );

        // ═══ KAIZEN: Flush cache so learning context is fresh for this batch ═══
        if ( class_exists( 'DBA_Kaizen' ) ) {
            DBA_Kaizen::flush_cache();
        }

        // Friday reduced schedule per instructions: Mon/Wed 5-7, Fri 4-6
        // Always use New York timezone for PE/US content
        $tz = DBA_NewsBot_Scheduler::PUBLISH_TZ;
        try {
            $now   = new DateTime( 'now', new DateTimeZone( $tz ) );
            $today = strtolower( $now->format( 'l' ) );
        } catch ( Exception $e ) {
            $today = strtolower( date( 'l' ) );
        }
        $posts_target = ( $today === 'friday' ) ? max( 1, $base_target - 1 ) : $base_target;

        // Init progress
        $this->set_progress( array(
            'total_posts'   => $posts_target + 1, // +1 for roundup
            'completed'     => 0,
            'failed'        => 0,
            'current_post'  => 0,
            'current_step'  => 0,
            'started'       => time(),
        ) );

        $results = array(
            'created'    => 0,
            'failed'     => 0,
            'roundup_id' => 0,
            'details'    => array(),
        );

        $published_posts = array(); // Collect for roundup generation

        // --- Generate individual posts ---
        for ( $i = 0; $i < $posts_target; $i++ ) {
            $post_num = $i + 1;

            $result = $this->create_single_post( $post_num );

            if ( is_wp_error( $result ) ) {
                $results['failed']++;
                $results['details'][] = array(
                    'status' => 'failed',
                    'error'  => $result->get_error_message(),
                );

                // Store error visibly for admin panel — THIS is the catch-all
                update_option( 'dba_pe_last_error', array(
                    'time'    => current_time( 'mysql' ),
                    'code'    => $result->get_error_code(),
                    'message' => "Post {$post_num} failed: " . $result->get_error_message(),
                ), false );

                error_log( self::LOG_PREFIX . ": Post {$post_num} FAILED — " . $result->get_error_message() );
            } else {
                $results['created']++;
                $results['details'][]  = $result;
                $published_posts[]     = $result;
            }

            // Update progress
            $progress = $this->get_progress();
            $progress['completed'] = $results['created'];
            $progress['failed']    = $results['failed'];
            $this->set_progress( $progress );

            // Breathing room between posts (API rate limits, DB writes)
            if ( $i < $posts_target - 1 ) {
                sleep( 5 );
            }
        }

        // --- Generate roundup hub post ---
        if ( ! empty( $published_posts ) ) {

            // Check if roundup already exists for today (prevent duplicate roundups)
            $today_key = current_time( 'Y-m-d' );
            $existing_roundup = get_posts( array(
                'post_type'      => 'post',
                'post_status'    => array( 'publish', 'draft', 'pending', 'future' ),
                'meta_query'     => array(
                    array( 'key' => '_dba_pe_is_roundup', 'value' => '1' ),
                ),
                'date_query'     => array( array(
                    'after'     => $today_key . ' 00:00:00',
                    'before'    => $today_key . ' 23:59:59',
                    'inclusive'  => true,
                ) ),
                'posts_per_page' => 1,
                'fields'         => 'ids',
            ) );

            if ( ! empty( $existing_roundup ) ) {
                error_log( self::LOG_PREFIX . ': Roundup already exists (#' . $existing_roundup[0] . '). Skipping.' );
                $results['roundup_id'] = $existing_roundup[0];
            } else {
                $this->log_step( $posts_target + 1, 1, 'roundup', 'running', 'Generating daily roundup hub…' );

                // Allow DB + cache to settle
                sleep( 5 );

                $roundup_id = $this->create_roundup_post( $published_posts );

                if ( is_wp_error( $roundup_id ) ) {
                    error_log( self::LOG_PREFIX . ': Roundup FAILED — ' . $roundup_id->get_error_message() );
                    $this->log_step( $posts_target + 1, 6, 'roundup', 'failed', $roundup_id->get_error_message() );
                } else {
                    $results['roundup_id'] = $roundup_id;
                    $this->log_step( $posts_target + 1, 6, 'roundup', 'done', "Roundup published: post #{$roundup_id}" );
                }
            }
        }

        // Store last run
        update_option( 'dba_pe_last_run', array(
            'time'    => current_time( 'mysql' ),
            'results' => $results,
        ), false );

        // If any failures, store ALL error details for admin visibility
        if ( $results['failed'] > 0 && ! empty( $results['details'] ) ) {
            $error_msgs = array();
            foreach ( $results['details'] as $i => $d ) {
                if ( ( $d['status'] ?? '' ) === 'failed' ) {
                    $error_msgs[] = 'Post ' . ( $i + 1 ) . ': ' . ( $d['error'] ?? 'unknown' );
                }
            }
            update_option( 'dba_pe_last_error', array(
                'time'    => current_time( 'mysql' ),
                'code'    => 'batch_summary',
                'message' => "Batch finished — {$results['created']} created, {$results['failed']} failed.\n" . implode( "\n", $error_msgs ),
            ), false );
        }

        // Final progress
        $progress = $this->get_progress();
        $progress['current_status'] = 'complete';
        $progress['current_detail'] = "Batch done: {$results['created']} created, {$results['failed']} failed";
        $this->set_progress( $progress );

        return $results;
    }

    /* =================================================================
     *  SINGLE POST PIPELINE
     * ================================================================= */

    /**
     * Create a single PE/CM individual news post.
     *
     * @param int $post_index 1-based index for progress tracking.
     * @return array|WP_Error
     */
    public function create_single_post( int $post_index, array $pre_candidate = null, array $pre_article = null ) {

        $post_start_time = time();
        $openai    = DBA_OpenAI::get_instance();
        $validator = DBA_NewsBot_Validator::get_instance();

        /* ── STEP 1: Research candidate ── */
        if ( $pre_candidate ) {
            $candidate = $pre_candidate;
            $this->log_step( $post_index, 1, 'research', 'done',
                "Pre-loaded: {$candidate['company_name']} in {$candidate['geo']}" );
        } else {
            $this->log_step( $post_index, 1, 'research', 'running', 'Searching for PE/CM news candidates…' );

        $candidate = $this->find_news_candidate( $post_index );

        if ( is_wp_error( $candidate ) ) {
            $this->log_step( $post_index, 1, 'research', 'failed', $candidate->get_error_message() );
            return $candidate;
        }

        $this->log_step( $post_index, 1, 'research', 'done',
            "{$candidate['company_name']} in {$candidate['geo']}" );

        /* ── STEP 2: Dedupe check ── */
        $this->log_step( $post_index, 2, 'dedupe', 'running', 'Checking duplicates…' );

        $dedupe = DBA_NewsBot_Dedupe::validate_and_reserve( array(
            'date'         => current_time( 'Y-m-d' ),
            'geo'          => $candidate['geo'],
            'company_name' => $candidate['company_name'],
            'official_url' => $candidate['official_url'] ?? '',
        ) );

        if ( ! $dedupe['ok'] ) {
            $this->log_step( $post_index, 2, 'dedupe', 'failed', $dedupe['reason'] );
            return new WP_Error( 'dedupe_fail', $dedupe['reason'] );
        }

        $this->log_step( $post_index, 2, 'dedupe', 'done', 'Unique ✓' );
        } // end else (research + dedupe when no pre_candidate)

        /* ── STEP 3: Generate article ── */
        if ( $pre_article ) {
            // Article was already generated in a separate AJAX call — skip OpenAI
            $article  = $pre_article;
            $category = DBA_NewsBot_Validator::assign_category(
                $article, $candidate['deal_type'] ?? '', $candidate['geo'] ?? ''
            );
            // Run quick validation (no retry possible in split mode)
            $val_errors = $validator->validate_individual( $article, $candidate['geo'], $category );
            if ( ! empty( $val_errors ) ) {
                $this->log_step( $post_index, 4, 'validate', 'warning', 'Split mode — continuing despite: ' . implode( ' | ', $val_errors ) );
            }
            $this->log_step( $post_index, 3, 'generate', 'done', 'Pre-loaded article (split mode) | Category: ' . $category );
        } else {
        $this->log_step( $post_index, 3, 'generate', 'running', 'Generating article via OpenAI…' );

        $article = null;
        $last_errors = array();
        $retry_hint = '';

        for ( $attempt = 0; $attempt < $this->max_retries; $attempt++ ) {
            if ( $attempt > 0 ) {
                $backoff = ( $attempt === 1 ) ? 5 : 15;
                $this->log_step( $post_index, 3, 'generate', 'running', "Retry #{$attempt} (waiting {$backoff}s)…" );
                sleep( $backoff );
            }

            $article = $this->call_openai_for_article( $candidate, $retry_hint );

            if ( is_wp_error( $article ) ) {
                $last_errors[] = $article->get_error_message();
                continue;
            }

            // ═══════════════════════════════════════════════════════════
            //  AUTO-FIX: Inject subtitle if GPT forgot it.
            //  GPT frequently skips <p class="subtitle"> even when prompted.
            //  Fix it here so the validator never rejects a good article.
            // ═══════════════════════════════════════════════════════════
            $pe_check = get_option( 'dba_pe_settings', array() );
            if ( ! empty( $pe_check['require_subtitle'] ?? true ) ) {
                $html = $article['content_html'] ?? '';
                $has_subtitle = ( stripos( $html, 'class="subtitle"' ) !== false )
                    || ( stripos( $html, "class='subtitle'" ) !== false );

                if ( ! $has_subtitle && ! empty( $html ) ) {
                    $sub_text = ! empty( $article['excerpt'] ) ? $article['excerpt'] : ( $article['title'] ?? '' );
                    $sub_words = explode( ' ', strip_tags( $sub_text ) );
                    if ( count( $sub_words ) > 25 ) {
                        $sub_text = implode( ' ', array_slice( $sub_words, 0, 25 ) ) . '.';
                    }
                    $subtitle_tag = '<p class="subtitle">' . esc_html( trim( $sub_text ) ) . '</p>';

                    if ( stripos( $html, '</h1>' ) !== false ) {
                        $pos = stripos( $html, '</h1>' ) + 5;
                        $article['content_html'] = substr( $html, 0, $pos ) . "\n" . $subtitle_tag . substr( $html, $pos );
                    } else {
                        $article['content_html'] = $subtitle_tag . "\n" . $article['content_html'];
                    }
                    error_log( self::LOG_PREFIX . ': Auto-injected subtitle for "' . ( $article['title'] ?? '' ) . '"' );
                }
            }

            // ═══════════════════════════════════════════════════════════
            //  AUTO-FIX: Truncate title to 80 chars max (geo-aware)
            // ═══════════════════════════════════════════════════════════
            if ( ! empty( $article['title'] ) && mb_strlen( $article['title'] ) > 80 ) {
                $old_title = $article['title'];
                // Try cutting at last space before 80 chars
                $cut = mb_substr( $article['title'], 0, 80 );
                $last_space = mb_strrpos( $cut, ' ' );
                if ( $last_space > 40 ) {
                    $article['title'] = mb_substr( $cut, 0, $last_space );
                } else {
                    $article['title'] = $cut;
                }
                // Geo-aware: if truncation removed the geography, re-append it
                $geo_name = $candidate['geo'] ?? '';
                if ( ! empty( $geo_name ) && stripos( $old_title, $geo_name ) !== false && stripos( $article['title'], $geo_name ) === false ) {
                    // Geo was in original title but got cut off — re-append
                    $article['title'] = rtrim( $article['title'], ' –—-,' ) . ' – ' . $geo_name;
                    error_log( self::LOG_PREFIX . ': Title truncation re-appended geo: ' . $geo_name );
                }
                // Also fix H1 in content_html
                if ( ! empty( $article['content_html'] ) ) {
                    $article['content_html'] = preg_replace(
                        '/<h1[^>]*>.*?<\/h1>/is',
                        '<h1>' . esc_html( $article['title'] ) . '</h1>',
                        $article['content_html'],
                        1
                    );
                }
                error_log( self::LOG_PREFIX . ': Title truncated from ' . mb_strlen( $old_title ) . ' to ' . mb_strlen( $article['title'] ) . ' chars' );
            }

            // ═══════════════════════════════════════════════════════════
            //  AUTO-FIX: Strip duplicate company & .gov links.
            //  Keep FIRST occurrence of each link, convert rest to plain text.
            //  Prevents spammy multi-link pattern.
            // ═══════════════════════════════════════════════════════════
            if ( ! empty( $article['content_html'] ) ) {
                $html = $article['content_html'];

                // Strip duplicate company links (keep first <a href="...company...">Name</a>, remove rest)
                $company_url = $candidate['official_url'] ?? '';
                if ( $company_url ) {
                    $company_domain = parse_url( $company_url, PHP_URL_HOST );
                    if ( $company_domain ) {
                        $found_first = false;
                        $html = preg_replace_callback(
                            '/<a\s[^>]*href=["\'][^"\']*' . preg_quote( $company_domain, '/' ) . '[^"\']*["\'][^>]*>(.*?)<\/a>/is',
                            function( $m ) use ( &$found_first ) {
                                if ( ! $found_first ) {
                                    $found_first = true;
                                    return $m[0]; // keep first
                                }
                                return $m[1]; // strip link, keep text
                            },
                            $html
                        );
                    }
                }

                // Strip duplicate .gov links (keep first)
                $found_gov = false;
                $html = preg_replace_callback(
                    '/<a\s[^>]*href=["\'][^"\']*\.gov[^"\']*["\'][^>]*>(.*?)<\/a>/is',
                    function( $m ) use ( &$found_gov ) {
                        if ( ! $found_gov ) {
                            $found_gov = true;
                            return $m[0];
                        }
                        return $m[1];
                    },
                    $html
                );

                // Also catch myflorida.com for Florida
                $found_alt_gov = false;
                $html = preg_replace_callback(
                    '/<a\s[^>]*href=["\'][^"\']*myflorida\.com[^"\']*["\'][^>]*>(.*?)<\/a>/is',
                    function( $m ) use ( &$found_alt_gov, $found_gov ) {
                        if ( ! $found_alt_gov && ! $found_gov ) {
                            $found_alt_gov = true;
                            return $m[0];
                        }
                        return $m[1];
                    },
                    $html
                );

                $article['content_html'] = $html;
            }

            // ═══════════════════════════════════════════════════════════
            //  AUTO-FIX: Readability post-processor (Yoast SEO)
            //  Splits long sentences, paragraphs, injects H3 subheadings
            // ═══════════════════════════════════════════════════════════
            if ( ! empty( $article['content_html'] ) && class_exists( 'DBA_NewsBot_Readability' ) ) {
                $article['content_html'] = DBA_NewsBot_Readability::process( $article['content_html'] );
            }

            /* ── STEP 4: Validate ── */
            $this->log_step( $post_index, 4, 'validate', 'running', 'Running 6-check validator…' );

            $category = DBA_NewsBot_Validator::assign_category(
                $article['title'] ?? '',
                $article['content_html'] ?? ''
            );

            $val_errors = $validator->validate_individual( $article, $candidate['geo'], $category );

            if ( ! empty( $val_errors ) ) {
                $last_errors[] = 'Validation: ' . implode( '; ', $val_errors );
                $this->log_step( $post_index, 4, 'validate', 'failed', implode( ' | ', $val_errors ) );

                // Store validation errors visibly for admin panel
                update_option( 'dba_pe_last_error', array(
                    'time'    => current_time( 'mysql' ),
                    'code'    => 'validation_fail',
                    'message' => 'Validator rejected article: ' . implode( ' | ', $val_errors ),
                    'article_title' => $article['title'] ?? '(no title)',
                    'geo'     => $candidate['geo'] ?? '',
                ), false );

                $article = null;

                // Build retry hints from validation errors for next attempt
                $retry_hint = '';
                $error_str = implode( ' ', $val_errors );
                $_pe = get_option( 'dba_pe_settings', array() );
                $_wc_min = intval( $_pe['word_count_min'] ?? 1000 );
                if ( stripos( $error_str, 'Word count too low' ) !== false ) {
                    $retry_hint .= "\n\nPREVIOUS ATTEMPT REJECTED: Article was too short. You MUST write at LEAST {$_wc_min} words. Write 4-5 paragraphs per H2 section. Expand on regulatory implications, comparable transactions, market sizing, fund strategy details, and investor impact. DO NOT write a brief summary — write a comprehensive, in-depth article.";
                }
                if ( stripos( $error_str, '.gov link' ) !== false ) {
                    $retry_hint .= "\n\nPREVIOUS ATTEMPT REJECTED: Missing .gov link. You MUST wrap the first mention of the state/city in an <a> link to its official .gov website. Example: <a href=\"https://www.ny.gov\">New York</a>";
                }
                if ( stripos( $error_str, 'Company name is not linked' ) !== false ) {
                    $retry_hint .= "\n\nPREVIOUS ATTEMPT REJECTED: Company name not linked. You MUST wrap the company name in an <a href> link to its official website.";
                }
                if ( stripos( $error_str, 'not found in title' ) !== false ) {
                    $retry_hint .= "\n\nPREVIOUS ATTEMPT REJECTED: The state name '{$candidate['geo']}' MUST appear in the article title (H1). Keep the title SHORT (under 80 chars). Example format: 'Company Name – Deal Type in {$candidate['geo']}'.";
                }

                continue;
            }

            $this->log_step( $post_index, 4, 'validate', 'done', "All 6 gates passed ✓ | Category: {$category}" );
            break;
        }

        if ( ! $article ) {
            $error_msg = 'All retries exhausted: ' . implode( ' → ', $last_errors );
            $this->log_step( $post_index, 3, 'generate', 'failed', $error_msg );
            return new WP_Error( 'generation_fail', $error_msg );
        }
        } // end else (generate article when no pre_article)

        /* ── STEP 4.5: Plagiarism / Uniqueness — Gate A + Gate B ── */

        // Build final content (with CTA) for plagiarism check
        $check_content = $article['content_html'] . $this->build_cta_html();

        // Gate A: Internal Duplicate Detection (Jaccard 3-gram shingling via DBA_Plagiarism)
        $this->log_step( $post_index, 4, 'plagiarism_a', 'running', 'Gate A — Jaccard shingling against all existing posts…' );
        if ( class_exists( 'DBA_Plagiarism' ) ) {
            $plagiarism = DBA_Plagiarism::get_instance()->check( $check_content, 0 );
            if ( ! $plagiarism['pass'] ) {
                $plag_detail = implode( ' | ', $plagiarism['details'] ?? array() );
                error_log( self::LOG_PREFIX . ': GATE A BLOCKED — score ' . ( $plagiarism['score'] ?? '?' ) . '% — ' . $plag_detail );
                $this->log_step( $post_index, 4, 'plagiarism_a', 'failed', 'Score: ' . ( $plagiarism['score'] ?? '?' ) . '% — ' . $plag_detail );
                return new WP_Error( 'plagiarism_gate_a', 'Gate A rejected: ' . $plag_detail );
            }
            $this->log_step( $post_index, 4, 'plagiarism_a', 'done',
                'Uniqueness: ' . $plagiarism['score'] . '% (' . ( $plagiarism['method'] ?? 'internal' ) . ') ✓' );
        }

        // Gate B: Sentence-Level Uniqueness (compare sentences against last 30 PE NewsBot posts)
        $this->log_step( $post_index, 4, 'plagiarism_b', 'running', 'Gate B — Sentence-level check vs last 30 PE posts…' );
        $gate_b = $this->check_sentence_uniqueness( $check_content );
        if ( ! $gate_b['pass'] ) {
            error_log( self::LOG_PREFIX . ': GATE B BLOCKED — ' . $gate_b['detail'] );
            $this->log_step( $post_index, 4, 'plagiarism_b', 'failed', $gate_b['detail'] );
            return new WP_Error( 'plagiarism_gate_b', 'Gate B rejected: ' . $gate_b['detail'] );
        }
        $this->log_step( $post_index, 4, 'plagiarism_b', 'done', $gate_b['detail'] );

        /* ── STEP 5: Create WP post ── */
        $this->log_step( $post_index, 5, 'create_post', 'running', 'Inserting WordPress post…' );

        $pe_settings = get_option( 'dba_pe_settings', array() );
        $post_status = $pe_settings['post_status'] ?? 'publish';

        // ALL USA-related posts publish at 10:00 AM New York time
        // Stagger by 2 minutes per post to avoid burst-publishing
        $publish_dt = DBA_NewsBot_Scheduler::get_publish_datetime( ( $post_index - 1 ) * 2 );

        // ═══ INDUSTRY LINKER: Inject ONE internal link to relevant Damalion industry page ═══
        if ( class_exists( 'DBA_NewsBot_Industry_Linker' ) ) {
            $article['content_html'] = DBA_NewsBot_Industry_Linker::inject(
                $article['content_html'],
                $candidate['deal_type'] ?? '',
                $category
            );
        }

        // ── Link Sanitizer: strip/replace hallucinated damalion.com links ──
        if ( class_exists( 'DBA_Link_Sanitizer' ) && ! empty( $article['content_html'] ) ) {
            $article['content_html'] = DBA_Link_Sanitizer::sanitize( $article['content_html'], 'en' );
        }

        // Append CTA block to article content
        $final_content = $article['content_html'] . $this->build_cta_html();

        $post_id = wp_insert_post( array(
            'post_title'    => sanitize_text_field( $article['title'] ),
            'post_content'  => wp_kses_post( $final_content ),
            'post_excerpt'  => sanitize_text_field( $article['excerpt'] ?? '' ),
            'post_name'     => sanitize_title( $article['slug'] ?? '' ),
            'post_status'   => $post_status,
            'post_type'     => 'post',
            'post_author'   => $pe_settings['author_id'] ?? 1,
            'post_date'     => $publish_dt['post_date'],
            'post_date_gmt' => $publish_dt['post_date_gmt'],
        ), true );

        if ( is_wp_error( $post_id ) ) {
            $this->log_step( $post_index, 5, 'create_post', 'failed', $post_id->get_error_message() );
            return $post_id;
        }

        // Tag with PE meta (isolated from core DBA posts)
        update_post_meta( $post_id, self::META_KEY,      '1' );
        update_post_meta( $post_id, self::META_GEO,      $candidate['geo'] );
        update_post_meta( $post_id, self::META_COMPANY,  $candidate['company_name'] );
        update_post_meta( $post_id, self::META_CATEGORY, $category );

        // ═══ KAIZEN: Store original AI output for correction learning ═══
        if ( class_exists( 'DBA_Kaizen' ) ) {
            DBA_Kaizen::store_original(
                $post_id,
                $article['title'],
                $article['content_html'],
                $article['excerpt'] ?? '',
                $article['featured_image']['alt'] ?? ''
            );
        }

        // Assign WP category
        $this->assign_wp_category( $post_id, $category );

        // Tags (max 3): Company name, State, Deal type
        $tags = array( $candidate['company_name'], $candidate['geo'] );
        if ( ! empty( $article['deal_type'] ) ) {
            $tags[] = $article['deal_type'];
        }
        wp_set_post_tags( $post_id, array_slice( $tags, 0, 3 ), false );

        // Article schema meta
        if ( ! empty( $article['schema'] ) ) {
            update_post_meta( $post_id, '_dba_pe_schema', $article['schema'] );
        }

        $this->log_step( $post_index, 5, 'create_post', 'done', "Post #{$post_id} created" );

        /* ── STEP 6: Yoast SEO meta ── */
        $this->log_step( $post_index, 6, 'yoast_seo', 'running', 'Setting SEO meta…' );
        $this->fill_pe_yoast_seo( $post_id, $article, $candidate, $category );
        $this->log_step( $post_index, 6, 'yoast_seo', 'done', 'Yoast focus keyphrase + title + desc ✓' );

        /* ── STEP 7: Featured image — DEFERRED to separate AJAX call (Cloudflare 100s limit) ── */
        $media_id = 0;
        if ( ! empty( $article['featured_image']['prompt'] ) ) {
            // Store image data as post meta — will be generated in a separate request
            update_post_meta( $post_id, '_dba_pe_image_data', $article['featured_image'] );
            update_post_meta( $post_id, '_dba_pe_image_pending', '1' );
            $this->log_step( $post_index, 7, 'image', 'deferred', 'Image queued for separate generation' );
        } else {
            $this->log_step( $post_index, 7, 'image', 'skipped', 'No image prompt in response' );
        }

        /* ── STEP 8: Log to generation_logs table ── */
        $logger = DBA_Logger::get_instance();
        $logger->log( array(
            'run_date'          => current_time( 'Y-m-d' ),
            'run_time'          => current_time( 'mysql' ),
            'theme'             => 'PE/' . $category,
            'title'             => $article['title'],
            'slug'              => $article['slug'] ?? '',
            'post_id'           => $post_id,
            'media_id'          => $media_id,
            'language'          => 'en',
            'json_output'       => wp_json_encode( $article ),
            'validation_result' => 'PASS (6/6 gates)',
            'retry_attempts'    => 0,
            'status'            => 'published',
            'error_trace'       => '',
        ) );

        /* ── STEP 9: Published ── */
        $this->log_step( $post_index, 9, 'publish', 'done', "Published: {$article['title']}" );

        return array(
            'status'       => 'published',
            'post_id'      => $post_id,
            'title'        => $article['title'],
            'geo'          => $candidate['geo'],
            'company'      => $candidate['company_name'],
            'category'     => $category,
            'permalink'    => get_permalink( $post_id ),
            'excerpt'      => $article['excerpt'] ?? '',
            'media_id'     => $media_id,
        );
    }

    /* =================================================================
     *  NEWS CANDIDATE DISCOVERY
     * ================================================================= */

    /**
     * Find a PE/CM news candidate via OpenAI Responses API + web_search tool.
     * Uses the same Responses API pattern as the existing DBA_OpenAI::research_news().
     *
     * @param int $post_index For geo rotation.
     * @return array|WP_Error { company_name, geo, official_url, headline, context }
     */
    public function find_news_candidate( int $post_index ) {

        $settings = get_option( 'dba_settings', array() );
        $api_key  = $settings['openai_api_key'] ?? '';

        if ( empty( $api_key ) ) {
            return new WP_Error( 'no_api_key', 'OpenAI API key not configured' );
        }

        // Rotate through active geographies (1-indexed post_index maps to geo)
        $active_geos = DBA_NewsBot_Dedupe::get_active_geo_names();
        $geo_index = ( $post_index - 1 ) % count( $active_geos );
        $target_geo = $active_geos[ $geo_index ];

        // Check if this geo is already used today
        $history = DBA_NewsBot_Dedupe::get_history();
        $today   = current_time( 'Y-m-d' );
        $used_geos = isset( $history['days'][ $today ]['geos'] ) ? $history['days'][ $today ]['geos'] : array();

        $geo_slug = DBA_NewsBot_Dedupe::normalize_geo( $target_geo );
        if ( in_array( $geo_slug, $used_geos, true ) ) {
            // Find an unused geo first
            $target_geo = null;
            foreach ( $active_geos as $geo ) {
                $slug = DBA_NewsBot_Dedupe::normalize_geo( $geo );
                if ( ! in_array( $slug, $used_geos, true ) ) {
                    $target_geo = $geo;
                    break;
                }
            }
            // ALL geos used today — allow re-use with different company.
            // Dedupe still prevents same company from being repeated.
            if ( ! $target_geo ) {
                $target_geo = $active_geos[ array_rand( $active_geos ) ];
                error_log( self::LOG_PREFIX . ": All geos used today — reusing {$target_geo} with different company." );
            }
        }

        // Build search prompt for Responses API — uses dynamic geo list
        $geo_list = implode( ', ', $active_geos );
        $search_prompt = "Find one recent U.S. private equity or capital markets news item specifically tied to {$target_geo}. "
            . "The news must involve a specific company and a concrete transaction (buyout, bond issuance, IPO, acquisition, private placement, direct lending deal, or real estate PE deal). "
            . "Return ONLY valid JSON with these keys:\n"
            . "- company_name: the exact legal company name\n"
            . "- official_url: the company's official website URL\n"
            . "- geo: the state name (one of: {$geo_list})\n"
            . "- gov_url: the official .gov website for the state (e.g., https://www.ny.gov for New York)\n"
            . "- headline: a one-line summary of the transaction\n"
            . "- context: 3-5 sentences of factual detail about the deal (amounts, parties, timeline)\n"
            . "- deal_type: one of: buyout, bond, placement, direct lending, real estate, IPO, acquisition\n"
            . "\nDo NOT include any media outlet names. JSON only, no markdown fences.";

        // Exclude companies already used today to avoid dedupe rejection
        $today_companies = isset( $history['days'][ $today ]['companies'] ) ? $history['days'][ $today ]['companies'] : array();
        if ( ! empty( $today_companies ) ) {
            $exclude_list = implode( ', ', $today_companies );
            $search_prompt .= "\n\nIMPORTANT: Do NOT use any of these companies (already covered today): {$exclude_list}. Find a DIFFERENT company.";
        }

        $instructions = 'You are a financial news research assistant. Search the web for real, recent PE/capital markets transactions. Return only valid JSON. Never mention news sources, journalists, or media outlets.';

        // Call Responses API with web_search tool — same pattern as DBA_OpenAI::research_news()
        $body = array(
            'model'        => $settings['openai_model'] ?? 'gpt-4o-mini',
            'input'        => $search_prompt,
            'instructions' => $instructions,
            'tools'        => array(
                array( 'type' => 'web_search' ),
            ),
            'store'        => false,
        );

        $response = wp_remote_post( 'https://api.openai.com/v1/responses', array(
            'headers' => array(
                'Authorization' => 'Bearer ' . $api_key,
                'Content-Type'  => 'application/json',
            ),
            'body'    => wp_json_encode( $body ),
            'timeout' => 30,
        ) );

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

        $status_code = wp_remote_retrieve_response_code( $response );
        if ( $status_code !== 200 ) {
            $body_text = wp_remote_retrieve_body( $response );
            return new WP_Error( 'api_error', "Responses API returned status {$status_code}: " . substr( $body_text, 0, 500 ) );
        }

        $resp_data = json_decode( wp_remote_retrieve_body( $response ), true );

        // Extract text from Responses API output format
        $text_parts = array();
        if ( ! empty( $resp_data['output'] ) && is_array( $resp_data['output'] ) ) {
            foreach ( $resp_data['output'] as $item ) {
                if ( isset( $item['type'] ) && $item['type'] === 'message' && ! empty( $item['content'] ) ) {
                    foreach ( $item['content'] as $block ) {
                        if ( isset( $block['type'] ) && $block['type'] === 'output_text' && ! empty( $block['text'] ) ) {
                            $text_parts[] = $block['text'];
                        }
                    }
                }
            }
        }

        if ( empty( $text_parts ) ) {
            return new WP_Error( 'no_content', 'Responses API returned no text output for candidate search' );
        }

        $raw = trim( implode( "\n", $text_parts ) );

        // Clean markdown fences if present
        $raw = preg_replace( '/^```(?:json)?\s*/m', '', $raw );
        $raw = preg_replace( '/```\s*$/m', '', $raw );
        $raw = trim( $raw );

        $candidate = json_decode( $raw, true );

        // Robust fallback: strip BOM, find first {...} block, fix trailing commas
        if ( json_last_error() !== JSON_ERROR_NONE ) {
            // Strip UTF-8 BOM
            $raw = preg_replace( '/^\xEF\xBB\xBF/', '', $raw );

            // Try extracting first {...} block (OpenAI sometimes adds text before/after JSON)
            $fb = strpos( $raw, '{' );
            $lb = strrpos( $raw, '}' );
            if ( $fb !== false && $lb !== false && $lb > $fb ) {
                $json_str = substr( $raw, $fb, $lb - $fb + 1 );

                // Fix trailing commas before } or ] (common GPT mistake)
                $json_str = preg_replace( '/,\s*([\]}])/s', '$1', $json_str );

                $candidate = json_decode( $json_str, true );
            }
        }

        if ( json_last_error() !== JSON_ERROR_NONE || ! is_array( $candidate ) ) {
            error_log( self::LOG_PREFIX . ': Candidate JSON raw (first 500): ' . substr( $raw, 0, 500 ) );
            return new WP_Error( 'json_error', 'Failed to parse candidate JSON: ' . json_last_error_msg() );
        }

        // Ensure required fields
        if ( empty( $candidate['company_name'] ) || empty( $candidate['geo'] ) ) {
            return new WP_Error( 'incomplete', 'Candidate missing company_name or geo' );
        }

        return $candidate;
    }

    /* =================================================================
     *  ARTICLE GENERATION
     * ================================================================= */

    /**
     * Call OpenAI to generate a full PE/CM article from a candidate.
     *
     * Uses the Responses API (/v1/responses) — same endpoint as research step.
     * This guarantees model compatibility: if research works, article generation works.
     *
     * @since 2.6.3 — Switched from Chat Completions to Responses API for model compatibility.
     *
     * @param array $candidate { company_name, geo, official_url, headline, context, deal_type }
     * @return array|WP_Error  Parsed article data.
     */
    public function call_openai_for_article( array $candidate, string $retry_hint = '' ) {

        $settings = get_option( 'dba_settings', array() );
        $api_key  = $settings['openai_api_key'] ?? '';

        if ( empty( $api_key ) ) {
            return new WP_Error( 'no_api_key', 'OpenAI API key not configured' );
        }

        $system_prompt = $this->build_pe_system_prompt();
        $user_prompt   = $this->build_pe_user_prompt( $candidate );

        // Append retry correction hints if this is a retry after validation failure
        if ( ! empty( $retry_hint ) ) {
            $user_prompt .= $retry_hint;
        }

        // ── Use Responses API (same as research step) for guaranteed model compatibility ──
        $body = array(
            'model'             => $settings['openai_model'] ?? 'gpt-4o-mini',
            'instructions'      => $system_prompt,
            'input'             => $user_prompt,
            'max_output_tokens' => 6500,
            'store'             => false,
        );

        // Add temperature only for models that support it (not o-series reasoning models)
        $model = $body['model'];
        if ( stripos( $model, 'gpt-' ) === 0 ) {
            $body['temperature'] = 0.35;
        }

        $response = wp_remote_post( 'https://api.openai.com/v1/responses', array(
            'headers' => array(
                'Authorization' => 'Bearer ' . $api_key,
                'Content-Type'  => 'application/json',
            ),
            'body'    => wp_json_encode( $body ),
            'timeout' => 90,
        ) );

        if ( is_wp_error( $response ) ) {
            update_option( 'dba_pe_last_error', array( 'time' => current_time( 'mysql' ), 'code' => 'network', 'message' => $response->get_error_message() ), false );
            return $response;
        }

        $status_code = wp_remote_retrieve_response_code( $response );
        $raw_body    = wp_remote_retrieve_body( $response );
        $resp_data   = json_decode( $raw_body, true );

        // Store raw response for admin debug panel
        update_option( 'dba_pe_debug_response', array(
            'time'         => current_time( 'mysql' ),
            'http_status'  => $status_code,
            'model'        => $body['model'],
            'api_status'   => $resp_data['status'] ?? 'unknown',
            'body_preview' => substr( $raw_body, 0, 2000 ),
        ), false );

        // ── Catch HTTP errors (429 rate limit, 402 billing, 404 model not found, etc.) ──
        if ( $status_code !== 200 ) {
            $api_error = $resp_data['error']['message']
                ?? ( $resp_data['error']['type'] ?? 'Unknown API error' );
            error_log( self::LOG_PREFIX . ": Responses API HTTP {$status_code} — {$api_error}" );
            update_option( 'dba_pe_last_error', array( 'time' => current_time( 'mysql' ), 'code' => 'http_' . $status_code, 'message' => "HTTP {$status_code}: {$api_error}" ), false );
            return new WP_Error( 'api_http_' . $status_code,
                "OpenAI API error (HTTP {$status_code}): {$api_error}" );
        }

        // ── Extract text from Responses API output format ──
        $text_parts = array();
        if ( ! empty( $resp_data['output'] ) && is_array( $resp_data['output'] ) ) {
            foreach ( $resp_data['output'] as $item ) {
                if ( ( $item['type'] ?? '' ) === 'message' && ! empty( $item['content'] ) ) {
                    foreach ( $item['content'] as $block ) {
                        if ( ( $block['type'] ?? '' ) === 'output_text' && ! empty( $block['text'] ) ) {
                            $text_parts[] = $block['text'];
                        }
                    }
                }
            }
        }

        if ( empty( $text_parts ) ) {
            $output_types = array();
            if ( ! empty( $resp_data['output'] ) ) { foreach ( $resp_data['output'] as $item ) { $output_types[] = $item['type'] ?? 'none'; } }
            $err = 'No output_text blocks. Status=' . ( $resp_data['status'] ?? '?' ) . '. Output types: [' . implode( ',', $output_types ) . ']';
            error_log( self::LOG_PREFIX . ': ' . $err . '. Body: ' . substr( $raw_body, 0, 500 ) );
            update_option( 'dba_pe_last_error', array( 'time' => current_time( 'mysql' ), 'code' => 'no_text', 'message' => $err ), false );
            return new WP_Error( 'no_content', $err );
        }

        $raw = trim( implode( "\n", $text_parts ) );

        // Clean markdown fences if present
        $raw = preg_replace( '/^```(?:json)?\s*/m', '', $raw );
        $raw = preg_replace( '/```\s*$/m', '', $raw );
        $raw = trim( $raw );

        // Robust JSON extraction: strip BOM, try direct parse, then find first {...} block
        $raw = preg_replace( '/^\xEF\xBB\xBF/', '', $raw ); // Strip UTF-8 BOM
        $data = json_decode( $raw, true );
        if ( json_last_error() !== JSON_ERROR_NONE ) {
            $fb = strpos( $raw, '{' );
            $lb = strrpos( $raw, '}' );
            if ( $fb !== false && $lb !== false && $lb > $fb ) {
                $json_str = substr( $raw, $fb, $lb - $fb + 1 );
                // Fix trailing commas before } or ] (common GPT mistake)
                $json_str = preg_replace( '/,\s*([\]}])/s', '$1', $json_str );
                $data = json_decode( $json_str, true );
            }
        }

        if ( json_last_error() !== JSON_ERROR_NONE || ! is_array( $data ) ) {
            $err = 'JSON parse failed: ' . json_last_error_msg() . ' | Raw (first 500): ' . substr( $raw, 0, 500 );
            error_log( self::LOG_PREFIX . ': ' . $err );
            update_option( 'dba_pe_last_error', array( 'time' => current_time( 'mysql' ), 'code' => 'json_fail', 'message' => $err ), false );
            return new WP_Error( 'json_error', $err );
        }

        // Ensure minimum required keys
        $required = array( 'title', 'slug', 'excerpt', 'content_html' );
        foreach ( $required as $key ) {
            if ( empty( $data[ $key ] ) ) {
                return new WP_Error( 'missing_key', "Article missing required key: {$key}" );
            }
        }

        // Carry forward candidate data
        $data['company_name'] = $candidate['company_name'];
        $data['official_url'] = $candidate['official_url'] ?? '';
        $data['deal_type']    = $candidate['deal_type'] ?? '';

        return $data;
    }

    /* =================================================================
     *  PE-SPECIFIC PROMPTS — Completely separate from core Damalion prompts
     * ================================================================= */

    /**
     * Build the CTA HTML block from dashboard settings.
     * Returns empty string if CTA is disabled.
     */
    private function build_cta_html() {
        $pe_settings = get_option( 'dba_pe_settings', array() );

        if ( empty( $pe_settings['cta_enabled'] ?? true ) ) {
            return '';
        }

        $text      = esc_html( $pe_settings['cta_text'] ?? 'Damalion helps international family offices, entrepreneurs, private equity and venture capital firms identify opportunities to achieve their goals.' );
        $link_text = esc_html( $pe_settings['cta_link_text'] ?? 'Contact your Damalion experts now.' );
        $link_url  = esc_url( $pe_settings['cta_link_url'] ?? 'https://www.damalion.com/lets-connect/' );

        $cta  = "\n\n<div class=\"damalion-cta\" style=\"margin-top:2em;padding:1.5em;background:#f8f9fa;border-left:4px solid #0056b3;border-radius:4px;\">\n";
        $cta .= "<p>{$text}</p>\n";
        $cta .= "<p><a href=\"{$link_url}\">{$link_text}</a></p>\n";
        $cta .= "</div>\n";

        return $cta;
    }

    /**
     * System prompt for PE/CM news articles.
     * Implements the System Prompt + Guardrails from the instructions doc.
     */
    public function build_pe_system_prompt() {

        $pe_settings = get_option( 'dba_pe_settings', array() );
        $active_geos = DBA_NewsBot_Dedupe::get_active_geo_names();
        $geo_list    = implode( ', ', $active_geos );

        // Dynamic word count from dashboard
        $wc_min = intval( $pe_settings['word_count_min'] ?? 1000 );
        $wc_max = intval( $pe_settings['word_count_max'] ?? 1800 );
        $wc_target = round( ( $wc_min + $wc_max ) / 2 );

        // ═══ KAIZEN: Learn from editor corrections ═══
        $kaizen_context = '';
        if ( class_exists( 'DBA_Kaizen' ) ) {
            $kaizen_context = DBA_Kaizen::get_learning_context();
        }

        $prompt  = "You are an AI news publishing agent specialized in U.S. private equity and capital markets.\n\n";
        $prompt .= "Your sole task is to generate SEO-optimized, investor-grade news articles.\n\n";

        $prompt .= "GEOGRAPHY SCOPE (mandatory, limited to):\n";
        $prompt .= "{$geo_list}\n\n";

        $prompt .= "TONE & STYLE:\n";
        $prompt .= "- Neutral, institutional, professional, investor-facing, non-promotional\n";
        $prompt .= "- No media outlets, sources, journalists, or 'according to' phrasing\n";
        $prompt .= "- No speculation, no marketing language\n\n";

        $prompt .= "STRICT RULES — VIOLATION = REJECTION:\n";
        $prompt .= "1. ONE company per article\n";
        $prompt .= "2. ONE geography (state or city+state) per article\n";
        $prompt .= "3. Company name MUST be wrapped in an <a> link to its REAL official website on FIRST mention ONLY. All subsequent mentions must be plain text (NO link). Do NOT repeat the <a> tag.\n";
        $prompt .= "4. First mention of the state or city MUST be wrapped in an <a> link to its official .gov website on FIRST mention ONLY. All subsequent mentions must be plain text. USE THESE EXACT URLs:\n";
        $prompt .= "   - Arizona: <a href=\"https://az.gov\">Arizona</a>\n";
        $prompt .= "   - California: <a href=\"https://www.ca.gov\">California</a>\n";
        $prompt .= "   - Colorado: <a href=\"https://www.colorado.gov\">Colorado</a>\n";
        $prompt .= "   - Florida: <a href=\"https://www.myflorida.com\">Florida</a>\n";
        $prompt .= "   - New Hampshire: <a href=\"https://www.nh.gov\">New Hampshire</a>\n";
        $prompt .= "   - New York: <a href=\"https://www.ny.gov\">New York</a>\n";
        $prompt .= "   - Texas: <a href=\"https://www.texas.gov\">Texas</a>\n";
        $prompt .= "5. No other external links allowed besides the ONE company link and the ONE .gov link\n";
        $prompt .= "6. No news outlet names, no 'according to', no analyst/journalist references\n";
        $prompt .= "7. No 'Last updated' dates\n";
        $prompt .= "8. MINIMUM WORD COUNT: {$wc_min} words. Articles below {$wc_min} words WILL BE REJECTED. Target {$wc_target} words. Write detailed, substantive paragraphs under each H2. Do NOT write a short summary — write a FULL article.\n\n";

        $prompt .= "MANDATORY H1 FORMAT:\n";
        $prompt .= "[Company Name] – [Transaction / Market Event] in [State or City, State]\n";
        $prompt .= "TITLE LENGTH: MAXIMUM 80 characters. Keep the H1 title SHORT and concise. Abbreviate where possible (e.g., '$370M' not '$370 Million', 'IPO' not 'Initial Public Offering'). Titles over 80 characters WILL BE REJECTED.\n\n";

        // Subtitle requirement (configurable from dashboard)
        if ( ! empty( $pe_settings['require_subtitle'] ?? true ) ) {
            $prompt .= "MANDATORY SUBTITLE:\n";
            $prompt .= "Immediately after the H1, include a subtitle as <p class=\"subtitle\">...</p>.\n";
            $prompt .= "The subtitle should be 15-25 words summarizing the transaction's significance for investors.\n";
            $prompt .= "Example: <p class=\"subtitle\">A strategic acquisition strengthening the firm's mid-market portfolio across the Southeastern United States.</p>\n\n";
        }

        $prompt .= "MANDATORY H2 SECTIONS (in this order):\n";
        $prompt .= "## Transaction overview\n";
        $prompt .= "## Investor and capital markets context\n";
        $prompt .= "## Market implications\n";
        $prompt .= "OPTIONAL (only if genuinely relevant):\n";
        $prompt .= "## Why this matters for investors\n\n";

        $prompt .= "INTRO (first paragraph, 40-60 words): Must include company name (linked to official website), location (linked to .gov website), and a market keyword from the allowed categories.\n\n";

        // Dynamic category list from dashboard settings (built-in + custom)
        $all_categories = DBA_NewsBot_Validator::get_allowed_categories();
        $cat_list = implode( ', ', $all_categories );

        $prompt .= "ALLOWED CATEGORIES:\n";
        $prompt .= "{$cat_list}\n\n";

        $prompt .= "LINKING RULES:\n";
        $prompt .= "- Company name: link to official company website URL (provided in the candidate data). Link ONLY the first mention.\n";
        $prompt .= "- State/city name: link to official .gov website. Link ONLY the first mention.\n";
        $prompt .= "- Do NOT link any other text. No media links, no Wikipedia links, no other external links.\n\n";

        $prompt .= "OUTPUT: Valid JSON only. No markdown fences. No explanations.\n";
        $prompt .= "Required JSON keys:\n";
        $prompt .= "- title (the H1)\n";
        $prompt .= "- subtitle (15-25 word summary for readability and SEO)\n";
        $prompt .= "- slug (URL-friendly)\n";
        $prompt .= "- excerpt (1-2 sentence summary, max 160 chars)\n";
        $prompt .= "- content_html (full article HTML with subtitle, H2s, company link, and .gov link)\n";
        $prompt .= "- gov_url (the official .gov URL used for the state/city link)\n";
        $prompt .= "- schema (Article schema JSON object with articleSection, about)\n";
        $prompt .= "- tags (array of max 3: company name, state, deal type)\n";
        $prompt .= "- deal_type (one of: buyout, bond, placement, direct lending, real estate, IPO, acquisition)\n";
        $prompt .= "- category (one of: {$cat_list})\n";
        $prompt .= "- featured_image: { \"prompt\": \"DALL-E image prompt (editorial documentary photo, no text/logos)\", \"filename\": \"slug-image.webp\", \"alt\": \"SEO alt text based on ARTICLE HEADLINE — NOT a description of the image\", \"title\": \"image title matching article\", \"caption\": \"one-sentence article summary\", \"description\": \"short article summary — NOT DALL-E prompt\" }\n";
        $prompt .= "- yoast_seo: { \"focus_keyphrase\": \"primary SEO phrase\", \"seo_title\": \"SEO title max 60 chars\", \"meta_description\": \"meta description max 155 chars\" }\n";

        // ═══ READABILITY: Yoast SEO compliance rules ═══
        if ( class_exists( 'DBA_NewsBot_Readability' ) ) {
            $prompt .= DBA_NewsBot_Readability::get_prompt_rules();
        }

        // ═══ KAIZEN: Append editor correction patterns ═══
        if ( ! empty( $kaizen_context ) ) {
            $prompt .= $kaizen_context;
        }

        return $prompt;
    }

    /**
     * User prompt for a specific PE/CM article.
     */
    public function build_pe_user_prompt( array $candidate ) {

        $pe_settings = get_option( 'dba_pe_settings', array() );
        $wc_min = intval( $pe_settings['word_count_min'] ?? 1000 );
        $wc_max = intval( $pe_settings['word_count_max'] ?? 1800 );
        $wc_target = round( ( $wc_min + $wc_max ) / 2 );

        $company = $candidate['company_name'];
        $geo     = $candidate['geo'];
        $url     = $candidate['official_url'] ?? '';
        $headline = $candidate['headline'] ?? '';
        $context = $candidate['context'] ?? '';
        $deal    = $candidate['deal_type'] ?? '';

        $prompt  = "Write a {$wc_min}-{$wc_max} word investor-grade news article about this transaction:\n\n";
        $prompt .= "COMPANY: {$company}\n";
        $prompt .= "OFFICIAL WEBSITE: {$url}\n";
        $prompt .= "GEOGRAPHY: {$geo}\n";
        $prompt .= "HEADLINE: {$headline}\n";
        $prompt .= "DEAL TYPE: {$deal}\n";
        $prompt .= "CONTEXT:\n{$context}\n\n";

        $prompt .= "REQUIREMENTS:\n";
        $prompt .= "1. H1 must follow format: {$company} – [Transaction] in {$geo} — MAXIMUM 80 CHARACTERS. Abbreviate amounts (e.g., '$370M'). Shorten company name if needed.\n";
        $prompt .= "2. Include a subtitle (<p class=\"subtitle\">) immediately after H1 — 15-25 words summarizing significance\n";
        $prompt .= "3. Company name '{$company}' must link to EXACTLY this URL on FIRST mention ONLY: {$url} — do NOT link subsequent mentions, use plain text.\n";
        $prompt .= "4. First mention of '{$geo}' must link to its official .gov website ONCE ONLY — do NOT link subsequent mentions.\n";
        $prompt .= "5. Include exactly these H2 sections: Transaction overview, Investor and capital markets context, Market implications\n";
        $prompt .= "6. First paragraph must mention {$company} (linked), {$geo} (linked to .gov), and a market keyword\n";
        $prompt .= "7. No media sources, no 'according to', no speculation\n";
        $prompt .= "8. Include featured_image object with DALL-E prompt (documentary editorial photo, NO text/logos)\n";
        $prompt .= "   - alt: Use the ARTICLE TITLE (or a rephrase of it) as alt text. Do NOT describe what the image shows.\n";
        $prompt .= "   - description: Write a SHORT article summary (one sentence). Do NOT repeat the DALL-E prompt.\n";
        $prompt .= "   - caption: One sentence summarizing the transaction.\n";
        $prompt .= "9. Include yoast_seo object with focus_keyphrase, seo_title (max 60 chars), meta_description (max 155 chars)\n";
        $prompt .= "10. Return valid JSON only\n";
        $prompt .= "11. TOTAL LINKS ALLOWED: Exactly 2 — one company link and one .gov link. No more.\n\n";
        $prompt .= "CRITICAL — WORD COUNT: Your article content_html MUST be at least {$wc_min} words. Write {$wc_target} words. Each H2 section must have at least 3-4 detailed paragraphs. Cover regulatory context, market sizing, comparable transactions, and strategic implications in depth. SHORT ARTICLES WILL BE REJECTED.\n";

        // Readability reminder (reinforces system prompt rules)
        $prompt .= "\nREADABILITY (Yoast compliance):\n";
        $prompt .= "- Keep MOST sentences under 20 words. Vary length. Use short punchy sentences between longer ones.\n";
        $prompt .= "- Paragraphs: max 4 sentences or ~100 words each. Break long paragraphs.\n";
        $prompt .= "- Add H3 sub-sections inside each H2 if the section exceeds 250 words.\n";
        $prompt .= "- Use ACTIVE VOICE. Not 'The deal was closed by X' but 'X closed the deal'.\n";
        $prompt .= "- Use transition words: however, therefore, moreover, additionally, consequently, as a result, furthermore, in particular.\n";

        return $prompt;
    }

    /* =================================================================
     *  ROUNDUP HUB POST
     * ================================================================= */

    /**
     * Create the daily roundup hub post.
     *
     * @param array $published_posts Array of result arrays from individual post creation.
     * @return int|WP_Error          Post ID on success.
     */
    private function create_roundup_post( array $published_posts ) {

        $pe_settings = get_option( 'dba_pe_settings', array() );

        // Build roundup content — always New York timezone for US content
        $tz = DBA_NewsBot_Scheduler::PUBLISH_TZ;
        try {
            $now = new DateTime( 'now', new DateTimeZone( $tz ) );
            $date_display = $now->format( 'l, F j, Y' );
        } catch ( Exception $e ) {
            $date_display = date( 'l, F j, Y' );
        }

        $title = "U.S. Private Equity & Capital Markets News – {$date_display}";

        // Index published posts by geography
        $by_geo = array();
        foreach ( $published_posts as $post ) {
            $geo_key = DBA_NewsBot_Dedupe::normalize_geo( $post['geo'] ?? '' );
            $by_geo[ $geo_key ] = $post;
        }

        // Build HTML — only include geographies that have published articles
        $html = '';
        $active_geos = DBA_NewsBot_Dedupe::get_active_geo_names();
        foreach ( $active_geos as $geo ) {
            $geo_key = DBA_NewsBot_Dedupe::normalize_geo( $geo );

            // Skip geos with no article — no "No qualifying update" filler
            if ( ! isset( $by_geo[ $geo_key ] ) ) {
                continue;
            }

            $html .= "<h2>{$geo}</h2>\n";
            $p = $by_geo[ $geo_key ];
            $company = esc_html( $p['company'] ?? '' );
            $link    = esc_url( $p['permalink'] ?? '' );
            $cat     = esc_html( $p['category'] ?? '' );
            $excerpt = esc_html( $p['excerpt'] ?? '' );
            $title_text = esc_html( $p['title'] ?? '' );

            $html .= "<p><strong>{$company}</strong> ({$cat}) — {$title_text}.";
            if ( $excerpt ) {
                $html .= " {$excerpt}";
            }
            $html .= " <a href=\"{$link}\">Read the full article →</a></p>\n";
        }

        // Roundup publishes at 10:00 AM NY + 15 min offset (after individual posts)
        $publish_dt = DBA_NewsBot_Scheduler::get_publish_datetime( 15 );

        // Append CTA block to roundup
        $html .= $this->build_cta_html();

        // Insert WP post
        $post_id = wp_insert_post( array(
            'post_title'    => sanitize_text_field( $title ),
            'post_content'  => wp_kses_post( $html ),
            'post_excerpt'  => "Daily roundup of U.S. private equity and capital markets news for {$date_display}.",
            'post_name'     => sanitize_title( 'us-pe-capital-markets-news-' . current_time( 'Y-m-d' ) ),
            'post_status'   => $pe_settings['post_status'] ?? 'publish',
            'post_type'     => 'post',
            'post_author'   => $pe_settings['author_id'] ?? 1,
            'post_date'     => $publish_dt['post_date'],
            'post_date_gmt' => $publish_dt['post_date_gmt'],
        ), true );

        if ( is_wp_error( $post_id ) ) return $post_id;

        // Tag as PE roundup
        update_post_meta( $post_id, self::META_KEY, '1' );
        update_post_meta( $post_id, '_dba_pe_is_roundup', '1' );

        // Assign NEWS category
        $this->assign_wp_category( $post_id, 'News' );

        // ═══════════════════════════════════════════════════════════
        //  FEATURED IMAGE: Reuse from first individual article
        // ═══════════════════════════════════════════════════════════
        $roundup_thumb_set = false;
        foreach ( $published_posts as $p ) {
            $child_post_id = $p['post_id'] ?? 0;
            if ( $child_post_id && has_post_thumbnail( $child_post_id ) ) {
                $thumb_id = get_post_thumbnail_id( $child_post_id );
                if ( $thumb_id ) {
                    set_post_thumbnail( $post_id, $thumb_id );
                    $roundup_thumb_set = true;
                    error_log( self::LOG_PREFIX . ': Roundup featured image set from post #' . $child_post_id );
                    break;
                }
            }
        }

        if ( ! $roundup_thumb_set ) {
            error_log( self::LOG_PREFIX . ': WARNING — No featured image available for roundup' );
        }

        // ═══════════════════════════════════════════════════════════
        //  YOAST SEO for roundup post
        // ═══════════════════════════════════════════════════════════
        $seo_title = "U.S. PE & Capital Markets News – {$date_display}";
        $seo_title = mb_substr( $seo_title, 0, 60 );

        $meta_desc = "Daily roundup of U.S. private equity and capital markets news for {$date_display}. Coverage across ";
        $geo_names = array();
        foreach ( $published_posts as $p ) {
            if ( ! empty( $p['geo'] ) && ! in_array( $p['geo'], $geo_names ) ) {
                $geo_names[] = $p['geo'];
            }
        }
        $meta_desc .= implode( ', ', array_slice( $geo_names, 0, 5 ) ) . '.';
        $meta_desc = mb_substr( $meta_desc, 0, 155 );

        $focus_kw = 'U.S. private equity capital markets news ' . current_time( 'F Y' );

        update_post_meta( $post_id, '_yoast_wpseo_focuskw',              sanitize_text_field( $focus_kw ) );
        update_post_meta( $post_id, '_yoast_wpseo_title',                sanitize_text_field( $seo_title ) );
        update_post_meta( $post_id, '_yoast_wpseo_metadesc',             sanitize_text_field( $meta_desc ) );
        update_post_meta( $post_id, '_yoast_wpseo_opengraph-title',       sanitize_text_field( $seo_title ) );
        update_post_meta( $post_id, '_yoast_wpseo_opengraph-description', sanitize_text_field( $meta_desc ) );
        update_post_meta( $post_id, '_yoast_wpseo_twitter-title',         sanitize_text_field( $seo_title ) );
        update_post_meta( $post_id, '_yoast_wpseo_twitter-description',   sanitize_text_field( $meta_desc ) );

        return $post_id;
    }

    /* =================================================================
     *  HELPERS
     * ================================================================= */

    /**
     * Assign a WP category by name, creating it if it doesn't exist.
     */
    private function assign_wp_category( int $post_id, string $category_name ) {
        $term = term_exists( $category_name, 'category' );
        if ( ! $term ) {
            $term = wp_insert_term( $category_name, 'category' );
        }
        if ( ! is_wp_error( $term ) ) {
            $term_id = is_array( $term ) ? $term['term_id'] : $term;
            wp_set_post_categories( $post_id, array( intval( $term_id ) ), false );
        }
    }

    /**
     * Fill Yoast SEO meta for a PE post — mirrors core DBA_Post_Creator::fill_yoast_seo()
     */
    private function fill_pe_yoast_seo( $post_id, $article, $candidate, $category ) {
        $yoast = isset( $article['yoast_seo'] ) ? $article['yoast_seo'] : array();

        $focus     = isset( $yoast['focus_keyphrase'] ) ? $yoast['focus_keyphrase'] : '';
        $seo_title = isset( $yoast['seo_title'] ) ? $yoast['seo_title'] : '';
        $meta_desc = isset( $yoast['meta_description'] ) ? $yoast['meta_description'] : '';

        // Fallbacks if OpenAI didn't return them
        if ( empty( $focus ) ) {
            $focus = $candidate['company_name'] . ' ' . $candidate['geo'] . ' ' . strtolower( $category );
        }
        if ( empty( $seo_title ) ) {
            $seo_title = mb_substr( $article['title'], 0, 60 );
        }
        if ( empty( $meta_desc ) ) {
            $meta_desc = isset( $article['excerpt'] ) ? mb_substr( $article['excerpt'], 0, 155 ) : '';
        }

        // Yoast SEO fields — same keys as core plugin
        update_post_meta( $post_id, '_yoast_wpseo_focuskw',              sanitize_text_field( $focus ) );
        update_post_meta( $post_id, '_yoast_wpseo_title',                sanitize_text_field( $seo_title ) );
        update_post_meta( $post_id, '_yoast_wpseo_metadesc',             sanitize_text_field( $meta_desc ) );

        // OpenGraph
        update_post_meta( $post_id, '_yoast_wpseo_opengraph-title',       sanitize_text_field( $seo_title ) );
        update_post_meta( $post_id, '_yoast_wpseo_opengraph-description', sanitize_text_field( $meta_desc ) );

        // Twitter
        update_post_meta( $post_id, '_yoast_wpseo_twitter-title',         sanitize_text_field( $seo_title ) );
        update_post_meta( $post_id, '_yoast_wpseo_twitter-description',   sanitize_text_field( $meta_desc ) );
    }

    /* =================================================================
     *  Gate B: Sentence-Level Uniqueness
     *  Extracts sentences (>30 chars) from new content, compares each against
     *  the last 30 PE NewsBot posts. If a sentence matches at 90%+
     *  (similar_text), it's flagged. If >15% of sentences flagged → REJECT.
     * ================================================================= */

    private function check_sentence_uniqueness( $new_content_html ) {
        global $wpdb;
        $result = array( 'pass' => true, 'detail' => '' );

        $new_plain = wp_strip_all_tags( $new_content_html );
        // Remove CTA boilerplate before comparing
        $new_plain = preg_replace( '/Damalion\s+(helps|supports|assists).*$/si', '', $new_plain );

        $new_sentences = $this->extract_sentences_for_gate_b( $new_plain );
        if ( count( $new_sentences ) < 5 ) {
            $result['detail'] = 'Too few sentences to check (' . count( $new_sentences ) . ')';
            return $result;
        }

        // Get last 30 PE NewsBot posts' content (uses _dba_pe_auto_created — isolated from other bots)
        $existing = $wpdb->get_col(
            "SELECT p.post_content
             FROM {$wpdb->posts} p
             INNER JOIN {$wpdb->postmeta} pm ON pm.post_id = p.ID AND pm.meta_key = '_dba_pe_auto_created'
             LEFT JOIN {$wpdb->postmeta} ru ON ru.post_id = p.ID AND ru.meta_key = '_dba_pe_is_roundup'
             WHERE p.post_status IN ('publish','draft','future')
               AND ( ru.meta_value IS NULL OR ru.meta_value != '1' )
             ORDER BY p.post_date DESC
             LIMIT 30"
        );

        if ( empty( $existing ) ) {
            $result['detail'] = 'No existing PE posts to compare (first post).';
            return $result;
        }

        // Build a pool of existing sentences
        $existing_sentences = array();
        foreach ( $existing as $ex_html ) {
            $ex_plain = wp_strip_all_tags( $ex_html );
            $ex_plain = preg_replace( '/Damalion\s+(helps|supports|assists).*$/si', '', $ex_plain );
            foreach ( $this->extract_sentences_for_gate_b( $ex_plain ) as $es ) {
                $existing_sentences[] = mb_strtolower( $es );
            }
        }

        if ( empty( $existing_sentences ) ) {
            $result['detail'] = 'No comparable sentences found in existing posts.';
            return $result;
        }

        $flagged = 0;
        $checked = count( $new_sentences );

        foreach ( $new_sentences as $ns ) {
            $ns_lower = mb_strtolower( $ns );
            foreach ( $existing_sentences as $es ) {
                similar_text( $ns_lower, $es, $pct );
                if ( $pct >= 90.0 ) {
                    $flagged++;
                    break;
                }
            }
        }

        $dup_ratio = $checked > 0 ? $flagged / $checked : 0;

        if ( $dup_ratio > 0.15 ) {
            $result['pass'] = false;
            $result['detail'] = sprintf(
                '%d/%d sentences (%.0f%%) match existing PE posts at 90%%+ similarity. Max allowed: 15%%.',
                $flagged, $checked, $dup_ratio * 100
            );
        } else {
            $result['detail'] = sprintf(
                '%d/%d sentences flagged (%.0f%%) — below 15%% threshold. OK.',
                $flagged, $checked, $dup_ratio * 100
            );
        }

        return $result;
    }

    /**
     * Extract meaningful sentences (>30 chars, 6+ words) from plain text for Gate B.
     */
    private function extract_sentences_for_gate_b( $plain_text ) {
        $raw = preg_split( '/(?<=[.!?])\s+/', trim( $plain_text ), -1, PREG_SPLIT_NO_EMPTY );
        $out = array();
        foreach ( $raw as $s ) {
            $s = trim( $s );
            if ( mb_strlen( $s ) > 30 && str_word_count( $s ) >= 6 ) {
                $out[] = $s;
            }
        }
        return $out;
    }
}
