<?php
/**
 * DBA_TIS_Pipeline — Automated Keyword Intelligence Pipeline
 *
 * Connects TIS → Sniper → OpenAI → Bots → Kaizen in a staged weekly cycle.
 * Each stage runs as a separate cron event on Monday morning:
 *
 *   03:00  TIS weekly scan (existing) → finds gaps, pushes sniper queue
 *   04:00  Pipeline Stage 1: Sniper Auto-Harvest → Google keywords
 *   05:00  Pipeline Stage 2: OpenAI Validator → validated briefs
 *
 * Bots consume briefs during their normal scheduled runs (Tue–Sun).
 * Conflict Center respected at every stage.
 *
 * @since 2.19.0
 */
if ( ! defined( 'ABSPATH' ) ) exit;

class DBA_TIS_Pipeline {

    const HARVEST_HOOK    = 'dba_pipeline_harvest';
    const VALIDATE_HOOK   = 'dba_pipeline_validate';
    const BRIEFS_KEY      = 'dba_pipeline_article_briefs';
    const HARVEST_KEY     = 'dba_pipeline_harvested_keywords';
    const LOG_KEY         = 'dba_pipeline_log';
    const LOCK_KEY        = 'dba_pipeline_lock';
    const LOCK_TTL        = 900;

    const MAX_SERPER_CALLS_PER_CYCLE  = 30;
    const MAX_OPENAI_CALLS_PER_CYCLE  = 5;
    const MAX_BRIEFS_PER_CYCLE        = 15;
    const TITLE_SIMILARITY_THRESHOLD  = 0.60;

    private static $instance = null;

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

    private function __construct() {
        add_action( self::HARVEST_HOOK,  array( $this, 'run_harvest' ) );
        add_action( self::VALIDATE_HOOK, array( $this, 'run_validate' ) );
    }

    /* ══════════════════════════════════════════════════════════════
     *  CRON SCHEDULING — Staged Monday morning
     * ══════════════════════════════════════════════════════════════ */

    public static function schedule() {
        if ( ! wp_next_scheduled( self::HARVEST_HOOK ) ) {
            $next = strtotime( 'next monday 04:00:00 UTC' );
            wp_schedule_event( $next, 'weekly', self::HARVEST_HOOK );
            error_log( 'DBA Pipeline: Scheduled harvest — next ' . date( 'Y-m-d H:i', $next ) . ' UTC' );
        }
        if ( ! wp_next_scheduled( self::VALIDATE_HOOK ) ) {
            $next = strtotime( 'next monday 05:00:00 UTC' );
            wp_schedule_event( $next, 'weekly', self::VALIDATE_HOOK );
            error_log( 'DBA Pipeline: Scheduled validate — next ' . date( 'Y-m-d H:i', $next ) . ' UTC' );
        }
    }

    public static function unschedule() {
        wp_clear_scheduled_hook( self::HARVEST_HOOK );
        wp_clear_scheduled_hook( self::VALIDATE_HOOK );
    }

    /* ══════════════════════════════════════════════════════════════
     *  STAGE 1: SNIPER AUTO-HARVEST
     *
     *  For each TIS gap topic, query Serper to capture:
     *   - Related Searches (what Google suggests)
     *   - People Also Ask (real user questions)
     *   - Competitor titles from top 10
     *
     *  Cost: 2-3 Serper calls per gap topic, ~20-30 per cycle.
     * ══════════════════════════════════════════════════════════════ */

    public function run_harvest() {
        if ( ! $this->acquire_lock( 'harvest' ) ) return;
        if ( function_exists( 'set_time_limit' ) ) @set_time_limit( 120 );
        if ( function_exists( 'ignore_user_abort' ) ) @ignore_user_abort( true );
        self::log( 'harvest', 'Starting auto-harvest' );

        $tis_queue = array();
        if ( class_exists( 'DBA_TIS_Recommender' ) ) {
            $tis_queue = DBA_TIS_Recommender::get_sniper_queue();
        }

        if ( empty( $tis_queue ) ) {
            self::log( 'harvest', 'TIS queue empty — nothing to harvest' );
            $this->release_lock();
            return;
        }

        // Group keywords by source topic
        $topics = array();
        foreach ( $tis_queue as $item ) {
            $source = $item['source'] ?? 'unknown';
            if ( ! isset( $topics[ $source ] ) ) {
                $topics[ $source ] = array(
                    'keywords'   => array(),
                    'priority'   => $item['priority'] ?? 'medium',
                    'grade'      => $item['grade'] ?? null,
                    'type'       => $item['type'] ?? 'gap',
                    'target_bot' => $item['target_bot'] ?? 'luxstrat',
                );
            }
            $topics[ $source ]['keywords'][] = $item['keyword'];
        }

        $harvested    = array();
        $serper_calls = 0;
        $bot_call_counts = array(); // Per-bot cap: max 5 Serper calls per bot
        $max_per_bot = 5;

        // Sort by priority
        $prio_order = array( 'critical' => 0, 'high' => 1, 'medium' => 2, 'low' => 3 );
        uasort( $topics, function( $a, $b ) use ( $prio_order ) {
            return ( $prio_order[ $a['priority'] ] ?? 4 ) <=> ( $prio_order[ $b['priority'] ] ?? 4 );
        } );

        foreach ( $topics as $source => $topic_data ) {
            if ( $serper_calls >= self::MAX_SERPER_CALLS_PER_CYCLE ) break;

            // Per-bot cap — distribute Serper budget across all bots
            $bot = $topic_data['target_bot'] ?? 'luxstrat';
            if ( ! isset( $bot_call_counts[ $bot ] ) ) $bot_call_counts[ $bot ] = 0;
            if ( $bot_call_counts[ $bot ] >= $max_per_bot ) {
                self::log( 'harvest', 'Skipping "' . $source . '" — bot "' . $bot . '" reached ' . $max_per_bot . ' call cap' );
                continue;
            }

            $root_keywords = array_slice( $topic_data['keywords'], 0, 2 );

            foreach ( $root_keywords as $root_kw ) {
                if ( $serper_calls >= self::MAX_SERPER_CALLS_PER_CYCLE ) break;
                if ( ! class_exists( 'DBA_SEO_Sniper_Crawler' ) ) break 2;

                // Determine search language from target bot
                $bot = $topic_data['target_bot'] ?? 'luxstrat';
                $lang = 'en';
                if ( strpos( $bot, '-fr' ) !== false ) $lang = 'fr';
                if ( strpos( $bot, '-de' ) !== false ) $lang = 'de';
                if ( strpos( $bot, '-pt' ) !== false ) $lang = 'pt';
                if ( strpos( $bot, '-es' ) !== false || $bot === 'latam' ) $lang = 'es';

                $serp_data = DBA_SEO_Sniper_Crawler::crawl_serp( $root_kw, $lang );
                $serper_calls++;
                $bot_call_counts[ $bot ]++;

                if ( is_wp_error( $serp_data ) ) {
                    self::log( 'harvest', 'Serper error for "' . $root_kw . '": ' . $serp_data->get_error_message() );
                    continue;
                }

                $organic = $serp_data['organic'] ?? $serp_data;
                $comp_titles = array();
                $comp_snippets = array();
                foreach ( array_slice( is_array( $organic ) ? $organic : array(), 0, 10 ) as $r ) {
                    if ( stripos( $r['domain'] ?? '', 'damalion.com' ) !== false ) continue;
                    if ( ! empty( $r['title'] ) ) $comp_titles[] = $r['title'];
                    if ( ! empty( $r['snippet'] ) ) $comp_snippets[] = $r['snippet'];
                }

                $related = $serp_data['related_searches'] ?? array();
                $paa     = $serp_data['people_also_ask'] ?? array();

                // ── Fallback: when Serper returns no Related Searches / PAA ──
                // Extract long-tail phrases from competitor titles and snippets
                $fallback_keywords = array();
                if ( empty( $related ) && empty( $paa ) ) {
                    // Extract 2-4 word phrases from competitor titles
                    foreach ( $comp_titles as $ct ) {
                        $ct_clean = strtolower( preg_replace( '/[^a-zA-Z0-9\s]/', '', $ct ) );
                        $words = explode( ' ', trim( $ct_clean ) );
                        // Extract 3-word chunks that contain the root keyword topic
                        $root_words = explode( ' ', strtolower( $root_kw ) );
                        for ( $w = 0; $w <= count( $words ) - 3; $w++ ) {
                            $chunk = implode( ' ', array_slice( $words, $w, 3 ) );
                            foreach ( $root_words as $rw ) {
                                if ( strlen( $rw ) > 3 && stripos( $chunk, $rw ) !== false ) {
                                    $fallback_keywords[] = $chunk;
                                    break;
                                }
                            }
                        }
                    }
                    $fallback_keywords = array_unique( array_slice( $fallback_keywords, 0, 8 ) );
                    if ( ! empty( $fallback_keywords ) ) {
                        self::log( 'harvest', 'Serper returned no Related/PAA for "' . $root_kw . '" — extracted ' . count( $fallback_keywords ) . ' from competitor titles' );
                    }
                }

                $harvested[] = array(
                    'root_keyword'        => $root_kw,
                    'source'              => $source,
                    'priority'            => $topic_data['priority'],
                    'grade'               => $topic_data['grade'],
                    'target_bot'          => $topic_data['target_bot'],
                    'related_searches'    => $related,
                    'people_also_ask'     => $paa,
                    'fallback_keywords'   => $fallback_keywords,
                    'competitor_titles'   => $comp_titles,
                    'harvested_at'        => current_time( 'mysql' ),
                );
            }
        }

        update_option( self::HARVEST_KEY, array(
            'generated_at' => current_time( 'mysql' ),
            'serper_calls' => $serper_calls,
            'topics'       => $harvested,
        ), false );

        $bot_summary = array();
        foreach ( $bot_call_counts as $b => $c ) { $bot_summary[] = $b . ':' . $c; }
        self::log( 'harvest', 'Complete — ' . count( $harvested ) . ' topics, ' . $serper_calls . ' Serper calls (' . implode( ', ', $bot_summary ) . ')' );
        $this->release_lock();
    }

    /* ══════════════════════════════════════════════════════════════
     *  STAGE 2: OPENAI KEYWORD VALIDATOR + BRIEF GENERATOR
     *
     *  Takes harvested Google data + Damalion content inventory.
     *  Sends to OpenAI with Luxembourg domain expertise prompt.
     *  Returns validated article briefs with:
     *   - Legally correct, human-readable titles
     *   - Target keywords (real search queries)
     *   - Competitor gaps to exploit
     *   - Deduplication against existing titles
     *
     *  Cost: 1-3 OpenAI calls per cycle (batched by priority).
     * ══════════════════════════════════════════════════════════════ */

    public function run_validate() {
        if ( ! $this->acquire_lock( 'validate' ) ) return;
        if ( function_exists( 'set_time_limit' ) ) @set_time_limit( 180 );
        if ( function_exists( 'ignore_user_abort' ) ) @ignore_user_abort( true );
        self::log( 'validate', 'Starting keyword validation' );

        $harvest_data = get_option( self::HARVEST_KEY, array() );
        $harvested    = $harvest_data['topics'] ?? array();

        if ( empty( $harvested ) ) {
            self::log( 'validate', 'No harvested data — skipping' );
            $this->release_lock();
            return;
        }

        $dba_settings = get_option( 'dba_settings', array() );
        $api_key      = $dba_settings['openai_api_key'] ?? '';
        if ( empty( $api_key ) ) {
            self::log( 'validate', 'No OpenAI API key — aborting' );
            $this->release_lock();
            return;
        }
        $model = $dba_settings['openai_model'] ?? 'gpt-4o-mini';

        $existing_titles = $this->get_all_published_titles();

        // Batch by priority
        $groups = array( 'critical' => array(), 'high' => array(), 'medium' => array(), 'low' => array() );
        foreach ( $harvested as $h ) {
            $groups[ $h['priority'] ?? 'medium' ][] = $h;
        }

        $all_briefs   = array();
        $openai_calls = 0;

        foreach ( $groups as $priority => $topics ) {
            if ( empty( $topics ) ) continue;
            if ( $openai_calls >= self::MAX_OPENAI_CALLS_PER_CYCLE ) break;
            if ( count( $all_briefs ) >= self::MAX_BRIEFS_PER_CYCLE ) break;

            // Split large groups into chunks of 8 to avoid token overflow
            $chunks = array_chunk( $topics, 8 );
            foreach ( $chunks as $chunk ) {
                if ( $openai_calls >= self::MAX_OPENAI_CALLS_PER_CYCLE ) break;
                if ( count( $all_briefs ) >= self::MAX_BRIEFS_PER_CYCLE ) break;

                $briefs = $this->validate_batch( $chunk, $existing_titles, $api_key, $model, $priority );
                $openai_calls++;

                self::log( 'validate', 'Got ' . count( $briefs ) . ' briefs for priority "' . $priority . '" (chunk of ' . count( $chunk ) . ' topics)' );

                foreach ( $briefs as $brief ) {
                    if ( count( $all_briefs ) >= self::MAX_BRIEFS_PER_CYCLE ) break;

                    if ( $this->is_title_unique( $brief['title'] ?? '', $existing_titles, $all_briefs ) ) {
                        $all_briefs[] = $brief;
                        $existing_titles[] = $brief['title'];
                    } else {
                        self::log( 'validate', 'Rejected duplicate: "' . ( $brief['title'] ?? '' ) . '"' );
                    }
                }
            }
        }

        update_option( self::BRIEFS_KEY, array(
            'generated_at' => current_time( 'mysql' ),
            'briefs'       => $all_briefs,
            'openai_calls' => $openai_calls,
        ), false );

        self::log( 'validate', 'Complete — ' . count( $all_briefs ) . ' briefs, ' . $openai_calls . ' OpenAI calls' );
        $this->release_lock();
    }

    /**
     * Validate a batch of topics via OpenAI — one call per priority group.
     */
    private function validate_batch( $topics, $existing_titles, $api_key, $model, $priority ) {
        $topic_block = '';
        foreach ( $topics as $idx => $t ) {
            $related  = implode( ', ', array_slice( $t['related_searches'] ?? array(), 0, 8 ) );
            $paa      = implode( ', ', array_slice( $t['people_also_ask'] ?? array(), 0, 6 ) );
            $comps    = implode( ' | ', array_slice( $t['competitor_titles'] ?? array(), 0, 5 ) );
            $fallback = implode( ', ', array_slice( $t['fallback_keywords'] ?? array(), 0, 8 ) );
            $bot      = $t['target_bot'] ?? 'luxstrat';

            $topic_block .= "\n--- TOPIC " . ( $idx + 1 ) . " (Grade: " . ( $t['grade'] ?? '?' ) . ", Bot: " . $bot . ") ---\n";
            $topic_block .= "Root keyword: {$t['root_keyword']}\n";
            $topic_block .= "Target bot: {$bot}\n";
            if ( ! empty( $related ) ) {
                $topic_block .= "Google Related Searches: {$related}\n";
            }
            if ( ! empty( $paa ) ) {
                $topic_block .= "People Also Ask: {$paa}\n";
            }
            if ( ! empty( $fallback ) ) {
                $topic_block .= "Competitor-derived keywords: {$fallback}\n";
            }
            if ( ! empty( $comps ) ) {
                $topic_block .= "Competitor titles: {$comps}\n";
            }
        }

        $recent_titles = implode( "\n", array_slice( $existing_titles, -100 ) );
        $max_briefs    = min( count( $topics ) * 3, self::MAX_BRIEFS_PER_CYCLE );

        $system = 'You are a multilingual content strategist for Damalion, an international corporate services firm. '
            . 'You know Luxembourg structuring (SOPARFI, RAIF, SIF, SICAR, SCSp, SCA, SPF, UCITS, SICAV), '
            . 'French accounting/tax (comptabilité, fiscalité, TVA), US startups, Latin America business, and Eastern Europe investment. '
            . 'Applicable Luxembourg laws: Law of 10 August 1915, Law of 23 July 2016, Law of 13 February 2007, CSSF regulations. '
            . 'You NEVER produce legally incorrect content. Example: "RAIF vs AIF" is INVALID because a RAIF IS an AIF. '
            . '"SCSp vs SCA" is VALID (different partnership forms). "RAIF vs SIF" is VALID (different fund regimes). '
            . 'You create article briefs with titles a professional would click — targeting real human search queries. '
            . 'TITLE STYLE: Titles must sound like a human wrote them — natural language, conversational, specific. '
            . 'NEVER use robotic AI-style titles. BAD: "Regulatory Process and Key Considerations", "A Comprehensive Guide to X". '
            . 'GOOD: "How Much Does a RAIF Cost in Luxembourg?", "SOPARFI vs SPF: Which One Saves You More Tax?".';

        // Inject banned words into the prompt so OpenAI avoids them
        $banned = array(
            'regulatory','framework','comprehensive','robust','holistic','landscape',
            'key considerations','key insights','key benefits','key advantages','key takeaways',
            'essential guide','complete guide','ultimate guide','definitive guide','step-by-step guide',
            'everything you need to know','what you need to know','in-depth look','in-depth guide',
            'a closer look','an overview','key factors','key strategies','key aspects',
            'key features','key elements','crucial role','vital role','pivotal role',
            'key role','strategic importance','strategic overview','key challenges','key opportunities',
            'innovative','transformative','cutting-edge','streamline','leveraging','navigating',
            'unlocking','unveiling','harnessing','demystifying','mastering','empowering',
        );

        $user_prompt = "Generate article briefs from Google keyword intelligence.\n\n"
            . "RULES:\n"
            . "1. Each brief: ONE human-readable, legally correct title targeting a real search query\n"
            . "2. Each brief: 3-5 target keywords (queries humans actually type)\n"
            . "3. REJECT any keyword that is legally wrong (explain why in rejected_keywords)\n"
            . "4. NO duplicate or near-duplicate of existing titles\n"
            . "5. IMPORTANT: Use the bot_id specified per topic. Each topic has a target bot — USE IT.\n"
            . "6. theme_slug must match the bot's themes (see BOT ROSTER below)\n"
            . "7. Maximum {$max_briefs} briefs\n"
            . "8. Match the article LANGUAGE to the bot:\n"
            . "   - luxstrat, geobot, startup-hub, latam, eeurope, newsbot → English titles\n"
            . "   - comfisc-fr, luxstrat-intl-fr → French titles\n"
            . "   - luxstrat-intl-de → German titles\n"
            . "   - luxstrat-intl-pt → Portuguese titles\n"
            . "9. BANNED WORDS IN TITLES — NEVER use these: " . implode( ', ', $banned ) . "\n"
            . "   Any brief with a banned word in its title will be REJECTED.\n\n"
            . "BOT ROSTER (bot_id → theme_slugs):\n"
            . "  luxstrat → scsp, raif, depositary_bank, sicav_raif, soparfi, sicar, sicav_ucits, spf, company_formation, esg, domiciliation, private_equity, fund_services, securitization\n"
            . "  comfisc-fr → c1_societe_creation, c1_sarl_anticiper, c1_sa_exigences, c2_impot_societes, c2_tva_international, c2_holding_structuration, c2_soparfi_regime, c2_prix_transfert, c3_comptes_annuels, c3_audit_obligatoire, c4_restructuration_strategique\n"
            . "  luxstrat-intl-fr → same themes as luxstrat BUT titles in FRENCH (Luxembourg structuring for French-speaking audience)\n"
            . "  luxstrat-intl-de → same themes as luxstrat BUT titles in GERMAN\n"
            . "  luxstrat-intl-pt → same themes as luxstrat BUT titles in PORTUGUESE\n"
            . "  geobot → country-specific investment articles (France, Italy, Spain, Portugal). theme_slug = country key (france, italy, spain, portugal)\n"
            . "  startup-hub → fintech, ai-ml, saas, biotech, cleantech, healthtech, cybersecurity, edtech, proptech, web3, robotics\n"
            . "  latam → country-specific business articles. theme_slug = country key (brazil, mexico, argentina, chile, peru, colombia, etc.)\n"
            . "  eeurope → Eastern Europe investment articles. theme_slug = country key\n"
            . "  newsbot → PE/Capital Markets news. theme_slug = pe_news\n\n"
            . "KEYWORD INTELLIGENCE:\n{$topic_block}\n\n"
            . "EXISTING TITLES (DO NOT DUPLICATE):\n{$recent_titles}\n\n"
            . "RESPOND IN PURE JSON ONLY — no markdown, no backticks, no preamble:\n"
            . '[{"title":"...","theme_slug":"...","bot_id":"luxstrat","keywords":["kw1","kw2","kw3"],"angle":"one sentence","rejected_keywords":[{"keyword":"...","reason":"..."}]}]';

        $response = wp_remote_post( 'https://api.openai.com/v1/chat/completions', array(
            'timeout' => 120,
            'headers' => array(
                'Content-Type'  => 'application/json',
                'Authorization' => 'Bearer ' . $api_key,
            ),
            'body' => wp_json_encode( array(
                'model'       => $model,
                'temperature' => 0.4,
                'max_tokens'  => 3000,
                'messages'    => array(
                    array( 'role' => 'system', 'content' => $system ),
                    array( 'role' => 'user',   'content' => $user_prompt ),
                ),
            ) ),
        ) );

        if ( is_wp_error( $response ) ) {
            self::log( 'validate', 'OpenAI error: ' . $response->get_error_message() );
            return array();
        }

        $code = wp_remote_retrieve_response_code( $response );
        $body = json_decode( wp_remote_retrieve_body( $response ), true );

        if ( $code !== 200 ) {
            self::log( 'validate', 'OpenAI HTTP ' . $code . ': ' . ( $body['error']['message'] ?? 'unknown' ) );
            return array();
        }

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

        $briefs = json_decode( $content, true );
        if ( ! is_array( $briefs ) ) {
            self::log( 'validate', 'Failed to parse OpenAI JSON' );
            return array();
        }

        // Log rejected keywords
        foreach ( $briefs as &$brief ) {
            $brief['priority']     = $priority;
            $brief['generated_at'] = current_time( 'mysql' );
            $brief['status']       = 'pending';

            foreach ( $brief['rejected_keywords'] ?? array() as $rk ) {
                self::log( 'validate', 'Rejected keyword: "' . ( $rk['keyword'] ?? '' ) . '" — ' . ( $rk['reason'] ?? '' ) );
            }
        }
        unset( $brief );

        return $briefs;
    }

    /* ══════════════════════════════════════════════════════════════
     *  TITLE UNIQUENESS GATE
     * ══════════════════════════════════════════════════════════════ */

    private function is_title_unique( $title, $existing_titles, $current_briefs ) {
        if ( empty( $title ) || mb_strlen( $title ) < 10 ) return false;

        $title_lower = mb_strtolower( trim( $title ) );
        $title_words = array_filter( explode( ' ', preg_replace( '/[^\w\s]/u', '', $title_lower ) ) );
        if ( count( $title_words ) < 3 ) return false;

        // Check against published titles (Jaccard word similarity)
        foreach ( $existing_titles as $existing ) {
            $ex_lower = mb_strtolower( trim( $existing ) );
            if ( $title_lower === $ex_lower ) return false;

            $ex_words = array_filter( explode( ' ', preg_replace( '/[^\w\s]/u', '', $ex_lower ) ) );
            if ( empty( $ex_words ) ) continue;

            $inter = count( array_intersect( $title_words, $ex_words ) );
            $union = count( array_unique( array_merge( $title_words, $ex_words ) ) );
            if ( $union > 0 && ( $inter / $union ) >= self::TITLE_SIMILARITY_THRESHOLD ) return false;
        }

        // Check against other briefs in this batch
        foreach ( $current_briefs as $b ) {
            $b_lower = mb_strtolower( trim( $b['title'] ?? '' ) );
            if ( $title_lower === $b_lower ) return false;

            $b_words = array_filter( explode( ' ', preg_replace( '/[^\w\s]/u', '', $b_lower ) ) );
            if ( empty( $b_words ) ) continue;

            $inter = count( array_intersect( $title_words, $b_words ) );
            $union = count( array_unique( array_merge( $title_words, $b_words ) ) );
            if ( $union > 0 && ( $inter / $union ) >= self::TITLE_SIMILARITY_THRESHOLD ) return false;
        }

        return true;
    }

    private function get_all_published_titles() {
        global $wpdb;
        return $wpdb->get_col(
            "SELECT post_title FROM {$wpdb->posts}
             WHERE post_status = 'publish' AND post_type = 'post' AND post_title != ''
             ORDER BY ID DESC LIMIT 500"
        ) ?: array();
    }

    /* ══════════════════════════════════════════════════════════════
     *  BRIEF QUEUE — Consumed by bots at generation time
     * ══════════════════════════════════════════════════════════════ */

    /**
     * Get next pending brief for a bot. Marks it consumed.
     */
    public static function consume_brief( $bot_id, $theme_slug = '' ) {
        $data   = get_option( self::BRIEFS_KEY, array() );
        $briefs = $data['briefs'] ?? array();
        if ( empty( $briefs ) ) return null;

        $best_idx  = null;
        $best_prio = 999;
        $prio_map  = array( 'critical' => 0, 'high' => 1, 'medium' => 2, 'low' => 3 );

        foreach ( $briefs as $idx => $brief ) {
            if ( ( $brief['status'] ?? '' ) !== 'pending' ) continue;
            if ( ( $brief['bot_id'] ?? '' ) !== $bot_id ) continue;
            if ( ! empty( $theme_slug ) && ( $brief['theme_slug'] ?? '' ) !== $theme_slug ) continue;

            $p = $prio_map[ $brief['priority'] ?? 'medium' ] ?? 3;
            if ( $p < $best_prio ) {
                $best_prio = $p;
                $best_idx  = $idx;
            }
        }

        if ( $best_idx === null ) return null;

        $briefs[ $best_idx ]['status']      = 'consumed';
        $briefs[ $best_idx ]['consumed_at'] = current_time( 'mysql' );
        $data['briefs'] = $briefs;
        update_option( self::BRIEFS_KEY, $data, false );

        error_log( 'DBA Pipeline: Brief consumed by ' . $bot_id . ': "' . $briefs[ $best_idx ]['title'] . '"' );
        return $briefs[ $best_idx ];
    }

    /**
     * Get next brief for a bot regardless of theme (overrides rotation).
     */
    public static function get_next_brief_for_bot( $bot_id ) {
        return self::consume_brief( $bot_id );
    }

    /**
     * Build prompt injection from a pipeline brief.
     * Used by theme-based bots — enforces title + keywords.
     */
    public static function build_brief_prompt( $brief ) {
        if ( empty( $brief ) ) return '';
        $kws = implode( ', ', $brief['keywords'] ?? array() );

        $p  = "\n\n═══ PIPELINE ARTICLE BRIEF ═══\n";
        $p .= "YOU MUST USE THIS EXACT TITLE: {$brief['title']}\n";
        $p .= "Target keywords (use in H1, H2s, first paragraph): {$kws}\n";
        $p .= "Article angle: " . ( $brief['angle'] ?? '' ) . "\n";
        $p .= "Priority: " . strtoupper( $brief['priority'] ?? 'medium' ) . "\n";
        $p .= "This title is validated for legal accuracy and uniqueness.\n";
        $p .= "DO NOT change the title. DO NOT add generic prefix/suffix.\n";
        $p .= "═══ END PIPELINE BRIEF ═══\n";
        return $p;
    }

    /**
     * Build softer prompt injection for geo/city/news bots.
     * Injects keyword focus and angle WITHOUT overriding the title.
     * Used by GeoBot, LATAM, EEurope, Startup Hub, NewsBot.
     */
    public static function build_brief_context( $brief ) {
        if ( empty( $brief ) ) return '';
        $kws = implode( ', ', $brief['keywords'] ?? array() );

        $p  = "\n\n═══ PIPELINE KEYWORD FOCUS ═══\n";
        $p .= "Strategic keywords to weave into this article: {$kws}\n";
        $p .= "Angle: " . ( $brief['angle'] ?? '' ) . "\n";
        $p .= "Include these keywords naturally in H2 headings and body text where relevant.\n";
        $p .= "Do NOT force them if they don't fit the article's geographic context.\n";
        $p .= "═══ END PIPELINE FOCUS ═══\n";
        return $p;
    }

    /**
     * Mark a brief as published.
     */
    public static function mark_published( $title, $post_id ) {
        $data   = get_option( self::BRIEFS_KEY, array() );
        $briefs = $data['briefs'] ?? array();

        foreach ( $briefs as &$brief ) {
            if ( mb_strtolower( $brief['title'] ?? '' ) === mb_strtolower( $title ) ) {
                $brief['status']       = 'published';
                $brief['post_id']      = $post_id;
                $brief['published_at'] = current_time( 'mysql' );
                break;
            }
        }
        unset( $brief );

        $data['briefs'] = $briefs;
        update_option( self::BRIEFS_KEY, $data, false );
    }

    public static function get_all_briefs() {
        return ( get_option( self::BRIEFS_KEY, array() ) )['briefs'] ?? array();
    }

    public static function get_stats() {
        $briefs  = self::get_all_briefs();
        $harvest = get_option( self::HARVEST_KEY, array() );
        $log     = get_option( self::LOG_KEY, array() );

        $by_status = array( 'pending' => 0, 'consumed' => 0, 'published' => 0 );
        foreach ( $briefs as $b ) $by_status[ $b['status'] ?? 'pending' ] = ( $by_status[ $b['status'] ?? 'pending' ] ?? 0 ) + 1;

        return array(
            'total_briefs'   => count( $briefs ),
            'pending'        => $by_status['pending'],
            'consumed'       => $by_status['consumed'],
            'published'      => $by_status['published'],
            'last_harvest'   => $harvest['generated_at'] ?? 'Never',
            'harvest_calls'  => $harvest['serper_calls'] ?? 0,
            'harvest_topics' => count( $harvest['topics'] ?? array() ),
            'next_harvest'   => wp_next_scheduled( self::HARVEST_HOOK ) ? date( 'Y-m-d H:i', wp_next_scheduled( self::HARVEST_HOOK ) ) : 'Not scheduled',
            'next_validate'  => wp_next_scheduled( self::VALIDATE_HOOK ) ? date( 'Y-m-d H:i', wp_next_scheduled( self::VALIDATE_HOOK ) ) : 'Not scheduled',
            'log'            => array_slice( $log, -20 ),
        );
    }

    /* ══════════════════════════════════════════════════════════════
     *  LOCK MANAGEMENT — Respects Conflict Center
     * ══════════════════════════════════════════════════════════════ */

    private function acquire_lock( $stage ) {
        if ( get_transient( self::LOCK_KEY ) ) {
            self::log( $stage, 'Lock held — skipping' );
            return false;
        }
        if ( class_exists( 'DBA_Conflict_Center' ) ) {
            if ( DBA_Conflict_Center::is_any_running() ) {
                self::log( $stage, 'Conflict Center busy — skipping' );
                return false;
            }
            if ( ! DBA_Conflict_Center::acquire( 'pipeline_' . $stage, self::HARVEST_HOOK ) ) {
                self::log( $stage, 'Conflict Center denied' );
                return false;
            }
        }
        set_transient( self::LOCK_KEY, $stage, self::LOCK_TTL );
        return true;
    }

    private function release_lock() {
        $stage = get_transient( self::LOCK_KEY );
        delete_transient( self::LOCK_KEY );
        if ( class_exists( 'DBA_Conflict_Center' ) && $stage ) {
            DBA_Conflict_Center::release( 'pipeline_' . $stage );
        }
    }

    private static function log( $stage, $message ) {
        $log   = get_option( self::LOG_KEY, array() );
        $log[] = array( 'time' => current_time( 'mysql' ), 'stage' => $stage, 'message' => $message );
        update_option( self::LOG_KEY, array_slice( $log, -50 ), false );
        error_log( 'DBA Pipeline [' . $stage . ']: ' . $message );
    }
}
