<?php
/**
 * DBA GeoBot Generator — City/commune investment article pipeline
 *
 * Completely isolated from core DBA_Post_Creator and PE NewsBot.
 * Country-agnostic: works for France, Italy, Spain, Portugal
 * using config from DBA_GeoBot_Registry.
 *
 * ═══════════════════════════════════════════════════════════════════════
 *  TWO-STAGE PIPELINE (per the France spec):
 *
 *  Stage 1 — RESEARCH (OpenAI + web_search tool):
 *    Find & extract for each commune:
 *      - Official city hall URL (mairie / comune / ayuntamiento / câmara)
 *      - Intercommunality / métropole / EPCI name + URL
 *      - Numeric facts (population, company counts, jobs)
 *      - Major local companies + official domains
 *      - Urban development projects + official page URLs
 *      - Prime streets / commercial locations
 *
 *  Stage 2 — WRITING (OpenAI Chat Completions):
 *    Generate the article from extracted facts using the template.
 *    Hard rules enforced:
 *      - No city link in <h2> title
 *      - City linked once, region linked once
 *      - Every company name linked to official site
 *      - No <hr>, no <strong>
 *      - Sentences must be unique (anti-plagiarism)
 *      - DALL-E featured image with alt SEO
 *      - Yoast focus keyphrase + meta description
 *
 *  Post-generation:
 *    - Validate via DBA_GeoBot_Validator
 *    - Insert WP post (WPML language, category)
 *    - Generate DALL-E image
 *    - Fill Yoast SEO meta
 *    - Log to dba_generation_logs
 *
 *  ── WPML HARDENING (v2.13.0) ───────────────────────────────────────
 *  Ensures Italian/French/Spanish/Portuguese posts are WPML-native:
 *
 *    FIX 1: switch_lang() BEFORE wp_insert_post() — post is born
 *           in the correct language context from the start.
 *
 *    FIX 2: Category resolution uses wpml_object_id to find/create
 *           the TRANSLATED category (e.g. "Investire in Italia" not
 *           "Invest in Italy") — uses wp_category_translated config.
 *
 *    FIX 3: Tags created within switched language context + explicitly
 *           tagged with target language via wpml_set_element_language_details.
 *
 *    FIX 4: source_language_code set to null so the post is the
 *           ORIGINAL in its language — not a "translation of" English.
 *
 *    FIX 5: Post-creation verification checks actual language and
 *           auto-corrects if WPML assigned wrong language.
 *
 *    FIX 6: Language context always restored (even on error paths)
 *           to prevent cascading language pollution.
 *  ─────────────────────────────────────────────────────────────────────
 * ═══════════════════════════════════════════════════════════════════════
 *
 * @since 2.8.0
 * @updated 2.13.0 — WPML hardening for non-English post creation
 * @updated 2.17.0 — Kaizen learning engine + Anti-Orphan protection + structural anti-spam
 */

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

class DBA_GeoBot_Generator {

    private static $instance = null;

    private $max_retries = 2;

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

    private function __construct() {
        // Register AJAX handlers for ALL countries
        add_action( 'wp_ajax_dba_geo_generate_batch', array( $this, 'ajax_generate_batch' ) );
        add_action( 'wp_ajax_dba_geo_get_progress',   array( $this, 'ajax_get_progress' ) );
        add_action( 'wp_ajax_dba_geo_import_cities',   array( $this, 'ajax_import_cities' ) );
        add_action( 'wp_ajax_dba_geo_create_hub_post', array( $this, 'ajax_create_hub_post' ) );
        add_action( 'wp_ajax_dba_geo_delete_hub_post', array( $this, 'ajax_delete_hub_post' ) );
        add_action( 'wp_ajax_dba_geo_flush_cache',     array( $this, 'ajax_flush_cache' ) );
    }

    /* =================================================================
     *  PROGRESS — Per-country transient
     * ================================================================= */

    private function set_progress( string $country_key, array $data ) {
        $config = DBA_GeoBot_Registry::get_country( $country_key );
        if ( $config ) set_transient( $config['progress_transient'], $data, 3600 );
    }

    public function get_progress( string $country_key ) {
        $config = DBA_GeoBot_Registry::get_country( $country_key );
        if ( ! $config ) return array();
        return get_transient( $config['progress_transient'] ) ?: array();
    }

    private function log_step( string $country_key, int $post_index, int $step, string $name, string $status, string $detail = '' ) {
        $progress = $this->get_progress( $country_key );
        $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( $country_key, $progress );
        error_log( "DBA GeoBot [{$country_key}] [{$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' );

        $country_key = sanitize_key( $_POST['country_key'] ?? '' );
        if ( ! DBA_GeoBot_Registry::get_country( $country_key ) ) {
            wp_send_json_error( 'Invalid country key' );
        }

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

        $results = $this->run_batch( $country_key );
        wp_send_json_success( $results );
    }

    public function ajax_get_progress() {
        check_ajax_referer( 'dba_admin_nonce', 'nonce' );

        $country_key = sanitize_key( $_POST['country_key'] ?? '' );
        wp_send_json_success( $this->get_progress( $country_key ) );
    }

    /**
     * AJAX: Import cities from pasted text.
     *
     * Accepted formats (one city per line):
     *   City
     *   City, Department
     *   City, Department, Region
     *   City | Department | Region | Population
     *   Comma-separated or pipe-separated.
     *
     * Can also paste tab-separated (copied from Excel).
     */
    public function ajax_import_cities() {
        check_ajax_referer( 'dba_admin_nonce', 'nonce' );
        if ( ! current_user_can( 'manage_options' ) ) wp_die( 'Unauthorized' );

        $country_key = sanitize_key( $_POST['country_key'] ?? '' );
        $config = DBA_GeoBot_Registry::get_country( $country_key );
        if ( ! $config ) {
            wp_send_json_error( 'Invalid country key: ' . $country_key );
        }

        $raw   = wp_unslash( $_POST['cities_text'] ?? '' );
        $mode  = sanitize_key( $_POST['import_mode'] ?? 'replace' ); // 'replace' or 'append'

        if ( empty( trim( $raw ) ) ) {
            wp_send_json_error( 'No cities provided.' );
        }

        // Parse lines
        $lines = preg_split( '/[\r\n]+/', $raw, -1, PREG_SPLIT_NO_EMPTY );
        $parsed = array();
        $rang = 1;

        foreach ( $lines as $line ) {
            $line = trim( $line );
            if ( $line === '' ) continue;

            // Skip header-like lines
            $lower = mb_strtolower( $line );
            if ( strpos( $lower, 'commune' ) !== false && strpos( $lower, 'region' ) !== false ) continue;
            if ( strpos( $lower, 'city' ) !== false && strpos( $lower, 'department' ) !== false ) continue;
            if ( preg_match( '/^rang\b/i', $line ) ) continue;

            // Detect delimiter: tab > pipe > semicolon > comma
            if ( strpos( $line, "\t" ) !== false ) {
                $parts = explode( "\t", $line );
            } elseif ( strpos( $line, '|' ) !== false ) {
                $parts = explode( '|', $line );
            } elseif ( strpos( $line, ';' ) !== false ) {
                $parts = explode( ';', $line );
            } else {
                $parts = explode( ',', $line );
            }

            $parts = array_map( 'trim', $parts );

            // Skip if first part is purely numeric (likely a rank column)
            $start = 0;
            if ( isset( $parts[0] ) && preg_match( '/^\d+$/', $parts[0] ) ) {
                $start = 1;
            }

            $commune     = $parts[ $start ] ?? '';
            $departement = $parts[ $start + 1 ] ?? '';
            $region      = $parts[ $start + 2 ] ?? '';
            $population  = $parts[ $start + 3 ] ?? '';

            // Clean footnotes like [c], [1], etc.
            $commune     = preg_replace( '/\[[^\]]*\]/', '', $commune );
            $departement = preg_replace( '/\[[^\]]*\]/', '', $departement );
            $region      = preg_replace( '/\[[^\]]*\]/', '', $region );
            $population  = preg_replace( '/\[[^\]]*\]/', '', $population );

            // Normalize non-breaking spaces
            $commune     = str_replace( "\xc2\xa0", ' ', trim( $commune ) );
            $departement = str_replace( "\xc2\xa0", ' ', trim( $departement ) );
            $region      = str_replace( "\xc2\xa0", ' ', trim( $region ) );
            $population  = str_replace( "\xc2\xa0", ' ', trim( $population ) );

            if ( $commune === '' ) continue;

            $parsed[] = array(
                'rang'        => $rang,
                'code'        => '',
                'commune'     => $commune,
                'departement' => $departement,
                'region'      => $region,
                'population'  => $population,
            );
            $rang++;
        }

        if ( empty( $parsed ) ) {
            wp_send_json_error( 'No valid cities found in pasted text.' );
        }

        // Apply mode
        if ( $mode === 'append' ) {
            $existing = get_option( $config['communes_option_key'], array() );
            $existing_names = array_map( function( $c ) {
                return DBA_GeoBot_Registry::normalize_commune( $c['commune'] );
            }, $existing );

            // Dedupe: only add communes not already in the list
            $added = 0;
            foreach ( $parsed as $city ) {
                $slug = DBA_GeoBot_Registry::normalize_commune( $city['commune'] );
                if ( ! in_array( $slug, $existing_names, true ) ) {
                    $city['rang'] = count( $existing ) + 1;
                    $existing[] = $city;
                    $existing_names[] = $slug;
                    $added++;
                }
            }
            update_option( $config['communes_option_key'], $existing, false );
            wp_send_json_success( array(
                'imported'    => $added,
                'duplicates'  => count( $parsed ) - $added,
                'total'       => count( $existing ),
                'mode'        => 'append',
                'country_key' => $country_key,
            ) );
        } else {
            // Replace mode
            update_option( $config['communes_option_key'], $parsed, false );
            wp_send_json_success( array(
                'imported'    => count( $parsed ),
                'duplicates'  => 0,
                'total'       => count( $parsed ),
                'mode'        => 'replace',
                'country_key' => $country_key,
            ) );
        }
    }

    /* =================================================================
     *  HUB/PILLAR POST — WPML-Hardened Creation
     *
     *  Creates the country hub post (e.g. "Investire in Italia: le
     *  nostre analisi per città") in the CORRECT WPML language.
     *
     *  Can be triggered via:
     *    1. Admin button  → ajax_create_hub_post()
     *    2. Auto-creation → called from run_batch() if hub missing
     *
     *  WPML flow:
     *    - switch_lang() BEFORE wp_insert_post()
     *    - wpml_set_element_language_details() AFTER insert
     *    - Category resolved via resolve_wpml_category()
     *    - source_language_code = null (Italian ORIGINAL)
     *    - Post-creation verification
     * ================================================================= */

    /**
     * AJAX: Create hub/pillar post for a country.
     * Triggered by the "Create Hub Post" button in admin.
     */
    public function ajax_create_hub_post() {
        check_ajax_referer( 'dba_admin_nonce', 'nonce' );
        if ( ! current_user_can( 'manage_options' ) ) wp_die( 'Unauthorized' );

        $country_key = sanitize_key( $_POST['country_key'] ?? '' );
        $config = DBA_GeoBot_Registry::get_country( $country_key );
        if ( ! $config ) {
            wp_send_json_error( 'Invalid country key: ' . $country_key );
        }

        // Check if hub already exists
        $existing_id = intval( get_option( 'dba_geo_' . $country_key . '_hub_page_id', 0 ) );
        if ( $existing_id && get_post_status( $existing_id ) ) {
            $force = ! empty( $_POST['force'] );
            if ( ! $force ) {
                wp_send_json_error( array(
                    'message'     => 'Hub post already exists: #' . $existing_id . ' — "' . get_the_title( $existing_id ) . '". Send force=1 to recreate.',
                    'existing_id' => $existing_id,
                    'existing_url' => get_permalink( $existing_id ),
                ) );
            }
            wp_trash_post( $existing_id );
            error_log( "DBA GeoBot [{$country_key}] Hub: Trashed old hub #{$existing_id} for recreation." );
        }

        $result = $this->create_hub_post( $country_key );

        if ( is_wp_error( $result ) ) {
            wp_send_json_error( $result->get_error_message() );
        }

        wp_send_json_success( $result );
    }

    /**
     * AJAX: Delete/reset hub post for a country.
     * Trashes the hub, clears the option, flushes all caches.
     */
    public function ajax_delete_hub_post() {
        check_ajax_referer( 'dba_admin_nonce', 'nonce' );
        if ( ! current_user_can( 'manage_options' ) ) wp_die( 'Unauthorized' );

        $country_key = sanitize_key( $_POST['country_key'] ?? '' );
        $config = DBA_GeoBot_Registry::get_country( $country_key );
        if ( ! $config ) {
            wp_send_json_error( 'Invalid country key: ' . $country_key );
        }

        $hub_id = intval( get_option( 'dba_geo_' . $country_key . '_hub_page_id', 0 ) );
        if ( $hub_id ) {
            wp_trash_post( $hub_id );
            error_log( "DBA GeoBot [{$country_key}] Hub: TRASHED hub post #{$hub_id}" );
        }

        // Delete the option completely
        delete_option( 'dba_geo_' . $country_key . '_hub_page_id' );

        // Nuclear cache flush
        if ( $hub_id ) {
            $this->flush_post_caches( $hub_id, $country_key );
        } else {
            wp_cache_flush();
        }

        wp_send_json_success( array(
            'message'    => 'Hub post deleted and cache flushed. You can now create a new one.',
            'trashed_id' => $hub_id,
        ) );
    }

    /**
     * AJAX: Nuclear cache flush — clears EVERYTHING.
     * Use when stale data persists after updates.
     */
    public function ajax_flush_cache() {
        check_ajax_referer( 'dba_admin_nonce', 'nonce' );
        if ( ! current_user_can( 'manage_options' ) ) wp_die( 'Unauthorized' );

        $country_key = sanitize_key( $_POST['country_key'] ?? '' );

        // Flush WP object cache
        wp_cache_flush();

        // Flush all term caches
        $taxonomies = get_taxonomies();
        foreach ( $taxonomies as $tax ) {
            delete_option( "{$tax}_children" );
        }
        wp_cache_delete( 'all_ids', 'category' );
        wp_cache_delete( 'get_terms', 'terms' );

        // WPML caches
        if ( function_exists( 'icl_cache_clear' ) ) {
            icl_cache_clear();
        }
        delete_transient( 'wpml_st_string_translations' );

        // GeoBot transients (all countries)
        foreach ( DBA_GeoBot_Registry::get_countries() as $key => $cfg ) {
            delete_transient( $cfg['progress_transient'] );
            delete_transient( 'dba_geo_' . $key . '_last_run_cache' );
        }

        // Page cache plugins
        if ( function_exists( 'wp_cache_clear_cache' ) ) wp_cache_clear_cache();
        if ( function_exists( 'w3tc_flush_all' ) ) w3tc_flush_all();
        if ( function_exists( 'rocket_clean_domain' ) ) rocket_clean_domain();
        if ( has_action( 'litespeed_purge_all' ) ) do_action( 'litespeed_purge_all' );
        if ( function_exists( 'sg_cachepress_purge_cache' ) ) sg_cachepress_purge_cache();
        if ( function_exists( 'wpfc_clear_all_cache' ) ) wpfc_clear_all_cache();
        if ( function_exists( 'et_core_clear_wp_cache' ) ) et_core_clear_wp_cache();
        if ( has_action( 'cachify_flush_cache' ) ) do_action( 'cachify_flush_cache' );
        if ( has_action( 'breeze_purge_all_cache' ) ) do_action( 'breeze_purge_all_cache' );

        error_log( "DBA GeoBot: NUCLEAR CACHE FLUSH executed by admin" );

        wp_send_json_success( array( 'message' => 'All caches flushed.' ) );
    }

    /**
     * Create the hub/pillar post for a country — FULLY WPML-hardened.
     *
     * @param string $country_key  e.g. 'italy', 'france'
     * @return array|WP_Error      { post_id, title, permalink, language }
     */
    public function create_hub_post( string $country_key ) {

        $config = DBA_GeoBot_Registry::get_country( $country_key );
        if ( ! $config ) {
            return new WP_Error( 'invalid_country', "Unknown country: {$country_key}" );
        }
        $settings = get_option( $config['settings_option'], array() );
        $target_lang = $config['wpml_code'];

        error_log( "DBA GeoBot [{$country_key}] Hub: Creating hub/pillar post in '{$target_lang}'…" );

        /* ── Hub content templates per language ── */
        $hub_templates = $this->get_hub_templates();
        $tpl = $hub_templates[ $target_lang ] ?? $hub_templates['en'];

        $country_native = $config['country_label'] ?? ucfirst( $country_key );
        $hub_title   = str_replace( '{COUNTRY}', $country_native, $tpl['title'] );
        $hub_content = str_replace( '{COUNTRY}', $country_native, $tpl['content'] );
        $hub_excerpt = str_replace( '{COUNTRY}', $country_native, $tpl['excerpt'] );
        $hub_slug    = str_replace( '{country_slug}', sanitize_title( $country_native ), $tpl['slug'] );

        /* ═══ WPML: Switch language BEFORE wp_insert_post() ═══ */
        $wpml_switched  = false;
        $wpml_prev_lang = '';

        if ( function_exists( 'icl_get_languages' ) ) {
            global $sitepress;
            if ( $sitepress ) {
                $wpml_prev_lang = $sitepress->get_current_language();
                $sitepress->switch_lang( $target_lang, true );
                $wpml_switched = true;
                error_log( "DBA GeoBot [{$country_key}] Hub WPML: Switched {$wpml_prev_lang} → {$target_lang}" );
            }
        }

        /* ── Insert post ── */
        $post_args = array(
            'post_title'   => sanitize_text_field( $hub_title ),
            'post_content' => wp_kses_post( $hub_content ),
            'post_excerpt' => sanitize_text_field( $hub_excerpt ),
            'post_name'    => sanitize_title( $hub_slug ),
            'post_status'  => 'publish',
            'post_type'    => 'post',
            'post_author'  => $settings['author_id'] ?? 1,
        );

        $post_id = wp_insert_post( $post_args, true );

        if ( is_wp_error( $post_id ) ) {
            if ( $wpml_switched && isset( $sitepress ) ) {
                $sitepress->switch_lang( $wpml_prev_lang, true );
            }
            return $post_id;
        }

        /* ── WPML: Set language explicitly ── */
        if ( function_exists( 'wpml_set_element_language_details' ) ) {
            $trid = apply_filters( 'wpml_element_trid', null, $post_id, 'post_post' );
            do_action( 'wpml_set_element_language_details', array(
                'element_id'           => $post_id,
                'element_type'         => 'post_post',
                'trid'                 => $trid,
                'language_code'        => $target_lang,
                'source_language_code' => null,
            ) );
            error_log( "DBA GeoBot [{$country_key}] Hub WPML: Set post #{$post_id} → '{$target_lang}'" );
        }

        /* ── Meta ── */
        update_post_meta( $post_id, $config['meta_prefix'] . 'auto_created', '1' );
        update_post_meta( $post_id, $config['meta_prefix'] . 'hub_post', '1' );
        update_post_meta( $post_id, $config['meta_prefix'] . 'country', $country_key );
        update_post_meta( $post_id, '_dba_auto_created', '1' );

        /* ── Category: Resolve WPML-translated category ── */
        $cat_name    = $config['wp_category'] ?? '';
        $cat_name_tr = $config['wp_category_translated'] ?? '';
        $resolved_cat_id = $this->resolve_wpml_category( $cat_name, $cat_name_tr, $target_lang );

        if ( $resolved_cat_id ) {
            // CRITICAL: Use FALSE (not append) — this REPLACES all categories, removing any stale English ones
            wp_set_post_categories( $post_id, array( intval( $resolved_cat_id ) ), false );
            error_log( "DBA GeoBot [{$country_key}] Hub: Assigned translated cat #{$resolved_cat_id} (stripped all other categories)" );
        } else {
            $create_name = ! empty( $cat_name_tr ) ? $cat_name_tr : $cat_name;
            $term = term_exists( $create_name, 'category' );
            if ( ! $term ) $term = wp_insert_term( $create_name, 'category' );
            if ( ! is_wp_error( $term ) ) {
                $tid = is_array( $term ) ? $term['term_id'] : intval( $term );
                wp_set_post_categories( $post_id, array( $tid ), false );
                if ( function_exists( 'wpml_set_element_language_details' ) ) {
                    $term_trid = apply_filters( 'wpml_element_trid', null, $tid, 'tax_category' );
                    do_action( 'wpml_set_element_language_details', array(
                        'element_id'    => $tid,
                        'element_type'  => 'tax_category',
                        'trid'          => $term_trid,
                        'language_code' => $target_lang,
                    ) );
                }
            }
        }

        /* ── Yoast SEO ── */
        $yoast_title = mb_substr( $hub_title, 0, 60 );
        $yoast_desc  = mb_substr( $hub_excerpt, 0, 155 );
        update_post_meta( $post_id, '_yoast_wpseo_focuskw',  sanitize_text_field( str_replace( '{COUNTRY}', $country_native, $tpl['focus_kw'] ) ) );
        update_post_meta( $post_id, '_yoast_wpseo_title',    sanitize_text_field( $yoast_title ) );
        update_post_meta( $post_id, '_yoast_wpseo_metadesc', sanitize_text_field( $yoast_desc ) );

        /* ── Store hub page ID ── */
        update_option( 'dba_geo_' . $country_key . '_hub_page_id', $post_id, false );

        /* ── Restore WPML language ── */
        if ( $wpml_switched && isset( $sitepress ) && $sitepress ) {
            $sitepress->switch_lang( $wpml_prev_lang, true );
        }

        /* ── WPML: Verify ── */
        if ( function_exists( 'wpml_get_language_information' ) ) {
            $lang_info   = apply_filters( 'wpml_post_language_details', null, $post_id );
            $actual_lang = $lang_info['language_code'] ?? 'unknown';
            if ( $actual_lang !== $target_lang ) {
                error_log( "DBA GeoBot [{$country_key}] Hub ⚠ MISMATCH: '{$actual_lang}' → forcing '{$target_lang}'" );
                $trid_fix = apply_filters( 'wpml_element_trid', null, $post_id, 'post_post' );
                do_action( 'wpml_set_element_language_details', array(
                    'element_id'           => $post_id,
                    'element_type'         => 'post_post',
                    'trid'                 => $trid_fix,
                    'language_code'        => $target_lang,
                    'source_language_code' => null,
                ) );
            } else {
                error_log( "DBA GeoBot [{$country_key}] Hub ✓ VERIFIED: #{$post_id} in '{$actual_lang}'" );
            }
        }

        error_log( "DBA GeoBot [{$country_key}] Hub: Created #{$post_id} \"{$hub_title}\" lang={$target_lang}" );

        /* ── Flush all caches ── */
        $this->flush_post_caches( $post_id, $country_key );

        // ── TIS: track directive progress ──
        if ( class_exists( 'DBA_TIS_Recommender' ) ) {
            DBA_TIS_Recommender::auto_track_progress( 'geobot', $hub_title );
        }

        return array(
            'post_id'   => $post_id,
            'title'     => $hub_title,
            'permalink' => get_permalink( $post_id ),
            'language'  => $target_lang,
        );
    }

    /**
     * Hub post content templates — one per language.
     * {COUNTRY} is replaced with the native country name.
     * <!-- GEOBOT_CITY_LIST --> is updated by the linker when city posts are published.
     */
    private function get_hub_templates() {
        return array(
            'it' => array(
                'title'    => 'Investire in {COUNTRY}: le nostre analisi per città',
                'slug'     => 'investire-in-{country_slug}-analisi-per-citta',
                'excerpt'  => 'Guida completa agli investimenti nelle principali città italiane. Analisi economica, settori chiave, imprese e opportunità per investitori e family office.',
                'focus_kw' => 'investire in {COUNTRY}',
                'content'  => '<p>Questa sezione raccoglie le nostre analisi approfondite sulle opportunità di investimento nelle principali città italiane. Per ogni città, esaminiamo il tessuto economico, i settori trainanti, le imprese di riferimento e i progetti di sviluppo urbano.</p>

<p>Le analisi sono destinate a investitori istituzionali, family office, fondi di private equity e imprenditori che desiderano comprendere le dinamiche locali prima di investire in {COUNTRY}.</p>

<h2>Le nostre analisi per città</h2>

<!-- GEOBOT_CITY_LIST -->

<p>Nuove analisi vengono pubblicate regolarmente. Ogni articolo fornisce dati concreti su popolazione, base imprenditoriale, settori economici, principali aziende locali e progetti di trasformazione urbana.</p>

<h2>Perché investire in {COUNTRY}</h2>

<p>L\'{COUNTRY} offre un tessuto imprenditoriale diversificato, con eccellenze nei settori manifatturiero, agroalimentare, moda, turismo, tecnologia e servizi finanziari. Le città italiane presentano caratteristiche economiche uniche che meritano un\'analisi individuale.</p>

<h2>Il nostro approccio</h2>

<p>Damalion accompagna investitori internazionali, imprenditori e family office nella strutturazione e nella conformità delle loro operazioni in {COUNTRY}. Le nostre analisi per città forniscono il contesto locale necessario per decisioni di investimento informate.</p>',
            ),
            'fr' => array(
                'title'    => 'Investir en {COUNTRY} : nos analyses par ville',
                'slug'     => 'investir-en-{country_slug}-analyses-par-ville',
                'excerpt'  => 'Guide complète des investissements dans les principales villes françaises. Analyse économique, secteurs clés, entreprises et opportunités.',
                'focus_kw' => 'investir en {COUNTRY}',
                'content'  => '<p>Cette section rassemble nos analyses approfondies sur les opportunités d\'investissement dans les principales villes françaises.</p>

<h2>Nos analyses par ville</h2>

<!-- GEOBOT_CITY_LIST -->

<p>De nouvelles analyses sont publiées régulièrement.</p>',
            ),
            'es' => array(
                'title'    => 'Invertir en {COUNTRY}: nuestros análisis por ciudad',
                'slug'     => 'invertir-en-{country_slug}-analisis-por-ciudad',
                'excerpt'  => 'Guía completa de inversiones en las principales ciudades. Análisis económico, sectores clave, empresas y oportunidades.',
                'focus_kw' => 'invertir en {COUNTRY}',
                'content'  => '<p>Esta sección reúne nuestros análisis detallados sobre las oportunidades de inversión en las principales ciudades.</p>

<h2>Nuestros análisis por ciudad</h2>

<!-- GEOBOT_CITY_LIST -->

<p>Se publican nuevos análisis regularmente.</p>',
            ),
            'pt' => array(
                'title'    => 'Investir em {COUNTRY}: as nossas análises por cidade',
                'slug'     => 'investir-em-{country_slug}-analises-por-cidade',
                'excerpt'  => 'Guia completo de investimentos nas principais cidades. Análise económica, setores-chave, empresas e oportunidades.',
                'focus_kw' => 'investir em {COUNTRY}',
                'content'  => '<p>Esta secção reúne as nossas análises aprofundadas sobre as oportunidades de investimento nas principais cidades.</p>

<h2>As nossas análises por cidade</h2>

<!-- GEOBOT_CITY_LIST -->

<p>Novas análises são publicadas regularmente.</p>',
            ),
            'en' => array(
                'title'    => 'Investing in {COUNTRY}: Our City-by-City Analysis',
                'slug'     => 'investing-in-{country_slug}-city-analysis',
                'excerpt'  => 'Comprehensive guide to investment opportunities across major cities.',
                'focus_kw' => 'investing in {COUNTRY}',
                'content'  => '<p>This section gathers our in-depth analyses of investment opportunities across major cities.</p>

<h2>Our city-by-city analyses</h2>

<!-- GEOBOT_CITY_LIST -->

<p>New analyses are published regularly.</p>',
            ),
        );
    }

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

    /**
     * Run a batch of posts for one country.
     *
     * @param string $country_key
     * @return array { created, failed, details[] }
     */
    public function run_batch( string $country_key ) {

        $config   = DBA_GeoBot_Registry::get_country( $country_key );
        $settings = get_option( $config['settings_option'], array() );
        $posts_target = max( 1, intval( $settings['posts_per_run'] ?? $config['default_posts_per_run'] ) );

        /* ═══════════════════════════════════════════════════════════
         *  HUB POST CHECK — warn if missing but DO NOT auto-create.
         *  The admin creates the hub manually via the "Create Hub Post"
         *  button in the dashboard. Batch generation works without it.
         * ═══════════════════════════════════════════════════════════ */
        $hub_id = intval( get_option( 'dba_geo_' . $country_key . '_hub_page_id', 0 ) );
        if ( ! $hub_id || ! get_post_status( $hub_id ) || get_post_status( $hub_id ) === 'trash' ) {
            error_log( "DBA GeoBot [{$country_key}] Batch: ⚠ No hub post found. City posts will still be created but hub linking will be skipped. Use the 'Create Hub Post' button in the admin dashboard to create it." );
        }

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

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

        for ( $i = 0; $i < $posts_target; $i++ ) {
            $post_num = $i + 1;

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

            if ( is_wp_error( $result ) ) {
                $results['failed']++;
                $results['details'][] = array(
                    'status' => 'failed',
                    'error'  => $result->get_error_message(),
                );
                error_log( "DBA GeoBot [{$country_key}]: Post {$post_num} FAILED — " . $result->get_error_message() );
            } else {
                $results['created']++;
                $results['details'][] = $result;
            }

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

            // Breathing room
            if ( $i < $posts_target - 1 ) {
                sleep( 5 );
            }
        }

        // Store last run
        update_option( $config['last_run_option'], array(
            'time'    => current_time( 'mysql' ),
            'results' => $results,
        ), false );

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

        return $results;
    }

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

    private function create_single_post( string $country_key, int $post_index ) {

        $config    = DBA_GeoBot_Registry::get_country( $country_key );
        $settings  = get_option( $config['settings_option'], array() );
        $validator = DBA_GeoBot_Validator::get_instance();

        /* ── STEP 1: Pick commune ── */
        $this->log_step( $country_key, $post_index, 1, 'pick_commune', 'running', 'Selecting next commune…' );

        $commune_data = DBA_GeoBot_Dedupe::pick_next_commune( $country_key );

        if ( ! $commune_data ) {
            $this->log_step( $country_key, $post_index, 1, 'pick_commune', 'failed', 'No communes available (all on cooldown)' );
            return new WP_Error( 'no_commune', 'All communes are on cooldown' );
        }

        $commune = $commune_data['commune'];
        $this->log_step( $country_key, $post_index, 1, 'pick_commune', 'done', "Selected: {$commune}" );

        /* ── STEP 2: Dedupe reserve ── */
        $this->log_step( $country_key, $post_index, 2, 'dedupe', 'running', 'Reserving commune…' );

        $dedupe = DBA_GeoBot_Dedupe::validate_and_reserve( $country_key, array(
            'date'    => current_time( 'Y-m-d' ),
            'commune' => $commune,
        ) );

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

        $this->log_step( $country_key, $post_index, 2, 'dedupe', 'done', 'Reserved ✓' );

        /* ── STEP 3: Research via OpenAI + web_search ── */
        $this->log_step( $country_key, $post_index, 3, 'research', 'running', "Researching {$commune}…" );

        $research = $this->research_commune( $country_key, $commune_data );

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

        $this->log_step( $country_key, $post_index, 3, 'research', 'done', 'Research data collected ✓' );

        /* ── STEP 4: Generate article ── */
        $this->log_step( $country_key, $post_index, 4, 'generate', 'running', "Writing article for {$commune}…" );

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

        for ( $attempt = 0; $attempt <= $this->max_retries; $attempt++ ) {
            if ( $attempt > 0 ) {
                $this->log_step( $country_key, $post_index, 4, 'generate', 'running', "Retry #{$attempt}…" );
                sleep( 3 );
            }

            $article = $this->generate_article( $country_key, $commune_data, $research );

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

            /* ── STEP 5: Validate ── */
            $this->log_step( $country_key, $post_index, 5, 'validate', 'running', 'Running validation gates…' );

            // Clean up HTML before validation
            $article['content_html'] = DBA_GeoBot_Validator::cleanup_html(
                $article['content_html'], $country_key
            );

            $val_errors = $validator->validate( $article, $country_key, $commune );

            if ( ! empty( $val_errors ) ) {
                $last_errors[] = 'Validation: ' . implode( '; ', $val_errors );
                $this->log_step( $country_key, $post_index, 5, 'validate', 'failed', implode( ' | ', $val_errors ) );
                $article = null;
                continue;
            }

            $this->log_step( $country_key, $post_index, 5, 'validate', 'done', 'All gates passed ✓' );
            break;
        }

        if ( ! $article ) {
            return new WP_Error( 'generation_fail', 'All retries exhausted: ' . implode( ' → ', $last_errors ) );
        }

        /* ── STEP 5b: PLAGIARISM CHECK — Full DBA_Plagiarism hard gate ── */
        $this->log_step( $country_key, $post_index, 5, 'plagiarism', 'running', 'Running plagiarism detector (internal + Copyscape + web sampling)…' );

        $plagiarism = DBA_Plagiarism::get_instance();
        $plag_result = $plagiarism->check( $article['content_html'], 0 );

        if ( ! $plag_result['pass'] ) {
            $plag_detail = implode( ' | ', $plag_result['details'] ?? array() );
            $this->log_step( $country_key, $post_index, 5, 'plagiarism', 'failed',
                "Score: {$plag_result['score']}% — {$plag_detail}" );
            return new WP_Error( 'plagiarism_fail',
                "Plagiarism check FAILED (score: {$plag_result['score']}%): {$plag_detail}" );
        }

        $this->log_step( $country_key, $post_index, 5, 'plagiarism', 'done',
            "Uniqueness: {$plag_result['score']}% via {$plag_result['method']} ✓" );

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

        $publish_dt = DBA_GeoBot_Scheduler::get_publish_datetime( $country_key, ( $post_index - 1 ) * 3 );

        // ── 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'], $config['wpml_code'] ?? 'en' );
        }

        // Append Related Cities + CTA
        $linker = DBA_GeoBot_Linker::get_instance();
        $related_block = $linker->build_related_cities_html( $country_key, $commune, $commune_data['region'] ?? '' );
        $cta_block = $this->build_cta_html( $country_key );
        $final_content = $article['content_html'] . $related_block . $cta_block;

        /* ═══════════════════════════════════════════════════════════════
         *  WPML LANGUAGE SWITCH — BEFORE post creation
         *
         *  Critical: WPML determines a post's language at insertion time
         *  based on the active language context. We MUST switch BEFORE
         *  wp_insert_post() so the post is born in the correct language.
         *  Category and tag assignments must also happen within this
         *  switched context so they resolve to translated terms.
         * ═══════════════════════════════════════════════════════════════ */
        $wpml_switched  = false;
        $wpml_prev_lang = '';
        $target_lang    = $config['wpml_code'];  // e.g. 'it', 'fr', 'es', 'pt'

        if ( function_exists( 'icl_get_languages' ) ) {
            global $sitepress;
            if ( $sitepress ) {
                $wpml_prev_lang = $sitepress->get_current_language();
                $sitepress->switch_lang( $target_lang, true );
                $wpml_switched = true;
                error_log( "DBA GeoBot [{$country_key}] WPML: Switched language context {$wpml_prev_lang} → {$target_lang} BEFORE wp_insert_post()" );
            }
        }

        $post_args = 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'   => $settings['post_status'] ?? 'publish',
            'post_type'     => 'post',
            'post_author'   => $settings['author_id'] ?? 1,
            'post_date'     => $publish_dt['post_date'],
            'post_date_gmt' => $publish_dt['post_date_gmt'],
        );

        $post_id = wp_insert_post( $post_args, true );

        if ( is_wp_error( $post_id ) ) {
            // Restore language before returning
            if ( $wpml_switched && $sitepress ) {
                $sitepress->switch_lang( $wpml_prev_lang, true );
            }
            $this->log_step( $country_key, $post_index, 6, 'create_post', 'failed', $post_id->get_error_message() );
            return $post_id;
        }

        /* ── WPML: Explicitly set post language (belt + suspenders) ── */
        if ( function_exists( 'wpml_set_element_language_details' ) ) {
            // Get trid FIRST — if post was auto-assigned to default lang, this corrects it
            $trid = apply_filters( 'wpml_element_trid', null, $post_id, 'post_post' );
            do_action( 'wpml_set_element_language_details', array(
                'element_id'           => $post_id,
                'element_type'         => 'post_post',
                'trid'                 => $trid,
                'language_code'        => $target_lang,
                'source_language_code' => null,  // null = this IS the original, not a translation
            ) );
            error_log( "DBA GeoBot [{$country_key}] WPML: Set post #{$post_id} language to '{$target_lang}' (trid={$trid})" );
        }

        // Tag with GeoBot meta (isolated)
        update_post_meta( $post_id, $config['meta_prefix'] . 'auto_created', '1' );
        update_post_meta( $post_id, $config['meta_prefix'] . 'commune',      $commune );
        update_post_meta( $post_id, $config['meta_prefix'] . 'region',       $commune_data['region'] ?? '' );
        update_post_meta( $post_id, $config['meta_prefix'] . 'country',      $country_key );
        update_post_meta( $post_id, '_dba_auto_created', '1' ); // Also tag for core detection

        // ── KAIZEN: Store original AI output for learning diff ──
        if ( class_exists( 'DBA_GeoBot_Kaizen' ) ) {
            DBA_GeoBot_Kaizen::store_original(
                $post_id,
                $article['title'],
                $article['content_html'],
                $article['excerpt'] ?? ''
            );
            $this->log_step( $country_key, $post_index, 6, 'create_post', 'running', 'Kaizen: stored original content for learning ✓' );
        }

        /* ── CATEGORY: Resolve in the CORRECT WPML language ──
         *
         *  WPML stores translated terms with separate term_ids.
         *  term_exists('Invest in Italy') finds the English term.
         *  We need the ITALIAN translated term_id for ?lang=it posts.
         *
         *  Strategy:
         *    1. Find or create the English/default category (anchor term)
         *    2. Use wpml_object_id filter to get the translated term_id
         *    3. If no translation exists, create one via WPML API
         *    4. Assign the TRANSLATED term_id to the post
         * ════════════════════════════════════════════════════════════ */
        $cat_name    = $config['wp_category'];
        $cat_name_it = $config['wp_category_translated'] ?? ''; // e.g. 'Investire in Italia'

        $resolved_cat_id = $this->resolve_wpml_category( $cat_name, $cat_name_it, $target_lang );

        if ( $resolved_cat_id ) {
            wp_set_post_categories( $post_id, array( intval( $resolved_cat_id ) ), false );
            error_log( "DBA GeoBot [{$country_key}] Category: Assigned translated cat #{$resolved_cat_id} (lang={$target_lang}) to post #{$post_id}" );
        } else {
            // Fallback: create/use the category by name in current (switched) language context
            $term = term_exists( $cat_name, 'category' );
            if ( ! $term && ! empty( $cat_name_it ) ) {
                $term = term_exists( $cat_name_it, 'category' );
            }
            if ( ! $term ) {
                // Create in the currently-switched language context
                $create_name = ! empty( $cat_name_it ) ? $cat_name_it : $cat_name;
                $term = wp_insert_term( $create_name, 'category' );
                error_log( "DBA GeoBot [{$country_key}] Category: Created NEW category '{$create_name}' in {$target_lang} context" );
            }
            if ( ! is_wp_error( $term ) ) {
                $tid = is_array( $term ) ? $term['term_id'] : $term;
                wp_set_post_categories( $post_id, array( intval( $tid ) ), false );

                // Also set this term's language via WPML
                if ( function_exists( 'wpml_set_element_language_details' ) ) {
                    $term_trid = apply_filters( 'wpml_element_trid', null, $tid, 'tax_category' );
                    do_action( 'wpml_set_element_language_details', array(
                        'element_id'    => $tid,
                        'element_type'  => 'tax_category',
                        'trid'          => $term_trid,
                        'language_code' => $target_lang,
                    ) );
                    error_log( "DBA GeoBot [{$country_key}] Category: Set term #{$tid} language to '{$target_lang}'" );
                }
            }
        }

        /* ── TAGS: Set within the switched language context ── */
        $tags = array( $commune, $commune_data['region'] ?? '', $commune_data['departement'] ?? '' );
        $tags = array_filter( array_map( 'trim', $tags ) );
        if ( ! empty( $tags ) ) {
            wp_set_post_tags( $post_id, array_slice( $tags, 0, 3 ), false );

            // WPML: set tag languages
            if ( function_exists( 'wpml_set_element_language_details' ) ) {
                $post_tags = wp_get_post_tags( $post_id, array( 'fields' => 'ids' ) );
                foreach ( $post_tags as $tag_id ) {
                    $tag_trid = apply_filters( 'wpml_element_trid', null, $tag_id, 'tax_post_tag' );
                    do_action( 'wpml_set_element_language_details', array(
                        'element_id'    => $tag_id,
                        'element_type'  => 'tax_post_tag',
                        'trid'          => $tag_trid,
                        'language_code' => $target_lang,
                    ) );
                }
                error_log( "DBA GeoBot [{$country_key}] Tags: Set " . count( $post_tags ) . " tag(s) to lang '{$target_lang}'" );
            }
        }

        /* ── WPML: Restore previous language context ── */
        if ( $wpml_switched && isset( $sitepress ) && $sitepress ) {
            $sitepress->switch_lang( $wpml_prev_lang, true );
            error_log( "DBA GeoBot [{$country_key}] WPML: Restored language context → {$wpml_prev_lang}" );
        }

        /* ── WPML: FINAL VERIFICATION — abort if post landed in wrong language ── */
        if ( function_exists( 'wpml_get_language_information' ) ) {
            $lang_info = apply_filters( 'wpml_post_language_details', null, $post_id );
            $actual_lang = $lang_info['language_code'] ?? 'unknown';
            if ( $actual_lang !== $target_lang ) {
                error_log( "DBA GeoBot [{$country_key}] ⚠ WPML MISMATCH: Post #{$post_id} is '{$actual_lang}' but should be '{$target_lang}' — attempting forced correction" );
                // Force correction
                $trid_fix = apply_filters( 'wpml_element_trid', null, $post_id, 'post_post' );
                do_action( 'wpml_set_element_language_details', array(
                    'element_id'           => $post_id,
                    'element_type'         => 'post_post',
                    'trid'                 => $trid_fix,
                    'language_code'        => $target_lang,
                    'source_language_code' => null,
                ) );
                // Re-verify
                $lang_info2 = apply_filters( 'wpml_post_language_details', null, $post_id );
                $actual_lang2 = $lang_info2['language_code'] ?? 'unknown';
                if ( $actual_lang2 === $target_lang ) {
                    error_log( "DBA GeoBot [{$country_key}] ✓ WPML: Forced correction SUCCEEDED — post #{$post_id} now '{$actual_lang2}'" );
                } else {
                    error_log( "DBA GeoBot [{$country_key}] ✗ WPML: Forced correction FAILED — post #{$post_id} still '{$actual_lang2}'. Manual fix required." );
                }
            } else {
                error_log( "DBA GeoBot [{$country_key}] ✓ WPML VERIFIED: Post #{$post_id} confirmed in '{$actual_lang}'" );
            }
        }

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

        /* ── STEP 7: Yoast SEO ── */
        $this->log_step( $country_key, $post_index, 7, 'yoast_seo', 'running', 'Setting SEO meta…' );
        $this->fill_yoast_seo( $post_id, $article, $config );
        $this->log_step( $country_key, $post_index, 7, 'yoast_seo', 'done', 'Yoast SEO filled ✓' );

        /* ── STEP 8: Featured image via DALL-E ── */
        $media_id = 0;
        if ( ! empty( $article['featured_image']['prompt'] ) ) {
            $this->log_step( $country_key, $post_index, 8, 'image', 'running', 'Generating DALL-E image…' );
            $img_gen = DBA_Image_Generator::get_instance();
            $img_result = $img_gen->generate_and_upload( $article['featured_image'], $post_id );
            if ( ! is_wp_error( $img_result ) ) {
                $media_id = $img_result;
                set_post_thumbnail( $post_id, $media_id );
                $this->log_step( $country_key, $post_index, 8, 'image', 'done', "Image #{$media_id} ✓" );
            } else {
                $this->log_step( $country_key, $post_index, 8, 'image', 'failed', $img_result->get_error_message() );
            }
        }

        /* ── STEP 9: Log ── */
        $logger = DBA_Logger::get_instance();
        $logger->log( array(
            'run_date'          => current_time( 'Y-m-d' ),
            'run_time'          => current_time( 'mysql' ),
            'theme'             => 'GeoBot/' . ucfirst( $country_key ) . '/' . $commune,
            'title'             => $article['title'],
            'slug'              => $article['slug'] ?? '',
            'post_id'           => $post_id,
            'media_id'          => $media_id,
            'language'          => $config['language'],
            'json_output'       => wp_json_encode( $article ),
            'validation_result' => 'PASS | Plagiarism: ' . ( $plag_result['score'] ?? '?' ) . '% via ' . ( $plag_result['method'] ?? 'unknown' ),
            'retry_attempts'    => 0,
            'status'            => 'published',
            'error_trace'       => '',
        ) );

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

        /* ── STEP 9b: FLUSH ALL CACHES — no stale data ── */
        $this->flush_post_caches( $post_id, $country_key );

        /* ── STEP 10: Internal Linking + Anti-Orphan Protection ── */
        $hub_id = intval( get_option( 'dba_geo_' . $country_key . '_hub_page_id', 0 ) );
        if ( $hub_id && get_post_status( $hub_id ) && get_post_status( $hub_id ) !== 'trash' ) {
            $this->log_step( $country_key, $post_index, 10, 'internal_links', 'running', 'Updating hub page, cross-links & anti-orphan…' );

            // Existing linker (hub list update + related cities in content)
            $linker = DBA_GeoBot_Linker::get_instance();
            $linker->on_post_published( $country_key, $post_id );

            // Anti-Orphan pipeline (hub link + backlinks + outbound links + verification cron)
            if ( class_exists( 'DBA_GeoBot_Anti_Orphan' ) ) {
                $anti_orphan = DBA_GeoBot_Anti_Orphan::get_instance();
                $anti_orphan->on_post_created(
                    $country_key,
                    $post_id,
                    $commune,
                    $commune_data['region'] ?? ''
                );
            }

            $this->log_step( $country_key, $post_index, 10, 'internal_links', 'done', 'Hub page updated + anti-orphan pipeline complete ✓' );
        } else {
            $this->log_step( $country_key, $post_index, 10, 'internal_links', 'skipped', 'No hub post found — hub linking skipped. Create the hub post from the admin dashboard.' );

            // Even without hub, still do cross-linking between city posts
            if ( class_exists( 'DBA_GeoBot_Anti_Orphan' ) ) {
                $anti_orphan = DBA_GeoBot_Anti_Orphan::get_instance();
                $anti_orphan->inject_backlinks_from_related( $country_key, $post_id, $commune, $commune_data['region'] ?? '' );
                $anti_orphan->append_related_to_new_post( $country_key, $post_id, $commune_data['region'] ?? '' );
            }
        }

        // ── TIS: track directive progress ──
        if ( class_exists( 'DBA_TIS_Recommender' ) ) {
            DBA_TIS_Recommender::auto_track_progress( 'geobot', $article['title'] );
        }

        return array(
            'status'    => 'published',
            'post_id'   => $post_id,
            'title'     => $article['title'],
            'commune'   => $commune,
            'permalink' => get_permalink( $post_id ),
            'media_id'  => $media_id,
        );
    }

    /* =================================================================
     *  STAGE 1: RESEARCH — OpenAI Responses API + web_search
     * ================================================================= */

    /**
     * Research a commune via OpenAI with web_search tool.
     * Returns structured data about the commune.
     *
     * @param string $country_key
     * @param array  $commune_data  { commune, code, departement, region, population }
     * @return array|WP_Error       Structured research data.
     */
    private function research_commune( string $country_key, array $commune_data ) {

        $config   = DBA_GeoBot_Registry::get_country( $country_key );
        $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' );
        }

        $commune = $commune_data['commune'];
        $dept    = $commune_data['departement'] ?? '';
        $region  = $commune_data['region'] ?? '';
        $pop     = $commune_data['population'] ?? '';

        $country_names = array(
            'france'   => 'France',
            'italy'    => 'Italy',
            'spain'    => 'Spain',
            'portugal' => 'Portugal',
        );
        $country_name = $country_names[ $country_key ] ?? ucfirst( $country_key );

        /* ── Language-specific research directives ── */
        $lang_map_research = array(
            'it' => array(
                'city_term' => 'comune',
                'dept_term' => 'Provincia',
                'region_term' => 'Regione',
                'output_note' => 'All descriptions, sector names, and project descriptions in the JSON output MUST be written in ITALIAN (italiano). Do NOT use English.',
            ),
            'fr' => array(
                'city_term' => 'commune / mairie',
                'dept_term' => 'Département',
                'region_term' => 'Région',
                'output_note' => 'All descriptions, sector names, and project descriptions in the JSON output MUST be written in FRENCH (français). Do NOT use English.',
            ),
            'es' => array(
                'city_term' => 'municipio / ayuntamiento',
                'dept_term' => 'Provincia',
                'region_term' => 'Comunidad Autónoma',
                'output_note' => 'All descriptions, sector names, and project descriptions in the JSON output MUST be written in SPANISH (español). Do NOT use English.',
            ),
            'pt' => array(
                'city_term' => 'município / câmara municipal',
                'dept_term' => 'Distrito',
                'region_term' => 'Região',
                'output_note' => 'All descriptions, sector names, and project descriptions in the JSON output MUST be written in PORTUGUESE (português). Do NOT use English.',
            ),
        );
        $lang_config = $lang_map_research[ $config['language'] ] ?? array(
            'city_term' => 'city/commune',
            'dept_term' => 'Department/Province',
            'region_term' => 'Region',
            'output_note' => '',
        );

        // Research prompt
        $search_prompt = "Research the city/commune of {$commune} in {$region}, {$country_name} for an investment-focused article.\n\n";
        $search_prompt .= "Known data: {$lang_config['dept_term']}: {$dept}. {$lang_config['region_term']}: {$region}. Population: {$pop}.\n\n";

        // CRITICAL: Force output language
        if ( ! empty( $lang_config['output_note'] ) ) {
            $search_prompt .= "⚠ LANGUAGE REQUIREMENT: {$lang_config['output_note']}\n\n";
        }
        $search_prompt .= "Find and return ALL of the following as valid JSON:\n\n";
        $search_prompt .= "1. \"city_url\": Official city hall website URL (mairie, comune, ayuntamiento, câmara municipal)\n";
        $search_prompt .= "2. \"metro_name\": Name of the intercommunality, métropole, EPCI, or metropolitan area\n";
        $search_prompt .= "3. \"metro_url\": Official website URL of the metro/EPCI\n";
        $search_prompt .= "4. \"metro_population\": Metropolitan area population (number)\n";
        $search_prompt .= "5. \"metro_municipalities\": Number of municipalities in the metro area\n";
        $search_prompt .= "6. \"company_count\": Approximate number of companies in the area\n";
        $search_prompt .= "7. \"job_count\": Approximate number of jobs in the area\n";
        $search_prompt .= "8. \"new_companies\": Recent annual new company creations (number + year)\n";
        $search_prompt .= "9. \"major_companies\": Array of 5-8 objects: { \"name\": \"...\", \"url\": \"official website\", \"sector\": \"...\" }\n";
        $search_prompt .= "10. \"industry_sectors\": Array of 4-6 key industry sectors with brief descriptions\n";
        $search_prompt .= "11. \"urban_projects\": Array of 3-5 objects: { \"name\": \"...\", \"url\": \"official project page\", \"description\": \"1-2 sentences\", \"scale\": \"hectares/units/etc\" }\n";
        $search_prompt .= "12. \"prime_streets\": Array of 5-8 notable streets, squares, or commercial areas\n";
        $search_prompt .= "13. \"region_url\": Official regional government website URL\n\n";
        $search_prompt .= "CRITICAL RULES:\n";
        $search_prompt .= "- Only return URLs you've VERIFIED exist. If you can't verify a URL, set it to null.\n";
        $search_prompt .= "- company URLs must be official corporate websites, NOT Wikipedia or directory pages.\n";
        $search_prompt .= "- Return ONLY valid JSON. No markdown fences. No explanations.\n";

        $body = array(
            'model'        => $settings['openai_model'] ?? 'gpt-4o-mini',
            'input'        => $search_prompt,
            'instructions' => $config['research_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' => 120,
        ) );

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

        $status_code = wp_remote_retrieve_response_code( $response );
        if ( $status_code !== 200 ) {
            return new WP_Error( 'api_error', "Responses API returned {$status_code}" );
        }

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

        // Extract text from Responses API
        $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', 'Research API returned no text' );
        }

        $raw = trim( implode( "\n", $text_parts ) );
        $raw = preg_replace( '/^```(?:json)?\s*/m', '', $raw );
        $raw = preg_replace( '/```\s*$/m', '', $raw );

        $data = json_decode( trim( $raw ), true );
        if ( json_last_error() !== JSON_ERROR_NONE ) {
            return new WP_Error( 'json_error', 'Research JSON parse failed: ' . json_last_error_msg() );
        }

        return $data;
    }

    /* =================================================================
     *  STAGE 2: ARTICLE GENERATION — OpenAI Chat Completions
     * ================================================================= */

    /**
     * Generate the full article from research data.
     */
    private function generate_article( string $country_key, array $commune_data, array $research ) {

        $config   = DBA_GeoBot_Registry::get_country( $country_key );
        $settings = get_option( 'dba_settings', array() );
        $api_key  = $settings['openai_api_key'] ?? '';

        $system_prompt = $this->build_system_prompt( $country_key );
        $user_prompt   = $this->build_user_prompt( $country_key, $commune_data, $research );

        // ── SEO Sniper: inject competitive intelligence ──
        // Priority: live transient (Sniper-triggered) → stored analysis (every cron run)
        if ( class_exists( 'DBA_SEO_Sniper_Router' ) ) {
            $sniper_brief = DBA_SEO_Sniper_Router::get_brief_for_prompt( $country_key, 'geobot' );
            if ( $sniper_brief ) {
                $user_prompt .= "\n\n" . $sniper_brief;
            }
        }

        // ── TIS: inject strategic content gap priorities ──
        if ( class_exists( 'DBA_TIS_Recommender' ) ) {
            $tis_context = DBA_TIS_Recommender::get_generator_context( 'geobot' );
            if ( $tis_context ) {
                $system_prompt .= "\n\n" . $tis_context;
            }
        }

        // ── KAIZEN: Inject learning context from editor corrections ──
        if ( class_exists( 'DBA_GeoBot_Kaizen' ) ) {
            $kaizen_context = DBA_GeoBot_Kaizen::get_learning_context( $country_key );
            if ( ! empty( $kaizen_context ) ) {
                $system_prompt .= "\n" . $kaizen_context;
            }

            // Check structural variety — add warnings to user prompt if articles are cloning
            $last_html = $this->get_last_article_html( $country_key );
            if ( ! empty( $last_html ) ) {
                $struct_warnings = DBA_GeoBot_Kaizen::check_structural_variety( $last_html, $country_key );
                if ( ! empty( $struct_warnings ) ) {
                    $user_prompt .= "\n\n⚠ STRUCTURAL VARIETY WARNINGS (from anti-spam system):\n";
                    foreach ( $struct_warnings as $sw ) {
                        $user_prompt .= "- {$sw}\n";
                    }
                }
            }

            // Blacklist overused transitions
            $overused = DBA_GeoBot_Kaizen::get_overused_transitions( $country_key );
            if ( ! empty( $overused ) ) {
                $user_prompt .= "\n\n⚠ OVERUSED TRANSITIONS — DO NOT USE THESE:\n";
                foreach ( array_slice( $overused, 0, 6 ) as $ot ) {
                    $user_prompt .= "✗ {$ot}\n";
                }
                $user_prompt .= "Find FRESH alternatives for each.\n";
            }
        }

        $body = array(
            'model'       => $settings['openai_model'] ?? 'gpt-4o-mini',
            'messages'    => array(
                array( 'role' => 'system', 'content' => $system_prompt ),
                array( 'role' => 'user',   'content' => $user_prompt ),
            ),
            'max_tokens'  => 6000,
            'temperature' => 0.7, // Higher temp = more lexical variety between city articles
        );

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

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

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

        if ( ! isset( $resp_body['choices'][0]['message']['content'] ) ) {
            return new WP_Error( 'no_content', 'OpenAI returned no article content' );
        }

        $raw = trim( $resp_body['choices'][0]['message']['content'] );
        $raw = preg_replace( '/^```(?:json)?\s*/m', '', $raw );
        $raw = preg_replace( '/```\s*$/m', '', $raw );

        $article = json_decode( trim( $raw ), true );
        if ( json_last_error() !== JSON_ERROR_NONE ) {
            return new WP_Error( 'json_error', 'Article JSON parse failed: ' . json_last_error_msg() );
        }

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

        /* ── LANGUAGE VALIDATION GATE ──
         *  For non-English countries, verify the AI actually wrote in the target language.
         *  Check for telltale English phrases that indicate the AI ignored the language instruction.
         *  This is a hard gate — rejects the article if it appears to be in English.
         */
        if ( $config['language'] !== 'en' ) {
            $content_lower = mb_strtolower( $article['content_html'] );
            $title_lower   = mb_strtolower( $article['title'] );

            // Common English phrases that should NEVER appear in Italian/French/Spanish/Portuguese articles
            $english_markers = array(
                'investing in',
                'investment opportunities',
                'the city of',
                'located in the',
                'is home to',
                'the region of',
                'economic landscape',
                'business environment',
                'key sectors',
                'major companies',
                'urban development',
                'in recent years',
                'according to',
                'metropolitan area',
                'local economy',
                'prime streets',
                'the municipality',
                'industrial sector',
            );

            $english_hits = 0;
            $hit_phrases  = array();
            foreach ( $english_markers as $marker ) {
                if ( strpos( $content_lower, $marker ) !== false ) {
                    $english_hits++;
                    $hit_phrases[] = $marker;
                }
                if ( strpos( $title_lower, $marker ) !== false ) {
                    $english_hits += 2; // Title in English is doubly bad
                    $hit_phrases[] = "(TITLE) {$marker}";
                }
            }

            // Language-specific positive markers (should be present)
            $positive_markers = array(
                'it' => array( 'investire', 'della', 'nella', 'imprese', 'settori', 'economia', 'città', 'sviluppo', 'territorio' ),
                'fr' => array( 'investir', 'économie', 'entreprises', 'secteurs', 'développement', 'commune', 'métropole' ),
                'es' => array( 'invertir', 'economía', 'empresas', 'sectores', 'desarrollo', 'municipio', 'ciudad' ),
                'pt' => array( 'investir', 'economia', 'empresas', 'setores', 'desenvolvimento', 'município', 'cidade' ),
            );

            $positive_hits = 0;
            $lang_markers  = $positive_markers[ $config['language'] ] ?? array();
            foreach ( $lang_markers as $pm ) {
                if ( strpos( $content_lower, $pm ) !== false ) {
                    $positive_hits++;
                }
            }

            // REJECT if: many English markers found AND few target-language markers
            if ( $english_hits >= 3 && $positive_hits < 3 ) {
                $lang_map_err = array( 'it' => 'Italian', 'fr' => 'French', 'es' => 'Spanish', 'pt' => 'Portuguese' );
                $expected_lang = $lang_map_err[ $config['language'] ] ?? $config['language'];
                return new WP_Error( 'wrong_language',
                    "Article appears to be in ENGLISH instead of {$expected_lang}. " .
                    "English markers found ({$english_hits}): " . implode( ', ', array_slice( $hit_phrases, 0, 5 ) ) . ". " .
                    "{$expected_lang} markers found: {$positive_hits}/9. REJECTED."
                );
            }

            // WARNING if borderline (logged but not rejected)
            if ( $english_hits >= 2 ) {
                error_log( "DBA GeoBot [{$country_key}] ⚠ Language warning: {$english_hits} English markers detected in {$config['language']} article. Phrases: " . implode( ', ', $hit_phrases ) );
            }
        }

        return $article;
    }

    /* =================================================================
     *  PROMPTS — Country-agnostic, driven by registry config
     * ================================================================= */

    /**
     * Build the system prompt for article generation.
     * Uses template structure as a blueprint — NOT verbatim text.
     */
    private function build_system_prompt( string $country_key ) {

        $config = DBA_GeoBot_Registry::get_country( $country_key );

        $country_names = array(
            'france' => 'France', 'italy' => 'Italy',
            'spain' => 'Spain', 'portugal' => 'Portugal',
        );
        $country_name = $country_names[ $country_key ] ?? ucfirst( $country_key );
        $lang_map = array( 'fr' => 'French', 'it' => 'Italian', 'es' => 'Spanish', 'pt' => 'Portuguese' );
        $lang_name = $lang_map[ $config['language'] ] ?? $config['language'];

        $wc_min = $config['word_count_min'];
        $wc_max = $config['word_count_max'];
        $wc_target = round( ( $wc_min + $wc_max ) / 2 );

        $prompt  = "You are an AI blog publishing agent specialized in city-level investment analysis for {$country_name}.\n\n";
        $prompt .= "CRITICAL: {$config['post_language_note']}. Write the ENTIRE article in {$lang_name}.\n\n";

        /* ── LANGUAGE ENFORCEMENT (non-English countries) ── */
        if ( $config['language'] !== 'en' ) {
            $prompt .= "═══════════════════════════════════════════════════════════\n";
            $prompt .= "⚠ ABSOLUTE LANGUAGE REQUIREMENT — ZERO TOLERANCE\n";
            $prompt .= "═══════════════════════════════════════════════════════════\n";
            $prompt .= "TARGET LANGUAGE: {$lang_name} (language code: {$config['language']})\n\n";
            $prompt .= "EVERY element of the output MUST be in {$lang_name}:\n";
            $prompt .= "- 'title' field → in {$lang_name}\n";
            $prompt .= "- 'excerpt' field → in {$lang_name}\n";
            $prompt .= "- 'content_html' → ALL text in {$lang_name} (headings, paragraphs, everything)\n";
            $prompt .= "- 'yoast_seo' → focus_keyphrase, seo_title, meta_description → ALL in {$lang_name}\n";
            $prompt .= "- 'featured_image' → alt, title, caption, description → ALL in {$lang_name}\n";
            $prompt .= "- <h2> section headings → in {$lang_name}\n\n";
            $prompt .= "If ANY text is in English, the article will be REJECTED.\n";
            $prompt .= "The only exceptions: company names, proper nouns, and URLs remain in their original form.\n";
            $prompt .= "═══════════════════════════════════════════════════════════\n\n";
        }

        /* ── ANTI-PLAGIARISM DIRECTIVE (highest priority) ── */
        $prompt .= "═══════════════════════════════════════════════════════════\n";
        $prompt .= "ANTI-PLAGIARISM DIRECTIVE — READ CAREFULLY\n";
        $prompt .= "═══════════════════════════════════════════════════════════\n";
        $prompt .= "This article will be checked by a multi-layer plagiarism detector BEFORE publishing:\n";
        $prompt .= "- Jaccard 3-gram shingling against ALL existing articles on this site\n";
        $prompt .= "- Sentence-level similarity (90% threshold) against the last 30 published articles\n";
        $prompt .= "- Copyscape web-wide duplication scan\n";
        $prompt .= "- Template phrase blacklist (35+ banned phrases — instant rejection)\n\n";
        $prompt .= "If the article fails ANY of these checks, it is REJECTED. You have max 3 attempts.\n\n";

        $prompt .= "MANDATORY UNIQUENESS RULES:\n";
        $prompt .= "1. Every sentence must be composed from scratch for THIS specific city\n";
        $prompt .= "2. DO NOT reuse sentence patterns from one city to another\n";
        $prompt .= "3. Vary your opening angle — some cities lead with geography, others with industry, others with demographics, others with history\n";
        $prompt .= "4. Vary paragraph length — mix short (1-2 sentences) with medium (3-4 sentences)\n";
        $prompt .= "5. Use city-SPECIFIC facts in every paragraph — no generic filler\n";
        $prompt .= "6. Vary transition words — do not always use 'moreover', 'in addition', 'furthermore'\n";
        $prompt .= "7. Each section must start differently from how the same section starts in other city articles\n\n";

        /* ── BANNED PHRASES ── */
        if ( ! empty( $config['banned_phrases'] ) ) {
            $prompt .= "BANNED PHRASES — Using ANY of these will cause INSTANT REJECTION:\n";
            foreach ( $config['banned_phrases'] as $phrase ) {
                $prompt .= "✗ \"{$phrase}\"\n";
            }
            $prompt .= "\nDo NOT paraphrase these either. Write completely original sentences specific to the city's actual data.\n\n";
        }

        /* ── TONE & STYLE ── */
        $prompt .= "TONE & STYLE:\n";
        $prompt .= "- Professional, investor-facing, neutral, institutional\n";
        $prompt .= "- No marketing language, no superlatives, no speculation\n";
        $prompt .= "- {$wc_min}-{$wc_max} words (target ~{$wc_target})\n";
        $prompt .= "- Ground every claim in concrete data from the RESEARCH DATA provided\n";
        $prompt .= "- When you don't have a specific number, describe the situation qualitatively using city-specific context\n\n";

        /* ── ARTICLE STRUCTURE (from template — structure only) ── */
        $prompt .= "ARTICLE STRUCTURE (follow this section order — compose all text freshly):\n\n";

        if ( ! empty( $config['article_structure'] ) ) {
            $struct = $config['article_structure'];

            $prompt .= "INTRODUCTION (no heading tag — just paragraphs):\n";
            $prompt .= "- Purpose: {$struct['intro']['purpose']}\n";
            $prompt .= "- Must include: {$struct['intro']['must_include']}\n";
            $prompt .= "- Guidance: {$struct['intro']['guidance']}\n\n";

            foreach ( $struct['sections'] as $i => $sec ) {
                $num = $i + 1;
                $prompt .= "SECTION {$num}: <h2>{$sec['h2']}</h2>\n";
                $prompt .= "- Content type: {$sec['content_type']}\n";
                $prompt .= "- Use data from: {$sec['data_fields']}\n\n";
            }
        } else {
            // Fallback: just list H2 sections
            $h2_list = implode( "\n", array_map( function( $h ) { return "- {$h}"; }, $config['h2_sections'] ) );
            $prompt .= "MANDATORY H2 SECTIONS (use <h2> tags, in this order):\n";
            $prompt .= $h2_list . "\n\n";
        }

        /* ── STRICT HTML RULES ── */
        $prompt .= "STRICT RULES — VIOLATION = REJECTION:\n";
        $prompt .= "1. DO NOT include any heading for the article title in content_html — WordPress renders the post title as H1 automatically\n";
        $prompt .= "2. All section headings must use <h2> tags (NOT <h3>)\n";
        $prompt .= "3. City/commune name linked ONCE (first mention in body) to official city hall site\n";
        $prompt .= "4. Métropole/intercommunality linked ONCE to official site\n";
        $prompt .= "5. Every company name MUST be wrapped in <a href> linking to official corporate website\n";
        $prompt .= "6. If a company's official URL is unknown, DO NOT mention the company at all\n";
        $prompt .= "7. Urban development projects should link to official project pages when available\n";
        $prompt .= "8. NO <hr> tags, NO <strong> tags, NO <b> tags anywhere\n";
        $prompt .= "9. NO 'Last updated' dates\n";
        $prompt .= "10. NO media sources, NO 'according to', NO journalist references\n";
        $prompt .= "11. All factual claims must be plausible and based on the provided research data\n\n";

        /* ── POST TITLE FORMAT (WordPress H1) ── */
        $prompt .= "POST TITLE FORMAT (used as WordPress post_title — rendered as H1 by theme):\n";
        $prompt .= str_replace( '{COMMUNE}', '[City Name]', $config['h1_format'] ) . "\n";
        $prompt .= "DO NOT repeat this title inside content_html. It goes ONLY in the 'title' JSON field.\n\n";

        /* ── LINKING RULES ── */
        $prompt .= "LINKING RULES:\n";
        $prompt .= "- City name: <a href=\"[city_url]\" target=\"_blank\" rel=\"noopener\">[City]</a> — first mention only\n";
        $prompt .= "- Métropole: <a href=\"[metro_url]\" target=\"_blank\" rel=\"noopener\">[Metro Name]</a> — first mention only\n";
        $prompt .= "- Companies: <a href=\"[company_url]\" target=\"_blank\" rel=\"noopener\">[Company Name]</a> — each company linked on first mention\n";
        $prompt .= "- Projects: <a href=\"[project_url]\" target=\"_blank\" rel=\"noopener\">[Project Name]</a> — if URL available\n";
        $prompt .= "- Region govt: <a href=\"[region_url]\" target=\"_blank\" rel=\"noopener\">[Region Name]</a> — if URL available, once only\n";
        $prompt .= "- No other external links.\n";
        $prompt .= "- NEVER invent or fabricate any damalion.com URL. Only use damalion.com links if explicitly provided.\n\n";

        /* ── OUTPUT FORMAT ── */
        $prompt .= "OUTPUT: Valid JSON only. No markdown fences. No explanations.\n";
        $prompt .= "Required JSON keys:\n";
        $prompt .= "- title: the H2 heading text (NO links in it)\n";
        $prompt .= "- slug: URL-friendly slug\n";
        $prompt .= "- excerpt: 1-2 sentence summary, max 155 chars\n";
        $prompt .= "- content_html: article body HTML starting with intro <p> paragraphs, then <h2> section headings + <p> paragraphs, with links. NO title/heading at the start — WordPress handles H1.\n";
        $prompt .= "- yoast_seo: { \"focus_keyphrase\": \"...\", \"seo_title\": \"max 60 chars\", \"meta_description\": \"max 155 chars\" }\n";
        $prompt .= "- featured_image: Choose ONE of the two DALL-E prompts below based on article content. If the article is about manufacturing, factories, industry, production, logistics, or industrial sectors → use PROMPT B. For ALL other topics (finance, real estate, services, tech, tourism, business services, etc.) → use PROMPT A. Replace {CITY} with the actual city name.\n";
        $prompt .= "  PROMPT A (city/business district): { \"prompt\": \"A sophisticated entrepreneur (randomly man or woman, face must be visible), confidently walking down a cobblestone street in {CITY}'s historic fashion district, carrying a sleek black leather briefcase. The image is in landscape orientation, wide format with a horizontal aspect ratio of 16:9, capturing a broad view of elegant local architecture with arched buildings and balconies lining both sides. In the background, several people (50% males 50% females) are working: on sidewalks, some at outdoor cafe tables typing on laptops, others in business attire discussing over coffee or on phone calls, adding a dynamic urban bustle. PEOPLE ARE NOT WEARING SUITS. THEY HAVE COOL NICE CLOTHES. Sunny afternoon lighting with soft shadows, realistic photographic style, high resolution, sharp details, vibrant colors, cinematic depth of field focusing on the entrepreneur in the foreground. Absolutely NO text, NO words, NO letters, NO signs, NO logos, NO watermarks anywhere in the image.\", \"filename\": \"slug.jpg\", \"alt\": \"descriptive alt text in {$lang_name}\", \"title\": \"image title\", \"caption\": \"short caption\", \"description\": \"detailed description\" }\n";
        $prompt .= "  PROMPT B (factory/industrial): { \"prompt\": \"A sophisticated entrepreneur (randomly man or woman, face clearly visible), confidently walking through a modern industrial factory interior, randomly carrying or not a sleek black leather briefcase. The entrepreneur is dressed in elegant smart-casual attire (tailored blazer, refined trousers or skirt, premium fabrics), projecting authority, innovation, and operational leadership. The image is in landscape orientation, wide format with a horizontal aspect ratio of 16:9, capturing a broad panoramic view of the factory floor. The environment features high ceilings, steel beams, large industrial windows allowing natural sunlight to stream in, advanced machinery, robotic arms, conveyor belts, and organized production lines stretching into the depth of the space. In the background, several people (50% male, 50% female) are actively working across the factory floor: some operating modern equipment and monitoring digital control panels, others reviewing production data on tablets or laptops, small groups discussing workflow improvements near machinery. No one is wearing traditional corporate suits. Instead, they wear clean, modern industrial-smart attire: fitted work jackets, quality jeans or technical trousers, safety shoes, and minimal protective gear (subtle helmets or safety glasses where appropriate), maintaining a stylish yet professional appearance. Bright afternoon lighting filtering through industrial windows with soft shadows. Realistic photographic style, ultra-high resolution, sharp mechanical details, natural metallic textures, vibrant yet balanced colors. Cinematic depth of field focusing sharply on the entrepreneur in the foreground, with the dynamic production environment softly blurred in the background. Mood: innovation-driven leadership, industrial excellence, modern manufacturing power. Absolutely NO text, NO words, NO letters, NO signs, NO logos, NO watermarks anywhere in the image.\", \"filename\": \"slug.jpg\", \"alt\": \"descriptive alt text in {$lang_name}\", \"title\": \"image title\", \"caption\": \"short caption\", \"description\": \"detailed description\" }\n";

        return $prompt;
    }

    /**
     * Build the user prompt with research data and variation seed.
     */
    private function build_user_prompt( string $country_key, array $commune_data, array $research ) {

        $config  = DBA_GeoBot_Registry::get_country( $country_key );
        $commune = $commune_data['commune'];
        $region  = $commune_data['region'] ?? '';
        $dept    = $commune_data['departement'] ?? '';
        $pop     = $commune_data['population'] ?? '';

        $slug_format = str_replace( '{commune_slug}', DBA_GeoBot_Registry::commune_slug( $commune ), $config['slug_format'] );

        $prompt  = "Write an investment-focused article about {$commune}, {$region}.\n\n";

        /* ── VARIATION SEED — forces different angles ── */
        $angles = array(
            'Lead with the city\'s geographic and strategic position in the region.',
            'Open by describing the city\'s primary economic identity and dominant industry.',
            'Start with a demographic insight — what makes this city\'s population profile distinctive.',
            'Begin with the city\'s most notable urban transformation or infrastructure project.',
            'Lead with the business ecosystem — the density and diversity of companies.',
            'Open with the city\'s role within its métropole or intercommunality structure.',
            'Start with a forward-looking statement about where the city is heading economically.',
            'Begin with what distinguishes this city from neighboring communes in the same region.',
            'Lead with a specific, striking fact from the research data.',
            'Open by describing the city\'s appeal to a specific investor profile (family office, PE fund, etc.).',
        );
        $seed = crc32( $commune . $region . date( 'Y-m' ) );
        $angle = $angles[ abs( $seed ) % count( $angles ) ];

        $prompt .= "WRITING ANGLE (use this as your opening approach):\n";
        $prompt .= "→ {$angle}\n\n";

        $prompt .= "BASIC DATA:\n";
        $prompt .= "- Commune: {$commune}\n";
        $prompt .= "- Department/Province: {$dept}\n";
        $prompt .= "- Region: {$region}\n";
        $prompt .= "- Population (city proper): {$pop}\n";
        $prompt .= "- Target slug: {$slug_format}\n\n";

        $prompt .= "RESEARCH DATA (use these facts — do not invent additional data):\n";
        $prompt .= wp_json_encode( $research, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES ) . "\n\n";

        $prompt .= "REQUIREMENTS:\n";
        $prompt .= "1. Post title (H1, rendered by WordPress): " . str_replace( '{COMMUNE}', $commune, $config['h1_format'] ) . "\n";
        $prompt .= "2. content_html must NOT include the title — start directly with intro paragraphs, then <h2> sections\n";
        $prompt .= "3. First body paragraph: follow the WRITING ANGLE above. Mention {$commune} (linked to city_url from research) and region.\n";
        $prompt .= "4. Use ALL research data to fill sections — do not invent facts\n";
        $prompt .= "5. If a company URL in research is null, skip that company entirely\n";
        $prompt .= "6. If metro_url is null, mention the metro by name but don't link it\n";
        $prompt .= "7. Feature image prompt should describe a real-looking editorial photo of {$commune}\n";
        $prompt .= "8. Yoast focus keyphrase should include: investing + {$commune}\n";
        $prompt .= "9. UNIQUENESS: every sentence must be specific to {$commune}. No generic investment filler.\n";
        $prompt .= "10. Return valid JSON only\n";

        /* ── LANGUAGE ENFORCEMENT in user prompt (reinforcement) ── */
        if ( $config['language'] !== 'en' ) {
            $lang_map_up = array( 'fr' => 'FRANÇAIS', 'it' => 'ITALIANO', 'es' => 'ESPAÑOL', 'pt' => 'PORTUGUÊS' );
            $lang_upper  = $lang_map_up[ $config['language'] ] ?? strtoupper( $config['language'] );
            $prompt .= "\n⚠ REMINDER: WRITE EVERYTHING IN {$lang_upper}. Title, headings, content, excerpt, SEO — ALL in {$lang_upper}.\n";
            $prompt .= "If you write in English, the article will be REJECTED and you will have to redo it.\n";
        }

        return $prompt;
    }

    /**
     * Nuclear cache flush — purge EVERY cache layer after post creation.
     *
     * Clears: WP object cache, post cache, term cache, WPML caches,
     * transients, and any page cache plugin (WP Super Cache, W3TC,
     * WP Rocket, LiteSpeed, SG Optimizer, WP Fastest Cache).
     *
     * Call this after EVERY post creation and hub update.
     */
    private function flush_post_caches( int $post_id, string $country_key = '' ) {

        // ── WordPress core caches ──
        clean_post_cache( $post_id );
        wp_cache_flush();

        // ── Term/taxonomy caches ──
        $taxonomies = get_object_taxonomies( 'post' );
        foreach ( $taxonomies as $tax ) {
            clean_object_term_cache( $post_id, $tax );
            delete_option( "{$tax}_children" );
        }
        wp_cache_delete( 'all_ids', 'category' );
        wp_cache_delete( 'get_terms', 'terms' );

        // ── WPML caches ──
        if ( function_exists( 'icl_cache_clear' ) ) {
            icl_cache_clear();
        }
        // WPML stores language assignments in options and transients
        delete_transient( 'wpml_st_string_translations' );
        // Clear WPML element language cache for this post
        wp_cache_delete( $post_id, 'icl_translations' );
        wp_cache_delete( 'element_translations_post_post', 'wpml_translations' );

        // ── GeoBot transients ──
        if ( ! empty( $country_key ) ) {
            delete_transient( 'dba_geo_' . $country_key . '_generation_progress' );
        }

        // ── Page cache plugins — try them all (only active ones respond) ──

        // WP Super Cache
        if ( function_exists( 'wp_cache_clear_cache' ) ) {
            wp_cache_clear_cache();
        }

        // W3 Total Cache
        if ( function_exists( 'w3tc_flush_all' ) ) {
            w3tc_flush_all();
        }

        // WP Rocket
        if ( function_exists( 'rocket_clean_post' ) ) {
            rocket_clean_post( $post_id );
        }
        if ( function_exists( 'rocket_clean_domain' ) ) {
            rocket_clean_domain();
        }

        // LiteSpeed Cache
        if ( class_exists( 'LiteSpeed_Cache_API' ) ) {
            if ( method_exists( 'LiteSpeed_Cache_API', 'purge_all' ) ) {
                LiteSpeed_Cache_API::purge_all();
            }
        }
        if ( has_action( 'litespeed_purge_all' ) ) {
            do_action( 'litespeed_purge_all' );
        }

        // SG Optimizer
        if ( function_exists( 'sg_cachepress_purge_cache' ) ) {
            sg_cachepress_purge_cache();
        }

        // WP Fastest Cache
        if ( function_exists( 'wpfc_clear_all_cache' ) ) {
            wpfc_clear_all_cache();
        }

        // Autoptimize
        if ( class_exists( 'autoptimizeCache' ) && method_exists( 'autoptimizeCache', 'clearall' ) ) {
            autoptimizeCache::clearall();
        }

        // Divi Builder cache (used on this site)
        if ( function_exists( 'et_core_clear_wp_cache' ) ) {
            et_core_clear_wp_cache();
        }
        // Divi static CSS
        delete_option( 'et_divi_static_css_custom_css_safety_check_done' );
        delete_transient( 'et_builder_css_files' );

        // Generic: fire common cache-clear hooks
        if ( has_action( 'cachify_flush_cache' ) ) {
            do_action( 'cachify_flush_cache' );
        }
        if ( has_action( 'breeze_purge_all_cache' ) ) {
            do_action( 'breeze_purge_all_cache' );
        }

        error_log( "DBA GeoBot [{$country_key}] Cache: FLUSHED all caches for post #{$post_id}" );
    }

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

    /**
     * Resolve the WPML-translated category ID for a given language.
     *
     * WPML stores each translated category as a separate term_id linked
     * by a shared trid. When we create an Italian post, we need the
     * Italian term_id — NOT the English one.
     *
     * Strategy:
     *   1. Find the English/default anchor category
     *   2. Use wpml_object_id to get the translated term_id
     *   3. If no translation exists and we have a translated name, create it
     *   4. Return the translated term_id (or null on failure)
     *
     * @param string $default_cat_name   English category name (e.g. "Invest in Italy")
     * @param string $translated_name    Native-language name (e.g. "Investire in Italia")
     * @param string $target_lang        WPML language code (e.g. 'it')
     * @return int|null                  Translated term_id or null
     */
    private function resolve_wpml_category( string $default_cat_name, string $translated_name, string $target_lang ) {

        // If WPML is not active, return null (caller will use fallback)
        if ( ! function_exists( 'icl_get_languages' ) || ! has_filter( 'wpml_object_id' ) ) {
            return null;
        }

        // Step 1: Find the default/English category
        $default_lang = apply_filters( 'wpml_default_language', 'en' );
        $default_term = term_exists( $default_cat_name, 'category' );

        if ( ! $default_term ) {
            // Try finding by the translated name (in case site was set up with Italian cats first)
            if ( ! empty( $translated_name ) ) {
                $default_term = term_exists( $translated_name, 'category' );
            }
            if ( ! $default_term ) {
                // No anchor category exists at all — let the fallback create it
                error_log( "DBA GeoBot WPML: No anchor category found for '{$default_cat_name}' or '{$translated_name}'" );
                return null;
            }
        }

        $default_tid = is_array( $default_term ) ? $default_term['term_id'] : intval( $default_term );

        // Step 2: If target language IS the default language, just return the default term
        if ( $target_lang === $default_lang ) {
            return $default_tid;
        }

        // Step 3: Ask WPML for the translated term_id
        $translated_tid = apply_filters( 'wpml_object_id', $default_tid, 'category', false, $target_lang );

        if ( $translated_tid && $translated_tid !== $default_tid ) {
            error_log( "DBA GeoBot WPML: Resolved category '{$default_cat_name}' → translated term #{$translated_tid} for lang '{$target_lang}'" );
            return intval( $translated_tid );
        }

        // Step 4: No translation exists — try to create one
        if ( ! empty( $translated_name ) && function_exists( 'wpml_set_element_language_details' ) ) {
            error_log( "DBA GeoBot WPML: No translation found for category #{$default_tid} in '{$target_lang}' — creating '{$translated_name}'…" );

            // Switch to target language for term creation
            global $sitepress;
            $prev_lang = null;
            if ( $sitepress ) {
                $prev_lang = $sitepress->get_current_language();
                $sitepress->switch_lang( $target_lang, true );
            }

            $new_term = wp_insert_term( $translated_name, 'category', array(
                'slug' => sanitize_title( $translated_name ),
            ) );

            if ( ! is_wp_error( $new_term ) ) {
                $new_tid = is_array( $new_term ) ? $new_term['term_id'] : intval( $new_term );

                // Link the new term as a translation of the default term
                $default_trid = apply_filters( 'wpml_element_trid', null, $default_tid, 'tax_category' );

                if ( $default_trid ) {
                    do_action( 'wpml_set_element_language_details', array(
                        'element_id'           => $new_tid,
                        'element_type'         => 'tax_category',
                        'trid'                 => $default_trid,
                        'language_code'        => $target_lang,
                        'source_language_code' => $default_lang,
                    ) );
                    error_log( "DBA GeoBot WPML: Created translated category '{$translated_name}' (#{$new_tid}) linked to trid={$default_trid} for lang '{$target_lang}'" );
                } else {
                    // No trid for default — set the new term as a standalone in target lang
                    $new_trid = apply_filters( 'wpml_element_trid', null, $new_tid, 'tax_category' );
                    do_action( 'wpml_set_element_language_details', array(
                        'element_id'    => $new_tid,
                        'element_type'  => 'tax_category',
                        'trid'          => $new_trid,
                        'language_code' => $target_lang,
                    ) );
                    error_log( "DBA GeoBot WPML: Created standalone category '{$translated_name}' (#{$new_tid}) for lang '{$target_lang}' (no default trid)" );
                }

                // Restore language
                if ( $prev_lang && $sitepress ) {
                    $sitepress->switch_lang( $prev_lang, true );
                }

                return intval( $new_tid );
            } else {
                error_log( "DBA GeoBot WPML: Failed to create translated category '{$translated_name}': " . $new_term->get_error_message() );

                // The term might already exist under a different name — try one more lookup
                $existing = get_term_by( 'slug', sanitize_title( $translated_name ), 'category' );
                if ( $existing ) {
                    error_log( "DBA GeoBot WPML: Found existing term by slug: #{$existing->term_id}" );
                    if ( $prev_lang && $sitepress ) {
                        $sitepress->switch_lang( $prev_lang, true );
                    }
                    return intval( $existing->term_id );
                }
            }

            // Restore language
            if ( $prev_lang && $sitepress ) {
                $sitepress->switch_lang( $prev_lang, true );
            }
        }

        // Last resort: return the default tid (English cat) — better than nothing
        error_log( "DBA GeoBot WPML: ⚠ Could not resolve translated category for '{$target_lang}' — falling back to default #{$default_tid}" );
        return $default_tid;
    }

    /**
     * Build CTA HTML from country config.
     */
    private function build_cta_html( string $country_key ) {

        $config   = DBA_GeoBot_Registry::get_country( $country_key );
        $settings = get_option( $config['settings_option'], array() );

        if ( ! empty( $settings['cta_disabled'] ) ) return '';

        $text      = $settings['cta_text']      ?? $config['cta_text'];
        $link_text = $settings['cta_link_text']  ?? $config['cta_link_text'];
        $link_url  = $settings['cta_link_url']   ?? $config['cta_link_url'];

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

        return $cta;
    }

    /**
     * Fill Yoast SEO meta fields.
     */
    private function fill_yoast_seo( int $post_id, array $article, array $config ) {

        $yoast = $article['yoast_seo'] ?? array();

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

        // Fallbacks
        if ( empty( $focus ) ) {
            $focus = $article['title'];
        }
        if ( empty( $seo_title ) ) {
            $seo_title = mb_substr( $article['title'], 0, 60 );
        }
        if ( empty( $meta_desc ) ) {
            $meta_desc = mb_substr( $article['excerpt'] ?? '', 0, 155 );
        }

        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 ) );
        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 ) );
    }

    /**
     * Get the HTML of the most recently generated GeoBot article for a country.
     * Used by Kaizen to compare structural patterns before generating a new one.
     *
     * @param string $country_key
     * @return string  HTML content or empty string.
     */
    private function get_last_article_html( string $country_key ) {

        $config = DBA_GeoBot_Registry::get_country( $country_key );
        if ( ! $config ) return '';

        $last = get_posts( array(
            'post_type'      => 'post',
            'post_status'    => array( 'publish', 'draft' ),
            'posts_per_page' => 1,
            'meta_query'     => array(
                array( 'key' => $config['meta_prefix'] . 'auto_created', 'compare' => 'EXISTS' ),
            ),
            'fields'  => 'ids',
            'orderby' => 'date',
            'order'   => 'DESC',
        ) );

        if ( empty( $last ) ) return '';

        return get_post_field( 'post_content', $last[0] );
    }
}
