<?php
/**
 * DBA Post Creator v2 — Step-by-step pipeline with live progress tracking
 *
 * PIPELINE (12 steps per post):
 *   1. select_theme        — Pick theme + contextual satellite links
 *   2. generate_content     — Call OpenAI for article JSON
 *   3. validate_content     — Run 11-point regex validator
 *   4. check_plagiarism     — Internal + external plagiarism scan
 *   5. create_draft         — Insert WP post (draft)
 *   6. install_links        — Verify pillar + inbound links in body text
 *   7. generate_image       — Call DALL-E 3 for featured image
 *   8. upload_image         — Upload to Media Library + fill alt/title/caption/description
 *   9. set_featured_image   — Attach thumbnail to post
 *  10. fill_yoast_seo       — Write focus keyphrase, SEO title, meta description
 *  11. translate            — Translate to additional languages (if configured)
 *  12. publish              — Set final status (publish / future / draft)
 *
 * Progress is stored in a transient so the admin UI can poll it for a live progress bar.
 */
class DBA_Post_Creator {

    private static $instance = null;
    private $max_retries = 3;

    const PROGRESS_KEY = 'dba_generation_progress';
    const TOTAL_STEPS  = 12;

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

    private function __construct() {
        add_action( 'wp_ajax_dba_generate_single',    array( $this, 'ajax_generate_single' ) );
        add_action( 'wp_ajax_dba_generate_batch',     array( $this, 'ajax_generate_batch' ) );
        add_action( 'wp_ajax_dba_get_progress',       array( $this, 'ajax_get_progress' ) );
        add_action( 'wp_ajax_dba_regenerate_post',    array( $this, 'ajax_regenerate_post' ) );
    }

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

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

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

    /**
     * Record a step update. step_number is 1-12.
     */
    private function step( $post_index, $step_number, $step_name, $status, $detail = '' ) {
        $progress = $this->get_progress_data();

        $progress['current_post']       = $post_index;
        $progress['current_step']       = $step_number;
        $progress['current_step_name']  = $step_name;
        $progress['current_status']     = $status;   // running | done | failed | warning | skipped
        $progress['current_detail']     = $detail;
        $progress['updated']            = time();

        // Percentage for the current post
        $progress['step_percent'] = round( ( $step_number / self::TOTAL_STEPS ) * 100 );

        // Overall percentage across all posts
        $total   = max( 1, intval( $progress['total_posts'] ?? 1 ) );
        $done    = intval( $progress['completed'] ?? 0 ) + intval( $progress['failed'] ?? 0 );
        $current = ( $step_number / self::TOTAL_STEPS );
        $progress['overall_percent'] = round( ( ( $done + $current ) / $total ) * 100 );

        // Append to step log
        if ( ! isset( $progress['steps_log'] ) ) {
            $progress['steps_log'] = array();
        }
        $progress['steps_log'][] = array(
            'post'   => $post_index,
            'step'   => $step_number,
            'name'   => $step_name,
            'status' => $status,
            'detail' => $detail,
            'time'   => current_time( 'H:i:s' ),
        );

        $this->set_progress( $progress );
    }

    /* =================================================================
     *  FULL PIPELINE — single post
     * ================================================================= */

    public function create_single_post( $theme = '', $subject = '', $language = 'en', $schedule_time = '', $post_index = 1 ) {

        $openai    = DBA_OpenAI::get_instance();
        $validator = DBA_Validator::get_instance();
        $sitemap   = DBA_Sitemap::get_instance();
        $img_gen   = DBA_Image_Generator::get_instance();
        $logger    = DBA_Logger::get_instance();
        $plagiarism = DBA_Plagiarism::get_instance();

        $settings         = get_option( 'dba_settings', array() );
        $content_settings = get_option( 'dba_content_settings', array() );
        $pillar_url       = $content_settings['pillar_url'] ?? 'https://www.damalion.com/blog/';

        // ── AUTO-SCAN: Ensure page registry is fresh before generating ──
        // Runs incremental scan if last scan > 24h ago. Takes seconds.
        $sitemap->ensure_fresh_registry();

        // Fallback: pick subject from settings if empty
        if ( empty( $subject ) ) {
            $subjects_list = $content_settings['subjects'] ?? array();
            $subject = ! empty( $subjects_list ) ? $subjects_list[ array_rand( $subjects_list ) ] : 'Luxembourg holding company';
        }

        // ── SUBJECT DEDUP GUARD (early exit) ──────────────────────────────
        // If this subject was already posted today, abort BEFORE calling OpenAI.
        // Saves API credits and prevents duplicate content / Google penalties.
        if ( function_exists( 'dba_subject_already_posted_today' ) && dba_subject_already_posted_today( $subject ) ) {
            error_log( 'DBA: BLOCKED create_single_post — subject "' . $subject . '" already posted today.' );
            return new WP_Error( 'duplicate_subject', 'Subject "' . $subject . '" was already posted today. Skipped to avoid keyword stuffing.' );
        }

        /* ── STEP 1 — Select theme + satellite links ────────────── */
        $this->step( $post_index, 1, 'select_theme', 'running', 'Picking theme and satellite links…' );

        // ── AUTO-REFRESH REGISTRY before link selection ──
        // Runs incremental scan if registry is stale (>24h) or missing.
        try {
            $sitemap->ensure_fresh_registry();
        } catch ( \Throwable $e ) {
            error_log( 'DBA: Registry auto-refresh failed (non-fatal): ' . $e->getMessage() );
        }

        // Detect URL-strict mode: when a news URL or headline is provided,
        // the news drives everything — skip random theme selection.
        $news_headline_peek = trim( $content_settings['news_headline'] ?? '' );
        $news_url_peek      = trim( $content_settings['news_url'] ?? '' );
        $is_url_strict_mode = ( ! empty( $news_headline_peek ) || ! empty( $news_url_peek ) );

        if ( $is_url_strict_mode ) {
            // In URL-strict mode, we DON'T pick a random theme.
            // Theme will be derived from the news research context (set below in Step 1.5).
            // For now use a placeholder — it gets overridden after research.
            if ( empty( $theme ) ) {
                $theme = 'News Analysis';
                error_log( 'DBA: URL-strict mode — skipping random theme selection, news will drive the article.' );
            }
        } elseif ( empty( $theme ) ) {
            $used_today = $logger->get_today_themes();
            $themes     = $openai->pick_themes( 1, $used_today );
            $theme      = $themes[0];
        }

        $existing_slugs  = $logger->get_today_slugs();

        // Try ONE quick sitemap refresh if registry is empty — never block generation
        $full_registry = $sitemap->get_full_registry();
        if ( empty( $full_registry ) ) {
            $this->step( $post_index, 1, 'select_theme', 'running', 'Link registry empty — attempting quick refresh…' );
            try {
                $sitemap->refresh_registry();
            } catch ( \Throwable $e ) {
                error_log( 'DBA: Sitemap auto-refresh failed: ' . $e->getMessage() );
            }
            $full_registry = $sitemap->get_full_registry();
            if ( empty( $full_registry ) ) {
                $this->step( $post_index, 1, 'select_theme', 'warning',
                    'Sitemap unreachable — proceeding without internal links. Click "Refresh Sitemap" manually later.' );
            }
        }

        // ── USE FULL REGISTRY for satellite links (all 479+ pages, not just 4 blog posts) ──
        // CRITICAL: NEVER pad with /blog/ — that causes Google spam penalties.
        // If the registry is empty, pass an empty array. OpenAI will write without internal links.
        $satellite_links = $sitemap->select_contextual_links( $existing_slugs, $theme, $subject, 8, $language );

        // NO PADDING WITH /blog/ — if we have 0 links, we have 0 links. That's fine.
        // The auto-linker will inject real links post-generation from the full registry.

        $this->step( $post_index, 1, 'select_theme', 'done',
            'Theme: ' . $theme . ' — ' . count( $satellite_links ) . ' contextual links from full registry' );

        // Open log
        $log_id = $logger->log( array( 'theme' => $theme, 'language' => $language, 'status' => 'generating' ) );

        /* ── STEP 1.5 — News research via OpenAI web_search (if configured) ── */
        $research_context = '';
        $news_headline = trim( $content_settings['news_headline'] ?? '' );
        $news_url      = trim( $content_settings['news_url'] ?? '' );
        $news_mode     = $content_settings['news_mode'] ?? 'next_only';
        $news_cta_override = trim( $content_settings['news_cta_override'] ?? '' );

        if ( ! empty( $news_headline ) || ! empty( $news_url ) ) {
            $this->step( $post_index, 1, 'select_theme', 'running', 'Researching news topic via OpenAI web search…' );
            $news_label = ! empty( $news_headline ) ? $news_headline : $news_url;
            error_log( 'DBA: News research requested — "' . $news_label . '"' );

            $research_result = $openai->research_news( $news_headline, $news_url );

            if ( is_wp_error( $research_result ) ) {
                error_log( 'DBA: News research failed (non-fatal): ' . $research_result->get_error_message() );
                $this->step( $post_index, 1, 'select_theme', 'warning',
                    'Web research failed: ' . $research_result->get_error_message() . ' — proceeding without it' );
            } else {
                $research_context = $research_result;
                // NEWS OVERRIDE: When news research is provided, it MUST drive the article.
                // Override subject with the news headline (this becomes the article's primary focus).
                if ( ! empty( $news_headline ) ) {
                    $subject = $news_headline;
                    error_log( 'DBA: Subject overridden by news headline: "' . $subject . '"' );
                } elseif ( ! empty( $news_url ) ) {
                    $subject = 'News analysis';
                }

                // URL-STRICT MODE: Derive theme from research context instead of random pick.
                // Extract the dominant topic from the first 500 chars of research.
                if ( $is_url_strict_mode ) {
                    $derived_theme = $openai->derive_theme_from_research( $research_context );
                    if ( ! empty( $derived_theme ) ) {
                        $theme = $derived_theme;
                        error_log( 'DBA: URL-strict mode — theme derived from research: "' . $theme . '"' );
                    }
                }

                $this->step( $post_index, 1, 'select_theme', 'done',
                    'Web research complete ✓ (' . strlen( $research_context ) . ' chars) — "' . wp_trim_words( $news_label, 8 ) . '"' );
            }
        }

        /* ── STEP 2 — Generate article via OpenAI ───────────────── */
        $this->step( $post_index, 2, 'generate_content', 'running',
            'Calling OpenAI (' . ( $settings['openai_model'] ?? 'gpt-4.1' ) . ')…' );

        // Get recent title patterns for diversity enforcement
        $recent_title_patterns = $this->get_recent_title_patterns();

        $data       = null;
        $raw_json   = '';
        $last_errors = array();
        $retry_count = 0;

        for ( $attempt = 0; $attempt < $this->max_retries; $attempt++ ) {
            $retry_count = $attempt;
            if ( $attempt > 0 ) {
                $this->step( $post_index, 2, 'generate_content', 'running', 'Retry #' . ( $attempt + 1 ) . '…' );
            }

            $result = $openai->generate_article( $theme, $satellite_links, $existing_slugs, $subject, $research_context, $news_cta_override, $is_url_strict_mode, $recent_title_patterns );

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

            $raw_json = isset( $result['_raw_json'] ) ? $result['_raw_json'] : json_encode( $result );
            unset( $result['_raw_json'] );

            /* ── STEP 3 — Validate ──────────────────────────────── */
            $this->step( $post_index, 3, 'validate_content', 'running', 'Running 11-point regex validation…' );

            $errors = $validator->validate( $raw_json );

            if ( empty( $errors ) ) {
                $data = $result;
                $warnings = $validator->get_warnings();
                if ( ! empty( $warnings ) ) {
                    $this->step( $post_index, 3, 'validate_content', 'warning',
                        'Passed with ' . count( $warnings ) . ' warnings: ' . implode( '; ', array_slice( $warnings, 0, 3 ) ) );
                    error_log( 'DBA Validation warnings: ' . implode( '; ', $warnings ) );
                } else {
                    $this->step( $post_index, 3, 'validate_content', 'done', 'All checks passed ✓' );
                }
                break;
            }

            $last_errors = $errors;
            $this->step( $post_index, 3, 'validate_content', 'running',
                'Failed: ' . implode( '; ', array_slice( $errors, 0, 2 ) ) . ' — retrying…' );

            // Reselect links if link error — use full registry, NEVER pad with /blog/
            foreach ( $errors as $e ) {
                if ( strpos( $e, 'satellite' ) !== false || strpos( $e, 'Related' ) !== false ) {
                    $satellite_links = $sitemap->select_contextual_links( $existing_slugs, $theme, $subject, 8, $language );
                    break;
                }
            }
            sleep( 2 );
        }

        if ( $data === null ) {
            $this->step( $post_index, 3, 'validate_content', 'failed', implode( '; ', $last_errors ) );
            $logger->update_log( $log_id, array(
                'status'            => 'failed',
                'retry_attempts'    => $retry_count + 1,
                'validation_result' => implode( '; ', $last_errors ),
                'json_output'       => $raw_json,
                'error_trace'       => implode( "\n", $last_errors ),
            ) );
            return new WP_Error( 'generation_failed', implode( '; ', $last_errors ) );
        }

        $this->step( $post_index, 2, 'generate_content', 'done',
            'Article generated: "' . wp_trim_words( $data['title'] ?? '', 8 ) . '"' );

        /* ── STEP 3.5 — Smart inbound link injection from sitemap ── */
        try {
            $this->step( $post_index, 3, 'validate_content', 'running', 'Injecting contextual inbound links from sitemap…' );

            $auto_linker = DBA_Auto_Linker::get_instance();
            $data['content_html'] = $auto_linker->inject_links(
                $data['content_html'],
                $pillar_url,
                $satellite_links,
                $subject,
                $theme,
                6, // max 6 inbound links per article
                $data['title'] ?? '',
                $data['category'] ?? ''
            );

            $this->step( $post_index, 3, 'validate_content', 'done', 'Inbound links injected ✓' );

            // ── STEP 3.6 — Country name → "Doing business in…" linking ──
            $data['content_html'] = $auto_linker->link_country_names( $data['content_html'] );
            $this->step( $post_index, 3, 'validate_content', 'done', 'Country links injected ✓' );

        } catch ( \Throwable $e ) {
            error_log( 'DBA Auto-Linker error (non-fatal): ' . $e->getMessage() );
            $this->step( $post_index, 3, 'validate_content', 'warning', 'Auto-linker skipped: ' . $e->getMessage() );
        }

        /* ── STEP 4 — Plagiarism check ──────────────────────────── */
        $this->step( $post_index, 4, 'check_plagiarism', 'running', 'Scanning for plagiarism…' );

        $plag_result = $plagiarism->check( $data['content_html'] );

        if ( ! $plag_result['pass'] ) {
            $this->step( $post_index, 4, 'check_plagiarism', 'running', 'Failed — regenerating unique content…' );

            $result2 = $openai->generate_article( $theme, $satellite_links, $existing_slugs, $subject, $research_context, $news_cta_override, $is_url_strict_mode, $recent_title_patterns );
            if ( ! is_wp_error( $result2 ) ) {
                $raw2 = isset( $result2['_raw_json'] ) ? $result2['_raw_json'] : json_encode( $result2 );
                unset( $result2['_raw_json'] );
                $errors2 = $validator->validate( $raw2 );
                if ( empty( $errors2 ) ) {
                    $plag2 = $plagiarism->check( $result2['content_html'] );
                    if ( $plag2['pass'] ) {
                        $data        = $result2;
                        $raw_json    = $raw2;
                        $plag_result = $plag2;
                    }
                }
            }

            if ( ! $plag_result['pass'] ) {
                $this->step( $post_index, 4, 'check_plagiarism', 'failed',
                    'Score: ' . $plag_result['score'] . '% — below 85% threshold' );
                $logger->update_log( $log_id, array(
                    'status'            => 'failed',
                    'validation_result' => 'Plagiarism: ' . implode( '; ', $plag_result['details'] ),
                ) );
                return new WP_Error( 'plagiarism_failed', implode( '; ', $plag_result['details'] ) );
            }
        }

        $this->step( $post_index, 4, 'check_plagiarism', 'done',
            'Uniqueness: ' . $plag_result['score'] . '% (' . $plag_result['method'] . ') ✓' );

        /* ── STEP 5 — Create WP draft ───────────────────────────── */
        $this->step( $post_index, 5, 'create_draft', 'running', 'Inserting WordPress post…' );

        // Translate first if needed
        if ( $language !== 'en' ) {
            $this->step( $post_index, 11, 'translate', 'running', 'Translating to ' . strtoupper( $language ) . '…' );
            $translated = $openai->translate_article( $data, $language );
            if ( ! is_wp_error( $translated ) ) {
                $data['title']        = $translated['title']        ?? $data['title'];
                $data['excerpt']      = $translated['excerpt']      ?? $data['excerpt'];
                $data['content_html'] = $translated['content_html'] ?? $data['content_html'];
                if ( isset( $translated['yoast_seo'] ) ) {
                    $data['yoast_seo'] = array_merge( $data['yoast_seo'] ?? array(), $translated['yoast_seo'] );
                }
            }
            $this->step( $post_index, 11, 'translate', 'done', 'Translated to ' . strtoupper( $language ) );
        }

        // ── CATEGORY ASSIGNMENT ──
        // URL-strict mode: trust the AI category directly (GPT read the actual URL)
        // Normal mode: score content against 144 approved categories (85% threshold)
        $scorer = DBA_Category_Scorer::get_instance();
        if ( $is_url_strict_mode && ! empty( $data['category'] ) ) {
            // In URL-strict mode, the AI read the source URL and chose the category.
            // Use AI category directly — find or create the WP term.
            $ai_cat_slug = sanitize_title( $data['category'] );
            $term = get_term_by( 'slug', $ai_cat_slug, 'category' );
            if ( $term ) {
                $category_ids = array( $term->term_id );
            } else {
                $term = get_term_by( 'name', $data['category'], 'category' );
                if ( $term ) {
                    $category_ids = array( $term->term_id );
                } else {
                    // Check if it's in approved list
                    $approved = $scorer->get_approved_categories();
                    $found_slug = $approved[ $data['category'] ] ?? null;
                    if ( $found_slug ) {
                        $result_term = wp_insert_term( $data['category'], 'category', array( 'slug' => $found_slug ) );
                        $category_ids = is_wp_error( $result_term ) ? array( 1 ) : array( $result_term['term_id'] );
                    } else {
                        // Fallback: run scorer anyway
                        $category_ids = $scorer->assign_categories( $data['title'], $data['content_html'], $data['theme'] ?? '', $data['category'] ?? '' );
                    }
                }
            }
            error_log( 'DBA: URL-strict mode — using AI category "' . $data['category'] . '" directly (term IDs: ' . implode( ',', $category_ids ) . ')' );
        } else {
            $category_ids = $scorer->assign_categories(
                $data['title'],
                $data['content_html'],
                $data['theme'] ?? '',
                $data['category'] ?? ''
            );
        }

        // ── POST-PROCESS CONTENT — fix hallucinated URLs, dates, links ──
        $content = $data['content_html'];

        // 1. Force real pillar URL (OpenAI may hallucinate fake ones)
        $content = preg_replace(
            '/href=["\']https?:\/\/www\.damalion\.com\/blog\/[^"\']*core-structuring-tool[^"\']*["\']/i',
            'href="' . esc_url( $pillar_url ) . '"',
            $content
        );

        // 2. Verify all damalion.com links exist in sitemap registry
        //    Use FULL registry (all 479+ pages), NOT just the 4-entry blog registry.
        //    If a URL isn't real, REMOVE the link entirely — never replace with /blog/.
        $full_reg = $sitemap->get_full_registry();
        $registry_urls = array();
        foreach ( $full_reg as $entry ) {
            $registry_urls[] = strtolower( rtrim( $entry['url'], '/' ) );
        }
        // Also allow pillar URL, lets-connect, and doing-business pages
        $registry_urls[] = strtolower( rtrim( $pillar_url, '/' ) );
        $registry_urls[] = 'https://www.damalion.com/lets-connect';
        $registry_urls[] = 'https://www.damalion.com';

        // Also include all DBA-created posts (they may not be in sitemap yet)
        $own_posts = get_posts( array(
            'post_type' => 'post', 'post_status' => 'publish',
            'posts_per_page' => 300, 'fields' => 'ids',
            'meta_query' => array( array( 'key' => '_dba_auto_created', 'compare' => 'EXISTS' ) ),
        ) );
        foreach ( $own_posts as $pid ) {
            $registry_urls[] = strtolower( rtrim( get_permalink( $pid ), '/' ) );
        }
        $registry_urls = array_unique( $registry_urls );

        // Find all damalion.com links — remove fakes instead of replacing with /blog/
        preg_match_all( '/href=["\']([^"\']*damalion\.com[^"\']*)["\']/', $content, $link_matches );
        if ( ! empty( $link_matches[1] ) ) {
            foreach ( $link_matches[1] as $found_url ) {
                $check_url = strtolower( rtrim( $found_url, '/' ) );
                if ( ! in_array( $check_url, $registry_urls ) ) {
                    // URL not real — REMOVE the entire <a> tag, keep the anchor text
                    $escaped = preg_quote( $found_url, '/' );
                    $content = preg_replace_callback(
                        '/<a\s+[^>]*href=["\']' . $escaped . '["\'][^>]*>(.*?)<\/a>/si',
                        function( $m ) {
                            return wp_strip_all_tags( $m[1] );
                        },
                        $content
                    );
                    error_log( 'DBA: REMOVED hallucinated link (not in registry): ' . $found_url );
                }
            }
        }

        // 3. Strip "Last updated" line entirely (user doesn't want it)
        $content = preg_replace(
            '/<p[^>]*class=["\']dba-last-updated["\'][^>]*>.*?<\/p>/si',
            '',
            $content
        );
        $content = preg_replace(
            '/Last updated:?\s*\d{4}[-\/]\d{1,2}[-\/]\d{1,2}/i',
            '',
            $content
        );
        $content = preg_replace(
            '/Last updated:?\s*[A-Z][a-z]+\s+\d{1,2},?\s*\d{4}/i',
            '',
            $content
        );

        $data['content_html'] = $content;

        $post_args = array(
            'post_title'    => sanitize_text_field( $data['title'] ),
            'post_name'     => sanitize_title( $data['slug'] ?? '' ),
            'post_content'  => wp_kses_post( $data['content_html'] ),
            'post_excerpt'  => sanitize_text_field( $data['excerpt'] ?? '' ),
            'post_status'   => 'draft',
            'post_type'     => 'post',
            'post_author'   => get_current_user_id() ?: 1,
            'post_category' => $category_ids,
        );

        $post_id = wp_insert_post( $post_args );

        if ( ! $post_id || is_wp_error( $post_id ) ) {
            $this->step( $post_index, 5, 'create_draft', 'failed', 'wp_insert_post error' );
            return new WP_Error( 'insert_failed', 'Failed to create WordPress post' );
        }

        // Tags
        if ( ! empty( $data['tags'] ) && is_array( $data['tags'] ) ) {
            wp_set_post_tags( $post_id, array_map( 'sanitize_text_field', $data['tags'] ) );
        }

        // Core meta
        update_post_meta( $post_id, '_dba_auto_created',      current_time( 'mysql' ) );
        update_post_meta( $post_id, '_dba_theme',             sanitize_text_field( $data['theme'] ?? $theme ) );
        update_post_meta( $post_id, '_dba_subject',           sanitize_text_field( $subject ) );
        update_post_meta( $post_id, '_dba_language',          sanitize_text_field( $language ) );
        update_post_meta( $post_id, '_dba_json_data',         $raw_json );
        update_post_meta( $post_id, '_dba_plagiarism_score',  intval( $plag_result['score'] ) );
        update_post_meta( $post_id, '_dba_plagiarism_method', sanitize_text_field( $plag_result['method'] ) );

        // ── KAIZEN: Store original AI output for self-improvement tracking ──
        // This is the "before" snapshot. Kaizen compares it against the
        // editor-corrected version to learn patterns (links, titles, headings, etc.)
        if ( class_exists( 'DBA_Kaizen' ) ) {
            DBA_Kaizen::store_original(
                $post_id,
                $data['title'] ?? '',
                $data['content_html'] ?? '',
                $data['excerpt'] ?? '',
                $data['featured_image']['alt'] ?? ''
            );
        }

        // DISABLE Divi Builder — force classic editor so content is editable
        update_post_meta( $post_id, '_et_pb_use_builder', 'off' );
        update_post_meta( $post_id, '_et_pb_old_content', $data['content_html'] );
        update_post_meta( $post_id, '_et_pb_page_layout', 'et_right_sidebar' );
        update_post_meta( $post_id, '_et_pb_show_title', 'on' );
        // Tell Divi not to auto-enable builder
        update_post_meta( $post_id, '_et_pb_built_for_post_type', 'post' );
        // Also disable Gutenberg block editor if active
        update_post_meta( $post_id, '_wp_page_template', 'default' );

        $this->step( $post_index, 5, 'create_draft', 'done', 'Post #' . $post_id . ' created as draft' );

        /* ── STEP 6 — Install & verify internal links ───────────── */
        $this->step( $post_index, 6, 'install_links', 'running', 'Verifying pillar link + inbound links in body…' );

        $link_report = $this->verify_internal_links( $data['content_html'], $pillar_url, $satellite_links );
        update_post_meta( $post_id, '_dba_link_report', $link_report );

        $link_status = 'done';
        $link_msg    = 'Pillar link ✓ + ' . $link_report['satellite_count'] . ' inbound link(s) in body ✓ — ' . $link_report['total_internal'] . ' internal links total';

        if ( ! $link_report['pillar_found'] || $link_report['satellite_count'] < 1 ) {
            $link_status = 'warning';
            $link_msg    = 'Pillar: ' . ( $link_report['pillar_found'] ? '✓' : '✗ MISSING' ) .
                           ' | Inbound links: ' . $link_report['satellite_count'];
        }

        $this->step( $post_index, 6, 'install_links', $link_status, $link_msg );

        /* ── STEP 7 + 8 + 9 — Featured image pipeline ──────────── */
        $media_id = 0;

        if ( ! empty( $data['featured_image']['prompt'] ) ) {

            // Step 7: Generate
            $this->step( $post_index, 7, 'generate_image', 'running', 'Generating image with DALL-E 3…' );
            $img_result = $img_gen->generate_and_upload( $data['featured_image'], $post_id );

            if ( ! is_wp_error( $img_result ) ) {
                $media_id = $img_result;

                // Step 8: Upload confirmation (alt/title/caption/description already set by image generator)
                $this->step( $post_index, 8, 'upload_image', 'done',
                    'Image #' . $media_id . ' — alt, title, caption, description filled ✓' );

                // Step 9: Set as featured + GUARANTEE all 4 image fields are filled
                $this->step( $post_index, 9, 'set_featured_image', 'running', 'Setting featured image on post…' );
                set_post_thumbnail( $post_id, $media_id );

                // ═══════════════════════════════════════════════════════════
                // FIX: Ensure Caption + Description are NEVER left empty.
                // The image generator SHOULD fill these, but sometimes doesn't.
                // This safety net checks and fills any missing fields.
                // ═══════════════════════════════════════════════════════════
                $this->ensure_image_meta( $media_id, $data );

                $this->step( $post_index, 9, 'set_featured_image', 'done',
                    'Featured image #' . $media_id . ' attached to post #' . $post_id . ' — all meta fields filled ✓' );
            } else {
                $this->step( $post_index, 7, 'generate_image', 'failed', $img_result->get_error_message() );
                $this->step( $post_index, 8, 'upload_image', 'skipped', 'No image to upload' );
                $this->step( $post_index, 9, 'set_featured_image', 'skipped', 'No image to attach' );
            }
        } else {
            $this->step( $post_index, 7, 'generate_image', 'skipped', 'No image prompt in response' );
            $this->step( $post_index, 8, 'upload_image', 'skipped', '' );
            $this->step( $post_index, 9, 'set_featured_image', 'skipped', '' );
        }

        /* ── STEP 10 — Fill Yoast SEO fields ────────────────────── */
        $this->step( $post_index, 10, 'fill_yoast_seo', 'running', 'Writing focus keyphrase + SEO title + meta description…' );

        $yoast = $this->fill_yoast_seo( $post_id, $data );

        $this->step( $post_index, 10, 'fill_yoast_seo', 'done',
            'Focus: "' . ( $yoast['focus_keyphrase'] ?? '—' ) . '" | Title: ' . mb_strlen( $yoast['seo_title'] ?? '' ) . 'ch | Desc: ' . mb_strlen( $yoast['meta_description'] ?? '' ) . 'ch ✓' );

        /* ── STEP 11 — WPML language ────────────────────────────── */
        if ( function_exists( 'wpml_set_element_language_details' ) && $language !== 'en' ) {
            $this->set_wpml_language( $post_id, $language );
        }
        // If translation happened earlier, step 11 is already logged.
        // If not, mark as skipped.
        if ( $language === 'en' ) {
            $this->step( $post_index, 11, 'translate', 'skipped', 'Primary language (EN) — no translation needed' );
        }

        /* ── STEP 12 — Publish / schedule ───────────────────────── */
        $final_status = $settings['post_status'] ?? 'publish';

        try {
            if ( ! empty( $schedule_time ) && $final_status !== 'draft' ) {
                $this->step( $post_index, 12, 'publish', 'running', 'Scheduling post for ' . $schedule_time . '…' );

                $schedule_settings = get_option( 'dba_schedule_settings', array() );
                $tz = $schedule_settings['timezone'] ?? 'Europe/Luxembourg';
                try {
                    $dt = new DateTime( $schedule_time, new DateTimeZone( $tz ) );
                    $dt->setTimezone( new DateTimeZone( 'UTC' ) );
                    wp_update_post( array(
                        'ID'            => $post_id,
                        'post_status'   => 'future',
                        'post_date_gmt' => $dt->format( 'Y-m-d H:i:s' ),
                    ) );
                    $final_status = 'future';
                } catch ( Exception $e ) {
                    // leave as draft
                }
            } elseif ( $final_status === 'publish' ) {
                $this->step( $post_index, 12, 'publish', 'running', 'Publishing…' );
                wp_update_post( array( 'ID' => $post_id, 'post_status' => 'publish' ) );
            }
        } catch ( \Throwable $e ) {
            error_log( 'DBA: Publish step error (post #' . $post_id . ' left as draft): ' . $e->getMessage() );
            $final_status = 'draft';
        }

        $this->step( $post_index, 12, 'publish', 'done',
            'Post #' . $post_id . ' → ' . strtoupper( $final_status ) . ' ✓' );

        // Ping sitemap (wrapped — must not block)
        try {
            $sitemap->ping_sitemap();
        } catch ( \Throwable $e ) {
            error_log( 'DBA: Sitemap ping failed (non-fatal): ' . $e->getMessage() );
        }

        // Final log (wrapped — must not block)
        try {
            $logger->update_log( $log_id, array(
            'title'             => $data['title'],
            'slug'              => $data['slug'] ?? '',
            'post_id'           => $post_id,
            'media_id'          => $media_id,
            'json_output'       => $raw_json,
            'validation_result' => sprintf(
                'PASS | Plagiarism: %d%% | Links: %d (pillar:%s, sat:%d) | Yoast: ✓ | Image: %s',
                $plag_result['score'],
                $link_report['total_internal'],
                $link_report['pillar_found'] ? '✓' : '✗',
                $link_report['satellite_count'],
                $media_id ? '#' . $media_id : 'none'
            ),
            'retry_attempts' => $retry_count,
            'status'         => ( $final_status === 'publish' ) ? 'published' : $final_status,
        ) );
        } catch ( \Throwable $e ) {
            error_log( 'DBA: Log update failed (non-fatal): ' . $e->getMessage() );
        }

        // Auto-clear news fields if mode is "next_only" (used for one post, then cleared)
        try {
            if ( $news_mode === 'next_only' && ( ! empty( $news_headline ) || ! empty( $news_url ) ) ) {
                $content_settings['news_headline']    = '';
                $content_settings['news_url']         = '';
                $content_settings['news_cta_override'] = '';
                update_option( 'dba_content_settings', $content_settings );
                error_log( 'DBA: News fields + CTA override auto-cleared (next_only mode)' );
            }
        } catch ( \Throwable $e ) {
            error_log( 'DBA: News auto-clear failed (non-fatal): ' . $e->getMessage() );
        }

        // ── TITLE PATTERN TRACKING — prevent consecutive similar title structures ──
        try {
            $this->store_title_pattern( $data['title'] );
        } catch ( \Throwable $e ) {
            error_log( 'DBA: Title pattern storage failed (non-fatal): ' . $e->getMessage() );
        }

        return array(
            'post_id'    => $post_id,
            'media_id'   => $media_id,
            'log_id'     => $log_id,
            'title'      => $data['title'],
            'theme'      => $theme,
            'status'     => $final_status,
            'links'      => $link_report,
            'yoast'      => $yoast,
            'plagiarism' => $plag_result['score'],
        );
    }

    /* =================================================================
     *  STEP 6 — Verify internal links
     * ================================================================= */

    private function verify_internal_links( $html, $pillar_url, $satellite_links ) {
        $report = array(
            'pillar_found'    => false,
            'pillar_url'      => $pillar_url,
            'satellite_count' => 0,
            'satellite_urls'  => array(),
            'total_internal'  => 0,
            'all_links'       => array(),
        );

        // 1) Check pillar link
        $pillar_clean = rtrim( $pillar_url, '/' );
        if ( strpos( $html, $pillar_clean ) !== false ) {
            $report['pillar_found'] = true;
        }

        // 2) Count ALL damalion.com links in body (excluding CTA)
        $body = preg_replace( '/Damalion supports investors.*?contact your Damalion experts now.*?<\/a>/si', '', $html );
        preg_match_all( '/href="(https?:\/\/(?:www\.)?damalion\.com[^"]*)"/', $body, $all );
        $unique_links             = array_unique( $all[1] ?? array() );
        $report['total_internal'] = count( $unique_links );
        $report['all_links']      = array_values( $unique_links );

        // 3) Count satellite links (internal links that aren't the pillar)
        foreach ( $unique_links as $link ) {
            $link_clean = rtrim( $link, '/' );
            if ( $link_clean !== $pillar_clean && strpos( $link, 'lets-connect' ) === false ) {
                $report['satellite_count']++;
                $report['satellite_urls'][] = $link;
            }
        }

        return $report;
    }

    /* =================================================================
     *  STEP 10 — Fill Yoast SEO meta
     * ================================================================= */

    private function fill_yoast_seo( $post_id, $data ) {
        $yoast = $data['yoast_seo'] ?? array();

        $focus     = $yoast['focus_keyphrase']  ?? '';
        $seo_title = $yoast['seo_title']        ?? '';
        $meta_desc = $yoast['meta_description'] ?? '';

        // Fallback: auto-generate from title/excerpt
        if ( empty( $focus ) ) {
            $words = explode( ' ', sanitize_text_field( $data['title'] ?? '' ) );
            $focus = implode( ' ', array_slice( $words, 0, min( 4, count( $words ) ) ) );
        }
        if ( empty( $seo_title ) ) {
            $seo_title = sanitize_text_field( $data['title'] ?? '' ) . ' | Damalion';
            if ( mb_strlen( $seo_title ) > 60 ) {
                $seo_title = mb_substr( $seo_title, 0, 57 ) . '…';
            }
        }
        if ( empty( $meta_desc ) ) {
            $meta_desc = sanitize_text_field( $data['excerpt'] ?? '' );
            if ( mb_strlen( $meta_desc ) > 160 ) {
                $meta_desc = mb_substr( $meta_desc, 0, 157 ) . '…';
            }
        }

        // ── Yoast SEO meta keys ──
        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 ) );

        // Open Graph
        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 Card
        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 ) );

        // Cornerstone & schema
        update_post_meta( $post_id, '_yoast_wpseo_is_cornerstone',       '0' );
        update_post_meta( $post_id, '_yoast_wpseo_schema_article_type',  'Article' );

        return array(
            'focus_keyphrase'  => $focus,
            'seo_title'        => $seo_title,
            'meta_description' => $meta_desc,
        );
    }

    /* =================================================================
     *  BATCH GENERATION
     * ================================================================= */

    public function create_daily_batch() {
        $settings         = get_option( 'dba_settings', array() );
        $schedule_settings = get_option( 'dba_schedule_settings', array() );
        $content_settings  = get_option( 'dba_content_settings', array() );
        $logger            = DBA_Logger::get_instance();
        $openai            = DBA_OpenAI::get_instance();

        // ── AUTO-SCAN: Ensure page registry is fresh before batch ──
        $sitemap = DBA_Sitemap::get_instance();
        $sitemap->ensure_fresh_registry();

        $posts_per_run = max( 1, intval( $settings['posts_per_run'] ?? 5 ) );
        $all_subjects  = $content_settings['subjects']     ?? array( 'Luxembourg holding company' );
        $subject_days  = $content_settings['subject_days'] ?? array();
        $languages     = $content_settings['languages']    ?? array( 'en' );
        $posting_times = $schedule_settings['posting_times'] ?? array( '09:10', '11:20', '13:30', '15:40', '17:50' );

        // Filter subjects: only those assigned to today's day
        $tz = $schedule_settings['timezone'] ?? 'Europe/Luxembourg';
        try {
            $now = new DateTime( 'now', new DateTimeZone( $tz ) );
            $today_short = strtolower( substr( $now->format( 'D' ), 0, 3 ) ); // mon, tue, wed...
        } catch ( Exception $e ) {
            $today_short = strtolower( substr( date( 'D' ), 0, 3 ) );
        }

        $subjects = array();
        foreach ( $all_subjects as $subj ) {
            $days = isset( $subject_days[ $subj ] ) ? $subject_days[ $subj ] : array();
            // Empty days array = post every day
            if ( empty( $days ) || in_array( $today_short, $days ) ) {
                $subjects[] = $subj;
            }
        }

        // If no subjects match today, skip
        if ( empty( $subjects ) ) {
            error_log( 'DBA Batch: No subjects scheduled for ' . $today_short . '. Skipping.' );
            return array( 'created' => 0, 'failed' => 0, 'skipped' => $posts_per_run, 'details' => array( 'No subjects scheduled for today (' . strtoupper( $today_short ) . ')' ) );
        }

        error_log( 'DBA Batch: Today is ' . strtoupper( $today_short ) . '. Active subjects: ' . implode( ', ', $subjects ) );

        $used_themes = $logger->get_today_themes();
        $themes      = $openai->pick_themes( $posts_per_run, $used_themes );

        $today_count = $logger->get_today_count();
        $remaining   = max( 0, $posts_per_run - $today_count );

        // Init progress
        $this->set_progress( array(
            'total_posts'      => $remaining,
            'completed'        => 0,
            'failed'           => 0,
            'current_post'     => 0,
            'current_step'     => 0,
            'current_step_name' => 'starting',
            'current_status'   => 'running',
            'current_detail'   => 'Starting batch of ' . $remaining . ' posts…',
            'step_percent'     => 0,
            'overall_percent'  => 0,
            'started'          => time(),
            'steps_log'        => array(),
        ) );

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

        if ( $remaining <= 0 ) {
            $results['skipped'] = $posts_per_run;
            $this->step( 0, 12, 'batch_done', 'done', 'Daily limit already reached (' . $today_count . ' posts today)' );
            return $results;
        }

        // ── SUBJECT DEDUPLICATION ──────────────────────────────────────────
        // CRITICAL: Each subject may only produce ONE post per day.
        // This prevents Google keyword-stuffing / spam penalties.
        //
        // How it works:
        //   - We build a queue of (theme, subject) pairs, 1 per unique subject.
        //   - If there are fewer subjects than posts_per_run, the remaining
        //     slots are SKIPPED — we never duplicate a subject.
        //   - We also check dba_subject_already_posted_today() (from the main
        //     plugin file) in case a prior run today already covered a subject.
        // ─────────────────────────────────────────────────────────────────────

        $used_subjects_in_batch = array();
        $post_queue = array(); // [ [ 'theme' => ..., 'subject' => ... ], ... ]

        $theme_idx = 0;
        foreach ( $subjects as $subj ) {
            // Skip if already posted today (e.g. from a manual run earlier)
            if ( function_exists( 'dba_subject_already_posted_today' ) && dba_subject_already_posted_today( $subj ) ) {
                error_log( 'DBA Batch: Skipping subject "' . $subj . '" — already posted today.' );
                $results['skipped']++;
                $results['details'][] = array( 'subject' => $subj, 'status' => 'skipped', 'reason' => 'Already posted today' );
                continue;
            }

            // Skip if somehow duplicated in the subjects list
            $subj_key = mb_strtolower( trim( $subj ) );
            if ( isset( $used_subjects_in_batch[ $subj_key ] ) ) {
                continue;
            }
            $used_subjects_in_batch[ $subj_key ] = true;

            // Pair with next available theme
            if ( $theme_idx < count( $themes ) ) {
                $post_queue[] = array( 'theme' => $themes[ $theme_idx ], 'subject' => $subj );
                $theme_idx++;
            }

            // Stop once we've filled the daily quota
            if ( count( $post_queue ) >= $remaining ) {
                break;
            }
        }

        // Adjust remaining to actual queue size (may be smaller than posts_per_run)
        $actual_remaining = count( $post_queue );
        if ( $actual_remaining < $remaining ) {
            $results['skipped'] += ( $remaining - $actual_remaining );
            error_log( 'DBA Batch: Only ' . $actual_remaining . ' unique subjects available (of ' . $remaining . ' slots). Extra slots skipped to avoid keyword stuffing.' );
        }

        // Update progress with actual count
        $progress = $this->get_progress_data();
        $progress['total_posts'] = max( 1, $actual_remaining );
        $this->set_progress( $progress );

        for ( $i = 0; $i < $actual_remaining; $i++ ) {
            $post_num = $i + 1;
            $theme    = $post_queue[ $i ]['theme'];
            $subject  = $post_queue[ $i ]['subject'];
            $lang     = $languages[0] ?? 'en';

            $schedule_time = '';
            $time_idx = $today_count + $i;
            if ( isset( $posting_times[ $time_idx ] ) ) {
                $schedule_time = current_time( 'Y-m-d' ) . ' ' . $posting_times[ $time_idx ] . ':00';
            }

            // Double-check dedup right before generation (another post may have been created in the meantime)
            if ( function_exists( 'dba_subject_already_posted_today' ) && dba_subject_already_posted_today( $subject ) ) {
                error_log( 'DBA Batch: Subject "' . $subject . '" was posted between queue build and execution. Skipping.' );
                $results['skipped']++;
                $results['details'][] = array( 'subject' => $subject, 'theme' => $theme, 'status' => 'skipped', 'reason' => 'Duplicate subject (race condition)' );
                continue;
            }

            $result = $this->create_single_post( $theme, $subject, $lang, $schedule_time, $post_num );

            // Mark subject as used immediately (belt-and-suspenders with the save_post hook)
            if ( function_exists( 'dba_mark_subject_used_today' ) ) {
                dba_mark_subject_used_today( $subject );
            }

            $progress = $this->get_progress_data();

            if ( is_wp_error( $result ) ) {
                $results['failed']++;
                $results['details'][] = array( 'theme' => $theme, 'subject' => $subject, 'status' => 'failed', 'error' => $result->get_error_message() );
                $progress['failed'] = ( $progress['failed'] ?? 0 ) + 1;
            } else {
                $results['created']++;
                $results['details'][] = $result;
                $progress['completed'] = ( $progress['completed'] ?? 0 ) + 1;
            }

            $progress['overall_percent'] = round( ( ( $results['created'] + $results['failed'] ) / max( 1, $actual_remaining ) ) * 100 );
            $this->set_progress( $progress );

            sleep( 3 );
        }

        // Final
        $progress = $this->get_progress_data();
        $progress['current_status']  = 'complete';
        $progress['current_detail']  = 'Batch complete: ' . $results['created'] . ' created, ' . $results['failed'] . ' failed';
        $progress['overall_percent'] = 100;
        $this->set_progress( $progress );

        update_option( 'dba_last_run', array(
            'time'    => current_time( 'mysql' ),
            'day'     => strtolower( date( 'l' ) ),
            'results' => $results,
        ) );

        return $results;
    }

    /* =================================================================
     *  TITLE PATTERN TRACKING — Prevent Google penalties from similar titles
     * ================================================================= */

    /**
     * Extract the structural pattern from a title.
     * E.g. "How Private Credit Is Reshaping European Deals" → "How X Are/Is Verbing Y"
     * E.g. "The Rise of X: What Y Means for Z" → "The Noun of X: What Y Means for Z"
     */
    public function extract_title_pattern( $title ) {
        $title = trim( $title );
        if ( empty( $title ) ) return '';

        // Normalize: remove specific nouns/adjectives, keep structural words
        $pattern = $title;

        // Replace proper nouns and specific terms with X/Y/Z placeholders
        // Keep structural words: How, What, Why, Where, When, The, Are, Is, In, For, Of, And, A, An
        $structural = array( 'how', 'what', 'why', 'where', 'when', 'the', 'are', 'is', 'in', 'for', 'of', 'and', 'a', 'an', 'to', 'from', 'with', 'by', 'on', 'at', 'as' );

        $words = preg_split( '/\s+/', $pattern );
        $result = array();
        $placeholder_idx = 0;
        $placeholders = array( 'X', 'Y', 'Z', 'W' );

        $prev_was_placeholder = false;
        foreach ( $words as $w ) {
            $w_lower = strtolower( preg_replace( '/[^a-zA-Z]/', '', $w ) );
            // Check for -ing verbs (gerunds)
            $is_gerund = preg_match( '/ing$/i', $w_lower ) && strlen( $w_lower ) > 4;
            // Keep structural words and gerunds
            if ( in_array( $w_lower, $structural ) ) {
                $result[] = ucfirst( $w_lower );
                $prev_was_placeholder = false;
            } elseif ( $is_gerund ) {
                $result[] = 'Verbing';
                $prev_was_placeholder = false;
            } elseif ( preg_match( '/^[:\-–—]$/', trim( $w ) ) ) {
                $result[] = ':';
                $prev_was_placeholder = false;
            } else {
                // Replace with placeholder, but consolidate consecutive placeholders
                if ( ! $prev_was_placeholder ) {
                    $result[] = $placeholders[ min( $placeholder_idx, 3 ) ];
                    $placeholder_idx++;
                    $prev_was_placeholder = true;
                }
            }
        }

        return implode( ' ', $result );
    }

    /**
     * Store a title pattern in the recent patterns list (keeps last 20).
     */
    public function store_title_pattern( $title ) {
        $pattern = $this->extract_title_pattern( $title );
        if ( empty( $pattern ) ) return;

        $patterns = get_option( 'dba_recent_title_patterns', array() );

        // Store with timestamp for age-based cleanup
        $patterns[] = array(
            'pattern' => $pattern,
            'title'   => $title,
            'time'    => current_time( 'mysql' ),
        );

        // Keep only last 20
        $patterns = array_slice( $patterns, -20 );

        update_option( 'dba_recent_title_patterns', $patterns );
        error_log( 'DBA: Title pattern stored: "' . $pattern . '" (from: "' . $title . '")' );
    }

    /**
     * Get recent title patterns as a blacklist for the AI prompt.
     */
    public function get_recent_title_patterns() {
        $patterns = get_option( 'dba_recent_title_patterns', array() );
        $result = array();
        foreach ( $patterns as $entry ) {
            $result[] = $entry['pattern'] . '  ← e.g. "' . $entry['title'] . '"';
        }
        return $result;
    }

    /* =================================================================
     *  STEP 9 HELPER — Ensure featured image has all 4 metadata fields
     *
     *  WordPress image fields:
     *    Alt Text    → _wp_attachment_image_alt  (post meta on attachment)
     *    Title       → post_title                (attachment post object)
     *    Caption     → post_excerpt              (attachment post object)
     *    Description → post_content              (attachment post object)
     *
     *  The image generator SHOULD set all 4, but this is a safety net
     *  that fills any field the generator missed.
     * ================================================================= */

    private function ensure_image_meta( $attachment_id, $data ) {
        if ( empty( $attachment_id ) ) return;

        $attachment = get_post( $attachment_id );
        if ( ! $attachment || $attachment->post_type !== 'attachment' ) return;

        // Gather source texts for fallbacks
        $article_title = sanitize_text_field( $data['title'] ?? '' );
        $article_excerpt = sanitize_text_field( $data['excerpt'] ?? '' );
        $ai_alt   = sanitize_text_field( $data['featured_image']['alt'] ?? '' );
        $ai_title = sanitize_text_field( $data['featured_image']['title'] ?? '' );
        $ai_caption     = sanitize_text_field( $data['featured_image']['caption'] ?? '' );
        $ai_description = sanitize_text_field( $data['featured_image']['description'] ?? '' );
        $theme    = sanitize_text_field( $data['theme'] ?? '' );
        $subject  = sanitize_text_field( $data['subject'] ?? '' );

        $needs_update = false;
        $update_args  = array( 'ID' => $attachment_id );

        // ── ALT TEXT (post meta) ──
        $current_alt = get_post_meta( $attachment_id, '_wp_attachment_image_alt', true );
        if ( empty( trim( $current_alt ) ) ) {
            // Priority: AI alt > article title > theme + subject
            $new_alt = $ai_alt;
            if ( empty( $new_alt ) ) $new_alt = $article_title;
            if ( empty( $new_alt ) ) $new_alt = trim( $theme . ' ' . $subject );
            if ( ! empty( $new_alt ) ) {
                update_post_meta( $attachment_id, '_wp_attachment_image_alt', $new_alt );
                error_log( 'DBA Image Meta: Filled Alt Text → "' . $new_alt . '"' );
            }
        }

        // ── TITLE (post_title on attachment) ──
        if ( empty( trim( $attachment->post_title ) ) || $attachment->post_title === basename( get_attached_file( $attachment_id ) ) ) {
            // Title is empty or just the filename — replace with something meaningful
            $new_title = $ai_title;
            if ( empty( $new_title ) ) $new_title = $article_title;
            if ( ! empty( $new_title ) ) {
                $update_args['post_title'] = $new_title;
                $needs_update = true;
                error_log( 'DBA Image Meta: Filled Title → "' . $new_title . '"' );
            }
        }

        // ── CAPTION (post_excerpt on attachment) ──
        if ( empty( trim( $attachment->post_excerpt ) ) ) {
            $new_caption = $ai_caption;
            if ( empty( $new_caption ) ) {
                // Build a meaningful caption from article context
                // Format: "Article title — theme context"
                $new_caption = $article_title;
                if ( ! empty( $theme ) && stripos( $new_caption, $theme ) === false ) {
                    // Only append theme if it adds info (not redundant with title)
                    $new_caption .= ' — ' . $theme;
                }
            }
            if ( ! empty( $new_caption ) ) {
                $update_args['post_excerpt'] = $new_caption;
                $needs_update = true;
                error_log( 'DBA Image Meta: Filled Caption → "' . $new_caption . '"' );
            }
        }

        // ── DESCRIPTION (post_content on attachment) ──
        if ( empty( trim( $attachment->post_content ) ) ) {
            $new_description = $ai_description;
            if ( empty( $new_description ) ) {
                // Use the article excerpt as the image description (SEO best practice)
                $new_description = $article_excerpt;
            }
            if ( empty( $new_description ) ) {
                // Last resort: use alt text + context
                $current_alt = get_post_meta( $attachment_id, '_wp_attachment_image_alt', true );
                $new_description = $current_alt;
                if ( ! empty( $subject ) ) {
                    $new_description .= '. ' . $subject;
                }
            }
            if ( ! empty( $new_description ) ) {
                $update_args['post_content'] = $new_description;
                $needs_update = true;
                error_log( 'DBA Image Meta: Filled Description → "' . mb_substr( $new_description, 0, 80 ) . '…"' );
            }
        }

        // ── APPLY UPDATES ──
        if ( $needs_update ) {
            wp_update_post( $update_args );
            error_log( 'DBA Image Meta: Updated attachment #' . $attachment_id . ' — fields filled ✓' );
        }
    }

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

    private function get_or_create_category( $name ) {
        $term = get_term_by( 'name', $name, 'category' );
        if ( $term ) return array( $term->term_id );
        $r = wp_insert_term( $name, 'category', array( 'slug' => sanitize_title( $name ) ) );
        if ( ! is_wp_error( $r ) ) return array( $r['term_id'] );
        return array( 1 );
    }

    private function set_wpml_language( $post_id, $language ) {
        if ( ! function_exists( 'wpml_set_element_language_details' ) ) return;
        do_action( 'wpml_set_element_language_details', array(
            'element_id'    => $post_id,
            'element_type'  => 'post_post',
            'trid'          => false,
            'language_code' => $language,
        ) );
    }

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

    public function ajax_generate_single() {
        check_ajax_referer( 'dba_admin_nonce', 'nonce' );
        if ( ! current_user_can( 'manage_options' ) ) wp_send_json_error( array( 'message' => 'Permission denied' ) );

        @set_time_limit( 300 );

        try {
            $this->set_progress( array(
                'total_posts' => 1, 'completed' => 0, 'failed' => 0,
                'current_post' => 1, 'current_step' => 0, 'current_step_name' => 'starting',
                'current_status' => 'running', 'current_detail' => 'Initializing…',
                'step_percent' => 0, 'overall_percent' => 0,
                'started' => time(), 'steps_log' => array(),
            ) );

            $theme    = sanitize_text_field( $_POST['theme']    ?? '' );
            $subject  = sanitize_text_field( $_POST['subject']  ?? '' );
            $language = sanitize_text_field( $_POST['language']  ?? 'en' );

            // LIVE FORM VALUES: If news fields were sent from the form, inject them into
            // content_settings so create_single_post() picks them up — no need to save first.
            $form_news_url      = sanitize_text_field( $_POST['news_url'] ?? '' );
            $form_news_headline = sanitize_text_field( $_POST['news_headline'] ?? '' );
            $form_news_cta      = wp_kses_post( $_POST['news_cta_override'] ?? '' );

            if ( ! empty( $form_news_url ) || ! empty( $form_news_headline ) ) {
                $content_settings = get_option( 'dba_content_settings', array() );
                if ( ! empty( $form_news_url ) )      $content_settings['news_url']      = $form_news_url;
                if ( ! empty( $form_news_headline ) )  $content_settings['news_headline'] = $form_news_headline;
                if ( ! empty( $form_news_cta ) )       $content_settings['news_cta_override'] = $form_news_cta;
                // Temporarily save so create_single_post reads updated values
                update_option( 'dba_content_settings', $content_settings );
                error_log( 'DBA: Live form news injected — url="' . $form_news_url . '" headline="' . $form_news_headline . '" cta_override=' . ( ! empty( $form_news_cta ) ? 'yes' : 'no' ) );
            }

            // If no subject given, pick first one from settings
            if ( empty( $subject ) ) {
                $content_settings = get_option( 'dba_content_settings', array() );
                $subjects = $content_settings['subjects'] ?? array();
                $subject = ! empty( $subjects ) ? $subjects[0] : 'Luxembourg holding company';
            }

            $result = $this->create_single_post( $theme, $subject, $language, '', 1 );

            if ( is_wp_error( $result ) ) {
                wp_send_json_error( array( 'message' => $result->get_error_message() ) );
            }
            wp_send_json_success( array( 'message' => 'Post created: ' . $result['title'], 'data' => $result ) );
        } catch ( \Throwable $e ) {
            error_log( 'DBA FATAL: ajax_generate_single crashed: ' . $e->getMessage() . ' in ' . $e->getFile() . ':' . $e->getLine() );
            wp_send_json_error( array( 'message' => 'Fatal error: ' . $e->getMessage() ) );
        }
    }

    public function ajax_generate_batch() {
        check_ajax_referer( 'dba_admin_nonce', 'nonce' );
        if ( ! current_user_can( 'manage_options' ) ) wp_send_json_error( array( 'message' => 'Permission denied' ) );

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

        try {
            $results = $this->create_daily_batch();

            wp_send_json_success( array(
                'message' => sprintf( 'Batch complete! Created: %d, Failed: %d, Skipped: %d',
                    $results['created'], $results['failed'], $results['skipped'] ),
                'data' => $results,
            ) );
        } catch ( \Throwable $e ) {
            error_log( 'DBA FATAL: ajax_generate_batch crashed: ' . $e->getMessage() . ' in ' . $e->getFile() . ':' . $e->getLine() );
            wp_send_json_error( array( 'message' => 'Fatal error: ' . $e->getMessage() ) );
        }
    }

    public function ajax_get_progress() {
        check_ajax_referer( 'dba_admin_nonce', 'nonce' );
        if ( ! current_user_can( 'manage_options' ) ) wp_send_json_error( array( 'message' => 'Permission denied' ) );
        wp_send_json_success( $this->get_progress_data() );
    }

    public function ajax_regenerate_post() {
        check_ajax_referer( 'dba_admin_nonce', 'nonce' );
        if ( ! current_user_can( 'manage_options' ) ) wp_send_json_error( array( 'message' => 'Permission denied' ) );

        $post_id = intval( $_POST['post_id'] ?? 0 );
        if ( ! $post_id ) wp_send_json_error( array( 'message' => 'No post ID' ) );

        @set_time_limit( 300 );

        try {
            $this->set_progress( array(
                'total_posts' => 1, 'completed' => 0, 'failed' => 0,
                'current_post' => 1, 'current_step' => 0, 'current_step_name' => 'starting',
                'current_status' => 'running', 'current_detail' => 'Regenerating post…',
                'step_percent' => 0, 'overall_percent' => 0,
                'started' => time(), 'steps_log' => array(),
            ) );

            $theme    = get_post_meta( $post_id, '_dba_theme',    true ) ?: '';
            $subject  = get_post_meta( $post_id, '_dba_subject',  true ) ?: '';
            $language = get_post_meta( $post_id, '_dba_language',  true ) ?: 'en';

            $result = $this->create_single_post( $theme, $subject, $language, '', 1 );
            if ( is_wp_error( $result ) ) wp_send_json_error( array( 'message' => $result->get_error_message() ) );

            wp_trash_post( $post_id );
            wp_send_json_success( array( 'message' => 'Post regenerated: ' . $result['title'], 'data' => $result ) );
        } catch ( \Throwable $e ) {
            error_log( 'DBA FATAL: ajax_regenerate_post crashed: ' . $e->getMessage() . ' in ' . $e->getFile() . ':' . $e->getLine() );
            wp_send_json_error( array( 'message' => 'Fatal error: ' . $e->getMessage() ) );
        }
    }
}
