<?php
/**
 * DBA_LuxStrat_Kaizen — Learning engine for LuxStrat
 *
 * Tracks editor corrections on Luxembourg structuring articles,
 * detects structural repetition across themes, blacklists overused
 * transitions, scans for 24 editorial anti-patterns, injects
 * learning context into AI prompt.
 *
 * Theme-based (not country/city): scans by theme slug.
 * ALL LuxStrat-specific: _dba_luxstrat_* meta, DBA_LuxStrat_* class.
 *
 * @since 2.17.0  – original Kaizen engine
 * @since 2.18.0  – 24-pattern detection, CTA guard, word-count lock
 */
if ( ! defined( 'ABSPATH' ) ) exit;

class DBA_LuxStrat_Kaizen {

    /* ══════════════════════════════════════════════════════════════
     *  EXISTING CONSTANTS — unchanged
     * ══════════════════════════════════════════════════════════════ */

    const META_ORIGINAL_CONTENT = '_dba_luxstrat_original_content';
    const META_ORIGINAL_TITLE   = '_dba_luxstrat_original_title';
    const META_ORIGINAL_EXCERPT = '_dba_luxstrat_original_excerpt';
    const META_STRUCTURE_HASH   = '_dba_luxstrat_structure_hash';
    const META_OPENER_SIG       = '_dba_luxstrat_opener_sig';
    const CACHE_PREFIX          = 'dba_luxstrat_kaizen_ctx_';
    const CACHE_TTL             = 21600; // 6 hours

    /* ══════════════════════════════════════════════════════════════
     *  EXISTING TRANSITION WORD LIST — unchanged
     * ══════════════════════════════════════════════════════════════ */

    private static $transition_words = array(
        'moreover','furthermore','additionally','consequently','nevertheless',
        'in addition','as a result','on the other hand','in conclusion',
        'for instance','in particular','notably','significantly',
        'therefore','meanwhile','however','likewise','accordingly',
        'subsequently','henceforth','undoubtedly','interestingly',
        'in contrast','for example','in fact','as such','that said',
        'it is worth noting','it should be noted','needless to say',
    );

    /* ══════════════════════════════════════════════════════════════
     *  NEW — 24-PATTERN REGISTRY (from editorial audit)
     *
     *  Each category => array of phrases/patterns to detect.
     *  Severity: 'high' = always flag, 'medium' = flag ≥ threshold,
     *            'low' = advisory only.
     *  Action: 'replace' = swap with substantive content (same length),
     *          'rewrite' = restructure sentence, 'remove' = strip only.
     *  Threshold: per-post occurrence count before flagging.
     *
     *  Filterable: apply_filters('dba_luxstrat_kaizen_patterns', ...)
     * ══════════════════════════════════════════════════════════════ */

    private static $pattern_registry = array(

        /* ── CONTENT PATTERNS (#1–#6) ── */

        'significance_inflation' => array(
            'id'        => 1,
            'label'     => 'Significance inflation',
            'severity'  => 'high',
            'action'    => 'replace',
            'threshold' => 1,
            'phrases'   => array(
                'pivotal role','pivotal moment','turning point','milestone in the evolution',
                'game-changing','groundbreaking','transformative','revolutionary',
                'cornerstone of','landmark','watershed moment','paradigm shift',
            ),
            'prompt'    => 'Replace grandiose significance claims with concrete factual statements. Keep sentence length equal.',
        ),

        'notability_namedrop' => array(
            'id'        => 2,
            'label'     => 'Notability name-dropping',
            'severity'  => 'medium',
            'action'    => 'rewrite',
            'threshold' => 2,
            'phrases'   => array(
                'cited in nyt, bbc','featured in','as reported by','recognized by',
                'praised by','endorsed by','acclaimed by',
            ),
            'prompt'    => 'If citing outlets, use ONE specific reference with date/context instead of listing names.',
        ),

        'superficial_ing' => array(
            'id'        => 3,
            'label'     => 'Superficial -ing analyses',
            'severity'  => 'high',
            'action'    => 'replace',
            'threshold' => 2,
            'phrases'   => array(
                'symbolizing','reflecting the','showcasing','epitomizing',
                'encapsulating','embodying','representing a',
                'underscoring','highlighting the importance',
            ),
            'prompt'    => 'Replace vague -ing interpretations with sourced factual analysis of equal length.',
        ),

        'promotional_language' => array(
            'id'        => 4,
            'label'     => 'Promotional language',
            'severity'  => 'high',
            'action'    => 'replace',
            'threshold' => 1,
            'phrases'   => array(
                'nestled within','breathtaking','vibrant tapestry','bustling',
                'world-class','cutting-edge','state-of-the-art','unparalleled',
                'second to none','premier destination','thriving ecosystem',
                'dynamic landscape','rich tapestry','jewel of',
            ),
            'prompt'    => 'Replace promotional/tourism language with neutral factual descriptions. Maintain word count.',
        ),

        'vague_attributions' => array(
            'id'        => 5,
            'label'     => 'Vague attributions',
            'severity'  => 'high',
            'action'    => 'replace',
            'threshold' => 1,
            'phrases'   => array(
                'experts believe','many argue','studies show','scholars suggest',
                'it is widely accepted','research indicates','analysts note',
                'observers point out','commentators suggest','some experts',
                'industry leaders','thought leaders',
            ),
            'prompt'    => 'Replace vague attributions with named sources, specific dates, or concrete data. Same sentence length.',
        ),

        'formulaic_challenges' => array(
            'id'        => 6,
            'label'     => 'Formulaic challenges',
            'severity'  => 'high',
            'action'    => 'replace',
            'threshold' => 1,
            'phrases'   => array(
                'despite challenges','continues to thrive','stands as a testament',
                'in the face of','has not been without challenges',
                'notwithstanding the challenges','remains resilient',
                'despite these hurdles','overcome significant obstacles',
            ),
            'prompt'    => 'Replace formulaic resilience claims with specific named challenges and factual outcomes.',
        ),

        /* ── LANGUAGE PATTERNS (#7–#12) ── */

        'ai_vocabulary' => array(
            'id'        => 7,
            'label'     => 'AI vocabulary',
            'severity'  => 'high',
            'action'    => 'replace',
            'threshold' => 2,
            'phrases'   => array(
                'testament','landscape','showcasing','delve','tapestry',
                'multifaceted','leverage','synergy','holistic','robust',
                'streamline','spearhead','foster','bolster','underpin',
                'navigate the complexities','at the forefront',
                'it is important to note','it bears mentioning',
            ),
            'prompt'    => 'Replace AI-typical vocabulary with plain equivalents. "testament" → proof, "landscape" → sector, "robust" → strong. Keep length.',
        ),

        'copula_avoidance' => array(
            'id'        => 8,
            'label'     => 'Copula avoidance',
            'severity'  => 'medium',
            'action'    => 'rewrite',
            'threshold' => 3,
            'phrases'   => array(
                'serves as','features','boasts','stands as','functions as',
                'operates as','acts as a hub','plays a role','plays a key role',
                'plays a crucial role','plays a pivotal role',
            ),
            'prompt'    => 'Use "is" or "has" where a periphrastic verb adds no meaning. "serves as the main" → "is the main".',
        ),

        'negative_parallelisms' => array(
            'id'        => 9,
            'label'     => 'Negative parallelisms',
            'severity'  => 'medium',
            'action'    => 'rewrite',
            'threshold' => 1,
            'phrases'   => array(
                'not just','not merely','not only','it\'s not about',
                'it is not simply','more than just','goes beyond',
            ),
            'prompt'    => 'State the point directly instead of "not just X, it\'s Y". Keep the sentence length equal.',
        ),

        'rule_of_three' => array(
            'id'        => 10,
            'label'     => 'Rule of three',
            'severity'  => 'low',
            'action'    => 'rewrite',
            'threshold' => 3,
            'regex'     => '/\b\w+,\s+\w+,\s+and\s+\w+\b/i',
            'phrases'   => array(), // detected by regex
            'prompt'    => 'Use the natural number of items, not forced triads. Two items or four are fine.',
        ),

        'synonym_cycling' => array(
            'id'        => 11,
            'label'     => 'Synonym cycling',
            'severity'  => 'medium',
            'action'    => 'rewrite',
            'threshold' => 1,
            'phrases'   => array(), // detected by dedicated method
            'prompt'    => 'Repeat the clearest noun instead of cycling synonyms. "the depositary" 4× is better than "the institution… the entity… the fiduciary… the bank".',
        ),

        'false_ranges' => array(
            'id'        => 12,
            'label'     => 'False ranges',
            'severity'  => 'medium',
            'action'    => 'rewrite',
            'threshold' => 1,
            'regex'     => '/from\s+(?:the\s+)?\w+\s+to\s+(?:the\s+)?\w+/i',
            'phrases'   => array(), // detected by regex + manual review
            'prompt'    => 'List topics directly instead of "from X to Y" sweeps.',
        ),

        /* ── STYLE PATTERNS (#13–#18) ── */

        'emdash_overuse' => array(
            'id'        => 13,
            'label'     => 'Em dash overuse',
            'severity'  => 'medium',
            'action'    => 'rewrite',
            'threshold' => 3, // per post
            'detect'    => 'count_char',
            'char'      => '—',
            'phrases'   => array(),
            'prompt'    => 'Use commas or periods instead of em dashes. Maximum 2 em dashes per article.',
        ),

        'boldface_overuse' => array(
            'id'        => 14,
            'label'     => 'Boldface overuse',
            'severity'  => 'medium',
            'action'    => 'rewrite',
            'threshold' => 5,
            'detect'    => 'count_tag',
            'tag'       => 'strong',
            'phrases'   => array(),
            'prompt'    => 'Bold only H2/H3 headings and the CTA company name. Remove inline bold from body text.',
        ),

        'inline_header_lists' => array(
            'id'        => 15,
            'label'     => 'Inline-header lists',
            'severity'  => 'medium',
            'action'    => 'rewrite',
            'threshold' => 2,
            'regex'     => '/<strong>[^<]{3,30}:<\/strong>/i',
            'phrases'   => array(),
            'prompt'    => 'Convert bold-colon inline headers to flowing prose or proper H3 sub-headings.',
        ),

        'title_case_headings' => array(
            'id'        => 16,
            'label'     => 'Title Case headings',
            'severity'  => 'high',
            'action'    => 'rewrite',
            'threshold' => 1,
            'detect'    => 'h2_title_case',
            'phrases'   => array(),
            'prompt'    => 'Use sentence case for all H2/H3 headings: capitalize only the first word and proper nouns.',
        ),

        'emoji_usage' => array(
            'id'        => 17,
            'label'     => 'Emoji usage',
            'severity'  => 'high',
            'action'    => 'remove',
            'threshold' => 1,
            'regex'     => '/[\x{1F300}-\x{1F9FF}\x{2600}-\x{26FF}\x{2700}-\x{27BF}]/u',
            'phrases'   => array(),
            'prompt'    => 'Remove all emojis. Professional articles must not contain emojis.',
        ),

        'curly_quotes' => array(
            'id'        => 18,
            'label'     => 'Curly quotes',
            'severity'  => 'low',
            'action'    => 'replace',
            'threshold' => 1,
            'detect'    => 'curly_quotes',
            'phrases'   => array(),
            'prompt'    => 'Use straight quotes (") not curly/smart quotes (" ").',
        ),

        /* ── COMMUNICATION PATTERNS (#19–#21) ── */

        'chatbot_artifacts' => array(
            'id'        => 19,
            'label'     => 'Chatbot artifacts',
            'severity'  => 'high',
            'action'    => 'remove',
            'threshold' => 1,
            'phrases'   => array(
                'i hope this helps','let me know if','feel free to ask',
                'feel free to reach out','don\'t hesitate to',
                'happy to help','if you have any questions',
                'here\'s a breakdown','let\'s dive in','let\'s explore',
                'in this article, we will','in this article we explore',
                'shall we explore','we\'ll delve into',
            ),
            'prompt'    => 'Remove chatbot phrases entirely and redistribute their word count into substantive content nearby.',
        ),

        'cutoff_disclaimers' => array(
            'id'        => 20,
            'label'     => 'Cutoff disclaimers',
            'severity'  => 'high',
            'action'    => 'remove',
            'threshold' => 1,
            'phrases'   => array(
                'while details are limited','based on available information',
                'at the time of writing','as of my last update',
                'information may have changed','subject to change',
                'details are scarce','limited information available',
            ),
            'prompt'    => 'Remove AI-knowledge-cutoff disclaimers. Replace with sourced facts of equal word count.',
        ),

        'sycophantic_tone' => array(
            'id'        => 21,
            'label'     => 'Sycophantic tone',
            'severity'  => 'high',
            'action'    => 'remove',
            'threshold' => 1,
            'phrases'   => array(
                'great question','excellent point','you\'re absolutely right',
                'that\'s a fantastic','what a great','absolutely','indeed',
            ),
            'prompt'    => 'Remove sycophantic filler. Redistribute word count into substance.',
        ),

        /* ── FILLER AND HEDGING (#22–#24) ── */

        'filler_phrases' => array(
            'id'        => 22,
            'label'     => 'Filler phrases',
            'severity'  => 'high',
            'action'    => 'replace',
            'threshold' => 2,
            'phrases'   => array(
                'in order to','due to the fact that','it is important to note that',
                'it is worth mentioning','it goes without saying',
                'at the end of the day','when all is said and done',
                'in today\'s world','in the modern era','in this day and age',
                'the fact of the matter is','by and large',
            ),
            'prompt'    => '"In order to" → "To". "Due to the fact that" → "Because". Replace, keep sentence length via added detail.',
        ),

        'excessive_hedging' => array(
            'id'        => 23,
            'label'     => 'Excessive hedging',
            'severity'  => 'medium',
            'action'    => 'replace',
            'threshold' => 2,
            'phrases'   => array(
                'could potentially','may possibly','might perhaps',
                'it is possible that','there is a chance that',
                'it could be argued','one might suggest',
                'it seems that','it appears that','arguably',
            ),
            'prompt'    => 'Collapse stacked hedges: "could potentially" → "may". Fill saved words with specifics.',
        ),

        'generic_conclusions' => array(
            'id'        => 24,
            'label'     => 'Generic conclusions',
            'severity'  => 'high',
            'action'    => 'replace',
            'threshold' => 1,
            'phrases'   => array(
                'the future looks bright','only time will tell',
                'remains to be seen','in conclusion','to sum up',
                'all in all','looking ahead','moving forward',
                'as we move forward','the road ahead',
            ),
            'prompt'    => 'Replace generic conclusions with specific forward-looking facts or concrete next steps. Same word count.',
        ),

        /* ── FORMULAIC OPENERS (#25) ── */

        'formulaic_openers' => array(
            'id'        => 25,
            'label'     => 'Formulaic AI openers',
            'severity'  => 'high',
            'action'    => 'rewrite',
            'threshold' => 1,
            'detect'    => 'opener_check',
            'phrases'   => array(
                'why international investors are',
                'why the capital is gaining',
                'why investors are looking',
                'why businesses are choosing',
                'why entrepreneurs are flocking',
                'what you need to know',
                'everything you need to know',
                'the ultimate guide to',
                'a comprehensive guide to',
                'your complete guide to',
                'the definitive guide to',
                'a step-by-step guide',
                'here is why','here\'s why',
                'here is what','here\'s what',
                'here is how','here\'s how',
                'in recent years',
                'in the ever-evolving',
                'in today\'s rapidly changing',
                'in today\'s globalized',
                'in today\'s competitive',
                'in an era of',
                'in a world where',
                'as the world becomes',
                'as businesses increasingly',
                'with the rise of',
                'with increasing globalization',
                'when it comes to',
                'if you are looking to',
                'if you\'re looking to',
                'are you looking to',
                'looking to expand',
                'thinking of investing',
                'considering investing',
            ),
            'prompt'    => 'NEVER start with Why/How/What hook questions, year-bait, or guide-style openers. Open with a concrete fact, a specific number, a regulation name, or a direct statement about the jurisdiction. First sentence must contain substance, not a clickbait hook.',
        ),
    );

    /* ══════════════════════════════════════════════════════════════
     *  NEW — CTA PATTERNS TO PROTECT (never strip these)
     *
     *  The generator appends a Damalion CTA paragraph. Kaizen must
     *  NEVER flag or suggest removing it. This list lets the scanner
     *  skip CTA paragraphs during pattern detection.
     *
     *  Filterable: apply_filters('dba_luxstrat_kaizen_cta_markers', ...)
     * ══════════════════════════════════════════════════════════════ */

    private static $cta_markers = array(
        'damalion supports',
        'contact your damalion',
        'contact damalion',
        'reach out to damalion',
        'get in touch with damalion',
        'damalion experts',
        'damalion team',
        'your damalion expert',
    );

    /* ══════════════════════════════════════════════════════════════
     *  EXISTING METHODS — 100% unchanged
     * ══════════════════════════════════════════════════════════════ */

    /* ── STORE ORIGINAL — call after wp_insert_post in generator ── */
    public static function store_original( $post_id, $title, $content, $excerpt = '' ) {
        update_post_meta( $post_id, self::META_ORIGINAL_CONTENT, $content );
        update_post_meta( $post_id, self::META_ORIGINAL_TITLE,   $title );
        if ( $excerpt ) update_post_meta( $post_id, self::META_ORIGINAL_EXCERPT, $excerpt );
        update_post_meta( $post_id, self::META_STRUCTURE_HASH, self::compute_structure_hash( $content ) );
        update_post_meta( $post_id, self::META_OPENER_SIG,     self::compute_opener_signature( $content ) );
    }

    /* ── STRUCTURE FINGERPRINT ── */
    public static function compute_structure_hash( $html ) {
        preg_match_all( '/<h2[^>]*>(.*?)<\/h2>/si', $html, $h2s );
        $h2c = count( $h2s[0] );
        $sections = preg_split( '/<h2[^>]*>/si', $html );
        $pp = array();
        foreach ( $sections as $i => $sec ) {
            if ( $i === 0 && $h2c > 0 ) continue;
            preg_match_all( '/<p[^>]*>/si', $sec, $ps );
            $pp[] = count( $ps[0] );
        }
        $avg = 0;
        preg_match_all( '/<p[^>]*>(.*?)<\/p>/si', $html, $ap );
        if ( ! empty( $ap[1] ) ) {
            $ls = array_map( function($p){ return str_word_count(strip_tags($p)); }, $ap[1] );
            $avg = count($ls) ? intval(array_sum($ls)/count($ls)) : 0;
        }
        return "h2:{$h2c}|pp:" . implode('-',$pp) . "|avg:{$avg}";
    }

    public static function compute_opener_signature( $html ) {
        preg_match_all( '/<h2[^>]*>.*?<\/h2>\s*<p[^>]*>(.*?)<\/p>/si', $html, $m );
        $ops = array();
        foreach ( $m[1] as $fp ) {
            $w = preg_split('/\s+/', strip_tags($fp), 4);
            $ops[] = strtolower(implode(' ', array_slice($w,0,3)));
        }
        return implode('|',$ops);
    }

    /* ── DETECT EDITOR CORRECTIONS (last 14 days — LuxStrat is weekly) ── */
    public static function detect_corrections( $theme_slug = '' ) {
        global $wpdb;
        $cw = '';
        if ( $theme_slug ) $cw = $wpdb->prepare(" AND EXISTS(SELECT 1 FROM {$wpdb->postmeta} pm2 WHERE pm2.post_id=p.ID AND pm2.meta_key='_dba_luxstrat_theme' AND pm2.meta_value=%s)", $theme_slug);
        $ids = $wpdb->get_col(
            "SELECT p.ID FROM {$wpdb->posts} p
             INNER JOIN {$wpdb->postmeta} pm ON p.ID=pm.post_id AND pm.meta_key='".self::META_ORIGINAL_CONTENT."'
             WHERE p.post_status IN('publish','draft')
             AND p.post_date > DATE_SUB(NOW(), INTERVAL 14 DAY) {$cw}
             ORDER BY p.post_date DESC LIMIT 10"
        );
        $out = array();
        foreach ($ids as $pid) {
            $oc = get_post_meta($pid, self::META_ORIGINAL_CONTENT, true);
            $ot = get_post_meta($pid, self::META_ORIGINAL_TITLE, true);
            $cc = get_post_field('post_content',$pid);
            $ct = get_the_title($pid);
            $theme = get_post_meta($pid,'_dba_luxstrat_theme',true)?:'';
            $label = get_post_meta($pid,'_dba_luxstrat_theme_label',true)?:$theme;

            if ($ot && $ot!==$ct) $out[] = array('post_id'=>$pid,'theme'=>$label,'type'=>'title','detail'=>"'{$ot}' → '{$ct}'");
            if ($oc) {
                $ow=str_word_count(strip_tags($oc)); $cw2=str_word_count(strip_tags($cc));
                if ($ow>0 && abs($cw2-$ow)/$ow*100>5) {
                    $d=$cw2>$ow?'expanded':'trimmed';
                    $out[]=array('post_id'=>$pid,'theme'=>$label,'type'=>'content_'.$d,'detail'=>"{$ow}→{$cw2} words ({$d} ".round(abs($cw2-$ow)/$ow*100)."%)");
                }
                preg_match_all('/<h2[^>]*>/si',$oc,$oh); preg_match_all('/<h2[^>]*>/si',$cc,$ch);
                if(count($oh[0])!==count($ch[0])) $out[]=array('post_id'=>$pid,'theme'=>$label,'type'=>'h2_change','detail'=>'H2: '.count($oh[0]).'→'.count($ch[0]));
            }
        }
        return $out;
    }

    /* ── OVERUSED TRANSITIONS ── */
    public static function get_overused_transitions( $theme_slug = '' ) {
        global $wpdb;
        $cw='';
        if($theme_slug) $cw=$wpdb->prepare(" AND EXISTS(SELECT 1 FROM {$wpdb->postmeta} pm2 WHERE pm2.post_id=p.ID AND pm2.meta_key='_dba_luxstrat_theme' AND pm2.meta_value=%s)",$theme_slug);
        $ids=$wpdb->get_col(
            "SELECT p.ID FROM {$wpdb->posts} p
             INNER JOIN {$wpdb->postmeta} pm ON p.ID=pm.post_id AND pm.meta_key='_dba_luxstrat_auto_created'
             WHERE p.post_status IN('publish','draft')
             AND p.post_date > DATE_SUB(NOW(), INTERVAL 30 DAY) {$cw}
             ORDER BY p.post_date DESC LIMIT 10"
        );
        if(empty($ids)) return array();
        $counts=array();
        foreach($ids as $pid) { $txt=strtolower(strip_tags(get_post_field('post_content',$pid))); foreach(self::$transition_words as $tw){ $c=substr_count($txt,$tw); if($c>0) $counts[$tw]=($counts[$tw]??0)+$c; } }
        $out=array(); foreach($counts as $tw=>$t){ if($t>=5) $out[]=$tw; } return $out;
    }

    /* ── STRUCTURAL VARIETY CHECK ── */
    public static function check_structural_variety_global( $theme_slug = '' ) {
        global $wpdb;
        $cw='';
        if($theme_slug) $cw=$wpdb->prepare(" AND EXISTS(SELECT 1 FROM {$wpdb->postmeta} pm2 WHERE pm2.post_id=p.ID AND pm2.meta_key='_dba_luxstrat_theme' AND pm2.meta_value=%s)",$theme_slug);
        $ids=$wpdb->get_col(
            "SELECT p.ID FROM {$wpdb->posts} p
             INNER JOIN {$wpdb->postmeta} pm ON p.ID=pm.post_id AND pm.meta_key='_dba_luxstrat_auto_created'
             WHERE p.post_status IN('publish','draft') {$cw}
             ORDER BY p.post_date DESC LIMIT 10"
        );
        if(count($ids)<3) return array();
        $warnings=array(); $hashes=array();
        foreach($ids as $pid){ $h=get_post_meta($pid,self::META_STRUCTURE_HASH,true); if($h) $hashes[]=$h; }
        $freq=array_count_values($hashes);
        foreach($freq as $h=>$c){ if($c>=3) $warnings[]="{$c}/".count($ids)." recent articles have IDENTICAL structure ({$h}). VARY layout."; }
        return $warnings;
    }

    /* ── CACHE INVALIDATION ── */
    public static function invalidate_cache() {
        global $wpdb;
        $wpdb->query("DELETE FROM {$wpdb->options} WHERE option_name LIKE '_transient_".self::CACHE_PREFIX."%' OR option_name LIKE '_transient_timeout_".self::CACHE_PREFIX."%'");
    }

    /* ══════════════════════════════════════════════════════════════
     *  NEW — GET LEARNING CONTEXT (replaces original to add patterns)
     *
     *  The original method is preserved as get_learning_context_legacy()
     *  for rollback safety. The new method calls all original detectors
     *  AND the 24-pattern scanner, then builds a richer prompt context
     *  with explicit word-count and CTA preservation rules.
     * ══════════════════════════════════════════════════════════════ */

    /**
     * Legacy learning context — exact original logic, renamed for rollback.
     * @since 2.18.0
     */
    public static function get_learning_context_legacy( $theme_slug = '' ) {
        $corrections = self::detect_corrections($theme_slug);
        $overused    = self::get_overused_transitions($theme_slug);
        $variety     = self::check_structural_variety_global($theme_slug);
        if ( empty($corrections) && empty($overused) && empty($variety) ) return '';
        $ctx = "\n\n── KAIZEN LEARNING (editor corrections on recent LuxStrat posts) ──\n";
        if ( ! empty($corrections) ) {
            $ctx .= "Editorial corrections — LEARN and apply:\n";
            foreach ( array_slice($corrections,0,10) as $co ) {
                $ctx .= '  '.strtoupper(str_replace('_',' ',$co['type']))." [{$co['theme']}]: {$co['detail']}\n";
            }
        }
        if ( ! empty($overused) ) { $ctx .= "\nBLACKLISTED TRANSITIONS (≥5× in last 10 posts — DO NOT USE):\n  ✗ ".implode(', ✗ ',$overused)."\n"; }
        if ( ! empty($variety) )  { $ctx .= "\nSTRUCTURAL WARNINGS:\n"; foreach($variety as $w) $ctx .= "  ⚠ {$w}\n"; }
        return $ctx;
    }

    /**
     * Full learning context — original detections + 24-pattern scan + CTA/word-count rules.
     *
     * Drop-in replacement: same signature, same cache key, same return type.
     * @since 2.18.0
     */
    public static function get_learning_context( $theme_slug = '' ) {
        $ck = self::CACHE_PREFIX . ( $theme_slug ?: 'all' );
        $c  = get_transient( $ck );
        if ( false !== $c ) return $c;

        /* ── Original detections (unchanged logic) ── */
        $corrections = self::detect_corrections( $theme_slug );
        $overused    = self::get_overused_transitions( $theme_slug );
        $variety     = self::check_structural_variety_global( $theme_slug );

        /* ── New: 24-pattern aggregate from recent posts ── */
        $pattern_report = self::scan_recent_posts_for_patterns( $theme_slug );

        $has_data = ! empty($corrections) || ! empty($overused) || ! empty($variety) || ! empty($pattern_report);
        if ( ! $has_data ) {
            set_transient( $ck, '', self::CACHE_TTL );
            return '';
        }

        /* ── Build context string ── */
        $ctx = "\n\n── KAIZEN LEARNING ENGINE v2 (editor corrections + 24-pattern audit) ──\n";

        /* --- Section 1: original editorial corrections --- */
        if ( ! empty($corrections) ) {
            $ctx .= "\n▸ EDITORIAL CORRECTIONS (last 14 days) — learn and apply:\n";
            foreach ( array_slice($corrections, 0, 10) as $co ) {
                $ctx .= '  ' . strtoupper( str_replace('_', ' ', $co['type']) )
                       . " [{$co['theme']}]: {$co['detail']}\n";
            }
        }

        /* --- Section 2: original blacklisted transitions --- */
        if ( ! empty($overused) ) {
            $ctx .= "\n▸ BLACKLISTED TRANSITIONS (≥5× in last 10 posts — DO NOT USE):\n  ✗ "
                   . implode( ', ✗ ', $overused ) . "\n";
        }

        /* --- Section 3: original structural warnings --- */
        if ( ! empty($variety) ) {
            $ctx .= "\n▸ STRUCTURAL WARNINGS:\n";
            foreach ( $variety as $w ) $ctx .= "  ⚠ {$w}\n";
        }

        /* --- Section 4: NEW — 24-pattern scorecard --- */
        if ( ! empty($pattern_report) ) {
            $ctx .= "\n▸ 24-PATTERN ANTI-PATTERN SCORECARD (aggregate from last 10 posts):\n";
            $ctx .= "  RULE: When a pattern is flagged, REPLACE the flagged phrase with\n";
            $ctx .= "  substantive, specific content of EQUAL OR GREATER word count.\n";
            $ctx .= "  NEVER simply delete flagged text. The article word count must NOT decrease.\n\n";

            foreach ( $pattern_report as $cat => $info ) {
                $sev = strtoupper( $info['severity'] );
                $ctx .= "  [{$sev}] #{$info['id']} {$info['label']}: {$info['total']} hits in {$info['posts']} posts\n";
                if ( ! empty( $info['top_phrases'] ) ) {
                    $ctx .= "         Top offenders: " . implode( ', ', array_slice($info['top_phrases'], 0, 5) ) . "\n";
                }
                $ctx .= "         → {$info['prompt']}\n";
            }
        }

        /* --- Section 5: NEW — absolute rules (CTA + word count) --- */
        $ctx .= "\n▸ ABSOLUTE RULES — NEVER VIOLATE:\n";
        $ctx .= "  ✦ WORD COUNT LOCK: Output must match or exceed the target word count. Removing\n";
        $ctx .= "    a flagged phrase means you MUST replace it with equal-length substantive content.\n";
        $ctx .= "    Do NOT produce shorter articles. Filler must be replaced, not deleted.\n";
        $ctx .= "  ✦ CTA PRESERVATION: The Damalion call-to-action paragraph is MANDATORY.\n";
        $ctx .= "    It must appear as the final body paragraph before any 'Related' links.\n";
        $ctx .= "    NEVER remove, rephrase, or move the CTA. Typical form:\n";
        $ctx .= "    \"Damalion supports [audience] with [service]. Contact your Damalion experts now.\"\n";
        $ctx .= "  ✦ RELATED LINKS: The 'Related Luxembourg Structuring Insights' section and its\n";
        $ctx .= "    links are MANDATORY. Never remove or alter them.\n";

        /* --- Section 6: TIS directives (title fixes + content refresh) --- */
        if ( class_exists( 'DBA_TIS_Recommender' ) ) {
            $tis_title_fixes = DBA_TIS_Recommender::get_title_fix_directives();
            $tis_refresh     = DBA_TIS_Recommender::get_refresh_directives();

            if ( ! empty( $tis_title_fixes ) || ! empty( $tis_refresh ) ) {
                $ctx .= "\n▸ TIS STRATEGIC DIRECTIVES (from Topic Intelligence Scanner):\n";

                if ( ! empty( $tis_title_fixes ) ) {
                    $ctx .= "  TITLE OPTIMIZATION — These topics have pages with MISSING keywords in titles:\n";
                    foreach ( array_slice( $tis_title_fixes, 0, 5 ) as $d ) {
                        $kws = implode( ', ', array_slice( $d['keywords'] ?? array(), 0, 3 ) );
                        $ctx .= "    • {$d['target_label']}: include \"{$kws}\" in H1/title tag\n";
                    }
                    $ctx .= "  When writing NEW articles on these topics, ALWAYS put target keywords in the title.\n";
                }

                if ( ! empty( $tis_refresh ) ) {
                    $ctx .= "  CONTENT FRESHNESS — These topics have aging content. When writing new articles:\n";
                    foreach ( array_slice( $tis_refresh, 0, 5 ) as $d ) {
                        $ctx .= "    • {$d['target_label']}: prioritize current data, recent law changes, 2024-2026 updates\n";
                    }
                }
            }
        }

        set_transient( $ck, $ctx, self::CACHE_TTL );
        return $ctx;
    }

    /* ══════════════════════════════════════════════════════════════
     *  NEW — 24-PATTERN SCANNER (scans recent posts in aggregate)
     * ══════════════════════════════════════════════════════════════ */

    /**
     * Scan last 10 auto-created posts for all 24 anti-patterns.
     * Returns only categories that exceed their threshold.
     *
     * @param  string $theme_slug  Optional theme filter.
     * @return array  Keyed by category slug => {id, label, severity, total, posts, top_phrases, prompt}.
     * @since  2.18.0
     */
    public static function scan_recent_posts_for_patterns( $theme_slug = '' ) {
        global $wpdb;
        $cw = '';
        if ( $theme_slug ) {
            $cw = $wpdb->prepare(
                " AND EXISTS(SELECT 1 FROM {$wpdb->postmeta} pm2 WHERE pm2.post_id=p.ID AND pm2.meta_key='_dba_luxstrat_theme' AND pm2.meta_value=%s)",
                $theme_slug
            );
        }
        $ids = $wpdb->get_col(
            "SELECT p.ID FROM {$wpdb->posts} p
             INNER JOIN {$wpdb->postmeta} pm ON p.ID=pm.post_id AND pm.meta_key='_dba_luxstrat_auto_created'
             WHERE p.post_status IN('publish','draft')
             AND p.post_date > DATE_SUB(NOW(), INTERVAL 30 DAY) {$cw}
             ORDER BY p.post_date DESC LIMIT 10"
        );
        if ( empty($ids) ) return array();

        $registry = apply_filters( 'dba_luxstrat_kaizen_patterns', self::$pattern_registry );
        $results  = array();

        foreach ( $ids as $pid ) {
            $html = get_post_field( 'post_content', $pid );
            $text = strtolower( strip_tags( $html ) );

            /* Strip CTA paragraph from scan text so CTA phrases don't trigger false positives */
            $text_no_cta = self::strip_cta_from_text( $text );
            $html_no_cta = self::strip_cta_from_html( $html );

            foreach ( $registry as $cat => $def ) {
                if ( ! isset( $results[ $cat ] ) ) {
                    $results[ $cat ] = array(
                        'id'          => $def['id'],
                        'label'       => $def['label'],
                        'severity'    => $def['severity'],
                        'prompt'      => $def['prompt'],
                        'total'       => 0,
                        'posts'       => 0,
                        'phrase_hits' => array(),
                    );
                }

                $hits = self::detect_single_pattern( $cat, $def, $text_no_cta, $html_no_cta );

                if ( $hits > 0 ) {
                    $results[ $cat ]['total'] += $hits;
                    $results[ $cat ]['posts'] += 1;

                    /* Track which phrases triggered */
                    if ( ! empty( $def['phrases'] ) ) {
                        foreach ( $def['phrases'] as $ph ) {
                            $c = substr_count( $text_no_cta, $ph );
                            if ( $c > 0 ) {
                                $results[ $cat ]['phrase_hits'][ $ph ] = ( $results[ $cat ]['phrase_hits'][ $ph ] ?? 0 ) + $c;
                            }
                        }
                    }
                }
            }
        }

        /* Filter to only categories exceeding threshold across all posts */
        $flagged = array();
        foreach ( $results as $cat => $info ) {
            $def = $registry[ $cat ] ?? null;
            if ( ! $def ) continue;
            if ( $info['total'] >= $def['threshold'] ) {
                /* Sort phrase hits descending for "top offenders" */
                arsort( $info['phrase_hits'] );
                $info['top_phrases'] = array_keys( array_slice( $info['phrase_hits'], 0, 5, true ) );
                unset( $info['phrase_hits'] ); // don't bloat prompt
                $flagged[ $cat ] = $info;
            }
        }

        /* Sort by severity (high first), then by total hits descending */
        $sev_order = array( 'high' => 0, 'medium' => 1, 'low' => 2 );
        uasort( $flagged, function( $a, $b ) use ( $sev_order ) {
            $sa = $sev_order[ $a['severity'] ] ?? 3;
            $sb = $sev_order[ $b['severity'] ] ?? 3;
            if ( $sa !== $sb ) return $sa - $sb;
            return $b['total'] - $a['total'];
        });

        return $flagged;
    }

    /**
     * Detect a single pattern category in one post's text/html.
     * Returns integer hit count.
     *
     * @since 2.18.0
     */
    private static function detect_single_pattern( $cat, $def, $text, $html ) {
        $hits = 0;

        /* Method 1: phrase list matching */
        if ( ! empty( $def['phrases'] ) ) {
            foreach ( $def['phrases'] as $phrase ) {
                $hits += substr_count( $text, $phrase );
            }
        }

        /* Method 2: regex matching */
        if ( ! empty( $def['regex'] ) ) {
            $hits += preg_match_all( $def['regex'], $text );
        }

        /* Method 3: special detectors */
        if ( ! empty( $def['detect'] ) ) {
            switch ( $def['detect'] ) {

                case 'count_char':
                    $hits += substr_count( $text, $def['char'] ?? '' );
                    break;

                case 'count_tag':
                    $tag = $def['tag'] ?? 'strong';
                    preg_match_all( '/<' . preg_quote($tag, '/') . '[^>]*>/si', $html, $tm );
                    $hits += count( $tm[0] );
                    break;

                case 'h2_title_case':
                    preg_match_all( '/<h2[^>]*>(.*?)<\/h2>/si', $html, $h2m );
                    foreach ( $h2m[1] as $h2text ) {
                        $clean = strip_tags( $h2text );
                        if ( self::is_title_case( $clean ) ) $hits++;
                    }
                    break;

                case 'curly_quotes':
                    $hits += substr_count( $html, "\u{201C}" ); // "
                    $hits += substr_count( $html, "\u{201D}" ); // "
                    $hits += substr_count( $html, "\u{2018}" ); // '
                    $hits += substr_count( $html, "\u{2019}" ); // '
                    break;

                case 'opener_check':
                    /* Only scan the FIRST paragraph + title area */
                    $opener = '';
                    if ( preg_match( '/<p[^>]*>(.*?)<\/p>/si', $html, $pm ) ) {
                        $opener = strtolower( strip_tags( $pm[1] ) );
                    }
                    if ( empty( $opener ) ) {
                        /* No <p> tag — take first 300 chars */
                        $opener = strtolower( substr( strip_tags( $html ), 0, 300 ) );
                    }
                    if ( ! empty( $def['phrases'] ) ) {
                        foreach ( $def['phrases'] as $phrase ) {
                            if ( strpos( $opener, $phrase ) !== false ) $hits++;
                        }
                    }
                    break;
            }
        }

        return $hits;
    }

    /* ══════════════════════════════════════════════════════════════
     *  NEW — CTA PROTECTION HELPERS
     * ══════════════════════════════════════════════════════════════ */

    /**
     * Strip the CTA paragraph from plain text before pattern scanning.
     * Prevents CTA wording (e.g. "contact", "experts") from being
     * incorrectly flagged as promotional language.
     *
     * @since 2.18.0
     */
    private static function strip_cta_from_text( $text ) {
        $markers = apply_filters( 'dba_luxstrat_kaizen_cta_markers', self::$cta_markers );
        $sentences = preg_split( '/(?<=[.!?])\s+/', $text );
        $clean = array();
        foreach ( $sentences as $s ) {
            $is_cta = false;
            foreach ( $markers as $m ) {
                if ( stripos( $s, $m ) !== false ) { $is_cta = true; break; }
            }
            if ( ! $is_cta ) $clean[] = $s;
        }
        return implode( ' ', $clean );
    }

    /**
     * Strip CTA paragraph(s) from HTML before pattern scanning.
     *
     * @since 2.18.0
     */
    private static function strip_cta_from_html( $html ) {
        $markers = apply_filters( 'dba_luxstrat_kaizen_cta_markers', self::$cta_markers );
        /* Remove <p> blocks containing CTA markers */
        return preg_replace_callback( '/<p[^>]*>.*?<\/p>/si', function( $match ) use ( $markers ) {
            $lower = strtolower( $match[0] );
            foreach ( $markers as $m ) {
                if ( stripos( $lower, $m ) !== false ) return '';
            }
            return $match[0];
        }, $html );
    }

    /* ══════════════════════════════════════════════════════════════
     *  NEW — UTILITY HELPERS
     * ══════════════════════════════════════════════════════════════ */

    /**
     * Detect title case: returns true if ≥ 3 words are capitalised
     * beyond the first word and known proper nouns.
     *
     * @since 2.18.0
     */
    private static function is_title_case( $heading ) {
        /* Common words that are legitimately capitalised (proper nouns, acronyms) */
        static $allowed = array(
            'luxembourg','ucits','aifmd','cssf','eu','eea','sicar','sif',
            'spf','soparfi','sarl','sa','sca','scs','scsp','raif','eltif',
            'gdpr','aml','crs','fatca','oecd','vat','damalion',
        );
        $words = preg_split( '/\s+/', trim( $heading ) );
        if ( count( $words ) < 3 ) return false;
        $caps = 0;
        foreach ( $words as $i => $w ) {
            if ( $i === 0 ) continue; // first word is always capitalised
            if ( in_array( strtolower( $w ), $allowed, true ) ) continue;
            if ( strtoupper( $w ) === $w && strlen( $w ) <= 5 ) continue; // acronym
            if ( preg_match( '/^[A-Z]/', $w ) ) $caps++;
        }
        return $caps >= 2;
    }

    /**
     * Scan a SINGLE post's content and return per-category hit counts.
     * Useful for real-time quality scoring in the editor or pre-publish hook.
     *
     * @param  int   $post_id
     * @return array Keyed by category => {id, label, severity, hits, top_phrases, prompt}
     * @since  2.18.0
     */
    public static function scan_single_post( $post_id, $lang = '' ) {
        $html = get_post_field( 'post_content', $post_id );
        if ( empty( $html ) ) return array();
        $text = strtolower( strip_tags( $html ) );

        $text_no_cta = self::strip_cta_from_text( $text );
        $html_no_cta = self::strip_cta_from_html( $html );

        $registry = apply_filters( 'dba_luxstrat_kaizen_patterns', self::$pattern_registry );
        $report   = array();

        foreach ( $registry as $cat => $def ) {
            $hits = self::detect_single_pattern( $cat, $def, $text_no_cta, $html_no_cta );
            if ( $hits > 0 ) {
                $phrase_hits = array();
                if ( ! empty( $def['phrases'] ) ) {
                    foreach ( $def['phrases'] as $ph ) {
                        $c = substr_count( $text_no_cta, $ph );
                        if ( $c > 0 ) $phrase_hits[ $ph ] = $c;
                    }
                }
                arsort( $phrase_hits );
                $report[ $cat ] = array(
                    'id'          => $def['id'],
                    'label'       => $def['label'],
                    'severity'    => $def['severity'],
                    'hits'        => $hits,
                    'top_phrases' => array_keys( array_slice( $phrase_hits, 0, 5, true ) ),
                    'prompt'      => $def['prompt'],
                );
            }
        }

        return $report;
    }

    /**
     * Compute a quality score 0–100 for a single post based on pattern hits.
     * 100 = clean, 0 = heavily flagged. Weight: high=3, medium=2, low=1.
     *
     * @param  int $post_id
     * @return int Score 0–100.
     * @since  2.18.0
     */
    public static function quality_score( $post_id, $lang = '' ) {
        $report = self::scan_single_post( $post_id, $lang );
        if ( empty( $report ) ) return 100;

        $weights  = array( 'high' => 3, 'medium' => 2, 'low' => 1 );
        $demerits = 0;
        foreach ( $report as $info ) {
            $w = $weights[ $info['severity'] ] ?? 1;
            $demerits += $info['hits'] * $w;
        }

        /* Scale: 50 demerit points = score 0 */
        $score = max( 0, 100 - intval( $demerits * 2 ) );
        return $score;
    }

    /**
     * Verify CTA presence in content. Returns true if at least one
     * CTA marker is found. Use in pre-publish validation.
     *
     * @param  string $html Post content.
     * @return bool
     * @since  2.18.0
     */
    public static function has_cta( $html, $lang = '' ) {
        $markers = apply_filters( 'dba_luxstrat_kaizen_cta_markers', self::$cta_markers );
        $lower   = strtolower( strip_tags( $html ) );
        foreach ( $markers as $m ) {
            if ( stripos( $lower, $m ) !== false ) return true;
        }
        return false;
    }

    /**
     * Get the full pattern registry (for admin UI / debugging).
     *
     * @return array
     * @since  2.18.0
     */
    public static function get_pattern_registry() {
        return apply_filters( 'dba_luxstrat_kaizen_patterns', self::$pattern_registry );
    }
}
